@mnemom/mnemom 0.17.0-next.7 → 0.17.0-next.8

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/README.md CHANGED
@@ -44,6 +44,32 @@ That's it. `mnemom init` detects your configured AI provider API keys (Anthropic
44
44
  | `mnemom card publish <file>` | Publish alignment card from JSON file |
45
45
  | `mnemom card validate <file>` | Validate card JSON locally |
46
46
  | `mnemom usage --org <id>` | Show per-person token and request consumption |
47
+ | `mnemom agent [name]` | Launch a governed coding agent through the gateway |
48
+
49
+ ### `mnemom agent` — goal alignment
50
+
51
+ `mnemom agent` launches your coding-agent CLI (Claude Code today) with its model traffic
52
+ routed through the Mnemom gateway under a governed identity. Goal alignment — the judge
53
+ nudging the agent back when it drifts from your goal — comes in three modes; a bare
54
+ `mnemom agent` in a terminal asks which one you want (Enter = off, for now):
55
+
56
+ | Mode | How | What is sent |
57
+ | ------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
58
+ | **Implicit** | `--implicit` — the gateway infers the goal from your own messages and revises it as you steer; nothing to type | `x-mnemom-goal-mode: implicit`, no contract |
59
+ | **Sealed** | `--goal "<statement>"` (+ `--requirement/--allow/--forbid/--goal-id`) — sealed as a contract on the first turn | `x-mnemom-contract` |
60
+ | **Off** | neither | neither header |
61
+
62
+ `--implicit` cannot be combined with the sealed-contract flags: a sealed contract always
63
+ wins on the gateway and would silently switch implicit mode off, so the launch refuses the
64
+ mix. The guardrail ceilings (`--budget`, `--max-turns`, `--stall`, or `guardrails.*` in
65
+ `~/.mnemom/launcher.toml`) apply in both modes — in implicit mode they ride on the mode
66
+ header. In implicit mode the goal lags your steering by one request: a response to a new
67
+ message carries `x-mnemom-goal-steer: queued` and the revision the agent acted under;
68
+ the next request reads the new revision (`x-mnemom-goal-revision`).
69
+
70
+ Make one mode the standing default with `mnemom agent config set goal_mode implicit`
71
+ (or `off`); `--goal` still seals for that launch and `--no-implicit` turns it off for one
72
+ launch. A sealed contract needs a per-launch statement, so it cannot be the default.
47
73
 
48
74
  ## How It Works
49
75
 
@@ -15,3 +15,15 @@
15
15
  */
16
16
  /** Entry point for `mnemom agent config <verb> [args...]`. Throws on misuse. */
17
17
  export declare function agentConfigCommand(argv: string[]): Promise<void>;
18
+ /**
19
+ * `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
20
+ * never shell syntax: the value is split on whitespace and every token must be a bare
21
+ * program name, path, or flag. Anything else (quotes, `;`, `$(`, backticks, redirections, …) is
22
+ * refused with a remediation rather than handed to spawnSync — the editor is launched WITHOUT a
23
+ * shell, so such a value could only ever fail confusingly or, via a lookalike binary on PATH, run
24
+ * something the user did not mean. Exported for the tests.
25
+ */
26
+ export declare function parseEditorSpec(spec: string): {
27
+ bin: string;
28
+ args: string[];
29
+ };
@@ -98,8 +98,8 @@ function editConfig() {
98
98
  // Seed a header-only file so $EDITOR opens something with guidance, not a blank.
99
99
  if (!existsSync(LAUNCHER_CONFIG_PATH))
100
100
  writeLauncherConfig(loadLauncherConfig());
101
- const editor = process.env.VISUAL || process.env.EDITOR || fallbackEditor();
102
- const res = spawnSync(editor, [LAUNCHER_CONFIG_PATH], { stdio: "inherit" });
101
+ const { bin: editor, args: editorArgs } = parseEditorSpec(process.env.VISUAL || process.env.EDITOR || fallbackEditor());
102
+ const res = spawnSync(editor, [...editorArgs, LAUNCHER_CONFIG_PATH], { stdio: "inherit" });
103
103
  if (res.error) {
104
104
  throw new Error(`mnemom agent config edit: could not launch editor '${editor}': ${res.error.message}\n` +
105
105
  ` Set $EDITOR, or edit ${LAUNCHER_CONFIG_PATH} directly.`);
@@ -108,6 +108,23 @@ function editConfig() {
108
108
  loadLauncherConfig();
109
109
  out(`Saved ${LAUNCHER_CONFIG_PATH}.`);
110
110
  }
111
+ /**
112
+ * `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
113
+ * never shell syntax: the value is split on whitespace and every token must be a bare
114
+ * program name, path, or flag. Anything else (quotes, `;`, `$(`, backticks, redirections, …) is
115
+ * refused with a remediation rather than handed to spawnSync — the editor is launched WITHOUT a
116
+ * shell, so such a value could only ever fail confusingly or, via a lookalike binary on PATH, run
117
+ * something the user did not mean. Exported for the tests.
118
+ */
119
+ export function parseEditorSpec(spec) {
120
+ const tokens = spec.trim().split(/\s+/).filter(Boolean);
121
+ const SAFE = /^[A-Za-z0-9_./+=:@,-]+$/;
122
+ if (tokens.length === 0 || !tokens.every((t) => SAFE.test(t))) {
123
+ throw new Error(`mnemom agent config edit: refusing to run $VISUAL/$EDITOR value '${spec}' — only a program name or path plus plain flags is accepted (no shell syntax).\n` +
124
+ ` Set $EDITOR to e.g. 'code --wait', or edit ${LAUNCHER_CONFIG_PATH} directly.`);
125
+ }
126
+ return { bin: tokens[0], args: tokens.slice(1) };
127
+ }
111
128
  function fallbackEditor() {
112
129
  for (const cand of ["nano", "vim", "vi"]) {
113
130
  const r = spawnSync("sh", ["-c", `command -v "$1" >/dev/null 2>&1`, "sh", cand], {
@@ -158,6 +175,9 @@ function deleteKey(cfg, key) {
158
175
  case "effort":
159
176
  delete cfg.effort;
160
177
  break;
178
+ case "goal_mode":
179
+ delete cfg.goalMode;
180
+ break;
161
181
  case "cli":
162
182
  delete cfg.cli;
163
183
  break;
@@ -7,25 +7,35 @@
7
7
  *
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
- * 2. an optional GOAL — one line. With a goal, Mnemom seals a goal contract
11
- * for the session and the judge nudges the agent back when it drifts (a
12
- * little µ per turn). Without one, the session runs governed but with goal
13
- * alignment OFF: no contract header is sent, so the gateway's goal hook is
14
- * simply not engaged for the conversation.
10
+ * 2. GOAL ALIGNMENT — one of three:
11
+ * - INFER it from your messages (implicit mode): nothing to type; the
12
+ * gateway reads your own messages, revises the goal as you steer, and the
13
+ * judge nudges the agent back when it drifts (a little µ per new message
14
+ * and per judged turn). Sends `x-mnemom-goal-mode: implicit`, no contract.
15
+ * - TYPE a one-line goal: sealed as a contract for the session; the judge
16
+ * nudges against it (a little µ per turn). Sends `x-mnemom-contract`.
17
+ * - OFF: the session runs governed with goal alignment off — neither header
18
+ * is sent, so the gateway's goal hooks are simply not engaged.
15
19
  *
16
20
  * Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
17
- * for a name. `--goal` on the command line always wins over the prompt. Pure
18
- * apart from the injected `ask` functions, so every arm is unit-tested.
21
+ * for a name. `--goal` on the command line always wins over the prompt, and a
22
+ * resolved implicit launch (`--implicit`, or launcher.toml `goal_mode =
23
+ * "implicit"` with no `--no-implicit`) skips it too. Pure apart from the
24
+ * injected `ask` functions, so every arm is unit-tested.
19
25
  */
20
26
  export interface SessionAsk {
21
27
  askInput(question: string): Promise<string>;
22
28
  askYesNo(question: string, defaultYes: boolean): Promise<boolean>;
29
+ /** Numbered single-select; resolves the chosen label, or null on no/invalid choice. */
30
+ askSelect(question: string, options: string[]): Promise<string | null>;
23
31
  }
24
32
  export interface SessionSetupInput {
25
33
  /** The positional `[scenario]` (session name), if given. */
26
34
  scenario?: string;
27
35
  /** `--goal <statement>`, if given — skips the goal prompt. */
28
36
  goal?: string;
37
+ /** Resolved implicit mode (flag or config default) — skips the goal prompt; alignment on without a contract. */
38
+ implicit?: boolean;
29
39
  interactive: boolean;
30
40
  /** For the default session name (tests inject one). */
31
41
  cwd?: string;
@@ -34,17 +44,23 @@ export interface SessionSetupInput {
34
44
  }
35
45
  export interface SessionSetup {
36
46
  scenario: string;
37
- /** The goal statement to seal, or undefined = goal alignment off. */
47
+ /** The goal statement to seal, or undefined = no sealed contract (implicit or off). */
38
48
  goal?: string;
39
- goalAlignment: "on" | "off";
49
+ goalAlignment: "on" | "implicit" | "off";
40
50
  /** True when the terminal prompts ran. */
41
51
  prompted: boolean;
42
52
  }
43
53
  export declare const NO_SESSION_NAME_MESSAGE: string;
54
+ /** The three goal-alignment choices offered by the interactive prompt (labels are the select options). */
55
+ export declare const GOAL_CHOICE_IMPLICIT = "Infer my goal from my messages \u2014 nothing to type; revised as I steer (implicit mode)";
56
+ export declare const GOAL_CHOICE_TYPED = "Type a one-line goal \u2014 sealed as a contract for this session";
57
+ export declare const GOAL_CHOICE_OFF = "Off \u2014 run governed with goal alignment off (Enter)";
58
+ export declare const GOAL_CHOICES: readonly string[];
59
+ export declare const GOAL_CHOICE_QUESTION = "Goal alignment for this session?";
44
60
  /** Current folder name, sanitised like an agent slug; "session" when that is empty. */
45
61
  export declare function defaultSessionName(cwd?: string): string;
46
62
  /** The one-line status printed at launch so the customer always knows the mode. */
47
- export declare function goalAlignmentLine(on: boolean): string;
63
+ export declare function goalAlignmentLine(mode: boolean | "on" | "implicit" | "off"): string;
48
64
  /**
49
65
  * Resolve the session name + goal for this launch. See the module doc for the
50
66
  * rules. Throws NO_SESSION_NAME_MESSAGE when no name was given and we cannot ask.
@@ -7,29 +7,50 @@
7
7
  *
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
- * 2. an optional GOAL — one line. With a goal, Mnemom seals a goal contract
11
- * for the session and the judge nudges the agent back when it drifts (a
12
- * little µ per turn). Without one, the session runs governed but with goal
13
- * alignment OFF: no contract header is sent, so the gateway's goal hook is
14
- * simply not engaged for the conversation.
10
+ * 2. GOAL ALIGNMENT — one of three:
11
+ * - INFER it from your messages (implicit mode): nothing to type; the
12
+ * gateway reads your own messages, revises the goal as you steer, and the
13
+ * judge nudges the agent back when it drifts (a little µ per new message
14
+ * and per judged turn). Sends `x-mnemom-goal-mode: implicit`, no contract.
15
+ * - TYPE a one-line goal: sealed as a contract for the session; the judge
16
+ * nudges against it (a little µ per turn). Sends `x-mnemom-contract`.
17
+ * - OFF: the session runs governed with goal alignment off — neither header
18
+ * is sent, so the gateway's goal hooks are simply not engaged.
15
19
  *
16
20
  * Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
17
- * for a name. `--goal` on the command line always wins over the prompt. Pure
18
- * apart from the injected `ask` functions, so every arm is unit-tested.
21
+ * for a name. `--goal` on the command line always wins over the prompt, and a
22
+ * resolved implicit launch (`--implicit`, or launcher.toml `goal_mode =
23
+ * "implicit"` with no `--no-implicit`) skips it too. Pure apart from the
24
+ * injected `ask` functions, so every arm is unit-tested.
19
25
  */
20
26
  import { basename } from "node:path";
21
27
  import { sanitizeAgentName } from "../lib/agent.js";
22
28
  export const NO_SESSION_NAME_MESSAGE = "mnemom agent: a session name is required here (e.g. `mnemom agent onboarding`). " +
23
29
  "In a terminal, plain `mnemom agent` asks for one.";
30
+ /** The three goal-alignment choices offered by the interactive prompt (labels are the select options). */
31
+ export const GOAL_CHOICE_IMPLICIT = "Infer my goal from my messages — nothing to type; revised as I steer (implicit mode)";
32
+ export const GOAL_CHOICE_TYPED = "Type a one-line goal — sealed as a contract for this session";
33
+ export const GOAL_CHOICE_OFF = "Off — run governed with goal alignment off (Enter)";
34
+ export const GOAL_CHOICES = [
35
+ GOAL_CHOICE_IMPLICIT,
36
+ GOAL_CHOICE_TYPED,
37
+ GOAL_CHOICE_OFF,
38
+ ];
39
+ export const GOAL_CHOICE_QUESTION = "Goal alignment for this session?";
24
40
  /** Current folder name, sanitised like an agent slug; "session" when that is empty. */
25
41
  export function defaultSessionName(cwd = process.cwd()) {
26
42
  return sanitizeAgentName(basename(cwd)) || "session";
27
43
  }
28
44
  /** The one-line status printed at launch so the customer always knows the mode. */
29
- export function goalAlignmentLine(on) {
30
- return on
45
+ export function goalAlignmentLine(mode) {
46
+ const m = mode === true ? "on" : mode === false ? "off" : mode;
47
+ if (m === "implicit") {
48
+ return ("mnemom agent: goal alignment: on, implicit (no sealed contract — the gateway infers your goal " +
49
+ "from your own messages, revises it as you steer, and the judge nudges the agent back when it drifts)");
50
+ }
51
+ return m === "on"
31
52
  ? "mnemom agent: goal alignment: on (the judge nudges the agent back when it drifts from your goal)"
32
- : "mnemom agent: goal alignment: off (no goal set — pass --goal or set one when prompted)";
53
+ : "mnemom agent: goal alignment: off (no goal set — pass --goal or --implicit, or set one when prompted)";
33
54
  }
34
55
  const defaultSay = (line = "") => console.error(line);
35
56
  /**
@@ -39,12 +60,13 @@ const defaultSay = (line = "") => console.error(line);
39
60
  export async function resolveSessionSetup(input, ask) {
40
61
  const say = input.say ?? defaultSay;
41
62
  const flagGoal = (input.goal ?? "").trim();
63
+ const implicit = input.implicit === true;
42
64
  const given = (input.scenario ?? "").trim();
43
65
  if (given) {
44
66
  return {
45
67
  scenario: given,
46
68
  goal: flagGoal || undefined,
47
- goalAlignment: flagGoal ? "on" : "off",
69
+ goalAlignment: flagGoal ? "on" : implicit ? "implicit" : "off",
48
70
  prompted: false,
49
71
  };
50
72
  }
@@ -57,22 +79,36 @@ export async function resolveSessionSetup(input, ask) {
57
79
  say(` (Next time you can pass it directly: mnemom agent <name>.)`);
58
80
  const typed = await ask.askInput(`Session name [${fallback}]:`);
59
81
  const scenario = sanitizeAgentName(typed.trim()) || fallback;
60
- // ── 2. goal (skipped when --goal was given) ──
82
+ // ── 2. goal alignment (skipped when --goal or implicit mode was resolved) ──
61
83
  if (flagGoal) {
62
84
  return { scenario, goal: flagGoal, goalAlignment: "on", prompted: true };
63
85
  }
86
+ if (implicit) {
87
+ return { scenario, goalAlignment: "implicit", prompted: true };
88
+ }
64
89
  say();
65
- say("Goal alignment — an optional one-line goal for this session.");
66
- say(" With a goal, Mnemom's judge watches each turn and nudges the agent back when it drifts");
67
- say(" (a little µ per turn). Without one, the session runs governed with goal alignment off.");
68
- const wantsGoal = await ask.askYesNo("Set a goal for this session?", true);
69
- if (!wantsGoal) {
70
- return { scenario, goalAlignment: "off", prompted: true };
90
+ say("Goal alignment — how Mnemom keeps this session on track. Three choices:");
91
+ say(" Infer: nothing to type — the gateway reads your own messages, revises the goal as you");
92
+ say(" steer, and the judge nudges the agent back when it drifts (a little µ per message + turn).");
93
+ say(" Type a goal: one line, sealed as a contract; the judge nudges against it (a little µ per turn).");
94
+ say(" Off: the session runs governed with goal alignment off (the default — just press Enter).");
95
+ say(" (Make one the default: mnemom agent config set goal_mode implicit|off.)");
96
+ const choice = await ask.askSelect(GOAL_CHOICE_QUESTION, [...GOAL_CHOICES]);
97
+ if (choice === GOAL_CHOICE_IMPLICIT) {
98
+ return { scenario, goalAlignment: "implicit", prompted: true };
99
+ }
100
+ if (choice === GOAL_CHOICE_TYPED) {
101
+ const goal = (await ask.askInput("Goal (one line):")).trim();
102
+ if (!goal) {
103
+ say(" No goal entered — continuing with goal alignment off.");
104
+ return { scenario, goalAlignment: "off", prompted: true };
105
+ }
106
+ return { scenario, goal, goalAlignment: "on", prompted: true };
71
107
  }
72
- const goal = (await ask.askInput("Goal (one line):")).trim();
73
- if (!goal) {
74
- say(" No goal entered — continuing with goal alignment off.");
75
- return { scenario, goalAlignment: "off", prompted: true };
108
+ // Enter (no pick) and any invalid pick both resolve to null → off. Off is the
109
+ // deliberate default for now: a µ-costing mode is never picked by a bare Enter.
110
+ if (choice !== GOAL_CHOICE_OFF) {
111
+ say(" Goal alignment off (press 1 or 2 next time to turn it on).");
76
112
  }
77
- return { scenario, goal, goalAlignment: "on", prompted: true };
113
+ return { scenario, goalAlignment: "off", prompted: true };
78
114
  }
@@ -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 LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
28
29
  export interface AgentOptions {
29
30
  goal?: string;
30
31
  requirement?: string[];
@@ -34,6 +35,12 @@ export interface AgentOptions {
34
35
  budget?: string;
35
36
  maxTurns?: string;
36
37
  stall?: string;
38
+ /**
39
+ * `--implicit`: goal alignment with no sealed contract — the gateway infers the
40
+ * goal from the user's messages and revises it as they steer; the ceilings above
41
+ * ride on the `x-mnemom-goal-mode` header. Exclusive with the contract flags.
42
+ */
43
+ implicit?: boolean;
37
44
  /** x-mnemom-agent value (defaults to "code-agent"). */
38
45
  agent?: string;
39
46
  conversationId?: string;
@@ -131,3 +138,32 @@ export declare function buildCliArgs(options: AgentOptions, ctx: {
131
138
  yolo: boolean;
132
139
  allowCliFlags: boolean;
133
140
  }): string[];
141
+ /**
142
+ * Which goal-alignment mode the launch status line names: a sealed contract →
143
+ * "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
144
+ * A sealed contract and a mode header never coexist (`assembleContract` refuses
145
+ * the combination), so the first arm winning is not a precedence choice.
146
+ */
147
+ export declare function goalAlignmentMode(envelope: SealedContractEnvelope | null, goalMode: string | null): "on" | "implicit" | "off";
148
+ export declare function logLaunch(ctx: {
149
+ shape: LaunchShape;
150
+ door: string;
151
+ agent: string;
152
+ conversationId: string;
153
+ cellNote: string;
154
+ envelope: SealedContractEnvelope | null;
155
+ goalMode: string | null;
156
+ cli: ResolvedCli;
157
+ }): void;
158
+ export declare function printPlan(ctx: {
159
+ scenario: string;
160
+ shape: LaunchShape;
161
+ agent: string;
162
+ conversationId: string;
163
+ door: string;
164
+ cellNote: string;
165
+ envelope: SealedContractEnvelope | null;
166
+ goalMode: string | null;
167
+ yolo: boolean;
168
+ cli: ResolvedCli;
169
+ }): void;
@@ -33,18 +33,28 @@ import { fileURLToPath } from "node:url";
33
33
  import { MNEMOM_DIR } from "../lib/config.js";
34
34
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
35
35
  import { fmt } from "../lib/format.js";
36
- import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
36
+ import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
37
37
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
38
38
  import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, } from "./agent-setup.js";
39
- import { assembleContract, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/agent.js";
39
+ import { assembleContract, buildGoalModeHeader, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/agent.js";
40
40
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
41
41
  const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
42
42
  const say = (line = "") => console.error(line);
43
43
  /** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
44
44
  export async function agentCommand(scenarioArg, options = {}) {
45
45
  // ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
46
- // explaining goal alignment; no goal ⇒ no contract header ⇒ goal alignment off)
47
- const session = await resolveSessionSetup({ scenario: scenarioArg, goal: options.goal, interactive: isInteractive() }, { askInput: (q) => askInput(q), askYesNo: (q, d) => askYesNo(q, d) });
46
+ // explaining goal alignment: infer it from your messages (implicit), type a
47
+ // one-line goal (sealed), or off — no contract header and no mode header)
48
+ const session = await resolveSessionSetup({
49
+ scenario: scenarioArg,
50
+ goal: options.goal,
51
+ implicit: options.implicit,
52
+ interactive: isInteractive(),
53
+ }, {
54
+ askInput: (q) => askInput(q),
55
+ askYesNo: (q, d) => askYesNo(q, d),
56
+ askSelect: (q, o) => askSelect(q, o),
57
+ });
48
58
  const scenario = session.scenario;
49
59
  const env = process.env;
50
60
  let shape = resolveLaunchShape({ remoteControl: options.remoteControl, server: options.server }, env);
@@ -68,6 +78,15 @@ export async function agentCommand(scenarioArg, options = {}) {
68
78
  budget: options.budget,
69
79
  maxTurns: options.maxTurns,
70
80
  stall: options.stall,
81
+ implicit: options.implicit,
82
+ });
83
+ // ── implicit goal mode (no contract; the gateway infers the goal from the user's
84
+ // messages) — the guardrail ceilings ride on the mode header instead ──
85
+ const goalMode = buildGoalModeHeader({
86
+ implicit: options.implicit,
87
+ budget: options.budget,
88
+ maxTurns: options.maxTurns,
89
+ stall: options.stall,
71
90
  });
72
91
  // ── door ──
73
92
  const gatewayHost = resolveGatewayHost(env);
@@ -117,6 +136,7 @@ export async function agentCommand(scenarioArg, options = {}) {
117
136
  door,
118
137
  cellNote,
119
138
  envelope,
139
+ goalMode,
120
140
  yolo,
121
141
  cli: planCli,
122
142
  });
@@ -175,8 +195,8 @@ export async function agentCommand(scenarioArg, options = {}) {
175
195
  say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
176
196
  }
177
197
  }
178
- logLaunch({ shape, door, agent, conversationId, cellNote, envelope, cli });
179
- say(goalAlignmentLine(envelope !== null));
198
+ logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
199
+ say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
180
200
  if (shape === "terminal") {
181
201
  launchTerminal({
182
202
  cli,
@@ -185,6 +205,7 @@ export async function agentCommand(scenarioArg, options = {}) {
185
205
  agent,
186
206
  conversationId,
187
207
  envelope,
208
+ goalMode,
188
209
  options,
189
210
  yolo,
190
211
  contextHint,
@@ -200,6 +221,7 @@ export async function agentCommand(scenarioArg, options = {}) {
200
221
  conversationId,
201
222
  scenario,
202
223
  envelope,
224
+ goalMode,
203
225
  options,
204
226
  yolo,
205
227
  });
@@ -220,7 +242,7 @@ export async function resolveAnthropicKey(env, opts) {
220
242
  const source = opts.keySource;
221
243
  // "prompt" forces the interactive prompt (skips env + store).
222
244
  if (source !== "prompt") {
223
- const fromEnv = (env.MNEMOM_AGENT_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "").trim();
245
+ const fromEnv = stripCrlf(env.MNEMOM_AGENT_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "");
224
246
  if (fromEnv)
225
247
  return fromEnv;
226
248
  // "env" restricts to env only — no store, no prompt.
@@ -231,11 +253,17 @@ export async function resolveAnthropicKey(env, opts) {
231
253
  const stored = readStoredKey();
232
254
  if (stored)
233
255
  return stored;
256
+ // "store" restricts to env → store — never the interactive prompt.
257
+ if (source === "store") {
258
+ throw new Error("mnemom agent: key_source is 'store' but no Anthropic key is set in env or ~/.mnemom/launcher.json.\n" +
259
+ " Set one: export MNEMOM_AGENT_ANTHROPIC_KEY=sk-ant-... (or ANTHROPIC_API_KEY)\n" +
260
+ " Or store it: `mnemom agent config set key_source prompt`, run `mnemom agent` once and accept the save.");
261
+ }
234
262
  }
235
- // (source can only be "store", "prompt", or undefined here — "env" threw above.)
263
+ // (source can only be "prompt" or undefined here — "env" and "store" threw above.)
236
264
  if (opts.allowPrompt && isInteractive()) {
237
265
  say("mnemom agent: no Anthropic key in env (MNEMOM_AGENT_ANTHROPIC_KEY / ANTHROPIC_API_KEY) or ~/.mnemom/launcher.json.");
238
- const entered = (await askInput("Enter your Anthropic API key (sk-ant-…):", true)).trim();
266
+ const entered = stripCrlf(await askInput("Enter your Anthropic API key (sk-ant-…):", true));
239
267
  if (entered) {
240
268
  if (await askYesNo("Save it to ~/.mnemom/launcher.json (0600) for next time?", true)) {
241
269
  storeKey(entered);
@@ -265,7 +293,7 @@ export function anthropicKeySource(env = process.env) {
265
293
  function readStoredKey() {
266
294
  try {
267
295
  const data = JSON.parse(readFileSync(CODE_STORE, "utf8"));
268
- const key = typeof data.anthropic_key === "string" ? data.anthropic_key.trim() : "";
296
+ const key = typeof data.anthropic_key === "string" ? stripCrlf(data.anthropic_key) : "";
269
297
  return key || undefined;
270
298
  }
271
299
  catch {
@@ -442,6 +470,7 @@ function launchTerminal(ctx) {
442
470
  agent: ctx.agent,
443
471
  conversationId: ctx.conversationId,
444
472
  contractB64: ctx.envelope?.base64,
473
+ goalMode: ctx.goalMode ?? undefined,
445
474
  });
446
475
  const args = buildCliArgs(ctx.options, { yolo: ctx.yolo, allowCliFlags: true });
447
476
  // Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
@@ -489,6 +518,7 @@ function launchRemoteControl(ctx) {
489
518
  MNEMOM_AGENT_RC_AGENT: ctx.agent,
490
519
  MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
491
520
  MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
521
+ MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
492
522
  MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
493
523
  MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
494
524
  },
@@ -720,14 +750,30 @@ function seedContextHintFlag(childEnv) {
720
750
  // ───────────────────────────────────────────────────────────────────────────
721
751
  // Human-facing logging (never prints the key)
722
752
  // ───────────────────────────────────────────────────────────────────────────
723
- function logLaunch(ctx) {
753
+ /**
754
+ * Which goal-alignment mode the launch status line names: a sealed contract →
755
+ * "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
756
+ * A sealed contract and a mode header never coexist (`assembleContract` refuses
757
+ * the combination), so the first arm winning is not a precedence choice.
758
+ */
759
+ export function goalAlignmentMode(envelope, goalMode) {
760
+ if (envelope)
761
+ return "on";
762
+ if (goalMode)
763
+ return "implicit";
764
+ return "off";
765
+ }
766
+ export function logLaunch(ctx) {
724
767
  say(`mnemom agent: routing ${ctx.cli.name} through ${ctx.door} [${ctx.cellNote}] as '${ctx.agent}' (conversation ${ctx.conversationId}).`);
725
768
  if (ctx.envelope) {
726
769
  say(`mnemom agent: contract statement: ${ctx.envelope.contract.statement}`);
727
770
  say(`mnemom agent: contract sha256[0:16]: ${ctx.envelope.sha}`);
728
771
  }
772
+ else if (ctx.goalMode) {
773
+ say(`mnemom agent: goal mode: ${ctx.goalMode} (inferred from your messages; watch x-mnemom-goal-revision / x-mnemom-goal-steer on each response)`);
774
+ }
729
775
  }
730
- function printPlan(ctx) {
776
+ export function printPlan(ctx) {
731
777
  const L = [];
732
778
  L.push("mnemom agent — launch plan (dry run)");
733
779
  L.push(` scenario: ${ctx.scenario}`);
@@ -741,6 +787,10 @@ function printPlan(ctx) {
741
787
  L.push(` contract: ${ctx.envelope.canonical}`);
742
788
  L.push(` contract sha: ${ctx.envelope.sha}`);
743
789
  }
790
+ else if (ctx.goalMode) {
791
+ L.push(" contract: (none — implicit goal mode: the gateway infers the goal from your messages)");
792
+ L.push(` goal mode: ${ctx.goalMode}`);
793
+ }
744
794
  else {
745
795
  L.push(" contract: (none — no contract flag given)");
746
796
  }
@@ -748,3 +798,11 @@ function printPlan(ctx) {
748
798
  L.push(" Dry run — no key was read and nothing was launched.");
749
799
  process.stdout.write(`${L.join("\n")}\n`);
750
800
  }
801
+ /**
802
+ * Header-value hygiene for the Anthropic key: the key is spliced into the newline-joined
803
+ * ANTHROPIC_CUSTOM_HEADERS block (`x-api-key: <key>`), so an embedded CR/LF from a multi-line
804
+ * paste would inject extra header lines. Same rule resolveConversationId applies to its input.
805
+ */
806
+ function stripCrlf(raw) {
807
+ return raw.replace(/[\r\n]+/g, "").trim();
808
+ }
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ import { agentDoctorCommand } from "./commands/agent-doctor.js";
26
26
  import { agentSetupCommand } from "./commands/agent-setup.js";
27
27
  import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
28
28
  import { applyConfigToEnv, loadLauncherConfig } from "./lib/launcher-config.js";
29
+ import { resolveImplicit } from "./lib/agent.js";
29
30
  import { wrapCommand } from "./commands/wrap.js";
30
31
  import { onboardCommand } from "./commands/onboard.js";
31
32
  import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
@@ -111,6 +112,8 @@ const agentCmd = program
111
112
  .option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
112
113
  .option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
113
114
  .option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
115
+ .option("--implicit", "Goal alignment without a sealed contract: 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)")
116
+ .option("--no-implicit", "Goal alignment off for this launch even when launcher.toml sets goal_mode = implicit")
114
117
  .option("--conversation-id <id>", "x-mnemom-conversation-id (default: auto-generated)")
115
118
  .option("--cli <name|path>", "Coding-agent CLI to launch: a name or full path (default: claude)")
116
119
  .option("--gateway <url>", "Gateway host override (default: https://gateway.mnemom.ai; or MNEMOM_AGENT_GATEWAY)")
@@ -174,10 +177,19 @@ const agentCmd = program
174
177
  return;
175
178
  }
176
179
  const numToStr = (n) => n === undefined ? undefined : String(n);
177
- // Config guardrails apply only when a contract is actually intended (a
178
- // --goal is present), so a saved ceiling never forces an unrequested
179
- // contract (which would then demand --goal and fail the launch).
180
- const contractIntended = Boolean(opts.goal && opts.goal.trim());
180
+ // Implicit mode: --implicit / --no-implicit win; a --goal statement seals
181
+ // (the standing config default never overrides it); else launcher.toml
182
+ // `goal_mode`; else off. Pure + unit-tested in lib/agent.ts.
183
+ const implicit = resolveImplicit({
184
+ goal: opts.goal,
185
+ implicit: opts.implicit,
186
+ configGoalMode: cfg.goalMode,
187
+ });
188
+ // Config guardrails apply only when goal alignment is actually intended (a
189
+ // --goal is present, or implicit mode — where they ride on the mode header),
190
+ // so a saved ceiling never forces an unrequested contract (which would
191
+ // then demand --goal and fail the launch).
192
+ const contractIntended = Boolean(opts.goal && opts.goal.trim()) || implicit;
181
193
  // The governed identity rides on the program-level `--agent` (which
182
194
  // shadows a subcommand `--agent` under commander@12 — the MNE-238 class),
183
195
  // exactly as status/logs/onboard read it.
@@ -188,6 +200,7 @@ const agentCmd = program
188
200
  allow: opts.allow,
189
201
  forbid: opts.forbid,
190
202
  goalId: opts.goalId,
203
+ implicit,
191
204
  budget: opts.budget ?? (contractIntended ? numToStr(cfg.guardrails?.budgetUsd) : undefined),
192
205
  maxTurns: opts.maxTurns ?? (contractIntended ? numToStr(cfg.guardrails?.maxTurns) : undefined),
193
206
  stall: opts.stall ?? (contractIntended ? numToStr(cfg.guardrails?.stallTurns) : undefined),
@@ -48,6 +48,14 @@ export interface ContractFlags {
48
48
  maxTurns?: string | number;
49
49
  budget?: string | number;
50
50
  stall?: string | number;
51
+ /**
52
+ * `--implicit`: goal alignment WITHOUT a sealed contract. No `x-mnemom-contract`
53
+ * is sent; the gateway infers the goal from the user's own messages (revision 1
54
+ * after the first message) and revises it as the user steers. The guardrail
55
+ * ceilings still apply — they ride on the `x-mnemom-goal-mode` header instead
56
+ * of inside a contract. Cannot be combined with the sealed-contract flags.
57
+ */
58
+ implicit?: boolean;
51
59
  }
52
60
  /** The three launch shapes the command supports (see commands/code.ts). */
53
61
  export type LaunchShape = "terminal" | "remote-control" | "server";
@@ -116,6 +124,15 @@ export interface SealedContractEnvelope {
116
124
  * here matches whatever the gateway seals for the same flags.
117
125
  */
118
126
  export declare function assembleContract(flags: ContractFlags): SealedContractEnvelope | null;
127
+ /** The request header that opts a conversation into implicit goal mode. */
128
+ export declare const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
129
+ /**
130
+ * The `x-mnemom-goal-mode` header value for `--implicit`, or null when the flag
131
+ * is absent: `implicit[; max_turns=N][; budget_usd=X][; stall_turns=N]` — the
132
+ * guardrail ceilings validated exactly as `assembleContract` validates them (a
133
+ * non-positive ceiling is refused, never silently dropped into an unguarded run).
134
+ */
135
+ export declare function buildGoalModeHeader(flags: ContractFlags): string | null;
119
136
  /**
120
137
  * Resolve the launch shape from the flags and the `MNEMOM_AGENT_REMOTE_CONTROL`
121
138
  * env var (the flags win). Default is `terminal` — a plain governed launch in
@@ -142,7 +159,8 @@ export declare function launchPreferenceExplicit(opts: {
142
159
  /**
143
160
  * Build the `ANTHROPIC_CUSTOM_HEADERS` value for the terminal-only launch: the
144
161
  * real Anthropic key as `x-api-key`, plus the governed-identity headers and the
145
- * sealed contract when present. Newline-separated, exactly the wire shape Claude
162
+ * sealed contract when present (or the implicit goal-mode header when the launch
163
+ * is `--implicit` — never both). Newline-separated, exactly the wire shape Claude
146
164
  * Code sends to the /anthropic door. NB: the returned string contains the key —
147
165
  * callers must place it only in the child's env, never in a log line.
148
166
  */
@@ -151,4 +169,28 @@ export declare function buildCustomHeaders(opts: {
151
169
  agent: string;
152
170
  conversationId: string;
153
171
  contractB64?: string;
172
+ /** `buildGoalModeHeader` output for an `--implicit` launch. */
173
+ goalMode?: string;
154
174
  }): string;
175
+ /**
176
+ * The persistable goal-alignment default (`launcher.toml` `goal_mode`). Only the
177
+ * two statement-free modes can be a standing default: a SEALED contract needs a
178
+ * per-session statement, so it is always a per-launch `--goal`.
179
+ */
180
+ export type GoalModeDefault = "implicit" | "off";
181
+ /**
182
+ * Is this launch implicit-mode? Precedence, highest wins:
183
+ * 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
184
+ * 2. a `--goal` statement — a sealed contract is a per-launch choice that the
185
+ * standing config default must never silently override;
186
+ * 3. the config default `goal_mode = "implicit"`;
187
+ * 4. off.
188
+ * Pure so every arm is unit-tested; the caller passes the resolved value on to
189
+ * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
190
+ * combined with a contract flag — that conflict is EXPLICIT and is reported).
191
+ */
192
+ export declare function resolveImplicit(opts: {
193
+ goal?: string;
194
+ implicit?: boolean;
195
+ configGoalMode?: GoalModeDefault;
196
+ }): boolean;
package/dist/lib/agent.js CHANGED
@@ -132,6 +132,12 @@ function hasText(v) {
132
132
  * here matches whatever the gateway seals for the same flags.
133
133
  */
134
134
  export function assembleContract(flags) {
135
+ // Implicit goal mode seals nothing: the ceilings ride on the mode header
136
+ // (`buildGoalModeHeader`), so a `--budget` alone must not demand `--goal` here.
137
+ if (flags.implicit) {
138
+ assertImplicitCompatible(flags);
139
+ return null;
140
+ }
135
141
  if (!contractFlagsPresent(flags))
136
142
  return null;
137
143
  const statement = (flags.goal ?? "").trim();
@@ -178,6 +184,58 @@ export function assembleContract(flags) {
178
184
  sha: sha256Hex16(canonical),
179
185
  };
180
186
  }
187
+ /** The request header that opts a conversation into implicit goal mode. */
188
+ export const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
189
+ const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
190
+ /**
191
+ * `--implicit` and a sealed contract are mutually exclusive: on the gateway a
192
+ * sealed `x-mnemom-contract` always wins and would silently switch implicit mode
193
+ * off, so the launch refuses the combination rather than ship a header that does
194
+ * nothing.
195
+ */
196
+ function assertImplicitCompatible(flags) {
197
+ const sealed = hasText(flags.goal) ||
198
+ (flags.requirement?.length ?? 0) > 0 ||
199
+ (flags.allow?.length ?? 0) > 0 ||
200
+ (flags.forbid?.length ?? 0) > 0 ||
201
+ hasText(flags.goalId);
202
+ if (sealed) {
203
+ throw new Error(`--implicit cannot be combined with ${SEALED_ONLY_FLAGS}: a sealed contract always wins on ` +
204
+ "the gateway and would silently switch implicit mode off. Drop --implicit to seal a " +
205
+ "contract, or drop the contract flags to let the gateway infer the goal from your messages.");
206
+ }
207
+ }
208
+ /**
209
+ * The `x-mnemom-goal-mode` header value for `--implicit`, or null when the flag
210
+ * is absent: `implicit[; max_turns=N][; budget_usd=X][; stall_turns=N]` — the
211
+ * guardrail ceilings validated exactly as `assembleContract` validates them (a
212
+ * non-positive ceiling is refused, never silently dropped into an unguarded run).
213
+ */
214
+ export function buildGoalModeHeader(flags) {
215
+ if (!flags.implicit)
216
+ return null;
217
+ assertImplicitCompatible(flags);
218
+ const parts = ["implicit"];
219
+ if (flags.maxTurns !== undefined) {
220
+ if (!isPositiveInt(flags.maxTurns)) {
221
+ throw new Error(`--max-turns must be a positive integer (got '${flags.maxTurns}').`);
222
+ }
223
+ parts.push(`max_turns=${Number(flags.maxTurns)}`);
224
+ }
225
+ if (flags.budget !== undefined) {
226
+ if (!isPositiveNumber(flags.budget)) {
227
+ throw new Error(`--budget must be a positive USD amount (got '${flags.budget}').`);
228
+ }
229
+ parts.push(`budget_usd=${Number(flags.budget)}`);
230
+ }
231
+ if (flags.stall !== undefined) {
232
+ if (!isPositiveInt(flags.stall)) {
233
+ throw new Error(`--stall must be a positive integer (got '${flags.stall}').`);
234
+ }
235
+ parts.push(`stall_turns=${Number(flags.stall)}`);
236
+ }
237
+ return parts.join("; ");
238
+ }
181
239
  /** Trim, drop empties — matches the gateway's `stringArray` filtering. */
182
240
  function cleanList(values) {
183
241
  return (values ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
@@ -236,7 +294,8 @@ export function launchPreferenceExplicit(opts, env = process.env) {
236
294
  /**
237
295
  * Build the `ANTHROPIC_CUSTOM_HEADERS` value for the terminal-only launch: the
238
296
  * real Anthropic key as `x-api-key`, plus the governed-identity headers and the
239
- * sealed contract when present. Newline-separated, exactly the wire shape Claude
297
+ * sealed contract when present (or the implicit goal-mode header when the launch
298
+ * is `--implicit` — never both). Newline-separated, exactly the wire shape Claude
240
299
  * Code sends to the /anthropic door. NB: the returned string contains the key —
241
300
  * callers must place it only in the child's env, never in a log line.
242
301
  */
@@ -248,5 +307,25 @@ export function buildCustomHeaders(opts) {
248
307
  ];
249
308
  if (opts.contractB64)
250
309
  lines.push(`x-mnemom-contract: ${opts.contractB64}`);
310
+ if (opts.goalMode)
311
+ lines.push(`${GOAL_MODE_HEADER}: ${opts.goalMode}`);
251
312
  return lines.join("\n");
252
313
  }
314
+ /**
315
+ * Is this launch implicit-mode? Precedence, highest wins:
316
+ * 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
317
+ * 2. a `--goal` statement — a sealed contract is a per-launch choice that the
318
+ * standing config default must never silently override;
319
+ * 3. the config default `goal_mode = "implicit"`;
320
+ * 4. off.
321
+ * Pure so every arm is unit-tested; the caller passes the resolved value on to
322
+ * `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
323
+ * combined with a contract flag — that conflict is EXPLICIT and is reported).
324
+ */
325
+ export function resolveImplicit(opts) {
326
+ if (opts.implicit !== undefined)
327
+ return opts.implicit;
328
+ if (hasText(opts.goal))
329
+ return false;
330
+ return opts.configGoalMode === "implicit";
331
+ }
package/dist/lib/auth.js CHANGED
@@ -117,7 +117,14 @@ export async function getAccessToken() {
117
117
  if (auth.expiresAt > now + 60) {
118
118
  return auth.accessToken;
119
119
  }
120
- // Auto-refresh
120
+ // Auto-refresh. A *thrown* error from refreshStoredTokens() (transient
121
+ // exhaustion / non-revoke 4xx from oauthRefreshTokens(), MNE-7394) is
122
+ // intentionally allowed to propagate here rather than being coerced to the
123
+ // `null` re-login signal — a momentary AS outage must not masquerade as
124
+ // "credentials revoked". Only a genuine invalid_grant/invalid_client returns
125
+ // null (see refreshStoredTokens). Refining caller-side handling (distinguish
126
+ // propagate-vs-degrade) is tracked under MNE-7394's parent lane card
127
+ // (keith/oauth-token-lifetime).
121
128
  const refreshed = await refreshStoredTokens(auth);
122
129
  if (refreshed)
123
130
  return refreshed.accessToken;
@@ -139,6 +146,9 @@ export async function forceRefreshAccessToken() {
139
146
  const auth = getAuthInfo();
140
147
  if (!auth?.refreshToken)
141
148
  return null;
149
+ // As in getAccessToken, a thrown transient-exhaustion / non-revoke error from
150
+ // refreshStoredTokens() propagates rather than being coerced to null (MNE-7394;
151
+ // caller-side refinement tracked under keith/oauth-token-lifetime).
142
152
  const refreshed = await refreshStoredTokens(auth);
143
153
  return refreshed?.accessToken ?? null;
144
154
  }
@@ -226,6 +236,16 @@ export async function loginWithDeviceFlow() {
226
236
  * Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
227
237
  * refresh token and the client_id they were issued to; returns null (rather
228
238
  * than throwing) when refresh isn't possible so callers degrade to re-login.
239
+ *
240
+ * Note: oauthRefreshTokens() returns null ONLY on a definitive revoke
241
+ * (invalid_grant/invalid_client) or missing credentials; on transient
242
+ * exhaustion (repeated 5xx / network faults) or any other non-revoke 4xx it
243
+ * *throws* (MNE-7394 / R6). That thrown error is intentionally allowed to
244
+ * propagate through this function to its callers rather than being coerced to
245
+ * null, so a momentary blip never presents as "credentials revoked". Refining
246
+ * the caller-side handling (propagate-vs-degrade in
247
+ * getAccessToken/forceRefreshAccessToken) is tracked under MNE-7394's parent
248
+ * lane card (keith/oauth-token-lifetime).
229
249
  */
230
250
  async function refreshStoredTokens(auth) {
231
251
  if (!auth.refreshToken || !auth.clientId)
@@ -15,7 +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 LaunchShape } from "./agent.js";
18
+ import { type GoalModeDefault, type LaunchShape } from "./agent.js";
19
19
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
20
20
  export declare const LAUNCHER_CONFIG_PATH: string;
21
21
  /** Where the CLI resolves the Anthropic key from; advisory ordering/restriction. */
@@ -29,6 +29,8 @@ export interface LauncherConfig {
29
29
  cli?: string;
30
30
  launch?: LaunchShape;
31
31
  keySource?: KeySource;
32
+ /** Standing goal-alignment default when neither --goal nor --implicit is given. */
33
+ goalMode?: GoalModeDefault;
32
34
  guardrails?: {
33
35
  maxTurns?: number;
34
36
  budgetUsd?: number;
@@ -19,12 +19,13 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
19
19
  import { dirname, join } from "node:path";
20
20
  import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
21
21
  import { MNEMOM_DIR } from "./config.js";
22
- import { isPositiveInt, isPositiveNumber } from "./agent.js";
22
+ import { isPositiveInt, isPositiveNumber, } from "./agent.js";
23
23
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
24
24
  export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
25
25
  const LAUNCH_SHAPES = ["terminal", "remote-control", "server"];
26
26
  const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
27
27
  const KEY_SOURCES = ["env", "store", "prompt"];
28
+ const GOAL_MODES = ["implicit", "off"];
28
29
  function coerceLaunch(raw) {
29
30
  const v = raw.trim();
30
31
  if (LAUNCH_SHAPES.includes(v))
@@ -43,6 +44,12 @@ function coerceKeySource(raw) {
43
44
  return v;
44
45
  throw new Error(`key_source must be one of ${KEY_SOURCES.join(", ")} (got '${raw}').`);
45
46
  }
47
+ function coerceGoalMode(raw) {
48
+ const v = raw.trim().toLowerCase();
49
+ if (GOAL_MODES.includes(v))
50
+ return v;
51
+ throw new Error(`goal_mode must be one of ${GOAL_MODES.join(", ")} (got '${raw}'). A sealed contract needs a statement, so it is always a per-launch --goal.`);
52
+ }
46
53
  function coerceNonEmpty(name, raw) {
47
54
  const v = raw.trim();
48
55
  if (!v)
@@ -112,6 +119,14 @@ export const CONFIG_KEYS = [
112
119
  c.keySource = coerceKeySource(v);
113
120
  },
114
121
  },
122
+ {
123
+ key: "goal_mode",
124
+ describe: `Goal alignment when no --goal/--implicit is given (${GOAL_MODES.join("|")}); a sealed contract needs a per-launch --goal`,
125
+ get: (c) => c.goalMode,
126
+ set: (c, v) => {
127
+ c.goalMode = coerceGoalMode(v);
128
+ },
129
+ },
115
130
  {
116
131
  key: "guardrails.max_turns",
117
132
  describe: "Contract guardrail: turn ceiling (positive integer)",
@@ -176,6 +191,8 @@ export function parseLauncherConfig(text) {
176
191
  cfg.launch = coerceLaunch(str(raw.launch) ?? "");
177
192
  if (raw.key_source !== undefined)
178
193
  cfg.keySource = coerceKeySource(str(raw.key_source) ?? "");
194
+ if (raw.goal_mode !== undefined)
195
+ cfg.goalMode = coerceGoalMode(str(raw.goal_mode) ?? "");
179
196
  const g = raw.guardrails;
180
197
  if (g !== undefined) {
181
198
  if (typeof g !== "object" || g === null || Array.isArray(g)) {
@@ -232,6 +249,8 @@ export function serializeLauncherConfig(cfg) {
232
249
  doc.launch = cfg.launch;
233
250
  if (cfg.keySource !== undefined)
234
251
  doc.key_source = cfg.keySource;
252
+ if (cfg.goalMode !== undefined)
253
+ doc.goal_mode = cfg.goalMode;
235
254
  const g = cfg.guardrails;
236
255
  if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
237
256
  const gr = {};
@@ -105,11 +105,33 @@ export declare function loginWithDevice(opts?: {
105
105
  sleep?: (ms: number) => Promise<void>;
106
106
  }): Promise<DeviceResult>;
107
107
  /**
108
- * Exchange a refresh token for a fresh access token. Returns null if refresh is
109
- * not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
110
- * so callers can fall back to prompting for re-login rather than crashing.
108
+ * Exchange a refresh token for a fresh access token.
109
+ *
110
+ * Failure classification (R6 / MNE-7394) — a transient failure must never be
111
+ * indistinguishable from a revoked credential:
112
+ * - 2xx → return the fresh tokens (carrying the prior refresh token forward
113
+ * per RFC 6749 §6 when the AS omits a new one).
114
+ * - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
115
+ * return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
116
+ * errors that mean the presented grant/client credential is genuinely no
117
+ * longer valid, so a fresh login is the correct remedy. No retry.
118
+ * - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
119
+ * error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
120
+ * the credentials are revoked, so we must not discard the session.
121
+ * - `res.status >= 500` or a fetch/network exception → retry with bounded
122
+ * exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
123
+ * retries are exhausted, throw. Exhausting transient retries must NOT
124
+ * masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
125
+ * preserves the stored session so a later invocation can succeed once the
126
+ * AS recovers.
127
+ *
128
+ * `null` is therefore narrowed to exactly the two re-login errors; every other
129
+ * outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
130
+ * so tests drive the backoff without real timers.
111
131
  */
112
- export declare function refreshTokens(refreshToken: string, clientId: string): Promise<OAuthTokens | null>;
132
+ export declare function refreshTokens(refreshToken: string, clientId: string, opts?: {
133
+ sleep?: (ms: number) => Promise<void>;
134
+ }): Promise<OAuthTokens | null>;
113
135
  /**
114
136
  * Open `url` in the user's default browser WITHOUT a shell. The URL is always
115
137
  * passed as a separate argv element (never concatenated into a command string),
package/dist/lib/oauth.js CHANGED
@@ -39,6 +39,14 @@ const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
39
39
  // both interactive login poll loops emit, so a multi-minute browser sign-in
40
40
  // doesn't read as a hung CLI (matches the card-write retry tick in try-me).
41
41
  const LOGIN_HEARTBEAT_MS = 15 * 1000;
42
+ // Refresh-token retry policy (R6 / MNE-7394). A transient AS 5xx or a dropped
43
+ // network connection during refresh must be retried with bounded exponential
44
+ // backoff rather than collapsed into the re-login signal — a one-second AS
45
+ // hiccup or a flaky Wi-Fi moment must not invalidate a valid, long-lived
46
+ // session. Per-attempt delay is REFRESH_BACKOFF_BASE_MS * 2**attemptIndex →
47
+ // ~1s / 2s / 4s across REFRESH_MAX_ATTEMPTS attempts.
48
+ const REFRESH_MAX_ATTEMPTS = 3;
49
+ const REFRESH_BACKOFF_BASE_MS = 1000;
42
50
  let cachedMetadata = null;
43
51
  /**
44
52
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
@@ -359,40 +367,101 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
359
367
  // Refresh (RFC 6749 §6)
360
368
  // ============================================================================
361
369
  /**
362
- * Exchange a refresh token for a fresh access token. Returns null if refresh is
363
- * not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
364
- * so callers can fall back to prompting for re-login rather than crashing.
370
+ * Exchange a refresh token for a fresh access token.
371
+ *
372
+ * Failure classification (R6 / MNE-7394) — a transient failure must never be
373
+ * indistinguishable from a revoked credential:
374
+ * - 2xx → return the fresh tokens (carrying the prior refresh token forward
375
+ * per RFC 6749 §6 when the AS omits a new one).
376
+ * - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
377
+ * return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
378
+ * errors that mean the presented grant/client credential is genuinely no
379
+ * longer valid, so a fresh login is the correct remedy. No retry.
380
+ * - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
381
+ * error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
382
+ * the credentials are revoked, so we must not discard the session.
383
+ * - `res.status >= 500` or a fetch/network exception → retry with bounded
384
+ * exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
385
+ * retries are exhausted, throw. Exhausting transient retries must NOT
386
+ * masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
387
+ * preserves the stored session so a later invocation can succeed once the
388
+ * AS recovers.
389
+ *
390
+ * `null` is therefore narrowed to exactly the two re-login errors; every other
391
+ * outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
392
+ * so tests drive the backoff without real timers.
365
393
  */
366
- export async function refreshTokens(refreshToken, clientId) {
394
+ export async function refreshTokens(refreshToken, clientId, opts) {
367
395
  if (!refreshToken || !clientId)
368
396
  return null;
369
- try {
370
- const meta = await discover();
371
- const res = await fetch(meta.token_endpoint, {
372
- method: "POST",
373
- headers: {
374
- "Content-Type": "application/x-www-form-urlencoded",
375
- Accept: "application/json",
376
- },
377
- body: new URLSearchParams({
378
- grant_type: "refresh_token",
379
- refresh_token: refreshToken,
380
- client_id: clientId,
381
- }),
382
- });
383
- if (!res.ok)
397
+ const sleep = opts?.sleep ?? defaultSleep;
398
+ // Discovery is not part of the refresh retry budget; a discovery failure
399
+ // propagates as it does today (it is already thrown by discover()).
400
+ const meta = await discover();
401
+ let lastTransient = "";
402
+ for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
403
+ let res;
404
+ try {
405
+ res = await fetch(meta.token_endpoint, {
406
+ method: "POST",
407
+ headers: {
408
+ "Content-Type": "application/x-www-form-urlencoded",
409
+ Accept: "application/json",
410
+ },
411
+ body: new URLSearchParams({
412
+ grant_type: "refresh_token",
413
+ refresh_token: refreshToken,
414
+ client_id: clientId,
415
+ }),
416
+ });
417
+ }
418
+ catch (err) {
419
+ // Network/fetch exception (DNS failure, connection reset, timeout) — this
420
+ // is unrelated to the validity of the refresh token, so retry it.
421
+ lastTransient = err instanceof Error ? err.message : String(err);
422
+ if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
423
+ await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
424
+ continue;
425
+ }
426
+ break;
427
+ }
428
+ if (res.ok) {
429
+ const data = (await res.json());
430
+ const tokens = tokensFromResponse(data);
431
+ // Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in
432
+ // which case the old one remains valid — preserve it so the next refresh
433
+ // works.
434
+ if (!tokens.refreshToken)
435
+ tokens.refreshToken = refreshToken;
436
+ return tokens;
437
+ }
438
+ if (res.status >= 500) {
439
+ // Transient AS outage — retry with backoff, then fall through to the
440
+ // "exhausted" throw once no attempts remain.
441
+ lastTransient = `HTTP ${res.status}`;
442
+ if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
443
+ await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
444
+ continue;
445
+ }
446
+ break;
447
+ }
448
+ // 4xx — a definitive client-side rejection, never retried. Only
449
+ // invalid_grant / invalid_client map to the null re-login signal; every
450
+ // other 4xx surfaces as a thrown error rather than a silent null that would
451
+ // wrongly discard a valid session.
452
+ const body = (await res.json().catch(() => ({})));
453
+ if (body.error === "invalid_grant" || body.error === "invalid_client") {
384
454
  return null;
385
- const data = (await res.json());
386
- const tokens = tokensFromResponse(data);
387
- // Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in which
388
- // case the old one remains valid — preserve it so the next refresh works.
389
- if (!tokens.refreshToken)
390
- tokens.refreshToken = refreshToken;
391
- return tokens;
392
- }
393
- catch {
394
- return null;
455
+ }
456
+ const detail = body.error
457
+ ? body.error + (body.error_description ? ` — ${body.error_description}` : "")
458
+ : `HTTP ${res.status}`;
459
+ throw new Error(`Token refresh failed: ${detail}`);
395
460
  }
461
+ // Retries exhausted on a 5xx or a network exception (fail-closed, MNE-442):
462
+ // throw rather than return null so the session is preserved for a later retry.
463
+ throw new Error(`Token refresh failed after ${REFRESH_MAX_ATTEMPTS} attempts ` +
464
+ `(last error: ${lastTransient || "unknown transient failure"}).`);
396
465
  }
397
466
  // ============================================================================
398
467
  // Helpers
package/dist/rc-proxy.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
20
20
  // gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
21
21
  // `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
22
- // `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` — the exact header set
22
+ // `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` / `x-mnemom-goal-mode` — the exact header set
23
23
  // the non-RC launch sends via ANTHROPIC_CUSTOM_HEADERS. The OAuth `authorization` bearer
24
24
  // is STRIPPED on that leg (it never leaves the machine) and `oauth-*` tokens are removed
25
25
  // from `anthropic-beta`. Everything else under api.anthropic.com (OAuth refresh, the
@@ -62,6 +62,8 @@
62
62
  // MNEMOM_AGENT_RC_AGENT x-mnemom-agent value (required)
63
63
  // MNEMOM_AGENT_RC_CONVERSATION_ID x-mnemom-conversation-id value (required)
64
64
  // MNEMOM_AGENT_RC_CONTRACT_B64 base64 contract for x-mnemom-contract (optional)
65
+ // MNEMOM_AGENT_RC_GOAL_MODE x-mnemom-goal-mode value (optional; `--implicit` — no contract,
66
+ // the gateway infers the goal from the user's messages)
65
67
  // MNEMOM_AGENT_RC_PROVIDER x-mnemom-provider directive JSON (optional; router door)
66
68
  // MNEMOM_AGENT_RC_THREAD_MODE `reject` (default: answer thread-bearing requests locally,
67
69
  // see above) | `strip` (legacy: delete `thread`, forward)
@@ -98,6 +100,7 @@ const AGENT = env.MNEMOM_AGENT_RC_AGENT || die("MNEMOM_AGENT_RC_AGENT is require
98
100
  const CONV =
99
101
  env.MNEMOM_AGENT_RC_CONVERSATION_ID || die("MNEMOM_AGENT_RC_CONVERSATION_ID is required");
100
102
  const CONTRACT_B64 = env.MNEMOM_AGENT_RC_CONTRACT_B64 || "";
103
+ const GOAL_MODE = env.MNEMOM_AGENT_RC_GOAL_MODE || "";
101
104
  const PROVIDER = env.MNEMOM_AGENT_RC_PROVIDER || "";
102
105
  const THREAD_MODE = env.MNEMOM_AGENT_RC_THREAD_MODE || "reject";
103
106
  if (THREAD_MODE !== "reject" && THREAD_MODE !== "strip")
@@ -107,7 +110,9 @@ const PARENT = Number(env.MNEMOM_AGENT_RC_PARENT_PID || 0);
107
110
  const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
108
111
 
109
112
  const log = (line) => {
110
- const s = `${new Date().toISOString()} ${line}\n`;
113
+ // One record per line: gateway header values and error bodies are spliced into log lines, so a
114
+ // CR/LF inside them must not fabricate extra records (log injection).
115
+ const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
111
116
  if (LOGFILE) {
112
117
  try {
113
118
  fs.appendFileSync(LOGFILE, s);
@@ -345,6 +350,7 @@ function handle(req, res, viaPlain) {
345
350
  if (PROVIDER) headers["x-mnemom-provider"] = PROVIDER;
346
351
  if (secrets.openai) headers["x-mnemom-egress-key"] = secrets.openai;
347
352
  if (CONTRACT_B64) headers["x-mnemom-contract"] = CONTRACT_B64;
353
+ if (GOAL_MODE) headers["x-mnemom-goal-mode"] = GOAL_MODE;
348
354
  if (headers["anthropic-beta"]) {
349
355
  // drop the OAuth-only beta tokens
350
356
  const b = String(headers["anthropic-beta"])
@@ -388,8 +394,12 @@ function handle(req, res, viaPlain) {
388
394
  });
389
395
  }
390
396
  ur.pipe(res);
397
+ const goalRev = ur.headers["x-mnemom-goal-revision"];
398
+ const steer = ur.headers["x-mnemom-goal-steer"];
391
399
  const ident = toGateway
392
- ? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}`
400
+ ? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
401
+ (goalRev ? ` goal-rev=${goalRev}` : "") +
402
+ (steer ? ` steer=${steer}` : "")
393
403
  : "";
394
404
  ur.on("end", () => {
395
405
  let bodyNote = "";
@@ -488,7 +498,7 @@ proxy.on("clientError", (e, sock) => {
488
498
  proxy.listen(PORT, "127.0.0.1", () => {
489
499
  const { port } = proxy.address();
490
500
  log(
491
- `listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
501
+ `listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} goal_mode=${GOAL_MODE || "-"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
492
502
  );
493
503
  process.stdout.write(`READY ${port} ${p("ca.pem")}\n`);
494
504
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.0-next.7",
3
+ "version": "0.17.0-next.8",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,10 +26,10 @@
26
26
  "devDependencies": {
27
27
  "@types/js-yaml": "^4.0.9",
28
28
  "@types/node": "^20.10.0",
29
- "@vitest/coverage-v8": "^4.1.10",
29
+ "@vitest/coverage-v8": "^4.1.11",
30
30
  "tsx": "^4.23.1",
31
31
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.10"
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "files": [
35
35
  "dist",
@@ -65,5 +65,9 @@
65
65
  },
66
66
  "engines": {
67
67
  "node": ">=18.0.0"
68
+ },
69
+ "overrides": {
70
+ "@vitest/mocker": "^4.1.11",
71
+ "nanoid": "^3.3.18"
68
72
  }
69
73
  }