@mnemom/mnemom 0.17.38-next.0 → 0.17.38-next.2

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 (79) hide show
  1. package/dist/commands/agent-doctor.d.ts +2 -2
  2. package/dist/commands/agent-doctor.js +4 -4
  3. package/dist/commands/agent-run.d.ts +10 -2
  4. package/dist/commands/agent-run.js +12 -6
  5. package/dist/commands/agent-setup.d.ts +11 -9
  6. package/dist/commands/agent-setup.js +24 -19
  7. package/dist/commands/card.js +1 -1
  8. package/dist/commands/onboard.js +5 -4
  9. package/dist/commands/scan.d.ts +21 -7
  10. package/dist/commands/scan.js +93 -56
  11. package/dist/commands/verify-card.d.ts +25 -1
  12. package/dist/commands/verify-card.js +29 -2
  13. package/dist/commands/wrap.js +4 -3
  14. package/dist/index.js +7 -5
  15. package/dist/lib/runner/executor-client.d.ts +6 -0
  16. package/dist/lib/runner/executor-client.js +6 -0
  17. package/dist/lib/runner/job.d.ts +12 -3
  18. package/dist/lib/runner/job.js +30 -2
  19. package/dist/lib/scan/caps.d.ts +1 -35
  20. package/dist/lib/scan/caps.js +2 -67
  21. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  22. package/dist/lib/scan/cost-per-pr.js +2 -101
  23. package/dist/lib/scan/credential.d.ts +2 -26
  24. package/dist/lib/scan/credential.js +5 -58
  25. package/dist/lib/scan/failure.d.ts +1 -10
  26. package/dist/lib/scan/failure.js +2 -43
  27. package/dist/lib/scan/pr-source.d.ts +1 -57
  28. package/dist/lib/scan/pr-source.js +2 -132
  29. package/dist/lib/scan/report-email.d.ts +9 -89
  30. package/dist/lib/scan/report-email.js +76 -92
  31. package/dist/lib/scan/scan.d.ts +1 -67
  32. package/dist/lib/scan/scan.js +2 -194
  33. package/dist/lib/scan/share.d.ts +10 -6
  34. package/dist/lib/scan/share.js +28 -11
  35. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  36. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  37. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  38. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  39. package/dist/lib/scan/spend/prices.d.ts +1 -33
  40. package/dist/lib/scan/spend/prices.js +2 -57
  41. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  42. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  43. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  44. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  45. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  46. package/dist/lib/scan/spend/token-rate.js +2 -54
  47. package/dist/lib/starter-integrity.d.ts +20 -0
  48. package/dist/lib/starter-integrity.js +28 -0
  49. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  50. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  51. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  52. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  53. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  54. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  55. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  56. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  57. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  58. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  59. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  60. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  61. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  62. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  63. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  64. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  65. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  66. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  67. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  68. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  69. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  70. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  71. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  72. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  73. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  74. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  75. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  76. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  77. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  78. package/package.json +2 -2
  79. /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
@@ -0,0 +1,43 @@
1
+ const EXACT = new Map([
2
+ ["github repo not visible", "not_installed"],
3
+ ["scan credential refused", "credential"],
4
+ ["scan credential not configured", "credential"],
5
+ ["invalid repo", "invalid_input"],
6
+ ["invalid cursor", "invalid_input"],
7
+ ["scan read not allowed", "invalid_input"],
8
+ ["unexpected list shape", "github"],
9
+ ]);
10
+ export function classifyFailure(e) {
11
+ if (e instanceof Error) {
12
+ if (e.name === "TimeoutError" || e.name === "AbortError")
13
+ return "timeout";
14
+ const exact = EXACT.get(e.message);
15
+ if (exact)
16
+ return exact;
17
+ const gh = /^github read failed \((\d{3})\)$/.exec(e.message);
18
+ if (gh) {
19
+ if (gh[1] === "401" || gh[1] === "403")
20
+ return "forbidden";
21
+ if (gh[1] === "429")
22
+ return "rate_limited";
23
+ return "github";
24
+ }
25
+ if (e instanceof TypeError)
26
+ return "network";
27
+ }
28
+ return "github";
29
+ }
30
+ const TEXT = {
31
+ not_installed: "a repo is not visible to the credential (check your GitHub access to it)",
32
+ credential: "the GitHub credential was refused or is not configured",
33
+ timeout: "GitHub did not answer in time",
34
+ forbidden: "GitHub refused the credential (401/403)",
35
+ rate_limited: "GitHub rate limit reached",
36
+ github: "a GitHub read failed",
37
+ network: "GitHub was unreachable",
38
+ invalid_input: "an input to the scan was invalid",
39
+ };
40
+ /** Fixed sentence for a reason. */
41
+ export function failureText(r) {
42
+ return TEXT[r];
43
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @mnemom/org-scan-core (MNE-9752): the org scan pipeline shared by the CLI and the Mnemom-side run.
3
+ * Runtime-neutral: GitHub reads go through an injected fetch and credential, time through an injected
4
+ * clock, cancellation through an injected AbortSignal. Nothing here stores or sends a report.
5
+ */
6
+ export * from "./caps.js";
7
+ export * from "./credential.js";
8
+ export * from "./failure.js";
9
+ export * from "./pr-source.js";
10
+ export * from "./scan.js";
11
+ export * from "./cost-per-pr.js";
12
+ export * from "./report-build.js";
13
+ export * from "./spend/ai-tool-detect.js";
14
+ export * from "./spend/prices.js";
15
+ export * from "./spend/token-rate.js";
16
+ export * from "./spend/spend-estimate.js";
17
+ export * from "./spend/spend-select.js";
18
+ export * from "./spend/anthropic-usage.js";
@@ -0,0 +1,19 @@
1
+ // GENERATED from shared/org-scan-core/src/index.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
2
+ /**
3
+ * @mnemom/org-scan-core (MNE-9752): the org scan pipeline shared by the CLI and the Mnemom-side run.
4
+ * Runtime-neutral: GitHub reads go through an injected fetch and credential, time through an injected
5
+ * clock, cancellation through an injected AbortSignal. Nothing here stores or sends a report.
6
+ */
7
+ export * from "./caps.js";
8
+ export * from "./credential.js";
9
+ export * from "./failure.js";
10
+ export * from "./pr-source.js";
11
+ export * from "./scan.js";
12
+ export * from "./cost-per-pr.js";
13
+ export * from "./report-build.js";
14
+ export * from "./spend/ai-tool-detect.js";
15
+ export * from "./spend/prices.js";
16
+ export * from "./spend/token-rate.js";
17
+ export * from "./spend/spend-estimate.js";
18
+ export * from "./spend/spend-select.js";
19
+ export * from "./spend/anthropic-usage.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Read-only GitHub PR source for the local scan. Port of mnemom-api src/org-scan/scan/github-pr-source.ts
3
+ * (window path, PRs #4208 / #4221 / #4223).
4
+ *
5
+ * METADATA only: size counts, a CI rollup, a revert boolean and the AI tools named in the PR body.
6
+ * Titles and bodies are inspected in memory and dropped; author logins, branch names, and code never
7
+ * leave this module (PrRecord has no such field). Every call goes through `guardedGet`, which only
8
+ * ever issues a GET to api.github.com on an allowlisted path. Failures throw fixed-text errors:
9
+ * never a GitHub body, a header, a URL or the token.
10
+ */
11
+ import { type GitHubCredential } from "./credential.js";
12
+ import type { AiTool } from "./spend/prices.js";
13
+ export declare const GITHUB_API = "https://api.github.com";
14
+ /** Merged PRs are listed 100 at a time; a list page is one read however many PRs it holds. */
15
+ export declare const INDEX_PAGE_SIZE = 100;
16
+ export type CiRollup = "none" | "pending" | "success" | "failure";
17
+ export interface PrIndexItem {
18
+ number: number;
19
+ /** YYYY-MM-DD, UTC. */
20
+ mergedDay: string;
21
+ /** YYYY-MM-DD, UTC, last activity (the listing is newest-activity first). */
22
+ updatedDay: string;
23
+ reverted: boolean;
24
+ }
25
+ export interface PrIndexPage {
26
+ items: PrIndexItem[];
27
+ next: string | null;
28
+ reads: number;
29
+ }
30
+ export interface PrRecord {
31
+ additions: number;
32
+ deletions: number;
33
+ changed_files: number;
34
+ ci?: CiRollup;
35
+ reverted: boolean;
36
+ /** AI tools named in the PR body, detected here so the body never leaves this module. */
37
+ tools: AiTool[];
38
+ }
39
+ export interface PrSource {
40
+ indexMerged(repo: string, cursor: string | null, maxReads?: number): Promise<PrIndexPage>;
41
+ readPr(repo: string, item: PrIndexItem, maxReads?: number): Promise<{
42
+ pr: PrRecord | null;
43
+ reads: number;
44
+ }>;
45
+ }
46
+ export interface GithubPrSourceDeps {
47
+ credential: GitHubCredential;
48
+ fetchImpl?: typeof fetch;
49
+ timeoutMs?: number;
50
+ /** Aborts every read (the caller's run deadline), on top of the per-read timeout. */
51
+ signal?: AbortSignal;
52
+ /** Sent as user-agent; defaults to the CLI's. */
53
+ userAgent?: string;
54
+ }
55
+ /** Pure: one word from check-run conclusions. No check names, logs or URLs are kept. */
56
+ export declare function rollupCi(runs: Array<{
57
+ status?: unknown;
58
+ conclusion?: unknown;
59
+ }>): CiRollup;
60
+ export declare function isAllowedScanPath(path: string): boolean;
61
+ export declare function createGithubPrSource(deps: GithubPrSourceDeps): PrSource;
@@ -0,0 +1,138 @@
1
+ // GENERATED from shared/org-scan-core/src/pr-source.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
2
+ /**
3
+ * Read-only GitHub PR source for the local scan. Port of mnemom-api src/org-scan/scan/github-pr-source.ts
4
+ * (window path, PRs #4208 / #4221 / #4223).
5
+ *
6
+ * METADATA only: size counts, a CI rollup, a revert boolean and the AI tools named in the PR body.
7
+ * Titles and bodies are inspected in memory and dropped; author logins, branch names, and code never
8
+ * leave this module (PrRecord has no such field). Every call goes through `guardedGet`, which only
9
+ * ever issues a GET to api.github.com on an allowlisted path. Failures throw fixed-text errors:
10
+ * never a GitHub body, a header, a URL or the token.
11
+ */
12
+ import { isValidRepoSlug } from "./credential.js";
13
+ import { detectAiTools } from "./spend/ai-tool-detect.js";
14
+ export const GITHUB_API = "https://api.github.com";
15
+ /** Merged PRs are listed 100 at a time; a list page is one read however many PRs it holds. */
16
+ export const INDEX_PAGE_SIZE = 100;
17
+ /** Pure: one word from check-run conclusions. No check names, logs or URLs are kept. */
18
+ export function rollupCi(runs) {
19
+ if (runs.length === 0)
20
+ return "none";
21
+ if (runs.some((r) => r.status !== "completed"))
22
+ return "pending";
23
+ const ok = new Set(["success", "neutral", "skipped"]);
24
+ return runs.every((r) => typeof r.conclusion === "string" && ok.has(r.conclusion))
25
+ ? "success"
26
+ : "failure";
27
+ }
28
+ const isCount = (n) => typeof n === "number" && Number.isInteger(n) && n >= 0;
29
+ const SLUG = "[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}";
30
+ /** The only paths the scan may read. Anything else is refused before a request is made. */
31
+ const ALLOWED_PATHS = [
32
+ new RegExp(`^/repos/${SLUG}/pulls\\?state=closed&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,4}$`),
33
+ new RegExp(`^/repos/${SLUG}/pulls/\\d{1,9}$`),
34
+ new RegExp(`^/repos/${SLUG}/commits/[0-9a-f]{40}/check-runs\\?per_page=100$`),
35
+ // The repo picker's one list read (MNE-9643): the repos this login can read, newest push first.
36
+ /^\/user\/repos\?sort=pushed&direction=desc&per_page=\d{1,3}$/,
37
+ ];
38
+ export function isAllowedScanPath(path) {
39
+ return ALLOWED_PATHS.some((re) => re.test(path));
40
+ }
41
+ /** The per-read timeout, joined with the caller's signal when there is one. */
42
+ function readSignal(timeoutMs, outer) {
43
+ const t = AbortSignal.timeout(timeoutMs);
44
+ return outer ? AbortSignal.any([outer, t]) : t;
45
+ }
46
+ export function createGithubPrSource(deps) {
47
+ const doFetch = deps.fetchImpl ?? fetch;
48
+ const headers = {
49
+ accept: "application/vnd.github+json",
50
+ "x-github-api-version": "2022-11-28",
51
+ "user-agent": deps.userAgent ?? "mnemom-cli-scan/1",
52
+ };
53
+ /**
54
+ * `list` marks the PR list read, the first read of a repo. GitHub hides a repo the credential cannot
55
+ * see behind a 404, so only there is a 404 "repo not visible"; a 404 on a later detail or
56
+ * check-runs read is an ordinary upstream miss and keeps its numeric message (#4223).
57
+ */
58
+ async function guardedGet(path, repo, list = false) {
59
+ if (!isAllowedScanPath(path))
60
+ throw new Error("scan read not allowed");
61
+ const bearer = await deps.credential.tokenFor(repo);
62
+ const res = await doFetch(`${GITHUB_API}${path}`, {
63
+ method: "GET",
64
+ headers: { ...headers, authorization: `Bearer ${bearer}` },
65
+ redirect: "error",
66
+ signal: readSignal(deps.timeoutMs ?? 10_000, deps.signal),
67
+ });
68
+ if (!res.ok) {
69
+ await res.body?.cancel().catch(() => { });
70
+ throw new Error(list && res.status === 404
71
+ ? "github repo not visible"
72
+ : `github read failed (${res.status})`);
73
+ }
74
+ return res.json();
75
+ }
76
+ const day = (v) => {
77
+ const t = typeof v === "string" ? Date.parse(v) : NaN;
78
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
79
+ };
80
+ return {
81
+ async indexMerged(repo, cursor) {
82
+ if (!isValidRepoSlug(repo))
83
+ throw new Error("invalid repo");
84
+ const page = cursor === null ? 1 : Number(cursor);
85
+ if (!Number.isInteger(page) || page < 1 || page > 1000)
86
+ throw new Error("invalid cursor");
87
+ const list = (await guardedGet(`/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${INDEX_PAGE_SIZE}&page=${page}`, repo, true));
88
+ if (!Array.isArray(list))
89
+ throw new Error("unexpected list shape");
90
+ const items = [];
91
+ for (const it of list) {
92
+ const mergedDay = day(it.merged_at);
93
+ const updatedDay = day(it.updated_at);
94
+ if (mergedDay === null || updatedDay === null || !isCount(it.number))
95
+ continue;
96
+ // The title is read for the revert marker only and dropped here.
97
+ items.push({
98
+ number: it.number,
99
+ mergedDay,
100
+ updatedDay,
101
+ reverted: typeof it.title === "string" && /^revert\b/i.test(it.title),
102
+ });
103
+ }
104
+ return { items, next: list.length < INDEX_PAGE_SIZE ? null : String(page + 1), reads: 1 };
105
+ },
106
+ async readPr(repo, item, maxReads = Number.POSITIVE_INFINITY) {
107
+ if (!isValidRepoSlug(repo))
108
+ throw new Error("invalid repo");
109
+ if (!isCount(item.number) || maxReads < 2)
110
+ return { pr: null, reads: 0 };
111
+ const detail = (await guardedGet(`/repos/${repo}/pulls/${item.number}`, repo));
112
+ let reads = 1;
113
+ let ci;
114
+ const sha = detail.head?.sha;
115
+ if (typeof sha === "string" && /^[0-9a-f]{40}$/.test(sha)) {
116
+ const checks = (await guardedGet(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, repo));
117
+ reads += 1;
118
+ ci = rollupCi(Array.isArray(checks.check_runs) ? checks.check_runs : []);
119
+ }
120
+ if (!isCount(detail.additions) ||
121
+ !isCount(detail.deletions) ||
122
+ !isCount(detail.changed_files))
123
+ return { pr: null, reads };
124
+ const tools = detectAiTools(detail.body);
125
+ return {
126
+ pr: {
127
+ additions: detail.additions,
128
+ deletions: detail.deletions,
129
+ changed_files: detail.changed_files,
130
+ ci,
131
+ reverted: item.reverted,
132
+ tools,
133
+ },
134
+ reads,
135
+ };
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The org-scan report (org-scan-report/1) built from a scan result: the exact body the report email
3
+ * carries. Shared by the CLI (`POST /v1/orgs/{org_id}/scan/report-email`) and the Mnemom-side run
4
+ * (MNE-9752), so both send the same numbers. Pure and in memory: no network, no Node or Worker API.
5
+ *
6
+ * The body is exactly the route's contract and nothing else: `report` (team totals, counts, and the
7
+ * per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional `emails`. No author,
8
+ * title, branch or body text, no credential.
9
+ */
10
+ import { type CostPerPr, type CostSpend } from "./cost-per-pr.js";
11
+ import type { ScanResult } from "./scan.js";
12
+ import type { SpendSelection } from "./spend/spend-select.js";
13
+ /** How much of the report Mnemom may keep on file (MNE-9634). The email is always the full report. */
14
+ export type ShareLevel = "full" | "analytics" | "none";
15
+ export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
16
+ export declare const ESTIMATE_NAME = "automatable_work_share";
17
+ export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
18
+ export interface EmailReport {
19
+ schema_version: typeof REPORT_SCHEMA_VERSION;
20
+ org_id: string;
21
+ scan_id: string;
22
+ period: {
23
+ start: string;
24
+ end: string;
25
+ };
26
+ generated_at: string;
27
+ measured: {
28
+ teams: [];
29
+ summary: {
30
+ pr_count: number;
31
+ revert_count: number;
32
+ automatable_count: number;
33
+ examined_count: number;
34
+ tokens_total: 0;
35
+ };
36
+ spend: {
37
+ source: "pr_estimate";
38
+ figure?: {
39
+ low_cents: number;
40
+ high_cents: number;
41
+ };
42
+ ai_prs_by_tool?: Array<{
43
+ tool: string;
44
+ count: number;
45
+ }>;
46
+ };
47
+ };
48
+ estimate: {
49
+ name: string;
50
+ value: number;
51
+ unit: string;
52
+ method_url: string;
53
+ };
54
+ /**
55
+ * Optional, added after org-scan-report/1 shipped; absent whenever it cannot be computed (no spend,
56
+ * no merged PR in the period, an incomplete scan, an empty bucket). See cost-per-pr.ts.
57
+ * cost_per_merged_pr average cost of one merged PR in the period
58
+ * cost_by_pr_size { small | medium | large }: average cost of ONE PR of that size, per-bucket
59
+ * keys absent when the bucket is empty. small < 50 lines changed, medium 50..500, large > 500.
60
+ * A figure is { label: "estimate", spend_source, pr_scope, pr_count } (+ spend_scope: "whole_org" on
61
+ * anthropic_usage) plus { value_cents } (anthropic_usage) or { low_cents, high_cents } (modelled_range). Integer cents.
62
+ * pr_scope is "ai_assisted" for modelled_range (spend and size buckets split across AI-assisted PRs only;
63
+ * absent if none) and "all_merged" otherwise; pr_count counts PRs in that scope.
64
+ * Always attached when present. The API's report validator (mnemom-api src/org-scan/report-schema.ts) is
65
+ * closed, so an API that does not yet list these keys answers 400 invalid_report; sendReportEmail then
66
+ * retries once without them so the report email still goes out.
67
+ */
68
+ cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
69
+ cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
70
+ /**
71
+ * The per-repo counts, by name (MNE-9634). Always sent, at every share level: the emailed report is the full
72
+ * report. Optional on the wire so an API that predates it can be retried without it. Capped at 500 repos
73
+ * (busiest first), valid names only, each name once.
74
+ */
75
+ by_repo?: Array<{
76
+ repo: string;
77
+ pr_count: number;
78
+ revert_count: number;
79
+ automatable_count: number;
80
+ examined_count: number;
81
+ }>;
82
+ }
83
+ export interface ReportEmailBody {
84
+ report: EmailReport;
85
+ share_level: ShareLevel;
86
+ coverage: {
87
+ complete: boolean;
88
+ };
89
+ /** The extra recipients the runner added. Absent when none. */
90
+ emails?: string[];
91
+ /** Only set on the retry to an API that predates `emails`: it reads one extra address under this name. */
92
+ cto_email?: string;
93
+ }
94
+ /**
95
+ * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
96
+ * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
97
+ * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
98
+ * itself would let that line leave the machine (`sendSpendRange`).
99
+ */
100
+ export declare function buildEmailReport(result: ScanResult, o: {
101
+ orgId: string;
102
+ now: () => number;
103
+ sendSpendRange: boolean;
104
+ scanId?: string;
105
+ /** The requested scan period (the one the spend covers), not the examined window. */
106
+ period?: {
107
+ start: string;
108
+ end: string;
109
+ };
110
+ /** The one spend source the cost figures are split from; null/absent leaves them out. */
111
+ costSpend?: CostSpend | null;
112
+ }): EmailReport;
113
+ export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, extraEmails?: string[]): ReportEmailBody;
114
+ /**
115
+ * The one spend source the cost-per-PR figures are split from, given the spend line selected (null when
116
+ * the spend tiers are off) and the measured Anthropic usage in dollars, if any.
117
+ */
118
+ export declare function costSpendFor(result: ScanResult, spend: SpendSelection | null, measuredUsd: number | undefined): CostSpend | null;
119
+ /** May the tier 3 range leave the machine in the report? Only when it is the tier shown (or tiers are off). */
120
+ export declare const sendsSpendRange: (spend: SpendSelection | null) => boolean;
@@ -0,0 +1,119 @@
1
+ // GENERATED from shared/org-scan-core/src/report-build.ts by cli/scripts/sync-org-scan-core.mjs; do not edit.
2
+ /**
3
+ * The org-scan report (org-scan-report/1) built from a scan result: the exact body the report email
4
+ * carries. Shared by the CLI (`POST /v1/orgs/{org_id}/scan/report-email`) and the Mnemom-side run
5
+ * (MNE-9752), so both send the same numbers. Pure and in memory: no network, no Node or Worker API.
6
+ *
7
+ * The body is exactly the route's contract and nothing else: `report` (team totals, counts, and the
8
+ * per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional `emails`. No author,
9
+ * title, branch or body text, no credential.
10
+ */
11
+ import { buildCostPerPr, modelledSpend } from "./cost-per-pr.js";
12
+ export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
13
+ export const ESTIMATE_NAME = "automatable_work_share";
14
+ export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
15
+ const cents = (usd) => Math.max(0, Math.round(usd * 100));
16
+ /**
17
+ * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
18
+ * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
19
+ * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
20
+ * itself would let that line leave the machine (`sendSpendRange`).
21
+ */
22
+ export function buildEmailReport(result, o) {
23
+ const { totals, coverage } = result;
24
+ const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
25
+ const scope = coverage.complete
26
+ ? "every merged pull request in the period"
27
+ : "a capped sample (not every merged pull request in the period)";
28
+ const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
29
+ `that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
30
+ const byTool = result.spend.kind === "range"
31
+ ? Object.entries(result.spend.byTool)
32
+ .filter(([, n]) => typeof n === "number" && n > 0)
33
+ .map(([tool, count]) => ({ tool, count: count }))
34
+ : [];
35
+ // Only a scan that read every merged PR in the period gives an average that means anything.
36
+ const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
37
+ const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
38
+ const byRepo = repoRows(result);
39
+ return {
40
+ schema_version: REPORT_SCHEMA_VERSION,
41
+ org_id: o.orgId,
42
+ scan_id: o.scanId ?? crypto.randomUUID(),
43
+ // The span the scan actually examined (coverage), never the requested period: with no --period
44
+ // that defaults to today..today, which is not what was read.
45
+ period: { start: coverage.windowStart, end: coverage.windowEnd },
46
+ generated_at: new Date(o.now()).toISOString(),
47
+ measured: {
48
+ teams: [],
49
+ summary: {
50
+ pr_count: totals.prs,
51
+ revert_count: totals.reverts,
52
+ automatable_count: totals.ciGreen,
53
+ examined_count: totals.examined,
54
+ tokens_total: 0,
55
+ },
56
+ spend: {
57
+ source: "pr_estimate",
58
+ ...(o.sendSpendRange && result.spend.kind === "range"
59
+ ? {
60
+ figure: {
61
+ low_cents: cents(result.spend.lowUsd),
62
+ high_cents: cents(result.spend.highUsd),
63
+ },
64
+ }
65
+ : {}),
66
+ ...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
67
+ },
68
+ },
69
+ estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
70
+ ...cost,
71
+ ...(byRepo.length > 0 ? { by_repo: byRepo } : {}),
72
+ };
73
+ }
74
+ const MAX_REPOS = 500;
75
+ const REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
76
+ /** The per-repo rows for the report: valid names only, each once (any case), busiest first, at most 500. */
77
+ function repoRows(result) {
78
+ const seen = new Set();
79
+ return [...result.byRepo]
80
+ .sort((a, b) => b.prs - a.prs || a.repo.localeCompare(b.repo))
81
+ .filter((r) => {
82
+ const k = r.repo.toLowerCase();
83
+ if (!REPO_NAME.test(r.repo) || seen.has(k))
84
+ return false;
85
+ seen.add(k);
86
+ return true;
87
+ })
88
+ .slice(0, MAX_REPOS)
89
+ .map((r) => ({
90
+ repo: r.repo,
91
+ pr_count: r.prs,
92
+ revert_count: r.reverts,
93
+ automatable_count: r.ciGreen,
94
+ examined_count: r.examined,
95
+ }));
96
+ }
97
+ export function buildEmailBody(report, level, complete, extraEmails = []) {
98
+ return {
99
+ report,
100
+ share_level: level,
101
+ coverage: { complete },
102
+ ...(extraEmails.length > 0 ? { emails: extraEmails } : {}),
103
+ };
104
+ }
105
+ /**
106
+ * The one spend source the cost-per-PR figures are split from, given the spend line selected (null when
107
+ * the spend tiers are off) and the measured Anthropic usage in dollars, if any.
108
+ */
109
+ export function costSpendFor(result, spend, measuredUsd) {
110
+ if (spend === null)
111
+ return modelledSpend(result.spend);
112
+ if (spend.tier === 1)
113
+ return typeof measuredUsd === "number" ? { source: "anthropic_usage", usd: measuredUsd } : null;
114
+ if (spend.tier === 2)
115
+ return null;
116
+ return spend.tier === 3 ? modelledSpend(result.spend) : null;
117
+ }
118
+ /** May the tier 3 range leave the machine in the report? Only when it is the tier shown (or tiers are off). */
119
+ export const sendsSpendRange = (spend) => spend ? spend.shareable && spend.tier === 3 : true;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Capped scan core, shared by the CLI and the Mnemom-side run (MNE-9752). 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 { PrSize } from "./cost-per-pr.js";
11
+ import { type SpendEstimate } from "./spend/spend-estimate.js";
12
+ export interface ScanInput {
13
+ repos: string[];
14
+ /** Inclusive dates, YYYY-MM-DD. */
15
+ period: {
16
+ start: string;
17
+ end: string;
18
+ };
19
+ }
20
+ export interface ScanDeps {
21
+ credential: GitHubCredential;
22
+ prs?: PrSource;
23
+ fetchImpl?: typeof fetch;
24
+ now?: () => number;
25
+ caps?: Partial<ScanCaps>;
26
+ /** The exact read-only permission set the credential must hold. Defaults to the CLI's set. */
27
+ permissions?: Readonly<Record<string, "read">>;
28
+ /** Aborts every GitHub read (the caller's run deadline). */
29
+ signal?: AbortSignal;
30
+ /** user-agent for the GitHub reads; defaults to the CLI's. */
31
+ userAgent?: string;
32
+ }
33
+ export interface Coverage {
34
+ complete: boolean;
35
+ stoppedBy: StopReason | null;
36
+ prsExamined: number;
37
+ prsSkippedLarge: number;
38
+ prsUnreadable: number;
39
+ reposRead: number;
40
+ reposTotal: number;
41
+ reads: number;
42
+ windowStart: string;
43
+ windowEnd: string;
44
+ /** Set only when the window held more merged PRs than the quota and an even sample was taken. */
45
+ windowSampledFrom?: number;
46
+ }
47
+ export interface RepoTotals {
48
+ repo: string;
49
+ prs: number;
50
+ reverts: number;
51
+ ciGreen: number;
52
+ /** PRs read and counted (not reverts, not skipped); the per-repo base of the CI-green share. */
53
+ examined: number;
54
+ }
55
+ export interface ScanResult {
56
+ coverage: Coverage;
57
+ totals: {
58
+ prs: number;
59
+ reverts: number;
60
+ ciGreen: number;
61
+ examined: number;
62
+ };
63
+ byRepo: RepoTotals[];
64
+ spend: SpendEstimate;
65
+ /** Every merged PR read in full (reverts and unreadable/oversize PRs excluded): merge day and size. In memory only. */
66
+ prSizes: PrSize[];
67
+ }
68
+ export type ScanOutcome = {
69
+ status: "done";
70
+ result: ScanResult;
71
+ } | {
72
+ status: "withheld";
73
+ reason: FailureReason;
74
+ };
75
+ export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;