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
@@ -15,9 +15,12 @@
15
15
  */
16
16
  import { execFile } from 'node:child_process';
17
17
  import { promisify } from 'node:util';
18
- import { attributeCommits, isGitRepo, projectName } from '../git/correlate.js';
18
+ import { attributeCommits, commitCountSince, isGitRepo, projectName } from '../git/correlate.js';
19
19
  import { isDemo } from '../config.js';
20
- import { survivingLines, revertScan, prefetchSurvival } from '../git/quality.js';
20
+ import { survivingLines, revertScan, prefetchSurvival, survivalHead } from '../git/quality.js';
21
+ import { repoIdentity, repoSpendScope } from '../git/repoScope.js';
22
+ import { mapLimit } from '../util/pool.js';
23
+ import { modelSpendFromRows } from '../store/scopeEvidence.js';
21
24
  import { revertCompletenessWitness } from '../git/completeness.js';
22
25
  import { acceptanceForCommit } from './proposals.js';
23
26
  import { GATE_LADDER, terminalRealizationBounds, serialRealization, aggregatePolarity, polarityFromVerdict, verdictFromPolarity, evaluateCodingOutcome, } from './gates.js';
@@ -93,6 +96,77 @@ function signalPolarity(signals, kind) {
93
96
  return aggregatePolarity(observations);
94
97
  }
95
98
  export const CLEAN_COMPLETENESS_EVENT_TYPES = CODING_CLEAN_COMPLETENESS_EVENT_TYPES;
99
+ /** `Store.modelPricingBasis`, over rows already in scope (same rule: null cards are not revisions). */
100
+ function pricingLineageFromRows(rows, model, provider) {
101
+ const bases = new Set();
102
+ const cards = new Set();
103
+ for (const r of rows) {
104
+ if (r.model !== model || r.provider !== provider)
105
+ continue;
106
+ if (r.costBasis != null)
107
+ bases.add(r.costBasis);
108
+ if (r.rateCardSha256)
109
+ cards.add(r.rateCardSha256);
110
+ }
111
+ return { costBases: [...bases].sort(), rateCardShas: [...cards].sort() };
112
+ }
113
+ export function keptBucketOf(u) {
114
+ if (u.maturing)
115
+ return 'maturing';
116
+ const survived = u.funnel.results.find((r) => r.gate === 'survived')?.verdict;
117
+ // A fail projected from contradictory evidence is not a refutation: the
118
+ // waste rollup already files it under 'conflicted', and calling it "not
119
+ // kept" here settled what the evidence left open.
120
+ const definiteFail = u.funnel.results.some((r) => r.verdict === 'fail' && r.polarity !== 'conflicted');
121
+ const contested = (u.funnel.conflicts?.length ?? 0) > 0;
122
+ return u.reverted || definiteFail ? 'notKept' : contested ? 'contested' : survived === 'pass' ? 'kept' : 'unknown';
123
+ }
124
+ export function keptSummary(report) {
125
+ const out = {
126
+ kept: { units: 0, costUsd: 0 },
127
+ notKept: { units: 0, costUsd: 0 },
128
+ contested: { units: 0, costUsd: 0 },
129
+ unknown: { units: 0, costUsd: 0 },
130
+ maturing: { units: 0, costUsd: 0, nextVerdictMs: null },
131
+ };
132
+ const DAY_MS = 24 * 60 * 60 * 1000;
133
+ for (const u of report.units) {
134
+ const cost = Number.isFinite(u.attributedCostUsd) ? u.attributedCostUsd : 0;
135
+ if (u.maturing) {
136
+ out.maturing.units += 1;
137
+ out.maturing.costUsd += cost;
138
+ // The unit's own window when it recorded one, as the Statement places it.
139
+ const due = u.tsEpochMs + (u.checkWindowDays ?? report.windowDays) * DAY_MS;
140
+ out.maturing.nextVerdictMs = out.maturing.nextVerdictMs === null ? due : Math.min(out.maturing.nextVerdictMs, due);
141
+ continue;
142
+ }
143
+ const bucket = out[keptBucketOf(u)];
144
+ bucket.units += 1;
145
+ bucket.costUsd += cost;
146
+ }
147
+ return out;
148
+ }
149
+ /** The most commits one period scan measures (the newest first). */
150
+ export const MAX_PERIOD_COMMITS = 1500;
151
+ /** The value period the terminal commands cover by default. */
152
+ export const CLI_PERIOD_DAYS = 90;
153
+ /**
154
+ * The git budget for a terminal command. The 20 s default exists so a
155
+ * dashboard route answers instead of hanging (it once took 416 s); a command
156
+ * someone runs and watches, with a progress line, can spend longer to measure
157
+ * a real period, and what the budget does not reach is still reported unknown.
158
+ */
159
+ export const CLI_GIT_BUDGET_MS = 180_000;
160
+ /**
161
+ * The time `scan` and `discover` give the value pass across ALL repositories
162
+ * together. Per-repository budgets alone stacked: a machine with dozens of
163
+ * repositories (agent worktrees count) spent 14 minutes and more. Largest spend
164
+ * goes first; a repository the stage does not reach is reported as not
165
+ * measured, with the command that measures it.
166
+ */
167
+ export const SCAN_VALUE_BUDGET_MS = 120_000;
168
+ /** Time kept back from the stage deadline for scoring and writing each repository after its git work. */
169
+ export const STAGE_FINISH_RESERVE_MS = 15_000;
96
170
  /**
97
171
  * Build a gate result from its four-valued polarity, deriving the legacy
98
172
  * verdict rather than accepting one. The projection lives in exactly one place
@@ -132,7 +206,10 @@ function cleanCompleteness(project, commitHash, commitTsEpochMs, observedAtMs, w
132
206
  };
133
207
  }
134
208
  export async function computeRealization(store, repoPath, opts = {}) {
135
- const limit = opts.limit ?? 30;
209
+ const limit = opts.limit
210
+ ?? (opts.sinceDays !== undefined
211
+ ? Math.max(1, Math.min(MAX_PERIOD_COMMITS, await commitCountSince(repoPath, opts.sinceDays)))
212
+ : 30);
136
213
  const windowDays = opts.windowDays ?? 14;
137
214
  // ONE budget across every commit's git work, not one per commit.
138
215
  //
@@ -171,11 +248,16 @@ export async function computeRealization(store, repoPath, opts = {}) {
171
248
  // x-segreant-project); otherwise fall back to the project-blind window sum so a
172
249
  // classic 'default'-tagged proxy store is unchanged. This is the bridge that
173
250
  // makes native, no-proxy imported spend produce correct per-project RoI.
174
- const projectScoped = store.hasProjectSpend(project);
251
+ // Extended by evidence: folders and sessions that verifiably made commits in
252
+ // this repository (git/repoScope.ts). Without any such evidence the scope is
253
+ // the label alone, exactly as before.
254
+ const spendScope = opts.spendScope ?? await repoSpendScope(store, repoPath);
255
+ const projectScoped = store.hasProjectSpend(project) || spendScope.extended;
175
256
  const attributions = await attributeCommits(store, repoPath, {
176
257
  limit,
177
258
  persist: opts.persist,
178
- scopeProject: projectScoped ? project : undefined,
259
+ scopeProject: projectScoped && !spendScope.extended ? project : undefined,
260
+ scope: projectScoped && spendScope.extended ? spendScope : undefined,
179
261
  });
180
262
  const scan = await revertScan(repoPath, limit);
181
263
  const reverted = scan.reverted;
@@ -197,11 +279,56 @@ export async function computeRealization(store, repoPath, opts = {}) {
197
279
  // the whole allowance before measuring anything — turning a scheduling
198
280
  // accident into forty unknown gates.
199
281
  gitDeadlineMs = Number.isFinite(gitBudgetMs) ? Date.now() + gitBudgetMs : undefined;
282
+ if (opts.gitDeadlineAtMs !== undefined) {
283
+ gitDeadlineMs = gitDeadlineMs === undefined ? opts.gitDeadlineAtMs : Math.min(gitDeadlineMs, opts.gitDeadlineAtMs);
284
+ }
200
285
  // Warm the git caches for every unit in parallel; the loop below still
201
286
  // measures (and decides measured/unmeasured) one unit at a time.
202
- await prefetchSurvival(repoPath, attributions.map((a) => a.hash), gitDeadlineMs);
287
+ // Blame need not trace history older than the oldest commit measured here: a
288
+ // day before it keeps every measured commit's line count exact (blameAt).
289
+ const blameSinceSec = attributions.length > 0
290
+ ? Math.floor(Math.min(...attributions.map((a) => a.tsEpochMs)) / 1000) - 24 * 60 * 60
291
+ : undefined;
292
+ // A budget that runs out leaves the rest unmeasured, so spend it where the
293
+ // dollars are: matured commits (the only ones survival can judge) by
294
+ // attributed cost, largest first, then the maturing ones. The report keeps
295
+ // the attribution order.
296
+ const position = new Map(attributions.map((a, i) => [a.hash, i]));
297
+ // With an evidence scope, every unit's model attribution reads the SAME
298
+ // scoped rows its dollars came from. Read them once for the span of all
299
+ // windows and slice per unit: two queries per commit, one of them mapping
300
+ // every row of the window (scoped or not) through the economic ledger, took
301
+ // most of a repository's measurement time on a real ledger.
302
+ let scopedAll = [];
303
+ let scopedAllTs = [];
304
+ if (spendScope.extended && attributions.length > 0) {
305
+ const lo = Math.min(...attributions.map((a) => a.windowStartMs));
306
+ const hi = Math.max(...attributions.map((a) => a.windowEndMs));
307
+ scopedAll = store.requestsInRange(lo, hi).filter((r) => spendScope.matches(r));
308
+ scopedAllTs = scopedAll.map((r) => r.tsEpochMs);
309
+ }
310
+ const atOrAfter = (t) => {
311
+ let lo = 0;
312
+ let hi = scopedAllTs.length;
313
+ while (lo < hi) {
314
+ const m = (lo + hi) >> 1;
315
+ if (scopedAllTs[m] < t)
316
+ lo = m + 1;
317
+ else
318
+ hi = m;
319
+ }
320
+ return lo;
321
+ };
322
+ const measureOrder = [...attributions].sort((a, b) => {
323
+ const am = now - a.tsEpochMs < windowMs ? 1 : 0;
324
+ const bm = now - b.tsEpochMs < windowMs ? 1 : 0;
325
+ return am - bm || b.attributedCostUsd - a.attributedCostUsd;
326
+ });
327
+ // One HEAD for the whole pass: every unit below is measured against it.
328
+ const passHead = await survivalHead(repoPath);
329
+ await prefetchSurvival(repoPath, measureOrder.map((a) => a.hash), gitDeadlineMs, 8, blameSinceSec, passHead);
203
330
  const units = [];
204
- for (const a of attributions) {
331
+ for (const a of measureOrder) {
205
332
  const ageDays = (now - a.tsEpochMs) / (24 * 60 * 60 * 1000);
206
333
  const maturing = now - a.tsEpochMs < windowMs;
207
334
  // Checked ONCE per unit and reused, so a unit is measured or unmeasured as a
@@ -211,17 +338,23 @@ export async function computeRealization(store, repoPath, opts = {}) {
211
338
  // were gathered.
212
339
  const scanned = !gitBudgetSpent();
213
340
  const survival = scanned
214
- ? await survivingLines(repoPath, a.hash, gitDeadlineMs)
215
- : { added: 0, surviving: 0, measured: false };
341
+ ? await survivingLines(repoPath, a.hash, gitDeadlineMs, blameSinceSec, passHead)
342
+ : { added: 0, surviving: 0, measured: false, filesWithAdditions: 0 };
216
343
  const { added, surviving } = survival;
217
344
  const survivalRatio = added > 0 ? Math.min(1, surviving / added) : 0;
218
345
  if (!survival.measured)
219
346
  unmeasuredSurvival += 1;
220
347
  const isReverted = reverted.has(a.hash) || reverted.has(a.hash.slice(0, 7));
221
- // proxy gates: proposals captured in this commit's attribution window
222
- const addedByFile = scanned ? await addedLinesByFile(repoPath, a.hash) : new Map();
223
- const committedPaths = [...addedByFile.keys()];
348
+ // proxy gates: proposals captured in this commit's attribution window.
349
+ // The full diff is read only when there is a proposal to compare it with:
350
+ // every use below is gated on one. Reading it for every commit cost one
351
+ // serial `git show` per unit (1,126 of them, about 56 s on Windows, for a
352
+ // ledger with no captured proposals at all).
224
353
  const winProposals = store.proposalsInWindow(project, a.windowStartMs, a.windowEndMs);
354
+ const addedByFile = scanned && winProposals.length > 0
355
+ ? await addedLinesByFile(repoPath, a.hash)
356
+ : new Map();
357
+ const committedPaths = [...addedByFile.keys()];
225
358
  const proposalCaptureCoverage = !scanned || winProposals.length === 0
226
359
  // Without this unit's committed paths there is nothing to match a proposal
227
360
  // against, so capture coverage is unknown for the same reason an empty
@@ -315,7 +448,7 @@ export async function computeRealization(store, repoPath, opts = {}) {
315
448
  // this. Only the line and file counts come from the skipped calls, so the
316
449
  // reason falls back to what the attribution already knew.
317
450
  committed: gate('committed', 'supported', scanned
318
- ? `${added} lines in ${committedPaths.length || a.filesChanged} files`
451
+ ? `${added} lines in ${survival.filesWithAdditions || a.filesChanged} files`
319
452
  : `${a.filesChanged} files; line counts unmeasured (scan budget exhausted)`),
320
453
  tested: gate('tested', signalPolarity(signals, 'tested'), 'CI/test signal'),
321
454
  merged: gate('merged', signalPolarity(signals, 'merged'), 'merge signal'),
@@ -347,10 +480,20 @@ export async function computeRealization(store, repoPath, opts = {}) {
347
480
  // from another project's concurrent traffic. A partial exact window has no
348
481
  // winner; a wholly legacy window retains a display-only compatibility label
349
482
  // with null cost/share so the frontier cannot treat it as priceable evidence.
350
- const modelSpend = store.byModel(a.windowStartMs, a.windowEndMs, projectScoped ? project : undefined);
351
- const economicModelRows = store.economicRequestRowsInRange(a.windowStartMs, a.windowEndMs, {
352
- project: projectScoped ? project : undefined,
353
- });
483
+ // With an evidence-extended scope the model read takes the SAME rows the
484
+ // dollars came from (label, linked sessions, linked folders).
485
+ const scopedRows = spendScope.extended
486
+ ? (a.windowEndMs > a.windowStartMs ? scopedAll.slice(atOrAfter(a.windowStartMs), atOrAfter(a.windowEndMs)) : [])
487
+ : null;
488
+ const unitSpendScope = scopedRows === null ? undefined : spendScope.evidenceFor(scopedRows);
489
+ const modelSpend = scopedRows !== null
490
+ ? modelSpendFromRows(scopedRows)
491
+ : store.byModel(a.windowStartMs, a.windowEndMs, projectScoped ? project : undefined);
492
+ const economicModelRows = scopedRows !== null
493
+ ? store.economicRowsOf(scopedRows)
494
+ : store.economicRequestRowsInRange(a.windowStartMs, a.windowEndMs, {
495
+ project: projectScoped ? project : undefined,
496
+ });
354
497
  const modelAuthority = canonicalModelAttribution(economicModelRows);
355
498
  const dominantProvider = modelAuthority.coverage === 'exact'
356
499
  ? modelAuthority.dominant?.provider ?? null
@@ -378,7 +521,9 @@ export async function computeRealization(store, repoPath, opts = {}) {
378
521
  let dominantModelCostBasis = null;
379
522
  let dominantModelRateCard = null;
380
523
  if (dominantProvider !== null && dominantModel !== null) {
381
- const lineage = store.modelPricingBasis(a.windowStartMs, a.windowEndMs, dominantModel, projectScoped ? project : undefined, dominantProvider);
524
+ const lineage = scopedRows !== null
525
+ ? pricingLineageFromRows(scopedRows, dominantModel, dominantProvider)
526
+ : store.modelPricingBasis(a.windowStartMs, a.windowEndMs, dominantModel, projectScoped ? project : undefined, dominantProvider);
382
527
  dominantModelCostBasis =
383
528
  lineage.costBases.length === 1 ? lineage.costBases[0] : lineage.costBases.length > 1 ? 'mixed' : null;
384
529
  dominantModelRateCard =
@@ -388,6 +533,7 @@ export async function computeRealization(store, repoPath, opts = {}) {
388
533
  ...a,
389
534
  ageDays,
390
535
  maturing,
536
+ checkWindowDays: windowDays,
391
537
  survivalRatio,
392
538
  reverted: isReverted,
393
539
  hadProposal,
@@ -405,10 +551,12 @@ export async function computeRealization(store, repoPath, opts = {}) {
405
551
  cleanCompleteness: completeness,
406
552
  proposalCaptureCoverage,
407
553
  ...(contributionEvidence === undefined ? {} : { contributionEvidence }),
554
+ ...(unitSpendScope === undefined ? {} : { spendScope: unitSpendScope }),
408
555
  });
409
556
  }
410
557
  // Persist the snapshot so this realized-value picture can be served later
411
558
  // without the repo (e.g. to a manager's dashboard). Keyed by commit hash.
559
+ units.sort((x, y) => (position.get(x.hash) ?? 0) - (position.get(y.hash) ?? 0));
412
560
  if (opts.persist) {
413
561
  store.saveRealizationUnits(units.map((u) => ({
414
562
  commitHash: u.hash,
@@ -425,7 +573,7 @@ export async function computeRealization(store, repoPath, opts = {}) {
425
573
  costScope: projectScoped ? 'project' : 'window',
426
574
  })));
427
575
  }
428
- return rollupRealization(units, {
576
+ const report = rollupRealization(units, {
429
577
  generatedAt: now,
430
578
  windowDays,
431
579
  acceptanceThreshold,
@@ -433,6 +581,39 @@ export async function computeRealization(store, repoPath, opts = {}) {
433
581
  projectScoped,
434
582
  survivalUnmeasuredUnits: unmeasuredSurvival,
435
583
  });
584
+ if (spendScope.extended) {
585
+ report.spendScope = {
586
+ linkedSessions: spendScope.linkedSessions.size,
587
+ linkedFolders: [...spendScope.linkedFolders],
588
+ verifiedObservations: spendScope.verifiedObservations,
589
+ };
590
+ }
591
+ if (opts.sinceDays !== undefined && projectScoped) {
592
+ report.periodCoverage = periodCoverage(store.requestsInRange(now - opts.sinceDays * 24 * 60 * 60 * 1000, now + 1).filter((r) => spendScope.matches(r)), units, now - opts.sinceDays * 24 * 60 * 60 * 1000, now);
593
+ }
594
+ return report;
595
+ }
596
+ /** Split the period's in-scope spend by whether, and why not, a commit window covers it. */
597
+ export function periodCoverage(rows, units, periodStartMs, periodEndMs) {
598
+ const windows = units.map((u) => [u.windowStartMs, u.windowEndMs]).sort((a, b) => a[0] - b[0]);
599
+ const oldest = windows.length > 0 ? windows[0][0] : Infinity;
600
+ const newest = windows.length > 0 ? Math.max(...windows.map((w) => w[1])) : -Infinity;
601
+ const out = {
602
+ periodStartMs, periodEndMs, scopedCostUsd: 0, onCommitsUsd: 0,
603
+ beforeOldestUsd: 0, noCommitFollowedUsd: 0, notCommittedYetUsd: 0,
604
+ };
605
+ for (const r of rows) {
606
+ out.scopedCostUsd += r.costUsd;
607
+ if (r.tsEpochMs < oldest)
608
+ out.beforeOldestUsd += r.costUsd;
609
+ else if (r.tsEpochMs >= newest)
610
+ out.notCommittedYetUsd += r.costUsd;
611
+ else if (windows.some(([a, b]) => r.tsEpochMs >= a && r.tsEpochMs < b))
612
+ out.onCommitsUsd += r.costUsd;
613
+ else
614
+ out.noCommitFollowedUsd += r.costUsd;
615
+ }
616
+ return out;
436
617
  }
437
618
  /**
438
619
  * Reconstruct a realization report from persisted snapshots — no git, no repo.
@@ -517,11 +698,23 @@ export function realizationFromStore(store, opts = {}) {
517
698
  * the subject, not whatever repository the process happens to be sitting in.
518
699
  */
519
700
  export async function loadRealization(store, repo, opts = {}) {
701
+ if (opts.preferStored && store.countRealizationUnits() > 0) {
702
+ return { source: 'store', report: realizationFromStore(store, { windowDays: opts.windowDays }) };
703
+ }
704
+ // With no request recorded there is no spend for git work to attribute, and
705
+ // the dashboard's value read spent 17-22 s per load proving that on an empty
706
+ // ledger. The Realized claim and the budget drawer's recommendation waited
707
+ // on it. (Other reads do not queue behind it: settings answered in 6-16 ms
708
+ // while a 25 s value read ran.)
709
+ if (opts.preferStored && store.earliestRequestMs() === null)
710
+ return null;
520
711
  if (!isDemo() && repo && (await isGitRepo(repo))) {
521
712
  return {
522
713
  source: 'git',
523
714
  report: await computeRealization(store, repo, {
524
- limit: opts.limit ?? 40,
715
+ limit: opts.limit ?? (opts.sinceDays !== undefined ? undefined : 40),
716
+ sinceDays: opts.sinceDays,
717
+ gitScanBudgetMs: opts.gitScanBudgetMs,
525
718
  windowDays: opts.windowDays ?? 14,
526
719
  persist: opts.persist ?? false,
527
720
  }),
@@ -657,6 +850,51 @@ export function projectTaskStrata(store, opts = {}) {
657
850
  }
658
851
  return out.sort((a, b) => b.costUsd - a.costUsd);
659
852
  }
853
+ /**
854
+ * Clones, worktrees and copies of one repository (same root commit) are one
855
+ * repository: measured once, on the checkout whose HEAD is newest (the one the
856
+ * others' work was merged into), with the others' spend in its scope through
857
+ * git/repoScope.ts. Measuring each separately counted the same spend once per
858
+ * checkout.
859
+ */
860
+ async function oneCheckoutPerRepository(repos) {
861
+ const groups = new Map();
862
+ const out = [];
863
+ for (const r of repos) {
864
+ const id = await repoIdentity(r.repoPath);
865
+ if (id === null) {
866
+ out.push(r);
867
+ continue;
868
+ }
869
+ const head = await headCommitTimeMs(r.repoPath);
870
+ const list = groups.get(id) ?? [];
871
+ list.push({ ...r, headMs: head });
872
+ groups.set(id, list);
873
+ }
874
+ for (const list of groups.values()) {
875
+ list.sort((a, b) => b.headMs - a.headMs || b.costUsd - a.costUsd);
876
+ const [best, ...rest] = list;
877
+ const { headMs: _h, ...keep } = best;
878
+ out.push({
879
+ ...keep,
880
+ sources: [...new Set(list.flatMap((r) => r.sources))].sort(),
881
+ costUsd: list.reduce((s, r) => s + r.costUsd, 0),
882
+ requests: list.reduce((s, r) => s + r.requests, 0),
883
+ ...(rest.length > 0 ? { otherCheckouts: rest.map((r) => r.repoPath) } : {}),
884
+ });
885
+ }
886
+ return out.sort((a, b) => b.costUsd - a.costUsd);
887
+ }
888
+ async function headCommitTimeMs(repoPath) {
889
+ try {
890
+ const { stdout } = await run('git', ['-C', repoPath, 'log', '-1', '--format=%ct', 'HEAD']);
891
+ const n = Number(stdout.trim());
892
+ return Number.isFinite(n) ? n * 1000 : 0;
893
+ }
894
+ catch {
895
+ return 0;
896
+ }
897
+ }
660
898
  /**
661
899
  * Discover the git repos behind the ledger's projects — the interconnectedness that
662
900
  * makes native per-project RoI possible with NO --repo and NO wiring. For each
@@ -666,13 +904,13 @@ export function projectTaskStrata(store, opts = {}) {
666
904
  * a cwd that isn't a repo (or has been deleted), is simply skipped — never guessed.
667
905
  */
668
906
  export async function discoverProjectRepos(store) {
669
- const out = [];
670
- for (const p of store.projectPaths()) {
671
- if (await isGitRepo(p.cwd)) {
672
- out.push({ project: p.project, repoPath: p.cwd, sources: p.sources, costUsd: p.costUsd, requests: p.requests });
673
- }
674
- }
675
- return out;
907
+ // One git process per folder, several at once: one after another this took
908
+ // 5.7 s on a real ledger before any repository was measured.
909
+ const paths = store.projectPaths();
910
+ const isRepo = await mapLimit(paths, 8, (p) => isGitRepo(p.cwd));
911
+ return paths
912
+ .filter((_, i) => isRepo[i])
913
+ .map((p) => ({ project: p.project, repoPath: p.cwd, sources: p.sources, costUsd: p.costUsd, requests: p.requests }));
676
914
  }
677
915
  /**
678
916
  * Auto-correlate every discovered project repo: run the REAL realization scorer on
@@ -684,7 +922,31 @@ export async function discoverProjectRepos(store) {
684
922
  * e.g. in demo mode, where seeded rows carry no cwd.
685
923
  */
686
924
  export async function realizeDiscoveredProjects(store, opts = {}) {
687
- const repos = await discoverProjectRepos(store);
925
+ // The deadline starts HERE, before discovery and scoping. It used to start
926
+ // after them, so about 19 s of setup on a real ledger ran outside the budget
927
+ // and the stage that promised 120 s took 162 s (H010).
928
+ const stageDeadline = opts.stageBudgetMs === undefined ? undefined : Date.now() + opts.stageBudgetMs;
929
+ const found = await oneCheckoutPerRepository(await discoverProjectRepos(store));
930
+ // With a period and a shared deadline, rank by what each repository really
931
+ // spent in the period (label plus commit evidence: a moved checkout's label
932
+ // alone can hold a sliver of it), and skip one with no priced spend at all.
933
+ // Every scope is built once, several at once, from one read of the commit
934
+ // observations, and handed to the measurement below instead of rebuilt there.
935
+ const periodCost = new Map();
936
+ const scopes = new Map();
937
+ if (opts.stageBudgetMs !== undefined && opts.sinceDays !== undefined) {
938
+ const now = Date.now();
939
+ const rows = store.requestsInRange(now - opts.sinceDays * 24 * 60 * 60 * 1000, now + 1);
940
+ const observations = store.observedCommits();
941
+ const built = await mapLimit(found, 6, (r) => repoSpendScope(store, r.repoPath, { observations }));
942
+ found.forEach((r, i) => {
943
+ const scope = built[i];
944
+ scopes.set(r.repoPath, scope);
945
+ periodCost.set(r.repoPath, rows.reduce((s, row) => (scope.matches(row) ? s + row.costUsd : s), 0));
946
+ });
947
+ }
948
+ const rank = (r) => periodCost.get(r.repoPath) ?? r.costUsd;
949
+ const repos = opts.stageBudgetMs === undefined ? found : [...found].sort((a, b) => rank(b) - rank(a));
688
950
  const results = new Array(repos.length);
689
951
  // Repositories are independent and almost all of their time is spent waiting
690
952
  // on git, so a few run at once. Safe on one DatabaseSync handle because every
@@ -696,13 +958,38 @@ export async function realizeDiscoveredProjects(store, opts = {}) {
696
958
  while (next < repos.length) {
697
959
  const i = next++;
698
960
  const r = repos[i];
961
+ const left = stageDeadline === undefined ? undefined : stageDeadline - Date.now();
962
+ const spent = periodCost.get(r.repoPath);
963
+ if (spent !== undefined && spent <= 0) {
964
+ results[i] = { ...r, units: 0, realizedUnits: 0, measured: false, skipped: 'no-spend', periodCostUsd: 0 };
965
+ done += 1;
966
+ opts.onProgress?.(done, repos.length, r.project);
967
+ continue;
968
+ }
969
+ if (left !== undefined && left <= 0) {
970
+ results[i] = { ...r, units: 0, realizedUnits: 0, measured: false, skipped: 'deadline', ...(spent === undefined ? {} : { periodCostUsd: spent }) };
971
+ done += 1;
972
+ opts.onProgress?.(done, repos.length, r.project);
973
+ continue;
974
+ }
699
975
  const rep = await computeRealization(store, r.repoPath, {
700
- limit: opts.limit ?? 40,
976
+ limit: opts.limit ?? (opts.sinceDays !== undefined ? undefined : 40),
977
+ sinceDays: opts.sinceDays,
978
+ gitScanBudgetMs: left === undefined ? opts.gitScanBudgetMs : Math.min(left, opts.gitScanBudgetMs ?? left),
701
979
  windowDays: opts.windowDays,
702
980
  persist: true,
981
+ ...(scopes.has(r.repoPath) ? { spendScope: scopes.get(r.repoPath) } : {}),
982
+ // Stop git a little before the stage deadline: the scoring and the
983
+ // snapshot write after it still have to fit inside the promise.
984
+ ...(stageDeadline === undefined ? {} : { gitDeadlineAtMs: stageDeadline - STAGE_FINISH_RESERVE_MS }),
703
985
  });
704
986
  const realizedUnits = rep.units.filter((u) => !u.maturing && u.funnel.realized).length;
705
- results[i] = { ...r, units: rep.units.length, realizedUnits };
987
+ const periodCostUsd = rep.periodCoverage?.scopedCostUsd ?? spent;
988
+ results[i] = {
989
+ ...r, units: rep.units.length, realizedUnits, measured: true, kept: keptSummary(rep),
990
+ ...(periodCostUsd === undefined ? {} : { periodCostUsd }),
991
+ ...(rep.periodCoverage === undefined ? {} : { coverage: rep.periodCoverage }),
992
+ };
706
993
  done += 1;
707
994
  opts.onProgress?.(done, repos.length, r.project);
708
995
  }
@@ -752,11 +1039,14 @@ export function rollupRealization(units, opts) {
752
1039
  wasteMap.set(stage, b);
753
1040
  }
754
1041
  const instrumentation = Object.fromEntries(GATE_LADDER.map((g) => [g, 0]));
1042
+ const gatePasses = Object.fromEntries(GATE_LADDER.map((g) => [g, 0]));
755
1043
  const gateConflicts = Object.fromEntries(GATE_LADDER.map((g) => [g, 0]));
756
1044
  for (const u of mature) {
757
1045
  for (const r of u.funnel.results) {
758
1046
  if (r.verdict !== 'unknown')
759
1047
  instrumentation[r.gate] += 1;
1048
+ if (r.verdict === 'pass' && r.polarity !== 'conflicted')
1049
+ gatePasses[r.gate] += 1;
760
1050
  if (r.polarity === 'conflicted')
761
1051
  gateConflicts[r.gate] += 1;
762
1052
  }
@@ -796,6 +1086,7 @@ export function rollupRealization(units, opts) {
796
1086
  realizedSpendShare: totalCostUsd > 0 ? spendOnRealizedUnitsUsd / totalCostUsd : null,
797
1087
  wasteByStage: [...wasteMap.values()].sort((a, b) => b.costUsd - a.costUsd),
798
1088
  instrumentation,
1089
+ gatePasses,
799
1090
  gateConflicts,
800
1091
  realizationBounds: terminalRealizationBounds(mature.map((u) => u.funnel)),
801
1092
  serial: serialRealization(mature.map((u) => u.funnel)),
@@ -42,6 +42,7 @@
42
42
  * 5. RoI, drift, time reclaimed, frontier — all read (1)–(4), never the store
43
43
  * directly, so no surface can re-derive an input its own way.
44
44
  */
45
+ import { capBasisSpend } from '../budget/guard.js';
45
46
  import { isDemo, DEFAULT_CONFIG } from '../config.js';
46
47
  import { demoLiftOptions } from '../demo/seed.js';
47
48
  import { projectName } from '../git/correlate.js';
@@ -76,9 +77,12 @@ const DEFAULT_SPEND_WINDOW_DAYS = 30;
76
77
  export async function valueSpine(store, config, opts) {
77
78
  const windowDays = opts.windowDays ?? 14;
78
79
  const loaded = await loadRealization(store, opts.repo, {
79
- limit: opts.limit ?? 40,
80
+ limit: opts.limit ?? (opts.sinceDays !== undefined ? undefined : 40),
80
81
  windowDays,
81
82
  persist: opts.persist ?? false,
83
+ sinceDays: opts.sinceDays,
84
+ gitScanBudgetMs: opts.gitScanBudgetMs,
85
+ preferStored: opts.preferStored,
82
86
  });
83
87
  if (!loaded)
84
88
  return null;
@@ -267,14 +271,30 @@ export function budgetAdvice(store, config, opts = {}) {
267
271
  const series = store.economicSeries(startMs, endMs, DAY_MS, liveOnly);
268
272
  const realizedSpendShare = opts.realizedSpendShare ?? null;
269
273
  const coverage = exactRows.length === 0 ? 'legacy_unknown' : exact.complete ? 'exact' : 'partial';
274
+ const rec = recommendBudget({
275
+ dailySpends: series.map((s) => s.costUsd),
276
+ realizedSpendShare,
277
+ frontier: opts.frontier ?? [],
278
+ currentDailyCapUsd: config.budget.dailyUsd,
279
+ decisionInputs: budgetCapDecisionInputs(coverage, realizedSpendShare),
280
+ });
281
+ // A user whose spend so far was all imported from agent logs has history,
282
+ // just none the cap governs. "Not enough spend history" would contradict
283
+ // every other page, so name the basis and the two ways forward instead.
284
+ if (liveOnly && rec.status === 'cold_start') {
285
+ // "No requests went through the proxy" is a claim about requests, so it
286
+ // is decided by counting them, on the guard's own projection (review 4, F3).
287
+ const live = capBasisSpend(store, config.budget, startMs, endMs);
288
+ const liveUsd = series.reduce((a, s) => a + s.costUsd, 0);
289
+ const importedUsd = store.economicSeries(startMs, endMs, DAY_MS, false).reduce((a, s) => a + s.costUsd, 0) - liveUsd;
290
+ if (live.requests === 0 && importedUsd >= 0.005) {
291
+ rec.rationale = [
292
+ `No requests went through Segreant's proxy in the last ${days} days. The $${importedUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} at list price in that time was imported from agent logs: it is observed, but a cap only governs proxied requests, so it does not count toward one. Route a tool through the proxy for a few active days, or count imported spend toward the cap with: segreant budget --include-imported on`,
293
+ ];
294
+ }
295
+ }
270
296
  return {
271
- ...recommendBudget({
272
- dailySpends: series.map((s) => s.costUsd),
273
- realizedSpendShare,
274
- frontier: opts.frontier ?? [],
275
- currentDailyCapUsd: config.budget.dailyUsd,
276
- decisionInputs: budgetCapDecisionInputs(coverage, realizedSpendShare),
277
- }),
297
+ ...rec,
278
298
  spendBasis: liveOnly ? 'live_proxy' : 'all_observed',
279
299
  windowDays: days,
280
300
  economic: {
@@ -131,12 +131,14 @@ function events(store, startMs, endMs) {
131
131
  return event;
132
132
  });
133
133
  }
134
- function matched(rows, link) {
135
- if (link.type === 'request')
136
- return rows.filter((row) => row.requestId === link.requestId);
137
- if (link.type === 'session')
138
- return rows.filter((row) => row.sessionId === link.sessionId);
139
- return rows.filter((row) => row.tsEpochMs >= link.fromMs && row.tsEpochMs < link.toMs && row.source === link.tool);
134
+ /**
135
+ * The requests before `endMs` that a link names, read per link rather than by
136
+ * filtering the whole ledger: the report ran on every /api/value load and held
137
+ * every request ever recorded, outcomes or not (540 MiB of heap on a
138
+ * 300,000-request ledger).
139
+ */
140
+ function matched(store, link, endMs) {
141
+ return store.requestsForSpendLink(link, endMs);
140
142
  }
141
143
  /** Preview is read-only; apply appends one signal to the existing ledger. */
142
144
  export function recordReportedOutcome(store, raw, apply = false, enabled = true, nowMs = Date.now()) {
@@ -146,7 +148,7 @@ export function recordReportedOutcome(store, raw, apply = false, enabled = true,
146
148
  const prior = events(store, 0, Number.MAX_SAFE_INTEGER).filter((item) => item.outcomeId === event.outcomeId);
147
149
  if (prior.some((item) => !sameIdentity(item, event)))
148
150
  throw new Error('outcomeId already has a different kind or spend link');
149
- const rows = matched(store.requestsInRange(0, Number.MAX_SAFE_INTEGER), event.link);
151
+ const rows = matched(store, event.link, Number.MAX_SAFE_INTEGER);
150
152
  const preview = {
151
153
  apply,
152
154
  basis: SELF_REPORTED_BASIS,
@@ -180,8 +182,7 @@ export function selfReportedValueReport(store, startMs, endMs, enabled = true) {
180
182
  // A linked session or request may have started before the report window, so
181
183
  // spend is matched over all history up to the window end, exactly as the
182
184
  // record preview matches it. The window selects outcomes, not their cost.
183
- const requestRows = store.requestsInRange(0, endMs);
184
- const claims = selected.map((group) => matched(requestRows, group[0].link));
185
+ const claims = selected.map((group) => matched(store, group[0].link, endMs));
185
186
  const claimedBy = new Map();
186
187
  for (const rows of claims)
187
188
  for (const row of rows)
@@ -263,9 +263,12 @@ selected eligible address is pinned into the one socket request; the OS resolver
263
263
  is not asked to choose a different address afterward.
264
264
  Only a genuinely absent receipt path may establish a genesis predecessor, and a
265
265
  present invalid path is never silently reset. Loaded JSON egress configuration
266
- also accepts only the two declared modes and exact rule field types; ambiguous
267
- mode/enabled values return to local-locked behavior rather than authorizing a
268
- cloud rule. Loaded rules use the same exact semantic validator as CLI-authored
266
+ also accepts only the two declared modes and exact rule field types. A present
267
+ but malformed egress section (ambiguous mode or enabled values, an invalid rule,
268
+ unexpected fields) loads as `invalid`, which refuses every non-loopback target,
269
+ the built-in provider and price-list routes included, until it is repaired; only
270
+ an absent section means local-locked. Falling back to local-locked would now let
271
+ built-in provider traffic through a typo. Loaded rules use the same exact semantic validator as CLI-authored
269
272
  rules: canonical HTTPS origin, non-empty safe path prefix, valid identifiers,
270
273
  and no unexpected fields. A bounded stale-lock refusal is
271
274
  operator-repairable only after confirming no Segreant writer is active; the lock
@@ -444,5 +447,6 @@ genuinely open:
444
447
 
445
448
  - Sees only traffic routed through it (D1).
446
449
  - Cost accuracy depends on the pricing table; unknown models are flagged `estimated` and use a conservative fallback rather than failing.
447
- - Budget blocking is pre-flight on cumulative state; a single in-flight request can still complete above a cap (you can't un-send a request mid-stream).
450
+ - Budget blocking is pre-flight on recorded spend. Every request already admitted when the cap is crossed still completes, so the overshoot is bounded by how many requests are in flight times their cost: a stream cannot be un-sent. Measured 2026-10-17 against a local stub provider: with 50 concurrent requests at $0.00072 each, 42 completed past a $1.66 cap ($0.031 over) and the other 752 were refused with 429. One agent working serially overshoots by at most one request.
451
+ - A cap saved from the CLI, the dashboard or an editor governs the running proxy within a second: the proxy re-reads the budget when `config.json` changes. While the file is invalid, every request is refused (503) without being sent; deleting the file keeps the cap in force.
448
452
  - "Cost reduction %" is a function of baseline waste, not a guarantee the tool makes. Segreant provides visibility and controls; the savings are the user's to realize.