segreant 0.1.0 → 0.2.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 (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. package/pricing/models.json +1366 -60
@@ -17,10 +17,12 @@
17
17
  */
18
18
  import { runScript } from './schema.js';
19
19
  import { pricingEvidenceFromRecord } from './rows.js';
20
+ import { formatMoneyAmount, money } from '../economics/money.js';
20
21
  import { requestEconomicEventId } from '../economics/request.js';
21
22
  import { priceCorrectionEvent } from '../economics/corrections.js';
22
23
  import { economicAttributionFromRows, economicAttributionNumber } from '../economics/attribution.js';
23
24
  import { canonicalModelAttribution } from './economicReadModel.js';
25
+ import { evidenceMatcher, modelSpendFromRows, parseScopeEvidence } from './scopeEvidence.js';
24
26
  export function saveReceipt(db, r) {
25
27
  db
26
28
  .prepare(`INSERT INTO receipts (unit, project, ts_epoch_ms, realized, receipt_json)
@@ -90,13 +92,25 @@ export function realizationProjects(db) {
90
92
  }
91
93
  /** Every row priced with a fallback/family-match rate — the reprice candidates. */
92
94
  export function estimatedRequestRows(db) {
93
- return db
95
+ const rows = db
94
96
  .prepare(`SELECT request_id AS requestId, provider, model,
95
97
  input_tokens AS inputTokens, output_tokens AS outputTokens,
96
98
  cache_write_tokens AS cacheWriteTokens, cache_read_tokens AS cacheReadTokens,
97
- cost_usd AS costUsd
98
- FROM requests WHERE estimated = 1`)
99
+ CASE WHEN cache_write_ttl IN ('5m', '1h') THEN cache_write_ttl END AS cacheWriteTtl,
100
+ cost_usd AS costUsd,
101
+ -- An unpriced row with no token counts has no captured usage to
102
+ -- price (a response cut off at the resource limit): repricing it
103
+ -- wrote a confirmed $0 list price over an unknown cost
104
+ -- (independent review, 2026-10-18). It stays in the report as an
105
+ -- untouched estimate; only the write plan skips it (PR #150
106
+ -- review). An unpriced row WITH token counts stays repriceable.
107
+ CASE WHEN cost_basis = 'unpriced'
108
+ AND input_tokens + output_tokens + cache_write_tokens + cache_read_tokens = 0
109
+ THEN 0 ELSE 1 END AS usageCaptured
110
+ FROM requests
111
+ WHERE estimated = 1 AND cost_basis <> 'not_sent'`)
99
112
  .all();
113
+ return rows.map((r) => ({ ...r, usageCaptured: r.usageCaptured === 1 }));
100
114
  }
101
115
  /**
102
116
  * Re-cost estimated rows in one transaction and retain the previous
@@ -113,10 +127,15 @@ export function estimatedRequestRows(db) {
113
127
  * left marked stale for disclosure instead of being recomputed on a guess.
114
128
  */
115
129
  export function applyRepricedCosts(db, deps, updates, appliedAtMs) {
116
- const prior = db.prepare(`SELECT cost_usd AS costUsd, estimated, ts_epoch_ms AS tsEpochMs, project,
117
- cost_basis AS CostBasis, rate_card_sha256 AS RateCardSha256,
118
- rate_card_source_kind AS RateCardSourceKind, rate_match_kind AS RateMatchKind,
119
- rate_match_provider AS RateMatchProvider, rate_match_model AS RateMatchModel
130
+ const prior = db.prepare(
131
+ // camelCase, which is what pricingEvidenceFromRecord reads with no prefix.
132
+ // These were aliased CostBasis/RateCardSha256/..., so every reprice event
133
+ // recorded legacy_unknown as the provenance it replaced (independent review,
134
+ // 2026-10-18).
135
+ `SELECT cost_usd AS costUsd, estimated, ts_epoch_ms AS tsEpochMs, project,
136
+ cost_basis AS costBasis, rate_card_sha256 AS rateCardSha256,
137
+ rate_card_source_kind AS rateCardSourceKind, rate_match_kind AS rateMatchKind,
138
+ rate_match_provider AS rateMatchProvider, rate_match_model AS rateMatchModel
120
139
  FROM requests WHERE request_id = ?`);
121
140
  const update = db.prepare(`UPDATE requests
122
141
  SET cost_usd = ?, estimated = 0,
@@ -131,6 +150,9 @@ export function applyRepricedCosts(db, deps, updates, appliedAtMs) {
131
150
  new_rate_card_source_kind, new_rate_match_kind, new_rate_match_provider, new_rate_match_model
132
151
  ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
133
152
  const touched = [];
153
+ // Which requests this transaction actually rewrote, so a caller reports
154
+ // those and not its plan (PR #146 review).
155
+ const writtenRequestIds = [];
134
156
  runScript(db, 'BEGIN');
135
157
  try {
136
158
  for (const u of updates) {
@@ -143,11 +165,22 @@ export function applyRepricedCosts(db, deps, updates, appliedAtMs) {
143
165
  throw new Error(`exact reprice for request ${u.requestId} requires an exact replacement amount`);
144
166
  }
145
167
  const recordedAt = new Date(appliedAtMs).toISOString();
168
+ // A correction keeps its source's basis: the economic ledger never
169
+ // moves an amount from one kind of evidence to another. An estimate
170
+ // repriced at a list rate is recorded as that estimate, corrected to the
171
+ // exact figure; the request row itself says which rate priced it. Without
172
+ // this, every fallback row on an exact ledger refused to reprice
173
+ // (PR #146 review). Re-labelling the other way would claim more
174
+ // certainty than the source event carries.
175
+ const source = exactSource.amount;
176
+ const nextAmount = u.economicAmount.basis === source.basis
177
+ ? u.economicAmount
178
+ : money(formatMoneyAmount(u.economicAmount), u.economicAmount.currency, source.basis);
146
179
  const correction = priceCorrectionEvent({
147
180
  id: `economic:request:${u.requestId}:price-corrected`,
148
181
  source: exactSource,
149
- previousAmount: exactSource.amount,
150
- nextAmount: u.economicAmount,
182
+ previousAmount: source,
183
+ nextAmount,
151
184
  recordedAt,
152
185
  });
153
186
  deps.economicLedger.appendWithinTransaction(correction);
@@ -158,10 +191,11 @@ export function applyRepricedCosts(db, deps, updates, appliedAtMs) {
158
191
  continue;
159
192
  event.run(u.requestId, 'reprice', appliedAtMs, Number(old.costUsd), Boolean(old.estimated) ? 1 : 0, oldPricing.costBasis, oldPricing.rateCardSha256, oldPricing.rateCardSourceKind, oldPricing.rateMatchKind, oldPricing.rateMatchProvider, oldPricing.rateMatchModel, u.costUsd, 0, u.pricing.costBasis, u.pricing.rateCardSha256, u.pricing.rateCardSourceKind, u.pricing.rateMatchKind, u.pricing.rateMatchProvider, u.pricing.rateMatchModel);
160
193
  touched.push({ tsEpochMs: Number(old.tsEpochMs), project: String(old.project) });
194
+ writtenRequestIds.push(u.requestId);
161
195
  }
162
196
  const sync = syncRealizationCosts(db, deps, touched);
163
197
  runScript(db, 'COMMIT');
164
- return sync;
198
+ return { ...sync, writtenRequestIds };
165
199
  }
166
200
  catch (e) {
167
201
  runScript(db, 'ROLLBACK');
@@ -234,7 +268,11 @@ function syncRealizationCosts(db, deps, repriced) {
234
268
  if (scope === 'synthetic_demo')
235
269
  continue;
236
270
  const unitProject = canon(row.project);
237
- const affected = priced.some((p) => p.tsEpochMs >= startMs && p.tsEpochMs < endMs && (scope !== 'project' || p.project === unitProject));
271
+ const evidence = scope === 'project' ? parseScopeEvidence(unit.spendScope) : undefined;
272
+ // A unit scoped by evidence also absorbed linked sessions' and folders' spend
273
+ // under other labels, so any repriced request in its window may touch it.
274
+ const affected = priced.some((p) => p.tsEpochMs >= startMs && p.tsEpochMs < endMs
275
+ && (scope !== 'project' || evidence !== undefined || p.project === unitProject));
238
276
  if (!affected)
239
277
  continue;
240
278
  out.markedStale += 1;
@@ -243,10 +281,36 @@ function syncRealizationCosts(db, deps, repriced) {
243
281
  out.unresolvable += 1;
244
282
  continue;
245
283
  }
284
+ if (evidence !== undefined && deps.requestsInRange === undefined) {
285
+ // The rule that produced these dollars cannot be re-run here: disclose.
286
+ markStale.run(row.commitHash);
287
+ out.unresolvable += 1;
288
+ continue;
289
+ }
246
290
  const scoped = scope === 'project' ? row.project : undefined;
247
- const spend = deps.summary(startMs, endMs, scoped);
248
- const modelSpend = deps.byModel(startMs, endMs, scoped);
249
- const economicRows = deps.economicRequestRows?.(startMs, endMs, scoped);
291
+ let spend;
292
+ let modelSpend;
293
+ let economicRows;
294
+ if (evidence !== undefined) {
295
+ const linked = evidenceMatcher(evidence);
296
+ const rows = deps.requestsInRange(startMs, endMs)
297
+ .filter((r) => canon(r.projectCanonical ?? r.project) === unitProject || linked(r));
298
+ const ids = new Set(rows.map((r) => r.requestId));
299
+ spend = {
300
+ label: row.project,
301
+ costUsd: rows.reduce((s, r) => s + r.costUsd, 0),
302
+ requests: rows.length,
303
+ inputTokens: rows.reduce((s, r) => s + r.inputTokens, 0),
304
+ outputTokens: rows.reduce((s, r) => s + r.outputTokens, 0),
305
+ };
306
+ modelSpend = modelSpendFromRows(rows);
307
+ economicRows = deps.economicRequestRows?.(startMs, endMs, undefined).filter((r) => ids.has(r.requestId));
308
+ }
309
+ else {
310
+ spend = deps.summary(startMs, endMs, scoped);
311
+ modelSpend = deps.byModel(startMs, endMs, scoped);
312
+ economicRows = deps.economicRequestRows?.(startMs, endMs, scoped);
313
+ }
250
314
  const economic = economicRows === undefined ? undefined : economicAttributionFromRows(economicRows);
251
315
  const modelAuthority = economicRows === undefined ? undefined : canonicalModelAttribution(economicRows);
252
316
  const totalLines = Number(unit.linesAdded ?? 0) + Number(unit.linesDeleted ?? 0);
@@ -59,7 +59,8 @@ CREATE TABLE IF NOT EXISTS requests (
59
59
  scope_capture_status TEXT NOT NULL DEFAULT 'legacy_unknown',
60
60
  provider_scope_declaration_id TEXT,
61
61
  attribution_basis TEXT NOT NULL DEFAULT 'legacy_unknown',
62
- capture_coverage TEXT NOT NULL DEFAULT 'legacy_unknown'
62
+ capture_coverage TEXT NOT NULL DEFAULT 'legacy_unknown',
63
+ cache_write_ttl TEXT NOT NULL DEFAULT 'legacy_unknown'
63
64
  );
64
65
 
65
66
  CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(ts_epoch_ms);
@@ -236,10 +237,46 @@ CREATE TABLE IF NOT EXISTS import_file_cursors (
236
237
  mtime_ms INTEGER NOT NULL,
237
238
  truncated_lines INTEGER NOT NULL DEFAULT 0,
238
239
  truncated_rows INTEGER NOT NULL DEFAULT 0,
240
+ reader_version INTEGER NOT NULL DEFAULT 1,
239
241
  at_ms INTEGER NOT NULL,
240
242
  PRIMARY KEY (source, path)
241
243
  );
242
244
 
245
+ -- A git commit an agent session reported making, read from the session's own
246
+ -- log (git prints "[branch sha] subject" when it creates a commit). This is an
247
+ -- observation, not yet a fact about any repository: realization accepts it only
248
+ -- when the sha exists in the repo, the subject matches and the times agree.
249
+ CREATE TABLE IF NOT EXISTS observed_commits (
250
+ source TEXT NOT NULL,
251
+ session_id TEXT NOT NULL,
252
+ short_sha TEXT NOT NULL,
253
+ branch TEXT NOT NULL,
254
+ subject TEXT NOT NULL,
255
+ ts_epoch_ms INTEGER NOT NULL,
256
+ cwd TEXT,
257
+ -- How the line was recognised. NULL rows came from a reader that matched a
258
+ -- commit line anywhere, code fixtures included. The migration drops them, and
259
+ -- the next import re-reads every log under the strict rule.
260
+ extraction TEXT,
261
+ PRIMARY KEY (source, session_id, short_sha)
262
+ );
263
+ CREATE INDEX IF NOT EXISTS idx_observed_commits_sha ON observed_commits(short_sha);
264
+
265
+ -- The vendors' own usage meters, as their tools log them (src/quota/limits.ts):
266
+ -- Codex's rate-limit percentages when they change, Claude's limit messages.
267
+ -- Evidence of what the vendor said, kept apart from Segreant's own count.
268
+ CREATE TABLE IF NOT EXISTS quota_events (
269
+ source TEXT NOT NULL,
270
+ kind TEXT NOT NULL,
271
+ ts_epoch_ms INTEGER NOT NULL,
272
+ used_percent REAL,
273
+ window_minutes INTEGER,
274
+ resets_at_ms INTEGER,
275
+ detail TEXT,
276
+ PRIMARY KEY (source, kind, ts_epoch_ms)
277
+ );
278
+ CREATE INDEX IF NOT EXISTS idx_quota_events_ts ON quota_events(ts_epoch_ms);
279
+
243
280
  -- What retention deleted, so an absence can be told from a deletion.
244
281
  --
245
282
  -- prune used to run one DELETE, print a count, and leave nothing behind. A
@@ -1116,10 +1153,25 @@ function validateAppendOnlyTriggerAuthority(db) {
1116
1153
  }
1117
1154
  /** Idempotent schema migrations for DBs created before a column existed. */
1118
1155
  function migrate(db) {
1156
+ // Commit observations made by the loose reader cannot be re-judged: the line
1157
+ // around each match was not kept. They are dropped once, and IMPORT_READER_
1158
+ // VERSION 4 makes the next import read every log again under the strict rule.
1159
+ const observedCols = db.prepare('PRAGMA table_info(observed_commits)').all();
1160
+ if (observedCols.length > 0 && !observedCols.some((c) => c.name === 'extraction')) {
1161
+ db.prepare('ALTER TABLE observed_commits ADD COLUMN extraction TEXT').run();
1162
+ db.prepare('DELETE FROM observed_commits WHERE extraction IS NULL').run();
1163
+ }
1119
1164
  const cols = db.prepare('PRAGMA table_info(requests)').all();
1120
1165
  if (!cols.some((c) => c.name === 'user')) {
1121
1166
  db.prepare('ALTER TABLE requests ADD COLUMN user TEXT').run();
1122
1167
  }
1168
+ if (!cols.some((c) => c.name === 'cache_write_ttl')) {
1169
+ // The cache-write duration ('5m' or '1h') a request was priced with;
1170
+ // 'not_recorded' when its source did not say. No backfill: an older row
1171
+ // reads 'legacy_unknown', and a reprice says how many rows it priced at the
1172
+ // 5-minute rate for that reason (PR #146 review).
1173
+ db.prepare("ALTER TABLE requests ADD COLUMN cache_write_ttl TEXT NOT NULL DEFAULT 'legacy_unknown'").run();
1174
+ }
1123
1175
  if (!cols.some((c) => c.name === 'source')) {
1124
1176
  db.prepare('ALTER TABLE requests ADD COLUMN source TEXT').run();
1125
1177
  }
@@ -1219,6 +1271,12 @@ function migrate(db) {
1219
1271
  // column exists to prevent. Unknown stays unknown.
1220
1272
  db.prepare("ALTER TABLE openai_cost_observation_runs ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'legacy_unknown'").run();
1221
1273
  }
1274
+ const cursorCols = db.prepare('PRAGMA table_info(import_file_cursors)').all();
1275
+ if (cursorCols.length > 0 && !cursorCols.some((c) => c.name === 'reader_version')) {
1276
+ // Cursors written before readers learned to capture commits: version 1, so
1277
+ // their files are read once more by the current reader.
1278
+ db.prepare('ALTER TABLE import_file_cursors ADD COLUMN reader_version INTEGER NOT NULL DEFAULT 1').run();
1279
+ }
1222
1280
  const signalCols = db.prepare('PRAGMA table_info(gate_signals)').all();
1223
1281
  if (!signalCols.some((c) => c.name === 'evidence_source')) {
1224
1282
  db.prepare("ALTER TABLE gate_signals ADD COLUMN evidence_source TEXT NOT NULL DEFAULT 'manual'").run();
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The evidence a value snapshot's dollars were scoped by beyond its project
3
+ * label: the agent sessions and the folders that verifiably made commits in the
4
+ * repository (see git/repoScope.ts for how that evidence is accepted).
5
+ *
6
+ * A snapshot records the evidence its own window used (`unit.spendScope`), so a
7
+ * later reprice re-sums exactly the same rows by exactly the same rule instead
8
+ * of falling back to the label and quietly dropping the linked spend. Pure: no
9
+ * git, no I/O, so the store layer can apply it.
10
+ */
11
+ import { resolve } from 'node:path';
12
+ /** Normalize a path for prefix comparison (Windows paths compare case-insensitively). */
13
+ export function normPath(p) {
14
+ const r = resolve(p).replace(/[\\/]+/g, '/').replace(/\/$/, '');
15
+ return process.platform === 'win32' ? r.toLowerCase() : r;
16
+ }
17
+ export function pathUnder(path, root) {
18
+ return path === root || path.startsWith(root + '/');
19
+ }
20
+ /** A matcher for rows the evidence covers (the label is checked by the caller). */
21
+ export function evidenceMatcher(ev) {
22
+ const sessions = new Set(ev.sessions);
23
+ const roots = ev.folders.map(normPath);
24
+ return (row) => {
25
+ if (row.sessionId !== null && sessions.has(row.sessionId))
26
+ return true;
27
+ if (row.cwd) {
28
+ const c = normPath(row.cwd);
29
+ if (roots.some((root) => pathUnder(c, root)))
30
+ return true;
31
+ }
32
+ return false;
33
+ };
34
+ }
35
+ /** Read evidence back from a persisted snapshot; anything malformed is absent. */
36
+ export function parseScopeEvidence(value) {
37
+ if (value === null || typeof value !== 'object')
38
+ return undefined;
39
+ const v = value;
40
+ const strings = (x) => Array.isArray(x) && x.every((s) => typeof s === 'string') ? x : null;
41
+ const sessions = strings(v.sessions);
42
+ const folders = strings(v.folders);
43
+ if (sessions === null || folders === null)
44
+ return undefined;
45
+ if (sessions.length === 0 && folders.length === 0)
46
+ return undefined;
47
+ return { sessions, folders };
48
+ }
49
+ /** The `byModel` shape, grouped from already-scoped rows (same order: cost, descending). */
50
+ export function modelSpendFromRows(rows) {
51
+ const groups = new Map();
52
+ for (const r of rows) {
53
+ const key = `${r.provider}\0${r.model}`;
54
+ const g = groups.get(key) ?? {
55
+ provider: r.provider, label: r.model, costUsd: 0, requests: 0, inputTokens: 0, outputTokens: 0,
56
+ cacheReadTokens: 0, cacheWriteTokens: 0,
57
+ };
58
+ g.costUsd += r.costUsd;
59
+ g.requests += 1;
60
+ g.inputTokens += r.inputTokens;
61
+ g.outputTokens += r.outputTokens;
62
+ g.cacheReadTokens += r.cacheReadTokens;
63
+ g.cacheWriteTokens += r.cacheWriteTokens;
64
+ groups.set(key, g);
65
+ }
66
+ return [...groups.values()].sort((a, b) => b.costUsd - a.costUsd);
67
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Map over items with at most `limit` calls in flight, keeping input order.
3
+ *
4
+ * Most of what the value stage does per repository is waiting on a git child
5
+ * process, so a few at once finish far sooner than one after another, and a
6
+ * bound keeps a machine with fifty repositories from spawning fifty gits.
7
+ */
8
+ export async function mapLimit(items, limit, fn) {
9
+ const out = new Array(items.length);
10
+ let next = 0;
11
+ const lane = async () => {
12
+ while (next < items.length) {
13
+ const i = next++;
14
+ out[i] = await fn(items[i], i);
15
+ }
16
+ };
17
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, lane));
18
+ return out;
19
+ }
@@ -0,0 +1,7 @@
1
+ /** Recorded evidence timestamps use zero as an unknown sentinel, never epoch. */
2
+ export function recordedTime(ms, dayOnly = false) {
3
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0 || ms > 8.64e15)
4
+ return 'unknown';
5
+ const iso = new Date(ms).toISOString();
6
+ return dayOnly ? iso.slice(0, 10) : iso;
7
+ }