@mnemom/mnemom 0.17.3-next.5 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -28,7 +28,10 @@ import { agentSlackCommand } from "./commands/agent-slack.js";
28
28
  import { agentDoctorCommand } from "./commands/agent-doctor.js";
29
29
  import { agentSetupCommand } from "./commands/agent-setup.js";
30
30
  import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
31
- import { applyConfigToEnv, loadLauncherConfig } from "./lib/launcher-config.js";
31
+ import { applyConfigToEnv, launcherEntries, loadLauncherConfig, withoutDisabledExperimental, } from "./lib/launcher-config.js";
32
+ import { loadExperimentalState } from "./lib/cli-config.js";
33
+ import { assertEnabled, EXPERIMENTAL_CONTRACT, gateCheck, ignoredSettingLines, reportCliError, } from "./lib/experimental.js";
34
+ import { experimentalDisableCommand, experimentalEnableCommand, experimentalListCommand, runExperimentalMigration, } from "./commands/experimental.js";
32
35
  import { parseContextProfile, resolveImplicit } from "./lib/agent.js";
33
36
  import { wrapCommand } from "./commands/wrap.js";
34
37
  import { onboardCommand } from "./commands/onboard.js";
@@ -97,12 +100,25 @@ program
97
100
  // CONTRACT the gateway seals on the first turn. Three launch shapes — terminal
98
101
  // (default), --remote-control (interactive RC in this terminal), --server
99
102
  // (headless RC dispatcher) — with yolo on by default. `--cli` selects the
100
- // executable (name or full path; default claude). The us-2/prod door is the
101
- // DEFAULT; set MNEMOM_AGENT_GATEWAY (or --gateway) to point elsewhere. The
103
+ // executable (name or full path; default claude). The door is the gateway of the
104
+ // cell the CLI uses (prod by default); a door on another cell is refused. The
102
105
  // Anthropic key is resolved with zero 1Password dependency (env → ~/.mnemom store
103
106
  // → interactive prompt) inside the launched process and never logged. See
104
107
  // commands/code.ts.
105
108
  // ─────────────────────────────────────────────────────────────────────────────
109
+ // ── experimental features (lib/experimental.ts) ── While a feature is off its flags and
110
+ // commands are hidden from --help (decided once, at load) and using one fails with a
111
+ // pointer to `mnemom experimental enable <id>` (decided per invocation, in the action).
112
+ const experimentalAtLoad = loadExperimentalState().enabled;
113
+ const hiddenUnless = (id) => !experimentalAtLoad.includes(id);
114
+ /** preAction guard for a gated command: print the guidance and exit 2 while `id` is off. */
115
+ function requireExperimental(what, id) {
116
+ const message = gateCheck(what, id, loadExperimentalState().enabled);
117
+ if (message) {
118
+ console.error(message);
119
+ process.exit(2);
120
+ }
121
+ }
106
122
  const collectRepeatable = (value, previous) => {
107
123
  previous.push(value);
108
124
  return previous;
@@ -119,11 +135,11 @@ const agentCmd = program
119
135
  .option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
120
136
  .option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
121
137
  .option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
122
- .option("--implicit", "Implicit goal alignment (the default): the gateway infers the goal from your messages and revises it as you steer (not combinable with --goal/--requirement/--allow/--forbid/--goal-id; the guardrail ceilings still apply)")
123
- .option("--no-implicit", "Turn goal alignment OFF for this launch (implicit alignment is on by default; also overrides launcher.toml goal_mode = implicit)")
138
+ .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")))
139
+ .option("--no-implicit", "Turn implicit goal alignment OFF for this launch (overrides launcher.toml goal_mode = implicit)")
124
140
  .option("--conversation-id <id>", "x-mnemom-conversation-id (default: auto-generated)")
125
141
  .option("--cli <name|path>", "Coding-agent CLI to launch: a name or full path (default: claude)")
126
- .option("--gateway <url>", "Gateway host override (default: https://gateway.mnemom.ai; or MNEMOM_AGENT_GATEWAY)")
142
+ .option("--gateway <url>", "Gateway host override — must be the same cell as the rest of the CLI (default: that cell's gateway; or MNEMOM_AGENT_GATEWAY)")
127
143
  .option("--remote-control", "Interactive Remote Control session in THIS terminal (behind a local proxy)")
128
144
  .option("--server", "Headless `claude remote-control` dispatcher (behind a local proxy)")
129
145
  .option("--model <alias|id>", "Model to launch (CLI alias or concrete id)")
@@ -140,7 +156,7 @@ const agentCmd = program
140
156
  .option("--no-setup", "Skip governed-agent provisioning entirely for this launch")
141
157
  .option("--no-yolo", "Do NOT pass --dangerously-skip-permissions (yolo is on by default)")
142
158
  .option("--no-context-hint", "Do NOT seed Claude Code's context-hint feature flag before launch")
143
- .option("--slack", "Attach the Slack channel: messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)")
159
+ .addOption(new Option("--slack", "EXPERIMENTAL (`mnemom experimental enable slack`): attach the Slack channel — messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)").hideHelp(hiddenUnless("slack")))
144
160
  .option("--context", "Force context folding ON for this launch even when launcher.toml (context = false) or MNEMOM_AGENT_CONTEXT turns it off")
145
161
  .option("--no-context", "Disable context folding for this launch (folding is on by default; tells the gateway to skip it for this session)")
146
162
  .addOption(new Option("--context-profile <profile>", 'How the gateway folds this session\'s context. "balanced" (default) keeps more of the earlier work verbatim and folds later — for long sessions that revisit earlier files and decisions. "aggressive" folds earlier and keeps a tighter working set — for cost-sensitive sessions and short, focused tasks. "off" leaves compaction to the coding agent. Also: config key context_profile, env MNEMOM_AGENT_CONTEXT_PROFILE.').argParser((v) => {
@@ -153,7 +169,7 @@ const agentCmd = program
153
169
  throw new InvalidArgumentError(e.message);
154
170
  }
155
171
  }))
156
- .option("--context-handoff", "EXPERIMENTAL, off by default: register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session")
172
+ .addOption(new Option("--context-handoff", "EXPERIMENTAL (`mnemom experimental enable context-handoff`): register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session").hideHelp(hiddenUnless("context-handoff")))
157
173
  .option("--no-context-handoff", "Keep context handoff off for this launch even when launcher.toml (context_handoff = true) or MNEMOM_AGENT_CONTEXT_HANDOFF turns it on")
158
174
  .option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
159
175
  .action(async (scenario, passthrough, opts) => {
@@ -168,29 +184,53 @@ const agentCmd = program
168
184
  });
169
185
  }
170
186
  catch (error) {
171
- console.error("Error:", error instanceof Error ? error.message : error);
172
- process.exit(1);
187
+ const report = reportCliError(error);
188
+ console.error(...report.args);
189
+ process.exit(report.code);
173
190
  }
174
191
  async function launchAgent(scenario) {
192
+ const experimental = loadExperimentalState().enabled;
175
193
  // Sub-verb: `mnemom agent config …` manages persistent settings and never
176
194
  // launches. (A scenario literally named "config" is reserved for this.)
177
195
  if (scenario === "config") {
178
- await agentConfigCommand(passthrough);
196
+ await agentConfigCommand(passthrough, { enabled: experimental });
179
197
  return;
180
198
  }
181
199
  // Sub-verb: `mnemom agent slack …` posts/receives Slack messages as this
182
- // session (lib/slack.ts) and never launches.
200
+ // session (lib/slack.ts) and never launches. Experimental (`slack`).
183
201
  if (scenario === "slack") {
202
+ assertEnabled([{ what: "`mnemom agent slack`", id: "slack", used: true }], experimental);
184
203
  await agentSlackCommand(passthrough);
185
204
  return;
186
205
  }
206
+ // Experimental flags while their feature is off: refuse (exit 2) with the pointer.
207
+ // The `--no-*` spellings stay available — turning an off feature off is harmless.
208
+ assertEnabled([
209
+ { what: "--slack", id: "slack", used: opts.slack === true },
210
+ {
211
+ what: "--context-handoff",
212
+ id: "context-handoff",
213
+ used: opts.contextHandoff === true,
214
+ },
215
+ { what: "--implicit", id: "implicit", used: opts.implicit === true },
216
+ ], experimental);
187
217
  if (opts.gateway)
188
218
  process.env.MNEMOM_AGENT_GATEWAY = opts.gateway;
189
219
  // Persistent settings (~/.mnemom/launcher.toml) sit UNDER env and flags:
190
220
  // precedence is flag > env > launcher.toml > built-in. Env-backed settings are
191
221
  // folded into env only when unset (so env/flags still win); the resolvers
192
222
  // in lib/code.ts keep the built-in default as the final fallback.
193
- const cfg = loadLauncherConfig();
223
+ // Settings of experimental features that are off are ignored (with the pointer),
224
+ // before they can reach env or the launch.
225
+ const rawCfg = loadLauncherConfig();
226
+ for (const line of ignoredSettingLines({
227
+ entries: launcherEntries(rawCfg),
228
+ env: process.env,
229
+ enabled: experimental,
230
+ })) {
231
+ console.error(line);
232
+ }
233
+ const cfg = withoutDisabledExperimental(rawCfg, experimental);
194
234
  applyConfigToEnv(cfg, process.env);
195
235
  // Sub-verb: `mnemom agent doctor` runs a read-only preflight and never
196
236
  // launches. Placed after config load so it reports the effective settings.
@@ -207,11 +247,13 @@ const agentCmd = program
207
247
  const numToStr = (n) => n === undefined ? undefined : String(n);
208
248
  // Implicit mode: --implicit / --no-implicit win; a --goal statement seals
209
249
  // (the standing config default never overrides it); else launcher.toml
210
- // `goal_mode`; else off. Pure + unit-tested in lib/agent.ts.
250
+ // `goal_mode`; else implicit while the `implicit` feature is on, off while it is
251
+ // off. Pure + unit-tested in lib/agent.ts.
211
252
  const implicit = resolveImplicit({
212
253
  goal: opts.goal,
213
254
  implicit: opts.implicit,
214
255
  configGoalMode: cfg.goalMode,
256
+ experimentalOn: experimental.includes("implicit"),
215
257
  });
216
258
  // Config guardrails apply only when goal alignment is actually intended (a
217
259
  // --goal is present, or implicit mode — where they ride on the mode header),
@@ -245,7 +287,9 @@ const agentCmd = program
245
287
  slack: opts.slack ?? cfg.slackListen,
246
288
  context: opts.context,
247
289
  contextProfile: opts.contextProfile,
248
- contextHandoff: opts.contextHandoff,
290
+ // Off ⇒ an explicit false, so MNEMOM_AGENT_CONTEXT_HANDOFF cannot turn it on.
291
+ contextHandoff: experimental.includes("context-handoff") ? opts.contextHandoff : false,
292
+ experimental,
249
293
  setup: opts.setup,
250
294
  keySource: cfg.keySource,
251
295
  passthrough,
@@ -763,9 +807,10 @@ agentCmd
763
807
  // (implicit-goal CLI). The handlers set process.exitCode themselves (1 / 2); the catch is for
764
808
  // the unexpected.
765
809
  const agentGoalCmd = agentCmd
766
- .command("goal")
767
- .description("Read and steer the goal the gateway inferred for an implicit-mode session (show / pin / reset). " +
768
- "Authenticates with the same key and agent the session launched with");
810
+ .command("goal", { hidden: hiddenUnless("implicit") })
811
+ .description("EXPERIMENTAL (`mnemom experimental enable implicit`): read and steer the goal the gateway inferred for an implicit-mode session (show / pin / reset). " +
812
+ "Authenticates with the same key and agent the session launched with")
813
+ .hook("preAction", () => requireExperimental("`mnemom agent goal`", "implicit"));
769
814
  const collectRepeat = (value, prev = []) => [...prev, value];
770
815
  agentGoalCmd
771
816
  .command("show [ref]")
@@ -1928,6 +1973,23 @@ configCmd
1928
1973
  .command("unset <key>")
1929
1974
  .description("Reset a preference to its default")
1930
1975
  .action((key) => runAction(() => configUnsetCommand(key)));
1976
+ // ── experimental (lib/experimental.ts) — turn experimental features on and off ──
1977
+ const experimentalCmd = program
1978
+ .command("experimental")
1979
+ .description(`List, enable and disable experimental features (stored in ~/.mnemom/config.json; MNEMOM_EXPERIMENTAL=<ids>|all overrides). ${EXPERIMENTAL_CONTRACT}`);
1980
+ experimentalCmd
1981
+ .command("list", { isDefault: true })
1982
+ .description("Show every experimental feature, whether it is on, and what it gates")
1983
+ .option("--json", "Emit machine-readable JSON")
1984
+ .action((options) => runAction(() => experimentalListCommand(options)));
1985
+ experimentalCmd
1986
+ .command("enable <id>")
1987
+ .description("Turn an experimental feature on")
1988
+ .action((id) => runAction(() => experimentalEnableCommand(id)));
1989
+ experimentalCmd
1990
+ .command("disable <id>")
1991
+ .description("Turn an experimental feature off")
1992
+ .action((id) => runAction(() => experimentalDisableCommand(id)));
1931
1993
  // Export the fully-assembled commander program so tooling (e.g. the
1932
1994
  // command-tree snapshot generator in scripts/gen-command-tree.mjs) can
1933
1995
  // statically introspect the command surface WITHOUT executing the CLI.
@@ -1941,8 +2003,12 @@ export { program };
1941
2003
  * unknown — so it bypasses commander entirely and reaches the sub-verb parser
1942
2004
  * verbatim. Exported (not auto-run) so the bypass is unit-testable.
1943
2005
  */
1944
- export function runEntrypoint(argv = process.argv.slice(2)) {
2006
+ export function runEntrypoint(argv = process.argv.slice(2), migrate = runExperimentalMigration) {
2007
+ // First run after the upgrade that introduced experimental features: keep on whatever
2008
+ // launcher.toml already uses (commands/experimental.ts; never throws).
2009
+ migrate();
1945
2010
  if (argv[0] === "agent" && argv[1] === "slack") {
2011
+ requireExperimental("`mnemom agent slack`", "slack");
1946
2012
  agentSlackCommand(argv.slice(2)).catch((err) => {
1947
2013
  console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
1948
2014
  process.exit(1);
@@ -81,6 +81,11 @@ export interface SessionRecord {
81
81
  * before profiles existed omit it (still `v: 1`).
82
82
  */
83
83
  context_profile?: ContextProfile;
84
+ /**
85
+ * The experimental features on for the launch (`mnemom experimental`). Optional and only
86
+ * written when at least one was on; older records omit it (still `v: 1`).
87
+ */
88
+ experimental?: string[];
84
89
  }
85
90
  /** Goal-alignment mode as recorded at launch. */
86
91
  export type GoalMode = "off" | "on" | "implicit";
@@ -160,6 +160,11 @@ export function parseRecord(line) {
160
160
  const rec = value;
161
161
  if (rec.v !== 1)
162
162
  return null;
163
+ // A hand-edited or future `experimental` that is not a string list is dropped, not trusted.
164
+ if (rec.experimental !== undefined &&
165
+ !(Array.isArray(rec.experimental) && rec.experimental.every((x) => typeof x === "string"))) {
166
+ delete rec.experimental;
167
+ }
163
168
  return rec;
164
169
  }
165
170
  function nonEmptyLines(text) {
@@ -63,11 +63,27 @@ export interface ContractFlags {
63
63
  export type LaunchShape = "terminal" | "remote-control" | "server";
64
64
  /**
65
65
  * Resolve the gateway HOST (scheme + host, no path). An explicit, non-empty
66
- * `MNEMOM_AGENT_GATEWAY` wins verbatim; otherwise the us-2/prod host. The default
67
- * is PROD — an unset var resolves to prod, and any other cell is reached only by
68
- * NAMING it. Trailing slash trimmed so door concatenation is clean.
69
- */
70
- export declare function resolveGatewayHost(env?: NodeJS.ProcessEnv): string;
66
+ * `MNEMOM_AGENT_GATEWAY` wins verbatim; otherwise the gateway of the cell the rest
67
+ * of the CLI uses (`getGatewayUrl()`: prod unless MNEMOM_ENV / MNEMOM_RING /
68
+ * MNEMOM_GATEWAY_URL name another). An override on a different cell is refused at
69
+ * launch — see `singleCellError`. Trailing slash trimmed so door concatenation is clean.
70
+ */
71
+ export declare function resolveGatewayHost(env?: NodeJS.ProcessEnv, cellGateway?: string): string;
72
+ /** Same scheme+host+port. An unparseable URL never matches. */
73
+ export declare function sameOrigin(a: string, b: string): boolean;
74
+ /**
75
+ * A launch must use ONE cell: the Anthropic `door` its traffic goes to must be the
76
+ * gateway of the cell the CLI signs in, resolves the agent and reads sessions on
77
+ * (`cellGateway`, paired with `apiUrl`). A mixed launch records one cell's agent
78
+ * and runs as another cell's, so `sessions`/`show` never find its goal state.
79
+ * Returns the refusal message, or null. A loopback door is a local dev gateway and
80
+ * is exempt.
81
+ */
82
+ export declare function singleCellError(opts: {
83
+ door: string;
84
+ apiUrl: string;
85
+ cellGateway: string;
86
+ }): string | null;
71
87
  /**
72
88
  * The Anthropic door the CLI points Claude Code at:
73
89
  * `${MNEMOM_AGENT_GATEWAY}/anthropic`, or the full-door override
@@ -324,6 +340,20 @@ export declare function buildSessionConfig(opts: {
324
340
  profile: ContextProfile;
325
341
  contextHandoff: boolean;
326
342
  }): Record<string, string>;
343
+ /**
344
+ * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
345
+ * experimental ids ride as a top-level array, beside (not inside) the env overlay, so the
346
+ * gateway's allow-list never sees them as env keys; it ignores unknown envelope fields.
347
+ */
348
+ export declare function buildSessionConfigPayload(opts: {
349
+ profile: ContextProfile;
350
+ contextHandoff: boolean;
351
+ experimental?: readonly string[];
352
+ }): {
353
+ v: 1;
354
+ config: Record<string, string>;
355
+ experimental?: string[];
356
+ } | undefined;
327
357
  /**
328
358
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
329
359
  * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
@@ -339,6 +369,7 @@ export declare function buildSessionConfig(opts: {
339
369
  export declare function buildSessionConfigB64(opts: {
340
370
  profile: ContextProfile;
341
371
  contextHandoff: boolean;
372
+ experimental?: readonly string[];
342
373
  }): string | undefined;
343
374
  /**
344
375
  * Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
@@ -427,10 +458,10 @@ export type GoalModeDefault = "implicit" | "off";
427
458
  * standing config default must never silently override;
428
459
  * 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
429
460
  * keeps it implicit;
430
- * 4. IMPLICIT (the default). A bare `mnemom agent` runs with implicit goal
431
- * alignment on: the gateway infers the goal from the user's own messages.
432
- * Turn it off for a launch with `--no-implicit`, or standing with
433
- * `goal_mode = "off"`.
461
+ * 4. with the `implicit` experimental feature ON, IMPLICIT: the gateway infers
462
+ * the goal from the user's own messages (turn it off for a launch with
463
+ * `--no-implicit`, or standing with `goal_mode = "off"`); with it OFF (the
464
+ * default), OFF.
434
465
  * Pure so every arm is unit-tested; the caller passes the resolved value on to
435
466
  * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
436
467
  * combined with a contract flag — that conflict is EXPLICIT and is reported).
@@ -439,4 +470,10 @@ export declare function resolveImplicit(opts: {
439
470
  goal?: string;
440
471
  implicit?: boolean;
441
472
  configGoalMode?: GoalModeDefault;
473
+ /**
474
+ * Is the `implicit` experimental feature on? Off ⇒ the default is OFF and
475
+ * `goal_mode = implicit` is ignored (`--implicit` is refused upstream, before
476
+ * this runs). Omitted ⇒ on.
477
+ */
478
+ experimentalOn?: boolean;
442
479
  }): boolean;
package/dist/lib/agent.js CHANGED
@@ -20,19 +20,56 @@
20
20
  * NAMED), but prod is never selected silently — see `resolveGatewayHost`.
21
21
  */
22
22
  import { createHash, randomUUID } from "node:crypto";
23
+ import { getGatewayUrl } from "./config.js";
23
24
  /** The us-2/prod gateway host. The default door — never any other cell silently. */
24
25
  export const PROD_GATEWAY_HOST = "https://gateway.mnemom.ai";
25
26
  /**
26
27
  * Resolve the gateway HOST (scheme + host, no path). An explicit, non-empty
27
- * `MNEMOM_AGENT_GATEWAY` wins verbatim; otherwise the us-2/prod host. The default
28
- * is PROD — an unset var resolves to prod, and any other cell is reached only by
29
- * NAMING it. Trailing slash trimmed so door concatenation is clean.
28
+ * `MNEMOM_AGENT_GATEWAY` wins verbatim; otherwise the gateway of the cell the rest
29
+ * of the CLI uses (`getGatewayUrl()`: prod unless MNEMOM_ENV / MNEMOM_RING /
30
+ * MNEMOM_GATEWAY_URL name another). An override on a different cell is refused at
31
+ * launch — see `singleCellError`. Trailing slash trimmed so door concatenation is clean.
30
32
  */
31
- export function resolveGatewayHost(env = process.env) {
33
+ export function resolveGatewayHost(env = process.env, cellGateway = getGatewayUrl()) {
32
34
  const raw = (env.MNEMOM_AGENT_GATEWAY ?? "").trim();
33
- const host = raw.length > 0 ? raw : PROD_GATEWAY_HOST;
35
+ const host = raw.length > 0 ? raw : cellGateway;
34
36
  return host.replace(/\/+$/, "");
35
37
  }
38
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
39
+ /** Same scheme+host+port. An unparseable URL never matches. */
40
+ export function sameOrigin(a, b) {
41
+ try {
42
+ return new URL(a).origin === new URL(b).origin;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ /**
49
+ * A launch must use ONE cell: the Anthropic `door` its traffic goes to must be the
50
+ * gateway of the cell the CLI signs in, resolves the agent and reads sessions on
51
+ * (`cellGateway`, paired with `apiUrl`). A mixed launch records one cell's agent
52
+ * and runs as another cell's, so `sessions`/`show` never find its goal state.
53
+ * Returns the refusal message, or null. A loopback door is a local dev gateway and
54
+ * is exempt.
55
+ */
56
+ export function singleCellError(opts) {
57
+ let doorUrl;
58
+ try {
59
+ doorUrl = new URL(opts.door);
60
+ }
61
+ catch {
62
+ return `mnemom agent: the gateway '${opts.door}' is not a valid URL.`;
63
+ }
64
+ if (LOOPBACK_HOSTS.has(doorUrl.hostname))
65
+ return null;
66
+ if (sameOrigin(opts.door, opts.cellGateway))
67
+ return null;
68
+ return (`mnemom agent: the gateway ${doorUrl.origin} is on a different cell from the one this CLI uses ` +
69
+ `(API ${opts.apiUrl}, gateway ${opts.cellGateway}). A launch must use one cell. Point the whole ` +
70
+ "CLI at that cell (MNEMOM_API_URL + MNEMOM_GATEWAY_URL, or MNEMOM_RING) and drop the gateway " +
71
+ "override (--gateway, MNEMOM_AGENT_GATEWAY, MNEMOM_AGENT_ANTHROPIC_GATEWAY, or `gateway` in launcher.toml).");
72
+ }
36
73
  /**
37
74
  * The Anthropic door the CLI points Claude Code at:
38
75
  * `${MNEMOM_AGENT_GATEWAY}/anthropic`, or the full-door override
@@ -513,6 +550,20 @@ export function buildSessionConfig(opts) {
513
550
  config[CONTEXT_HANDOFF_KEY] = "true";
514
551
  return config;
515
552
  }
553
+ /**
554
+ * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
555
+ * experimental ids ride as a top-level array, beside (not inside) the env overlay, so the
556
+ * gateway's allow-list never sees them as env keys; it ignores unknown envelope fields.
557
+ */
558
+ export function buildSessionConfigPayload(opts) {
559
+ const config = buildSessionConfig(opts);
560
+ const experimental = opts.experimental ?? [];
561
+ if (Object.keys(config).length === 0 && experimental.length === 0)
562
+ return undefined;
563
+ return experimental.length > 0
564
+ ? { v: 1, config, experimental: [...experimental] }
565
+ : { v: 1, config };
566
+ }
516
567
  /**
517
568
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
518
569
  * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
@@ -526,11 +577,10 @@ export function buildSessionConfig(opts) {
526
577
  * base64url so it survives the gateway's inbound strip.
527
578
  */
528
579
  export function buildSessionConfigB64(opts) {
529
- const config = buildSessionConfig(opts);
530
- if (Object.keys(config).length === 0)
580
+ const payload = buildSessionConfigPayload(opts);
581
+ if (!payload)
531
582
  return undefined;
532
- const payload = JSON.stringify({ v: 1, config });
533
- return Buffer.from(payload, "utf8").toString("base64url");
583
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
534
584
  }
535
585
  /**
536
586
  * Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
@@ -587,10 +637,10 @@ export function buildHandoffMcpConfigJson(p) {
587
637
  * standing config default must never silently override;
588
638
  * 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
589
639
  * keeps it implicit;
590
- * 4. IMPLICIT (the default). A bare `mnemom agent` runs with implicit goal
591
- * alignment on: the gateway infers the goal from the user's own messages.
592
- * Turn it off for a launch with `--no-implicit`, or standing with
593
- * `goal_mode = "off"`.
640
+ * 4. with the `implicit` experimental feature ON, IMPLICIT: the gateway infers
641
+ * the goal from the user's own messages (turn it off for a launch with
642
+ * `--no-implicit`, or standing with `goal_mode = "off"`); with it OFF (the
643
+ * default), OFF.
594
644
  * Pure so every arm is unit-tested; the caller passes the resolved value on to
595
645
  * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
596
646
  * combined with a contract flag — that conflict is EXPLICIT and is reported).
@@ -600,5 +650,7 @@ export function resolveImplicit(opts) {
600
650
  return opts.implicit;
601
651
  if (hasText(opts.goal))
602
652
  return false;
653
+ if (opts.experimentalOn === false)
654
+ return false;
603
655
  return opts.configGoalMode !== "off";
604
656
  }
@@ -17,6 +17,7 @@
17
17
  * Membership can change after it's stored — consumers must treat a server
18
18
  * 403 as the truth (the claim path already renders the teaching list).
19
19
  */
20
+ import { type ExperimentalId, type ExperimentalPrefs, type ExperimentalState } from "./experimental.js";
20
21
  import type { UpdatePrefs } from "./update/decide.js";
21
22
  export interface ActiveOrg {
22
23
  org_id: string;
@@ -32,6 +33,11 @@ export interface CliConfig {
32
33
  /** Auto-update preferences (`mnemom config set update.channel|update.mode`). */
33
34
  update?: UpdatePrefs;
34
35
  sessions?: SessionPrefs;
36
+ /**
37
+ * Experimental features the user turned on (`mnemom experimental enable <id>`). The
38
+ * section's presence (even `{ enabled: [] }`) also marks the one-time migration done.
39
+ */
40
+ experimental?: ExperimentalPrefs;
35
41
  }
36
42
  /** The config.json sections `mnemom config` edits. */
37
43
  export interface PrefSections {
@@ -52,3 +58,7 @@ export declare function getActiveOrg(): ActiveOrg | undefined;
52
58
  export declare function setPref<S extends keyof PrefSections, K extends keyof PrefSections[S] & string>(section: S, key: K, value: PrefSections[S][K] | undefined): void;
53
59
  /** One auto-update preference — `setPref("update", …)`. */
54
60
  export declare function setUpdatePref<K extends keyof UpdatePrefs>(key: K, value: UpdatePrefs[K] | undefined): void;
61
+ /** Persist the full set of enabled experimental features (an empty list is kept). */
62
+ export declare function setExperimentalEnabled(ids: readonly ExperimentalId[]): void;
63
+ /** Which experimental features are on right now (MNEMOM_EXPERIMENTAL > config.json > off). */
64
+ export declare function loadExperimentalState(env?: Record<string, string | undefined>): ExperimentalState;
@@ -20,6 +20,7 @@
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  import { MNEMOM_DIR } from "./config.js";
23
+ import { resolveExperimental, } from "./experimental.js";
23
24
  function configFile() {
24
25
  return path.join(MNEMOM_DIR, "config.json");
25
26
  }
@@ -84,3 +85,13 @@ export function setPref(section, key, value) {
84
85
  export function setUpdatePref(key, value) {
85
86
  setPref("update", key, value);
86
87
  }
88
+ /** Persist the full set of enabled experimental features (an empty list is kept). */
89
+ export function setExperimentalEnabled(ids) {
90
+ const config = loadCliConfig();
91
+ config.experimental = { enabled: [...ids] };
92
+ saveCliConfig(config);
93
+ }
94
+ /** Which experimental features are on right now (MNEMOM_EXPERIMENTAL > config.json > off). */
95
+ export function loadExperimentalState(env = process.env) {
96
+ return resolveExperimental({ stored: loadCliConfig().experimental, env });
97
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Experimental features — the single registry and the pure logic around it.
3
+ *
4
+ * An experimental feature is OFF unless the user turns it on (`mnemom experimental
5
+ * enable <id>`, stored in ~/.mnemom/config.json `experimental.enabled`, or the
6
+ * MNEMOM_EXPERIMENTAL env var). While it is off its flags and commands are hidden
7
+ * from --help, using one fails with a pointer to `mnemom experimental enable`, and
8
+ * its launcher.toml keys are ignored with the same pointer. The contract: an
9
+ * experimental feature may change or be removed in any release, with no
10
+ * deprecation period.
11
+ *
12
+ * Everything here is pure or dependency-injected; index.ts and the command
13
+ * modules are the thin drivers.
14
+ */
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit";
16
+ export interface ExperimentalFeature {
17
+ id: ExperimentalId;
18
+ summary: string;
19
+ /** The first CLI release that shipped the feature as experimental. */
20
+ since: string;
21
+ /** What the feature gates while it is off (for `mnemom experimental list`). */
22
+ gates: {
23
+ flags: string[];
24
+ configKeys: string[];
25
+ commands: string[];
26
+ env: string[];
27
+ };
28
+ }
29
+ export declare const EXPERIMENTAL_FEATURES: readonly ExperimentalFeature[];
30
+ export declare const EXPERIMENTAL_IDS: readonly ExperimentalId[];
31
+ export declare const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
32
+ export declare const EXPERIMENTAL_CONTRACT = "Experimental features may change or be removed in any release, with no deprecation period.";
33
+ export declare function isExperimentalId(value: string): value is ExperimentalId;
34
+ /** The config.json `experimental` section. */
35
+ export interface ExperimentalPrefs {
36
+ enabled?: string[];
37
+ }
38
+ export interface ExperimentalState {
39
+ enabled: ExperimentalId[];
40
+ source: "env" | "config" | "default";
41
+ /** Ids named in MNEMOM_EXPERIMENTAL or config.json that this CLI does not know. */
42
+ unknown: string[];
43
+ }
44
+ /**
45
+ * Which experimental features are on. Precedence: MNEMOM_EXPERIMENTAL (a comma
46
+ * list of ids, `all`, or `none`) replaces the stored set; else config.json
47
+ * `experimental.enabled`; else everything off.
48
+ */
49
+ export declare function resolveExperimental(opts: {
50
+ stored: ExperimentalPrefs | undefined;
51
+ env: Record<string, string | undefined>;
52
+ }): ExperimentalState;
53
+ /** The one-line stderr notice for a launch with experimental features on; null when none are. */
54
+ export declare function experimentalNotice(enabled: readonly ExperimentalId[]): string | null;
55
+ /** The error for using an experimental flag or command while its feature is off. */
56
+ export declare function gatedMessage(what: string, id: ExperimentalId): string;
57
+ /** Thrown when a gated flag or command is used while its feature is off; the CLI exits 2. */
58
+ export declare class ExperimentalGateError extends Error {
59
+ readonly exitCode = 2;
60
+ constructor(what: string, id: ExperimentalId);
61
+ }
62
+ /** Throw for the first use whose feature is off. */
63
+ export declare function assertEnabled(uses: ReadonlyArray<{
64
+ what: string;
65
+ id: ExperimentalId;
66
+ used: boolean;
67
+ }>, enabled: readonly ExperimentalId[]): void;
68
+ /**
69
+ * The feature a launcher.toml key/value belongs to, when setting it only has an
70
+ * effect with that feature on. `goal_mode = off`, `context_handoff = false` and
71
+ * `slack_listen = false` are harmless either way.
72
+ */
73
+ export declare function experimentalIdForConfigKey(key: string, value: string): ExperimentalId | undefined;
74
+ /**
75
+ * The stderr lines for experimental settings a launch ignores because their feature
76
+ * is off: launcher.toml keys (`entries` as `[key, value]`) and MNEMOM_AGENT_CONTEXT_HANDOFF.
77
+ */
78
+ export declare function ignoredSettingLines(opts: {
79
+ entries: ReadonlyArray<[string, string]>;
80
+ env: Record<string, string | undefined>;
81
+ enabled: readonly ExperimentalId[];
82
+ }): string[];
83
+ /** The features an existing launcher.toml already uses (the one-time migration). */
84
+ export declare function featuresInUse(entries: ReadonlyArray<[string, string]>): ExperimentalId[];
85
+ export interface MigrationDeps {
86
+ stored(): ExperimentalPrefs | undefined;
87
+ /** launcher.toml as `[key, value]` pairs; may throw on a malformed file. */
88
+ launcherEntries(): Array<[string, string]>;
89
+ persist(ids: ExperimentalId[]): void;
90
+ writeErr(line: string): void;
91
+ }
92
+ /** One-time notice for users who had implicit goal alignment by default (no goal_mode set). */
93
+ export declare const IMPLICIT_NOW_OFF_NOTICE: string;
94
+ /**
95
+ * First run after the upgrade that introduced experimental features: config.json has
96
+ * no `experimental` section yet, so turn on whatever the user's launcher.toml already
97
+ * uses, persist (an empty list too — the section's presence marks the migration
98
+ * done), and say once which were kept on. A malformed launcher.toml skips the
99
+ * migration this run (the launch reports that file itself) so it retries next time.
100
+ * Never throws.
101
+ */
102
+ export declare function migrateExperimental(deps: MigrationDeps): ExperimentalId[] | null;
103
+ /** The feature a launcher.toml key belongs to at all (for marking `mnemom agent config list`). */
104
+ export declare function configKeyFeature(key: string): ExperimentalId | undefined;
105
+ /**
106
+ * ` (experimental, off)` for a launcher.toml key whose feature is off, else "". With a
107
+ * `value`, only a setting that would take effect is marked (`goal_mode = off` is not).
108
+ */
109
+ export declare function configKeyMark(key: string, value: string | undefined, enabled: readonly ExperimentalId[]): string;
110
+ /** `null` when `id` is on, else the gated-use message. */
111
+ export declare function gateCheck(what: string, id: ExperimentalId, enabled: readonly ExperimentalId[]): string | null;
112
+ /**
113
+ * How a command's catch reports an error: a gated use prints its guidance bare and
114
+ * exits 2; anything else keeps the CLI's `Error: <message>` and exit 1.
115
+ */
116
+ export declare function reportCliError(error: unknown): {
117
+ args: unknown[];
118
+ code: number;
119
+ };