@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
@@ -1,93 +1,10 @@
1
- import { type CostPerPr, type CostSpend } from "./cost-per-pr.js";
2
- import type { ScanResult } from "./scan.js";
3
- import type { ShareLevel } from "./share.js";
4
- export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
5
- export declare const ESTIMATE_NAME = "automatable_work_share";
6
- export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
7
- export interface EmailReport {
8
- schema_version: typeof REPORT_SCHEMA_VERSION;
9
- org_id: string;
10
- scan_id: string;
11
- period: {
12
- start: string;
13
- end: string;
14
- };
15
- generated_at: string;
16
- measured: {
17
- teams: [];
18
- summary: {
19
- pr_count: number;
20
- revert_count: number;
21
- automatable_count: number;
22
- examined_count: number;
23
- tokens_total: 0;
24
- };
25
- spend: {
26
- source: "pr_estimate";
27
- figure?: {
28
- low_cents: number;
29
- high_cents: number;
30
- };
31
- ai_prs_by_tool?: Array<{
32
- tool: string;
33
- count: number;
34
- }>;
35
- };
36
- };
37
- estimate: {
38
- name: string;
39
- value: number;
40
- unit: string;
41
- method_url: string;
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"];
59
- }
60
- export interface ReportEmailBody {
61
- report: EmailReport;
62
- share_level: ShareLevel;
63
- coverage: {
64
- complete: boolean;
65
- };
66
- cto_email?: string;
67
- }
68
- /**
69
- * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
70
- * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
71
- * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
72
- * itself would let that line leave the machine (`sendSpendRange`).
73
- */
74
- export declare function buildEmailReport(result: ScanResult, o: {
75
- orgId: string;
76
- now: () => number;
77
- sendSpendRange: boolean;
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;
86
- }): EmailReport;
87
- export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
1
+ import type { ReportEmailBody } from "../../vendor/shared/org-scan-core/report-build.js";
2
+ export { REPORT_SCHEMA_VERSION, ESTIMATE_NAME, METHOD_URL, buildEmailReport, buildEmailBody, type EmailReport, type ReportEmailBody, } from "../../vendor/shared/org-scan-core/report-build.js";
88
3
  /** Closed set of failure names; each has fixed text. */
89
- export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "network_error" | "unexpected_response";
90
- export declare const failureMessage: (f: EmailFailure) => string;
4
+ export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "emails_rejected" | "email_domain_not_allowed" | "admins_not_found" | "scan_limit_reached" | "network_error" | "unexpected_response";
5
+ /** How long to wait, in plain words (rounded up to whole seconds, minutes or hours). */
6
+ export declare function retryWait(ms: number): string;
7
+ export declare const failureMessage: (f: EmailFailure, retryAfterMs?: number) => string;
91
8
  export declare const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
92
9
  export declare const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
93
10
  export declare class ReportEmailError extends Error {
@@ -97,6 +14,8 @@ export declare class ReportEmailError extends Error {
97
14
  export type EmailOutcome = {
98
15
  status: "sent";
99
16
  recipients: number;
17
+ adminsMissed?: number;
18
+ limited?: true;
100
19
  } | {
101
20
  status: "dry_run";
102
21
  } | {
@@ -104,6 +23,7 @@ export type EmailOutcome = {
104
23
  } | {
105
24
  status: "failed";
106
25
  reason: EmailFailure;
26
+ retryAfterMs?: number;
107
27
  };
108
28
  export interface ReportEmailDeps {
109
29
  post?: (endpoint: string, body: ReportEmailBody) => Promise<unknown>;
@@ -1,87 +1,20 @@
1
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.
2
+ * The report email: after every successful scan the full report, with the counts for each repo, is
3
+ * emailed to the person who ran it, every Org Admin of the org and anyone the runner added (MNE-9634),
4
+ * with no opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`.
5
+ * The server resolves the runner's address from the signed-in identity and the Org Admins from the org,
6
+ * so the request carries neither; `emails` holds only the extra addresses the runner added.
6
7
  *
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.
8
+ * The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals,
9
+ * counts, and the per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional
10
+ * `emails`. No author, title, branch or body text, no credential.
10
11
  *
11
12
  * Every failure has a fixed name and fixed text. The server's body, the thrown value and the
12
13
  * credential never reach the terminal.
13
14
  */
14
- import { randomUUID } from "node:crypto";
15
15
  import { MnemomApiError, postApi } from "../api.js";
16
- import { buildCostPerPr } from "./cost-per-pr.js";
17
- export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
18
- export const ESTIMATE_NAME = "automatable_work_share";
19
- export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
20
- const cents = (usd) => Math.max(0, Math.round(usd * 100));
21
- /**
22
- * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
23
- * Anthropic usage, which the local scan does not carry). The estimate is the share of examined merged
24
- * PRs that finished with every CI check passing. `figure` (tier 3 range) is included only when the CLI
25
- * itself would let that line leave the machine (`sendSpendRange`).
26
- */
27
- export function buildEmailReport(result, o) {
28
- const { totals, coverage } = result;
29
- const value = totals.examined > 0 ? Math.round((totals.ciGreen / totals.examined) * 1000) / 10 : 0;
30
- const scope = coverage.complete
31
- ? "every merged pull request in the period"
32
- : "a capped sample (not every merged pull request in the period)";
33
- const unit = `percent of merged pull requests examined (${totals.examined} of ${totals.prs}, ${scope}) ` +
34
- `that finished with all CI checks passing; an estimate from pull request metadata, not a measured share`;
35
- const byTool = result.spend.kind === "range"
36
- ? Object.entries(result.spend.byTool)
37
- .filter(([, n]) => typeof n === "number" && n > 0)
38
- .map(([tool, count]) => ({ tool, count: count }))
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);
43
- return {
44
- schema_version: REPORT_SCHEMA_VERSION,
45
- org_id: o.orgId,
46
- scan_id: o.scanId ?? randomUUID(),
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 },
50
- generated_at: new Date(o.now()).toISOString(),
51
- measured: {
52
- teams: [],
53
- summary: {
54
- pr_count: totals.prs,
55
- revert_count: totals.reverts,
56
- automatable_count: totals.ciGreen,
57
- examined_count: totals.examined,
58
- tokens_total: 0,
59
- },
60
- spend: {
61
- source: "pr_estimate",
62
- ...(o.sendSpendRange && result.spend.kind === "range"
63
- ? {
64
- figure: {
65
- low_cents: cents(result.spend.lowUsd),
66
- high_cents: cents(result.spend.highUsd),
67
- },
68
- }
69
- : {}),
70
- ...(o.sendSpendRange && byTool.length > 0 ? { ai_prs_by_tool: byTool } : {}),
71
- },
72
- },
73
- estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
74
- ...cost,
75
- };
76
- }
77
- export function buildEmailBody(report, level, complete, ctoEmail) {
78
- return {
79
- report,
80
- share_level: level,
81
- coverage: { complete },
82
- ...(ctoEmail !== undefined ? { cto_email: ctoEmail } : {}),
83
- };
84
- }
16
+ // The report body is built by the shared core (MNE-9752), so the Mnemom-side run sends the same report.
17
+ export { REPORT_SCHEMA_VERSION, ESTIMATE_NAME, METHOD_URL, buildEmailReport, buildEmailBody, } from "../../vendor/shared/org-scan-core/report-build.js";
85
18
  const FAILURE_TEXT = {
86
19
  presend_check_failed: "the report failed Mnemom's pre-send checks, so no email was sent.",
87
20
  email_not_sent: "Mnemom could not send the email.",
@@ -89,11 +22,30 @@ const FAILURE_TEXT = {
89
22
  not_a_member: "your account is not a member of the org passed to --org.",
90
23
  report_rejected: "Mnemom rejected the report as malformed.",
91
24
  no_recipients: "the server accepted the report but reported zero recipients.",
92
- runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the CTO. Re-run the scan, and check that your account has a valid email address.",
25
+ runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the others. Re-run the scan, and check that your account has a valid email address.",
26
+ emails_rejected: "an address you added was not accepted. Add up to 10 valid email addresses.",
27
+ email_domain_not_allowed: "an address you added is not on your organization's email domain. Add addresses on your own domain or mnemom.ai.",
28
+ admins_not_found: "Mnemom could not look up your org admins, so no email was sent. Try again in a moment.",
29
+ scan_limit_reached: "the scan limit was reached, so no email was sent.",
93
30
  network_error: "Mnemom could not be reached.",
94
31
  unexpected_response: "Mnemom answered in an unexpected way.",
95
32
  };
96
- export const failureMessage = (f) => `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
33
+ /** How long to wait, in plain words (rounded up to whole seconds, minutes or hours). */
34
+ export function retryWait(ms) {
35
+ const s = Math.max(1, Math.ceil(ms / 1000));
36
+ if (s < 60)
37
+ return `${s} second${s === 1 ? "" : "s"}`;
38
+ const m = Math.ceil(s / 60);
39
+ if (m < 120)
40
+ return `${m} minute${m === 1 ? "" : "s"}`;
41
+ const h = Math.ceil(m / 60);
42
+ return `${h} hour${h === 1 ? "" : "s"}`;
43
+ }
44
+ export const failureMessage = (f, retryAfterMs) => f === "scan_limit_reached"
45
+ ? `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]} ${retryAfterMs !== undefined
46
+ ? `Try again in about ${retryWait(retryAfterMs)}.`
47
+ : "Try again later."}`
48
+ : `Your report was NOT emailed: ${f}. ${FAILURE_TEXT[f]}`;
97
49
  export const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
98
50
  export const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
99
51
  export class ReportEmailError extends Error {
@@ -110,6 +62,8 @@ function classifyStatus(status) {
110
62
  return "not_signed_in";
111
63
  if (status === 403)
112
64
  return "not_a_member";
65
+ if (status === 429)
66
+ return "scan_limit_reached";
113
67
  if (status === 422)
114
68
  return "presend_check_failed";
115
69
  if (status === 502)
@@ -118,11 +72,27 @@ function classifyStatus(status) {
118
72
  return "report_rejected";
119
73
  return "unexpected_response";
120
74
  }
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 }
75
+ /**
76
+ * The body an API that predates this contract understands: no cost figures, no per-repo rows, and the
77
+ * share level under its old name (`analytics` was `summary`; the meaning is the same, never upgraded).
78
+ * The same object when nothing needs changing.
79
+ */
80
+ function legacyBody(body) {
81
+ const { cost_per_merged_pr: _a, cost_by_pr_size: _b, by_repo: _c, ...rest } = body.report;
82
+ const changed = "cost_per_merged_pr" in body.report ||
83
+ "cost_by_pr_size" in body.report ||
84
+ "by_repo" in body.report ||
85
+ body.share_level === "analytics" ||
86
+ (body.emails?.length ?? 0) > 0;
87
+ const { emails, ...top } = body;
88
+ const extra = emails?.[0];
89
+ return changed
90
+ ? {
91
+ ...top,
92
+ ...(extra ? { cto_email: extra } : {}),
93
+ report: rest,
94
+ share_level: body.share_level === "analytics" ? "summary" : body.share_level,
95
+ }
126
96
  : body;
127
97
  }
128
98
  /** Never throws. The thrown value, the response body and the credential are never surfaced. */
@@ -135,18 +105,29 @@ export async function sendReportEmail(orgId, body, deps = {}) {
135
105
  catch (e) {
136
106
  if (e instanceof MnemomApiError && e.effectiveStatus === 404)
137
107
  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.
108
+ // An API that predates the cost figures, the per-repo rows or the analytics name rejects them with a 400. The email must still
109
+ // go out, so retry once in the old shape; any other failure is classified as below.
140
110
  if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
141
- const bare = withoutCostFigures(body);
142
- if (bare !== body)
143
- return sendReportEmail(orgId, bare, deps);
111
+ const bare = legacyBody(body);
112
+ if (bare !== body && !/email|admin/i.test(e.code ?? "")) {
113
+ const r = await sendReportEmail(orgId, bare, deps);
114
+ return r.status === "sent" ? { ...r, limited: true } : r;
115
+ }
144
116
  }
145
117
  if (e instanceof MnemomApiError) {
146
118
  // A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
119
+ if (e.code === "email_domain_not_allowed")
120
+ return { status: "failed", reason: "email_domain_not_allowed" };
121
+ if (/emails/i.test(e.code ?? ""))
122
+ return { status: "failed", reason: "emails_rejected" };
123
+ if (e.code === "admin_lookup_failed")
124
+ return { status: "failed", reason: "admins_not_found" };
147
125
  if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
148
126
  return { status: "failed", reason: "runner_not_emailed" };
149
- return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
127
+ const reason = classifyStatus(e.effectiveStatus);
128
+ return reason === "scan_limit_reached" && e.retryAfterMs !== undefined
129
+ ? { status: "failed", reason, retryAfterMs: e.retryAfterMs }
130
+ : { status: "failed", reason };
150
131
  }
151
132
  return { status: "failed", reason: "network_error" };
152
133
  }
@@ -158,8 +139,11 @@ export async function sendReportEmail(orgId, body, deps = {}) {
158
139
  return { status: "failed", reason: "unexpected_response" };
159
140
  if (recipients === 0)
160
141
  return { status: "failed", reason: "no_recipients" };
161
- // `skipped` lists the roles ("runner", "cto") that got no copy. The runner must always get the report.
142
+ // `skipped` lists the roles ("runner", "admin", "added", "cto") that got no copy. The runner must always get the report.
162
143
  if (Array.isArray(o.skipped) && o.skipped.includes("runner"))
163
144
  return { status: "failed", reason: "runner_not_emailed" };
164
- return o.status === "dry_run" ? { status: "dry_run" } : { status: "sent", recipients };
145
+ const adminsMissed = Array.isArray(o.skipped) ? o.skipped.filter((s) => s === "admin").length : 0;
146
+ return o.status === "dry_run"
147
+ ? { status: "dry_run" }
148
+ : { status: "sent", recipients, ...(adminsMissed > 0 ? { adminsMissed } : {}) };
165
149
  }
@@ -1,67 +1 @@
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 { 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
- }
27
- export interface Coverage {
28
- complete: boolean;
29
- stoppedBy: StopReason | null;
30
- prsExamined: number;
31
- prsSkippedLarge: number;
32
- prsUnreadable: number;
33
- reposRead: number;
34
- reposTotal: number;
35
- reads: number;
36
- windowStart: string;
37
- windowEnd: string;
38
- /** Set only when the window held more merged PRs than the quota and an even sample was taken. */
39
- windowSampledFrom?: number;
40
- }
41
- export interface RepoTotals {
42
- repo: string;
43
- prs: number;
44
- reverts: number;
45
- ciGreen: number;
46
- }
47
- export interface ScanResult {
48
- coverage: Coverage;
49
- totals: {
50
- prs: number;
51
- reverts: number;
52
- ciGreen: number;
53
- examined: number;
54
- };
55
- byRepo: RepoTotals[];
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[];
59
- }
60
- export type ScanOutcome = {
61
- status: "done";
62
- result: ScanResult;
63
- } | {
64
- status: "withheld";
65
- reason: FailureReason;
66
- };
67
- export declare function runLocalScan(deps: ScanDeps, input: ScanInput): Promise<ScanOutcome>;
1
+ export * from "../../vendor/shared/org-scan-core/scan.js";
@@ -1,194 +1,2 @@
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
- const prSizes = [];
113
- let skippedLarge = 0;
114
- let unreadable = 0;
115
- for (const { repo, item } of chosen) {
116
- // A revert PR is never a candidate: counted from the listing, never read.
117
- if (item.reverted) {
118
- totals.prs += 1;
119
- totals.reverts += 1;
120
- bucket(repo).prs += 1;
121
- bucket(repo).reverts += 1;
122
- continue;
123
- }
124
- if (stop(budget.block(2)))
125
- break;
126
- budget.charge(1);
127
- const res = await prs.readPr(repo, item, budget.room() + 1);
128
- budget.charge((Number.isFinite(res.reads) && res.reads >= 1 ? res.reads : 1) - 1);
129
- const overrun = budget.overrun();
130
- if (overrun) {
131
- stop(overrun);
132
- break;
133
- }
134
- const rec = res.pr;
135
- if (!rec) {
136
- unreadable += 1;
137
- continue;
138
- }
139
- if (rec.additions + rec.deletions > caps.maxPrSizeLines) {
140
- skippedLarge += 1;
141
- continue;
142
- }
143
- totals.prs += 1;
144
- totals.examined += 1;
145
- bucket(repo).prs += 1;
146
- if (rec.ci === "success") {
147
- totals.ciGreen += 1;
148
- bucket(repo).ciGreen += 1;
149
- }
150
- prSizes.push({
151
- mergedDay: item.mergedDay,
152
- lines: rec.additions + rec.deletions,
153
- aiAssisted: (rec.tools?.length ?? 0) > 0,
154
- });
155
- spendInputs.push({
156
- additions: rec.additions,
157
- deletions: rec.deletions,
158
- markText: "",
159
- tools: rec.tools,
160
- });
161
- }
162
- const coverage = {
163
- complete: stoppedBy === null &&
164
- !listingCut &&
165
- unreadable === 0 &&
166
- reposRead === input.repos.length &&
167
- !sampled,
168
- stoppedBy: stoppedBy ?? (listingCut ? "read-cap" : null),
169
- prsExamined: totals.examined,
170
- prsSkippedLarge: skippedLarge,
171
- prsUnreadable: unreadable,
172
- reposRead,
173
- reposTotal: input.repos.length,
174
- reads: budget.reads,
175
- windowStart,
176
- windowEnd: endDay,
177
- ...(sampled ? { windowSampledFrom: inWindow.length } : {}),
178
- };
179
- return {
180
- status: "done",
181
- result: {
182
- coverage,
183
- totals,
184
- byRepo: [...byRepo.values()],
185
- spend: estimateSpend(spendInputs),
186
- prSizes,
187
- },
188
- };
189
- }
190
- catch (e) {
191
- // Reads only; the raw error is dropped and a fixed reason returned.
192
- return { status: "withheld", reason: classifyFailure(e) };
193
- }
194
- }
1
+ // Moved to shared/org-scan-core (MNE-9752); vendored at cli/src/vendor/shared/org-scan-core. Re-exported so CLI imports stay put.
2
+ export * from "../../vendor/shared/org-scan-core/scan.js";
@@ -1,20 +1,23 @@
1
1
  import type { ScanResult } from "./scan.js";
2
- export type ShareLevel = "summary" | "full" | "none";
2
+ import type { ShareLevel } from "../../vendor/shared/org-scan-core/report-build.js";
3
+ /** Defined in the shared core (MNE-9752); re-exported for the CLI's callers. */
4
+ export type { ShareLevel };
3
5
  /** The level preselected at the prompt (Enter picks it). */
4
6
  export declare const DEFAULT_SHARE_LEVEL: ShareLevel;
5
7
  export declare const SHARE_OPTIONS: ReadonlyArray<{
6
8
  level: ShareLevel;
7
9
  label: string;
8
10
  }>;
9
- /** Shown just above the share prompt, so pressing Enter is an informed choice. */
11
+ /** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
10
12
  export declare const SHARE_DISCLOSURE: string;
11
13
  /** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
12
14
  export declare function promptShareLevel(): Promise<ShareLevel>;
15
+ /** `summary` is the old name for `analytics`; the meaning is unchanged. */
13
16
  export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
14
- /** Who receives the report. Sent only with a non-"none" share level. */
17
+ /** Who receives the report. The runner comes from the Mnemom login; the server resolves the Org Admins. */
15
18
  export interface ReportRecipients {
16
- runner_email: string;
17
- cto_email: string;
19
+ runner_email?: string;
20
+ extra_emails: string[];
18
21
  }
19
22
  export interface SharePayload {
20
23
  recipients: ReportRecipients;
@@ -30,6 +33,7 @@ export interface SharePayload {
30
33
  coverage_complete: boolean;
31
34
  /** Absent when no spend line may be shown. */
32
35
  spend_line?: string;
36
+ /** Present at "full" only. */
33
37
  repos?: Array<{
34
38
  repo: string;
35
39
  prs: number;
@@ -37,6 +41,6 @@ export interface SharePayload {
37
41
  ci_green: number;
38
42
  }>;
39
43
  }
40
- /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the two report recipients. */
44
+ /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the report recipients. */
41
45
  export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
42
46
  export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;