@mnemom/mnemom 0.17.38-next.1 → 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 (66) hide show
  1. package/dist/commands/agent-run.d.ts +10 -2
  2. package/dist/commands/agent-run.js +12 -6
  3. package/dist/commands/scan.d.ts +1 -1
  4. package/dist/commands/scan.js +7 -14
  5. package/dist/lib/runner/executor-client.d.ts +6 -0
  6. package/dist/lib/runner/executor-client.js +6 -0
  7. package/dist/lib/runner/job.d.ts +12 -3
  8. package/dist/lib/runner/job.js +30 -2
  9. package/dist/lib/scan/caps.d.ts +1 -35
  10. package/dist/lib/scan/caps.js +2 -67
  11. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  12. package/dist/lib/scan/cost-per-pr.js +2 -101
  13. package/dist/lib/scan/credential.d.ts +2 -26
  14. package/dist/lib/scan/credential.js +5 -58
  15. package/dist/lib/scan/failure.d.ts +1 -10
  16. package/dist/lib/scan/failure.js +2 -43
  17. package/dist/lib/scan/pr-source.d.ts +1 -57
  18. package/dist/lib/scan/pr-source.js +2 -132
  19. package/dist/lib/scan/report-email.d.ts +7 -104
  20. package/dist/lib/scan/report-email.js +25 -97
  21. package/dist/lib/scan/scan.d.ts +1 -69
  22. package/dist/lib/scan/scan.js +2 -195
  23. package/dist/lib/scan/share.d.ts +3 -1
  24. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  25. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  26. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  27. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  28. package/dist/lib/scan/spend/prices.d.ts +1 -33
  29. package/dist/lib/scan/spend/prices.js +2 -57
  30. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  31. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  32. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  33. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  34. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  35. package/dist/lib/scan/spend/token-rate.js +2 -54
  36. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  37. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  38. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  39. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  40. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  41. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  42. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  43. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  44. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  45. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  46. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  47. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  48. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  49. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  50. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  51. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  52. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  53. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  54. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  55. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  56. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  57. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  58. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  59. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  60. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  61. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  62. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  63. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  64. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  65. package/package.json +1 -1
  66. /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
@@ -8,7 +8,9 @@
8
8
  * errors exit 2 with no outcome line; every outcome line goes to stdout, every log to stderr.
9
9
  *
10
10
  * The GitHub token (GH_TOKEN, GITHUB_TOKEN, else `gh auth token`) is the runner's own: it reaches
11
- * git only through GIT_CONFIG_* env (never argv) and the phases never see it (phaseEnv).
11
+ * git only through GIT_CONFIG_* env (never argv) and the phases never see it (phaseEnv). Under
12
+ * --oidc the PR is pushed and opened with the job's PR token instead (ruling 34.34): the api's
13
+ * Record App token for the seal's repo, fetched with the lease key, carried the same way.
12
14
  *
13
15
  * So is the Anthropic key (ruling 34.30.1): the phases' model calls are billed to the runner's own
14
16
  * ANTHROPIC_API_KEY, which only the model proxy holds and sets on each upstream request. A runner
@@ -35,7 +37,9 @@ export interface RunDeps {
35
37
  stop: AbortSignal;
36
38
  /** The text of this host's executor-state.json, or null when there is none. */
37
39
  readExecutorState?(): Promise<string | null>;
38
- jobDeps(api: RunnerApi, executorId: string | null, github: GithubBases, modelKey: string): JobDeps;
40
+ jobDeps(api: RunnerApi, executorId: string | null, github: GithubBases, modelKey: string,
41
+ /** True under --oidc: the job opens its PR with the api's PR token (ruling 34.34). */
42
+ prTokenFromLease: boolean): JobDeps;
39
43
  }
40
44
  /** The refusal of a runner with no ANTHROPIC_API_KEY (34.30.1); a config error, exit 2. */
41
45
  export declare const RUNNER_MODEL_KEY_MISSING = "runner_model_key_missing";
@@ -78,6 +82,10 @@ export declare function liveJobDeps(api: RunnerApi, opts: {
78
82
  executorId: string | null;
79
83
  github: GithubBases;
80
84
  modelKey: string;
85
+ /** The credential is the --oidc exchange: the PR uses the job's PR token (ruling 34.34). */
86
+ prTokenFromLease?: boolean;
87
+ /** The git runner for an env; tests inject one to see which credentials a push carries. */
88
+ makeGit?: (env: NodeJS.ProcessEnv) => Git;
81
89
  }): JobDeps;
82
90
  /** The process wiring: signals, stdout and stderr. Resolves the exit code. */
83
91
  export declare function runAgentRun(flags: RunFlags): Promise<number>;
@@ -8,7 +8,9 @@
8
8
  * errors exit 2 with no outcome line; every outcome line goes to stdout, every log to stderr.
9
9
  *
10
10
  * The GitHub token (GH_TOKEN, GITHUB_TOKEN, else `gh auth token`) is the runner's own: it reaches
11
- * git only through GIT_CONFIG_* env (never argv) and the phases never see it (phaseEnv).
11
+ * git only through GIT_CONFIG_* env (never argv) and the phases never see it (phaseEnv). Under
12
+ * --oidc the PR is pushed and opened with the job's PR token instead (ruling 34.34): the api's
13
+ * Record App token for the seal's repo, fetched with the lease key, carried the same way.
12
14
  *
13
15
  * So is the Anthropic key (ruling 34.30.1): the phases' model calls are billed to the runner's own
14
16
  * ANTHROPIC_API_KEY, which only the model proxy holds and sets on each upstream request. A runner
@@ -91,7 +93,7 @@ export async function agentRunCommand(flags, deps) {
91
93
  }
92
94
  const api = runnerApi({ apiUrl: deps.apiUrl, executorKey, fetch: deps.fetch });
93
95
  const loop = { failMode: plan.failMode, stop: deps.stop, emit: deps.emit };
94
- const job = deps.jobDeps(api, executorId, gh.bases, modelKey);
96
+ const job = deps.jobDeps(api, executorId, gh.bases, modelKey, plan.credential.kind === "oidc");
95
97
  return plan.form === "once" ? runOnce(job, loop) : runWatch(job, loop);
96
98
  }
97
99
  // ── GitHub and git, for the live driver ──────────────────────────────────────
@@ -259,7 +261,10 @@ export function runPhaseProcess(inv, ws, modelUrl, signal, parentEnv = process.e
259
261
  export function liveJobDeps(api, opts) {
260
262
  const gh = opts.github;
261
263
  const token = resolveGithubToken(opts.env);
262
- const git = liveGit(gitAuthEnv(opts.env, token, gh.web));
264
+ const makeGit = opts.makeGit ?? liveGit;
265
+ const git = makeGit(gitAuthEnv(opts.env, token, gh.web));
266
+ // The PR token's git: a fresh env, so its header replaces the runner token's, never adds to it.
267
+ const gitFor = (t) => t === null ? git : makeGit(gitAuthEnv(opts.env, t, gh.web));
263
268
  return {
264
269
  api,
265
270
  executorId: opts.executorId,
@@ -272,11 +277,12 @@ export function liveJobDeps(api, opts) {
272
277
  startProxy: (state) => startModelProxy(`${getGatewayUrl()}/anthropic`, state, opts.modelKey),
273
278
  prepareWorkspace: (repo, sessionId) => prepareWorkspace(git, `${gh.web}/${repo}.git`, sessionId),
274
279
  runPhase: (inv, ws, modelUrl, signal) => runPhaseProcess(inv, ws, modelUrl, signal, opts.env),
275
- openPr: (repo, ws, title, body) => openPr(git, opts.fetch, token, repo, ws, title, body, gh.api),
280
+ openPr: (repo, ws, title, body, prToken) => openPr(gitFor(prToken), opts.fetch, prToken ?? token, repo, ws, title, body, gh.api),
276
281
  commitsAhead: (ws) => commitsAhead(git, ws),
277
282
  headSha: (ws) => headSha(git, ws),
278
283
  commitBodies: (ws, since, lineageKey, repo) => commitBodies(git, ws, since, lineageKey, repo),
279
- enableAutoMerge: (prUrl) => enableAutoMerge(opts.fetch, token, prUrl, gh),
284
+ enableAutoMerge: (prUrl, prToken) => enableAutoMerge(opts.fetch, prToken ?? token, prUrl, gh),
285
+ prTokenFromLease: opts.prTokenFromLease === true,
280
286
  cleanup: cleanupWorkspace,
281
287
  };
282
288
  }
@@ -296,7 +302,7 @@ export function runAgentRun(flags) {
296
302
  err: (line) => process.stderr.write(`${line}\n`),
297
303
  stop: stop.signal,
298
304
  readExecutorState: () => readFile(executorStatePath(homedir()), "utf8").catch(() => null),
299
- jobDeps: (api, executorId, github, modelKey) => liveJobDeps(api, { fetch, env: process.env, executorId, github, modelKey }),
305
+ jobDeps: (api, executorId, github, modelKey, prTokenFromLease) => liveJobDeps(api, { fetch, env: process.env, executorId, github, modelKey, prTokenFromLease }),
300
306
  }).finally(() => {
301
307
  process.off("SIGTERM", onSignal);
302
308
  process.off("SIGINT", onSignal);
@@ -10,7 +10,7 @@ import { selectSpend } from "../lib/scan/spend/spend-tiers.js";
10
10
  import { type AnthropicUsageDeps, type MeasuredUsage } from "../lib/scan/spend/anthropic-usage.js";
11
11
  import { type WifDeps } from "../lib/scan/spend/anthropic-wif.js";
12
12
  import { type RecordDeps, type RecordOutcome } from "../lib/scan/record.js";
13
- import { type CostSpend } from "../lib/scan/cost-per-pr.js";
13
+ import type { CostSpend } from "../lib/scan/cost-per-pr.js";
14
14
  import { type ReportEmailDeps } from "../lib/scan/report-email.js";
15
15
  import { type RepoListDeps } from "../lib/scan/repo-picker.js";
16
16
  export interface ScanCommandOptions {
@@ -15,7 +15,7 @@ import { getAuthInfo } from "../lib/auth.js";
15
15
  import { listMyOrgs } from "../lib/api.js";
16
16
  import { getActiveOrg } from "../lib/cli-config.js";
17
17
  import { fetchRecordTotals, isOrgId, } from "../lib/scan/record.js";
18
- import { modelledSpend } from "../lib/scan/cost-per-pr.js";
18
+ import { costSpendFor, sendsSpendRange } from "../vendor/shared/org-scan-core/report-build.js";
19
19
  import { RECORD_NO_ORG_NOTE, renderRecordSection } from "../lib/scan/report.js";
20
20
  import { buildEmailBody, buildEmailReport, DRY_RUN_MSG, UNAVAILABLE_MSG, failureMessage, ReportEmailError, sendReportEmail, } from "../lib/scan/report-email.js";
21
21
  import { pickRepos, REPO_PICKER_NO_TERMINAL_HINT, } from "../lib/scan/repo-picker.js";
@@ -110,15 +110,8 @@ export async function resolveSelfReportedSpend(o) {
110
110
  * tiers off) the modelled range. Sources are never mixed.
111
111
  */
112
112
  export function resolveCostSpend(o, result, spend, measured) {
113
- if (spend === null)
114
- return modelledSpend(result.spend);
115
- if (spend.tier === 1) {
116
- const usd = measured?.usd ?? o.anthropicUsageUsd;
117
- return typeof usd === "number" ? { source: "anthropic_usage", usd } : null;
118
- }
119
- if (spend.tier === 2)
120
- return null;
121
- return spend.tier === 3 ? modelledSpend(result.spend) : null;
113
+ // Shared with the Mnemom-side run (MNE-9752): same spend source, same cost figures.
114
+ return costSpendFor(result, spend, measured?.usd ?? o.anthropicUsageUsd);
122
115
  }
123
116
  export const NO_ADMIN_KEY_TERMINAL_MSG = "Set ANTHROPIC_ADMIN_KEY (there is no terminal to ask on).";
124
117
  export const TIER1_FALLBACK_NOTE = "Tier 1 skipped: falling back to the next tier.";
@@ -353,21 +346,21 @@ export async function scanCommand(o) {
353
346
  * returns it so the caller exits non-zero after the local report is shown.
354
347
  */
355
348
  async function emailReport(o, org, result, level, spend, period, costSpend, extraEmails) {
356
- const fail = (f) => {
357
- console.log(failureMessage(f));
349
+ const fail = (f, retryAfterMs) => {
350
+ console.log(failureMessage(f, retryAfterMs));
358
351
  return f;
359
352
  };
360
353
  const report = buildEmailReport(result, {
361
354
  orgId: org,
362
355
  now: o.now ?? Date.now,
363
- sendSpendRange: spend ? spend.shareable && spend.tier === 3 : true,
356
+ sendSpendRange: sendsSpendRange(spend),
364
357
  period,
365
358
  costSpend,
366
359
  });
367
360
  const body = buildEmailBody(report, level, result.coverage.complete, extraEmails);
368
361
  const outcome = await sendReportEmail(org, body, o.emailDeps);
369
362
  if (outcome.status === "failed")
370
- return fail(outcome.reason);
363
+ return fail(outcome.reason, outcome.retryAfterMs);
371
364
  if (outcome.status === "unavailable") {
372
365
  // Org Scan is off by default: the gate's 404 is not a failure and must not break the scan.
373
366
  console.log(UNAVAILABLE_MSG);
@@ -34,6 +34,11 @@ export declare function errorDetails(body: unknown): Record<string, unknown>;
34
34
  export declare const JOB_READ_PATH: (sessionId: string) => string;
35
35
  /** The served bundle fetch, per claim (W-L12). */
36
36
  export declare const BUNDLE_FETCH_PATH: (sessionId: string) => string;
37
+ /**
38
+ * The job's PR token (ruling 34.34): a Record App installation token narrowed to the seal's repo,
39
+ * contents and pull_requests write, fetched with the lease key. Never cached or logged.
40
+ */
41
+ export declare const GITHUB_TOKEN_PATH: (sessionId: string) => string;
37
42
  export declare const JWKS_PATH = "/v1/.well-known/jwks.json";
38
43
  /** Record's commit index (ruling 34.05 (a)): the lease key may write the session's own commits. */
39
44
  export declare const RECORD_COMMITS_PATH: (orgId: string) => string;
@@ -65,6 +70,7 @@ export interface RunnerApi {
65
70
  fail(sessionId: string, body: Record<string, unknown>): Promise<HttpResult>;
66
71
  job(sessionId: string): Promise<HttpResult>;
67
72
  bundle(sessionId: string, leaseKey: string): Promise<HttpResult>;
73
+ githubToken(sessionId: string, leaseKey: string): Promise<HttpResult>;
68
74
  jwks(): Promise<HttpResult>;
69
75
  /** One RecordCommit/v1 body, with the lease key; the api pins it to the session's lineage. */
70
76
  recordCommit(orgId: string, body: RecordCommitBody, leaseKey: string): Promise<HttpResult>;
@@ -33,6 +33,11 @@ export function errorDetails(body) {
33
33
  export const JOB_READ_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/job`;
34
34
  /** The served bundle fetch, per claim (W-L12). */
35
35
  export const BUNDLE_FETCH_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/bundle`;
36
+ /**
37
+ * The job's PR token (ruling 34.34): a Record App installation token narrowed to the seal's repo,
38
+ * contents and pull_requests write, fetched with the lease key. Never cached or logged.
39
+ */
40
+ export const GITHUB_TOKEN_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/github-token`;
36
41
  export const JWKS_PATH = "/v1/.well-known/jwks.json";
37
42
  /** Record's commit index (ruling 34.05 (a)): the lease key may write the session's own commits. */
38
43
  export const RECORD_COMMITS_PATH = (orgId) => `/v1/orgs/${encodeURIComponent(orgId)}/record/commits`;
@@ -95,6 +100,7 @@ export function runnerApi(opts) {
95
100
  fail: (id, body) => call(routePath("exec_fail", scoped(id)), "POST", ex, body),
96
101
  job: (id) => call(JOB_READ_PATH(scoped(id)), "GET", ex),
97
102
  bundle: (id, leaseKey) => call(BUNDLE_FETCH_PATH(scoped(id)), "GET", leaseKey),
103
+ githubToken: (id, leaseKey) => call(GITHUB_TOKEN_PATH(scoped(id)), "POST", leaseKey),
98
104
  jwks: () => call(JWKS_PATH, "GET", null),
99
105
  recordCommit: (orgId, body, leaseKey) => {
100
106
  if (!ORG_ID_RE.test(orgId))
@@ -42,8 +42,11 @@ export interface JobDeps {
42
42
  prepareWorkspace(repo: string, sessionId: string): Promise<Workspace>;
43
43
  /** Run one phase's process; resolves its exit code. Rejects only when it could not start. */
44
44
  runPhase(inv: PhaseInvocation, ws: Workspace, modelUrl: string, signal: AbortSignal): Promise<number>;
45
- /** Push the job branch and open its PR; resolves the PR URL. */
46
- openPr(repo: string, ws: Workspace, title: string, body: string): Promise<string>;
45
+ /**
46
+ * Push the job branch and open its PR; resolves the PR URL. `token` is the job's PR token when
47
+ * `prTokenFromLease` is set; null means the runner's own GitHub token.
48
+ */
49
+ openPr(repo: string, ws: Workspace, title: string, body: string, token: string | null): Promise<string>;
47
50
  /** Commits on the job branch that the base does not have; 0 means there is nothing to open. */
48
51
  commitsAhead(ws: Workspace): Promise<number>;
49
52
  /** The working copy's HEAD sha before a phase runs; null only for a clone with no commit. Throws otherwise. */
@@ -53,7 +56,13 @@ export interface JobDeps {
53
56
  * first, under `lineageKey` and the job's repo. A commit that cannot be read is left out.
54
57
  */
55
58
  commitBodies(ws: Workspace, since: string | null, lineageKey: string, repo: string): Promise<RecordCommitBody[]>;
56
- enableAutoMerge(prUrl: string): Promise<void>;
59
+ enableAutoMerge(prUrl: string, token: string | null): Promise<void>;
60
+ /**
61
+ * True when the credential is the --oidc exchange: the PR is pushed and opened with the job's PR
62
+ * token from the api (ruling 34.34), never the runner's own GitHub token, which in a sealed repo's
63
+ * Action is a GITHUB_TOKEN the org forbids from opening PRs. A refused token fails the job.
64
+ */
65
+ prTokenFromLease?: boolean;
57
66
  cleanup(ws: Workspace): Promise<void>;
58
67
  }
59
68
  export interface JobOptions {
@@ -379,6 +379,7 @@ export async function runJob(sessionId, opts, deps) {
379
379
  });
380
380
  }
381
381
  if (opensPr) {
382
+ let prToken = null;
382
383
  try {
383
384
  // Checked before the push, so an empty diff never reaches GitHub's 422 (amendment 59).
384
385
  if ((await deps.commitsAhead(ws)) === 0) {
@@ -386,7 +387,13 @@ export async function runJob(sessionId, opts, deps) {
386
387
  await reportFail(deps, sessionId, claim.epoch, "runner_failed", "pr_no_changes");
387
388
  return { kind: "outcome", outcome: failedOutcome(facts, "pr_no_changes") };
388
389
  }
389
- facts.pr_url = await deps.openPr(job.repo, ws, `mnemom: ${bundle.workflow.id} job ${sessionId.slice(0, 8)}`, prBody(job));
390
+ if (deps.prTokenFromLease) {
391
+ const t = await fetchPrToken(deps, sessionId, claim.leaseKey, job.repo);
392
+ if (!t.ok)
393
+ throw new Error(t.detail);
394
+ prToken = t.token;
395
+ }
396
+ facts.pr_url = await deps.openPr(job.repo, ws, `mnemom: ${bundle.workflow.id} job ${sessionId.slice(0, 8)}`, prBody(job), prToken);
390
397
  }
391
398
  catch (e) {
392
399
  deps.log(`opening the PR failed: ${e.message}`);
@@ -395,7 +402,7 @@ export async function runJob(sessionId, opts, deps) {
395
402
  }
396
403
  if (facts.merge_strategy === "merge_on_green") {
397
404
  try {
398
- await deps.enableAutoMerge(facts.pr_url);
405
+ await deps.enableAutoMerge(facts.pr_url, prToken);
399
406
  }
400
407
  catch (e) {
401
408
  deps.log(`enabling auto-merge failed (the PR stays open): ${e.message}`);
@@ -469,6 +476,27 @@ export async function reportPhaseCommits(deps, report, p) {
469
476
  deps.log(`commit report failed: ${e.message}`);
470
477
  }
471
478
  }
479
+ /**
480
+ * The job's PR token (ruling 34.34), fetched once per claim with the lease key. It must name the
481
+ * job's repo; the detail never carries the token.
482
+ */
483
+ async function fetchPrToken(deps, sessionId, leaseKey, repo) {
484
+ const res = await deps.api.githubToken(sessionId, leaseKey);
485
+ if (res.status !== 200) {
486
+ return {
487
+ ok: false,
488
+ detail: `the PR token fetch answered ${res.code ?? res.networkError ?? res.status}`,
489
+ };
490
+ }
491
+ const b = (res.body ?? {});
492
+ if (typeof b.token !== "string" || b.token === "") {
493
+ return { ok: false, detail: "the PR token fetch answered 200 with no token" };
494
+ }
495
+ if (typeof b.repository !== "string" || b.repository.toLowerCase() !== repo.toLowerCase()) {
496
+ return { ok: false, detail: `the PR token names another repo than the job's ${repo}` };
497
+ }
498
+ return { ok: true, token: b.token };
499
+ }
472
500
  async function fetchBundle(deps, sessionId, claim) {
473
501
  const [jwksRes, res] = await Promise.all([
474
502
  deps.api.jwks(),
@@ -1,35 +1 @@
1
- /**
2
- * Coverage caps and sampling. Port of mnemom-api src/org-scan/scan/capped-scan.ts (PR #4208).
3
- *
4
- * Coverage decision (Shraddha, 2026-10-28): each scan covers the last 3 months OR the last 2,000 merged
5
- * PRs, whichever is MORE; above 2,000 in the window, an even deterministic sample of 2,000.
6
- */
7
- export declare const COVERAGE_MONTHS = 3;
8
- export interface ScanCaps {
9
- maxPrs: number;
10
- /** A PR with more additions+deletions than this is skipped, never read in full. */
11
- maxPrSizeLines: number;
12
- /** Upstream read calls. */
13
- maxReads: number;
14
- /** Wall-clock budget. */
15
- maxMillis: number;
16
- }
17
- export declare const DEFAULT_SCAN_CAPS: ScanCaps;
18
- export type StopReason = "pr-cap" | "read-cap" | "time-cap";
19
- /** The day `months` calendar months before `end` (YYYY-MM-DD, UTC), clamped to the month's last day. */
20
- export declare function monthsBefore(end: string, months: number): string;
21
- /** `k` items spread evenly across `items` (order kept): index floor(i*n/k). Deterministic; n <= k returns all. */
22
- export declare function evenSample<T>(items: readonly T[], k: number): T[];
23
- export declare function sanitiseCaps(c: Partial<ScanCaps>): ScanCaps;
24
- /** Running read budget. Every call is told how many reads it may spend (`room`). */
25
- export declare class Budget {
26
- private caps;
27
- private now;
28
- private startedAt;
29
- reads: number;
30
- constructor(caps: ScanCaps, now: () => number, startedAt: number);
31
- block(n?: number): StopReason | null;
32
- room(): number;
33
- overrun(): StopReason | null;
34
- charge(n: number): void;
35
- }
1
+ export * from "../../vendor/shared/org-scan-core/caps.js";
@@ -1,67 +1,2 @@
1
- /**
2
- * Coverage caps and sampling. Port of mnemom-api src/org-scan/scan/capped-scan.ts (PR #4208).
3
- *
4
- * Coverage decision (Shraddha, 2026-10-28): each scan covers the last 3 months OR the last 2,000 merged
5
- * PRs, whichever is MORE; above 2,000 in the window, an even deterministic sample of 2,000.
6
- */
7
- export const COVERAGE_MONTHS = 3;
8
- export const DEFAULT_SCAN_CAPS = {
9
- maxPrs: 2_000,
10
- maxPrSizeLines: 20_000,
11
- maxReads: 4_500,
12
- maxMillis: 15 * 60_000,
13
- };
14
- /** The day `months` calendar months before `end` (YYYY-MM-DD, UTC), clamped to the month's last day. */
15
- export function monthsBefore(end, months) {
16
- const [y, m, d] = end.split("-").map(Number);
17
- const target = new Date(Date.UTC(y, m - 1 - months, 1));
18
- const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
19
- target.setUTCDate(Math.min(d, last));
20
- return target.toISOString().slice(0, 10);
21
- }
22
- /** `k` items spread evenly across `items` (order kept): index floor(i*n/k). Deterministic; n <= k returns all. */
23
- export function evenSample(items, k) {
24
- const n = items.length;
25
- if (n <= k)
26
- return [...items];
27
- return Array.from({ length: k }, (_, i) => items[Math.floor((i * n) / k)]);
28
- }
29
- export function sanitiseCaps(c) {
30
- const pos = (n, d) => typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : d;
31
- return {
32
- maxPrs: pos(c.maxPrs, DEFAULT_SCAN_CAPS.maxPrs),
33
- maxPrSizeLines: pos(c.maxPrSizeLines, DEFAULT_SCAN_CAPS.maxPrSizeLines),
34
- maxReads: pos(c.maxReads, DEFAULT_SCAN_CAPS.maxReads),
35
- maxMillis: pos(c.maxMillis, DEFAULT_SCAN_CAPS.maxMillis),
36
- };
37
- }
38
- /** Running read budget. Every call is told how many reads it may spend (`room`). */
39
- export class Budget {
40
- caps;
41
- now;
42
- startedAt;
43
- reads = 0;
44
- constructor(caps, now, startedAt) {
45
- this.caps = caps;
46
- this.now = now;
47
- this.startedAt = startedAt;
48
- }
49
- block(n = 1) {
50
- if (this.now() - this.startedAt >= this.caps.maxMillis)
51
- return "time-cap";
52
- if (this.reads + n > this.caps.maxReads)
53
- return "read-cap";
54
- return null;
55
- }
56
- room() {
57
- if (this.now() - this.startedAt >= this.caps.maxMillis)
58
- return 0;
59
- return Math.max(0, this.caps.maxReads - this.reads);
60
- }
61
- overrun() {
62
- return this.reads > this.caps.maxReads ? "read-cap" : null;
63
- }
64
- charge(n) {
65
- this.reads += n;
66
- }
67
- }
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/caps.js";
@@ -1,93 +1 @@
1
- /**
2
- * Cost per merged PR and cost by PR size, for the Org Scan report. Pure: numbers in, numbers out.
3
- *
4
- * Source of the spend. Measured per-request cost cannot be tied to a PR with the data the scan has:
5
- * the Anthropic usage reads return one whole-org total (no per-request or per-PR rows) and the Mnemom
6
- * Record totals are one window sum (model cost and a request count, no PR key). So every figure here is
7
- * `label: "estimate"`: the period's spend split across the merged PRs in proportion to lines changed.
8
- * Each figure names the spend it was split from (`spend_source`); sources are never mixed in one report.
9
- *
10
- * anthropic_usage the whole-org Anthropic cost for the period (the CTO's admin key)
11
- * modelled_range our modelled estimate (a low to high range, so the figures are ranges)
12
- *
13
- * Size is per PR, in lines changed = additions + deletions, exactly as GitHub reports them. Lockfiles and
14
- * generated files are NOT excluded. small < 50; medium 50..500 inclusive; large > 500.
15
- *
16
- * Each size figure is the average cost of ONE PR of that size: spend * (mean lines of the bucket) /
17
- * (total lines of the PRs in scope). Scope: the modelled estimate prices AI-assisted PRs only, so it is
18
- * split across those only (`pr_scope: "ai_assisted"`, buckets too); the others use every merged PR
19
- * (`pr_scope: "all_merged"`). `pr_count` counts PRs in that scope. Usage-key figures also carry
20
- * `spend_scope: "whole_org"` (the key covers the whole organization, not just the scanned repos). The cost per merged PR is the spend over the count of merged PRs.
21
- *
22
- * Missing data is an absent field, never 0 or NaN: no usable spend, zero merged PRs in the period, an
23
- * empty bucket, a scan that did not read every merged PR in the period, or a result past safe-integer
24
- * range. Reverts are listed without sizes, so they are not counted as merged PRs here.
25
- */
26
- import type { SpendEstimate } from "./spend/spend-estimate.js";
27
- /** Strictly below this is small. 50 is medium. */
28
- export declare const SMALL_MAX_EXCLUSIVE = 50;
29
- /** At or below this (and not small) is medium. 500 is medium; 501 is large. */
30
- export declare const MEDIUM_MAX_INCLUSIVE = 500;
31
- export type SizeBucket = "small" | "medium" | "large";
32
- export type CostSpendSourceName = "anthropic_usage" | "modelled_range";
33
- export interface PrSize {
34
- /** YYYY-MM-DD, UTC. */
35
- mergedDay: string;
36
- /** additions + deletions. */
37
- lines: number;
38
- /** The PR carries an AI-tool mark (the same rule the modelled spend uses to count AI-assisted PRs). */
39
- aiAssisted: boolean;
40
- }
41
- /** Where the spend comes from. USD, never cents. */
42
- export type CostSpend = {
43
- source: "anthropic_usage";
44
- usd: number;
45
- } | {
46
- source: "modelled_range";
47
- lowUsd: number;
48
- highUsd: number;
49
- };
50
- /** One figure: a single value, or a low to high range for the modelled source. Integer cents. */
51
- export type PrScope = "ai_assisted" | "all_merged";
52
- export type CostFigure = {
53
- /** "measured" is not possible with current data (see header); every figure is an estimate. */
54
- label: "estimate";
55
- spend_source: CostSpendSourceName;
56
- /**
57
- * Which PRs the average is over. The modelled estimate prices AI-assisted PRs only, so it is split
58
- * across those only ("ai_assisted"); the usage key covers all spend, so they use "all_merged".
59
- */
60
- pr_scope: PrScope;
61
- /** PRs in that scope. */
62
- pr_count: number;
63
- /** Present on anthropic_usage only: the key's usage is the whole organization's, not just these PRs'. */
64
- spend_scope?: "whole_org";
65
- } & ({
66
- value_cents: number;
67
- } | {
68
- low_cents: number;
69
- high_cents: number;
70
- });
71
- export interface CostPerPr {
72
- cost_per_merged_pr?: CostFigure;
73
- cost_by_pr_size?: Partial<Record<SizeBucket, CostFigure>>;
74
- }
75
- export declare const sizeBucket: (lines: number) => SizeBucket;
76
- /** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
77
- export declare function periodDays(p: {
78
- start: string;
79
- end: string;
80
- }): number | null;
81
- /** The period's spend in USD as [low, high]; null when there is no usable spend. */
82
- export declare function spendForPeriod(spend: CostSpend): [number, number] | null;
83
- /** The modelled estimate as a spend source, or null when the estimate is "none". */
84
- export declare const modelledSpend: (e: SpendEstimate) => CostSpend | null;
85
- /**
86
- * @param prs every merged PR the scan read, with merge day and size
87
- * @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
88
- * large PR, none unreadable, no cap hit). Otherwise the average is not defined.
89
- */
90
- export declare function buildCostPerPr(prs: readonly PrSize[] | undefined, period: {
91
- start: string;
92
- end: string;
93
- }, spend: CostSpend | null, exhaustive: boolean): CostPerPr;
1
+ export * from "../../vendor/shared/org-scan-core/cost-per-pr.js";
@@ -1,101 +1,2 @@
1
- /** Strictly below this is small. 50 is medium. */
2
- export const SMALL_MAX_EXCLUSIVE = 50;
3
- /** At or below this (and not small) is medium. 500 is medium; 501 is large. */
4
- export const MEDIUM_MAX_INCLUSIVE = 500;
5
- export const sizeBucket = (lines) => lines < SMALL_MAX_EXCLUSIVE ? "small" : lines <= MEDIUM_MAX_INCLUSIVE ? "medium" : "large";
6
- const DAY_MS = 86_400_000;
7
- /** Inclusive day count of YYYY-MM-DD..YYYY-MM-DD, or null if unparsable or reversed. */
8
- export function periodDays(p) {
9
- const s = Date.parse(`${p.start}T00:00:00Z`);
10
- const e = Date.parse(`${p.end}T00:00:00Z`);
11
- if (!Number.isFinite(s) || !Number.isFinite(e) || e < s)
12
- return null;
13
- return Math.round((e - s) / DAY_MS) + 1;
14
- }
15
- const pos = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
16
- /** The period's spend in USD as [low, high]; null when there is no usable spend. */
17
- export function spendForPeriod(spend) {
18
- let r;
19
- if (spend.source === "anthropic_usage") {
20
- if (!pos(spend.usd))
21
- return null;
22
- r = [spend.usd, spend.usd];
23
- }
24
- else {
25
- if (!pos(spend.highUsd) || !(spend.lowUsd >= 0) || spend.lowUsd > spend.highUsd)
26
- return null;
27
- r = [spend.lowUsd, spend.highUsd];
28
- }
29
- return r.every((x) => Number.isFinite(x) && x * 100 <= Number.MAX_SAFE_INTEGER) ? r : null;
30
- }
31
- /** The modelled estimate as a spend source, or null when the estimate is "none". */
32
- export const modelledSpend = (e) => e.kind === "range" ? { source: "modelled_range", lowUsd: e.lowUsd, highUsd: e.highUsd } : null;
33
- /**
34
- * `share` is the fraction of the period's spend one average PR here carries: always in (0, 1] by
35
- * construction, and the spend is already bounded to safe-integer cents by spendForPeriod, so the
36
- * rounded cents are finite safe integers without a further guard.
37
- */
38
- function figure(spend, [low, high], count, share, scope) {
39
- const c = (usd) => Math.round(usd * share * 100);
40
- const base = {
41
- label: "estimate",
42
- spend_source: spend.source,
43
- pr_scope: scope,
44
- pr_count: count,
45
- ...(spend.source === "anthropic_usage" ? { spend_scope: "whole_org" } : {}),
46
- };
47
- return spend.source === "modelled_range"
48
- ? { ...base, low_cents: c(low), high_cents: c(high) }
49
- : { ...base, value_cents: c(low) };
50
- }
51
- /**
52
- * @param prs every merged PR the scan read, with merge day and size
53
- * @param exhaustive true only if the scan read EVERY merged PR in the period (no sample, no skipped
54
- * large PR, none unreadable, no cap hit). Otherwise the average is not defined.
55
- */
56
- export function buildCostPerPr(prs, period, spend, exhaustive) {
57
- if (!exhaustive || !spend || !Array.isArray(prs))
58
- return {};
59
- if (periodDays(period) === null)
60
- return {};
61
- const total = spendForPeriod(spend);
62
- if (!total)
63
- return {};
64
- const inPeriod = prs.filter((p) => p.mergedDay >= period.start &&
65
- p.mergedDay <= period.end &&
66
- Number.isSafeInteger(p.lines) &&
67
- p.lines >= 0);
68
- // The modelled estimate is AI-tool spend, so it is split across AI-assisted PRs only; the usage key is
69
- // the whole spend, so it is split across every merged PR.
70
- const scope = spend.source === "modelled_range" ? "ai_assisted" : "all_merged";
71
- const scoped = scope === "ai_assisted" ? inPeriod.filter((p) => p.aiAssisted) : inPeriod;
72
- const n = scoped.length;
73
- if (n === 0)
74
- return {};
75
- const out = {};
76
- out.cost_per_merged_pr = figure(spend, total, n, 1 / n, scope);
77
- let allLines = 0;
78
- const agg = {
79
- small: { n: 0, lines: 0 },
80
- medium: { n: 0, lines: 0 },
81
- large: { n: 0, lines: 0 },
82
- };
83
- for (const p of scoped) {
84
- allLines += p.lines;
85
- const a = agg[sizeBucket(p.lines)];
86
- a.n += 1;
87
- a.lines += p.lines;
88
- }
89
- // Splitting by lines needs lines: with none (or a sum past safe range) there is no size figure.
90
- if (allLines <= 0 || !Number.isSafeInteger(allLines))
91
- return out;
92
- const bySize = {};
93
- for (const b of ["small", "medium", "large"]) {
94
- const a = agg[b];
95
- if (a.n === 0)
96
- continue;
97
- bySize[b] = figure(spend, total, a.n, a.lines / a.n / allLines, scope);
98
- }
99
- out.cost_by_pr_size = bySize;
100
- return out;
101
- }
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/cost-per-pr.js";
@@ -1,29 +1,5 @@
1
- /** The complete permission set a scan credential may hold. Read only. */
2
- export declare const SCAN_READ_PERMISSIONS: Readonly<Record<string, "read">>;
3
- export interface GitHubCredential {
4
- /** Fixed label for diagnostics ("app", "personal"): never a token, login or URL. */
5
- readonly kind: string;
6
- /** A bearer token for reading `repo` (owner/name). A rejection fails that read closed. */
7
- tokenFor(repo: string): Promise<string>;
8
- /**
9
- * The permissions GitHub granted this credential, when the source can tell. Null = unknown (for
10
- * example a classic token); the scan then relies on GET-only egress and does not claim more.
11
- */
12
- grantedPermissions?(): Promise<Record<string, string> | null>;
13
- }
14
- /** Violations (empty when the map is exactly the read-only scan set). Port of readOnlyViolations. */
15
- export declare function readOnlyViolations(permissions: Record<string, string> | null | undefined): string[];
16
- /** owner/name, no dot-only segment. */
17
- export declare function isValidRepoSlug(s: unknown): s is string;
18
- /** Fixed-text failure: the message never carries a token, a body or a URL. */
19
- export declare class ScanCredentialError extends Error {
20
- constructor(message: "scan credential refused" | "scan credential not configured");
21
- }
22
- /**
23
- * Refuses a credential GitHub granted more than read to. Throws a fixed-text error and nothing else.
24
- * Unknown (null) permissions pass: the source cannot tell, and the scan is GET-only regardless.
25
- */
26
- export declare function assertReadOnlyCredential(c: GitHubCredential): Promise<void>;
1
+ import { type GitHubCredential } from "../../vendor/shared/org-scan-core/credential.js";
2
+ export * from "../../vendor/shared/org-scan-core/credential.js";
27
3
  /** Runs `gh auth token`; resolves stdout or rejects. Injectable so tests never spawn a process. */
28
4
  export type GhTokenRunner = () => Promise<string>;
29
5
  /**