agentwrangler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * src/query/forecast.ts — BurnForecaster (ADR-107 §D-5, 5-state + OFF).
3
+ *
4
+ * The authoritative model is ADR-107 §D-5 (the Data Model §2 forecast block is
5
+ * STALE). The state machine is pure arithmetic, so it lives in TypeScript rather
6
+ * than SQL — this keeps `now` injectable (SQL `julianday('now')` is not) and
7
+ * makes every state deterministically testable.
8
+ *
9
+ * Token metric (ADR-107 §D-2 as amended by the Data Model §2A cap meter): the
10
+ * windowed sum is CAP-WEIGHTED via the shared `capWeightExprSql` helper —
11
+ * full(input + output + cache_write_5m + cache_write_1h + cache_write_other)
12
+ * + COEFF × cache_read_tokens (COEFF default 0.1, UNVERIFIED)
13
+ * so forecast burn matches what the T0 cap meter attributes. Cache-heavy windows
14
+ * therefore yield LOWER burn than the old full-weight sum. Forecast still does
15
+ * NOT filter provisional (burn = all compute engaged).
16
+ *
17
+ * Window: trailing 1 day (ADR-107 §D-3). elapsed_days = now - window_start where
18
+ * window_start = MAX(now - 1d, earliest turn ts in the trailing window). On
19
+ * install day (< 6h of data) that yields elapsed < 0.25 → COLD_START; once >1d of
20
+ * history exists it saturates at 1d. This anchor choice is documented here because
21
+ * the ADR SQL leaves how window_start yields COLD_START implicit (see M-02).
22
+ *
23
+ * Rate floor: MAX(0.25, elapsed_days) is used ONLY for the rate denominator, so a
24
+ * near-zero elapsed never explodes the rate; the raw elapsed drives the COLD_START
25
+ * test. Eval order (ADR-107 + WP2 spec): OFF → COLD_START → EXCEEDED → NO_BURN →
26
+ * WARNING/OK. COLD_START is evaluated BEFORE EXCEEDED.
27
+ */
28
+ import { capWeightExprSql, resolveCapReadCoeff } from "./cap-weighted.js";
29
+ /** ADR-107 §D-4 default warning threshold (days). */
30
+ export const DEFAULT_WARN_THRESHOLD_DAYS = 2;
31
+ /** ADR-107 §D-3 trailing window (days). */
32
+ export const FORECAST_WINDOW_DAYS = 1;
33
+ /** ADR-107 §D-5 / M-02 elapsed floor for the rate denominator (days). */
34
+ export const ELAPSED_FLOOR_DAYS = 0.25;
35
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
36
+ /** Unix epoch (1970-01-01T00:00:00Z) as a Julian Day number. */
37
+ const UNIX_EPOCH_JD = 2440587.5;
38
+ /** Convert a Date to a Julian Day number (matching SQLite julianday()). */
39
+ export function toJulianDay(d) {
40
+ return d.getTime() / MS_PER_DAY + UNIX_EPOCH_JD;
41
+ }
42
+ /**
43
+ * Pure state machine. Returns the BurnForecast DTO. Never throws.
44
+ * See file header for the eval order and rate-floor rationale.
45
+ */
46
+ export function computeForecast(input) {
47
+ const { limitTokens, tokensUsed, elapsedDays, nowJd, warnThresholdDays } = input;
48
+ // OFF — no limit configured.
49
+ if (limitTokens === null) {
50
+ return {
51
+ state: "OFF",
52
+ limit_tokens: null,
53
+ tokens_used: tokensUsed,
54
+ tokens_per_day: null,
55
+ projected_exhaustion_jd: null,
56
+ warn_threshold_days: warnThresholdDays,
57
+ };
58
+ }
59
+ const ratePerDay = tokensUsed / Math.max(ELAPSED_FLOOR_DAYS, elapsedDays);
60
+ // COLD_START — evaluated BEFORE EXCEEDED (ADR-107 eval order).
61
+ if (elapsedDays < ELAPSED_FLOOR_DAYS) {
62
+ return {
63
+ state: "COLD_START",
64
+ limit_tokens: limitTokens,
65
+ tokens_used: tokensUsed,
66
+ tokens_per_day: null,
67
+ projected_exhaustion_jd: null,
68
+ warn_threshold_days: warnThresholdDays,
69
+ };
70
+ }
71
+ // EXCEEDED — over the limit; ETA is meaningless (would be a past date). C-02.
72
+ if (tokensUsed >= limitTokens) {
73
+ return {
74
+ state: "EXCEEDED",
75
+ limit_tokens: limitTokens,
76
+ tokens_used: tokensUsed,
77
+ tokens_per_day: ratePerDay,
78
+ projected_exhaustion_jd: null,
79
+ warn_threshold_days: warnThresholdDays,
80
+ };
81
+ }
82
+ // NO_BURN — no tokens in the window; rate is zero, ETA undefined.
83
+ if (ratePerDay === 0) {
84
+ return {
85
+ state: "NO_BURN",
86
+ limit_tokens: limitTokens,
87
+ tokens_used: tokensUsed,
88
+ tokens_per_day: null,
89
+ projected_exhaustion_jd: null,
90
+ warn_threshold_days: warnThresholdDays,
91
+ };
92
+ }
93
+ const etaDays = (limitTokens - tokensUsed) / ratePerDay;
94
+ const projected = nowJd + etaDays;
95
+ return {
96
+ state: etaDays <= warnThresholdDays ? "WARNING" : "OK",
97
+ limit_tokens: limitTokens,
98
+ tokens_used: tokensUsed,
99
+ tokens_per_day: ratePerDay,
100
+ projected_exhaustion_jd: projected,
101
+ warn_threshold_days: warnThresholdDays,
102
+ };
103
+ }
104
+ /** Read the `limit_tokens` user_config value; null when unset/blank. */
105
+ export function readLimitTokens(db) {
106
+ const row = db.prepare("SELECT value FROM user_config WHERE key = 'limit_tokens'").get();
107
+ if (row === undefined || row.value === null || row.value === "")
108
+ return null;
109
+ const n = Number(row.value);
110
+ return Number.isFinite(n) ? n : null;
111
+ }
112
+ /**
113
+ * Read the `limit_provenance` user_config value; null when unset.
114
+ * Written by calibrateLimit ("calibrated YYYY-MM-DD @ X%; cap-weighted …")
115
+ * and applySettingsUpdate ("manual") — see src/query/settings-store.ts.
116
+ */
117
+ export function readLimitProvenance(db) {
118
+ const row = db.prepare("SELECT value FROM user_config WHERE key = 'limit_provenance'").get();
119
+ return row?.value ?? null;
120
+ }
121
+ /**
122
+ * Read the `limit_resets_at` user_config value; null when unset. Written by
123
+ * calibrateLimit alongside limit_tokens. Lets the card place the elapsed-week
124
+ * budget tick when a live oauth reading (burn-status) is unavailable.
125
+ */
126
+ export function readLimitResetsAt(db) {
127
+ const row = db.prepare("SELECT value FROM user_config WHERE key = 'limit_resets_at'").get();
128
+ return row?.value ?? null;
129
+ }
130
+ /**
131
+ * Compute the burn forecast from the DB: sums CAP-WEIGHTED tokens over the
132
+ * trailing window (cache reads × COEFF via `capWeightExprSql`, coeff resolved
133
+ * from user_config), derives elapsed from the earliest turn in that window,
134
+ * and runs the pure state machine. Provisional turns are deliberately included.
135
+ */
136
+ export function forecastFromDb(db, opts = {}) {
137
+ const now = opts.now ?? new Date();
138
+ const windowDays = opts.windowDays ?? FORECAST_WINDOW_DAYS;
139
+ const warnThresholdDays = opts.warnThresholdDays ?? DEFAULT_WARN_THRESHOLD_DAYS;
140
+ const limitTokens = opts.limitTokens !== undefined ? opts.limitTokens : readLimitTokens(db);
141
+ const windowStartMs = now.getTime() - windowDays * MS_PER_DAY;
142
+ const windowStartIso = new Date(windowStartMs).toISOString();
143
+ const nowIso = now.toISOString();
144
+ const coeff = resolveCapReadCoeff(db);
145
+ const agg = db
146
+ .prepare(`SELECT COALESCE(SUM(${capWeightExprSql("turns", coeff)}), 0) AS tok,
147
+ MIN(ts) AS first_ts
148
+ FROM turns
149
+ WHERE ts >= ? AND ts < ?`)
150
+ .get(windowStartIso, nowIso);
151
+ // Anchor elapsed at the earliest turn in the window (bounded below by the
152
+ // window start) so install-day data (< 6h) surfaces as COLD_START.
153
+ const anchorMs = agg.first_ts !== null
154
+ ? Math.max(windowStartMs, new Date(agg.first_ts).getTime())
155
+ : windowStartMs;
156
+ const elapsedDays = (now.getTime() - anchorMs) / MS_PER_DAY;
157
+ // Legacy-scale detection (review P1): commit beea3d2 switched the burn meter
158
+ // from full-weight token sums to cap-weighted (~10× lower for cache-heavy
159
+ // users). A limit_tokens value calibrated under the OLD meter is still stored
160
+ // in user_config and is now compared against cap-weighted burn, so states can
161
+ // silently flip WARNING/EXCEEDED → OK. Calibration provenance written AFTER
162
+ // beea3d2 contains the marker "cap-weighted"; older provenance does not.
163
+ //
164
+ // Decision: ANY stored provenance lacking the marker — including "manual"
165
+ // (applySettingsUpdate) or a missing row — is treated as legacy-scale, because
166
+ // a manual limit that predates the meter change is INDISTINGUISHABLE from a
167
+ // fresh one. This is deliberately conservative: a false positive merely shows
168
+ // an extra honest "re-run Calibrate" nudge, while a false negative would let
169
+ // the forecast silently mis-state WARNING/EXCEEDED. The number itself is
170
+ // never rescaled (that would fabricate data).
171
+ const limitScaleLegacy = limitTokens !== null && !(readLimitProvenance(db) ?? "").includes("cap-weighted");
172
+ const limitScaleNote = limitScaleLegacy
173
+ ? "limit was calibrated under the previous full-weight meter; re-run Calibrate"
174
+ : null;
175
+ return {
176
+ ...computeForecast({
177
+ limitTokens,
178
+ tokensUsed: agg.tok,
179
+ elapsedDays,
180
+ nowJd: toJulianDay(now),
181
+ warnThresholdDays,
182
+ }),
183
+ cap_weighted: true,
184
+ cap_read_coeff: coeff,
185
+ token_metric: "cap-weighted tokens (input+output+cache-writes full weight; cache reads × COEFF — COEFF unverified)",
186
+ limit_scale_legacy: limitScaleLegacy,
187
+ limit_scale_note: limitScaleNote,
188
+ limit_confidence: (readLimitProvenance(db) ?? "").includes("LOW CONFIDENCE") ? "low" : null,
189
+ limit_resets_at: readLimitResetsAt(db),
190
+ };
191
+ }
@@ -0,0 +1,441 @@
1
+ /**
2
+ * src/query/settings-store.ts — Data access for getSettings/updateSettings (WP4).
3
+ *
4
+ * Provides:
5
+ * - Health context: setHealthInstance / clearHealthInstance
6
+ * - getSettingsData(db) — assembles the full Settings payload
7
+ * - applySettingsUpdate(db, update) — validates, persists, returns updated Settings or Error
8
+ * - validateScanRoots(roots) — path guard (used by updateSettings + UI)
9
+ *
10
+ * No SQL in the UI; all reads/writes go through this module.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { loadConfig } from "../daemon/config.js";
15
+ import { newHealthCounters } from "../ingest/types.js";
16
+ import { fetchOAuthUsage } from "../oauth/usage.js";
17
+ import { capWeightExprSql, resolveCapReadCoeff } from "./cap-weighted.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Health context — wired by the daemon; zero-filled when not injected
20
+ // ---------------------------------------------------------------------------
21
+ let _health = null;
22
+ /** Inject the running Health instance (daemon boot / tests). */
23
+ export function setHealthInstance(h) {
24
+ _health = h;
25
+ }
26
+ /** Clear the injected Health instance (tests, between cases). */
27
+ export function clearHealthInstance() {
28
+ _health = null;
29
+ }
30
+ // Runtime-reset hook — wired by the daemon so resetDatabase can also clear the
31
+ // running Ingestor's in-memory caches (offsets/correlation). Zero-op when unset.
32
+ let _runtimeReset = null;
33
+ /** Inject a callback that clears live ingestor runtime state on DB reset (daemon boot / tests). */
34
+ export function setRuntimeResetHook(fn) {
35
+ _runtimeReset = fn;
36
+ }
37
+ /** Clear the injected runtime-reset hook (tests, between cases). */
38
+ export function clearRuntimeResetHook() {
39
+ _runtimeReset = null;
40
+ }
41
+ function getHealthCounters() {
42
+ return _health?.snapshot() ?? newHealthCounters();
43
+ }
44
+ function toParserHealth(c) {
45
+ return {
46
+ files_seen: c.filesSeen,
47
+ files_parsed: c.filesParsed,
48
+ lines_quarantined: c.linesQuarantined,
49
+ synthetic_excluded: c.syntheticExcluded,
50
+ duplicate_drops: c.duplicateDrops,
51
+ parser_version_mix: c.parserVersionMix,
52
+ };
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // user_config key helpers
56
+ // ---------------------------------------------------------------------------
57
+ function configGet(db, key) {
58
+ const row = db.prepare("SELECT value FROM user_config WHERE key = ?").get(key);
59
+ return row?.value ?? null;
60
+ }
61
+ function configSet(db, key, value) {
62
+ db.prepare(`INSERT INTO user_config (key, value, updated_at) VALUES (?, ?, ?)
63
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`).run(key, value, new Date().toISOString());
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Read
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Explain why a still-unmapped workspace has no GitHub canonical, from DB + a
70
+ * cheap fs check (no subprocess). The auto-mapper (ingestor discovery tick)
71
+ * writes repo_path from the transcript cwd and derives the canonical from the
72
+ * git remote; a row stays unmapped only when one of those inputs is absent.
73
+ */
74
+ function deriveMappingReason(repoPath, discoveredCwd) {
75
+ const localPath = repoPath ?? discoveredCwd;
76
+ if (localPath === null)
77
+ return "No working directory recorded in transcripts yet.";
78
+ if (!fs.existsSync(localPath))
79
+ return "Working directory no longer exists locally.";
80
+ return "No GitHub remote detected for this checkout.";
81
+ }
82
+ function readWorkspaceMappings(db) {
83
+ const rows = db
84
+ .prepare("SELECT workspace_id, project_slug, repo_path, repo_owner, repo_name, discovered_cwd FROM workspaces")
85
+ .all();
86
+ return rows.map((r) => {
87
+ const isTransient = r.repo_owner === null && r.repo_name === null;
88
+ return {
89
+ workspace_id: r.workspace_id,
90
+ project_slug: r.project_slug,
91
+ repo_path: r.repo_path,
92
+ repo_canonical: r.repo_owner !== null && r.repo_name !== null ? `${r.repo_owner}/${r.repo_name}` : null,
93
+ is_transient: isTransient,
94
+ ...(isTransient
95
+ ? { mapping_reason: deriveMappingReason(r.repo_path, r.discovered_cwd) }
96
+ : {}),
97
+ };
98
+ });
99
+ }
100
+ function readQuarantineRows(db) {
101
+ return db
102
+ .prepare(`SELECT file_path, line_no, error_class, seen_at
103
+ FROM ingest_quarantine
104
+ ORDER BY seen_at DESC, q_id DESC
105
+ LIMIT 100`)
106
+ .all();
107
+ }
108
+ /**
109
+ * Assemble the full Settings payload from the DB and daemon config.
110
+ * Parser health counters come from the injected Health instance (zero-filled when absent).
111
+ */
112
+ export function getSettingsData(db) {
113
+ const cfg = loadConfig();
114
+ // Overlay DB-persisted values over daemon defaults
115
+ const scanRootsRaw = configGet(db, "scan_roots");
116
+ let scanRoots;
117
+ if (scanRootsRaw !== null) {
118
+ try {
119
+ const parsed = JSON.parse(scanRootsRaw);
120
+ // Degrade a corrupt/wrong-typed value (e.g. hand-edited DB) to daemon
121
+ // defaults rather than throwing — a getSettings/reset return-read must
122
+ // never hard-fail — and never surface a non-array as scan_roots.
123
+ scanRoots = Array.isArray(parsed) ? parsed : cfg.scanRoots;
124
+ }
125
+ catch {
126
+ scanRoots = cfg.scanRoots;
127
+ }
128
+ }
129
+ else {
130
+ scanRoots = cfg.scanRoots;
131
+ }
132
+ const windowRaw = configGet(db, "activity_window_secs");
133
+ const activityWindowSecs = windowRaw !== null ? Number(windowRaw) : cfg.activityWindowSecs;
134
+ const limitRaw = configGet(db, "limit_tokens");
135
+ const limitTokens = limitRaw !== null ? Number(limitRaw) : null;
136
+ // Provenance (ADR-111): "calibrated {date} @ {util}%" or "manual".
137
+ // Only meaningful when limit_tokens is set; null when limit is unset.
138
+ const limitProvenanceRaw = configGet(db, "limit_provenance");
139
+ const limitProvenance = limitTokens !== null ? (limitProvenanceRaw ?? "manual") : null;
140
+ // When the weekly window resets (stored at calibration time; null if never calibrated).
141
+ const limitResetsAt = configGet(db, "limit_resets_at");
142
+ const lastResetAt = configGet(db, "last_reset_at");
143
+ // R12 — bytes→token calibration fields
144
+ const bptEnabled = configGet(db, "bytes_per_token_calibration_enabled") === "true";
145
+ const bptRaw = configGet(db, "bytes_per_token");
146
+ const bptMeasuredAt = configGet(db, "bytes_per_token_measured_at");
147
+ const bptProvenance = configGet(db, "bytes_per_token_provenance");
148
+ const bpt = bptRaw !== null && Number.isFinite(Number(bptRaw)) ? Number(bptRaw) : null;
149
+ return {
150
+ db_path: cfg.dbPath,
151
+ scan_roots: scanRoots,
152
+ port: cfg.port,
153
+ activity_window_secs: activityWindowSecs,
154
+ limit_tokens: limitTokens,
155
+ limit_provenance: limitProvenance,
156
+ limit_resets_at: limitResetsAt,
157
+ workspace_mappings: readWorkspaceMappings(db),
158
+ parser_health: toParserHealth(getHealthCounters()),
159
+ quarantine_rows: readQuarantineRows(db),
160
+ last_reset_at: lastResetAt,
161
+ bytes_per_token_calibration_enabled: bptEnabled,
162
+ bytes_per_token: bpt,
163
+ bytes_per_token_provenance: bptProvenance,
164
+ bytes_per_token_measured_at: bptMeasuredAt,
165
+ };
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Validate
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Parse an "owner/repo" canonical into its parts, or null when malformed.
172
+ * Valid = exactly one "/" with a non-empty owner and non-empty repo.
173
+ */
174
+ function parseRepoCanonical(canonical) {
175
+ const slash = canonical.indexOf("/");
176
+ if (slash <= 0)
177
+ return null; // no slash, or leading slash (empty owner)
178
+ const owner = canonical.slice(0, slash);
179
+ const name = canonical.slice(slash + 1);
180
+ if (name.length === 0 || name.includes("/"))
181
+ return null; // empty or extra slash
182
+ return { owner, name };
183
+ }
184
+ /**
185
+ * Check that every root is an absolute path that exists and is a directory.
186
+ * Returns an error string on the first failure, null on success.
187
+ */
188
+ export function validateScanRoots(roots) {
189
+ for (const root of roots) {
190
+ if (!path.isAbsolute(root)) {
191
+ return `Scan root "${root}" is not an absolute path.`;
192
+ }
193
+ let stat;
194
+ try {
195
+ stat = fs.statSync(root);
196
+ }
197
+ catch {
198
+ return `Scan root "${root}" does not exist.`;
199
+ }
200
+ if (!stat.isDirectory()) {
201
+ return `Scan root "${root}" is not a directory.`;
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Write
208
+ // ---------------------------------------------------------------------------
209
+ /**
210
+ * Apply a partial settings update and persist to the DB.
211
+ *
212
+ * Returns the updated Settings on success, or throws an Error on validation failure.
213
+ * Persistence is in a single transaction — either all fields update or none do.
214
+ *
215
+ * @throws {Error} When scan_roots contains an invalid path.
216
+ */
217
+ export function applySettingsUpdate(db, update) {
218
+ // Validate scan roots before writing anything
219
+ if (update.scan_roots !== undefined) {
220
+ const err = validateScanRoots(update.scan_roots);
221
+ if (err !== null) {
222
+ throw new Error(err);
223
+ }
224
+ }
225
+ // Validate any non-empty repo_canonical shape before writing anything
226
+ if (update.workspace_mappings !== undefined) {
227
+ for (const m of update.workspace_mappings) {
228
+ if (m.repo_canonical !== null &&
229
+ m.repo_canonical !== undefined &&
230
+ m.repo_canonical !== "" &&
231
+ parseRepoCanonical(m.repo_canonical) === null) {
232
+ throw new Error(`Canonical "${m.repo_canonical}" must be in "owner/repo" form.`);
233
+ }
234
+ }
235
+ }
236
+ db.transaction(() => {
237
+ if (update.limit_tokens !== undefined) {
238
+ configSet(db, "limit_tokens", update.limit_tokens !== null ? String(update.limit_tokens) : null);
239
+ // Track provenance: manual entry clears any prior calibration label (ADR-111).
240
+ if (update.limit_tokens !== null) {
241
+ configSet(db, "limit_provenance", "manual");
242
+ }
243
+ else {
244
+ configSet(db, "limit_provenance", null);
245
+ configSet(db, "limit_resets_at", null);
246
+ }
247
+ }
248
+ if (update.scan_roots !== undefined) {
249
+ configSet(db, "scan_roots", JSON.stringify(update.scan_roots));
250
+ }
251
+ if (update.activity_window_secs !== undefined) {
252
+ configSet(db, "activity_window_secs", String(update.activity_window_secs));
253
+ }
254
+ // R12 — opt-in calibration toggle
255
+ if (update.bytes_per_token_calibration_enabled !== undefined) {
256
+ configSet(db, "bytes_per_token_calibration_enabled", update.bytes_per_token_calibration_enabled ? "true" : null);
257
+ }
258
+ if (update.workspace_mappings !== undefined) {
259
+ const stmt = db.prepare(`UPDATE workspaces
260
+ SET repo_path = ?, repo_owner = ?, repo_name = ?
261
+ WHERE workspace_id = ?`);
262
+ for (const m of update.workspace_mappings) {
263
+ let owner = null;
264
+ let name = null;
265
+ if (m.repo_canonical !== null &&
266
+ m.repo_canonical !== undefined &&
267
+ m.repo_canonical !== "") {
268
+ const parsed = parseRepoCanonical(m.repo_canonical);
269
+ // Shape already validated above; parsed is non-null here.
270
+ if (parsed !== null) {
271
+ owner = parsed.owner;
272
+ name = parsed.name;
273
+ }
274
+ }
275
+ const info = stmt.run(m.repo_path !== undefined ? m.repo_path : null, owner, name, m.workspace_id);
276
+ if (info.changes === 0) {
277
+ throw new Error(`Unknown workspace "${m.workspace_id}" — nothing was saved.`);
278
+ }
279
+ }
280
+ }
281
+ })();
282
+ return getSettingsData(db);
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // Calibration (ADR-111)
286
+ // ---------------------------------------------------------------------------
287
+ /** Seven-day nominal window duration (ms) for deriving window_start from resets_at. */
288
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
289
+ /**
290
+ * Auto-calibrate `:limit_tokens` from the oauth/usage endpoint (ADR-111).
291
+ *
292
+ * Formula: limit_tokens ≈ tokens_in_window / seven_day.utilization
293
+ * where tokens_in_window is the CAP-WEIGHTED token metric (Data Model §2A cap
294
+ * meter, reusing `capWeightExprSql`) over the current weekly window — cache
295
+ * reads × COEFF (default 0.1, UNVERIFIED), everything else full weight. All
296
+ * turns including provisional are counted (burn = all compute engaged). The
297
+ * derived limit is therefore on the same cap-weighted scale as the forecast's
298
+ * tokens_used, keeping the two comparable.
299
+ *
300
+ * Guards (mandatory per ADR-111):
301
+ * - utilization < 0.02 → refuse; < 0.10 proceeds with low confidence.
302
+ * - any non-200 from oauth/usage → return ok:false, never throw or block.
303
+ *
304
+ * The `reader` parameter is injectable for tests; defaults to fetchOAuthUsage().
305
+ */
306
+ export async function calibrateLimit(db, reader = fetchOAuthUsage) {
307
+ // 1. Fetch utilization
308
+ let usage;
309
+ try {
310
+ usage = await reader();
311
+ }
312
+ catch (error) {
313
+ return {
314
+ ok: false,
315
+ reason: `oauth/usage reader failed: ${error instanceof Error ? error.message : String(error)}`,
316
+ };
317
+ }
318
+ if (!usage.ok) {
319
+ return { ok: false, reason: usage.reason };
320
+ }
321
+ const sevenDay = usage.data?.seven_day;
322
+ if (sevenDay === undefined || sevenDay === null) {
323
+ return { ok: false, reason: "oauth/usage did not return a seven_day period." };
324
+ }
325
+ const { utilization, resets_at: resetsAt } = sevenDay;
326
+ if (!Number.isFinite(utilization) || utilization < 0 || utilization > 1) {
327
+ return { ok: false, reason: "oauth/usage returned an invalid seven_day utilization." };
328
+ }
329
+ // 2. Guard: refuse only when utilization makes the denominator too noisy.
330
+ if (utilization < 0.02) {
331
+ const pct = (utilization * 100).toFixed(1);
332
+ return {
333
+ ok: false,
334
+ reason: `Utilization is only ${pct}% — use Claude Code for a while to build up a reliable reading, then re-calibrate.`,
335
+ };
336
+ }
337
+ const lowConfidence = utilization < 0.1;
338
+ // 3. Window: resets_at minus 7 days (nominal; actual reset cadence may vary).
339
+ if (typeof resetsAt !== "string" || resetsAt.length === 0) {
340
+ return { ok: false, reason: "oauth/usage returned an invalid resets_at timestamp." };
341
+ }
342
+ const resetsAtMs = new Date(resetsAt).getTime();
343
+ if (!Number.isFinite(resetsAtMs)) {
344
+ return { ok: false, reason: "oauth/usage returned an invalid resets_at timestamp." };
345
+ }
346
+ const windowStartIso = new Date(resetsAtMs - SEVEN_DAY_MS).toISOString();
347
+ // 4. Sum CAP-WEIGHTED tokens (Data Model §2A meter: cache reads × COEFF,
348
+ // unverified; input/output/cache-writes full weight) in the window.
349
+ // Half-open interval [window_start, resets_at): the upper bound excludes any
350
+ // turns that landed after the current window reset (e.g. if resets_at is
351
+ // slightly in the past due to clock skew or a just-rolled window).
352
+ const coeff = resolveCapReadCoeff(db);
353
+ const row = db
354
+ .prepare(`SELECT COALESCE(SUM(${capWeightExprSql("turns", coeff)}), 0) AS tok
355
+ FROM turns
356
+ WHERE ts >= ? AND ts < ?`)
357
+ .get(windowStartIso, resetsAt);
358
+ const tokensInWindow = row.tok;
359
+ if (!Number.isFinite(tokensInWindow) || tokensInWindow <= 0) {
360
+ return {
361
+ ok: false,
362
+ reason: "No local token usage exists in the calibration window; record local usage before calibrating.",
363
+ };
364
+ }
365
+ const limitTokens = Math.round(tokensInWindow / utilization);
366
+ const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
367
+ const provenance = `calibrated ${date} @ ${(utilization * 100).toFixed(1)}%; cap-weighted (cache reads ×${coeff} COEFF, unverified)`;
368
+ const calibratedProvenance = `${provenance}${lowConfidence ? " — LOW CONFIDENCE (<10% utilization; re-calibrate after ~10% for a stable number)" : ""}`;
369
+ // 5. Persist atomically.
370
+ db.transaction(() => {
371
+ configSet(db, "limit_tokens", String(limitTokens));
372
+ configSet(db, "limit_provenance", calibratedProvenance);
373
+ configSet(db, "limit_resets_at", resetsAt);
374
+ })();
375
+ return {
376
+ ok: true,
377
+ limit_tokens: limitTokens,
378
+ provenance: calibratedProvenance,
379
+ ...(lowConfidence ? { confidence: "low" } : {}),
380
+ };
381
+ }
382
+ // ---------------------------------------------------------------------------
383
+ // Reset
384
+ // ---------------------------------------------------------------------------
385
+ /**
386
+ * Ingested data tables wiped by resetDatabase, in FK-safe order
387
+ * (dependents before parents). Kept as an exported literal so a drift-guard
388
+ * test can assert it stays in sync with the schema as migrations add tables.
389
+ */
390
+ export const RESET_DATA_TABLES = [
391
+ "reports",
392
+ "apply_jobs",
393
+ "recommendation_effects",
394
+ "recommendations",
395
+ "analysis_runs",
396
+ "session_work_links",
397
+ "observed_outcomes",
398
+ "review_findings",
399
+ "work_item_branch_keys",
400
+ "work_items",
401
+ "tool_event_metadata",
402
+ "tool_events",
403
+ "turns",
404
+ "session_churn",
405
+ "sessions",
406
+ "context_inventory",
407
+ "context_inventory_history",
408
+ "workspaces",
409
+ "ingest_quarantine",
410
+ "ingest_offsets",
411
+ ];
412
+ /**
413
+ * Tables intentionally NOT wiped by resetDatabase:
414
+ * - user_config / schema_migrations: config + schema, preserved by contract.
415
+ * - pricing_snapshots: seeded reference data (Ingestor re-seeds it once at boot
416
+ * and caches snapshot_ids for the process lifetime). Wiping it while the
417
+ * daemon runs would orphan those cached ids and make the next re-ingested
418
+ * turn violate the turns.pricing_snapshot_id FK; it is not ingested session data.
419
+ */
420
+ export const RESET_PRESERVED_TABLES = [
421
+ "user_config",
422
+ "schema_migrations",
423
+ "pricing_snapshots",
424
+ ];
425
+ /**
426
+ * Full data-table wipe (preserves RESET_PRESERVED_TABLES).
427
+ * Executes in a single transaction; sets last_reset_at in user_config.
428
+ * Returns fresh settings after the reset.
429
+ */
430
+ export function resetDatabase(db) {
431
+ db.transaction(() => {
432
+ for (const table of RESET_DATA_TABLES) {
433
+ db.prepare(`DELETE FROM ${table}`).run();
434
+ }
435
+ configSet(db, "last_reset_at", new Date().toISOString());
436
+ })();
437
+ // Coordinate with a live ingestor (if wired): clear its in-memory caches so a
438
+ // reset on a running daemon re-ingests faithfully rather than lossily.
439
+ _runtimeReset?.();
440
+ return getSettingsData(db);
441
+ }