@mnemom/mnemom 0.17.36 → 0.17.38-next.0

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 (121) hide show
  1. package/dist/commands/agent-run.d.ts +11 -0
  2. package/dist/commands/agent-run.js +37 -0
  3. package/dist/commands/agent-share-render.d.ts +5 -5
  4. package/dist/commands/agent-share-render.js +9 -7
  5. package/dist/commands/agent-share-test-issuer.d.ts +1 -1
  6. package/dist/commands/agent-share-test-issuer.js +1 -1
  7. package/dist/commands/agent.d.ts +8 -1
  8. package/dist/commands/agent.js +14 -3
  9. package/dist/commands/scan.d.ts +15 -2
  10. package/dist/commands/scan.js +48 -3
  11. package/dist/host/commands.js +4 -1
  12. package/dist/host/owner.d.ts +2 -0
  13. package/dist/host/verbs.d.ts +16 -1
  14. package/dist/host/verbs.js +45 -3
  15. package/dist/index.js +1 -1
  16. package/dist/lib/commit-report.d.ts +7 -13
  17. package/dist/lib/commit-report.js +12 -5
  18. package/dist/lib/conduct-receipt.d.ts +8 -3
  19. package/dist/lib/conduct-receipt.js +29 -7
  20. package/dist/lib/experimental.d.ts +1 -1
  21. package/dist/lib/experimental.js +1 -12
  22. package/dist/lib/runner/executor-client.d.ts +5 -0
  23. package/dist/lib/runner/executor-client.js +8 -0
  24. package/dist/lib/runner/job.d.ts +37 -0
  25. package/dist/lib/runner/job.js +74 -1
  26. package/dist/lib/runner/phases.d.ts +7 -2
  27. package/dist/lib/runner/phases.js +7 -2
  28. package/dist/lib/runner/proxy.d.ts +26 -2
  29. package/dist/lib/runner/proxy.js +33 -1
  30. package/dist/lib/runner/record-commit.d.ts +16 -0
  31. package/dist/lib/runner/record-commit.js +7 -0
  32. package/dist/lib/scan/spend/anthropic-usage.d.ts +20 -1
  33. package/dist/lib/scan/spend/anthropic-usage.js +32 -7
  34. package/dist/lib/scan/spend/anthropic-wif.d.ts +42 -0
  35. package/dist/lib/scan/spend/anthropic-wif.js +153 -0
  36. package/dist/vendor/durable-contract/index.js +6 -3
  37. package/dist/vendor/shared/durable-contract/cli-surface.js +2 -2
  38. package/dist/vendor/shared/durable-contract/codes.js +4 -1
  39. package/package.json +2 -5
  40. package/node_modules/@mnemom/aip-verifier/LICENSE +0 -190
  41. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +0 -478
  42. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +0 -478
  43. package/node_modules/@mnemom/aip-verifier/dist/index.cjs +0 -971
  44. package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +0 -1
  45. package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +0 -330
  46. package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +0 -330
  47. package/node_modules/@mnemom/aip-verifier/dist/index.js +0 -933
  48. package/node_modules/@mnemom/aip-verifier/dist/index.js.map +0 -1
  49. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +0 -879
  50. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +0 -1
  51. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +0 -96
  52. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +0 -96
  53. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +0 -875
  54. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +0 -1
  55. package/node_modules/@mnemom/aip-verifier/package.json +0 -86
  56. package/node_modules/@noble/ed25519/LICENSE +0 -21
  57. package/node_modules/@noble/ed25519/README.md +0 -371
  58. package/node_modules/@noble/ed25519/index.d.ts +0 -391
  59. package/node_modules/@noble/ed25519/index.js +0 -933
  60. package/node_modules/@noble/ed25519/index.ts +0 -1134
  61. package/node_modules/@noble/ed25519/package.json +0 -54
  62. package/node_modules/@noble/hashes/LICENSE +0 -21
  63. package/node_modules/@noble/hashes/README.md +0 -478
  64. package/node_modules/@noble/hashes/_blake.d.ts +0 -20
  65. package/node_modules/@noble/hashes/_blake.js +0 -59
  66. package/node_modules/@noble/hashes/_md.d.ts +0 -95
  67. package/node_modules/@noble/hashes/_md.js +0 -209
  68. package/node_modules/@noble/hashes/_u64.d.ts +0 -36
  69. package/node_modules/@noble/hashes/_u64.js +0 -77
  70. package/node_modules/@noble/hashes/argon2.d.ts +0 -148
  71. package/node_modules/@noble/hashes/argon2.js +0 -577
  72. package/node_modules/@noble/hashes/blake1.d.ts +0 -162
  73. package/node_modules/@noble/hashes/blake1.js +0 -630
  74. package/node_modules/@noble/hashes/blake2.d.ts +0 -203
  75. package/node_modules/@noble/hashes/blake2.js +0 -573
  76. package/node_modules/@noble/hashes/blake3.d.ts +0 -68
  77. package/node_modules/@noble/hashes/blake3.js +0 -310
  78. package/node_modules/@noble/hashes/eskdf.d.ts +0 -91
  79. package/node_modules/@noble/hashes/eskdf.js +0 -225
  80. package/node_modules/@noble/hashes/hkdf.d.ts +0 -70
  81. package/node_modules/@noble/hashes/hkdf.js +0 -152
  82. package/node_modules/@noble/hashes/hmac.d.ts +0 -50
  83. package/node_modules/@noble/hashes/hmac.js +0 -93
  84. package/node_modules/@noble/hashes/index.d.ts +0 -1
  85. package/node_modules/@noble/hashes/index.js +0 -34
  86. package/node_modules/@noble/hashes/legacy.d.ts +0 -107
  87. package/node_modules/@noble/hashes/legacy.js +0 -348
  88. package/node_modules/@noble/hashes/package.json +0 -91
  89. package/node_modules/@noble/hashes/pbkdf2.d.ts +0 -66
  90. package/node_modules/@noble/hashes/pbkdf2.js +0 -176
  91. package/node_modules/@noble/hashes/scrypt.d.ts +0 -98
  92. package/node_modules/@noble/hashes/scrypt.js +0 -304
  93. package/node_modules/@noble/hashes/sha2.d.ts +0 -176
  94. package/node_modules/@noble/hashes/sha2.js +0 -446
  95. package/node_modules/@noble/hashes/sha3-addons.d.ts +0 -533
  96. package/node_modules/@noble/hashes/sha3-addons.js +0 -973
  97. package/node_modules/@noble/hashes/sha3.d.ts +0 -208
  98. package/node_modules/@noble/hashes/sha3.js +0 -475
  99. package/node_modules/@noble/hashes/src/_blake.ts +0 -67
  100. package/node_modules/@noble/hashes/src/_md.ts +0 -227
  101. package/node_modules/@noble/hashes/src/_u64.ts +0 -111
  102. package/node_modules/@noble/hashes/src/argon2.ts +0 -693
  103. package/node_modules/@noble/hashes/src/blake1.ts +0 -677
  104. package/node_modules/@noble/hashes/src/blake2.ts +0 -648
  105. package/node_modules/@noble/hashes/src/blake3.ts +0 -344
  106. package/node_modules/@noble/hashes/src/eskdf.ts +0 -265
  107. package/node_modules/@noble/hashes/src/hkdf.ts +0 -161
  108. package/node_modules/@noble/hashes/src/hmac.ts +0 -130
  109. package/node_modules/@noble/hashes/src/index.ts +0 -33
  110. package/node_modules/@noble/hashes/src/legacy.ts +0 -361
  111. package/node_modules/@noble/hashes/src/pbkdf2.ts +0 -219
  112. package/node_modules/@noble/hashes/src/scrypt.ts +0 -375
  113. package/node_modules/@noble/hashes/src/sha2.ts +0 -486
  114. package/node_modules/@noble/hashes/src/sha3-addons.ts +0 -1157
  115. package/node_modules/@noble/hashes/src/sha3.ts +0 -525
  116. package/node_modules/@noble/hashes/src/utils.ts +0 -978
  117. package/node_modules/@noble/hashes/src/webcrypto.ts +0 -283
  118. package/node_modules/@noble/hashes/utils.d.ts +0 -560
  119. package/node_modules/@noble/hashes/utils.js +0 -687
  120. package/node_modules/@noble/hashes/webcrypto.d.ts +0 -121
  121. package/node_modules/@noble/hashes/webcrypto.js +0 -228
@@ -1,6 +1,9 @@
1
1
  /**
2
- * The conduct receipt (experimental `record`, Doors W1.2 / W1.3): the engineer's private view of
3
- * one session, printed when `mnemom agent` exits and when a durable session parks.
2
+ * The conduct receipt (Doors W1.2 / W1.3): the engineer's private view of one session, printed
3
+ * when `mnemom agent` exits and when a durable session parks. On by default since 0.17.38
4
+ * (MNE-9013; MNEMOM_AGENT_RECEIPT=0 opts out). It prints ONLY when the server returns tally data:
5
+ * an org outside the record cohort (404), a caller without the role (403), no captured lineage,
6
+ * or a read that never answers inside the deadline all print nothing.
4
7
  *
5
8
  * Conduct receipt
6
9
  * model cost $0.42
@@ -13,14 +16,14 @@
13
16
  *
14
17
  * NEVER BLOCKS EXIT. The whole read, auth refresh included, races a hard deadline
15
18
  * (RECEIPT_DEADLINE_MS): with the API or the record builder down, exit takes at most that much
16
- * longer, and the receipt falls back to the link. The caller keeps its exit code; nothing here
19
+ * longer, and the receipt prints nothing. The caller keeps its exit code; nothing here
17
20
  * throws.
18
21
  *
19
22
  * The lineage key is the gateway's (x-mnemom-lineage-key, set on every response), captured by the
20
23
  * local base-URL proxy into a state file: the CLI never derives one itself.
21
24
  */
22
25
  import { readFileSync } from "node:fs";
23
- import { getRecordTotals } from "./api.js";
26
+ import { getRecordTotals, MnemomApiError } from "./api.js";
24
27
  import { getWebsiteUrl } from "./config.js";
25
28
  /** The hard ceiling on what the receipt adds to exit (W1.3: at most 2 s). */
26
29
  export const RECEIPT_DEADLINE_MS = 1800;
@@ -101,7 +104,7 @@ export function formatMu(mmu) {
101
104
  /** The receipt lines for what was read (pure). */
102
105
  export function receiptLines(read, link, trigger = "exit") {
103
106
  const head = trigger === "park" ? "Conduct receipt (parked)" : "Conduct receipt";
104
- if (!read || read.kind === "unavailable") {
107
+ if (!read || read.kind !== "ok") {
105
108
  return [head, ` record not available yet: ${link}`];
106
109
  }
107
110
  const lines = [head, ` model cost ${formatUsd(read.modelCostMmu)}`];
@@ -117,8 +120,9 @@ export function receiptLines(read, link, trigger = "exit") {
117
120
  */
118
121
  export async function buildConductReceipt(state, deps, trigger = "exit") {
119
122
  const link = state.recordUrl ?? deps.fallbackUrl;
123
+ // No lineage captured: nothing to read, so nothing to print.
120
124
  if (!state.lineageKey)
121
- return receiptLines(null, link, trigger);
125
+ return [];
122
126
  const now = deps.now ?? Date.now;
123
127
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
124
128
  const deadline = now() + (deps.deadlineMs ?? RECEIPT_DEADLINE_MS);
@@ -130,6 +134,10 @@ export async function buildConductReceipt(state, deps, trigger = "exit") {
130
134
  if (left <= 0)
131
135
  break;
132
136
  const read = await readWithin(deps.readTotals, lineageKey, left);
137
+ // Not in the cohort, or no role to read it: stop at once, no exit delay. An earlier good read
138
+ // (the cohort or role changed mid-poll) still prints; otherwise nothing does.
139
+ if (read.kind === "absent")
140
+ break;
133
141
  // A later failure never hides an earlier good read.
134
142
  if (read.kind === "ok" || !last)
135
143
  last = read;
@@ -139,6 +147,9 @@ export async function buildConductReceipt(state, deps, trigger = "exit") {
139
147
  break;
140
148
  await sleep(pollMs);
141
149
  }
150
+ // Only tally data prints: every read failing or timing out stays silent.
151
+ if (!last || last.kind !== "ok")
152
+ return [];
142
153
  return receiptLines(last, link, trigger);
143
154
  }
144
155
  async function readWithin(readTotals, lineageKey, ms) {
@@ -171,7 +182,18 @@ export async function conductReceiptFor(launch, trigger = "exit", deps = {}) {
171
182
  return buildConductReceipt(readRecordState(launch.statePath), {
172
183
  ...deps,
173
184
  readTotals: deps.readTotals ??
174
- (async (lineageKey, signal) => parseTotals(await getRecordTotals(launch.orgId, lineageKey, signal))),
185
+ (async (lineageKey, signal) => {
186
+ try {
187
+ return parseTotals(await getRecordTotals(launch.orgId, lineageKey, signal));
188
+ }
189
+ catch (err) {
190
+ // effectiveStatus, not status: the enforce hook may rewrite a 404 to a synthetic 500.
191
+ if (err instanceof MnemomApiError && [403, 404].includes(err.effectiveStatus)) {
192
+ return { kind: "absent" };
193
+ }
194
+ throw err;
195
+ }
196
+ }),
175
197
  fallbackUrl: deps.fallbackUrl ?? `${getWebsiteUrl()}/agents/${encodeURIComponent(launch.agentId)}`,
176
198
  }, trigger);
177
199
  }
@@ -12,7 +12,7 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable" | "dispatch";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "fuel" | "durable" | "dispatch";
16
16
  /**
17
17
  * Features that used to be experimental and are now on by default for everyone. Their ids are
18
18
  * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
@@ -24,6 +24,7 @@ export const GRADUATED_FEATURES = {
24
24
  since: "0.17.23",
25
25
  optOut: "--no-compaction-intercept, `compaction_intercept = false` in launcher.toml, or MNEMOM_AGENT_COMPACTION_INTERCEPT=0",
26
26
  },
27
+ record: { since: "0.17.38", optOut: "MNEMOM_AGENT_RECEIPT=0" },
27
28
  };
28
29
  /** The one-line note for `mnemom experimental enable|disable <graduated id>`; null otherwise. */
29
30
  export function graduatedNote(id) {
@@ -85,18 +86,6 @@ export const EXPERIMENTAL_FEATURES = [
85
86
  env: [],
86
87
  },
87
88
  },
88
- {
89
- // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
90
- id: "record",
91
- summary: "Print the conduct receipt at exit: the session's measured model cost and μ, or the record link",
92
- since: "0.17.32",
93
- gates: {
94
- flags: [],
95
- configKeys: [],
96
- commands: [],
97
- env: [],
98
- },
99
- },
100
89
  {
101
90
  id: "fuel",
102
91
  summary: "The fuel step: `mnemom agent fuel <file>` seals a batch of job contracts with one confirmation and commits them to the repo",
@@ -8,6 +8,7 @@
8
8
  * Nothing here decides an outcome: each call returns its status, body and error code, and the job
9
9
  * (job.ts) maps them to 34.12's reasons. Never throws: a network failure is status 0.
10
10
  */
11
+ import type { RecordCommitBody } from "./record-commit.js";
11
12
  import type { ExecAppendBody } from "./exec-append.js";
12
13
  export interface HttpResult {
13
14
  readonly status: number;
@@ -34,6 +35,8 @@ export declare const JOB_READ_PATH: (sessionId: string) => string;
34
35
  /** The served bundle fetch, per claim (W-L12). */
35
36
  export declare const BUNDLE_FETCH_PATH: (sessionId: string) => string;
36
37
  export declare const JWKS_PATH = "/v1/.well-known/jwks.json";
38
+ /** Record's commit index (ruling 34.05 (a)): the lease key may write the session's own commits. */
39
+ export declare const RECORD_COMMITS_PATH: (orgId: string) => string;
37
40
  export interface RunnerApi {
38
41
  readyList(limit: number): Promise<HttpResult>;
39
42
  attest(sessionId: string, nonce: string): Promise<HttpResult>;
@@ -63,6 +66,8 @@ export interface RunnerApi {
63
66
  job(sessionId: string): Promise<HttpResult>;
64
67
  bundle(sessionId: string, leaseKey: string): Promise<HttpResult>;
65
68
  jwks(): Promise<HttpResult>;
69
+ /** One RecordCommit/v1 body, with the lease key; the api pins it to the session's lineage. */
70
+ recordCommit(orgId: string, body: RecordCommitBody, leaseKey: string): Promise<HttpResult>;
66
71
  }
67
72
  export declare function runnerApi(opts: {
68
73
  apiUrl: string;
@@ -34,6 +34,9 @@ export const JOB_READ_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/
34
34
  /** The served bundle fetch, per claim (W-L12). */
35
35
  export const BUNDLE_FETCH_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/bundle`;
36
36
  export const JWKS_PATH = "/v1/.well-known/jwks.json";
37
+ /** Record's commit index (ruling 34.05 (a)): the lease key may write the session's own commits. */
38
+ export const RECORD_COMMITS_PATH = (orgId) => `/v1/orgs/${encodeURIComponent(orgId)}/record/commits`;
39
+ const ORG_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
37
40
  const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
38
41
  export function runnerApi(opts) {
39
42
  const base = opts.apiUrl.replace(/\/+$/, "");
@@ -93,5 +96,10 @@ export function runnerApi(opts) {
93
96
  job: (id) => call(JOB_READ_PATH(scoped(id)), "GET", ex),
94
97
  bundle: (id, leaseKey) => call(BUNDLE_FETCH_PATH(scoped(id)), "GET", leaseKey),
95
98
  jwks: () => call(JWKS_PATH, "GET", null),
99
+ recordCommit: (orgId, body, leaseKey) => {
100
+ if (!ORG_ID_RE.test(orgId))
101
+ throw new RangeError("org id is not a Mnemom org id");
102
+ return call(RECORD_COMMITS_PATH(orgId), "POST", leaseKey, body);
103
+ },
96
104
  };
97
105
  }
@@ -15,6 +15,7 @@ import { type HttpResult, type RunnerApi } from "./executor-client.js";
15
15
  import { type OutcomeFields, type RefusedReason } from "./outcome.js";
16
16
  import { type PhaseInvocation } from "./phases.js";
17
17
  import { type ModelProxy, type ProxyState } from "./proxy.js";
18
+ import type { RecordCommitBody } from "./record-commit.js";
18
19
  import { type RunnerFailMode } from "./settings.js";
19
20
  /** A claimed job's working copy. */
20
21
  export interface Workspace {
@@ -45,6 +46,13 @@ export interface JobDeps {
45
46
  openPr(repo: string, ws: Workspace, title: string, body: string): Promise<string>;
46
47
  /** Commits on the job branch that the base does not have; 0 means there is nothing to open. */
47
48
  commitsAhead(ws: Workspace): Promise<number>;
49
+ /** The working copy's HEAD sha before a phase runs; null only for a clone with no commit. Throws otherwise. */
50
+ headSha(ws: Workspace): Promise<string | null>;
51
+ /**
52
+ * RecordCommit/v1 bodies for the commits made since `since` (null: since the base), oldest
53
+ * first, under `lineageKey` and the job's repo. A commit that cannot be read is left out.
54
+ */
55
+ commitBodies(ws: Workspace, since: string | null, lineageKey: string, repo: string): Promise<RecordCommitBody[]>;
48
56
  enableAutoMerge(prUrl: string): Promise<void>;
49
57
  cleanup(ws: Workspace): Promise<void>;
50
58
  }
@@ -69,6 +77,35 @@ export declare function authRefusal(r: HttpResult): RefusedReason | null;
69
77
  * outcome. Throws only on a runner bug.
70
78
  */
71
79
  export declare function runJob(sessionId: string, opts: JobOptions, deps: JobDeps): Promise<JobResult>;
80
+ /** What the job reported to Record's commit index; logged once, never part of the outcome. */
81
+ export interface CommitReport {
82
+ reported: number;
83
+ /** Refused by the api (4xx other than 404) or unreachable: left out of the index. */
84
+ dropped: number;
85
+ /** Made in a phase whose model calls left no lineage key, so there was nothing to file under. */
86
+ unlinked: number;
87
+ /** Phases whose starting HEAD could not be read: their commits are never guessed at. */
88
+ skipped: number;
89
+ /** The index answered 404 (Record dark for the org): nothing more is sent for this job. */
90
+ dark: boolean;
91
+ }
92
+ /** Waits before each retry of a 429, 5xx or unreachable report; then the commit is dropped. */
93
+ export declare const COMMIT_RETRY_MS: readonly [1000, 3000];
94
+ /**
95
+ * Report the commits one phase made (ruling 34.05 (a)): each under the lineage key the gateway
96
+ * answered for that phase's conversation, with the lease key, from this process. Never throws and
97
+ * never changes the outcome; the lease key is sent only as the request credential, never logged.
98
+ */
99
+ export declare function reportPhaseCommits(deps: JobDeps, report: CommitReport, p: {
100
+ ws: Workspace;
101
+ /** The HEAD before the phase; null: a clone with no commit; undefined: it could not be read. */
102
+ before: string | null | undefined;
103
+ conversationId: string;
104
+ proxy: Pick<ModelProxy, "lineageFor">;
105
+ orgId: string;
106
+ repo: string;
107
+ leaseKey: string;
108
+ }): Promise<void>;
72
109
  /**
73
110
  * Report a failed job after claim (34.12 (6), (10)); a refusal releases the lease instead. The
74
111
  * fail reason vocabulary is the Durable lead's to rule (A3 accepts only transcript_lost today); a
@@ -20,7 +20,7 @@ import { errorCode, errorDetails } from "./executor-client.js";
20
20
  import { doneOutcome, failedOutcome, phaseReason, refusedOutcome, } from "./outcome.js";
21
21
  import { planPhase } from "./phases.js";
22
22
  import { canonNodeKey, prBody } from "./pr-body.js";
23
- import { requestSummary } from "./proxy.js";
23
+ import { phaseConversationId, requestSummary } from "./proxy.js";
24
24
  import { resolveMergeStrategy } from "./settings.js";
25
25
  const TOLERATED = RUNNER_OPEN_MODE_TOLERATED;
26
26
  const OPEN_MODE_RETRIES = 3;
@@ -189,6 +189,13 @@ export async function runJob(sessionId, opts, deps) {
189
189
  let leaseLost = false;
190
190
  let ws = null;
191
191
  let proxy = null;
192
+ const commits = {
193
+ reported: 0,
194
+ dropped: 0,
195
+ unlinked: 0,
196
+ skipped: 0,
197
+ dark: false,
198
+ };
192
199
  let heartbeat = null;
193
200
  const phaseAbort = new AbortController();
194
201
  const onStop = () => phaseAbort.abort();
@@ -303,10 +310,17 @@ export async function runJob(sessionId, opts, deps) {
303
310
  contractValue: header.value,
304
311
  leaseKey: claim.leaseKey,
305
312
  agentId: job.agent_id,
313
+ conversationId: phaseConversationId(sessionId, claim.epoch, phases[0].order),
306
314
  };
307
315
  proxy = await deps.startProxy(state);
308
316
  let opensPr = false;
309
317
  for (const phase of phases) {
318
+ state.conversationId = phaseConversationId(sessionId, claim.epoch, phase.order);
319
+ // undefined: the HEAD could not be read, so this phase's commits cannot be told apart.
320
+ const before = await deps.headSha(ws).catch((e) => {
321
+ deps.log(`reading HEAD before phase ${phase.order} failed: ${e.message}`);
322
+ return undefined;
323
+ });
310
324
  const planned = planPhase(phase);
311
325
  if (!planned.ok) {
312
326
  deps.log(`phase ${phase.order} ${phase.name}: ${planned.detail}`);
@@ -354,6 +368,15 @@ export async function runJob(sessionId, opts, deps) {
354
368
  }
355
369
  break;
356
370
  }
371
+ await reportPhaseCommits(deps, commits, {
372
+ ws,
373
+ before,
374
+ conversationId: state.conversationId,
375
+ proxy,
376
+ orgId: bundle.org_id,
377
+ repo: job.repo,
378
+ leaseKey: claim.leaseKey,
379
+ });
357
380
  }
358
381
  if (opensPr) {
359
382
  try {
@@ -390,12 +413,62 @@ export async function runJob(sessionId, opts, deps) {
390
413
  await proxy.close();
391
414
  // One line per job that reached its phases: what every model request carried (W3.11).
392
415
  deps.log(`model requests: ${JSON.stringify(requestSummary(proxy.forwarded))}`);
416
+ deps.log(`commits reported: ${JSON.stringify(commits)}`);
393
417
  }
394
418
  // A workspace left behind never changes the outcome; it is logged for the operator.
395
419
  if (ws)
396
420
  await deps.cleanup(ws).catch((e) => deps.log(`cleanup failed: ${e.message}`));
397
421
  }
398
422
  }
423
+ /** Waits before each retry of a 429, 5xx or unreachable report; then the commit is dropped. */
424
+ export const COMMIT_RETRY_MS = [1_000, 3_000];
425
+ /**
426
+ * Report the commits one phase made (ruling 34.05 (a)): each under the lineage key the gateway
427
+ * answered for that phase's conversation, with the lease key, from this process. Never throws and
428
+ * never changes the outcome; the lease key is sent only as the request credential, never logged.
429
+ */
430
+ export async function reportPhaseCommits(deps, report, p) {
431
+ if (report.dark)
432
+ return;
433
+ if (p.before === undefined) {
434
+ report.skipped += 1;
435
+ deps.log(`${p.conversationId}: commits not reported, the phase's starting HEAD is unknown`);
436
+ return;
437
+ }
438
+ try {
439
+ const lineageKey = p.proxy.lineageFor(p.conversationId);
440
+ if (lineageKey === null) {
441
+ const n = (await deps.commitBodies(p.ws, p.before, "-", p.repo)).length;
442
+ report.unlinked += n;
443
+ if (n > 0)
444
+ deps.log(`${n} commit(s) in ${p.conversationId} have no lineage key: not reported`);
445
+ return;
446
+ }
447
+ for (const body of await deps.commitBodies(p.ws, p.before, lineageKey, p.repo)) {
448
+ let res = await deps.api.recordCommit(p.orgId, body, p.leaseKey);
449
+ for (const wait of COMMIT_RETRY_MS) {
450
+ if (!(res.status === 0 || res.status === 429 || res.status >= 500))
451
+ break;
452
+ await deps.sleep(wait);
453
+ res = await deps.api.recordCommit(p.orgId, body, p.leaseKey);
454
+ }
455
+ if (res.status >= 200 && res.status < 300)
456
+ report.reported += 1;
457
+ else if (res.status === 404) {
458
+ report.dark = true;
459
+ deps.log("the commit index answered 404 (Record is off for this org): commits not reported");
460
+ return;
461
+ }
462
+ else {
463
+ report.dropped += 1;
464
+ deps.log(`commit ${body.sha.slice(0, 12)} not reported: ${res.code ?? res.networkError ?? res.status}`);
465
+ }
466
+ }
467
+ }
468
+ catch (e) {
469
+ deps.log(`commit report failed: ${e.message}`);
470
+ }
471
+ }
399
472
  async function fetchBundle(deps, sessionId, claim) {
400
473
  const [jwksRes, res] = await Promise.all([
401
474
  deps.api.jwks(),
@@ -15,8 +15,13 @@ export declare const CAPABILITY_TOOLS: Readonly<Record<string, {
15
15
  tools: string[];
16
16
  allowed: string[];
17
17
  }>>;
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"];
18
+ /**
19
+ * Env names a phase may inherit: a working PATH, the shell and locale. Nothing else. Claude Code's
20
+ * Bash tool needs SHELL to name a POSIX shell; without it, on an image with no bash on PATH (Alpine),
21
+ * every Bash call fails and a phase can write files but never commit them (door2-path image run
22
+ * 41940973594 ended pr_no_changes).
23
+ */
24
+ export declare const PHASE_ENV_PASSTHROUGH: readonly ["PATH", "SHELL", "LANG", "TZ"];
20
25
  /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
21
26
  export declare const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
22
27
  export type ToolGrant = {
@@ -21,8 +21,13 @@ export const CAPABILITY_TOOLS = Object.freeze({
21
21
  // The runner opens the PR itself once every phase has passed; the model gets no tool for it.
22
22
  "pr.open": { tools: [], allowed: [] },
23
23
  });
24
- /** Env names a phase may inherit: a working PATH and locale. Nothing else. */
25
- export const PHASE_ENV_PASSTHROUGH = ["PATH", "LANG", "TZ"];
24
+ /**
25
+ * Env names a phase may inherit: a working PATH, the shell and locale. Nothing else. Claude Code's
26
+ * Bash tool needs SHELL to name a POSIX shell; without it, on an image with no bash on PATH (Alpine),
27
+ * every Bash call fails and a phase can write files but never commit them (door2-path image run
28
+ * 41940973594 ended pr_no_changes).
29
+ */
30
+ export const PHASE_ENV_PASSTHROUGH = ["PATH", "SHELL", "LANG", "TZ"];
26
31
  /** Not a secret: lets claude start; the proxy drops it before the gateway (ruling: option (i)). */
27
32
  export const PHASE_MODEL_KEY_PLACEHOLDER = "mnemom-runner-placeholder";
28
33
  export function toolsFor(capabilityIds) {
@@ -7,24 +7,39 @@
7
7
  * Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
8
8
  * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
9
9
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
10
- * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
10
+ * sees the lease key. Each phase gets its own `x-mnemom-conversation-id` (from the session, lease
11
+ * epoch and phase order), so the gateway keys one lineage per phase instead of a body-hash one per
12
+ * request, and the `x-mnemom-lineage-key` it answers is kept for the phase's commit report. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
11
13
  * which refetches and continues only on a higher sealed version, else fails closed.
12
14
  */
13
15
  import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
14
16
  export declare const LEASE_KEY_HEADER = "x-mnemom-api-key";
15
17
  export declare const AGENT_ID_HEADER = "x-mnemom-agent-id";
18
+ export declare const CONVERSATION_ID_HEADER = "x-mnemom-conversation-id";
19
+ export declare const LINEAGE_KEY_HEADER = "x-mnemom-lineage-key";
20
+ /**
21
+ * One phase's conversation id: the session, the lease epoch and the phase order, so a retried
22
+ * claim or another phase never shares a lineage. Within the gateway's 1-256 printable, no-`#` id.
23
+ */
24
+ export declare function phaseConversationId(sessionId: string, epoch: number, order: number): string;
16
25
  export interface ProxyState {
17
26
  /** The sealed header value to send; swapped in place after an accepted amend. */
18
27
  contractValue: string;
19
28
  leaseKey: string;
20
29
  /** The session's agent, from the job read; always sent, a phase's own value never is. */
21
30
  agentId: string;
31
+ /** The running phase's conversation id; the job sets it before each phase. */
32
+ conversationId: string;
22
33
  }
23
34
  export interface ForwardedRequest {
24
35
  method: string;
25
36
  path: string;
26
37
  contract: string;
27
38
  status: number;
39
+ /** The conversation id the request carried. */
40
+ conversation: string;
41
+ /** The gateway's `x-mnemom-lineage-key` answer, when it sent a well-formed one. */
42
+ lineage: string | null;
28
43
  }
29
44
  export interface ModelProxy {
30
45
  url: string;
@@ -34,17 +49,26 @@ export interface ModelProxy {
34
49
  contractMismatch: {
35
50
  expected_version: number | null;
36
51
  } | null;
52
+ /** The lineage key the gateway last answered for a conversation id, or null when none came. */
53
+ lineageFor(conversationId: string): string | null;
37
54
  close(): Promise<void>;
38
55
  }
39
56
  /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
40
57
  export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState, modelKey: string): Record<string, string | string[] | undefined>;
41
- /** What the job's model requests carried: each contract by its sha256, in first-seen order. */
58
+ /**
59
+ * What the job's model requests carried: each contract by its sha256, in first-seen order, and the
60
+ * lineage key the gateway answered for each conversation (one per phase), in first-seen order.
61
+ */
42
62
  export interface RequestSummary {
43
63
  count: number;
44
64
  contracts: {
45
65
  contract_sha256: string;
46
66
  requests: number;
47
67
  }[];
68
+ lineages: {
69
+ conversation_id: string;
70
+ lineage_key: string | null;
71
+ }[];
48
72
  }
49
73
  /**
50
74
  * One job's forwarded requests, summarised for the operator's log and for the door2-path header
@@ -7,7 +7,9 @@
7
7
  * Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
8
8
  * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
9
9
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
10
- * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
10
+ * sees the lease key. Each phase gets its own `x-mnemom-conversation-id` (from the session, lease
11
+ * epoch and phase order), so the gateway keys one lineage per phase instead of a body-hash one per
12
+ * request, and the `x-mnemom-lineage-key` it answers is kept for the phase's commit report. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
11
13
  * which refetches and continues only on a higher sealed version, else fails closed.
12
14
  */
13
15
  import { createServer, request as httpRequest, } from "node:http";
@@ -16,6 +18,17 @@ import { createHash } from "node:crypto";
16
18
  import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
17
19
  export const LEASE_KEY_HEADER = "x-mnemom-api-key";
18
20
  export const AGENT_ID_HEADER = "x-mnemom-agent-id";
21
+ export const CONVERSATION_ID_HEADER = "x-mnemom-conversation-id";
22
+ export const LINEAGE_KEY_HEADER = "x-mnemom-lineage-key";
23
+ /** The gateway's lineage key as echoed: short, printable, no spaces. Anything else is not kept. */
24
+ const LINEAGE_KEY_RE = /^[\x21-\x7e]{1,256}$/;
25
+ /**
26
+ * One phase's conversation id: the session, the lease epoch and the phase order, so a retried
27
+ * claim or another phase never shares a lineage. Within the gateway's 1-256 printable, no-`#` id.
28
+ */
29
+ export function phaseConversationId(sessionId, epoch, order) {
30
+ return `durable-${sessionId}-e${epoch}-p${order}`;
31
+ }
19
32
  /** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
20
33
  const DROP = new Set([
21
34
  "connection",
@@ -43,8 +56,16 @@ export function outboundHeaders(incoming, state, modelKey) {
43
56
  kept[LEASE_KEY_HEADER] = state.leaseKey;
44
57
  kept["x-api-key"] = modelKey;
45
58
  kept[AGENT_ID_HEADER] = state.agentId;
59
+ kept[CONVERSATION_ID_HEADER] = state.conversationId;
46
60
  return applySealedContract(kept, state.contractValue);
47
61
  }
62
+ /** The last well-formed lineage key answered for each conversation, in first-seen order. */
63
+ function lineagesOf(forwarded) {
64
+ const m = new Map();
65
+ for (const f of forwarded)
66
+ m.set(f.conversation, f.lineage ?? m.get(f.conversation) ?? null);
67
+ return m;
68
+ }
48
69
  /**
49
70
  * One job's forwarded requests, summarised for the operator's log and for the door2-path header
50
71
  * check (W3.11): the sha256 of each header value's decoded bytes is the contract it sealed to.
@@ -58,6 +79,10 @@ export function requestSummary(forwarded) {
58
79
  return {
59
80
  count: forwarded.length,
60
81
  contracts: [...by].map(([contract_sha256, requests]) => ({ contract_sha256, requests })),
82
+ lineages: [...lineagesOf(forwarded)].map(([conversation_id, lineage_key]) => ({
83
+ conversation_id,
84
+ lineage_key,
85
+ })),
61
86
  };
62
87
  }
63
88
  /** The largest 409 body the proxy reads for a contract mismatch. */
@@ -102,11 +127,13 @@ export async function startModelProxy(gatewayUrl, state, modelKey) {
102
127
  url: "",
103
128
  forwarded,
104
129
  contractMismatch: null,
130
+ lineageFor: (id) => lineagesOf(forwarded).get(id) ?? null,
105
131
  close: async () => { },
106
132
  };
107
133
  const server = createServer((req, res) => {
108
134
  const headers = outboundHeaders(req.headers, state, modelKey);
109
135
  const sent = headers[CONTRACT_HEADER];
136
+ const conversation = state.conversationId;
110
137
  const out = send({
111
138
  protocol: upstream.protocol,
112
139
  hostname: upstream.hostname,
@@ -116,11 +143,14 @@ export async function startModelProxy(gatewayUrl, state, modelKey) {
116
143
  headers,
117
144
  }, (up) => {
118
145
  const status = up.statusCode ?? 502;
146
+ const echoed = up.headers[LINEAGE_KEY_HEADER];
119
147
  forwarded.push({
120
148
  method: req.method ?? "GET",
121
149
  path: req.url ?? "/",
122
150
  contract: sent,
123
151
  status,
152
+ conversation,
153
+ lineage: typeof echoed === "string" && LINEAGE_KEY_RE.test(echoed) ? echoed : null,
124
154
  });
125
155
  if (status === 409) {
126
156
  // Buffered to read the mismatch, up to a cap; a larger body is relayed unread.
@@ -162,6 +192,8 @@ export async function startModelProxy(gatewayUrl, state, modelKey) {
162
192
  path: req.url ?? "/",
163
193
  contract: sent,
164
194
  status: 502,
195
+ conversation,
196
+ lineage: null,
165
197
  });
166
198
  upstreamFailed(res, e);
167
199
  });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The RecordCommit/v1 body Record's commit index takes (`POST /v1/orgs/{org}/record/commits`).
3
+ * It lives here so the runner can type its commit report without loading the CLI's commit reporter
4
+ * (which spawns git and reads auth); lib/commit-report.ts re-exports both names.
5
+ */
6
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
7
+ export declare const COMMIT_SCHEMA = "RecordCommit/v1";
8
+ /** RecordCommit/v1: strings only, every field required; the route rejects an unknown field. */
9
+ export interface RecordCommitBody {
10
+ schema: typeof COMMIT_SCHEMA;
11
+ lineage_key: string;
12
+ repo: string;
13
+ branch: string;
14
+ sha: string;
15
+ patch_id: string;
16
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The RecordCommit/v1 body Record's commit index takes (`POST /v1/orgs/{org}/record/commits`).
3
+ * It lives here so the runner can type its commit report without loading the CLI's commit reporter
4
+ * (which spawns git and reads auth); lib/commit-report.ts re-exports both names.
5
+ */
6
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
7
+ export const COMMIT_SCHEMA = "RecordCommit/v1";
@@ -1,12 +1,24 @@
1
1
  export declare const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
2
2
  export declare const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
3
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
4
+ export declare const REPORT_PATHS: readonly string[];
5
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
6
+ export declare function assertReportPath(path: string): void;
7
+ /** The exact auth header to send; same shape as mnemom-api src/org-scan/usage/credential-source.ts. */
8
+ export type AnthropicAuthHeader = {
9
+ header: "x-api-key";
10
+ value: string;
11
+ } | {
12
+ header: "authorization";
13
+ value: string;
14
+ };
3
15
  export declare const DEFAULT_MAX_PAGES = 20;
4
16
  /** One shared deadline for a request including its retries (not N x timeout). */
5
17
  export declare const DEFAULT_TIMEOUT_MS = 15000;
6
18
  export declare const DEFAULT_MAX_RETRIES = 2;
7
19
  /** Shape check only; the key is never echoed, even on a mismatch. */
8
20
  export declare const isAdminKeyShape: (k: unknown) => k is string;
9
- export type UsageFailure = "key_invalid" | "key_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
21
+ export type UsageFailure = "key_invalid" | "key_refused" | "token_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
10
22
  /** Fixed sentence for a failure reason. */
11
23
  export declare const usageFailureText: (r: UsageFailure) => string;
12
24
  /** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
@@ -55,3 +67,10 @@ export declare function fetchMeasuredUsage(key: string | undefined, period: {
55
67
  start: string;
56
68
  end: string;
57
69
  }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
70
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
71
+ export declare function bearerAuth(token: string): AnthropicAuthHeader;
72
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
73
+ export declare function fetchMeasuredUsageWithAuth(auth: AnthropicAuthHeader, period: {
74
+ start: string;
75
+ end: string;
76
+ }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;