@mnemom/mnemom 0.17.31 → 0.17.32-next.1

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 (46) hide show
  1. package/dist/base-url-proxy.mjs +30 -0
  2. package/dist/commands/agent-fuel.d.ts +75 -0
  3. package/dist/commands/agent-fuel.js +298 -0
  4. package/dist/commands/agent-implicit-notice.d.ts +31 -0
  5. package/dist/commands/agent-implicit-notice.js +73 -0
  6. package/dist/commands/agent-session.d.ts +2 -1
  7. package/dist/commands/agent-session.js +2 -1
  8. package/dist/commands/agent-share-fixture.d.ts +20 -0
  9. package/dist/commands/agent-share-fixture.js +134 -0
  10. package/dist/commands/agent-share.d.ts +87 -0
  11. package/dist/commands/agent-share.js +251 -0
  12. package/dist/commands/agent.d.ts +75 -2
  13. package/dist/commands/agent.js +165 -28
  14. package/dist/commands/status.js +1 -1
  15. package/dist/commands/update.js +3 -1
  16. package/dist/commands/validate.d.ts +1 -1
  17. package/dist/commands/validate.js +1 -1
  18. package/dist/index.js +68 -12
  19. package/dist/lib/agent.d.ts +34 -19
  20. package/dist/lib/agent.js +41 -32
  21. package/dist/lib/api.d.ts +66 -0
  22. package/dist/lib/api.js +50 -10
  23. package/dist/lib/canonical-json.d.ts +12 -0
  24. package/dist/lib/canonical-json.js +22 -0
  25. package/dist/lib/commit-report.d.ts +109 -0
  26. package/dist/lib/commit-report.js +205 -0
  27. package/dist/lib/conduct-receipt.d.ts +81 -0
  28. package/dist/lib/conduct-receipt.js +177 -0
  29. package/dist/lib/contract-hash.d.ts +61 -0
  30. package/dist/lib/contract-hash.js +126 -0
  31. package/dist/lib/experimental.d.ts +10 -1
  32. package/dist/lib/experimental.js +35 -1
  33. package/dist/lib/oauth.d.ts +2 -1
  34. package/dist/lib/oauth.js +2 -1
  35. package/dist/lib/statusline-bin.d.ts +2 -2
  36. package/dist/lib/statusline-bin.js +4 -7
  37. package/dist/lib/statusline-feed.d.ts +33 -0
  38. package/dist/lib/statusline-feed.js +68 -0
  39. package/dist/lib/statusline.d.ts +39 -88
  40. package/dist/lib/statusline.js +58 -250
  41. package/dist/lib/update/decide.d.ts +22 -3
  42. package/dist/lib/update/decide.js +35 -12
  43. package/dist/lib/update/doctor.js +5 -1
  44. package/dist/lib/update/run.d.ts +2 -0
  45. package/dist/lib/update/run.js +22 -8
  46. package/package.json +1 -1
package/dist/lib/agent.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { createHash, randomUUID } from "node:crypto";
23
23
  import { readFileSync } from "node:fs";
24
24
  import { join } from "node:path";
25
+ import { canonicalJson } from "./canonical-json.js";
25
26
  import { getGatewayUrl } from "./config.js";
26
27
  import { normSession } from "./slack-route.js";
27
28
  /** The us-2/prod gateway host. The default door — never any other cell silently. */
@@ -313,6 +314,13 @@ export function readCcSettings(opts) {
313
314
  const view = {};
314
315
  if (typeof o.model === "string")
315
316
  view.model = o.model;
317
+ if (o.attribution === false)
318
+ view.commitAttribution = false;
319
+ else if (o.attribution && typeof o.attribution === "object") {
320
+ const commit = o.attribution.commit;
321
+ if (typeof commit === "string")
322
+ view.commitAttribution = commit;
323
+ }
316
324
  if (o.env && typeof o.env === "object") {
317
325
  view.env = Object.fromEntries(Object.entries(o.env).filter((e) => typeof e[1] === "string"));
318
326
  }
@@ -438,6 +446,7 @@ export const AGENT_SUB_VERBS = new Set([
438
446
  "move",
439
447
  "export",
440
448
  "sessions",
449
+ "fuel",
441
450
  "show",
442
451
  "goal",
443
452
  ]);
@@ -480,23 +489,7 @@ export function isPositiveNumber(value) {
480
489
  const n = Number(s);
481
490
  return Number.isFinite(n) && n > 0;
482
491
  }
483
- /**
484
- * Canonical JSON — object keys sorted recursively, arrays in order, no
485
- * whitespace, `undefined` keys dropped. Byte-identical to the gateway's
486
- * `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
487
- * sha we print match what the gateway seals.
488
- */
489
- export function canonicalJson(value) {
490
- if (value === null || typeof value !== "object")
491
- return JSON.stringify(value);
492
- if (Array.isArray(value))
493
- return `[${value.map(canonicalJson).join(",")}]`;
494
- const obj = value;
495
- const keys = Object.keys(obj)
496
- .filter((k) => obj[k] !== undefined)
497
- .sort();
498
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
499
- }
492
+ export { canonicalJson } from "./canonical-json.js";
500
493
  /** sha256 hex (first 16 chars) of a string — the operator-eyeball contract hash. */
501
494
  export function sha256Hex16(text) {
502
495
  return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
@@ -652,7 +645,8 @@ export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
652
645
  /**
653
646
  * The gateway env key that has it measure each request's saving for a single session and put
654
647
  * it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
655
- * the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
648
+ * it lands in Claude Code's transcript. The status bar never shows it (decision 14: never
649
+ * "saved"). Default OFF on the gateway; sent `"on"`
656
650
  * only while the `measured-savings` experimental feature is on and the launched CLI is Claude
657
651
  * Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
658
652
  * where the measured saving is honestly $0.00.
@@ -1283,9 +1277,9 @@ export function resolveCompactionIntercept(opts) {
1283
1277
  return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
1284
1278
  }
1285
1279
  /**
1286
- * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
1287
- * status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
1288
- * only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
1280
+ * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header)? Only
1281
+ * with the `measured-savings` experimental feature on, and only for Claude Code, whose
1282
+ * transcript keeps the measurement. No flag or
1289
1283
  * launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
1290
1284
  */
1291
1285
  export function resolveUsageSavings(opts) {
@@ -1333,27 +1327,42 @@ export function buildHandoffMcpConfig(p) {
1333
1327
  export function buildHandoffMcpConfigJson(p) {
1334
1328
  return JSON.stringify(buildHandoffMcpConfig(p));
1335
1329
  }
1330
+ /** Does a Claude Code passthrough run headless (`-p` / `--print`)? */
1331
+ export function passthroughIsPrint(passthrough) {
1332
+ return passthrough.some((a) => a === "-p" || a === "--print" || a.startsWith("--print="));
1333
+ }
1336
1334
  /**
1337
1335
  * Is this launch implicit-mode? Precedence, highest wins:
1338
1336
  * 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
1339
1337
  * 2. a `--goal` statement — a sealed contract is a per-launch choice that the
1340
- * standing config default must never silently override;
1338
+ * standing config default must never silently override, so a launch never
1339
+ * runs both modes;
1341
1340
  * 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
1342
1341
  * keeps it implicit;
1343
- * 4. with the `implicit` experimental feature ON, IMPLICIT: the gateway infers
1344
- * the goal from the user's own messages (turn it off for a launch with
1345
- * `--no-implicit`, or standing with `goal_mode = "off"`); with it OFF (the
1346
- * default), OFF.
1342
+ * 4. with the `implicit` experimental feature ON, IMPLICIT (as before T16);
1343
+ * 5. on a prerelease (`next`) build, IMPLICIT for an interactive or Remote Control
1344
+ * launch and OFF for a headless `-p` one (OD-13: T16 never covers `-p`);
1345
+ * 6. otherwise OFF — stable without the experimental feature behaves as today.
1347
1346
  * Pure so every arm is unit-tested; the caller passes the resolved value on to
1348
1347
  * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
1349
1348
  * combined with a contract flag — that conflict is EXPLICIT and is reported).
1350
1349
  */
1351
- export function resolveImplicit(opts) {
1350
+ export function resolveImplicitSource(opts) {
1352
1351
  if (opts.implicit !== undefined)
1353
- return opts.implicit;
1352
+ return { implicit: opts.implicit, source: "flag" };
1354
1353
  if (hasText(opts.goal))
1355
- return false;
1356
- if (opts.experimentalOn === false)
1357
- return false;
1358
- return opts.configGoalMode !== "off";
1354
+ return { implicit: false, source: "goal" };
1355
+ const experimentalOn = opts.experimentalOn !== false;
1356
+ if (!experimentalOn && !opts.prerelease)
1357
+ return { implicit: false, source: "default" };
1358
+ if (opts.configGoalMode !== undefined) {
1359
+ return { implicit: opts.configGoalMode === "implicit", source: "config" };
1360
+ }
1361
+ if (experimentalOn)
1362
+ return { implicit: true, source: "experimental" };
1363
+ return { implicit: !opts.print, source: opts.print ? "default" : "prerelease" };
1364
+ }
1365
+ /** `resolveImplicitSource(opts).implicit`. */
1366
+ export function resolveImplicit(opts) {
1367
+ return resolveImplicitSource(opts).implicit;
1359
1368
  }
package/dist/lib/api.d.ts CHANGED
@@ -236,6 +236,12 @@ export declare function verifyBinding(agentId: string, keyHash: string): Promise
236
236
  bound: boolean;
237
237
  key_prefix: string | null;
238
238
  }>;
239
+ /**
240
+ * GET /v1/agents/:id, sent with the caller's credentials. The API answers with the projection
241
+ * the caller may see: the org-member view (claimed_by, org_id, `caller: "org_member"`) for an
242
+ * agent in one of the caller's orgs, the public view otherwise. A pinned launch needs the member
243
+ * view, so an unauthenticated read here refuses every pin.
244
+ */
239
245
  export declare function getAgent(id: string): Promise<Agent>;
240
246
  export interface AgentListItem {
241
247
  id: string;
@@ -431,6 +437,32 @@ export interface MuBalance {
431
437
  effectiveMode?: string;
432
438
  enforcementPausedReason?: string | null;
433
439
  }
440
+ /**
441
+ * GET /v1/orgs/{org_id}/record/totals?lineage_key= — one lineage's model cost and μ as
442
+ * decimal-string millimu (`mu_charged_mmu` null until billing commits a charge). Dark unless the
443
+ * org is in the record-accumulator cohort: a 404 then, like an unknown lineage.
444
+ */
445
+ export interface RecordTotals {
446
+ lineage_key: string;
447
+ model_cost_mmu: string;
448
+ mu_charged_mmu: string | null;
449
+ as_of: string;
450
+ row_count: number;
451
+ }
452
+ export declare function getRecordTotals(orgId: string, lineageKey: string, signal?: AbortSignal): Promise<RecordTotals>;
453
+ /**
454
+ * Report one commit to Record's commit index (Doors W1.8, lib/commit-report.ts):
455
+ * `POST /v1/orgs/{org}/record/commits`, body RecordCommit/v1. Returns the HTTP
456
+ * status, never throws on a non-2xx: the caller fails open on every outcome.
457
+ */
458
+ export declare function postRecordCommit(orgId: string, body: {
459
+ schema: "RecordCommit/v1";
460
+ lineage_key: string;
461
+ repo: string;
462
+ branch: string;
463
+ sha: string;
464
+ patch_id: string;
465
+ }, signal?: AbortSignal): Promise<number>;
434
466
  export declare function getMuBalance(orgId?: string): Promise<MuBalance>;
435
467
  /**
436
468
  * Mint the one-time `cards:write:protection` capability for an agent — the
@@ -1277,6 +1309,40 @@ export interface OrgUsageQuery {
1277
1309
  * the user "org not found" on what is most often just the flag being off.
1278
1310
  */
1279
1311
  export declare function getOrgUsage(orgId: string, opts?: OrgUsageQuery): Promise<OrgUsageResponse>;
1312
+ /** One sealed contract in the batch-seal response (mnemom-api migration 582). */
1313
+ export interface SealedContractRow {
1314
+ seal_id: string;
1315
+ ordinal: number;
1316
+ contract_sha256: string;
1317
+ /** The deterministic node identity: `contract:<org_id>:<sha256>`. */
1318
+ node_id: string;
1319
+ version: number;
1320
+ job_key: string | null;
1321
+ canon_status: "pending" | "written";
1322
+ }
1323
+ /** `POST /v1/orgs/{org_id}/contract-step/batch-seal` 200 body. */
1324
+ export interface BatchSealResponse {
1325
+ batch_id: string;
1326
+ replayed: boolean;
1327
+ repo: string;
1328
+ seals: SealedContractRow[];
1329
+ /** Whether the sealed contracts were handed to dispatch: off, enqueued, or deferred (fail open). */
1330
+ dispatch: "off" | "enqueued" | "deferred";
1331
+ }
1332
+ export interface BatchSealRequest {
1333
+ repo: string;
1334
+ source: "cli";
1335
+ contracts: Array<{
1336
+ contract: unknown;
1337
+ job_key?: string;
1338
+ }>;
1339
+ }
1340
+ /**
1341
+ * Seal a batch of job contracts in one transaction (Doors fuel step, W3.2). The caller passes the
1342
+ * Idempotency-Key and holds it across retries, so a retried confirmation replays the first batch
1343
+ * instead of sealing twice.
1344
+ */
1345
+ export declare function sealContractBatch(orgId: string, body: BatchSealRequest, idempotencyKey: string, githubToken: string): Promise<BatchSealResponse>;
1280
1346
  /** `GET /v1/code/entitlement` 200 body — why Mnemom Agent is enabled for the org. */
1281
1347
  export interface CodeEntitlement {
1282
1348
  org_id: string;
package/dist/lib/api.js CHANGED
@@ -112,14 +112,6 @@ export async function readApiError(response, fallback) {
112
112
  specDeviation: parsed.spec_deviation,
113
113
  });
114
114
  }
115
- async function fetchApi(endpoint) {
116
- const url = validateUrl(`${API_BASE}${endpoint}`);
117
- const response = await mnemomFetch(url);
118
- if (!response.ok) {
119
- throw await readApiError(response, "API request failed");
120
- }
121
- return response.json();
122
- }
123
115
  export async function postApi(endpoint, body, opts = {}) {
124
116
  const url = validateUrl(`${API_BASE}${endpoint}`);
125
117
  const headers = {
@@ -197,8 +189,19 @@ async function fetchWithAuthRetry(url, buildInit) {
197
189
  return first;
198
190
  return mnemomFetch(url, await buildInit());
199
191
  }
192
+ /**
193
+ * GET /v1/agents/:id, sent with the caller's credentials. The API answers with the projection
194
+ * the caller may see: the org-member view (claimed_by, org_id, `caller: "org_member"`) for an
195
+ * agent in one of the caller's orgs, the public view otherwise. A pinned launch needs the member
196
+ * view, so an unauthenticated read here refuses every pin.
197
+ */
200
198
  export async function getAgent(id) {
201
- return fetchApi(`/v1/agents/${id}`);
199
+ const url = validateUrl(`${API_BASE}/v1/agents/${id}`);
200
+ const response = await fetchWithAuthRetry(url, async () => ({ headers: await authHeaders() }));
201
+ if (!response.ok) {
202
+ throw await readApiError(response, "API request failed");
203
+ }
204
+ return (await response.json());
202
205
  }
203
206
  /**
204
207
  * Key-compatible agent listing — `GET /v1/agents` is scoped by the caller's
@@ -405,6 +408,28 @@ export async function getMyPersonalOrg() {
405
408
  export async function getMe() {
406
409
  return gFetch("/v1/auth/me", {}, { fallback: "Could not read your identity" });
407
410
  }
411
+ export async function getRecordTotals(orgId, lineageKey, signal) {
412
+ return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/record/totals?lineage_key=${encodeURIComponent(lineageKey)}`, signal ? { signal } : {}, { notFoundLabel: "Record totals", fallback: "Could not read record totals" });
413
+ }
414
+ /**
415
+ * Report one commit to Record's commit index (Doors W1.8, lib/commit-report.ts):
416
+ * `POST /v1/orgs/{org}/record/commits`, body RecordCommit/v1. Returns the HTTP
417
+ * status, never throws on a non-2xx: the caller fails open on every outcome.
418
+ */
419
+ export async function postRecordCommit(orgId, body, signal) {
420
+ const url = validateUrl(`${API_BASE}/v1/orgs/${encodeURIComponent(orgId)}/record/commits`);
421
+ const response = await fetchWithAuthRetry(url, async () => ({
422
+ method: "POST",
423
+ headers: {
424
+ ...(await authHeaders()),
425
+ Accept: "application/json",
426
+ "Content-Type": "application/json",
427
+ },
428
+ body: JSON.stringify(body),
429
+ ...(signal ? { signal } : {}),
430
+ }));
431
+ return response.status;
432
+ }
408
433
  export async function getMuBalance(orgId) {
409
434
  const q = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
410
435
  return gFetch(`/v1/billing/mu${q}`, {}, { fallback: "Could not read MU balance" });
@@ -747,7 +772,7 @@ function emptyIntegrityRow(agentId) {
747
772
  export async function getTraces(id, limit = 10) {
748
773
  const data = await gFetch(`/v1/traces?agent_id=${id}&limit=${limit}`, {}, { fallback: "API request failed" });
749
774
  // Accept both the envelope shape (current API) and a bare array (defensive
750
- // — staging is mid-deploy when a CLI smoke runs against an older API).
775
+ // — the test cell is mid-deploy when a CLI smoke runs against an older API).
751
776
  if (Array.isArray(data))
752
777
  return data;
753
778
  return data.traces ?? [];
@@ -1308,6 +1333,7 @@ async function gFetch(path, init = {}, opts = {}) {
1308
1333
  ...(init.headers ?? {}),
1309
1334
  },
1310
1335
  body: init.body,
1336
+ ...(init.signal ? { signal: init.signal } : {}),
1311
1337
  }));
1312
1338
  if (!response.ok) {
1313
1339
  if (response.status === 401)
@@ -1569,6 +1595,20 @@ async function codeFetch(path, init = {}, fallback) {
1569
1595
  }
1570
1596
  return (await response.json());
1571
1597
  }
1598
+ /**
1599
+ * Seal a batch of job contracts in one transaction (Doors fuel step, W3.2). The caller passes the
1600
+ * Idempotency-Key and holds it across retries, so a retried confirmation replays the first batch
1601
+ * instead of sealing twice.
1602
+ */
1603
+ export async function sealContractBatch(orgId, body, idempotencyKey, githubToken) {
1604
+ return codeFetch(`/v1/orgs/${encodeURIComponent(orgId)}/contract-step/batch-seal`, {
1605
+ method: "POST",
1606
+ body: JSON.stringify(body),
1607
+ // The api checks with this token that the caller can write to the repo (OD-10). It reads
1608
+ // it for that check only and never stores it.
1609
+ headers: { "Idempotency-Key": idempotencyKey, "X-GitHub-Token": githubToken },
1610
+ }, "Could not seal the contracts");
1611
+ }
1572
1612
  /** The machine-readable code on the gate's 403 when the org is not enabled. */
1573
1613
  export const CODE_NOT_ENABLED = "code_not_enabled";
1574
1614
  /**
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Canonical JSON: the one place the CLI serialises a contract for hashing. Kept in its own module,
3
+ * with no imports, so the contract hash helper and the thin runner can use it without loading
4
+ * agent.ts. `agent.ts` re-exports it for its existing importers.
5
+ */
6
+ /**
7
+ * Canonical JSON — object keys sorted recursively, arrays in order, no
8
+ * whitespace, `undefined` keys dropped. Byte-identical to the gateway's
9
+ * `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
10
+ * sha we print match what the gateway seals.
11
+ */
12
+ export declare function canonicalJson(value: unknown): string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Canonical JSON: the one place the CLI serialises a contract for hashing. Kept in its own module,
3
+ * with no imports, so the contract hash helper and the thin runner can use it without loading
4
+ * agent.ts. `agent.ts` re-exports it for its existing importers.
5
+ */
6
+ /**
7
+ * Canonical JSON — object keys sorted recursively, arrays in order, no
8
+ * whitespace, `undefined` keys dropped. Byte-identical to the gateway's
9
+ * `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
10
+ * sha we print match what the gateway seals.
11
+ */
12
+ export function canonicalJson(value) {
13
+ if (value === null || typeof value !== "object")
14
+ return JSON.stringify(value);
15
+ if (Array.isArray(value))
16
+ return `[${value.map(canonicalJson).join(",")}]`;
17
+ const obj = value;
18
+ const keys = Object.keys(obj)
19
+ .filter((k) => obj[k] !== undefined)
20
+ .sort();
21
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
22
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Door one's commit reporting (Doors W1.8, OD-3, OD-5): the launcher adds the
3
+ * `Mnemom-Conversation` trailer and reports every commit the session makes to
4
+ * Record's commit index, so the PR record reads "N of M commits bound" with N
5
+ * equal to M.
6
+ *
7
+ * TRAILER. Claude Code writes `attribution.commit` into each commit it makes; the
8
+ * launcher passes it through `--settings` (OD-3). The model writes it, so it is
9
+ * best-effort; the commit index is the proof. The user's own commit attribution
10
+ * (or Claude Code's default Co-Authored-By line) is kept in front of ours, because
11
+ * a `--settings` value replaces theirs.
12
+ *
13
+ * INDEX. While Claude Code runs, the launcher looks for commits made in this
14
+ * repository since the launch, on local branches, by this repository's git user,
15
+ * and POSTs each one (sha, `git patch-id --stable`, branch, repo) to
16
+ * `POST /v1/orgs/{org}/record/commits` under the session's lineage key: the
17
+ * gateway's `x-mnemom-lineage-key`, captured by the local proxy into the
18
+ * record-state file (the CLI never derives one). Every session commit is
19
+ * reported, trailer or not: a commit is bound when the index holds its sha or
20
+ * patch-id, never by its trailer alone.
21
+ *
22
+ * PRERELEASE DEFAULT. On for a prerelease build (`next`), off for stable;
23
+ * MNEMOM_AGENT_COMMIT_REPORT=on|off overrides, and any other value reads off.
24
+ *
25
+ * FAIL OPEN. Nothing here throws into the launch or changes its exit code. A
26
+ * dark route (404) stops reporting for the session; a failed POST is retried on
27
+ * the next poll; a commit whose fields cannot be read is not sent (it stays
28
+ * unbound rather than a doomed request). The final sweep at exit races a hard
29
+ * deadline.
30
+ */
31
+ export declare const COMMIT_REPORT_ENV = "MNEMOM_AGENT_COMMIT_REPORT";
32
+ export declare const TRAILER_KEY = "Mnemom-Conversation";
33
+ /** Claude Code's own commit attribution when none is configured (settings reference, `attribution`). */
34
+ export declare const CC_DEFAULT_COMMIT_ATTRIBUTION = "Co-Authored-By: Claude <noreply@anthropic.com>";
35
+ /** How often the launcher looks for new commits while the session runs. */
36
+ export declare const COMMIT_POLL_MS = 5000;
37
+ /** The final sweep at exit shares W1.3's 2 s exit budget with the conduct receipt. */
38
+ export declare const COMMIT_FINAL_DEADLINE_MS = 1500;
39
+ /** on | off | unset (prerelease default); any other value reads off. */
40
+ export declare function commitReportEnabled(raw: string | undefined, version: string): boolean;
41
+ /**
42
+ * The `--settings` fragment carrying the trailer. `user` is the commit attribution
43
+ * the user's settings files give (highest precedence first; `false` hides all
44
+ * attribution, an empty string hides the commit line): it is kept in front of ours.
45
+ */
46
+ export declare function attributionSettings(conversationId: string, user: string | false | undefined): {
47
+ attribution: {
48
+ commit: string;
49
+ };
50
+ };
51
+ /** `owner/name` from a GitHub remote URL (https, ssh or scp form), or null. */
52
+ export declare function parseRepoSlug(url: string): string | null;
53
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
54
+ export declare const COMMIT_SCHEMA = "RecordCommit/v1";
55
+ /** RecordCommit/v1: strings only, every field required; the route rejects an unknown field. */
56
+ export interface RecordCommitBody {
57
+ schema: typeof COMMIT_SCHEMA;
58
+ lineage_key: string;
59
+ repo: string;
60
+ branch: string;
61
+ sha: string;
62
+ patch_id: string;
63
+ }
64
+ /** Runs git in the session's repository; null on any failure. */
65
+ export type Git = (args: string[], input?: string) => string | null;
66
+ export declare function realGit(cwd: string): Git;
67
+ /**
68
+ * Commits made since `sinceSec` (committer time) on local branches by this
69
+ * repository's git user, oldest first. Remote-tracking refs are left out, so a
70
+ * teammate's fetched commit is never reported as this session's.
71
+ */
72
+ export declare function sessionCommits(git: Git, sinceSec: number): string[];
73
+ /** The body for one commit, or the fields that could not be read. */
74
+ export declare function commitBody(git: Git, sha: string, lineageKey: string): {
75
+ ok: true;
76
+ body: RecordCommitBody;
77
+ } | {
78
+ ok: false;
79
+ missing: string[];
80
+ };
81
+ export type PostResult = "ok" | "dark" | "retry" | "drop";
82
+ export interface ReporterOptions {
83
+ orgId: string;
84
+ /** The proxy's record-state file, carrying the gateway's lineage key. */
85
+ recordStateFile: string;
86
+ /** Launch time, seconds since the epoch: commits before it are not the session's. */
87
+ sinceSec: number;
88
+ git: Git;
89
+ intervalMs?: number;
90
+ /** Test seam; the real one is postRecordCommit. */
91
+ post?: (orgId: string, body: RecordCommitBody, signal: AbortSignal) => Promise<PostResult>;
92
+ now?: () => number;
93
+ }
94
+ /** postRecordCommit as a PostResult: 404 dark, 429/5xx/network retry, any other 4xx drop. */
95
+ export declare function postCommit(orgId: string, body: RecordCommitBody, signal: AbortSignal): Promise<PostResult>;
96
+ /** The reporter's state across polls (exported for tests). */
97
+ export interface ReporterState {
98
+ done: Set<string>;
99
+ dark: boolean;
100
+ }
101
+ /** One sweep: report every session commit not reported yet. Never throws. */
102
+ export declare function reportOnce(o: ReporterOptions, state: ReporterState, signal: AbortSignal): Promise<void>;
103
+ /**
104
+ * Poll while the session runs; the returned `finish` runs one last sweep under
105
+ * a hard deadline and stops the timer. The timer is unref'd.
106
+ */
107
+ export declare function startCommitReporter(o: ReporterOptions): {
108
+ finish: (deadlineMs?: number) => Promise<void>;
109
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Door one's commit reporting (Doors W1.8, OD-3, OD-5): the launcher adds the
3
+ * `Mnemom-Conversation` trailer and reports every commit the session makes to
4
+ * Record's commit index, so the PR record reads "N of M commits bound" with N
5
+ * equal to M.
6
+ *
7
+ * TRAILER. Claude Code writes `attribution.commit` into each commit it makes; the
8
+ * launcher passes it through `--settings` (OD-3). The model writes it, so it is
9
+ * best-effort; the commit index is the proof. The user's own commit attribution
10
+ * (or Claude Code's default Co-Authored-By line) is kept in front of ours, because
11
+ * a `--settings` value replaces theirs.
12
+ *
13
+ * INDEX. While Claude Code runs, the launcher looks for commits made in this
14
+ * repository since the launch, on local branches, by this repository's git user,
15
+ * and POSTs each one (sha, `git patch-id --stable`, branch, repo) to
16
+ * `POST /v1/orgs/{org}/record/commits` under the session's lineage key: the
17
+ * gateway's `x-mnemom-lineage-key`, captured by the local proxy into the
18
+ * record-state file (the CLI never derives one). Every session commit is
19
+ * reported, trailer or not: a commit is bound when the index holds its sha or
20
+ * patch-id, never by its trailer alone.
21
+ *
22
+ * PRERELEASE DEFAULT. On for a prerelease build (`next`), off for stable;
23
+ * MNEMOM_AGENT_COMMIT_REPORT=on|off overrides, and any other value reads off.
24
+ *
25
+ * FAIL OPEN. Nothing here throws into the launch or changes its exit code. A
26
+ * dark route (404) stops reporting for the session; a failed POST is retried on
27
+ * the next poll; a commit whose fields cannot be read is not sent (it stays
28
+ * unbound rather than a doomed request). The final sweep at exit races a hard
29
+ * deadline.
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { postRecordCommit } from "./api.js";
33
+ import { readLineage } from "./statusline-feed.js";
34
+ import { isPrerelease } from "./update/semver.js";
35
+ export const COMMIT_REPORT_ENV = "MNEMOM_AGENT_COMMIT_REPORT";
36
+ export const TRAILER_KEY = "Mnemom-Conversation";
37
+ /** Claude Code's own commit attribution when none is configured (settings reference, `attribution`). */
38
+ export const CC_DEFAULT_COMMIT_ATTRIBUTION = "Co-Authored-By: Claude <noreply@anthropic.com>";
39
+ /** How often the launcher looks for new commits while the session runs. */
40
+ export const COMMIT_POLL_MS = 5_000;
41
+ /** The final sweep at exit shares W1.3's 2 s exit budget with the conduct receipt. */
42
+ export const COMMIT_FINAL_DEADLINE_MS = 1_500;
43
+ const HEX40 = /^[0-9a-f]{40}$/;
44
+ const REPO_SLUG = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
45
+ const BRANCH_MAX = 255;
46
+ /** on | off | unset (prerelease default); any other value reads off. */
47
+ export function commitReportEnabled(raw, version) {
48
+ const v = (raw ?? "").trim().toLowerCase();
49
+ if (v === "")
50
+ return isPrerelease(version);
51
+ return ["on", "1", "true", "yes"].includes(v);
52
+ }
53
+ /**
54
+ * The `--settings` fragment carrying the trailer. `user` is the commit attribution
55
+ * the user's settings files give (highest precedence first; `false` hides all
56
+ * attribution, an empty string hides the commit line): it is kept in front of ours.
57
+ */
58
+ export function attributionSettings(conversationId, user) {
59
+ const ours = `${TRAILER_KEY}: ${conversationId}`;
60
+ const base = user === undefined ? CC_DEFAULT_COMMIT_ATTRIBUTION : user === false ? "" : user;
61
+ return { attribution: { commit: base.trim() ? `${base.trimEnd()}\n${ours}` : ours } };
62
+ }
63
+ /** `owner/name` from a GitHub remote URL (https, ssh or scp form), or null. */
64
+ export function parseRepoSlug(url) {
65
+ const m = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
66
+ if (!m)
67
+ return null;
68
+ const slug = `${m[1]}/${m[2]}`;
69
+ return REPO_SLUG.test(slug) ? slug : null;
70
+ }
71
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
72
+ export const COMMIT_SCHEMA = "RecordCommit/v1";
73
+ export function realGit(cwd) {
74
+ return (args, input) => {
75
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", input, timeout: 10_000 });
76
+ return r.status === 0 ? r.stdout : null;
77
+ };
78
+ }
79
+ /**
80
+ * Commits made since `sinceSec` (committer time) on local branches by this
81
+ * repository's git user, oldest first. Remote-tracking refs are left out, so a
82
+ * teammate's fetched commit is never reported as this session's.
83
+ */
84
+ export function sessionCommits(git, sinceSec) {
85
+ const email = (git(["config", "user.email"]) ?? "").trim();
86
+ if (!email)
87
+ return [];
88
+ const out = git(["log", "--branches", "--reverse", `--since=@${sinceSec}`, "--format=%H%x09%ce"]);
89
+ if (out === null)
90
+ return [];
91
+ return out
92
+ .split("\n")
93
+ .map((l) => l.split("\t"))
94
+ .filter(([sha, ce]) => sha !== undefined && HEX40.test(sha) && ce === email)
95
+ .map(([sha]) => sha);
96
+ }
97
+ /** The body for one commit, or the fields that could not be read. */
98
+ export function commitBody(git, sha, lineageKey) {
99
+ const diff = git(["diff-tree", "-p", "--root", sha]);
100
+ const patchId = diff
101
+ ? (/^\s*([0-9a-f]{40})\b/.exec(git(["patch-id", "--stable"], diff) ?? "")?.[1] ?? "")
102
+ : "";
103
+ const current = (git(["symbolic-ref", "--quiet", "--short", "HEAD"]) ?? "").trim();
104
+ const holders = (git(["for-each-ref", "--contains", sha, "--format=%(refname:short)", "refs/heads"]) ?? "")
105
+ .split("\n")
106
+ .map((s) => s.trim())
107
+ .filter(Boolean);
108
+ const branch = holders.includes(current) ? current : (holders[0] ?? "");
109
+ const repo = parseRepoSlug(git(["remote", "get-url", "origin"]) ?? "") ?? "";
110
+ const missing = [];
111
+ if (!HEX40.test(sha))
112
+ missing.push("sha");
113
+ // A merge commit's or an empty commit's diff has no patch-id.
114
+ if (!HEX40.test(patchId))
115
+ missing.push("patch_id");
116
+ if (!branch || branch.length > BRANCH_MAX)
117
+ missing.push("branch");
118
+ if (!repo)
119
+ missing.push("repo");
120
+ if (missing.length > 0)
121
+ return { ok: false, missing };
122
+ return {
123
+ ok: true,
124
+ body: { schema: COMMIT_SCHEMA, lineage_key: lineageKey, repo, branch, sha, patch_id: patchId },
125
+ };
126
+ }
127
+ /** postRecordCommit as a PostResult: 404 dark, 429/5xx/network retry, any other 4xx drop. */
128
+ export async function postCommit(orgId, body, signal) {
129
+ try {
130
+ const status = await postRecordCommit(orgId, body, signal);
131
+ if (status >= 200 && status < 300)
132
+ return "ok";
133
+ if (status === 404)
134
+ return "dark";
135
+ if (status === 429 || status >= 500)
136
+ return "retry";
137
+ return "drop";
138
+ }
139
+ catch {
140
+ return "retry";
141
+ }
142
+ }
143
+ /** One sweep: report every session commit not reported yet. Never throws. */
144
+ export async function reportOnce(o, state, signal) {
145
+ if (state.dark)
146
+ return;
147
+ const lineageKey = readLineage(o.recordStateFile);
148
+ if (!lineageKey)
149
+ return;
150
+ for (const sha of sessionCommits(o.git, o.sinceSec)) {
151
+ if (state.done.has(sha) || signal.aborted)
152
+ continue;
153
+ const built = commitBody(o.git, sha, lineageKey);
154
+ if (!built.ok) {
155
+ state.done.add(sha);
156
+ continue;
157
+ }
158
+ const r = await (o.post ?? postCommit)(o.orgId, built.body, signal);
159
+ if (r === "dark") {
160
+ state.dark = true;
161
+ return;
162
+ }
163
+ if (r !== "retry")
164
+ state.done.add(sha);
165
+ }
166
+ }
167
+ /**
168
+ * Poll while the session runs; the returned `finish` runs one last sweep under
169
+ * a hard deadline and stops the timer. The timer is unref'd.
170
+ */
171
+ export function startCommitReporter(o) {
172
+ const state = { done: new Set(), dark: false };
173
+ let running = null;
174
+ const sweep = (signal) => {
175
+ running ??= reportOnce(o, state, signal)
176
+ .catch(() => undefined)
177
+ .finally(() => {
178
+ running = null;
179
+ });
180
+ return running;
181
+ };
182
+ const timer = setInterval(() => void sweep(AbortSignal.timeout(o.intervalMs ?? COMMIT_POLL_MS)), o.intervalMs ?? COMMIT_POLL_MS);
183
+ timer.unref();
184
+ return {
185
+ async finish(deadlineMs = COMMIT_FINAL_DEADLINE_MS) {
186
+ clearInterval(timer);
187
+ const signal = AbortSignal.timeout(deadlineMs);
188
+ let timeout;
189
+ const deadline = new Promise((r) => {
190
+ timeout = setTimeout(r, deadlineMs);
191
+ timeout.unref();
192
+ });
193
+ // Let an in-flight poll finish first, then sweep once more; both inside the deadline.
194
+ await Promise.race([
195
+ (async () => {
196
+ if (running)
197
+ await running;
198
+ await sweep(signal);
199
+ })(),
200
+ deadline,
201
+ ]);
202
+ clearTimeout(timeout);
203
+ },
204
+ };
205
+ }