@mnemom/mnemom 0.17.38-next.1 → 0.17.38-next.3

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 (82) hide show
  1. package/dist/commands/agent-run.d.ts +10 -2
  2. package/dist/commands/agent-run.js +18 -7
  3. package/dist/commands/scan.d.ts +1 -1
  4. package/dist/commands/scan.js +7 -14
  5. package/dist/index.js +1 -0
  6. package/dist/lib/runner/executor-client.d.ts +6 -0
  7. package/dist/lib/runner/executor-client.js +6 -0
  8. package/dist/lib/runner/job.d.ts +12 -3
  9. package/dist/lib/runner/job.js +30 -2
  10. package/dist/lib/runner/loop.d.ts +7 -2
  11. package/dist/lib/runner/loop.js +15 -3
  12. package/dist/lib/runner/options.d.ts +7 -0
  13. package/dist/lib/runner/options.js +11 -0
  14. package/dist/lib/scan/caps.d.ts +1 -35
  15. package/dist/lib/scan/caps.js +2 -67
  16. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  17. package/dist/lib/scan/cost-per-pr.js +2 -101
  18. package/dist/lib/scan/credential.d.ts +2 -26
  19. package/dist/lib/scan/credential.js +5 -58
  20. package/dist/lib/scan/failure.d.ts +1 -10
  21. package/dist/lib/scan/failure.js +2 -43
  22. package/dist/lib/scan/pr-source.d.ts +1 -57
  23. package/dist/lib/scan/pr-source.js +2 -132
  24. package/dist/lib/scan/report-email.d.ts +7 -104
  25. package/dist/lib/scan/report-email.js +25 -97
  26. package/dist/lib/scan/scan.d.ts +1 -69
  27. package/dist/lib/scan/scan.js +2 -195
  28. package/dist/lib/scan/share.d.ts +3 -1
  29. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  30. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  31. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  32. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  33. package/dist/lib/scan/spend/prices.d.ts +1 -33
  34. package/dist/lib/scan/spend/prices.js +2 -57
  35. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  36. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  37. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  38. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  39. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  40. package/dist/lib/scan/spend/token-rate.js +2 -54
  41. package/dist/vendor/durable-contract/index.js +19 -2
  42. package/dist/vendor/shared/durable-contract/cli-surface.js +2 -1
  43. package/dist/vendor/shared/durable-contract/codes.d.ts +12 -0
  44. package/dist/vendor/shared/durable-contract/codes.js +11 -0
  45. package/dist/vendor/shared/durable-contract/constants.d.ts +7 -1
  46. package/dist/vendor/shared/durable-contract/constants.js +7 -1
  47. package/dist/vendor/shared/durable-contract/doors.d.ts +9 -1
  48. package/dist/vendor/shared/durable-contract/doors.js +13 -0
  49. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +1 -1
  50. package/dist/vendor/shared/durable-contract/schema.d.ts +9 -0
  51. package/dist/vendor/shared/durable-contract/schema.js +11 -0
  52. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  53. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  54. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  55. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  56. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  57. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  58. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  59. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  60. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  61. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  62. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  63. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  64. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  65. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  66. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  67. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  68. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  69. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  70. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  71. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  72. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  73. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  74. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  75. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  76. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  77. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  78. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  79. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  80. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  81. package/package.json +1 -1
  82. /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
@@ -90,8 +92,13 @@ export async function agentRunCommand(flags, deps) {
90
92
  executorId = executorIdFromState(await deps.readExecutorState(), executorKey) ?? executorId;
91
93
  }
92
94
  const api = runnerApi({ apiUrl: deps.apiUrl, executorKey, fetch: deps.fetch });
93
- const loop = { failMode: plan.failMode, stop: deps.stop, emit: deps.emit };
94
- const job = deps.jobDeps(api, executorId, gh.bases, modelKey);
95
+ const loop = {
96
+ failMode: plan.failMode,
97
+ stop: deps.stop,
98
+ emit: deps.emit,
99
+ ...(plan.sessionId !== undefined ? { sessionId: plan.sessionId } : {}),
100
+ };
101
+ const job = deps.jobDeps(api, executorId, gh.bases, modelKey, plan.credential.kind === "oidc");
95
102
  return plan.form === "once" ? runOnce(job, loop) : runWatch(job, loop);
96
103
  }
97
104
  // ── GitHub and git, for the live driver ──────────────────────────────────────
@@ -259,7 +266,10 @@ export function runPhaseProcess(inv, ws, modelUrl, signal, parentEnv = process.e
259
266
  export function liveJobDeps(api, opts) {
260
267
  const gh = opts.github;
261
268
  const token = resolveGithubToken(opts.env);
262
- const git = liveGit(gitAuthEnv(opts.env, token, gh.web));
269
+ const makeGit = opts.makeGit ?? liveGit;
270
+ const git = makeGit(gitAuthEnv(opts.env, token, gh.web));
271
+ // The PR token's git: a fresh env, so its header replaces the runner token's, never adds to it.
272
+ const gitFor = (t) => t === null ? git : makeGit(gitAuthEnv(opts.env, t, gh.web));
263
273
  return {
264
274
  api,
265
275
  executorId: opts.executorId,
@@ -272,11 +282,12 @@ export function liveJobDeps(api, opts) {
272
282
  startProxy: (state) => startModelProxy(`${getGatewayUrl()}/anthropic`, state, opts.modelKey),
273
283
  prepareWorkspace: (repo, sessionId) => prepareWorkspace(git, `${gh.web}/${repo}.git`, sessionId),
274
284
  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),
285
+ openPr: (repo, ws, title, body, prToken) => openPr(gitFor(prToken), opts.fetch, prToken ?? token, repo, ws, title, body, gh.api),
276
286
  commitsAhead: (ws) => commitsAhead(git, ws),
277
287
  headSha: (ws) => headSha(git, ws),
278
288
  commitBodies: (ws, since, lineageKey, repo) => commitBodies(git, ws, since, lineageKey, repo),
279
- enableAutoMerge: (prUrl) => enableAutoMerge(opts.fetch, token, prUrl, gh),
289
+ enableAutoMerge: (prUrl, prToken) => enableAutoMerge(opts.fetch, prToken ?? token, prUrl, gh),
290
+ prTokenFromLease: opts.prTokenFromLease === true,
280
291
  cleanup: cleanupWorkspace,
281
292
  };
282
293
  }
@@ -296,7 +307,7 @@ export function runAgentRun(flags) {
296
307
  err: (line) => process.stderr.write(`${line}\n`),
297
308
  stop: stop.signal,
298
309
  readExecutorState: () => readFile(executorStatePath(homedir()), "utf8").catch(() => null),
299
- jobDeps: (api, executorId, github, modelKey) => liveJobDeps(api, { fetch, env: process.env, executorId, github, modelKey }),
310
+ jobDeps: (api, executorId, github, modelKey, prTokenFromLease) => liveJobDeps(api, { fetch, env: process.env, executorId, github, modelKey, prTokenFromLease }),
300
311
  }).finally(() => {
301
312
  process.off("SIGTERM", onSignal);
302
313
  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);
package/dist/index.js CHANGED
@@ -1220,6 +1220,7 @@ agentCmd
1220
1220
  .option("--fail-mode <mode>", "closed (default) or open; also MNEMOM_RUNNER_FAIL_MODE")
1221
1221
  .option("--json", "Print only outcome lines on stdout; logs go to stderr")
1222
1222
  .option("--oidc", "With --once: exchange the GitHub Actions ID token for a one-job key")
1223
+ .option("--session <uuid>", "With --once: claim only this session (a wake-up's own); never take another from the pool")
1223
1224
  .hook("preAction", () => requireExperimental("`mnemom agent run`", "dispatch"))
1224
1225
  .action(async (opts) => {
1225
1226
  process.exitCode = await runAgentRun(opts);
@@ -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(),
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * --once: list the ready pool sessions (exec_ready_list, oldest first), try each in order, and run
5
5
  * at most one job. A lost race, or an ineligible waiting / not_pool_member, moves to the next
6
- * candidate; an empty list, or every candidate lost, ends no_job. --watch: the same, again and
7
- * again, until SIGTERM or SIGINT. Every job prints exactly one outcome line (none when stopped
6
+ * candidate; an empty list, or every candidate lost, ends no_job. --once --session <id> (the
7
+ * wake-up's own session, #1329): try exactly that session and never list the pool; a 404 attest, a
8
+ * lost race or a skippable ineligible ends no_job, and no other session is tried, so a wake-up
9
+ * dispatched for one repo's session never works another's. --watch: the same as plain --once, again
10
+ * and again, until SIGTERM or SIGINT. Every job prints exactly one outcome line (none when stopped
8
11
  * mid-job), and the process exit code is the --once outcome's, or 0 for --watch stopped.
9
12
  *
10
13
  * Every side effect is in `JobDeps` (plus `emit`), so each arm is tested; `liveJobDeps`
@@ -21,6 +24,8 @@ export interface LoopOptions {
21
24
  emit(line: string): void;
22
25
  /** --watch: wait between empty polls, ms. */
23
26
  idlePollMs?: number;
27
+ /** --once --session: the one session to try; the ready list is never read. */
28
+ sessionId?: string;
24
29
  }
25
30
  export declare const WATCH_IDLE_POLL_MS = 15000;
26
31
  /** --watch backs off on a ready-list the api cannot answer, up to this, ms. */
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * --once: list the ready pool sessions (exec_ready_list, oldest first), try each in order, and run
5
5
  * at most one job. A lost race, or an ineligible waiting / not_pool_member, moves to the next
6
- * candidate; an empty list, or every candidate lost, ends no_job. --watch: the same, again and
7
- * again, until SIGTERM or SIGINT. Every job prints exactly one outcome line (none when stopped
6
+ * candidate; an empty list, or every candidate lost, ends no_job. --once --session <id> (the
7
+ * wake-up's own session, #1329): try exactly that session and never list the pool; a 404 attest, a
8
+ * lost race or a skippable ineligible ends no_job, and no other session is tried, so a wake-up
9
+ * dispatched for one repo's session never works another's. --watch: the same as plain --once, again
10
+ * and again, until SIGTERM or SIGINT. Every job prints exactly one outcome line (none when stopped
8
11
  * mid-job), and the process exit code is the --once outcome's, or 0 for --watch stopped.
9
12
  *
10
13
  * Every side effect is in `JobDeps` (plus `emit`), so each arm is tested; `liveJobDeps`
@@ -58,9 +61,18 @@ async function onePass(deps, opts) {
58
61
  }
59
62
  return { kind: "none" };
60
63
  }
64
+ /** --once --session: that session's outcome, or none; never another session. */
65
+ async function sessionPass(deps, opts, id) {
66
+ if (opts.stop.aborted)
67
+ return { kind: "stopped" };
68
+ const r = await runJob(id, { failMode: opts.failMode, stop: opts.stop }, deps);
69
+ return r.kind === "next" ? { kind: "none" } : r;
70
+ }
61
71
  /** --once: at most one job. Resolves the process exit code. */
62
72
  export async function runOnce(deps, opts) {
63
- const p = await onePass(deps, opts);
73
+ const p = opts.sessionId === undefined
74
+ ? await onePass(deps, opts)
75
+ : await sessionPass(deps, opts, opts.sessionId);
64
76
  if (p.kind === "stopped")
65
77
  return 0;
66
78
  const outcome = p.kind === "outcome" ? p.outcome : noJobOutcome();
@@ -16,6 +16,8 @@ export interface RunFlags {
16
16
  failMode?: string;
17
17
  json?: boolean;
18
18
  oidc?: boolean;
19
+ /** --session <uuid>: with --once, claim exactly this session and never list the pool. */
20
+ session?: string;
19
21
  }
20
22
  export type RunCredential = {
21
23
  kind: "executor_key";
@@ -34,6 +36,11 @@ export interface RunPlan {
34
36
  executorId: string | null;
35
37
  /** Warnings to print on stderr (an unknown fail mode reads as closed). */
36
38
  warnings: string[];
39
+ /**
40
+ * --session: the one session this --once run may claim (the wake-up's own, #1329). Absent: the
41
+ * pool's ready list, oldest first, as before.
42
+ */
43
+ sessionId?: string;
37
44
  }
38
45
  export type RunPlanResult = {
39
46
  ok: true;
@@ -22,6 +22,16 @@ export function resolveRunPlan(flags, env) {
22
22
  if (flags.oidc && form === "watch") {
23
23
  return usage("mnemom agent run: --oidc is valid only with --once");
24
24
  }
25
+ let sessionId;
26
+ if (flags.session !== undefined) {
27
+ if (form === "watch") {
28
+ return usage("mnemom agent run: --session is valid only with --once");
29
+ }
30
+ if (!UUID_RE.test(flags.session)) {
31
+ return usage("mnemom agent run: --session is not a lowercase uuid");
32
+ }
33
+ sessionId = flags.session;
34
+ }
25
35
  const fail = resolveFailMode(flags.failMode, env[RUNNER_FAIL_MODE_ENV]);
26
36
  const warnings = fail.warning ? [`mnemom agent run: ${fail.warning}`] : [];
27
37
  let credential;
@@ -60,6 +70,7 @@ export function resolveRunPlan(flags, env) {
60
70
  credential,
61
71
  executorId,
62
72
  warnings,
73
+ ...(sessionId !== undefined ? { sessionId } : {}),
63
74
  },
64
75
  };
65
76
  }
@@ -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";