@mnemom/mnemom 0.17.33 → 0.17.34-next.1

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 (47) hide show
  1. package/dist/commands/agent-share-render.d.ts +1 -1
  2. package/dist/commands/agent-share-render.js +1 -1
  3. package/dist/commands/agent.d.ts +15 -0
  4. package/dist/commands/agent.js +83 -7
  5. package/dist/commands/protection.js +1 -1
  6. package/dist/commands/scan.d.ts +97 -0
  7. package/dist/commands/scan.js +238 -0
  8. package/dist/index.js +35 -0
  9. package/dist/lib/api.d.ts +13 -0
  10. package/dist/lib/api.js +8 -0
  11. package/dist/lib/scan/caps.d.ts +35 -0
  12. package/dist/lib/scan/caps.js +67 -0
  13. package/dist/lib/scan/credential.d.ts +37 -0
  14. package/dist/lib/scan/credential.js +104 -0
  15. package/dist/lib/scan/failure.d.ts +10 -0
  16. package/dist/lib/scan/failure.js +43 -0
  17. package/dist/lib/scan/pr-source.d.ts +57 -0
  18. package/dist/lib/scan/pr-source.js +130 -0
  19. package/dist/lib/scan/record.d.ts +49 -0
  20. package/dist/lib/scan/record.js +85 -0
  21. package/dist/lib/scan/report-email.d.ts +92 -0
  22. package/dist/lib/scan/report-email.js +136 -0
  23. package/dist/lib/scan/report.d.ts +13 -0
  24. package/dist/lib/scan/report.js +30 -0
  25. package/dist/lib/scan/scan.d.ts +64 -0
  26. package/dist/lib/scan/scan.js +182 -0
  27. package/dist/lib/scan/share.d.ts +38 -0
  28. package/dist/lib/scan/share.js +47 -0
  29. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  30. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  31. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  32. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  33. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  34. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  35. package/dist/lib/scan/spend/prices.d.ts +33 -0
  36. package/dist/lib/scan/spend/prices.js +57 -0
  37. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  38. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  39. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  40. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  41. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  42. package/dist/lib/scan/spend/token-rate.js +54 -0
  43. package/dist/lib/session-key.d.ts +41 -0
  44. package/dist/lib/session-key.js +124 -0
  45. package/dist/rc-key-401.mjs +75 -0
  46. package/dist/rc-proxy.mjs +221 -25
  47. package/package.json +2 -2
@@ -0,0 +1,92 @@
1
+ import type { ScanResult } from "./scan.js";
2
+ import type { ShareLevel } from "./share.js";
3
+ export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
4
+ export declare const ESTIMATE_NAME = "automatable_work_share";
5
+ export declare const METHOD_URL = "https://www.mnemom.ai/method";
6
+ export interface EmailReport {
7
+ schema_version: typeof REPORT_SCHEMA_VERSION;
8
+ org_id: string;
9
+ scan_id: string;
10
+ period: {
11
+ start: string;
12
+ end: string;
13
+ };
14
+ generated_at: string;
15
+ measured: {
16
+ teams: [];
17
+ summary: {
18
+ pr_count: number;
19
+ revert_count: number;
20
+ automatable_count: number;
21
+ examined_count: number;
22
+ tokens_total: 0;
23
+ };
24
+ spend: {
25
+ source: "pr_estimate";
26
+ figure?: {
27
+ low_cents: number;
28
+ high_cents: number;
29
+ };
30
+ ai_prs_by_tool?: Array<{
31
+ tool: string;
32
+ count: number;
33
+ }>;
34
+ };
35
+ };
36
+ estimate: {
37
+ name: string;
38
+ value: number;
39
+ unit: string;
40
+ method_url: string;
41
+ };
42
+ }
43
+ export interface ReportEmailBody {
44
+ report: EmailReport;
45
+ share_level: ShareLevel;
46
+ coverage: {
47
+ complete: boolean;
48
+ };
49
+ cto_email?: string;
50
+ }
51
+ /**
52
+ * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
53
+ * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
54
+ * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
55
+ * itself would let that line leave the machine (`sendSpendRange`).
56
+ */
57
+ export declare function buildEmailReport(result: ScanResult, o: {
58
+ orgId: string;
59
+ period: {
60
+ start: string;
61
+ end: string;
62
+ };
63
+ now: () => number;
64
+ sendSpendRange: boolean;
65
+ scanId?: string;
66
+ }): EmailReport;
67
+ export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
68
+ /** Closed set of failure names; each has fixed text. */
69
+ export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "network_error" | "unexpected_response";
70
+ export declare const failureMessage: (f: EmailFailure) => string;
71
+ export declare const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
72
+ export declare const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
73
+ export declare class ReportEmailError extends Error {
74
+ readonly reason: EmailFailure;
75
+ constructor(reason: EmailFailure);
76
+ }
77
+ export type EmailOutcome = {
78
+ status: "sent";
79
+ recipients: number;
80
+ } | {
81
+ status: "dry_run";
82
+ } | {
83
+ status: "unavailable";
84
+ } | {
85
+ status: "failed";
86
+ reason: EmailFailure;
87
+ };
88
+ export interface ReportEmailDeps {
89
+ post?: (endpoint: string, body: ReportEmailBody) => Promise<unknown>;
90
+ }
91
+ /** Never throws. The thrown value, the response body and the credential are never surfaced. */
92
+ export declare function sendReportEmail(orgId: string, body: ReportEmailBody, deps?: ReportEmailDeps): Promise<EmailOutcome>;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The report email: after every successful scan the person who ran it is emailed the report, with no
3
+ * opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`
4
+ * (mnemom-api#4270). The server resolves the runner's address from the signed-in identity, so the
5
+ * request carries no runner address; `cto_email` is sent only when the user passed --cto-email.
6
+ *
7
+ * The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals
8
+ * and counts only), `share_level`, `coverage.complete` and the optional `cto_email`. No Record totals,
9
+ * no repo names, no author, title, branch or body text, no credential.
10
+ *
11
+ * Every failure has a fixed name and fixed text. The server's body, the thrown value and the
12
+ * credential never reach the terminal.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ import { MnemomApiError, postApi } from "../api.js";
16
+ export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
17
+ export const ESTIMATE_NAME = "automatable_work_share";
18
+ export const METHOD_URL = "https://www.mnemom.ai/method";
19
+ const cents = (usd) => Math.max(0, Math.round(usd * 100));
20
+ /**
21
+ * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
22
+ * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
23
+ * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
24
+ * itself would let that line leave the machine (`sendSpendRange`).
25
+ */
26
+ export function buildEmailReport(result, o) {
27
+ const { totals, coverage } = result;
28
+ const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
29
+ const scope = coverage.complete
30
+ ? "every merged pull request in the period"
31
+ : "a capped sample (not every merged pull request in the period)";
32
+ const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
33
+ `that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
34
+ const byTool = result.spend.kind === "range"
35
+ ? Object.entries(result.spend.byTool)
36
+ .filter(([, n]) => typeof n === "number" && n > 0)
37
+ .map(([tool, count]) => ({ tool, count: count }))
38
+ : [];
39
+ return {
40
+ schema_version: REPORT_SCHEMA_VERSION,
41
+ org_id: o.orgId,
42
+ scan_id: o.scanId ?? randomUUID(),
43
+ period: o.period,
44
+ generated_at: new Date(o.now()).toISOString(),
45
+ measured: {
46
+ teams: [],
47
+ summary: {
48
+ pr_count: totals.prs,
49
+ revert_count: totals.reverts,
50
+ automatable_count: totals.ciGreen,
51
+ examined_count: totals.examined,
52
+ tokens_total: 0,
53
+ },
54
+ spend: {
55
+ source: "pr_estimate",
56
+ ...(o.sendSpendRange && result.spend.kind === "range"
57
+ ? {
58
+ figure: {
59
+ low_cents: cents(result.spend.lowUsd),
60
+ high_cents: cents(result.spend.highUsd),
61
+ },
62
+ }
63
+ : {}),
64
+ ...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
65
+ },
66
+ },
67
+ estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
68
+ };
69
+ }
70
+ export function buildEmailBody(report, level, complete, ctoEmail) {
71
+ return {
72
+ report,
73
+ share_level: level,
74
+ coverage: { complete },
75
+ ...(ctoEmail !== undefined ? { cto_email: ctoEmail } : {}),
76
+ };
77
+ }
78
+ const FAILURE_TEXT = {
79
+ presend_check_failed: "the report failed Mnemom's pre-send checks, so no email was sent.",
80
+ email_not_sent: "Mnemom could not send the email.",
81
+ not_signed_in: "you are not signed in. Run `mnemom login` and re-run the scan.",
82
+ not_a_member: "your account is not a member of the org passed to --org.",
83
+ report_rejected: "Mnemom rejected the report as malformed.",
84
+ no_recipients: "the server accepted the report but reported zero recipients.",
85
+ network_error: "Mnemom could not be reached.",
86
+ unexpected_response: "Mnemom answered in an unexpected way.",
87
+ };
88
+ export const failureMessage = (f) => `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
89
+ export const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
90
+ export const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
91
+ export class ReportEmailError extends Error {
92
+ reason;
93
+ constructor(reason) {
94
+ super(failureMessage(reason));
95
+ this.reason = reason;
96
+ this.name = "ReportEmailError";
97
+ }
98
+ }
99
+ const defaultPost = (endpoint, body) => postApi(endpoint, body);
100
+ function classifyStatus(status) {
101
+ if (status === 401)
102
+ return "not_signed_in";
103
+ if (status === 403)
104
+ return "not_a_member";
105
+ if (status === 422)
106
+ return "presend_check_failed";
107
+ if (status === 502)
108
+ return "email_not_sent";
109
+ if (status === 400 || status === 413)
110
+ return "report_rejected";
111
+ return "unexpected_response";
112
+ }
113
+ /** Never throws. The thrown value, the response body and the credential are never surfaced. */
114
+ export async function sendReportEmail(orgId, body, deps = {}) {
115
+ const post = deps.post ?? defaultPost;
116
+ let res;
117
+ try {
118
+ res = await post(`/v1/orgs/${encodeURIComponent(orgId)}/scan/report-email`, body);
119
+ }
120
+ catch (e) {
121
+ if (e instanceof MnemomApiError && e.effectiveStatus === 404)
122
+ return { status: "unavailable" };
123
+ if (e instanceof MnemomApiError)
124
+ return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
125
+ return { status: "failed", reason: "network_error" };
126
+ }
127
+ const o = typeof res === "object" && res !== null ? res : null;
128
+ const recipients = o?.recipients;
129
+ if (o === null || (o.status !== "sent" && o.status !== "dry_run"))
130
+ return { status: "failed", reason: "unexpected_response" };
131
+ if (typeof recipients !== "number" || !Number.isInteger(recipients) || recipients < 0)
132
+ return { status: "failed", reason: "unexpected_response" };
133
+ if (recipients === 0)
134
+ return { status: "failed", reason: "no_recipients" };
135
+ return o.status === "dry_run" ? { status: "dry_run" } : { status: "sent", recipients };
136
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Renders the scan preview report from values already in memory: the local PR scan plus the org's
3
+ * Record totals. Pure (returns lines, prints nothing) and never persists anything. Totals are
4
+ * labelled "last 30 days"; no other window number is invented. Money is integer millimu
5
+ * (1 MU = 1000 mmu), formatted with BigInt so values above 2^53 stay exact.
6
+ */
7
+ import { type RecordOutcome } from "./record.js";
8
+ export declare const RECORD_NOT_AVAILABLE_NOTE = "Records not available for this org.";
9
+ export declare const RECORD_UNAVAILABLE_NOTE = "Records could not be read right now; continuing without them.";
10
+ export declare const RECORD_NO_ORG_NOTE = "Records skipped: pass --org <org_id> to include the org's Record totals.";
11
+ /** "12.5 MU" from "12500". Caller guarantees a canonical mmu string. */
12
+ export declare function formatMu(mmu: string): string;
13
+ export declare function renderRecordSection(outcome: RecordOutcome): string[];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Renders the scan preview report from values already in memory: the local PR scan plus the org's
3
+ * Record totals. Pure (returns lines, prints nothing) and never persists anything. Totals are
4
+ * labelled "last 30 days"; no other window number is invented. Money is integer millimu
5
+ * (1 MU = 1000 mmu), formatted with BigInt so values above 2^53 stay exact.
6
+ */
7
+ import { RECORD_WINDOW_LABEL } from "./record.js";
8
+ export const RECORD_NOT_AVAILABLE_NOTE = "Records not available for this org.";
9
+ export const RECORD_UNAVAILABLE_NOTE = "Records could not be read right now; continuing without them.";
10
+ export const RECORD_NO_ORG_NOTE = "Records skipped: pass --org <org_id> to include the org's Record totals.";
11
+ /** "12.5 MU" from "12500". Caller guarantees a canonical mmu string. */
12
+ export function formatMu(mmu) {
13
+ const n = BigInt(mmu);
14
+ const whole = (n / 1000n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
15
+ const frac = (n % 1000n).toString().padStart(3, "0").replace(/0+$/, "");
16
+ return `${whole}${frac ? `.${frac}` : ""} MU`;
17
+ }
18
+ export function renderRecordSection(outcome) {
19
+ if (outcome.status === "not_in_cohort")
20
+ return [RECORD_NOT_AVAILABLE_NOTE];
21
+ if (outcome.status === "unavailable")
22
+ return [RECORD_UNAVAILABLE_NOTE];
23
+ const t = outcome.totals;
24
+ return [
25
+ `Org Record (${RECORD_WINDOW_LABEL}, read from Mnemom; not stored):`,
26
+ ` Model cost: ${formatMu(t.model_cost_mmu)}`,
27
+ ` Charged: ${t.mu_charged_mmu === null ? "not charged" : formatMu(t.mu_charged_mmu)}`,
28
+ ` Requests recorded: ${t.row_count}`,
29
+ ];
30
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Local capped scan core. Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
3
+ * plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
4
+ * core touches no network or disk, and the report exists only in memory.
5
+ */
6
+ import { type GitHubCredential } from "./credential.js";
7
+ import { type ScanCaps, type StopReason } from "./caps.js";
8
+ import { type FailureReason } from "./failure.js";
9
+ import { type PrSource } from "./pr-source.js";
10
+ import { type SpendEstimate } from "./spend/spend-estimate.js";
11
+ export interface ScanInput {
12
+ repos: string[];
13
+ /** Inclusive dates, YYYY-MM-DD. */
14
+ period: {
15
+ start: string;
16
+ end: string;
17
+ };
18
+ }
19
+ export interface ScanDeps {
20
+ credential: GitHubCredential;
21
+ prs?: PrSource;
22
+ fetchImpl?: typeof fetch;
23
+ now?: () => number;
24
+ caps?: Partial<ScanCaps>;
25
+ }
26
+ export interface Coverage {
27
+ complete: boolean;
28
+ stoppedBy: StopReason | null;
29
+ prsExamined: number;
30
+ prsSkippedLarge: number;
31
+ prsUnreadable: number;
32
+ reposRead: number;
33
+ reposTotal: number;
34
+ reads: number;
35
+ windowStart: string;
36
+ windowEnd: string;
37
+ /** Set only when the window held more merged PRs than the quota and an even sample was taken. */
38
+ windowSampledFrom?: number;
39
+ }
40
+ export interface RepoTotals {
41
+ repo: string;
42
+ prs: number;
43
+ reverts: number;
44
+ ciGreen: number;
45
+ }
46
+ export interface ScanResult {
47
+ coverage: Coverage;
48
+ totals: {
49
+ prs: number;
50
+ reverts: number;
51
+ ciGreen: number;
52
+ examined: number;
53
+ };
54
+ byRepo: RepoTotals[];
55
+ spend: SpendEstimate;
56
+ }
57
+ export type ScanOutcome = {
58
+ status: "done";
59
+ result: ScanResult;
60
+ } | {
61
+ status: "withheld";
62
+ reason: FailureReason;
63
+ };
64
+ export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Local capped scan core. Port of the window path of runCappedScan (mnemom-api capped-scan.ts, PR #4208)
3
+ * plus the spend estimate (#4222). Everything outside is injected (credential, PR source, clock), so the
4
+ * core touches no network or disk, and the report exists only in memory.
5
+ */
6
+ import { assertReadOnlyCredential, isValidRepoSlug } from "./credential.js";
7
+ import { Budget, COVERAGE_MONTHS, evenSample, monthsBefore, sanitiseCaps, } from "./caps.js";
8
+ import { classifyFailure } from "./failure.js";
9
+ import { createGithubPrSource } from "./pr-source.js";
10
+ import { estimateSpend } from "./spend/spend-estimate.js";
11
+ const validDay = (d) => typeof d === "string" &&
12
+ /^\d{4}-\d{2}-\d{2}$/.test(d) &&
13
+ !Number.isNaN(Date.parse(`${d}T00:00:00Z`));
14
+ export async function runLocalScan(deps, input) {
15
+ if (!validDay(input.period.start) ||
16
+ !validDay(input.period.end) ||
17
+ input.repos.length === 0 ||
18
+ !input.repos.every(isValidRepoSlug)) {
19
+ return { status: "withheld", reason: "invalid_input" };
20
+ }
21
+ try {
22
+ await assertReadOnlyCredential(deps.credential);
23
+ }
24
+ catch (e) {
25
+ return { status: "withheld", reason: classifyFailure(e) };
26
+ }
27
+ const prs = deps.prs ?? createGithubPrSource({ credential: deps.credential, fetchImpl: deps.fetchImpl });
28
+ const now = deps.now ?? Date.now;
29
+ const caps = sanitiseCaps(deps.caps ?? {});
30
+ const budget = new Budget(caps, now, now());
31
+ let stoppedBy = null;
32
+ const stop = (r) => {
33
+ if (r && !stoppedBy)
34
+ stoppedBy = r;
35
+ return stoppedBy !== null;
36
+ };
37
+ const periodStart = input.period.start;
38
+ const windowStart = periodStart < monthsBefore(input.period.end, COVERAGE_MONTHS)
39
+ ? periodStart
40
+ : monthsBefore(input.period.end, COVERAGE_MONTHS);
41
+ const endDay = input.period.end;
42
+ const pool = [];
43
+ let reposRead = 0;
44
+ let listingCut = false;
45
+ try {
46
+ for (const repo of input.repos) {
47
+ if (stoppedBy !== null || stop(budget.block()))
48
+ break;
49
+ reposRead += 1;
50
+ let cursor = null;
51
+ const days = [];
52
+ let oldestUpdated = "9999-99-99";
53
+ do {
54
+ if (stop(budget.block()))
55
+ break;
56
+ // Listing may use at most half the read budget, so it never starves the reads of the PRs it found.
57
+ if (budget.reads * 2 > caps.maxReads) {
58
+ listingCut = true;
59
+ break;
60
+ }
61
+ budget.charge(1);
62
+ const page = await prs.indexMerged(repo, cursor, budget.room() + 1);
63
+ budget.charge((Number.isFinite(page.reads) && page.reads >= 1 ? page.reads : 1) - 1);
64
+ const overrun = budget.overrun();
65
+ if (overrun) {
66
+ stop(overrun);
67
+ break;
68
+ }
69
+ for (const item of page.items) {
70
+ pool.push({ repo, item });
71
+ days.push(item.mergedDay);
72
+ if (item.updatedDay < oldestUpdated)
73
+ oldestUpdated = item.updatedDay;
74
+ }
75
+ cursor = page.next;
76
+ // Newest-activity first: once a seen PR was last touched before D, no later page holds a PR
77
+ // merged on or after D. Stop when D is both the window start and the merge day of this repo's
78
+ // maxPrs-th newest merged PR.
79
+ const kth = days.length >= caps.maxPrs ? [...days].sort().reverse()[caps.maxPrs - 1] : null;
80
+ if (kth !== null && oldestUpdated < (kth < windowStart ? kth : windowStart))
81
+ break;
82
+ } while (cursor !== null && stoppedBy === null);
83
+ if (listingCut) {
84
+ if (cursor === null && days.length === 0)
85
+ reposRead -= 1;
86
+ break;
87
+ }
88
+ }
89
+ // Newest first, ties by repo then number: the same pool always gives the same sample.
90
+ for (let i = pool.length - 1; i >= 0; i--)
91
+ if (pool[i].item.mergedDay > endDay)
92
+ pool.splice(i, 1);
93
+ pool.sort((a, b) => (a.item.mergedDay === b.item.mergedDay
94
+ ? 0
95
+ : a.item.mergedDay < b.item.mergedDay
96
+ ? 1
97
+ : -1) ||
98
+ (a.repo < b.repo ? -1 : a.repo > b.repo ? 1 : 0) ||
99
+ b.item.number - a.item.number);
100
+ const inWindow = pool.filter((p) => p.item.mergedDay >= windowStart);
101
+ const sampled = inWindow.length > caps.maxPrs;
102
+ const chosen = sampled ? evenSample(inWindow, caps.maxPrs) : pool.slice(0, caps.maxPrs);
103
+ const byRepo = new Map();
104
+ const bucket = (repo) => {
105
+ let b = byRepo.get(repo);
106
+ if (!b)
107
+ byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0 }));
108
+ return b;
109
+ };
110
+ const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
111
+ const spendInputs = [];
112
+ let skippedLarge = 0;
113
+ let unreadable = 0;
114
+ for (const { repo, item } of chosen) {
115
+ // A revert PR is never a candidate: counted from the listing, never read.
116
+ if (item.reverted) {
117
+ totals.prs += 1;
118
+ totals.reverts += 1;
119
+ bucket(repo).prs += 1;
120
+ bucket(repo).reverts += 1;
121
+ continue;
122
+ }
123
+ if (stop(budget.block(2)))
124
+ break;
125
+ budget.charge(1);
126
+ const res = await prs.readPr(repo, item, budget.room() + 1);
127
+ budget.charge((Number.isFinite(res.reads) && res.reads >= 1 ? res.reads : 1) - 1);
128
+ const overrun = budget.overrun();
129
+ if (overrun) {
130
+ stop(overrun);
131
+ break;
132
+ }
133
+ const rec = res.pr;
134
+ if (!rec) {
135
+ unreadable += 1;
136
+ continue;
137
+ }
138
+ if (rec.additions + rec.deletions > caps.maxPrSizeLines) {
139
+ skippedLarge += 1;
140
+ continue;
141
+ }
142
+ totals.prs += 1;
143
+ totals.examined += 1;
144
+ bucket(repo).prs += 1;
145
+ if (rec.ci === "success") {
146
+ totals.ciGreen += 1;
147
+ bucket(repo).ciGreen += 1;
148
+ }
149
+ spendInputs.push({
150
+ additions: rec.additions,
151
+ deletions: rec.deletions,
152
+ markText: "",
153
+ tools: rec.tools,
154
+ });
155
+ }
156
+ const coverage = {
157
+ complete: stoppedBy === null &&
158
+ !listingCut &&
159
+ unreadable === 0 &&
160
+ reposRead === input.repos.length &&
161
+ !sampled,
162
+ stoppedBy: stoppedBy ?? (listingCut ? "read-cap" : null),
163
+ prsExamined: totals.examined,
164
+ prsSkippedLarge: skippedLarge,
165
+ prsUnreadable: unreadable,
166
+ reposRead,
167
+ reposTotal: input.repos.length,
168
+ reads: budget.reads,
169
+ windowStart,
170
+ windowEnd: endDay,
171
+ ...(sampled ? { windowSampledFrom: inWindow.length } : {}),
172
+ };
173
+ return {
174
+ status: "done",
175
+ result: { coverage, totals, byRepo: [...byRepo.values()], spend: estimateSpend(spendInputs) },
176
+ };
177
+ }
178
+ catch (e) {
179
+ // Reads only; the raw error is dropped and a fixed reason returned.
180
+ return { status: "withheld", reason: classifyFailure(e) };
181
+ }
182
+ }
@@ -0,0 +1,38 @@
1
+ import type { ScanResult } from "./scan.js";
2
+ export type ShareLevel = "summary" | "full" | "none";
3
+ export declare const SHARE_OPTIONS: ReadonlyArray<{
4
+ level: ShareLevel;
5
+ label: string;
6
+ }>;
7
+ /** Asks the user. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
8
+ export declare function promptShareLevel(): Promise<ShareLevel>;
9
+ export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
10
+ /** Who receives the report. Sent only with a non-"none" share level. */
11
+ export interface ReportRecipients {
12
+ runner_email: string;
13
+ cto_email: string;
14
+ }
15
+ export interface SharePayload {
16
+ recipients: ReportRecipients;
17
+ level: Exclude<ShareLevel, "none">;
18
+ period: {
19
+ start: string;
20
+ end: string;
21
+ };
22
+ prs: number;
23
+ reverts: number;
24
+ ci_green: number;
25
+ examined: number;
26
+ coverage_complete: boolean;
27
+ /** Absent when no spend line may be shown. */
28
+ spend_line?: string;
29
+ repos?: Array<{
30
+ repo: string;
31
+ prs: number;
32
+ reverts: number;
33
+ ci_green: number;
34
+ }>;
35
+ }
36
+ /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the two report recipients. */
37
+ export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
38
+ export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Share level: what, if anything, may leave the machine. Asked before any output is built for sharing.
3
+ * Default is summary metrics. "none" yields no payload at all.
4
+ */
5
+ import { askSelect } from "../prompt.js";
6
+ export const SHARE_OPTIONS = [
7
+ { level: "summary", label: "Summary metrics only (counts and one estimate line; default)" },
8
+ { level: "full", label: "Full report (summary plus per-repo counts)" },
9
+ { level: "none", label: "Nothing (keep results on this machine)" },
10
+ ];
11
+ /** Asks the user. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
12
+ export async function promptShareLevel() {
13
+ const answer = await askSelect("What may leave this machine?", SHARE_OPTIONS.map((o) => o.label));
14
+ if (answer === null)
15
+ return "none";
16
+ return SHARE_OPTIONS.find((o) => o.label === answer)?.level ?? "none";
17
+ }
18
+ export function parseShareLevel(v) {
19
+ return v === "summary" || v === "full" || v === "none" ? v : null;
20
+ }
21
+ export function buildSharePayload(level, r, recipients,
22
+ /** undefined: the legacy line; null: no spend line; string: that line. */
23
+ spendLine) {
24
+ if (level === "none")
25
+ return null;
26
+ const base = {
27
+ recipients,
28
+ level,
29
+ period: { start: r.coverage.windowStart, end: r.coverage.windowEnd },
30
+ prs: r.totals.prs,
31
+ reverts: r.totals.reverts,
32
+ ci_green: r.totals.ciGreen,
33
+ examined: r.totals.examined,
34
+ coverage_complete: r.coverage.complete,
35
+ ...((spendLine === undefined ? r.spend.line : spendLine) === null
36
+ ? {}
37
+ : { spend_line: spendLine ?? r.spend.line }),
38
+ };
39
+ if (level === "full")
40
+ base.repos = r.byRepo.map((b) => ({
41
+ repo: b.repo,
42
+ prs: b.prs,
43
+ reverts: b.reverts,
44
+ ci_green: b.ciGreen,
45
+ }));
46
+ return base;
47
+ }
@@ -0,0 +1,6 @@
1
+ import type { AiTool } from "./prices.js";
2
+ /** Upper bound on text scanned per PR, so one huge body cannot dominate a scan. The TAIL is kept: trailers and "generated with" footers sit at the end. */
3
+ export declare const MAX_MARK_TEXT = 20000;
4
+ export declare const AI_TOOLS: AiTool[];
5
+ /** Tools whose mark appears in the text, in a fixed order. Non-string input yields none. */
6
+ export declare function detectAiTools(markText: unknown): AiTool[];
@@ -0,0 +1,18 @@
1
+ /** Upper bound on text scanned per PR, so one huge body cannot dominate a scan. The TAIL is kept: trailers and "generated with" footers sit at the end. */
2
+ export const MAX_MARK_TEXT = 20_000;
3
+ const TRAILER = String.raw `^[ \t>*-]*co-authored-by:[^\n]*?`;
4
+ const GEN = String.raw `generated (?:with|by|using)[^\n]{0,24}?`;
5
+ const MARKS = {
6
+ claude_code: new RegExp(`${TRAILER}(?:\\bclaude\\b|noreply@anthropic\\.com)|${GEN}\\bclaude code\\b`, "im"),
7
+ codex: new RegExp(`${TRAILER}\\bcodex\\b|${GEN}\\bcodex\\b`, "im"),
8
+ copilot: new RegExp(`${TRAILER}\\bcopilot\\b|${GEN}\\bgithub copilot\\b`, "im"),
9
+ gemini: new RegExp(`${TRAILER}\\bgemini\\b|${GEN}\\bgemini(?: cli)?\\b`, "im"),
10
+ };
11
+ export const AI_TOOLS = Object.keys(MARKS);
12
+ /** Tools whose mark appears in the text, in a fixed order. Non-string input yields none. */
13
+ export function detectAiTools(markText) {
14
+ if (typeof markText !== "string" || markText.length === 0)
15
+ return [];
16
+ const text = markText.length > MAX_MARK_TEXT ? markText.slice(-MAX_MARK_TEXT) : markText;
17
+ return AI_TOOLS.filter((t) => MARKS[t].test(text));
18
+ }