@mnemom/mnemom 0.17.15 → 0.17.16

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.
@@ -31,7 +31,7 @@ import { randomUUID } from "node:crypto";
31
31
  import { tmpdir, homedir, hostname, userInfo } from "node:os";
32
32
  import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
- import { statuslineLaunch } from "../lib/statusline.js";
34
+ import { MEASURED_ENV, statuslineLaunch } from "../lib/statusline.js";
35
35
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
36
36
  import { createApiKey } from "../lib/api.js";
37
37
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
@@ -46,7 +46,7 @@ import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, }
46
46
  import { requireConsoleKey, readConsoleKey } from "../lib/console-key.js";
47
47
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
48
48
  import { defaultAgentSlug, ensureClaimedServedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
49
- import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
49
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, assertCompactAtCompatible, assertOffProfileCompatible, compactAtWindowWarning, describeCompactAt, launchWindow, resolveCompactAt, applyGatewayHintHeaders, resolveCompactionIntercept, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, resolveUsageSavings, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, applyToolSearchDefault, DISABLE_1M_ENV, readCcSettings, resolveWindowModel, resolveAnthropicDoor, resolveLaunchModel, resolveConversationId, resolveGatewayHost, singleCellError, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, resumeCommand, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
50
50
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
51
51
  import { slackChannelArgs } from "../lib/slack-launch.js";
52
52
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
@@ -176,7 +176,9 @@ export async function agentCommand(scenarioArg, options = {}) {
176
176
  budget: options.budget,
177
177
  maxTurns: options.maxTurns,
178
178
  stall: options.stall,
179
+ mode: options.mode,
179
180
  implicit: options.implicit,
181
+ leadDirected: options.leadDirected,
180
182
  });
181
183
  // ── implicit goal mode (no contract; the gateway infers the goal from the user's
182
184
  // messages) — the guardrail ceilings ride on the mode header instead ──
@@ -232,17 +234,24 @@ export async function agentCommand(scenarioArg, options = {}) {
232
234
  compactAt: options.compactAt,
233
235
  });
234
236
  const experimental = options.experimental ?? [];
237
+ // Decided from the REQUESTED CLI: the header is built before the executable is resolved.
238
+ const usageSavings = resolveUsageSavings({
239
+ experimental,
240
+ cliName: basename(requestedCli(options.cli, env)),
241
+ });
235
242
  const sessionConfig = buildSessionConfig({
236
243
  profile: contextProfile,
237
244
  contextHandoff,
238
245
  compactionIntercept,
239
246
  compactAt,
247
+ usageSavings,
240
248
  });
241
249
  let sessionConfigB64 = buildSessionConfigB64({
242
250
  profile: contextProfile,
243
251
  contextHandoff,
244
252
  compactionIntercept,
245
253
  compactAt,
254
+ usageSavings,
246
255
  experimental,
247
256
  });
248
257
  const expNotice = experimentalNotice(experimental);
@@ -269,7 +278,7 @@ export async function agentCommand(scenarioArg, options = {}) {
269
278
  if (options.dryRun) {
270
279
  const planCli = cli ?? {
271
280
  bin: "(not found — pass --cli <path> or `mnemom agent config set cli <path>`)",
272
- name: basename((options.cli || env.MNEMOM_AGENT_CLI || "claude").trim()),
281
+ name: basename(requestedCli(options.cli, env)),
273
282
  };
274
283
  // A defaulted slug is `agent-<handle>` from the login (resolved for real after
275
284
  // sign-in, below). Signed in: show that name. Signed out, or no usable handle:
@@ -475,6 +484,7 @@ export async function agentCommand(scenarioArg, options = {}) {
475
484
  contextHandoff,
476
485
  compactionIntercept,
477
486
  compactAt,
487
+ usageSavings,
478
488
  experimental,
479
489
  });
480
490
  }
@@ -571,6 +581,7 @@ export async function agentCommand(scenarioArg, options = {}) {
571
581
  envelope,
572
582
  goalMode,
573
583
  sessionConfigB64,
584
+ usageSavings,
574
585
  options: launchOptions,
575
586
  yolo,
576
587
  contextHint,
@@ -923,6 +934,10 @@ function storeKey(key) {
923
934
  // best effort — the launch proceeds with the in-memory key either way
924
935
  }
925
936
  }
937
+ /** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
938
+ export function requestedCli(cliOpt, env) {
939
+ return (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
940
+ }
926
941
  /**
927
942
  * Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
928
943
  * name or a full path; default "claude". A value with a path separator is used
@@ -932,7 +947,7 @@ function storeKey(key) {
932
947
  * a non-claude executable is launched through it with a warning.
933
948
  */
934
949
  export function resolveCliBin(cliOpt, env) {
935
- const requested = (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
950
+ const requested = requestedCli(cliOpt, env);
936
951
  const name = basename(requested);
937
952
  if (requested.includes("/")) {
938
953
  if (existsSync(requested))
@@ -985,7 +1000,7 @@ export /**
985
1000
  * so they never retype it). A non-interactive session re-throws the clear,
986
1001
  * actionable error rather than hanging on a prompt.
987
1002
  */ async function promptForCliPath(cliOpt, env, origErr) {
988
- const requested = (cliOpt || env.MNEMOM_AGENT_CLI || "claude").trim();
1003
+ const requested = requestedCli(cliOpt, env);
989
1004
  if (!isInteractive()) {
990
1005
  throw origErr ?? new Error(`mnemom agent: coding-agent CLI '${requested}' not found.`);
991
1006
  }
@@ -1121,7 +1136,9 @@ function launchTerminal(ctx) {
1121
1136
  if (ctx.options.slack)
1122
1137
  args.push(...slackChannelArgs(sessionName));
1123
1138
  // The status-bar savings counter (lib/statusline.ts). Claude Code only; the
1124
- // user's own statusLine keeps running in front of it.
1139
+ // user's own statusLine keeps running in front of it. Measured mode is this launch's
1140
+ // decision alone: a value inherited from an outer `mnemom agent` session never rides along.
1141
+ Reflect.deleteProperty(childEnv, MEASURED_ENV);
1125
1142
  if (ctx.cli.name === "claude") {
1126
1143
  const sl = statuslineLaunch({
1127
1144
  passthrough: ctx.options.passthrough ?? [],
@@ -1129,6 +1146,7 @@ function launchTerminal(ctx) {
1129
1146
  cwd: process.cwd(),
1130
1147
  node: process.execPath,
1131
1148
  bin: join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "statusline-bin.js"),
1149
+ measured: ctx.usageSavings,
1132
1150
  });
1133
1151
  if (sl) {
1134
1152
  args.push(...sl.args);
package/dist/index.js CHANGED
@@ -150,7 +150,12 @@ const agentCmd = program
150
150
  .option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
151
151
  .option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
152
152
  .option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
153
- .addOption(new Option("--implicit", "EXPERIMENTAL (`mnemom experimental enable implicit`): implicit goal alignment — the gateway infers the goal from your messages and revises it as you steer (the default while the feature is on; not combinable with --goal/--requirement/--allow/--forbid/--goal-id; the guardrail ceilings still apply)").hideHelp(hiddenUnless("implicit")))
153
+ .option("--mode <nudge|enforce>", "Sealed contract mode (MNE-8199; default nudge). Under enforce, a crossed turn/budget/time " +
154
+ "ceiling REFUSES the next request on this conversation until the owner raises it via a " +
155
+ "contract update — under nudge the ceiling only nudges, unchanged. Requires --goal.")
156
+ .option("--lead-directed", "Seal the standard lead-directed requirement: own-branch push and draft/ready PR creation, " +
157
+ "from a git worktree, on the lead's direction — not merging. Requires --goal; idempotent.")
158
+ .addOption(new Option("--implicit", "EXPERIMENTAL (`mnemom experimental enable implicit`): implicit goal alignment — the gateway infers the goal from your messages and revises it as you steer (the default while the feature is on; not combinable with --goal/--requirement/--allow/--forbid/--goal-id/--mode/--lead-directed; the guardrail ceilings still apply)").hideHelp(hiddenUnless("implicit")))
154
159
  .option("--no-implicit", "Turn implicit goal alignment OFF for this launch (overrides launcher.toml goal_mode = implicit)")
155
160
  .option("--conversation-id <id>", "x-mnemom-conversation-id (default: auto-generated)")
156
161
  .option("--cli <name|path>", "Coding-agent CLI to launch: a name or full path (default: claude)")
@@ -345,9 +350,11 @@ const agentCmd = program
345
350
  forbid: opts.forbid,
346
351
  goalId: opts.goalId,
347
352
  implicit,
353
+ leadDirected: opts.leadDirected,
348
354
  budget: opts.budget ?? (contractIntended ? numToStr(cfg.guardrails?.budgetUsd) : undefined),
349
355
  maxTurns: opts.maxTurns ?? (contractIntended ? numToStr(cfg.guardrails?.maxTurns) : undefined),
350
356
  stall: opts.stall ?? (contractIntended ? numToStr(cfg.guardrails?.stallTurns) : undefined),
357
+ mode: opts.mode,
351
358
  agent: parentOpts.agent ?? cfg.agent,
352
359
  conversationId: opts.conversationId,
353
360
  cli: opts.cli,
@@ -878,9 +885,21 @@ agentCmd
878
885
  .description("Show one session — the local launch record merged with the gateway's live goal state (sealed contract, latest verdict, guardrails). " +
879
886
  "`ref` is a session name (newest match wins) or a unique conversation-id prefix; omitted = the newest session")
880
887
  .option("--json", "Output the merged view as JSON (each section tagged with its source)")
888
+ .option("--follow", "After the snapshot, keep watching and print each new verdict as it lands (Ctrl-C to stop)")
889
+ .option("--follow-interval <seconds>", "Poll interval for --follow, in seconds (default 3; also settable via MNEMOM_AGENT_FOLLOW_INTERVAL)")
881
890
  .action(async (ref, opts) => {
882
891
  try {
883
- await agentShowCommand(ref, { json: opts.json }, liveSessionDeps);
892
+ let followIntervalMs;
893
+ if (opts.followInterval !== undefined) {
894
+ const seconds = Number(opts.followInterval);
895
+ if (!Number.isFinite(seconds) || seconds <= 0) {
896
+ console.error("Error: --follow-interval must be a positive number of seconds.");
897
+ process.exit(1);
898
+ return;
899
+ }
900
+ followIntervalMs = seconds * 1000;
901
+ }
902
+ await agentShowCommand(ref, { json: opts.json, follow: opts.follow, followIntervalMs }, liveSessionDeps);
884
903
  }
885
904
  catch (error) {
886
905
  console.error("Error:", error instanceof Error ? error.message : error);
@@ -39,6 +39,15 @@ export interface SealedContract {
39
39
  max_turns?: number;
40
40
  budget_usd?: number;
41
41
  stall_turns?: number;
42
+ /**
43
+ * `nudge` | `enforce` (MNE-8199, design D9/R9): under `enforce` a crossed turn/budget/time
44
+ * ceiling refuses the gateway's NEXT request on this conversation rather than only
45
+ * nudging. ALWAYS present once a contract is sealed (never omitted) — the gateway's
46
+ * `parseContractHeader` defaults an absent/invalid value to `"nudge"` unconditionally
47
+ * before hashing, so the CLI must set it here too (defaulting to `"nudge"`) or the sha
48
+ * printed here would disagree with the one the gateway seals (the MNE-7649 class of bug).
49
+ */
50
+ mode?: "nudge" | "enforce";
42
51
  }
43
52
  /** The raw contract/guardrail flags as commander hands them to the command. */
44
53
  export interface ContractFlags {
@@ -50,6 +59,8 @@ export interface ContractFlags {
50
59
  maxTurns?: string | number;
51
60
  budget?: string | number;
52
61
  stall?: string | number;
62
+ /** `--mode nudge|enforce` (MNE-8199) — validated and defaulted in `assembleContract`. */
63
+ mode?: string;
53
64
  /**
54
65
  * `--implicit`: goal alignment WITHOUT a sealed contract. No `x-mnemom-contract`
55
66
  * is sent; the gateway infers the goal from the user's own messages (revision 1
@@ -58,7 +69,26 @@ export interface ContractFlags {
58
69
  * of inside a contract. Cannot be combined with the sealed-contract flags.
59
70
  */
60
71
  implicit?: boolean;
72
+ /**
73
+ * `--lead-directed`: append `LEAD_DIRECTED_REQUIREMENT` to the sealed contract's
74
+ * `requirements` (MNE-8152, design `mnemom-agent-wave` D4). A lead-directed agent
75
+ * (e.g. an ADW lane lead) legitimately pushes its own branches and opens draft/ready
76
+ * PRs; under the sealed framing the judge reads only `goal`/`requirements`/fences, so
77
+ * without this line a push/PR step is flagged `violation` ("an effect of this step is
78
+ * not allowed by the requirements"). The mechanism is a requirement line, not a judge
79
+ * input — sealed mode takes no in-session steering, so the lead's direction must
80
+ * already be IN the seal. Idempotent: appended at most once, whether the flag is
81
+ * given once or the requirement text is already present (e.g. supplied verbatim via
82
+ * `--requirement`).
83
+ */
84
+ leadDirected?: boolean;
61
85
  }
86
+ /**
87
+ * The standard requirement line `--lead-directed` seals: own-branch push and draft/ready
88
+ * PR creation, from a git worktree, on the lead's direction — merging is explicitly out.
89
+ * A single constant so every launch that opts in seals byte-identical wording (MNE-8152).
90
+ */
91
+ export declare const LEAD_DIRECTED_REQUIREMENT: string;
62
92
  /** The three launch shapes the command supports (see commands/code.ts). */
63
93
  export type LaunchShape = "terminal" | "remote-control" | "server";
64
94
  /**
@@ -325,6 +355,15 @@ export declare const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
325
355
  */
326
356
  export declare const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
327
357
  export declare const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
358
+ /**
359
+ * The gateway env key that has it measure each request's saving for a single session and put
360
+ * it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
361
+ * the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
362
+ * only while the `measured-savings` experimental feature is on and the launched CLI is Claude
363
+ * Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
364
+ * where the measured saving is honestly $0.00.
365
+ */
366
+ export declare const USAGE_SAVINGS_KEY = "GATEWAY_USAGE_SAVINGS";
328
367
  /**
329
368
  * The named presets for how the gateway folds a session's context (`--context-profile`,
330
369
  * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
@@ -607,6 +646,7 @@ export declare function resolveContextProfile(opts: {
607
646
  * (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
608
647
  * `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
609
648
  * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
649
+ * `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
610
650
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
611
651
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
612
652
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
@@ -623,6 +663,8 @@ export declare function buildSessionConfig(opts: {
623
663
  compactionIntercept?: boolean;
624
664
  /** `resolveCompactAt` output: `null` (or absent) ⇒ no target, send nothing new. */
625
665
  compactAt?: number | null;
666
+ /** `resolveUsageSavings` output: true ⇒ the gateway measures the session's saving. */
667
+ usageSavings?: boolean;
626
668
  }): Record<string, string>;
627
669
  /**
628
670
  * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
@@ -634,6 +676,7 @@ export declare function buildSessionConfigPayload(opts: {
634
676
  contextHandoff: boolean;
635
677
  compactionIntercept?: boolean;
636
678
  compactAt?: number | null;
679
+ usageSavings?: boolean;
637
680
  experimental?: readonly string[];
638
681
  }): {
639
682
  v: 1;
@@ -656,7 +699,8 @@ export declare function buildSessionConfigPayload(opts: {
656
699
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
657
700
  * setting) — the SAME header the `handoff` MCP tool's own call to
658
701
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
659
- * per request (see `commands/agent.ts` handoff MCP wiring).
702
+ * per request (see `commands/agent.ts` handoff MCP wiring);
703
+ * - `usageSavings: true` → `GATEWAY_USAGE_SAVINGS: "on"` (`resolveUsageSavings`).
660
704
  * base64url so it survives the gateway's inbound strip.
661
705
  */
662
706
  export declare function buildSessionConfigB64(opts: {
@@ -664,6 +708,7 @@ export declare function buildSessionConfigB64(opts: {
664
708
  contextHandoff: boolean;
665
709
  compactionIntercept?: boolean;
666
710
  compactAt?: number | null;
711
+ usageSavings?: boolean;
667
712
  experimental?: readonly string[];
668
713
  }): string | undefined;
669
714
  /**
@@ -689,6 +734,16 @@ export declare function resolveCompactionIntercept(opts: {
689
734
  compactionIntercept?: boolean;
690
735
  env?: NodeJS.ProcessEnv;
691
736
  }): boolean;
737
+ /**
738
+ * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
739
+ * status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
740
+ * only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
741
+ * launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
742
+ */
743
+ export declare function resolveUsageSavings(opts: {
744
+ experimental: readonly string[];
745
+ cliName: string;
746
+ }): boolean;
692
747
  /** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
693
748
  export declare const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
694
749
  /**
package/dist/lib/agent.js CHANGED
@@ -25,6 +25,13 @@ import { join } from "node:path";
25
25
  import { getGatewayUrl } from "./config.js";
26
26
  /** The us-2/prod gateway host. The default door — never any other cell silently. */
27
27
  export const PROD_GATEWAY_HOST = "https://gateway.mnemom.ai";
28
+ /**
29
+ * The standard requirement line `--lead-directed` seals: own-branch push and draft/ready
30
+ * PR creation, from a git worktree, on the lead's direction — merging is explicitly out.
31
+ * A single constant so every launch that opts in seals byte-identical wording (MNE-8152).
32
+ */
33
+ export const LEAD_DIRECTED_REQUIREMENT = "On the lead's direction, this session may push its own branches and open draft or " +
34
+ "ready pull requests, from a git worktree, never the primary checkout; it may not merge them.";
28
35
  /**
29
36
  * Resolve the gateway HOST (scheme + host, no path). An explicit, non-empty
30
37
  * `MNEMOM_AGENT_GATEWAY` wins verbatim; otherwise the gateway of the cell the rest
@@ -381,7 +388,9 @@ export function contractFlagsPresent(flags) {
381
388
  hasText(flags.goalId) ||
382
389
  flags.maxTurns !== undefined ||
383
390
  flags.budget !== undefined ||
384
- flags.stall !== undefined);
391
+ flags.stall !== undefined ||
392
+ hasText(flags.mode) ||
393
+ flags.leadDirected === true);
385
394
  }
386
395
  function hasText(v) {
387
396
  return typeof v === "string" && v.trim().length > 0;
@@ -413,11 +422,18 @@ export function assembleContract(flags) {
413
422
  const statement = (flags.goal ?? "").trim();
414
423
  if (!statement) {
415
424
  throw new Error("--goal is required when any contract flag " +
416
- "(--requirement/--allow/--forbid/--goal-id/--budget/--max-turns/--stall) is given.");
425
+ "(--requirement/--allow/--forbid/--goal-id/--budget/--max-turns/--stall/--mode/--lead-directed) is given.");
426
+ }
427
+ const requirements = cleanList(flags.requirement);
428
+ // Idempotent: append the standard line only when it is not already present, whether
429
+ // because `--lead-directed` was given more than once or because the exact wording
430
+ // was already supplied verbatim via `--requirement`.
431
+ if (flags.leadDirected === true && !requirements.includes(LEAD_DIRECTED_REQUIREMENT)) {
432
+ requirements.push(LEAD_DIRECTED_REQUIREMENT);
417
433
  }
418
434
  const contract = {
419
435
  statement,
420
- requirements: cleanList(flags.requirement),
436
+ requirements,
421
437
  allowed_paths: cleanList(flags.allow),
422
438
  forbidden_paths: cleanList(flags.forbid),
423
439
  };
@@ -442,6 +458,19 @@ export function assembleContract(flags) {
442
458
  }
443
459
  contract.stall_turns = Number(flags.stall);
444
460
  }
461
+ // MNE-8199: set only for the non-default "enforce" — the gateway's own canonicalHash
462
+ // folds `mode` into the hash the same way (see its doc comment): a contract that never
463
+ // named `--mode` must keep hashing identically to the byte-identical contract sealed
464
+ // before the field existed, or every live conversation logs `goal_contract_ignored_change`
465
+ // on deploy. Setting a defaulted "nudge" here unconditionally would instead make the SHA
466
+ // this prints disagree with the one the gateway actually seals for the exact same launch —
467
+ // the MNE-7649 class of bug, just reintroduced from the client side.
468
+ const rawMode = (flags.mode ?? "").trim();
469
+ if (rawMode && rawMode !== "nudge" && rawMode !== "enforce") {
470
+ throw new Error(`--mode must be "nudge" or "enforce" (got '${flags.mode}').`);
471
+ }
472
+ if (rawMode === "enforce")
473
+ contract.mode = "enforce";
445
474
  const canonical = canonicalJson(contract);
446
475
  return {
447
476
  contract,
@@ -487,6 +516,15 @@ export const CONTEXT_SIZE_POLICY_KEY = "CONTEXT_RUNTIME_SIZE_POLICY";
487
516
  */
488
517
  export const USAGE_REAL_SIZE_KEY = "GATEWAY_USAGE_REAL_SIZE";
489
518
  export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
519
+ /**
520
+ * The gateway env key that has it measure each request's saving for a single session and put
521
+ * it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
522
+ * the status bar reads it from Claude Code's transcript. Default OFF on the gateway; sent `"on"`
523
+ * only while the `measured-savings` experimental feature is on and the launched CLI is Claude
524
+ * Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
525
+ * where the measured saving is honestly $0.00.
526
+ */
527
+ export const USAGE_SAVINGS_KEY = "GATEWAY_USAGE_SAVINGS";
490
528
  /**
491
529
  * The named presets for how the gateway folds a session's context (`--context-profile`,
492
530
  * launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`). Choosing NONE sends no fold
@@ -747,7 +785,7 @@ export function applyGatewayHintHeaders(childEnv) {
747
785
  }
748
786
  return childEnv;
749
787
  }
750
- const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
788
+ const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id/--mode/--lead-directed";
751
789
  /**
752
790
  * `--implicit` and a sealed contract are mutually exclusive: on the gateway a
753
791
  * sealed `x-mnemom-contract` always wins and would silently switch implicit mode
@@ -759,7 +797,9 @@ function assertImplicitCompatible(flags) {
759
797
  (flags.requirement?.length ?? 0) > 0 ||
760
798
  (flags.allow?.length ?? 0) > 0 ||
761
799
  (flags.forbid?.length ?? 0) > 0 ||
762
- hasText(flags.goalId);
800
+ hasText(flags.goalId) ||
801
+ hasText(flags.mode) ||
802
+ flags.leadDirected === true;
763
803
  if (sealed) {
764
804
  throw new Error(`--implicit cannot be combined with ${SEALED_ONLY_FLAGS}: a sealed contract always wins on ` +
765
805
  "the gateway and would silently switch implicit mode off. Drop --implicit to seal a " +
@@ -997,6 +1037,7 @@ export function resolveContextProfile(opts) {
997
1037
  * (`CONTEXT_RUNTIME_ENABLED: "false"` + real-size and intercept `"off"` for `off`; the three fold-tuning keys + size policy off for
998
1038
  * `aggressive`; size policy off for `balanced`; size policy banded for `adaptive`; nothing when
999
1039
  * no profile was chosen), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON, then
1040
+ * `GATEWAY_USAGE_SAVINGS: "on"` when measured savings is ON (under every profile), then
1000
1041
  * `GATEWAY_USAGE_REAL_SIZE: "on"` + `GATEWAY_COMPACTION_INTERCEPT: "on"` when EITHER the
1001
1042
  * `compaction_intercept` setting is ON OR `--compact-at` arms a target below 1M (the target
1002
1043
  * cannot be honored without the intercept), then `GATEWAY_COMPACTION_UPLOAD_TARGET_TOKENS: "<N>"`
@@ -1014,6 +1055,9 @@ export function buildSessionConfig(opts) {
1014
1055
  }
1015
1056
  if (opts.contextHandoff)
1016
1057
  config[CONTEXT_HANDOFF_KEY] = "true";
1058
+ // Before the `off` return: measuring is not folding, so an off session sends it too.
1059
+ if (opts.usageSavings)
1060
+ config[USAGE_SAVINGS_KEY] = "on";
1017
1061
  const hasCompactionTarget = opts.compactAt !== undefined && opts.compactAt !== null;
1018
1062
  // A target needs the compaction intercept + real-size rewrite to arm (#2216) — without them
1019
1063
  // the upload-target key is silently inert (compaction-align.ts: "armed only inside the
@@ -1059,7 +1103,8 @@ export function buildSessionConfigPayload(opts) {
1059
1103
  * - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
1060
1104
  * setting) — the SAME header the `handoff` MCP tool's own call to
1061
1105
  * `/mnemom/context/handoff` must also send, since the gateway resolves the flag
1062
- * per request (see `commands/agent.ts` handoff MCP wiring).
1106
+ * per request (see `commands/agent.ts` handoff MCP wiring);
1107
+ * - `usageSavings: true` → `GATEWAY_USAGE_SAVINGS: "on"` (`resolveUsageSavings`).
1063
1108
  * base64url so it survives the gateway's inbound strip.
1064
1109
  */
1065
1110
  export function buildSessionConfigB64(opts) {
@@ -1099,6 +1144,15 @@ export function resolveCompactionIntercept(opts) {
1099
1144
  return false;
1100
1145
  return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
1101
1146
  }
1147
+ /**
1148
+ * Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header, and the
1149
+ * status bar in measured mode)? Only with the `measured-savings` experimental feature on, and
1150
+ * only for Claude Code — the one CLI whose status bar reads the measurement. No flag or
1151
+ * launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
1152
+ */
1153
+ export function resolveUsageSavings(opts) {
1154
+ return opts.experimental.includes("measured-savings") && opts.cliName === "claude";
1155
+ }
1102
1156
  /** The name the `handoff` MCP tool server is registered under in `--mcp-config`. */
1103
1157
  export const HANDOFF_MCP_SERVER_NAME = "mnemom-handoff";
1104
1158
  /**
package/dist/lib/api.d.ts CHANGED
@@ -1289,6 +1289,22 @@ export interface AgentSessionGuardrails {
1289
1289
  };
1290
1290
  } | null;
1291
1291
  }
1292
+ /**
1293
+ * One entry of the gateway's verdict trail (R10, MNE-8200) — up to the last 50 judged turns,
1294
+ * oldest first (same order the gateway stores and returns them in). `request_id` is the request
1295
+ * that produced this verdict, or null when the judge ran without one. `fence_breach` is `true`/
1296
+ * `false` for a conclusive mechanical allowed/forbidden-paths check, `null` when the contract has
1297
+ * no fences or the verdict predates the check. This shape is also what MNE-8234 mirrors — keep
1298
+ * the field name and member shape stable.
1299
+ */
1300
+ export interface AgentSessionVerdictTrailEntry {
1301
+ request_id: string | null;
1302
+ turn: number;
1303
+ verdict: "aligned" | "drifting" | "violation";
1304
+ reason: string;
1305
+ fence_breach: boolean | null;
1306
+ at: string;
1307
+ }
1292
1308
  /** `GET /v1/agent/sessions/{conversationId}` 200 body. Nulls mean "nothing stored" — absence is data. */
1293
1309
  export interface AgentSessionResponse {
1294
1310
  conversation_id: string;
@@ -1301,6 +1317,8 @@ export interface AgentSessionResponse {
1301
1317
  verdict: AgentSessionVerdict | null;
1302
1318
  turn: number | null;
1303
1319
  guardrails: AgentSessionGuardrails | null;
1320
+ /** Absent on an older server (back-compat) — never shown then, never an empty-array assumption. */
1321
+ verdict_trail?: AgentSessionVerdictTrailEntry[];
1304
1322
  }
1305
1323
  /**
1306
1324
  * The live goal state of one `mnemom agent` session, read through mnemom-api
@@ -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" | "compaction-intercept";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "compaction-intercept" | "measured-savings";
16
16
  /**
17
17
  * Is a `compact_at` / `MNEMOM_COMPACT_AT` value (trimmed, case-insensitive) an ARMED gateway
18
18
  * target, as opposed to "no target" (`""`, `"1m"`, `"off"`)? Deliberately duplicates
@@ -69,6 +69,18 @@ export const EXPERIMENTAL_FEATURES = [
69
69
  env: ["MNEMOM_AGENT_COMPACTION_INTERCEPT"],
70
70
  },
71
71
  },
72
+ {
73
+ // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
74
+ id: "measured-savings",
75
+ summary: "The status bar shows the saving the gateway measured on this session instead of the benchmark estimate",
76
+ since: "0.17.16",
77
+ gates: {
78
+ flags: [],
79
+ configKeys: [],
80
+ commands: [],
81
+ env: [],
82
+ },
83
+ },
72
84
  ];
73
85
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
74
86
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
@@ -4,13 +4,14 @@
4
4
  */
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { INNER_ENV, render } from "./statusline.js";
7
+ import { INNER_ENV, measuredMode, render } from "./statusline.js";
8
8
  const chunks = [];
9
9
  process.stdin.on("data", (c) => chunks.push(c));
10
10
  process.stdin.on("end", () => {
11
11
  const line = render(Buffer.concat(chunks).toString("utf8"), {
12
12
  stateDir: join(homedir(), ".mnemom", "statusline"),
13
13
  inner: process.env[INNER_ENV] || undefined,
14
+ measured: measuredMode(process.env),
14
15
  });
15
16
  process.stdout.write(`${line}\n`);
16
17
  });
@@ -13,6 +13,15 @@
13
13
  * keeps each file's byte offset and each message id's contribution, and a tick reads at
14
14
  * most MAX_BYTES_PER_TICK of new bytes (a big resumed session catches up over ticks).
15
15
  *
16
+ * Measured mode (the `measured-savings` experimental feature; the launch sets
17
+ * MNEMOM_STATUSLINE_MEASURED=1): the gateway measures each request's saving — the stock
18
+ * request's input cost minus what it billed — and writes it into the response usage as
19
+ * `mnemom_savings` inside a `usage.iterations` entry, the one place Claude Code keeps an
20
+ * unknown usage key in its transcript. The counter sums those per message id
21
+ * ("mnemom saved $X this session", no "~": it is measured) and makes no claim before the
22
+ * first one arrives. Its state lives in its own file (<session>.measured.json), so the two
23
+ * modes never share a count.
24
+ *
16
25
  * A user's own statusLine is never replaced: the launch passes it in
17
26
  * MNEMOM_STATUSLINE_INNER, it runs with the same stdin, and our segment is appended.
18
27
  * Read-only on every transcript; no network. It lives outside lib/first-run because it runs
@@ -24,6 +33,8 @@ export declare const MAX_BYTES_PER_TICK: number;
24
33
  export declare const INNER_ENV = "MNEMOM_STATUSLINE_INNER";
25
34
  /** Set to 1 to launch without the counter. */
26
35
  export declare const NO_STATUSLINE_ENV = "MNEMOM_AGENT_NO_STATUSLINE";
36
+ /** The launcher sets this to 1 when the gateway measures the session's saving. */
37
+ export declare const MEASURED_ENV = "MNEMOM_STATUSLINE_MEASURED";
27
38
  export declare const INNER_TIMEOUT_MS = 2000;
28
39
  export interface SessionState {
29
40
  v: 1;
@@ -35,6 +46,17 @@ export interface SessionState {
35
46
  saved: number;
36
47
  }
37
48
  export declare function freshState(): SessionState;
49
+ /** Measured mode's state (<session>.measured.json). */
50
+ export interface MeasuredState {
51
+ v: 1;
52
+ files: Record<string, number>;
53
+ /** Per message id: the gateway's measured saving (USD). A re-written id replaces its old share. */
54
+ ids: Record<string, number>;
55
+ saved: number;
56
+ /** Has any record carried a measurement yet? Until then the segment makes no claim. */
57
+ seen: boolean;
58
+ }
59
+ export declare function freshMeasuredState(): MeasuredState;
38
60
  /** The main transcript, then its subagent transcripts (sorted). */
39
61
  export declare function sessionFiles(transcriptPath: string): string[];
40
62
  /**
@@ -47,17 +69,38 @@ export declare function readNewLines(path: string, offset: number, maxBytes: num
47
69
  } | null;
48
70
  /** Read what is new in the session's files into `st`. A file that shrank resets the count. */
49
71
  export declare function update(st: SessionState, files: string[], maxBytes?: number): SessionState;
72
+ /** A `usage.iterations` entry's measured saving (`mnemom_savings` v1), or null. */
73
+ export declare function measuredSaving(entry: unknown): number | null;
74
+ /**
75
+ * One transcript line → the gateway's measured saving for its request, or null (not an
76
+ * assistant record, or no measurement in its `message.usage.iterations`). `id` is the message
77
+ * id (or record uuid); null when the record carries neither. Claude Code writes one record per
78
+ * content block, all with the same id and usage, so the id is what dedupes them.
79
+ */
80
+ export declare function parseMeasuredLine(line: Buffer): {
81
+ id: string | null;
82
+ saved: number;
83
+ } | null;
84
+ /** Measured mode's `update`: sums each request's measured saving, replace-by-id. */
85
+ export declare function updateMeasured(st: MeasuredState, files: string[], maxBytes?: number): MeasuredState;
50
86
  /** The segment, rounded DOWN to the cent and never below $0.00. */
51
87
  export declare function segment(saved: number): string;
88
+ /** Measured mode's segment: no claim until the gateway has measured something; no "~". */
89
+ export declare function measuredSegment(st: MeasuredState): string;
52
90
  export declare function loadState(path: string): SessionState;
53
- export declare function saveState(path: string, st: SessionState): void;
91
+ export declare function loadMeasuredState(path: string): MeasuredState;
92
+ export declare function saveState(path: string, st: SessionState | MeasuredState): void;
54
93
  /** Run the user's own statusLine command with the same stdin; its first line, or "". */
55
94
  export declare function runInner(command: string, input: string): string;
56
95
  export interface RenderDeps {
57
96
  stateDir: string;
58
97
  inner?: string;
98
+ /** Measured mode (`measuredMode`): the gateway's measured saving, not the estimate. */
99
+ measured?: boolean;
59
100
  runInner?: (command: string, input: string) => string;
60
101
  }
102
+ /** Is the counter in measured mode? The launch's MEASURED_ENV, exactly "1". */
103
+ export declare function measuredMode(env: NodeJS.ProcessEnv): boolean;
61
104
  /** Claude Code's status JSON → the one status line. Never throws. */
62
105
  export declare function render(stdin: string, deps: RenderDeps): string;
63
106
  export interface UserStatusLine {
@@ -72,6 +115,7 @@ export declare function userStatusLine(cwd: string, configDir: string): UserStat
72
115
  /**
73
116
  * Launch args + env that add the counter, or null when it should stay off: the opt-out
74
117
  * env, or the user passing their own --settings (two would fight over precedence).
118
+ * `measured` puts the counter in measured mode (MEASURED_ENV).
75
119
  */
76
120
  export declare function statuslineLaunch(o: {
77
121
  passthrough: readonly string[];
@@ -80,6 +124,7 @@ export declare function statuslineLaunch(o: {
80
124
  home?: string;
81
125
  node: string;
82
126
  bin: string;
127
+ measured?: boolean;
83
128
  }): {
84
129
  args: string[];
85
130
  env: Record<string, string>;