@mnemom/mnemom 0.17.3-next.5 → 0.17.3-next.6

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.
@@ -13,8 +13,11 @@
13
13
  * Settable keys are the single source of truth in lib/code-config.ts CONFIG_KEYS.
14
14
  * The secret Anthropic key is NOT a config key — it lives in ~/.mnemom/launcher.json.
15
15
  */
16
+ import { type ExperimentalId } from "../lib/experimental.js";
16
17
  /** Entry point for `mnemom agent config <verb> [args...]`. Throws on misuse. */
17
- export declare function agentConfigCommand(argv: string[]): Promise<void>;
18
+ export declare function agentConfigCommand(argv: string[], opts?: {
19
+ enabled?: readonly ExperimentalId[];
20
+ }): Promise<void>;
18
21
  /**
19
22
  * `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
20
23
  * never shell syntax: the value is split on whitespace and every token must be a bare
@@ -16,15 +16,17 @@
16
16
  import { spawnSync } from "node:child_process";
17
17
  import { existsSync } from "node:fs";
18
18
  import { LAUNCHER_CONFIG_PATH, CONFIG_KEYS, configKeySpec, loadLauncherConfig, writeLauncherConfig, } from "../lib/launcher-config.js";
19
+ import { loadExperimentalState } from "../lib/cli-config.js";
20
+ import { configKeyMark } from "../lib/experimental.js";
19
21
  const out = (line = "") => console.log(line);
20
22
  const err = (line = "") => console.error(line);
21
23
  /** Entry point for `mnemom agent config <verb> [args...]`. Throws on misuse. */
22
- export async function agentConfigCommand(argv) {
24
+ export async function agentConfigCommand(argv, opts = {}) {
23
25
  const [verb, ...rest] = argv;
24
26
  switch (verb) {
25
27
  case undefined:
26
28
  case "list":
27
- return listConfig();
29
+ return listConfig(opts.enabled ?? loadExperimentalState().enabled);
28
30
  case "path":
29
31
  out(LAUNCHER_CONFIG_PATH);
30
32
  return;
@@ -40,7 +42,8 @@ export async function agentConfigCommand(argv) {
40
42
  throw new Error(`mnemom agent config: unknown verb '${verb}'. Use list | get | set | unset | edit | path.`);
41
43
  }
42
44
  }
43
- function listConfig() {
45
+ /** Settings of experimental features that are off are marked `(experimental, off)`. */
46
+ function listConfig(enabled) {
44
47
  const cfg = loadLauncherConfig();
45
48
  const rows = [];
46
49
  for (const spec of CONFIG_KEYS) {
@@ -54,13 +57,13 @@ function listConfig() {
54
57
  out("");
55
58
  out("Settable keys:");
56
59
  for (const spec of CONFIG_KEYS)
57
- out(` ${spec.key.padEnd(22)} ${spec.describe}`);
60
+ out(` ${spec.key.padEnd(22)} ${spec.describe}${configKeyMark(spec.key, undefined, enabled)}`);
58
61
  return;
59
62
  }
60
63
  const width = Math.max(...rows.map(([k]) => k.length));
61
64
  out(`# ${LAUNCHER_CONFIG_PATH}`);
62
65
  for (const [k, v] of rows)
63
- out(`${k.padEnd(width)} ${v}`);
66
+ out(`${k.padEnd(width)} ${v}${configKeyMark(k, v, enabled)}`);
64
67
  }
65
68
  function getConfig(key) {
66
69
  if (!key)
@@ -8,12 +8,13 @@
8
8
  * 1. a SESSION NAME — labels the session in traces and the claude.ai session
9
9
  * list (defaults to the current folder's name).
10
10
  *
11
- * GOAL ALIGNMENT is not prompted for: it defaults to IMPLICIT. A bare launch
12
- * runs governed with the goal inferred from your own messages
13
- * (`x-mnemom-goal-mode: implicit`, no contract). `--goal` seals a one-line
14
- * contract instead (`x-mnemom-contract`); `--no-implicit` (or launcher.toml
15
- * `goal_mode = "off"`) turns alignment off — neither header is sent. The mode is
16
- * resolved upstream (`resolveImplicit`) and announced on the launch line.
11
+ * GOAL ALIGNMENT is not prompted for. `--goal` seals a one-line contract
12
+ * (`x-mnemom-contract`). Implicit alignment — the goal inferred from your own
13
+ * messages (`x-mnemom-goal-mode: implicit`, no contract) — is the experimental
14
+ * `implicit` feature: while it is on a bare launch runs implicit, while it is off
15
+ * (the default) a bare launch runs with alignment off. `--no-implicit` (or
16
+ * launcher.toml `goal_mode = "off"`) turns it off. The mode is resolved upstream
17
+ * (`resolveImplicit`) and announced on the launch line.
17
18
  *
18
19
  * Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
19
20
  * for a name. Pure apart from the injected `ask` functions, so every arm is
@@ -8,12 +8,13 @@
8
8
  * 1. a SESSION NAME — labels the session in traces and the claude.ai session
9
9
  * list (defaults to the current folder's name).
10
10
  *
11
- * GOAL ALIGNMENT is not prompted for: it defaults to IMPLICIT. A bare launch
12
- * runs governed with the goal inferred from your own messages
13
- * (`x-mnemom-goal-mode: implicit`, no contract). `--goal` seals a one-line
14
- * contract instead (`x-mnemom-contract`); `--no-implicit` (or launcher.toml
15
- * `goal_mode = "off"`) turns alignment off — neither header is sent. The mode is
16
- * resolved upstream (`resolveImplicit`) and announced on the launch line.
11
+ * GOAL ALIGNMENT is not prompted for. `--goal` seals a one-line contract
12
+ * (`x-mnemom-contract`). Implicit alignment — the goal inferred from your own
13
+ * messages (`x-mnemom-goal-mode: implicit`, no contract) — is the experimental
14
+ * `implicit` feature: while it is on a bare launch runs implicit, while it is off
15
+ * (the default) a bare launch runs with alignment off. `--no-implicit` (or
16
+ * launcher.toml `goal_mode = "off"`) turns it off. The mode is resolved upstream
17
+ * (`resolveImplicit`) and announced on the launch line.
17
18
  *
18
19
  * Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
19
20
  * for a name. Pure apart from the injected `ask` functions, so every arm is
@@ -65,11 +66,9 @@ export async function resolveSessionSetup(input, ask) {
65
66
  say(` (Next time you can pass it directly: mnemom agent <name>.)`);
66
67
  const typed = await ask.askInput(`Session name [${fallback}]:`);
67
68
  const scenario = sanitizeAgentName(typed.trim()) || fallback;
68
- // ── 2. goal alignment — resolved upstream, no prompt. Implicit is the DEFAULT
69
- // (a bare launch runs governed with the goal inferred from your messages), so
70
- // there is nothing to pick here: `--goal` seals a contract, the default (and
71
- // `--implicit` / `goal_mode = "implicit"`) is implicit, and `--no-implicit` /
72
- // `goal_mode = "off"` turns it off. The launch line announces the mode.
69
+ // ── 2. goal alignment — resolved upstream, no prompt: `--goal` seals a contract,
70
+ // the `implicit` experimental feature (when on) makes implicit the default, and
71
+ // `--no-implicit` / `goal_mode = "off"` turns it off. The launch line announces the mode.
73
72
  if (flagGoal) {
74
73
  return { scenario, goal: flagGoal, goalAlignment: "on", prompted: true };
75
74
  }
@@ -180,6 +180,9 @@ export function buildShowView(local, remote, reason) {
180
180
  if (local.context_profile)
181
181
  launchBits.push(`context ${local.context_profile}`);
182
182
  L.push(fmt.label("launch: ", launchBits.join(" · ")));
183
+ if (local.experimental?.length) {
184
+ L.push(fmt.label("experimental: ", local.experimental.join(", ")));
185
+ }
183
186
  L.push(fmt.label("conversation: ", local.conversation_id));
184
187
  const goalMode = resolveGoalMode(local, remote);
185
188
  L.push(fmt.label("goal alignment:", goalBadge(goalMode)));
@@ -25,6 +25,7 @@
25
25
  * line, or the proxy's stdin under RC). It is never logged, printed, or passed as
26
26
  * an argv value.
27
27
  */
28
+ import { type ExperimentalId } from "../lib/experimental.js";
28
29
  import { type ContextProfile, type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
29
30
  export interface AgentOptions {
30
31
  goal?: string;
@@ -87,6 +88,12 @@ export interface AgentOptions {
87
88
  * Undefined = the full default order.
88
89
  */
89
90
  keySource?: "env" | "store" | "prompt";
91
+ /**
92
+ * The experimental features on for this launch (`mnemom experimental`), already gated
93
+ * upstream in index.ts. Announced on stderr, recorded in the ledger, and sent in the
94
+ * session-config header's `experimental` array. Absent/empty ⇒ none of the three.
95
+ */
96
+ experimental?: readonly ExperimentalId[];
90
97
  passthrough?: string[];
91
98
  /** Print the resolved launch plan and exit — no key read, no spawn. */
92
99
  dryRun?: boolean;
@@ -245,4 +252,6 @@ export declare function printPlan(ctx: {
245
252
  contextProfile: ContextProfile;
246
253
  /** The decoded `x-mnemom-session-config` overlay (`buildSessionConfig`); empty ⇒ no header. */
247
254
  sessionConfig: Record<string, string>;
255
+ /** The experimental features on for this launch (the header's `experimental` array). */
256
+ experimental?: readonly string[];
248
257
  }): void;
@@ -36,6 +36,7 @@ import { getAgentByName } from "../lib/api.js";
36
36
  import { LEDGER_FILE, appendSessionRecord, resolveRetentionDays, toLedgerContract, } from "../lib/agent-ledger.js";
37
37
  import { loadCliConfig } from "../lib/cli-config.js";
38
38
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
39
+ import { experimentalNotice } from "../lib/experimental.js";
39
40
  import { fmt } from "../lib/format.js";
40
41
  import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
41
42
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
@@ -60,9 +61,9 @@ function sayContextProfile(profile) {
60
61
  }
61
62
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
62
63
  export async function agentCommand(scenarioArg, options = {}) {
63
- // ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
64
- // goal alignment is implicit by default (inferred from your messages); --goal
65
- // seals a contract and --no-implicit turns it off — all resolved upstream.
64
+ // ── session name + goal ── (a bare `mnemom agent` in a terminal asks for the name;
65
+ // goal alignment is resolved upstream: --goal seals a contract, implicit alignment
66
+ // is on by default only while the `implicit` experimental feature is on.
66
67
  const session = await resolveSessionSetup({
67
68
  scenario: scenarioArg,
68
69
  goal: options.goal,
@@ -121,8 +122,14 @@ export async function agentCommand(scenarioArg, options = {}) {
121
122
  env,
122
123
  });
123
124
  const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
125
+ const experimental = options.experimental ?? [];
124
126
  const sessionConfig = buildSessionConfig({ profile: contextProfile, contextHandoff });
125
- const sessionConfigB64 = buildSessionConfigB64({ profile: contextProfile, contextHandoff });
127
+ const sessionConfigB64 = buildSessionConfigB64({
128
+ profile: contextProfile,
129
+ contextHandoff,
130
+ experimental,
131
+ });
132
+ const expNotice = experimentalNotice(experimental);
126
133
  // ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
127
134
  // found we don't crash with a technical error — we ask for the path at launch.
128
135
  let cli;
@@ -163,7 +170,10 @@ export async function agentCommand(scenarioArg, options = {}) {
163
170
  cli: planCli,
164
171
  contextProfile,
165
172
  sessionConfig,
173
+ experimental,
166
174
  });
175
+ if (expNotice)
176
+ say(expNotice);
167
177
  if (previewShape === "remote-control") {
168
178
  say("mnemom agent: the plan shows Remote Control because this machine can run it; the real launch uses it only if your account is entitled (internal Mnemom accounts), else terminal-only.");
169
179
  }
@@ -290,6 +300,8 @@ export async function agentCommand(scenarioArg, options = {}) {
290
300
  contextProfile,
291
301
  });
292
302
  logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
303
+ if (expNotice)
304
+ say(expNotice);
293
305
  say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
294
306
  sayContextProfile(contextProfile);
295
307
  // ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
@@ -396,6 +408,7 @@ export async function recordSessionLaunch(ctx) {
396
408
  goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
397
409
  context_profile: ctx.contextProfile,
398
410
  cc_session_id: ctx.ccSessionId ?? null,
411
+ ...(ctx.options.experimental?.length ? { experimental: [...ctx.options.experimental] } : {}),
399
412
  }, {
400
413
  dir: MNEMOM_DIR,
401
414
  now: () => new Date(),
@@ -1110,6 +1123,8 @@ export function printPlan(ctx) {
1110
1123
  for (const [k, v] of overlay)
1111
1124
  L.push(` ${k}=${v}`);
1112
1125
  }
1126
+ if (ctx.experimental?.length)
1127
+ L.push(` experimental: ${ctx.experimental.join(", ")}`);
1113
1128
  if (ctx.envelope) {
1114
1129
  L.push(` contract: ${ctx.envelope.canonical}`);
1115
1130
  L.push(` contract sha: ${ctx.envelope.sha}`);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `mnemom experimental list [--json] | enable <id> | disable <id>` — turn experimental
3
+ * features on and off (registry: lib/experimental.ts; stored in ~/.mnemom/config.json).
4
+ */
5
+ import { type ExperimentalId, type ExperimentalPrefs } from "../lib/experimental.js";
6
+ export interface ExperimentalDeps {
7
+ stored(): ExperimentalPrefs | undefined;
8
+ persist(ids: ExperimentalId[]): void;
9
+ env: Record<string, string | undefined>;
10
+ log(line: string): void;
11
+ }
12
+ export declare function experimentalListCommand(opts?: {
13
+ json?: boolean;
14
+ }, deps?: ExperimentalDeps): void;
15
+ export declare function experimentalEnableCommand(id: string | undefined, deps?: ExperimentalDeps): void;
16
+ export declare function experimentalDisableCommand(id: string | undefined, deps?: ExperimentalDeps): void;
17
+ /** The one-time migration (lib/experimental.ts migrateExperimental) against the real files. */
18
+ export declare function runExperimentalMigration(): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `mnemom experimental list [--json] | enable <id> | disable <id>` — turn experimental
3
+ * features on and off (registry: lib/experimental.ts; stored in ~/.mnemom/config.json).
4
+ */
5
+ import { EXPERIMENTAL_CONTRACT, EXPERIMENTAL_ENV, EXPERIMENTAL_FEATURES, EXPERIMENTAL_IDS, isExperimentalId, migrateExperimental, resolveExperimental, } from "../lib/experimental.js";
6
+ import { loadCliConfig, setExperimentalEnabled } from "../lib/cli-config.js";
7
+ import { launcherEntries, loadLauncherConfig } from "../lib/launcher-config.js";
8
+ function defaultDeps() {
9
+ return {
10
+ stored: () => loadCliConfig().experimental,
11
+ persist: (ids) => setExperimentalEnabled(ids),
12
+ env: process.env,
13
+ log: (line) => console.log(line),
14
+ };
15
+ }
16
+ function requireId(id, verb) {
17
+ if (!id)
18
+ throw new Error(`mnemom experimental ${verb}: an <id> is required.`);
19
+ const v = id.trim().toLowerCase();
20
+ if (!isExperimentalId(v)) {
21
+ throw new Error(`Unknown experimental feature '${id}'. Valid ids: ${EXPERIMENTAL_IDS.join(", ")}.`);
22
+ }
23
+ return v;
24
+ }
25
+ export function experimentalListCommand(opts = {}, deps = defaultDeps()) {
26
+ const state = resolveExperimental({ stored: deps.stored(), env: deps.env });
27
+ const rows = EXPERIMENTAL_FEATURES.map((f) => ({
28
+ id: f.id,
29
+ enabled: state.enabled.includes(f.id),
30
+ summary: f.summary,
31
+ since: f.since,
32
+ gates: f.gates,
33
+ }));
34
+ if (opts.json) {
35
+ deps.log(JSON.stringify({ contract: EXPERIMENTAL_CONTRACT, source: state.source, features: rows }, null, 2));
36
+ return;
37
+ }
38
+ deps.log(EXPERIMENTAL_CONTRACT);
39
+ deps.log("");
40
+ const width = Math.max(...rows.map((r) => r.id.length));
41
+ for (const r of rows) {
42
+ deps.log(`${r.enabled ? "on " : "off"} ${r.id.padEnd(width)} ${r.summary}`);
43
+ }
44
+ deps.log("");
45
+ if (state.source === "env") {
46
+ deps.log(`(set by ${EXPERIMENTAL_ENV} in your environment — it overrides config.json)`);
47
+ }
48
+ deps.log("Turn one on with `mnemom experimental enable <id>`.");
49
+ }
50
+ function toggle(verb, rawId, deps) {
51
+ const id = requireId(rawId, verb);
52
+ const current = new Set(resolveExperimental({ stored: deps.stored(), env: {} }).enabled);
53
+ if (verb === "enable")
54
+ current.add(id);
55
+ else
56
+ current.delete(id);
57
+ const next = EXPERIMENTAL_IDS.filter((x) => current.has(x));
58
+ deps.persist(next);
59
+ deps.log(verb === "enable" ? `${id}: on. ${EXPERIMENTAL_CONTRACT}` : `${id}: off.`);
60
+ if ((deps.env[EXPERIMENTAL_ENV] ?? "").trim()) {
61
+ deps.log(`note: ${EXPERIMENTAL_ENV} is set in your environment and overrides this.`);
62
+ }
63
+ }
64
+ export function experimentalEnableCommand(id, deps = defaultDeps()) {
65
+ toggle("enable", id, deps);
66
+ }
67
+ export function experimentalDisableCommand(id, deps = defaultDeps()) {
68
+ toggle("disable", id, deps);
69
+ }
70
+ /** The one-time migration (lib/experimental.ts migrateExperimental) against the real files. */
71
+ export function runExperimentalMigration() {
72
+ migrateExperimental({
73
+ stored: () => loadCliConfig().experimental,
74
+ launcherEntries: () => launcherEntries(loadLauncherConfig()),
75
+ persist: (ids) => setExperimentalEnabled(ids),
76
+ writeErr: (line) => void process.stderr.write(line),
77
+ });
78
+ }
package/dist/index.d.ts CHANGED
@@ -7,4 +7,4 @@ export { program };
7
7
  * unknown — so it bypasses commander entirely and reaches the sub-verb parser
8
8
  * verbatim. Exported (not auto-run) so the bypass is unit-testable.
9
9
  */
10
- export declare function runEntrypoint(argv?: string[]): void;
10
+ export declare function runEntrypoint(argv?: string[], migrate?: () => void): void;
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";
@@ -103,6 +106,19 @@ program
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,8 +135,8 @@ 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
142
  .option("--gateway <url>", "Gateway host override (default: https://gateway.mnemom.ai; or MNEMOM_AGENT_GATEWAY)")
@@ -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) {
@@ -324,6 +324,20 @@ export declare function buildSessionConfig(opts: {
324
324
  profile: ContextProfile;
325
325
  contextHandoff: boolean;
326
326
  }): Record<string, string>;
327
+ /**
328
+ * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
329
+ * experimental ids ride as a top-level array, beside (not inside) the env overlay, so the
330
+ * gateway's allow-list never sees them as env keys; it ignores unknown envelope fields.
331
+ */
332
+ export declare function buildSessionConfigPayload(opts: {
333
+ profile: ContextProfile;
334
+ contextHandoff: boolean;
335
+ experimental?: readonly string[];
336
+ }): {
337
+ v: 1;
338
+ config: Record<string, string>;
339
+ experimental?: string[];
340
+ } | undefined;
327
341
  /**
328
342
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
329
343
  * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
@@ -339,6 +353,7 @@ export declare function buildSessionConfig(opts: {
339
353
  export declare function buildSessionConfigB64(opts: {
340
354
  profile: ContextProfile;
341
355
  contextHandoff: boolean;
356
+ experimental?: readonly string[];
342
357
  }): string | undefined;
343
358
  /**
344
359
  * Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
@@ -427,10 +442,10 @@ export type GoalModeDefault = "implicit" | "off";
427
442
  * standing config default must never silently override;
428
443
  * 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
429
444
  * 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"`.
445
+ * 4. with the `implicit` experimental feature ON, IMPLICIT: the gateway infers
446
+ * the goal from the user's own messages (turn it off for a launch with
447
+ * `--no-implicit`, or standing with `goal_mode = "off"`); with it OFF (the
448
+ * default), OFF.
434
449
  * Pure so every arm is unit-tested; the caller passes the resolved value on to
435
450
  * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
436
451
  * combined with a contract flag — that conflict is EXPLICIT and is reported).
@@ -439,4 +454,10 @@ export declare function resolveImplicit(opts: {
439
454
  goal?: string;
440
455
  implicit?: boolean;
441
456
  configGoalMode?: GoalModeDefault;
457
+ /**
458
+ * Is the `implicit` experimental feature on? Off ⇒ the default is OFF and
459
+ * `goal_mode = implicit` is ignored (`--implicit` is refused upstream, before
460
+ * this runs). Omitted ⇒ on.
461
+ */
462
+ experimentalOn?: boolean;
442
463
  }): boolean;
package/dist/lib/agent.js CHANGED
@@ -513,6 +513,20 @@ export function buildSessionConfig(opts) {
513
513
  config[CONTEXT_HANDOFF_KEY] = "true";
514
514
  return config;
515
515
  }
516
+ /**
517
+ * The `x-mnemom-session-config` payload: `{ v: 1, config, experimental? }`. The active
518
+ * experimental ids ride as a top-level array, beside (not inside) the env overlay, so the
519
+ * gateway's allow-list never sees them as env keys; it ignores unknown envelope fields.
520
+ */
521
+ export function buildSessionConfigPayload(opts) {
522
+ const config = buildSessionConfig(opts);
523
+ const experimental = opts.experimental ?? [];
524
+ if (Object.keys(config).length === 0 && experimental.length === 0)
525
+ return undefined;
526
+ return experimental.length > 0
527
+ ? { v: 1, config, experimental: [...experimental] }
528
+ : { v: 1, config };
529
+ }
516
530
  /**
517
531
  * The `x-mnemom-session-config` value for this launch, or `undefined` when there is
518
532
  * nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
@@ -526,11 +540,10 @@ export function buildSessionConfig(opts) {
526
540
  * base64url so it survives the gateway's inbound strip.
527
541
  */
528
542
  export function buildSessionConfigB64(opts) {
529
- const config = buildSessionConfig(opts);
530
- if (Object.keys(config).length === 0)
543
+ const payload = buildSessionConfigPayload(opts);
544
+ if (!payload)
531
545
  return undefined;
532
- const payload = JSON.stringify({ v: 1, config });
533
- return Buffer.from(payload, "utf8").toString("base64url");
546
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
534
547
  }
535
548
  /**
536
549
  * Is the `handoff` MCP tool + `CONTEXT_HANDOFF_ENABLED` session-config header ON for
@@ -587,10 +600,10 @@ export function buildHandoffMcpConfigJson(p) {
587
600
  * standing config default must never silently override;
588
601
  * 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
589
602
  * 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"`.
603
+ * 4. with the `implicit` experimental feature ON, IMPLICIT: the gateway infers
604
+ * the goal from the user's own messages (turn it off for a launch with
605
+ * `--no-implicit`, or standing with `goal_mode = "off"`); with it OFF (the
606
+ * default), OFF.
594
607
  * Pure so every arm is unit-tested; the caller passes the resolved value on to
595
608
  * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
596
609
  * combined with a contract flag — that conflict is EXPLICIT and is reported).
@@ -600,5 +613,7 @@ export function resolveImplicit(opts) {
600
613
  return opts.implicit;
601
614
  if (hasText(opts.goal))
602
615
  return false;
616
+ if (opts.experimentalOn === false)
617
+ return false;
603
618
  return opts.configGoalMode !== "off";
604
619
  }
@@ -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
+ };
@@ -0,0 +1,226 @@
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 const EXPERIMENTAL_FEATURES = [
16
+ {
17
+ id: "context-handoff",
18
+ summary: "Register the `handoff(note)` MCP tool and turn on the gateway's context-handoff capability for the session",
19
+ since: "0.17.3",
20
+ gates: {
21
+ flags: ["--context-handoff"],
22
+ configKeys: ["context_handoff"],
23
+ commands: [],
24
+ env: ["MNEMOM_AGENT_CONTEXT_HANDOFF"],
25
+ },
26
+ },
27
+ {
28
+ id: "slack",
29
+ summary: "Attach the Slack channel to a session: messages addressed to it arrive in it, and it can reply",
30
+ since: "0.17.3",
31
+ gates: {
32
+ flags: ["--slack"],
33
+ configKeys: ["slack_channel", "slack_listen", "slack_allow"],
34
+ commands: ["mnemom agent slack"],
35
+ env: [],
36
+ },
37
+ },
38
+ {
39
+ id: "implicit",
40
+ summary: "Implicit goal alignment: the gateway infers the goal from your messages and revises it as you steer",
41
+ since: "0.17.3",
42
+ gates: {
43
+ flags: ["--implicit"],
44
+ configKeys: ["goal_mode = implicit"],
45
+ commands: ["mnemom agent goal show|pin|reset"],
46
+ env: [],
47
+ },
48
+ },
49
+ ];
50
+ export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
51
+ export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
52
+ export const EXPERIMENTAL_CONTRACT = "Experimental features may change or be removed in any release, with no deprecation period.";
53
+ export function isExperimentalId(value) {
54
+ return EXPERIMENTAL_IDS.includes(value);
55
+ }
56
+ /** Registry order, deduplicated, unknown ids dropped. */
57
+ function normalize(ids) {
58
+ const set = new Set(ids);
59
+ return EXPERIMENTAL_IDS.filter((id) => set.has(id));
60
+ }
61
+ /**
62
+ * Which experimental features are on. Precedence: MNEMOM_EXPERIMENTAL (a comma
63
+ * list of ids, `all`, or `none`) replaces the stored set; else config.json
64
+ * `experimental.enabled`; else everything off.
65
+ */
66
+ export function resolveExperimental(opts) {
67
+ const raw = (opts.env[EXPERIMENTAL_ENV] ?? "").trim();
68
+ if (raw !== "") {
69
+ const tokens = raw
70
+ .split(",")
71
+ .map((t) => t.trim().toLowerCase())
72
+ .filter(Boolean);
73
+ if (tokens.includes("all"))
74
+ return { enabled: [...EXPERIMENTAL_IDS], source: "env", unknown: [] };
75
+ const listed = tokens.filter((t) => t !== "none");
76
+ return {
77
+ enabled: normalize(listed),
78
+ source: "env",
79
+ unknown: listed.filter((t) => !isExperimentalId(t)),
80
+ };
81
+ }
82
+ const stored = Array.isArray(opts.stored?.enabled) ? opts.stored.enabled.map(String) : undefined;
83
+ if (stored === undefined)
84
+ return { enabled: [], source: "default", unknown: [] };
85
+ return {
86
+ enabled: normalize(stored),
87
+ source: "config",
88
+ unknown: stored.filter((t) => !isExperimentalId(t)),
89
+ };
90
+ }
91
+ /** The one-line stderr notice for a launch with experimental features on; null when none are. */
92
+ export function experimentalNotice(enabled) {
93
+ if (enabled.length === 0)
94
+ return null;
95
+ return `mnemom agent: experimental: ${enabled.join(", ")} — may change or break without notice`;
96
+ }
97
+ /** The error for using an experimental flag or command while its feature is off. */
98
+ export function gatedMessage(what, id) {
99
+ return `${what} is experimental. Turn it on with \`mnemom experimental enable ${id}\`.`;
100
+ }
101
+ /** Thrown when a gated flag or command is used while its feature is off; the CLI exits 2. */
102
+ export class ExperimentalGateError extends Error {
103
+ exitCode = 2;
104
+ constructor(what, id) {
105
+ super(gatedMessage(what, id));
106
+ this.name = "ExperimentalGateError";
107
+ }
108
+ }
109
+ /** Throw for the first use whose feature is off. */
110
+ export function assertEnabled(uses, enabled) {
111
+ for (const u of uses) {
112
+ if (u.used && !enabled.includes(u.id))
113
+ throw new ExperimentalGateError(u.what, u.id);
114
+ }
115
+ }
116
+ /**
117
+ * The feature a launcher.toml key/value belongs to, when setting it only has an
118
+ * effect with that feature on. `goal_mode = off`, `context_handoff = false` and
119
+ * `slack_listen = false` are harmless either way.
120
+ */
121
+ export function experimentalIdForConfigKey(key, value) {
122
+ switch (key) {
123
+ case "slack_channel":
124
+ case "slack_allow":
125
+ return "slack";
126
+ case "slack_listen":
127
+ return value === "true" ? "slack" : undefined;
128
+ case "context_handoff":
129
+ return value === "true" ? "context-handoff" : undefined;
130
+ case "goal_mode":
131
+ return value === "implicit" ? "implicit" : undefined;
132
+ default:
133
+ return undefined;
134
+ }
135
+ }
136
+ /** Is an env value one of the truthy spellings resolveContextHandoff accepts? */
137
+ function truthy(raw) {
138
+ return ["1", "true", "on", "yes"].includes((raw ?? "").trim().toLowerCase());
139
+ }
140
+ /**
141
+ * The stderr lines for experimental settings a launch ignores because their feature
142
+ * is off: launcher.toml keys (`entries` as `[key, value]`) and MNEMOM_AGENT_CONTEXT_HANDOFF.
143
+ */
144
+ export function ignoredSettingLines(opts) {
145
+ const lines = [];
146
+ for (const [key, value] of opts.entries) {
147
+ const id = experimentalIdForConfigKey(key, value);
148
+ if (id && !opts.enabled.includes(id)) {
149
+ lines.push(`mnemom agent: ignoring launcher.toml ${gatedMessage(key, id)}`);
150
+ }
151
+ }
152
+ if (truthy(opts.env.MNEMOM_AGENT_CONTEXT_HANDOFF) && !opts.enabled.includes("context-handoff")) {
153
+ lines.push(`mnemom agent: ignoring ${gatedMessage("MNEMOM_AGENT_CONTEXT_HANDOFF", "context-handoff")}`);
154
+ }
155
+ return lines;
156
+ }
157
+ /** The features an existing launcher.toml already uses (the one-time migration). */
158
+ export function featuresInUse(entries) {
159
+ return normalize(entries.flatMap(([k, v]) => {
160
+ const id = experimentalIdForConfigKey(k, v);
161
+ return id ? [id] : [];
162
+ }));
163
+ }
164
+ /** One-time notice for users who had implicit goal alignment by default (no goal_mode set). */
165
+ export const IMPLICIT_NOW_OFF_NOTICE = "mnemom: implicit goal alignment is now experimental and OFF by default (it used to be on). " +
166
+ "Turn it back on with `mnemom experimental enable implicit`, or seal a goal per launch with --goal.\n";
167
+ /**
168
+ * First run after the upgrade that introduced experimental features: config.json has
169
+ * no `experimental` section yet, so turn on whatever the user's launcher.toml already
170
+ * uses, persist (an empty list too — the section's presence marks the migration
171
+ * done), and say once which were kept on. A malformed launcher.toml skips the
172
+ * migration this run (the launch reports that file itself) so it retries next time.
173
+ * Never throws.
174
+ */
175
+ export function migrateExperimental(deps) {
176
+ try {
177
+ if (deps.stored() !== undefined)
178
+ return null;
179
+ const entries = deps.launcherEntries();
180
+ const ids = featuresInUse(entries);
181
+ deps.persist(ids);
182
+ if (ids.length > 0) {
183
+ deps.writeErr(`mnemom: ${ids.join(", ")} ${ids.length === 1 ? "is" : "are"} now experimental and kept on because your launcher.toml uses ${ids.length === 1 ? "it" : "them"} — see \`mnemom experimental list\`.\n`);
184
+ }
185
+ // Implicit was the unconfigured DEFAULT, so most users relied on it without a
186
+ // goal_mode entry. Don't let it switch off silently for them: say so once.
187
+ const optedOut = entries.some(([k, v]) => k === "goal_mode" && v === "off");
188
+ if (!ids.includes("implicit") && !optedOut)
189
+ deps.writeErr(IMPLICIT_NOW_OFF_NOTICE);
190
+ return ids;
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ /** The feature a launcher.toml key belongs to at all (for marking `mnemom agent config list`). */
197
+ export function configKeyFeature(key) {
198
+ if (key === "slack_channel" || key === "slack_listen" || key === "slack_allow")
199
+ return "slack";
200
+ if (key === "context_handoff")
201
+ return "context-handoff";
202
+ if (key === "goal_mode")
203
+ return "implicit";
204
+ return undefined;
205
+ }
206
+ /**
207
+ * ` (experimental, off)` for a launcher.toml key whose feature is off, else "". With a
208
+ * `value`, only a setting that would take effect is marked (`goal_mode = off` is not).
209
+ */
210
+ export function configKeyMark(key, value, enabled) {
211
+ const id = value === undefined ? configKeyFeature(key) : experimentalIdForConfigKey(key, value);
212
+ return id && !enabled.includes(id) ? " (experimental, off)" : "";
213
+ }
214
+ /** `null` when `id` is on, else the gated-use message. */
215
+ export function gateCheck(what, id, enabled) {
216
+ return enabled.includes(id) ? null : gatedMessage(what, id);
217
+ }
218
+ /**
219
+ * How a command's catch reports an error: a gated use prints its guidance bare and
220
+ * exits 2; anything else keeps the CLI's `Error: <message>` and exit 1.
221
+ */
222
+ export function reportCliError(error) {
223
+ if (error instanceof ExperimentalGateError)
224
+ return { args: [error.message], code: 2 };
225
+ return { args: ["Error:", error instanceof Error ? error.message : error], code: 1 };
226
+ }
@@ -15,6 +15,7 @@
15
15
  * resolvers in lib/code.ts keep the built-in default as the final fallback);
16
16
  * the caller folds the plain-value settings under the CLI flags.
17
17
  */
18
+ import type { ExperimentalId } from "./experimental.js";
18
19
  import { type ContextProfile, type GoalModeDefault, type LaunchShape } from "./agent.js";
19
20
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
20
21
  export declare const LAUNCHER_CONFIG_PATH: string;
@@ -98,3 +99,11 @@ export declare function writeLauncherConfig(cfg: LauncherConfig, path?: string):
98
99
  * (see resolveLaunchShape): terminal→"0", remote-control→"1", server→"server".
99
100
  */
100
101
  export declare function applyConfigToEnv(cfg: LauncherConfig, env?: NodeJS.ProcessEnv): void;
102
+ /** The set keys of `cfg` as `[key, value]` pairs (CONFIG_KEYS order, values stringified). */
103
+ export declare function launcherEntries(cfg: LauncherConfig): Array<[string, string]>;
104
+ /**
105
+ * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
106
+ * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
107
+ * off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
108
+ */
109
+ export declare function withoutDisabledExperimental(cfg: LauncherConfig, enabled: readonly ExperimentalId[]): LauncherConfig;
@@ -402,3 +402,28 @@ export function applyConfigToEnv(cfg, env = process.env) {
402
402
  env.MNEMOM_AGENT_CONTEXT_PROFILE = cfg.contextProfile;
403
403
  }
404
404
  }
405
+ /** The set keys of `cfg` as `[key, value]` pairs (CONFIG_KEYS order, values stringified). */
406
+ export function launcherEntries(cfg) {
407
+ return CONFIG_KEYS.flatMap((spec) => {
408
+ const v = spec.get(cfg);
409
+ return v === undefined ? [] : [[spec.key, String(v)]];
410
+ });
411
+ }
412
+ /**
413
+ * `cfg` without the settings of experimental features that are off (a copy; `cfg` is
414
+ * untouched): `slack_*` while `slack` is off, `context_handoff` while `context-handoff` is
415
+ * off, and `goal_mode = implicit` while `implicit` is off (`goal_mode = off` is kept).
416
+ */
417
+ export function withoutDisabledExperimental(cfg, enabled) {
418
+ const out = { ...cfg };
419
+ if (!enabled.includes("slack")) {
420
+ delete out.slackChannel;
421
+ delete out.slackListen;
422
+ delete out.slackAllow;
423
+ }
424
+ if (!enabled.includes("context-handoff"))
425
+ delete out.contextHandoff;
426
+ if (!enabled.includes("implicit") && out.goalMode === "implicit")
427
+ delete out.goalMode;
428
+ return out;
429
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.3-next.5",
3
+ "version": "0.17.3-next.6",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {