@mnemom/mnemom 0.17.34-next.2 → 0.17.34-next.4

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 (87) hide show
  1. package/dist/commands/agent-run.d.ts +16 -6
  2. package/dist/commands/agent-run.js +52 -27
  3. package/dist/commands/agent-share-test-issuer.d.ts +67 -0
  4. package/dist/commands/agent-share-test-issuer.js +139 -0
  5. package/dist/commands/agent-share.d.ts +7 -1
  6. package/dist/commands/agent-share.js +14 -4
  7. package/dist/commands/agent.d.ts +7 -5
  8. package/dist/commands/agent.js +84 -45
  9. package/dist/commands/durable-lock.d.ts +8 -1
  10. package/dist/commands/durable-lock.js +9 -4
  11. package/dist/commands/scan.d.ts +12 -1
  12. package/dist/commands/scan.js +22 -3
  13. package/dist/commands/wrap.js +1 -1
  14. package/dist/durable/adopt-live.js +5 -8
  15. package/dist/durable/adopt.d.ts +8 -0
  16. package/dist/durable/adopt.js +27 -6
  17. package/dist/durable/cwd-rule.d.ts +9 -1
  18. package/dist/durable/cwd-rule.js +18 -0
  19. package/dist/durable/launch-spec.d.ts +2 -0
  20. package/dist/durable/launch-spec.js +1 -1
  21. package/dist/durable/test-mode.mjs +25 -1
  22. package/dist/host/client.d.ts +3 -1
  23. package/dist/host/daemon.d.ts +7 -3
  24. package/dist/host/daemon.js +13 -3
  25. package/dist/host/owner.d.ts +3 -0
  26. package/dist/host/owner.js +2 -0
  27. package/dist/host/run.d.ts +2 -3
  28. package/dist/host/run.js +5 -20
  29. package/dist/host/s1-names.d.ts +1 -0
  30. package/dist/host/s1-names.js +1 -0
  31. package/dist/host/verbs.d.ts +2 -0
  32. package/dist/host/verbs.js +11 -0
  33. package/dist/index.js +13 -9
  34. package/dist/lib/agent.d.ts +5 -3
  35. package/dist/lib/agent.js +6 -3
  36. package/dist/lib/claude-bin.d.ts +6 -0
  37. package/dist/lib/claude-bin.js +26 -0
  38. package/dist/lib/commit-report.d.ts +5 -4
  39. package/dist/lib/commit-report.js +6 -9
  40. package/dist/lib/config.d.ts +8 -1
  41. package/dist/lib/config.js +28 -0
  42. package/dist/lib/durable/launch.d.ts +28 -11
  43. package/dist/lib/durable/launch.js +33 -15
  44. package/dist/lib/durable/live.d.ts +15 -19
  45. package/dist/lib/durable/live.js +41 -43
  46. package/dist/lib/launcher-config.d.ts +6 -0
  47. package/dist/lib/launcher-config.js +11 -0
  48. package/dist/lib/runner/exec-append.d.ts +15 -0
  49. package/dist/lib/runner/exec-append.js +2 -0
  50. package/dist/lib/runner/executor-client.d.ts +2 -1
  51. package/dist/lib/runner/executor-state.d.ts +10 -0
  52. package/dist/lib/runner/executor-state.js +24 -0
  53. package/dist/lib/runner/github-base.d.ts +24 -0
  54. package/dist/lib/runner/github-base.js +27 -0
  55. package/dist/lib/runner/job.d.ts +11 -4
  56. package/dist/lib/runner/job.js +59 -16
  57. package/dist/lib/runner/oidc.d.ts +3 -2
  58. package/dist/lib/runner/oidc.js +7 -4
  59. package/dist/lib/runner/options.d.ts +6 -0
  60. package/dist/lib/runner/options.js +19 -1
  61. package/dist/lib/runner/outcome.js +2 -3
  62. package/dist/lib/runner/phases.d.ts +7 -4
  63. package/dist/lib/runner/phases.js +8 -4
  64. package/dist/lib/runner/proxy.d.ts +5 -1
  65. package/dist/lib/runner/proxy.js +7 -1
  66. package/dist/lib/scan/cost-per-pr.d.ts +93 -0
  67. package/dist/lib/scan/cost-per-pr.js +101 -0
  68. package/dist/lib/scan/record.d.ts +27 -1
  69. package/dist/lib/scan/record.js +114 -16
  70. package/dist/lib/scan/report-email.d.ts +25 -5
  71. package/dist/lib/scan/report-email.js +23 -2
  72. package/dist/lib/scan/scan.d.ts +3 -0
  73. package/dist/lib/scan/scan.js +13 -1
  74. package/dist/lib/session-key.d.ts +4 -3
  75. package/dist/vendor/durable-contract/index.js +207 -169
  76. package/dist/vendor/shared/durable-contract/doors.d.ts +26 -1
  77. package/dist/vendor/shared/durable-contract/doors.js +37 -1
  78. package/dist/vendor/shared/durable-contract/events.d.ts +1 -1
  79. package/dist/vendor/shared/durable-contract/events.js +13 -3
  80. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +2 -2
  81. package/dist/vendor/shared/durable-contract/schema.d.ts +5 -0
  82. package/dist/vendor/shared/durable-contract/schema.js +6 -0
  83. package/dist/vendor/shared/durable-contract/states.d.ts +2 -1
  84. package/dist/vendor/shared/durable-contract/states.js +2 -1
  85. package/package.json +1 -1
  86. package/dist/lib/durable/first-turn.d.ts +0 -50
  87. package/dist/lib/durable/first-turn.js +0 -102
@@ -4,10 +4,19 @@
4
4
  * Read-only and local: the answer is held in memory to build the report and is never uploaded or
5
5
  * stored (mnemom-api#4211: Mnemom stores no scan result).
6
6
  *
7
- * The window is always the last 30 days ending now (the contract's maximum). It is its own
7
+ * The window is the last 30 days ending now, less a one-minute margin at the start so it stays
8
+ * inside the API's retention floor (the contract's maximum is 30 days). It is its own
8
9
  * window, not the PR scan period, and the report labels it "last 30 days"; no other scan-window
9
10
  * number is ever produced here.
10
11
  *
12
+ * An org with more requests in the window than the API will total in one read (it answers 400
13
+ * "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
14
+ * at its midpoint, recursively, and the pieces are summed. The API's window is half-open
15
+ * [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
16
+ * bounded (RECORD_MAX_REQUESTS reads, no piece split below RECORD_MIN_SPLIT_MS per half), and a
17
+ * total is all or nothing: any piece that fails for any other reason makes the whole read
18
+ * "unavailable", never a partial sum.
19
+ *
11
20
  * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
12
21
  * and the credential never reach the terminal:
13
22
  * ok totals read and shape-checked
@@ -19,6 +28,21 @@ import { type RecordWindowTotals } from "../api.js";
19
28
  export declare const RECORD_WINDOW_DAYS = 30;
20
29
  export declare const RECORD_WINDOW_LABEL = "last 30 days";
21
30
  export declare const RECORD_TIMEOUT_MS = 10000;
31
+ /**
32
+ * The API 400s a `from` earlier than its own now minus 30 days (retention floor, strict on purpose:
33
+ * mnemom-api#4310). Its clock runs later than ours by latency, so a window opening exactly 30 days
34
+ * before our now is always past that floor. Open it this much after instead; `to` stays our now
35
+ * (the API accepts a `to` up to 60s ahead of its clock: WINDOW_SKEW_MS in mnemom-api
36
+ * src/record/totals.ts). Ending at now centres the budget: the window is accepted while our clock is
37
+ * within 60s of the API's in either direction; ending earlier would trade away the floor side.
38
+ */
39
+ export declare const RECORD_FLOOR_MARGIN_MS = 60000;
40
+ /** Most reads one scan may make (the first full-window read included). */
41
+ export declare const RECORD_MAX_REQUESTS = 64;
42
+ /** Wall-clock budget for all the reads of one scan; past it the result is "unavailable". */
43
+ export declare const RECORD_TOTAL_TIMEOUT_MS = 60000;
44
+ /** A window is split only if both halves stay at least this long. */
45
+ export declare const RECORD_MIN_SPLIT_MS: number;
22
46
  export declare const isOrgId: (v: unknown) => v is string;
23
47
  export declare const RECORD_AUTH_MSG = "Record totals were refused for this org. Run `mnemom login` (or set MNEMOM_API_KEY) with an owner, admin or auditor of the org passed to --org, then retry.";
24
48
  export declare class RecordAuthError extends Error {
@@ -41,6 +65,8 @@ export interface RecordDeps {
41
65
  fetchTotals?: (orgId: string, from: string, to: string, signal: AbortSignal) => Promise<unknown>;
42
66
  now?: () => number;
43
67
  timeoutMs?: number;
68
+ /** Budget for every read of one total together (default RECORD_TOTAL_TIMEOUT_MS). */
69
+ totalTimeoutMs?: number;
44
70
  }
45
71
  export declare function recordWindow(nowMs: number): RecordWindow;
46
72
  /** Shape-check the body: a malformed answer is "unavailable", never rendered. */
@@ -4,10 +4,19 @@
4
4
  * Read-only and local: the answer is held in memory to build the report and is never uploaded or
5
5
  * stored (mnemom-api#4211: Mnemom stores no scan result).
6
6
  *
7
- * The window is always the last 30 days ending now (the contract's maximum). It is its own
7
+ * The window is the last 30 days ending now, less a one-minute margin at the start so it stays
8
+ * inside the API's retention floor (the contract's maximum is 30 days). It is its own
8
9
  * window, not the PR scan period, and the report labels it "last 30 days"; no other scan-window
9
10
  * number is ever produced here.
10
11
  *
12
+ * An org with more requests in the window than the API will total in one read (it answers 400
13
+ * "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
14
+ * at its midpoint, recursively, and the pieces are summed. The API's window is half-open
15
+ * [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
16
+ * bounded (RECORD_MAX_REQUESTS reads, no piece split below RECORD_MIN_SPLIT_MS per half), and a
17
+ * total is all or nothing: any piece that fails for any other reason makes the whole read
18
+ * "unavailable", never a partial sum.
19
+ *
11
20
  * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
12
21
  * and the credential never reach the terminal:
13
22
  * ok totals read and shape-checked
@@ -19,8 +28,23 @@ import { getRecordWindowTotals, MnemomApiError } from "../api.js";
19
28
  export const RECORD_WINDOW_DAYS = 30;
20
29
  export const RECORD_WINDOW_LABEL = "last 30 days";
21
30
  export const RECORD_TIMEOUT_MS = 10_000;
22
- /** `to` may not be in the future; stay a minute behind so a fast local clock cannot trip it. */
23
- const CLOCK_SKEW_MS = 60_000;
31
+ /**
32
+ * The API 400s a `from` earlier than its own now minus 30 days (retention floor, strict on purpose:
33
+ * mnemom-api#4310). Its clock runs later than ours by latency, so a window opening exactly 30 days
34
+ * before our now is always past that floor. Open it this much after instead; `to` stays our now
35
+ * (the API accepts a `to` up to 60s ahead of its clock: WINDOW_SKEW_MS in mnemom-api
36
+ * src/record/totals.ts). Ending at now centres the budget: the window is accepted while our clock is
37
+ * within 60s of the API's in either direction; ending earlier would trade away the floor side.
38
+ */
39
+ export const RECORD_FLOOR_MARGIN_MS = 60_000;
40
+ /** Most reads one scan may make (the first full-window read included). */
41
+ export const RECORD_MAX_REQUESTS = 64;
42
+ /** Wall-clock budget for all the reads of one scan; past it the result is "unavailable". */
43
+ export const RECORD_TOTAL_TIMEOUT_MS = 60_000;
44
+ /** A window is split only if both halves stay at least this long. */
45
+ export const RECORD_MIN_SPLIT_MS = 60 * 60 * 1000;
46
+ /** The API's bigint ceiling: a larger sum cannot be a real total. */
47
+ const MAX_MMU = 9223372036854775807n;
24
48
  const DAY_MS = 24 * 60 * 60 * 1000;
25
49
  const ORG_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
26
50
  const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
@@ -33,9 +57,9 @@ export class RecordAuthError extends Error {
33
57
  }
34
58
  }
35
59
  export function recordWindow(nowMs) {
36
- const to = nowMs - CLOCK_SKEW_MS;
60
+ const to = nowMs;
37
61
  return {
38
- from: new Date(to - RECORD_WINDOW_DAYS * DAY_MS).toISOString(),
62
+ from: new Date(to - RECORD_WINDOW_DAYS * DAY_MS + RECORD_FLOOR_MARGIN_MS).toISOString(),
39
63
  to: new Date(to).toISOString(),
40
64
  };
41
65
  }
@@ -60,26 +84,100 @@ export function parseWindowTotals(v) {
60
84
  as_of: o.as_of,
61
85
  };
62
86
  }
87
+ /** The API's too-many-requests answer: a 400 saying the window holds more than N requests. */
88
+ const isWindowTooBig = (e) => e instanceof MnemomApiError &&
89
+ e.effectiveStatus === 400 &&
90
+ /holds more than \d+ requests/.test(e.message);
91
+ /** Latest of two as_of timestamps (a "latest row" field is combined by max, never added). */
92
+ function laterAsOf(a, b) {
93
+ if (a === null)
94
+ return b;
95
+ if (b === null)
96
+ return a;
97
+ const ta = Date.parse(a);
98
+ const tb = Date.parse(b);
99
+ if (Number.isNaN(ta) || Number.isNaN(tb))
100
+ throw new Error("unparsable as_of");
101
+ return tb > ta ? b : a;
102
+ }
103
+ /**
104
+ * Add two windows' totals. Costs, charges and row counts are additive over disjoint windows (a
105
+ * request sits in the window of its gateway row, and its billing row follows it). `mu_charged_mmu`
106
+ * is null for a window with no billing row, so it is null only when every piece is null, as the
107
+ * API's own multi-lineage sum does. `as_of` is the latest row's time, so it is the max.
108
+ */
109
+ function addTotals(a, b) {
110
+ const model = BigInt(a.model_cost_mmu) + BigInt(b.model_cost_mmu);
111
+ const mu = a.mu_charged_mmu === null && b.mu_charged_mmu === null
112
+ ? null
113
+ : BigInt(a.mu_charged_mmu ?? "0") + BigInt(b.mu_charged_mmu ?? "0");
114
+ const rows = a.row_count + b.row_count;
115
+ if (model > MAX_MMU || (mu !== null && mu > MAX_MMU) || !Number.isSafeInteger(rows)) {
116
+ throw new Error("summed total out of range");
117
+ }
118
+ return {
119
+ model_cost_mmu: model.toString(),
120
+ mu_charged_mmu: mu === null ? null : mu.toString(),
121
+ row_count: rows,
122
+ as_of: laterAsOf(a.as_of, b.as_of),
123
+ };
124
+ }
125
+ /** Any failure other than auth / not-in-cohort makes the whole total unavailable. */
126
+ class SplitFailed extends Error {
127
+ }
128
+ class NotInCohort extends Error {
129
+ }
63
130
  /** Never throws except RecordAuthError (401/403, fail closed). */
64
131
  export async function fetchRecordTotals(orgId, deps = {}) {
65
132
  if (!isOrgId(orgId))
66
133
  return { status: "unavailable" };
67
134
  const window = recordWindow((deps.now ?? Date.now)());
68
135
  const fetchTotals = deps.fetchTotals ?? getRecordWindowTotals;
69
- const signal = AbortSignal.timeout(deps.timeoutMs ?? RECORD_TIMEOUT_MS);
136
+ const timeoutMs = deps.timeoutMs ?? RECORD_TIMEOUT_MS;
137
+ const deadline = Date.now() + (deps.totalTimeoutMs ?? RECORD_TOTAL_TIMEOUT_MS);
138
+ let requests = 0;
139
+ /** Total of [fromMs, toMs). Throws RecordAuthError, NotInCohort (root 404 only) or SplitFailed. */
140
+ const read = async (fromMs, toMs, root) => {
141
+ const left = deadline - Date.now();
142
+ if (requests >= RECORD_MAX_REQUESTS || left <= 0)
143
+ throw new SplitFailed();
144
+ requests += 1;
145
+ try {
146
+ const body = await fetchTotals(orgId, new Date(fromMs).toISOString(), new Date(toMs).toISOString(), AbortSignal.timeout(Math.min(timeoutMs, left)));
147
+ const totals = parseWindowTotals(body);
148
+ if (!totals)
149
+ throw new SplitFailed();
150
+ return totals;
151
+ }
152
+ catch (e) {
153
+ if (e instanceof MnemomApiError) {
154
+ const s = e.effectiveStatus;
155
+ if (s === 401 || s === 403)
156
+ throw new RecordAuthError();
157
+ if (s === 404 && root)
158
+ throw new NotInCohort();
159
+ if (isWindowTooBig(e)) {
160
+ const mid = fromMs + Math.floor((toMs - fromMs) / 2);
161
+ if (mid - fromMs < RECORD_MIN_SPLIT_MS || toMs - mid < RECORD_MIN_SPLIT_MS) {
162
+ throw new SplitFailed();
163
+ }
164
+ const first = await read(fromMs, mid, false);
165
+ return addTotals(first, await read(mid, toMs, false));
166
+ }
167
+ }
168
+ // RecordAuthError / NotInCohort / nested failures above are thrown from this handler and never land here.
169
+ throw new SplitFailed();
170
+ }
171
+ };
70
172
  try {
71
- const body = await fetchTotals(orgId, window.from, window.to, signal);
72
- const totals = parseWindowTotals(body);
73
- return totals ? { status: "ok", totals, window } : { status: "unavailable" };
173
+ const totals = await read(Date.parse(window.from), Date.parse(window.to), true);
174
+ return { status: "ok", totals, window };
74
175
  }
75
176
  catch (e) {
76
- if (e instanceof MnemomApiError) {
77
- const s = e.effectiveStatus;
78
- if (s === 401 || s === 403)
79
- throw new RecordAuthError();
80
- if (s === 404)
81
- return { status: "not_in_cohort" };
82
- }
177
+ if (e instanceof RecordAuthError)
178
+ throw e;
179
+ if (e instanceof NotInCohort)
180
+ return { status: "not_in_cohort" };
83
181
  return { status: "unavailable" };
84
182
  }
85
183
  }
@@ -1,8 +1,9 @@
1
+ import { type CostPerPr, type CostSpend } from "./cost-per-pr.js";
1
2
  import type { ScanResult } from "./scan.js";
2
3
  import type { ShareLevel } from "./share.js";
3
4
  export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
4
5
  export declare const ESTIMATE_NAME = "automatable_work_share";
5
- export declare const METHOD_URL = "https://www.mnemom.ai/method";
6
+ export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
6
7
  export interface EmailReport {
7
8
  schema_version: typeof REPORT_SCHEMA_VERSION;
8
9
  org_id: string;
@@ -39,6 +40,22 @@ export interface EmailReport {
39
40
  unit: string;
40
41
  method_url: string;
41
42
  };
43
+ /**
44
+ * Optional, added after org-scan-report/1 shipped; absent whenever it cannot be computed (no spend,
45
+ * no merged PR in the period, an incomplete scan, an empty bucket). See cost-per-pr.ts.
46
+ * cost_per_merged_pr average cost of one merged PR in the period
47
+ * cost_by_pr_size { small | medium | large }: average cost of ONE PR of that size, per-bucket
48
+ * keys absent when the bucket is empty. small < 50 lines changed, medium 50..500, large > 500.
49
+ * A figure is { label: "estimate", spend_source, pr_scope, pr_count } (+ spend_scope: "whole_org" on
50
+ * anthropic_usage) plus { value_cents } (anthropic_usage) or { low_cents, high_cents } (modelled_range). Integer cents.
51
+ * pr_scope is "ai_assisted" for modelled_range (spend and size buckets split across AI-assisted PRs only;
52
+ * absent if none) and "all_merged" otherwise; pr_count counts PRs in that scope.
53
+ * Always attached when present. The API's report validator (mnemom-api src/org-scan/report-schema.ts) is
54
+ * closed, so an API that does not yet list these keys answers 400 invalid_report; sendReportEmail then
55
+ * retries once without them so the report email still goes out.
56
+ */
57
+ cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
58
+ cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
42
59
  }
43
60
  export interface ReportEmailBody {
44
61
  report: EmailReport;
@@ -56,13 +73,16 @@ export interface ReportEmailBody {
56
73
  */
57
74
  export declare function buildEmailReport(result: ScanResult, o: {
58
75
  orgId: string;
59
- period: {
60
- start: string;
61
- end: string;
62
- };
63
76
  now: () => number;
64
77
  sendSpendRange: boolean;
65
78
  scanId?: string;
79
+ /** The requested scan period (the one the spend covers), not the examined window. */
80
+ period?: {
81
+ start: string;
82
+ end: string;
83
+ };
84
+ /** The one spend source the cost figures are split from; null/absent leaves them out. */
85
+ costSpend?: CostSpend | null;
66
86
  }): EmailReport;
67
87
  export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
68
88
  /** Closed set of failure names; each has fixed text. */
@@ -13,9 +13,10 @@
13
13
  */
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { MnemomApiError, postApi } from "../api.js";
16
+ import { buildCostPerPr } from "./cost-per-pr.js";
16
17
  export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
17
18
  export const ESTIMATE_NAME = "automatable_work_share";
18
- export const METHOD_URL = "https://www.mnemom.ai/method";
19
+ export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
19
20
  const cents = (usd) => Math.max(0, Math.round(usd * 100));
20
21
  /**
21
22
  * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
@@ -36,11 +37,16 @@ export function buildEmailReport(result, o) {
36
37
  .filter(([, n]) => typeof n === "number" && n > 0)
37
38
  .map(([tool, count]) => ({ tool, count: count }))
38
39
  : [];
40
+ // Only a scan that read every merged PR in the period gives an average that means anything.
41
+ const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
42
+ const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
39
43
  return {
40
44
  schema_version: REPORT_SCHEMA_VERSION,
41
45
  org_id: o.orgId,
42
46
  scan_id: o.scanId ?? randomUUID(),
43
- period: o.period,
47
+ // The span the scan actually examined (coverage), never the requested period: with no --period
48
+ // that defaults to today..today, which is not what was read.
49
+ period: { start: coverage.windowStart, end: coverage.windowEnd },
44
50
  generated_at: new Date(o.now()).toISOString(),
45
51
  measured: {
46
52
  teams: [],
@@ -65,6 +71,7 @@ export function buildEmailReport(result, o) {
65
71
  },
66
72
  },
67
73
  estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
74
+ ...cost,
68
75
  };
69
76
  }
70
77
  export function buildEmailBody(report, level, complete, ctoEmail) {
@@ -111,6 +118,13 @@ function classifyStatus(status) {
111
118
  return "report_rejected";
112
119
  return "unexpected_response";
113
120
  }
121
+ /** The body without the optional cost figures; the same object when it carries none. */
122
+ function withoutCostFigures(body) {
123
+ const { cost_per_merged_pr: _a, cost_by_pr_size: _b, ...rest } = body.report;
124
+ return "cost_per_merged_pr" in body.report || "cost_by_pr_size" in body.report
125
+ ? { ...body, report: rest }
126
+ : body;
127
+ }
114
128
  /** Never throws. The thrown value, the response body and the credential are never surfaced. */
115
129
  export async function sendReportEmail(orgId, body, deps = {}) {
116
130
  const post = deps.post ?? defaultPost;
@@ -121,6 +135,13 @@ export async function sendReportEmail(orgId, body, deps = {}) {
121
135
  catch (e) {
122
136
  if (e instanceof MnemomApiError && e.effectiveStatus === 404)
123
137
  return { status: "unavailable" };
138
+ // An API that predates the cost figures rejects the unknown keys with a 400. The email must still
139
+ // go out, so retry once with the figures left off; any other failure is classified as below.
140
+ if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
141
+ const bare = withoutCostFigures(body);
142
+ if (bare !== body)
143
+ return sendReportEmail(orgId, bare, deps);
144
+ }
124
145
  if (e instanceof MnemomApiError) {
125
146
  // A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
126
147
  if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
@@ -7,6 +7,7 @@ import { type GitHubCredential } from "./credential.js";
7
7
  import { type ScanCaps, type StopReason } from "./caps.js";
8
8
  import { type FailureReason } from "./failure.js";
9
9
  import { type PrSource } from "./pr-source.js";
10
+ import type { PrSize } from "./cost-per-pr.js";
10
11
  import { type SpendEstimate } from "./spend/spend-estimate.js";
11
12
  export interface ScanInput {
12
13
  repos: string[];
@@ -53,6 +54,8 @@ export interface ScanResult {
53
54
  };
54
55
  byRepo: RepoTotals[];
55
56
  spend: SpendEstimate;
57
+ /** Every merged PR read in full (reverts and unreadable/oversize PRs excluded): merge day and size. In memory only. */
58
+ prSizes: PrSize[];
56
59
  }
57
60
  export type ScanOutcome = {
58
61
  status: "done";
@@ -109,6 +109,7 @@ export async function runLocalScan(deps, input) {
109
109
  };
110
110
  const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
111
111
  const spendInputs = [];
112
+ const prSizes = [];
112
113
  let skippedLarge = 0;
113
114
  let unreadable = 0;
114
115
  for (const { repo, item } of chosen) {
@@ -146,6 +147,11 @@ export async function runLocalScan(deps, input) {
146
147
  totals.ciGreen += 1;
147
148
  bucket(repo).ciGreen += 1;
148
149
  }
150
+ prSizes.push({
151
+ mergedDay: item.mergedDay,
152
+ lines: rec.additions + rec.deletions,
153
+ aiAssisted: (rec.tools?.length ?? 0) > 0,
154
+ });
149
155
  spendInputs.push({
150
156
  additions: rec.additions,
151
157
  deletions: rec.deletions,
@@ -172,7 +178,13 @@ export async function runLocalScan(deps, input) {
172
178
  };
173
179
  return {
174
180
  status: "done",
175
- result: { coverage, totals, byRepo: [...byRepo.values()], spend: estimateSpend(spendInputs) },
181
+ result: {
182
+ coverage,
183
+ totals,
184
+ byRepo: [...byRepo.values()],
185
+ spend: estimateSpend(spendInputs),
186
+ prSizes,
187
+ },
176
188
  };
177
189
  }
178
190
  catch (e) {
@@ -24,9 +24,10 @@ export interface SessionKeyDeps {
24
24
  revoke: (keyId: string) => Promise<void>;
25
25
  say: (line: string) => void;
26
26
  /**
27
- * Override the mint/revoke timeout, tests only — real callers never set these, so the real
28
- * 10s/3s bounds below always apply in production. Exists so a test can exercise "never
29
- * settles" against a short, fast timeout rather than waiting out the real one.
27
+ * Override the mint/revoke timeout. Tests use it; the one real caller is the exit path of a
28
+ * launch that prints a conduct receipt, which bounds the revoke by the receipt's deadline
29
+ * (agent.ts, Doors W1.3). Otherwise the 10s/3s bounds below apply. Tests use it to exercise
30
+ * "never settles" against a short, fast timeout rather than waiting out the real one.
30
31
  */
31
32
  mintTimeoutMs?: number;
32
33
  revokeTimeoutMs?: number;