@mnemom/mnemom 0.17.34-next.2 → 0.17.34-next.4

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 (87) hide show
  1. package/dist/commands/agent-run.d.ts +16 -6
  2. package/dist/commands/agent-run.js +52 -27
  3. package/dist/commands/agent-share-test-issuer.d.ts +67 -0
  4. package/dist/commands/agent-share-test-issuer.js +139 -0
  5. package/dist/commands/agent-share.d.ts +7 -1
  6. package/dist/commands/agent-share.js +14 -4
  7. package/dist/commands/agent.d.ts +7 -5
  8. package/dist/commands/agent.js +84 -45
  9. package/dist/commands/durable-lock.d.ts +8 -1
  10. package/dist/commands/durable-lock.js +9 -4
  11. package/dist/commands/scan.d.ts +12 -1
  12. package/dist/commands/scan.js +22 -3
  13. package/dist/commands/wrap.js +1 -1
  14. package/dist/durable/adopt-live.js +5 -8
  15. package/dist/durable/adopt.d.ts +8 -0
  16. package/dist/durable/adopt.js +27 -6
  17. package/dist/durable/cwd-rule.d.ts +9 -1
  18. package/dist/durable/cwd-rule.js +18 -0
  19. package/dist/durable/launch-spec.d.ts +2 -0
  20. package/dist/durable/launch-spec.js +1 -1
  21. package/dist/durable/test-mode.mjs +25 -1
  22. package/dist/host/client.d.ts +3 -1
  23. package/dist/host/daemon.d.ts +7 -3
  24. package/dist/host/daemon.js +13 -3
  25. package/dist/host/owner.d.ts +3 -0
  26. package/dist/host/owner.js +2 -0
  27. package/dist/host/run.d.ts +2 -3
  28. package/dist/host/run.js +5 -20
  29. package/dist/host/s1-names.d.ts +1 -0
  30. package/dist/host/s1-names.js +1 -0
  31. package/dist/host/verbs.d.ts +2 -0
  32. package/dist/host/verbs.js +11 -0
  33. package/dist/index.js +13 -9
  34. package/dist/lib/agent.d.ts +5 -3
  35. package/dist/lib/agent.js +6 -3
  36. package/dist/lib/claude-bin.d.ts +6 -0
  37. package/dist/lib/claude-bin.js +26 -0
  38. package/dist/lib/commit-report.d.ts +5 -4
  39. package/dist/lib/commit-report.js +6 -9
  40. package/dist/lib/config.d.ts +8 -1
  41. package/dist/lib/config.js +28 -0
  42. package/dist/lib/durable/launch.d.ts +28 -11
  43. package/dist/lib/durable/launch.js +33 -15
  44. package/dist/lib/durable/live.d.ts +15 -19
  45. package/dist/lib/durable/live.js +41 -43
  46. package/dist/lib/launcher-config.d.ts +6 -0
  47. package/dist/lib/launcher-config.js +11 -0
  48. package/dist/lib/runner/exec-append.d.ts +15 -0
  49. package/dist/lib/runner/exec-append.js +2 -0
  50. package/dist/lib/runner/executor-client.d.ts +2 -1
  51. package/dist/lib/runner/executor-state.d.ts +10 -0
  52. package/dist/lib/runner/executor-state.js +24 -0
  53. package/dist/lib/runner/github-base.d.ts +24 -0
  54. package/dist/lib/runner/github-base.js +27 -0
  55. package/dist/lib/runner/job.d.ts +11 -4
  56. package/dist/lib/runner/job.js +59 -16
  57. package/dist/lib/runner/oidc.d.ts +3 -2
  58. package/dist/lib/runner/oidc.js +7 -4
  59. package/dist/lib/runner/options.d.ts +6 -0
  60. package/dist/lib/runner/options.js +19 -1
  61. package/dist/lib/runner/outcome.js +2 -3
  62. package/dist/lib/runner/phases.d.ts +7 -4
  63. package/dist/lib/runner/phases.js +8 -4
  64. package/dist/lib/runner/proxy.d.ts +5 -1
  65. package/dist/lib/runner/proxy.js +7 -1
  66. package/dist/lib/scan/cost-per-pr.d.ts +93 -0
  67. package/dist/lib/scan/cost-per-pr.js +101 -0
  68. package/dist/lib/scan/record.d.ts +27 -1
  69. package/dist/lib/scan/record.js +114 -16
  70. package/dist/lib/scan/report-email.d.ts +25 -5
  71. package/dist/lib/scan/report-email.js +23 -2
  72. package/dist/lib/scan/scan.d.ts +3 -0
  73. package/dist/lib/scan/scan.js +13 -1
  74. package/dist/lib/session-key.d.ts +4 -3
  75. package/dist/vendor/durable-contract/index.js +207 -169
  76. package/dist/vendor/shared/durable-contract/doors.d.ts +26 -1
  77. package/dist/vendor/shared/durable-contract/doors.js +37 -1
  78. package/dist/vendor/shared/durable-contract/events.d.ts +1 -1
  79. package/dist/vendor/shared/durable-contract/events.js +13 -3
  80. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +2 -2
  81. package/dist/vendor/shared/durable-contract/schema.d.ts +5 -0
  82. package/dist/vendor/shared/durable-contract/schema.js +6 -0
  83. package/dist/vendor/shared/durable-contract/states.d.ts +2 -1
  84. package/dist/vendor/shared/durable-contract/states.js +2 -1
  85. package/package.json +1 -1
  86. package/dist/lib/durable/first-turn.d.ts +0 -50
  87. package/dist/lib/durable/first-turn.js +0 -102
@@ -11,7 +11,8 @@
11
11
  * Every side effect is in `JobDeps`, so each arm is tested; `liveJobDeps` (commands/agent-run.ts)
12
12
  * is the thin driver.
13
13
  */
14
- import { RUNNER_OPEN_MODE_TOLERATED, claimNeedsKeyRetry, } from "../../vendor/durable-contract/index.js";
14
+ import { EXECUTOR_WRITABLE_KINDS, RUNNER_OPEN_MODE_TOLERATED, claimNeedsKeyRetry, } from "../../vendor/durable-contract/index.js";
15
+ import { spawnIdempotencyKey } from "./exec-append.js";
15
16
  import { canonicalJson } from "../canonical-json.js";
16
17
  import { verifyServedPhaseBundle } from "./bundle.js";
17
18
  import { sealedContractHeader } from "./contract-header.js";
@@ -133,6 +134,8 @@ async function claimSession(deps, sessionId) {
133
134
  },
134
135
  };
135
136
  }
137
+ /** A header-safe agent id; the gateway compares it with the lease's session. */
138
+ const AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
136
139
  function parseJob(body) {
137
140
  if (!body || typeof body !== "object")
138
141
  return null;
@@ -147,7 +150,11 @@ function parseJob(body) {
147
150
  return null;
148
151
  if (sha === null || version === null || typeof b.contract !== "string")
149
152
  return null;
150
- return { repo, contract_sha256: sha, version, contract: b.contract };
153
+ // No agent id, no job: a model request without x-mnemom-agent-id would be refused anyway.
154
+ const agentId = str(b.agent_id);
155
+ if (agentId === null || !AGENT_ID_RE.test(agentId))
156
+ return null;
157
+ return { repo, agent_id: agentId, contract_sha256: sha, version, contract: b.contract };
151
158
  }
152
159
  /**
153
160
  * Run one candidate session to an outcome. Never throws for a Mnemom error: every one is an
@@ -189,15 +196,19 @@ export async function runJob(sessionId, opts, deps) {
189
196
  };
190
197
  const refuse = async (reason, detail) => {
191
198
  deps.log(`refused (${reason}): ${detail}`);
192
- await reportFail(deps, sessionId, claim.epoch, "runner_refused", reason);
199
+ // A refusal hands the lease back: S1's fail reasons are closed, and the api refuses
200
+ // runner_refused as durable_reason_invalid, which left the session leased until it lapsed.
201
+ await release(deps, sessionId, claim.epoch);
193
202
  return { kind: "outcome", outcome: refusedOutcome(facts, reason) };
194
203
  };
195
204
  try {
196
205
  // Register the spawn, then hello: the session moves to running and the lease is ours.
197
206
  const ap = await deps.api.append(sessionId, {
198
- kind: "executor.spawned",
199
- detail: { spawn_id: spawnId },
207
+ kind: EXECUTOR_WRITABLE_KINDS[0], // executor.spawned
208
+ refs: { spawn_id: spawnId },
209
+ idempotency_key: spawnIdempotencyKey(sessionId, claim.epoch, spawnId),
200
210
  expected_epoch: claim.epoch,
211
+ nonce: deps.nonce,
201
212
  });
202
213
  if (!ok2xx(ap))
203
214
  return await refuse(routeRefusal(ap), `register spawn: ${ap.code ?? ap.status}`);
@@ -279,7 +290,11 @@ export async function runJob(sessionId, opts, deps) {
279
290
  await reportFail(deps, sessionId, claim.epoch, "runner_failed", reason);
280
291
  return { kind: "outcome", outcome: failedOutcome(facts, reason) };
281
292
  }
282
- const state = { contractValue: header.value, leaseKey: claim.leaseKey };
293
+ const state = {
294
+ contractValue: header.value,
295
+ leaseKey: claim.leaseKey,
296
+ agentId: job.agent_id,
297
+ };
283
298
  proxy = await deps.startProxy(state);
284
299
  let opensPr = false;
285
300
  for (const phase of phases) {
@@ -309,7 +324,7 @@ export async function runJob(sessionId, opts, deps) {
309
324
  if (proxy.contractMismatch && !amended) {
310
325
  // 34.11: the charter moved. Continue only on a higher sealed version with both the job
311
326
  // and a fresh bundle bound to it; then the phase runs again under the new seal.
312
- const next = await amendedSeal(deps, sessionId, claim, bundle);
327
+ const next = await amendedSeal(deps, sessionId, claim, bundle, job.agent_id);
313
328
  if (!next)
314
329
  return await refuse("contract_mismatch_unrecoverable", "the amended seal did not check out");
315
330
  state.contractValue = next.value;
@@ -333,6 +348,12 @@ export async function runJob(sessionId, opts, deps) {
333
348
  }
334
349
  if (opensPr) {
335
350
  try {
351
+ // Checked before the push, so an empty diff never reaches GitHub's 422 (amendment 59).
352
+ if ((await deps.commitsAhead(ws)) === 0) {
353
+ deps.log("no commits ahead of the base: there is no PR to open");
354
+ await reportFail(deps, sessionId, claim.epoch, "runner_failed", "pr_no_changes");
355
+ return { kind: "outcome", outcome: failedOutcome(facts, "pr_no_changes") };
356
+ }
336
357
  facts.pr_url = await deps.openPr(job.repo, ws, `mnemom: ${bundle.workflow.id} job ${sessionId.slice(0, 8)}`);
337
358
  }
338
359
  catch (e) {
@@ -381,19 +402,29 @@ async function fetchBundle(deps, sessionId, claim) {
381
402
  detail: `jwks: ${jwksRes.networkError ?? jwksRes.status}`,
382
403
  };
383
404
  }
384
- if (claim.executorId === null) {
385
- // No answer named this runner's executor id, so the bundle cannot be bound to the claim.
405
+ if (deps.executorId !== null &&
406
+ claim.executorId !== null &&
407
+ deps.executorId !== claim.executorId) {
408
+ return {
409
+ kind: "refused",
410
+ reason: "bundle_binding_mismatch",
411
+ detail: "the claim answer names another executor_id than this runner's",
412
+ };
413
+ }
414
+ const executorId = deps.executorId ?? claim.executorId;
415
+ if (executorId === null) {
416
+ // Neither the runner nor the claim answer names the executor id, so the bundle cannot be bound.
386
417
  return {
387
418
  kind: "refused",
388
419
  reason: "bundle_binding_mismatch",
389
- detail: "the claim answer named no executor_id",
420
+ detail: `no executor_id: set MNEMOM_EXECUTOR_ID, the claim answer named none`,
390
421
  };
391
422
  }
392
423
  const v = verifyServedPhaseBundle(res.text, jwksRes.body, {
393
424
  org_id: orgOf(res.body),
394
425
  session_id: sessionId,
395
426
  epoch: claim.epoch,
396
- executor_id: claim.executorId,
427
+ executor_id: executorId,
397
428
  contract_sha256: claim.contractSha256,
398
429
  contract_version: claim.contractVersion,
399
430
  }, { now: deps.now });
@@ -410,11 +441,14 @@ function orgOf(body) {
410
441
  const o = body?.org_id;
411
442
  return typeof o === "string" ? o : "";
412
443
  }
413
- async function amendedSeal(deps, sessionId, claim, running) {
444
+ async function amendedSeal(deps, sessionId, claim, running, agentId) {
414
445
  const jr = await deps.api.job(sessionId);
415
446
  const job = jr.status === 200 ? parseJob(jr.body) : null;
416
447
  if (!job || job.version <= claim.contractVersion)
417
448
  return null;
449
+ // An amend never moves the session to another agent.
450
+ if (job.agent_id !== agentId)
451
+ return null;
418
452
  const fresh = await fetchBundle(deps, sessionId, {
419
453
  ...claim,
420
454
  contractSha256: job.contract_sha256,
@@ -438,9 +472,9 @@ function sameWork(a, b) {
438
472
  canonicalJson({ workflow: b.workflow, phases: b.phases }));
439
473
  }
440
474
  /**
441
- * Report a failed or refused job after claim (34.12 (6), (10)). The fail reason vocabulary is the
442
- * Durable lead's to rule (A3 accepts only transcript_lost today); a refused report is logged and
443
- * never changes the outcome, because the lease lapses on its own.
475
+ * Report a failed job after claim (34.12 (6), (10)); a refusal releases the lease instead. The
476
+ * fail reason vocabulary is the Durable lead's to rule (A3 accepts only transcript_lost today); a
477
+ * fail the api refuses is logged and never changes the outcome, because the lease lapses on its own.
444
478
  */
445
479
  export async function reportFail(deps, sessionId, epoch, kind, reason) {
446
480
  const r = await deps.api.fail(sessionId, {
@@ -449,8 +483,17 @@ export async function reportFail(deps, sessionId, epoch, kind, reason) {
449
483
  reason: kind,
450
484
  refs: { reason },
451
485
  });
452
- if (!ok2xx(r))
486
+ if (!ok2xx(r)) {
453
487
  deps.log(`exec_fail ${kind}/${reason}: ${errorCode(r.body) ?? r.networkError ?? r.status}`);
488
+ return;
489
+ }
490
+ // Amendment 59: the api decides retry or fail; the runner only says which.
491
+ const b = (r.body ?? {});
492
+ const job = `job ${sessionId.slice(0, 8)}`;
493
+ if (b.state === "ready" && Number.isInteger(b.attempt) && Number.isInteger(b.retries))
494
+ deps.log(`${job} returned to the pool for retry (attempt ${b.attempt} of ${b.retries})`);
495
+ else if (b.state === "failed" && Number.isInteger(b.attempt))
496
+ deps.log(`${job} failed after ${b.attempt} attempt${b.attempt === 1 ? "" : "s"}`);
454
497
  }
455
498
  async function release(deps, sessionId, epoch) {
456
499
  const r = await deps.api.release(sessionId, { epoch, nonce: deps.nonce });
@@ -8,14 +8,15 @@
8
8
  * is oidc_<code> for S1's OIDC_EXCHANGE_REFUSALS, anything else oidc_unavailable. The token never
9
9
  * reaches argv, a log line or an outcome.
10
10
  *
11
- * S1 names the route but not its answer's field; `executor_key` is this runner's reading, open
12
- * with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
11
+ * S1 names the route but not its answer's fields; `executor_key` (and `executor_id`, which binds
12
+ * the bundle) is this runner's reading, open with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
13
13
  */
14
14
  import { type FetchFn } from "./executor-client.js";
15
15
  import type { RefusedReason } from "./outcome.js";
16
16
  export type OidcExchange = {
17
17
  ok: true;
18
18
  executorKey: string;
19
+ executorId: string | null;
19
20
  } | {
20
21
  ok: false;
21
22
  reason: RefusedReason;
@@ -8,11 +8,12 @@
8
8
  * is oidc_<code> for S1's OIDC_EXCHANGE_REFUSALS, anything else oidc_unavailable. The token never
9
9
  * reaches argv, a log line or an outcome.
10
10
  *
11
- * S1 names the route but not its answer's field; `executor_key` is this runner's reading, open
12
- * with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
11
+ * S1 names the route but not its answer's fields; `executor_key` (and `executor_id`, which binds
12
+ * the bundle) is this runner's reading, open with the Durable lead until S1 freezes it (no api handler exists yet to read it from).
13
13
  */
14
14
  import { OIDC_EXCHANGE_AUDIENCE, OIDC_EXCHANGE_REFUSALS, routePath, } from "../../vendor/durable-contract/index.js";
15
15
  import { errorCode } from "./executor-client.js";
16
+ import { UUID_RE } from "./options.js";
16
17
  const unavailable = (detail) => ({
17
18
  ok: false,
18
19
  reason: "oidc_unavailable",
@@ -72,9 +73,11 @@ export async function exchangeOidc(opts) {
72
73
  }
73
74
  const body = await readJson(res);
74
75
  if (res.status === 200) {
75
- const key = body?.executor_key;
76
+ const b = body;
77
+ const key = b?.executor_key;
78
+ const id = typeof b?.executor_id === "string" && UUID_RE.test(b.executor_id) ? b.executor_id : null;
76
79
  if (typeof key === "string" && key !== "")
77
- return { ok: true, executorKey: key };
80
+ return { ok: true, executorKey: key, executorId: id };
78
81
  return unavailable("exchange answer has no executor_key");
79
82
  }
80
83
  const code = errorCode(body);
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { type RunnerFailMode } from "./settings.js";
8
8
  export declare const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
9
+ /** The enrolled executor's own id (executor-state.json `executor_id`): the bundle binds to it. */
10
+ export declare const EXECUTOR_ID_ENV = "MNEMOM_EXECUTOR_ID";
9
11
  export declare const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
10
12
  export declare const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
11
13
  export interface RunFlags {
@@ -28,6 +30,8 @@ export interface RunPlan {
28
30
  failMode: RunnerFailMode;
29
31
  json: boolean;
30
32
  credential: RunCredential;
33
+ /** This runner's executor id, when the env names one; else the claim or exchange answer's. */
34
+ executorId: string | null;
31
35
  /** Warnings to print on stderr (an unknown fail mode reads as closed). */
32
36
  warnings: string[];
33
37
  }
@@ -38,4 +42,6 @@ export type RunPlanResult = {
38
42
  ok: false;
39
43
  usage: string;
40
44
  };
45
+ /** A lowercase uuid: the executor id's only accepted form. */
46
+ export declare const UUID_RE: RegExp;
41
47
  export declare function resolveRunPlan(flags: RunFlags, env: NodeJS.ProcessEnv): RunPlanResult;
@@ -6,10 +6,14 @@
6
6
  */
7
7
  import { resolveFailMode, RUNNER_FAIL_MODE_ENV } from "./settings.js";
8
8
  export const EXECUTOR_KEY_ENV = "MNEMOM_EXECUTOR_KEY";
9
+ /** The enrolled executor's own id (executor-state.json `executor_id`): the bundle binds to it. */
10
+ export const EXECUTOR_ID_ENV = "MNEMOM_EXECUTOR_ID";
9
11
  export const ACTIONS_ID_TOKEN_URL_ENV = "ACTIONS_ID_TOKEN_REQUEST_URL";
10
12
  export const ACTIONS_ID_TOKEN_TOKEN_ENV = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
11
13
  const usage = (message) => ({ ok: false, usage: message });
12
14
  const set = (v) => v !== undefined && v.trim() !== "";
15
+ /** A lowercase uuid: the executor id's only accepted form. */
16
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
13
17
  export function resolveRunPlan(flags, env) {
14
18
  if (Boolean(flags.watch) === Boolean(flags.once)) {
15
19
  return usage("mnemom agent run: give exactly one of --watch or --once");
@@ -40,8 +44,22 @@ export function resolveRunPlan(flags, env) {
40
44
  }
41
45
  credential = { kind: "executor_key", key: key.trim() };
42
46
  }
47
+ let executorId = null;
48
+ if (set(env[EXECUTOR_ID_ENV])) {
49
+ executorId = env[EXECUTOR_ID_ENV].trim();
50
+ if (!UUID_RE.test(executorId)) {
51
+ return usage(`mnemom agent run: ${EXECUTOR_ID_ENV} is not a lowercase uuid`);
52
+ }
53
+ }
43
54
  return {
44
55
  ok: true,
45
- plan: { form, failMode: fail.mode, json: Boolean(flags.json), credential, warnings },
56
+ plan: {
57
+ form,
58
+ failMode: fail.mode,
59
+ json: Boolean(flags.json),
60
+ credential,
61
+ executorId,
62
+ warnings,
63
+ },
46
64
  };
47
65
  }
@@ -6,7 +6,7 @@
6
6
  * a reason outside S1's closed lists is a bug in the runner, so `outcomeLine` throws on one rather
7
7
  * than print a line no consumer can read.
8
8
  */
9
- import { RUNNER_EXIT_CODES, RUNNER_FAILED_PHASE_RE, RUNNER_FAILED_REASONS, RUNNER_OUTCOME_FIELDS, RUNNER_REFUSED_REASONS, } from "../../vendor/durable-contract/index.js";
9
+ import { isRunnerFailedReason, RUNNER_EXIT_CODES, RUNNER_OUTCOME_FIELDS, RUNNER_REFUSED_REASONS, } from "../../vendor/durable-contract/index.js";
10
10
  /** `phase_<order>_<name>`: the name lowercased, anything outside [a-z0-9_] replaced by `_`. */
11
11
  export function phaseReason(order, name) {
12
12
  return `phase_${order}_${name.toLowerCase().replace(/[^a-z0-9_]/g, "_")}`;
@@ -20,8 +20,7 @@ function reasonOk(outcome, reason) {
20
20
  if (reason === null)
21
21
  return false;
22
22
  if (outcome === "failed")
23
- return (RUNNER_FAILED_REASONS.includes(reason) ||
24
- RUNNER_FAILED_PHASE_RE.test(reason));
23
+ return isRunnerFailedReason(reason);
25
24
  return isRefusedReason(reason);
26
25
  }
27
26
  const pick = (facts, keep) => ({
@@ -5,8 +5,9 @@
5
5
  * verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
6
6
  * tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
7
7
  * phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
8
- * HOME, the model URL of the runner's own proxy and the model credential, never the executor key,
9
- * the lease key or a GitHub token.
8
+ * HOME, the model URL of the runner's own proxy and a fixed placeholder model key, never a real
9
+ * provider key, the executor key, the lease key or a GitHub token. The gateway authenticates the
10
+ * phase's model requests on the lease key the proxy adds, so claude only needs some key to start.
10
11
  */
11
12
  import type { ServedPhase } from "./bundle.js";
12
13
  /** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
@@ -14,8 +15,10 @@ export declare const CAPABILITY_TOOLS: Readonly<Record<string, {
14
15
  tools: string[];
15
16
  allowed: string[];
16
17
  }>>;
17
- /** Env names a phase may inherit: the model credential and a working PATH. Nothing else. */
18
- export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "ANTHROPIC_API_KEY", "LANG", "TZ"];
18
+ /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
19
+ export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "LANG", "TZ"];
20
+ /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
21
+ export declare const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
19
22
  export type ToolGrant = {
20
23
  ok: true;
21
24
  tools: string[];
@@ -5,8 +5,9 @@
5
5
  * verified bundle, in its order. A capabilities phase runs headless Claude Code with exactly the
6
6
  * tools its capability ids grant (an unknown id fails the phase; nothing is ever widened). A code
7
7
  * phase runs the bundle's JavaScript in a child node. Both get a scrubbed environment: a fresh
8
- * HOME, the model URL of the runner's own proxy and the model credential, never the executor key,
9
- * the lease key or a GitHub token.
8
+ * HOME, the model URL of the runner's own proxy and a fixed placeholder model key, never a real
9
+ * provider key, the executor key, the lease key or a GitHub token. The gateway authenticates the
10
+ * phase's model requests on the lease key the proxy adds, so claude only needs some key to start.
10
11
  */
11
12
  /** W-L12's capability ids (workflow mnemom.door2 v1), and the Claude Code tools each grants. */
12
13
  export const CAPABILITY_TOOLS = Object.freeze({
@@ -20,8 +21,10 @@ export const CAPABILITY_TOOLS = Object.freeze({
20
21
  // The runner opens the PR itself once every phase has passed; the model gets no tool for it.
21
22
  "pr.open": { tools: [], allowed: [] },
22
23
  });
23
- /** Env names a phase may inherit: the model credential and a working PATH. Nothing else. */
24
- export const PHASE_ENV_PASSTHROUGH = ["PATH", "ANTHROPIC_API_KEY", "LANG", "TZ"];
24
+ /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
25
+ export const PHASE_ENV_PASSTHROUGH = ["PATH", "LANG", "TZ"];
26
+ /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
27
+ export const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
25
28
  export function toolsFor(capabilityIds) {
26
29
  const unknown = capabilityIds.filter((id) => !Object.hasOwn(CAPABILITY_TOOLS, id));
27
30
  if (unknown.length > 0)
@@ -54,6 +57,7 @@ export function phaseEnv(parent, home, modelUrl) {
54
57
  env[name] = v;
55
58
  }
56
59
  env.HOME = home;
60
+ env.ANTHROPIC_API_KEY = PHASE_MODEL_KEY_PLACEHOLDER;
57
61
  env.ANTHROPIC_BASE_URL = modelUrl;
58
62
  env.MNEMOM_RUNNER_MODEL_URL = modelUrl;
59
63
  env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
@@ -3,17 +3,21 @@
3
3
  *
4
4
  * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
5
  * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
- * any `x-mnemom-*` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, and the job's
6
+ * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
7
+ * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
7
8
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
8
9
  * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
9
10
  * which refetches and continues only on a higher sealed version, else fails closed.
10
11
  */
11
12
  import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
12
13
  export declare const LEASE_KEY_HEADER = "x-mnemom-api-key";
14
+ export declare const AGENT_ID_HEADER = "x-mnemom-agent-id";
13
15
  export interface ProxyState {
14
16
  /** The sealed header value to send; swapped in place after an accepted amend. */
15
17
  contractValue: string;
16
18
  leaseKey: string;
19
+ /** The session's agent, from the job read; always sent, a phase's own value never is. */
20
+ agentId: string;
17
21
  }
18
22
  export interface ForwardedRequest {
19
23
  method: string;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
5
  * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
- * any `x-mnemom-*` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, and the job's
6
+ * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
7
+ * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
7
8
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
8
9
  * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
9
10
  * which refetches and continues only on a higher sealed version, else fails closed.
@@ -12,6 +13,7 @@ import { createServer, request as httpRequest, } from "node:http";
12
13
  import { request as httpsRequest } from "node:https";
13
14
  import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
14
15
  export const LEASE_KEY_HEADER = "x-mnemom-api-key";
16
+ export const AGENT_ID_HEADER = "x-mnemom-agent-id";
15
17
  /** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
16
18
  const DROP = new Set([
17
19
  "connection",
@@ -23,6 +25,9 @@ const DROP = new Set([
23
25
  "trailer",
24
26
  "host",
25
27
  "content-length",
28
+ // The phase's model key is a placeholder; the gateway authenticates on the lease key alone.
29
+ "x-api-key",
30
+ "authorization",
26
31
  ]);
27
32
  /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
28
33
  export function outboundHeaders(incoming, state) {
@@ -34,6 +39,7 @@ export function outboundHeaders(incoming, state) {
34
39
  kept[lower] = value;
35
40
  }
36
41
  kept[LEASE_KEY_HEADER] = state.leaseKey;
42
+ kept[AGENT_ID_HEADER] = state.agentId;
37
43
  return applySealedContract(kept, state.contractValue);
38
44
  }
39
45
  /** The largest 409 body the proxy reads for a contract mismatch. */
@@ -0,0 +1,93 @@
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;
@@ -0,0 +1,101 @@
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
+ }