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
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The monthly statement as a spreadsheet.
3
+ *
4
+ * Built from the same payload the Statement page renders, so the file and the
5
+ * screen cannot disagree. The four claims stay four sections: each row names
6
+ * its claim, its basis and its own dates, and there is no total row, because
7
+ * metered usage, provider-billed cost, allocated cost and realized value are
8
+ * different questions and adding them answers none of them.
9
+ */
10
+ import { toCsv } from './csv.js';
11
+ export const STATEMENT_CSV_COLUMNS = [
12
+ 'claim', 'item', 'amountUsd', 'amountMicros', 'requests', 'basis',
13
+ 'periodStartUtc', 'periodEndUtc', 'coversMonth', 'recordedAtUtc', 'runId', 'note',
14
+ ];
15
+ const iso = (ms) => new Date(ms).toISOString();
16
+ const microsUsd = (micros) => {
17
+ const sign = micros < 0 ? '-' : '';
18
+ const abs = Math.abs(micros);
19
+ return `${sign}${Math.trunc(abs / 1_000_000)}.${String(abs % 1_000_000).padStart(6, '0')}`;
20
+ };
21
+ function billedSource(kind) {
22
+ if (kind === 'operator_supplied_export')
23
+ return 'provider figure from an export the operator supplied';
24
+ if (kind === 'provider_api_pull')
25
+ return 'provider figure read from the provider';
26
+ return 'provider figure, source not recorded';
27
+ }
28
+ function billedNote(r) {
29
+ const notes = [];
30
+ if (r.snapshotStability === 'changed_across_observations')
31
+ notes.push('conflicted: the provider reported different figures for the same days');
32
+ else if (r.snapshotStability === null)
33
+ notes.push('snapshot stability not recorded');
34
+ if (r.offPathBound === 'none_local_estimate_exceeds_provider')
35
+ notes.push('local estimate exceeds the provider total, so the remainder bounds nothing');
36
+ else if (r.offPathBound === 'unknown_local_total_truncated_by_retention')
37
+ notes.push('retention deleted local rows, so the remainder bounds nothing');
38
+ else if (r.offPathBound === null)
39
+ notes.push('remainder bound not recorded');
40
+ if (!r.coversWholeMonth)
41
+ notes.push('figures are for this run\'s own period, not prorated to the month');
42
+ if (r.localQuantization && r.localQuantization.daysRounded > 0) {
43
+ notes.push(`local side summed exactly per day; ${r.localQuantization.daysRounded} day total(s) rounded half to even to the microdollar`);
44
+ }
45
+ return notes.join('; ');
46
+ }
47
+ /** One calendar month, four claims, never summed. */
48
+ export function statementToCsv(s) {
49
+ const month = [iso(s.period.startMs), iso(s.period.endMs)];
50
+ const rows = [];
51
+ rows.push(['statement', s.period.label, '', '', '', s.demo ? 'sample data, not measured' : 'local ledger',
52
+ ...month, '', s.generatedAt, '',
53
+ 'Four separate claims. Do not add the sections together. The month runs from local midnight; times are shown in UTC.' + (s.period.complete ? '' : ' Month in progress: figures so far.')]);
54
+ const m = s.metered;
55
+ const exact = m.basis.exactShare === null ? '' : `; ${Math.round(m.basis.exactShare * 100)}% at each model's own list rate`;
56
+ const basis = `local estimate: ${m.basis.headlineLabel.toLowerCase()}${exact}`;
57
+ const retention = m.retention.truncated ? `retention deleted ${m.retention.rowsRemoved} row(s) in this period; covers what survived` : '';
58
+ rows.push(['metered', 'total', m.costUsd.toFixed(6), '', m.requests, basis, ...month, 'yes', '', '', retention]);
59
+ for (const p of m.byProject)
60
+ rows.push(['metered', `project: ${p.label}`, p.costUsd.toFixed(6), '', p.requests, 'local estimate', ...month, 'yes', '', '', '']);
61
+ for (const x of m.byModel)
62
+ rows.push(['metered', `model: ${x.provider ? `${x.provider}/` : ''}${x.label}`, x.costUsd.toFixed(6), '', x.requests, 'local estimate', ...month, 'yes', '', '', '']);
63
+ const b = s.billed;
64
+ if (b.runs.length === 0) {
65
+ rows.push(['billed', 'not established', '', '', '', '', ...month, 'no', '', '',
66
+ b.scanComplete ? 'no comparison with a provider bill covers this month' : 'none among the most recent comparisons; an older one may exist']);
67
+ }
68
+ for (const r of b.runs) {
69
+ const own = [iso(r.periodStartMs), iso(r.periodEndMs), r.coversWholeMonth ? 'yes' : 'no', iso(r.computedAtMs), r.reconciliationRunId];
70
+ const project = r.providerProjectRef ? ` (${r.providerProjectRef})` : '';
71
+ const note = billedNote(r);
72
+ rows.push(['billed', `provider reported${project}`, microsUsd(r.providerReportedMicros), r.providerReportedMicros, '', billedSource(r.providerSourceKind), ...own, note]);
73
+ rows.push(['billed', `measured locally for comparison${project}`, microsUsd(r.localCapturedMicros), r.localCapturedMicros, '', 'local estimate, same scope and days', ...own, note]);
74
+ rows.push(['billed', `not explained${project}`, microsUsd(r.unexplainedVarianceMicros), r.unexplainedVarianceMicros, '', 'provider reported minus measured', ...own, note]);
75
+ }
76
+ const a = s.allocated;
77
+ const names = new Map(a.costCentres.map((c) => [c.costCentreId, c.name]));
78
+ if (a.runs.length === 0) {
79
+ rows.push(['allocated', 'not established', '', '', '', '', ...month, 'no', '', '',
80
+ a.scanComplete ? 'no recorded split covers this month' : 'none among the most recent splits; an older one may exist']);
81
+ }
82
+ for (const r of a.runs) {
83
+ const own = [iso(r.periodStartMs), iso(r.periodEndMs), r.coversWholeMonth ? 'yes' : 'no', iso(r.computedAtMs), r.allocationRunId];
84
+ const note = r.coversWholeMonth ? '' : 'figures are for this run\'s own period, not prorated to the month';
85
+ for (const c of r.byCostCentre) {
86
+ rows.push(['allocated', `cost centre: ${names.get(c.costCentreId) ?? c.costCentreId}`, microsUsd(c.allocatedMicros), c.allocatedMicros, c.requestCount, 'showback split of the local estimate', ...own, note]);
87
+ }
88
+ if (r.unallocatedMicros !== 0)
89
+ rows.push(['allocated', 'not allocated', microsUsd(r.unallocatedMicros), r.unallocatedMicros, '', 'showback split of the local estimate', ...own, note]);
90
+ }
91
+ const kept = (item, c) => {
92
+ // No amount: the realized section never carries a dollar a reader could sum
93
+ // as value. The count is in the requests column; the spend behind it is named
94
+ // in the note as spend.
95
+ rows.push(['realized', item, '', '', c.units, 'pieces of work judged in the month (count in the requests column); not value', ...month, 'yes', s.realized.measuredAt ?? '', '', `list cost behind this work: $${c.costUsd.toFixed(2)} (spend, not value). ${s.realized.reason}`]);
96
+ };
97
+ kept('kept', s.realized.kept);
98
+ kept('not kept', s.realized.notKept);
99
+ kept('results disagree', s.realized.contested);
100
+ kept('no signal', s.realized.unknown);
101
+ kept('too early to tell', s.realized.tooEarly);
102
+ rows.push(['close', s.close.status, '', '', '', 'economic period close', ...month, '', '', s.close.activeFinalizationId ?? '', '']);
103
+ return toCsv([...STATEMENT_CSV_COLUMNS], rows);
104
+ }
@@ -65,6 +65,17 @@ export async function repoToplevel(dir) {
65
65
  return null;
66
66
  }
67
67
  }
68
+ /** Commits reachable from HEAD made in the last `days` days (0 when unreadable). */
69
+ export async function commitCountSince(repoPath, days) {
70
+ try {
71
+ const out = await git(repoPath, ['rev-list', '--count', `--since=${days}.days`, 'HEAD']);
72
+ const n = Number(out.trim());
73
+ return Number.isSafeInteger(n) && n > 0 ? n : 0;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
68
79
  export async function projectName(repoPath) {
69
80
  try {
70
81
  const top = (await git(repoPath, ['rev-parse', '--show-toplevel'])).trim();
@@ -146,29 +157,95 @@ export async function attributeCommits(store, repoPath, opts = {}) {
146
157
  const limit = opts.limit ?? 20;
147
158
  const maxLookbackMs = (opts.maxLookbackHours ?? 8) * 60 * 60 * 1000;
148
159
  const project = await projectName(repoPath);
149
- const commits = await readCommits(repoPath, limit + 1);
160
+ // Newest first BY TIME, not in log order. `git log` follows the graph, and
161
+ // after a rebase, cherry-pick or merge the author times along it are not
162
+ // monotonic; windows built from log neighbours then overlapped and booked the
163
+ // same request to two commits. On one real repository 40 windows overlapped
164
+ // and the Kept answer counted $99 of spend twice. Sorted, the windows tile
165
+ // time and every request lands in at most one of them.
166
+ const commits = (await readCommits(repoPath, limit + 1))
167
+ .map((c, i) => ({ c, i }))
168
+ .sort((a, b) => b.c.tsEpochMs - a.c.tsEpochMs || a.i - b.i)
169
+ .map((x) => x.c);
150
170
  const results = [];
151
- for (let i = 0; i < Math.min(limit, commits.length); i++) {
171
+ const n = Math.min(limit, commits.length);
172
+ const windowOf = (i) => {
152
173
  const commit = commits[i];
153
174
  const prev = commits[i + 1];
154
175
  const naturalStart = prev ? prev.tsEpochMs : commit.tsEpochMs - maxLookbackMs;
155
- const windowStartMs = Math.max(naturalStart, commit.tsEpochMs - maxLookbackMs);
156
- const windowEndMs = commit.tsEpochMs;
176
+ return [Math.max(naturalStart, commit.tsEpochMs - maxLookbackMs), commit.tsEpochMs];
177
+ };
178
+ // With an evidence scope, read the scoped rows ONCE for the whole span and
179
+ // slice each window out of them. Three queries per commit took 10.5 s for
180
+ // 1,018 commits on a real repository; the slices give the same rows, in the
181
+ // same [start, end) bounds and order, because the read is ordered by time.
182
+ let scoped = null;
183
+ let scopedTs = [];
184
+ if (opts.scope && n > 0) {
185
+ let lo = Infinity;
186
+ let hi = -Infinity;
187
+ for (let i = 0; i < n; i++) {
188
+ const [a, b] = windowOf(i);
189
+ if (a < lo)
190
+ lo = a;
191
+ if (b > hi)
192
+ hi = b;
193
+ }
194
+ scoped = store.requestsInRange(lo, hi).filter((r) => opts.scope.matches(r));
195
+ scopedTs = scoped.map((r) => r.tsEpochMs);
196
+ }
197
+ const firstAtOrAfter = (t) => {
198
+ let a = 0;
199
+ let b = scopedTs.length;
200
+ while (a < b) {
201
+ const m = (a + b) >> 1;
202
+ if (scopedTs[m] < t)
203
+ a = m + 1;
204
+ else
205
+ b = m;
206
+ }
207
+ return a;
208
+ };
209
+ // The retention floor is one fact for the whole pass; read it once.
210
+ const floor = store.windowCoverage(Number.POSITIVE_INFINITY);
211
+ for (let i = 0; i < n; i++) {
212
+ const commit = commits[i];
213
+ const [windowStartMs, windowEndMs] = windowOf(i);
157
214
  // Scope the window's spend to this repo's project when the caller asks — so a
158
215
  // commit absorbs only its own project's native/imported spend, not every
159
216
  // project's concurrent traffic. Undefined scope = the project-blind window sum
160
217
  // (proxy default), preserving the original behavior.
161
- const spend = store.summary(windowStartMs, windowEndMs, opts.scopeProject);
162
- const economicRows = store.economicRequestRowsInRange(windowStartMs, windowEndMs, {
163
- project: opts.scopeProject,
164
- });
218
+ let spend;
219
+ let economicRows;
220
+ if (opts.scope) {
221
+ // The repository's label PLUS the folders and sessions its commits prove
222
+ // belong to it (a moved checkout, a worktree, a session started elsewhere).
223
+ const rows = windowEndMs > windowStartMs
224
+ ? scoped.slice(firstAtOrAfter(windowStartMs), firstAtOrAfter(windowEndMs))
225
+ : [];
226
+ spend = {
227
+ costUsd: rows.reduce((s, r) => s + r.costUsd, 0),
228
+ requests: rows.length,
229
+ outputTokens: rows.reduce((s, r) => s + r.outputTokens, 0),
230
+ };
231
+ economicRows = store.economicRowsOf(rows);
232
+ }
233
+ else {
234
+ spend = store.summary(windowStartMs, windowEndMs, opts.scopeProject);
235
+ economicRows = store.economicRequestRowsInRange(windowStartMs, windowEndMs, {
236
+ project: opts.scopeProject,
237
+ });
238
+ }
165
239
  const economic = economicAttributionFromRows(economicRows);
166
240
  const totalLines = commit.linesAdded + commit.linesDeleted;
167
241
  const attributedCostUsd = economicAttributionNumber(economic, spend.costUsd);
168
242
  // Did retention delete rows from inside this window? Strictly before the
169
243
  // boundary, because `prune` removes rows older than it; a null boundary is
170
244
  // "no prune on record" and licenses nothing either way (D-170, D-176).
171
- const coverage = store.windowCoverage(windowStartMs);
245
+ const coverage = {
246
+ truncated: floor.prunedBeforeMs !== null && windowStartMs < floor.prunedBeforeMs,
247
+ prunedBeforeMs: floor.prunedBeforeMs,
248
+ };
172
249
  const costPerHundredLines = totalLines > 0 && !coverage.truncated
173
250
  ? (attributedCostUsd / totalLines) * 100
174
251
  : null;
@@ -16,6 +16,7 @@ import { promisify } from 'node:util';
16
16
  import { attributeCommits } from './correlate.js';
17
17
  import { measurementModel } from '../measurement/model.js';
18
18
  import { scope } from '../epistemic/scope.js';
19
+ import { storedCommitFiles, rememberCommitFiles, reusableBlame, rememberBlame, flushSurvivalStore } from './survivalStore.js';
19
20
  const run = promisify(execFile);
20
21
  /** The construct measured by Git line retention. */
21
22
  export const ARTIFACT_PERSISTENCE_CONSTRUCT = 'artifact_persistence';
@@ -84,6 +85,19 @@ async function git(repoPath, args) {
84
85
  const { stdout } = await run('git', ['-C', repoPath, ...args], { maxBuffer: 64 * 1024 * 1024 });
85
86
  return stdout;
86
87
  }
88
+ /**
89
+ * How many survival reads actually started git, since the process began or the
90
+ * last reset. For tests and diagnostics: a cache that answers shows up here as
91
+ * work not done.
92
+ */
93
+ const gitRuns = { blame: 0, numstat: 0 };
94
+ export function survivalGitRuns() {
95
+ return { ...gitRuns };
96
+ }
97
+ export function resetSurvivalGitRuns() {
98
+ gitRuns.blame = 0;
99
+ gitRuns.numstat = 0;
100
+ }
87
101
  /** A commit's numstat never changes, so it is read once per process. */
88
102
  const commitFilesCache = new Map();
89
103
  /** Files a commit touched, with lines added per file (numstat). */
@@ -93,7 +107,11 @@ function commitFiles(repoPath, hash) {
93
107
  if (pending === undefined) {
94
108
  if (commitFilesCache.size >= BLAME_CACHE_MAX)
95
109
  commitFilesCache.clear();
96
- pending = readCommitFiles(repoPath, hash);
110
+ // A commit's file list is fixed by its hash, so one read in any earlier run stands.
111
+ const stored = storedCommitFiles(repoPath, hash);
112
+ pending = stored !== undefined
113
+ ? Promise.resolve(stored)
114
+ : (gitRuns.numstat += 1, readCommitFiles(repoPath, hash).then((files) => { rememberCommitFiles(repoPath, hash, files); return files; }));
97
115
  // A failed read is not cached: the next caller retries rather than inheriting it.
98
116
  pending.catch(() => commitFilesCache.delete(key));
99
117
  commitFilesCache.set(key, pending);
@@ -101,15 +119,35 @@ function commitFiles(repoPath, hash) {
101
119
  return pending;
102
120
  }
103
121
  async function readCommitFiles(repoPath, hash) {
104
- // --format= suppresses the commit header; --numstat gives "added<TAB>deleted<TAB>path".
105
- const out = await git(repoPath, ['show', '--numstat', '--format=', hash]);
122
+ // --format= suppresses the commit header; -z keeps paths exact (see parseNumstatZ).
123
+ return parseNumstatZ(await git(repoPath, ['show', '--numstat', '-z', '--format=', hash]));
124
+ }
125
+ /**
126
+ * Parse `git show --numstat -z`. Each file is `added<TAB>deleted<TAB>path<NUL>`,
127
+ * except a rename or copy, which is `added<TAB>deleted<TAB><NUL>old<NUL>new<NUL>`.
128
+ *
129
+ * The paths returned are blamed at HEAD, so a rename has to yield its NEW path.
130
+ * Without -z, git prints a renamed file as `src/{old.ts => new.ts}`, which is not
131
+ * a path: blame refused it ("no such path in HEAD"), the file read as deleted,
132
+ * and every line the commit added there counted as not retained. -z also keeps
133
+ * a path containing spaces, tabs, quotes or " => " exactly as it is.
134
+ */
135
+ export function parseNumstatZ(out) {
136
+ const fields = out.split('\0');
106
137
  const files = [];
107
- for (const line of out.split('\n')) {
108
- const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
138
+ for (let i = 0; i < fields.length; i++) {
139
+ const m = /^\n?(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(fields[i]);
109
140
  if (!m)
110
141
  continue;
111
- const added = m[1] === '-' ? 0 : Number(m[1]);
112
- files.push({ path: m[3], added });
142
+ let path = m[3];
143
+ if (path === '') {
144
+ // A rename or copy: the old path, then the new one, as their own fields.
145
+ path = fields[i + 2] ?? '';
146
+ i += 2;
147
+ }
148
+ if (path === '')
149
+ continue;
150
+ files.push({ path, added: m[1] === '-' ? 0 : Number(m[1]) });
113
151
  }
114
152
  return files;
115
153
  }
@@ -141,7 +179,11 @@ export function clearBlameCache() {
141
179
  * caller's own deadline check, so a prefetch can never turn an unmeasured
142
180
  * commit into a measured one.
143
181
  */
144
- export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency = 8) {
182
+ export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency = 8,
183
+ /** The same history bound the measuring pass will use, so the caches meet. */
184
+ sinceSec,
185
+ /** The pass's HEAD (survivalHead); read here when not given. */
186
+ passHead) {
145
187
  const expired = () => deadlineMs !== undefined && Date.now() >= deadlineMs;
146
188
  const pool = async (items, work) => {
147
189
  let next = 0;
@@ -156,7 +198,7 @@ export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency
156
198
  });
157
199
  await Promise.all(lanes);
158
200
  };
159
- const head = await headCommit(repoPath);
201
+ const head = passHead === undefined ? await survivalHead(repoPath) : passHead;
160
202
  if (head === null)
161
203
  return;
162
204
  const paths = new Set();
@@ -165,9 +207,17 @@ export async function prefetchSurvival(repoPath, hashes, deadlineMs, concurrency
165
207
  if (f.added > 0)
166
208
  paths.add(f.path);
167
209
  });
168
- await pool([...paths], (path) => blameAt(repoPath, head, path));
210
+ await pool([...paths], (path) => blameAt(repoPath, head, path, sinceSec));
211
+ flushSurvivalStore();
169
212
  }
170
- async function headCommit(repoPath) {
213
+ /**
214
+ * The commit HEAD names now, or null for an unborn branch. A measuring pass
215
+ * reads it once and hands it to `prefetchSurvival` and `survivingLines`: asking
216
+ * per commit started one `git rev-parse` each, which was 3.6 s of a 3.9 s pass
217
+ * over 80 commits once the blames were cached. A caller that passes no HEAD
218
+ * still gets a fresh read per call, so a commit made a moment ago is seen.
219
+ */
220
+ export async function survivalHead(repoPath) {
171
221
  try {
172
222
  const out = (await git(repoPath, ['rev-parse', 'HEAD'])).trim();
173
223
  return /^[0-9a-f]{40,64}$/.test(out) ? out : null;
@@ -192,13 +242,32 @@ export function blameCountsFromPorcelain(out) {
192
242
  }
193
243
  return counts;
194
244
  }
195
- function blameAt(repoPath, head, path) {
196
- const key = `${repoPath}\0${head}\0${path}`;
245
+ /**
246
+ * `sinceSec` bounds blame's walk back through history. Every commit newer than
247
+ * it keeps its exact line count; lines from older history are credited to a
248
+ * boundary commit instead of being traced to their origin, which is where
249
+ * almost all of blame's time goes on a long-lived file. Callers pass a bound
250
+ * older than every commit they measure, so their counts are unchanged.
251
+ */
252
+ function blameAt(repoPath, head, path, sinceSec) {
253
+ const key = `${repoPath}\0${head}\0${path}\0${sinceSec ?? ''}`;
197
254
  let pending = blameCache.get(key);
198
255
  if (pending === undefined) {
199
256
  if (blameCache.size >= BLAME_CACHE_MAX)
200
257
  blameCache.clear();
201
- pending = git(repoPath, ['blame', '--porcelain', head, '--', path]).then(blameCountsFromPorcelain, () => null);
258
+ const bound = sinceSec === undefined ? [] : [`--since=${sinceSec}`];
259
+ pending = (async () => {
260
+ // An earlier run's blame of this file, when it is provably the same
261
+ // observation (survivalStore.ts says when that is).
262
+ const reused = await reusableBlame(repoPath, head, path, sinceSec);
263
+ if (reused !== undefined)
264
+ return reused;
265
+ gitRuns.blame += 1;
266
+ const counts = await git(repoPath, ['blame', '--porcelain', ...bound, head, '--', path]).then(blameCountsFromPorcelain, () => null);
267
+ if (counts !== null)
268
+ rememberBlame(repoPath, head, path, sinceSec, counts);
269
+ return counts;
270
+ })();
202
271
  blameCache.set(key, pending);
203
272
  }
204
273
  return pending;
@@ -208,8 +277,8 @@ function blameAt(repoPath, head, path) {
208
277
  * `hash` by git blame. This is the retained-line count for `hash`'s introduced
209
278
  * artifact lines; it is not a quality score.
210
279
  */
211
- async function survivingLinesInFile(repoPath, head, hash, path) {
212
- const counts = await blameAt(repoPath, head, path);
280
+ async function survivingLinesInFile(repoPath, head, hash, path, sinceSec) {
281
+ const counts = await blameAt(repoPath, head, path, sinceSec);
213
282
  if (counts === null)
214
283
  return 0; // file deleted/renamed at HEAD → none of its lines survived as-is
215
284
  const exact = counts.get(hash);
@@ -231,23 +300,29 @@ async function survivingLinesInFile(repoPath, head, hash, path) {
231
300
  * between files: a single `git blame` already running is allowed to finish,
232
301
  * because killing it would cost the work without producing an answer.
233
302
  */
234
- export async function survivingLines(repoPath, hash, deadlineMs) {
235
- const [files, head] = await Promise.all([commitFiles(repoPath, hash), headCommit(repoPath)]);
303
+ export async function survivingLines(repoPath, hash, deadlineMs,
304
+ /** Optional history bound for blame; must be older than `hash` (see blameAt). */
305
+ sinceSec,
306
+ /** The pass's HEAD (survivalHead); read here when not given. */
307
+ passHead) {
308
+ const [files, head] = await Promise.all([commitFiles(repoPath, hash), passHead === undefined ? survivalHead(repoPath) : passHead]);
236
309
  let added = 0;
237
310
  let surviving = 0;
238
311
  let measured = true;
312
+ let filesWithAdditions = 0;
239
313
  for (const f of files) {
240
314
  added += f.added;
241
315
  if (f.added === 0)
242
316
  continue;
317
+ filesWithAdditions += 1;
243
318
  if (head === null || (deadlineMs !== undefined && Date.now() >= deadlineMs)) {
244
319
  // No HEAD to blame against (an unborn branch) is unmeasured, never zero.
245
320
  measured = false;
246
321
  continue;
247
322
  }
248
- surviving += await survivingLinesInFile(repoPath, head, hash, f.path);
323
+ surviving += await survivingLinesInFile(repoPath, head, hash, f.path, sinceSec);
249
324
  }
250
- return { added, surviving, measured };
325
+ return { added, surviving, measured, filesWithAdditions };
251
326
  }
252
327
  /**
253
328
  * Scan recent history for reverts, and report the boundary of what was read.
@@ -304,8 +379,9 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
304
379
  const attributions = await attributeCommits(store, repoPath, { limit, persist: opts.persist });
305
380
  const reverted = await revertedHashes(repoPath, limit);
306
381
  const commits = [];
382
+ const passHead = await survivalHead(repoPath);
307
383
  for (const a of attributions) {
308
- const survival = await survivingLines(repoPath, a.hash);
384
+ const survival = await survivingLines(repoPath, a.hash, undefined, undefined, passHead);
309
385
  const { added, surviving } = survival;
310
386
  const retainedArtifact = artifactPersistence({
311
387
  introducedLines: added,
@@ -316,6 +392,9 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
316
392
  const ageDays = (now - a.tsEpochMs) / (24 * 60 * 60 * 1000);
317
393
  const maturing = now - a.tsEpochMs < windowMs;
318
394
  const cost = a.attributedCostUsd;
395
+ // A window retention emptied reports the surviving spend, which is not the
396
+ // cost of the work, so no per-dollar figure is derived from it.
397
+ const spendKnown = a.spendWindowTruncated === false;
319
398
  const isReverted = reverted.has(a.hash.slice(0, 7));
320
399
  commits.push({
321
400
  ...a,
@@ -327,8 +406,8 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
327
406
  reverted: isReverted,
328
407
  ageDays,
329
408
  maturing,
330
- aiYield: cost > 0 ? surviving / cost : null,
331
- costPerSurvivingLine: surviving > 0 ? cost / surviving : null,
409
+ aiYield: spendKnown && cost > 0 ? surviving / cost : null,
410
+ costPerSurvivingLine: spendKnown && surviving > 0 ? cost / surviving : null,
332
411
  });
333
412
  }
334
413
  // Retention/churn/revert are artifact observations over all matured commits.
@@ -345,7 +424,10 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
345
424
  const revertCount = mature.filter((c) => c.reverted).length;
346
425
  // Legacy AI Yield/effective-spend projections only count commits with AI cost,
347
426
  // so pure-human commits do not pad the retained-lines-per-dollar numerator.
348
- const aiCommits = mature.filter((c) => c.attributedCostUsd > 0);
427
+ // Commits whose spend window retention pruned are withheld from every
428
+ // cost-based figure: their surviving cost would understate the denominator.
429
+ const spendTruncated = mature.filter((c) => c.spendWindowTruncated !== false);
430
+ const aiCommits = mature.filter((c) => c.spendWindowTruncated === false && c.attributedCostUsd > 0);
349
431
  const aiCost = aiCommits.reduce((s, c) => s + c.attributedCostUsd, 0);
350
432
  const aiSurviving = aiCommits.reduce((s, c) => s + c.survivingLines, 0);
351
433
  const costOnDurable = aiCommits
@@ -371,6 +453,7 @@ export async function computeArtifactPersistence(store, repoPath, opts = {}) {
371
453
  aiYield: aiCost > 0 ? aiSurviving / aiCost : null,
372
454
  costPerSurvivingLine: aiSurviving > 0 ? aiCost / aiSurviving : null,
373
455
  effectiveSpendRatio: aiCost > 0 ? costOnDurable / aiCost : null,
456
+ spendWindowTruncatedCommits: spendTruncated.length,
374
457
  },
375
458
  };
376
459
  }