@mnemom/mnemom 0.17.2-next.7 → 0.17.2-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/commands/agent-doctor.d.ts +1 -1
  2. package/dist/commands/agent-doctor.js +5 -1
  3. package/dist/commands/agent-setup.d.ts +34 -3
  4. package/dist/commands/agent-setup.js +86 -11
  5. package/dist/commands/agent.js +11 -3
  6. package/dist/commands/config.d.ts +36 -0
  7. package/dist/commands/config.js +82 -0
  8. package/dist/commands/update.d.ts +30 -0
  9. package/dist/commands/update.js +90 -0
  10. package/dist/index.js +41 -0
  11. package/dist/lib/agent.d.ts +13 -7
  12. package/dist/lib/agent.js +12 -12
  13. package/dist/lib/cli-config.d.ts +8 -0
  14. package/dist/lib/cli-config.js +13 -0
  15. package/dist/lib/slack-router.js +3 -1
  16. package/dist/lib/update/action.d.ts +9 -0
  17. package/dist/lib/update/action.js +17 -0
  18. package/dist/lib/update/decide.d.ts +152 -0
  19. package/dist/lib/update/decide.js +195 -0
  20. package/dist/lib/update/doctor.d.ts +18 -0
  21. package/dist/lib/update/doctor.js +63 -0
  22. package/dist/lib/update/install-method.d.ts +12 -0
  23. package/dist/lib/update/install-method.js +33 -0
  24. package/dist/lib/update/min-version.d.ts +34 -0
  25. package/dist/lib/update/min-version.js +69 -0
  26. package/dist/lib/update/registry.d.ts +27 -0
  27. package/dist/lib/update/registry.js +50 -0
  28. package/dist/lib/update/run.d.ts +54 -0
  29. package/dist/lib/update/run.js +100 -0
  30. package/dist/lib/update/semver.d.ts +22 -0
  31. package/dist/lib/update/semver.js +65 -0
  32. package/dist/lib/update/startup.d.ts +27 -0
  33. package/dist/lib/update/startup.js +75 -0
  34. package/dist/lib/update/state.d.ts +23 -0
  35. package/dist/lib/update/state.js +83 -0
  36. package/dist/lib/update/system.d.ts +55 -0
  37. package/dist/lib/update/system.js +104 -0
  38. package/dist/lib/update/worker.d.ts +42 -0
  39. package/dist/lib/update/worker.js +104 -0
  40. package/dist/lib/version-gate.d.ts +2 -1
  41. package/dist/lib/version-gate.js +9 -1
  42. package/dist/update-worker.d.ts +6 -0
  43. package/dist/update-worker.js +8 -0
  44. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  * Checks: settings file, coding-agent CLI, Anthropic key source, gateway health
10
10
  * (200 + the x-mnemom-verdict header that proves the governance pipeline is
11
11
  * live), Remote-Control capability (node + openssl + the --remote-control flag),
12
- * and claude.ai login (informational — Remote Control needs it).
12
+ * claude.ai login (informational — Remote Control needs it), and CLI auto-update status.
13
13
  */
14
14
  type Mark = "ok" | "warn" | "miss" | "info";
15
15
  export interface DoctorOptions {
@@ -9,7 +9,7 @@
9
9
  * Checks: settings file, coding-agent CLI, Anthropic key source, gateway health
10
10
  * (200 + the x-mnemom-verdict header that proves the governance pipeline is
11
11
  * live), Remote-Control capability (node + openssl + the --remote-control flag),
12
- * and claude.ai login (informational — Remote Control needs it).
12
+ * claude.ai login (informational — Remote Control needs it), and CLI auto-update status.
13
13
  */
14
14
  import { spawnSync } from "node:child_process";
15
15
  import { existsSync } from "node:fs";
@@ -20,6 +20,7 @@ import { getCodeEntitlement, MnemomApiError } from "../lib/api.js";
20
20
  import { isLoggedIn } from "../lib/auth.js";
21
21
  import { getActiveOrg } from "../lib/cli-config.js";
22
22
  import { isCodeNotEnabled, sourceLabel } from "./agent-invite.js";
23
+ import { probeUpdateStatus } from "../lib/update/doctor.js";
23
24
  const MARK = { ok: "[ ok ]", warn: "[warn]", miss: "[MISS]", info: "[info]" };
24
25
  const out = (line = "") => console.log(line);
25
26
  /** Run the preflight and print the checklist. Sets process.exitCode on failure. */
@@ -85,6 +86,9 @@ export async function agentDoctorCommand(options = {}) {
85
86
  // ── Mnemom Agent access (invited cohort gate) ──
86
87
  const gate = await probeCodeAccess();
87
88
  add(gate.mark, gate.text, gate.hint);
89
+ // ── CLI auto-update ──
90
+ const upd = await probeUpdateStatus();
91
+ add(upd.mark, upd.text, upd.hint);
88
92
  // ── render ──
89
93
  const door = resolveAnthropicDoor(env);
90
94
  out("mnemom agent doctor");
@@ -34,10 +34,35 @@ export declare function defaultAgentSlug(): string;
34
34
  * The default slug resolved from the authenticated IDENTITY: `agent-<handle>` from
35
35
  * the login email fetched via `/v1/auth/me`. The CLI's stored OAuth token carries
36
36
  * no email, so the sync `defaultAgentSlug()` alone falls back to "agent-user";
37
- * this fetches the real email once a session exists. Falls back to
38
- * `defaultAgentSlug()` on any failure. Call it AFTER the session is ensured.
37
+ * this fetches the real email once a session exists. Call it AFTER the session
38
+ * is ensured.
39
+ *
40
+ * Default (lenient): falls back to `defaultAgentSlug()` on any failure. With
41
+ * `strict` (an explicit `--setup` / `mnemom agent setup`) a handle that cannot
42
+ * be resolved THROWS instead: the gateway identity is hash(credential, name),
43
+ * so a silent fall-back to "agent-user" would provision and govern a different
44
+ * agent than the one a prior setup under the real handle created (MNE-7650).
45
+ */
46
+ export declare function resolveDefaultSlug(opts?: {
47
+ strict?: boolean;
48
+ }): Promise<string>;
49
+ /**
50
+ * Thrown when a name-matched agent in the caller's org is NOT the agent the
51
+ * gateway serves for (credential, name). Both ids are in the message; the
52
+ * launcher treats it as fatal on an explicit `--setup` (MNE-7650).
39
53
  */
40
- export declare function resolveDefaultSlug(): Promise<string>;
54
+ export declare class AgentIdentityMismatchError extends Error {
55
+ readonly slug: string;
56
+ /** The agent the org lookup found by name. */
57
+ readonly namedId: string;
58
+ /** The agent the gateway answered with on x-mnemom-agent. */
59
+ readonly servedId: string;
60
+ constructor(slug: string,
61
+ /** The agent the org lookup found by name. */
62
+ namedId: string,
63
+ /** The agent the gateway answered with on x-mnemom-agent. */
64
+ servedId: string);
65
+ }
41
66
  export interface EnsureAgentOptions {
42
67
  /** The already-resolved final agent slug (what rides as x-mnemom-agent). */
43
68
  slug: string;
@@ -65,6 +90,12 @@ export interface EnsureAgentResult {
65
90
  * setup`) or a warn-and-continue (a bare launch).
66
91
  */
67
92
  export declare function ensureGovernedAgent(opts: EnsureAgentOptions): Promise<EnsureAgentResult>;
93
+ /**
94
+ * One line for an api failure that keeps the parts an operator can act on: the
95
+ * message, the error code, and the scope the endpoint said it needed
96
+ * (`details.required_scope`). Never the token, never the body.
97
+ */
98
+ export declare function describeApiError(err: unknown): string;
68
99
  /** `mnemom agent setup [--agent <slug>]` — the explicit provisioning entry point. */
69
100
  export declare function agentSetupCommand(opts?: {
70
101
  agent?: string;
@@ -51,21 +51,59 @@ export function defaultAgentSlug() {
51
51
  * The default slug resolved from the authenticated IDENTITY: `agent-<handle>` from
52
52
  * the login email fetched via `/v1/auth/me`. The CLI's stored OAuth token carries
53
53
  * no email, so the sync `defaultAgentSlug()` alone falls back to "agent-user";
54
- * this fetches the real email once a session exists. Falls back to
55
- * `defaultAgentSlug()` on any failure. Call it AFTER the session is ensured.
54
+ * this fetches the real email once a session exists. Call it AFTER the session
55
+ * is ensured.
56
+ *
57
+ * Default (lenient): falls back to `defaultAgentSlug()` on any failure. With
58
+ * `strict` (an explicit `--setup` / `mnemom agent setup`) a handle that cannot
59
+ * be resolved THROWS instead: the gateway identity is hash(credential, name),
60
+ * so a silent fall-back to "agent-user" would provision and govern a different
61
+ * agent than the one a prior setup under the real handle created (MNE-7650).
56
62
  */
57
- export async function resolveDefaultSlug() {
63
+ export async function resolveDefaultSlug(opts = {}) {
64
+ let reason;
58
65
  try {
59
66
  const email = (await getMe()).email ?? "";
60
67
  const handle = sanitizeAgentName(email.split("@")[0] ?? "");
61
68
  if (handle)
62
69
  return `agent-${handle}`;
70
+ reason = email
71
+ ? `login email '${email}' yields no usable handle`
72
+ : "no login email on this session";
63
73
  }
64
- catch {
65
- // fall through to the token-based best-effort
74
+ catch (err) {
75
+ reason = `/v1/auth/me failed: ${err instanceof Error ? err.message : String(err)}`;
76
+ }
77
+ if (opts.strict) {
78
+ throw new Error(`mnemom agent setup: couldn't derive your agent handle (${reason}) — pass --agent <slug>. ` +
79
+ "Setup won't silently fall back to 'agent-user': the gateway identity is hash(credential, name), so a different name is a different agent.");
66
80
  }
67
81
  return defaultAgentSlug();
68
82
  }
83
+ /**
84
+ * Thrown when a name-matched agent in the caller's org is NOT the agent the
85
+ * gateway serves for (credential, name). Both ids are in the message; the
86
+ * launcher treats it as fatal on an explicit `--setup` (MNE-7650).
87
+ */
88
+ export class AgentIdentityMismatchError extends Error {
89
+ slug;
90
+ namedId;
91
+ servedId;
92
+ constructor(slug,
93
+ /** The agent the org lookup found by name. */
94
+ namedId,
95
+ /** The agent the gateway answered with on x-mnemom-agent. */
96
+ servedId) {
97
+ super(`mnemom agent setup: '${slug}' in your org is ${namedId}, but the gateway serves ${servedId} for this Anthropic key + name. ` +
98
+ "Refusing to govern the wrong agent. The gateway identity is hash(credential, name): a different key, " +
99
+ "or a prior setup under another slug, is a different agent. " +
100
+ `Either claim the served one (mnemom agent claim ${servedId} --key <the same key>) or launch with the --agent and key you set up.`);
101
+ this.slug = slug;
102
+ this.namedId = namedId;
103
+ this.servedId = servedId;
104
+ this.name = "AgentIdentityMismatchError";
105
+ }
106
+ }
69
107
  /**
70
108
  * Ensure `slug` is a usable governed agent in the customer's org. Returns
71
109
  * {provisioned:false} when it is already usable, when provisioning is not
@@ -87,7 +125,18 @@ export async function ensureGovernedAgent(opts) {
87
125
  // already-claimed agent (e.g. a protection card that failed on a prior run
88
126
  // before we minted its one-time grant). A bare launch leaves it alone.
89
127
  if (opts.setup === true) {
90
- say(fmt.dim(`mnemom agent: '${slug}' is already claimed — refreshing its cards.`));
128
+ // MNE-7650: the name is client-controlled and the API exposes no
129
+ // agent_hash, so a name match proves nothing about which agent the
130
+ // gateway will serve for (key, slug) — it looks agents up by
131
+ // hash(credential, name) only. Ask the gateway: the same 16-token
132
+ // "hello" the birth path uses answers with the served mnm- id on
133
+ // x-mnemom-agent. Govern that agent or none.
134
+ const gatewayUrl = opts.gatewayHost.replace(/\/+$/, "");
135
+ const served = await birthThroughGateway(gatewayUrl, "anthropic", slug, opts.anthropicKey);
136
+ if (served !== existing.id) {
137
+ throw new AgentIdentityMismatchError(slug, existing.id, served);
138
+ }
139
+ say(fmt.dim(`mnemom agent: '${slug}' is already claimed (${existing.id}, the agent the gateway serves) — refreshing its cards.`));
91
140
  await publishDefaultCards(existing.id);
92
141
  persistAgentSlug(slug);
93
142
  return { slug, provisioned: true };
@@ -140,6 +189,25 @@ export async function ensureGovernedAgent(opts) {
140
189
  return { slug, provisioned: true };
141
190
  }
142
191
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
192
+ /**
193
+ * One line for an api failure that keeps the parts an operator can act on: the
194
+ * message, the error code, and the scope the endpoint said it needed
195
+ * (`details.required_scope`). Never the token, never the body.
196
+ */
197
+ export function describeApiError(err) {
198
+ if (!(err instanceof MnemomApiError))
199
+ return err instanceof Error ? err.message : String(err);
200
+ const bits = [];
201
+ if (err.code)
202
+ bits.push(`code ${err.code}`);
203
+ const details = err.details;
204
+ const scope = details && typeof details === "object" && "required_scope" in details
205
+ ? details.required_scope
206
+ : undefined;
207
+ if (typeof scope === "string" && scope)
208
+ bits.push(`requires ${scope}`);
209
+ return bits.length ? `${err.message} (${bits.join(", ")})` : err.message;
210
+ }
143
211
  /**
144
212
  * Write one card with a brief retry to absorb the org-membership propagation that
145
213
  * can transiently 401/403/409 a write right after a claim (the same window
@@ -172,12 +240,18 @@ async function publishDefaultCards(agentId) {
172
240
  // it with our human login JWT — the "Provision and claim?" confirm is the
173
241
  // explicit human approval, and the mint endpoint accepts a human operator (only
174
242
  // agent OAuth principals are rejected). The next write consumes the grant. A
175
- // mint failure falls through to the write's retry + graceful degrade.
243
+ // mint failure falls through to the write's retry + graceful degrade, but it
244
+ // is NAMED: the mint's error code (e.g. an ownership `agent_org_forbidden`)
245
+ // and `details.required_scope` are the actionable diagnosis — the downstream
246
+ // protection-write denial alone points at the wrong remedy (MNE-7650).
176
247
  try {
177
248
  await mintProtectionCapability(agentId);
178
249
  }
179
- catch {
180
- // best effort — the protection write below surfaces the real, actionable error
250
+ catch (err) {
251
+ say(fmt.warn(`mnemom agent: couldn't mint the protection grant for ${agentId} — ${describeApiError(err)}`));
252
+ if (err instanceof MnemomApiError && err.code === "agent_org_forbidden") {
253
+ say(fmt.dim(" That agent is owned by an org other than your active one — `mnemom org use <org>` to switch, or set up under a different --agent."));
254
+ }
181
255
  }
182
256
  const prot = await tryPublishCard("protection", () => putProtectionCard(agentId, JSON.stringify(buildCodeProtectionCard(agentId)), "application/json"));
183
257
  if (align.ok && prot.ok) {
@@ -209,10 +283,11 @@ export async function agentSetupCommand(opts = {}) {
209
283
  throw err;
210
284
  }
211
285
  const anthropicKey = await resolveAnthropicKey(process.env, { allowPrompt: true });
212
- // Session exists now, so derive the real handle from /v1/auth/me (not "agent-user").
286
+ // Session exists now, so derive the real handle from /v1/auth/me. Strict: an
287
+ // explicit setup never silently provisions "agent-user" (MNE-7650).
213
288
  const slug = opts.agent?.trim()
214
289
  ? sanitizeAgentName(opts.agent.trim())
215
- : await resolveDefaultSlug();
290
+ : await resolveDefaultSlug({ strict: true });
216
291
  if (!slug)
217
292
  throw new Error("mnemom agent setup: agent slug sanitised to empty — pass --agent <slug>.");
218
293
  const result = await ensureGovernedAgent({ slug, anthropicKey, setup: true, gatewayHost });
@@ -38,7 +38,7 @@ import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/l
38
38
  import { fmt } from "../lib/format.js";
39
39
  import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
40
40
  import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
41
- import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
41
+ import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
42
42
  import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfigB64, resolveContextFold, resolveContextHandoff, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, } from "../lib/agent.js";
43
43
  import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
44
44
  import { slackChannelArgs } from "../lib/slack-launch.js";
@@ -215,9 +215,12 @@ export async function agentCommand(scenarioArg, options = {}) {
215
215
  });
216
216
  // Now that a session exists, a defaulted slug uses the real login handle from
217
217
  // /v1/auth/me (the stored token has no email, so the pre-sign-in value was the
218
- // "agent-user" fallback). Only when the caller didn't name an agent.
218
+ // "agent-user" fallback). Only when the caller didn't name an agent. Under an
219
+ // explicit --setup the lookup is strict and a missing handle aborts the launch:
220
+ // provisioning "agent-user" would govern a different gateway identity than the
221
+ // one a prior setup under the real handle created (MNE-7650).
219
222
  if (!options.agent?.trim()) {
220
- const refreshed = sanitizeAgentName(await resolveDefaultSlug());
223
+ const refreshed = sanitizeAgentName(await resolveDefaultSlug({ strict: options.setup === true }));
221
224
  if (refreshed)
222
225
  agent = refreshed;
223
226
  }
@@ -236,6 +239,11 @@ export async function agentCommand(scenarioArg, options = {}) {
236
239
  });
237
240
  }
238
241
  catch (err) {
242
+ // An identity mismatch is the one setup failure that must NOT degrade: the
243
+ // launch would run as an agent other than the one --setup just tried to
244
+ // govern. Refuse, with both ids in the message (MNE-7650).
245
+ if (err instanceof AgentIdentityMismatchError)
246
+ throw err;
239
247
  const msg = err instanceof Error ? err.message : String(err);
240
248
  say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
241
249
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `mnemom config list|get|set|unset` — CLI-wide preferences in
3
+ * ~/.mnemom/config.json. Today: the auto-update keys. (Launcher settings for
4
+ * `mnemom agent` stay in `mnemom agent config` / launcher.toml.)
5
+ *
6
+ * Keys are data (CONFIG_KEYS) so list/get/set/unset share one source of truth.
7
+ */
8
+ import { type UpdatePrefs } from "../lib/update/decide.js";
9
+ import { setUpdatePref } from "../lib/cli-config.js";
10
+ export interface CliConfigKey {
11
+ key: string;
12
+ describe: string;
13
+ env: string;
14
+ /** Stored value (undefined when unset). */
15
+ stored(prefs: UpdatePrefs): string | undefined;
16
+ /** Value in effect after env + default. */
17
+ effective(prefs: UpdatePrefs, env: Record<string, string | undefined>): string;
18
+ /** Validate + persist; throws with a user-facing message on a bad value. */
19
+ set(raw: string, persist: typeof setUpdatePref): void;
20
+ unset(persist: typeof setUpdatePref): void;
21
+ }
22
+ export declare const CONFIG_KEYS: readonly CliConfigKey[];
23
+ export declare function findKey(key: string): CliConfigKey;
24
+ export interface ConfigDeps {
25
+ prefs(): UpdatePrefs;
26
+ env: Record<string, string | undefined>;
27
+ persist: typeof setUpdatePref;
28
+ log(line: string): void;
29
+ }
30
+ /** One line per key: `key = effective (source) — description`. */
31
+ export declare function configListCommand(opts?: {
32
+ json?: boolean;
33
+ }, deps?: ConfigDeps): void;
34
+ export declare function configGetCommand(key: string, deps?: ConfigDeps): void;
35
+ export declare function configSetCommand(key: string, value: string, deps?: ConfigDeps): void;
36
+ export declare function configUnsetCommand(key: string, deps?: ConfigDeps): void;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `mnemom config list|get|set|unset` — CLI-wide preferences in
3
+ * ~/.mnemom/config.json. Today: the auto-update keys. (Launcher settings for
4
+ * `mnemom agent` stay in `mnemom agent config` / launcher.toml.)
5
+ *
6
+ * Keys are data (CONFIG_KEYS) so list/get/set/unset share one source of truth.
7
+ */
8
+ import { isUpdateChannel, isUpdateMode, resolveChannel, resolveMode, UPDATE_CHANNELS, UPDATE_MODES, } from "../lib/update/decide.js";
9
+ import { loadCliConfig, setUpdatePref } from "../lib/cli-config.js";
10
+ export const CONFIG_KEYS = [
11
+ {
12
+ key: "update.channel",
13
+ describe: `Release channel for auto-update: ${UPDATE_CHANNELS.join(" | ")} (next requires an invite)`,
14
+ env: "MNEMOM_UPDATE_CHANNEL",
15
+ stored: (p) => p.channel,
16
+ effective: (p, env) => resolveChannel(p, env),
17
+ set: (raw, persist) => {
18
+ const v = raw.trim();
19
+ if (!isUpdateChannel(v))
20
+ throw new Error(`update.channel must be one of ${UPDATE_CHANNELS.join(", ")} (got '${raw}').`);
21
+ persist("channel", v);
22
+ },
23
+ unset: (persist) => persist("channel", undefined),
24
+ },
25
+ {
26
+ key: "update.mode",
27
+ describe: `Auto-update behaviour: ${UPDATE_MODES.join(" | ")}`,
28
+ env: "MNEMOM_UPDATE_MODE",
29
+ stored: (p) => p.mode,
30
+ effective: (p, env) => resolveMode(p, env),
31
+ set: (raw, persist) => {
32
+ const v = raw.trim();
33
+ if (!isUpdateMode(v))
34
+ throw new Error(`update.mode must be one of ${UPDATE_MODES.join(", ")} (got '${raw}').`);
35
+ persist("mode", v);
36
+ },
37
+ unset: (persist) => persist("mode", undefined),
38
+ },
39
+ ];
40
+ export function findKey(key) {
41
+ const spec = CONFIG_KEYS.find((k) => k.key === key);
42
+ if (!spec)
43
+ throw new Error(`Unknown config key '${key}'. Known keys: ${CONFIG_KEYS.map((k) => k.key).join(", ")}`);
44
+ return spec;
45
+ }
46
+ function defaultDeps() {
47
+ return {
48
+ prefs: () => loadCliConfig().update ?? {},
49
+ env: process.env,
50
+ persist: setUpdatePref,
51
+ log: (line) => console.log(line),
52
+ };
53
+ }
54
+ /** One line per key: `key = effective (source) — description`. */
55
+ export function configListCommand(opts = {}, deps = defaultDeps()) {
56
+ const prefs = deps.prefs();
57
+ const rows = CONFIG_KEYS.map((k) => {
58
+ const stored = k.stored(prefs);
59
+ const source = deps.env[k.env] ? `env ${k.env}` : stored !== undefined ? "config" : "default";
60
+ return { key: k.key, value: k.effective(prefs, deps.env), source, describe: k.describe };
61
+ });
62
+ if (opts.json) {
63
+ deps.log(JSON.stringify(rows, null, 2));
64
+ return;
65
+ }
66
+ for (const r of rows)
67
+ deps.log(`${r.key} = ${r.value} (${r.source}) — ${r.describe}`);
68
+ }
69
+ export function configGetCommand(key, deps = defaultDeps()) {
70
+ deps.log(findKey(key).effective(deps.prefs(), deps.env));
71
+ }
72
+ export function configSetCommand(key, value, deps = defaultDeps()) {
73
+ const spec = findKey(key);
74
+ spec.set(value, deps.persist);
75
+ deps.log(`${key} = ${value.trim()}`);
76
+ if (deps.env[spec.env])
77
+ deps.log(`note: ${spec.env} is set in your environment and overrides this.`);
78
+ }
79
+ export function configUnsetCommand(key, deps = defaultDeps()) {
80
+ findKey(key).unset(deps.persist);
81
+ deps.log(`${key} reset to default`);
82
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `mnemom update [--check] [--channel latest|next] [--force] [--json]`
3
+ *
4
+ * The foreground twin of the background worker: same `runUpdate` pass, but it
5
+ * reports what happened and ignores the CI / non-TTY auto-off (an explicit
6
+ * command is consent). `--check` never installs; `--force` permits a downgrade
7
+ * to the channel's version (e.g. leaving next for latest).
8
+ */
9
+ import { type ResolvedSettings } from "../lib/update/decide.js";
10
+ import { type RunDeps, type RunResult } from "../lib/update/run.js";
11
+ export declare const NEXT_NOT_ENTITLED_HINT: string;
12
+ export interface UpdateOptions {
13
+ check?: boolean;
14
+ channel?: string;
15
+ force?: boolean;
16
+ json?: boolean;
17
+ }
18
+ /** Validate flags → the settings for this foreground pass, or an error message. */
19
+ export declare function foregroundSettings(opts: UpdateOptions, prefs?: import("../lib/update/decide.js").UpdatePrefs, env?: Record<string, string | undefined>): ResolvedSettings | string;
20
+ /** Human-readable report of one pass. Pure, for testing. */
21
+ export declare function formatUpdateResult(result: RunResult, current: string): string[];
22
+ /** Exit status: non-zero only when something actually failed. */
23
+ export declare function updateExitCode(result: RunResult): number;
24
+ export declare function updateCommand(opts: UpdateOptions, deps?: {
25
+ settings?: ResolvedSettings | string;
26
+ acquire?: () => (() => void) | null;
27
+ runDeps?: RunDeps;
28
+ log?: (line: string) => void;
29
+ setExit?: (code: number) => void;
30
+ }): Promise<void>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `mnemom update [--check] [--channel latest|next] [--force] [--json]`
3
+ *
4
+ * The foreground twin of the background worker: same `runUpdate` pass, but it
5
+ * reports what happened and ignores the CI / non-TTY auto-off (an explicit
6
+ * command is consent). `--check` never installs; `--force` permits a downgrade
7
+ * to the channel's version (e.g. leaving next for latest).
8
+ */
9
+ import chalk from "chalk";
10
+ import { CLI_VERSION } from "../version.js";
11
+ import { canSelfUpdate, isUpdateChannel, manualUpgradeCommand, resolveChannel, UPDATE_CHANNELS, } from "../lib/update/decide.js";
12
+ import { runUpdate } from "../lib/update/run.js";
13
+ import { acquireLock } from "../lib/update/state.js";
14
+ import { entryRealpath, foregroundCredential, loadUpdatePrefs } from "../lib/update/system.js";
15
+ import { realRunDeps } from "../lib/update/worker.js";
16
+ export const NEXT_NOT_ENTITLED_HINT = "The next channel is available to invited accounts. Ask your Mnemom contact for an invite " +
17
+ "(and make sure you're logged in: mnemom login). Using latest for now.";
18
+ /** Validate flags → the settings for this foreground pass, or an error message. */
19
+ export function foregroundSettings(opts, prefs = loadUpdatePrefs(), env = process.env) {
20
+ if (opts.channel !== undefined && !isUpdateChannel(opts.channel)) {
21
+ return `--channel must be one of ${UPDATE_CHANNELS.join(", ")} (got '${opts.channel}').`;
22
+ }
23
+ const channel = resolveChannel(prefs, env, opts.channel);
24
+ return { channel, mode: opts.check ? "notify" : "auto" };
25
+ }
26
+ /** Human-readable report of one pass. Pure, for testing. */
27
+ export function formatUpdateResult(result, current) {
28
+ const lines = [];
29
+ if (result.channelDowngraded)
30
+ lines.push(chalk.yellow(NEXT_NOT_ENTITLED_HINT));
31
+ const action = result.action;
32
+ if (!action) {
33
+ lines.push(chalk.red(`✖ Could not check for updates: ${result.error ?? "unknown error"}`));
34
+ return lines;
35
+ }
36
+ const manual = (v) => manualUpgradeCommand(result.method ?? "unknown", v);
37
+ if (result.installed) {
38
+ lines.push(chalk.green(`✓ Updated mnemom ${current} → ${result.installed} (${result.channel})`));
39
+ }
40
+ else if (action.kind === "install") {
41
+ // An install action that didn't produce `installed` failed.
42
+ lines.push(chalk.red(`✖ Update to ${action.version} failed: ${result.error ?? "unknown error"}`));
43
+ lines.push(` Try manually: ${manual(action.version)}`);
44
+ }
45
+ else if (action.kind === "notify") {
46
+ lines.push(`mnemom ${action.version} is available on ${result.channel} (you have ${current}).`);
47
+ lines.push(` Run: ${canSelfUpdate(result.method ?? "unknown") ? "mnemom update" : manual(action.version)}`);
48
+ }
49
+ else {
50
+ lines.push(`mnemom ${current}: ${action.reason} (${result.channel}).`);
51
+ }
52
+ return lines;
53
+ }
54
+ /** Exit status: non-zero only when something actually failed. */
55
+ export function updateExitCode(result) {
56
+ return result.error ? 1 : 0;
57
+ }
58
+ export async function updateCommand(opts, deps = {}) {
59
+ const log = deps.log ?? ((line) => console.log(line));
60
+ const setExit = deps.setExit ?? ((code) => void (process.exitCode = code));
61
+ const settings = deps.settings ?? foregroundSettings(opts);
62
+ if (typeof settings === "string") {
63
+ log(chalk.red(settings));
64
+ setExit(2);
65
+ return;
66
+ }
67
+ const release = (deps.acquire ?? (() => acquireLock()))();
68
+ if (!release) {
69
+ log("Another mnemom update is already running — try again in a minute.");
70
+ setExit(1);
71
+ return;
72
+ }
73
+ try {
74
+ if (!opts.check && !opts.json)
75
+ log(`Checking the ${settings.channel} channel…`);
76
+ const runDeps = deps.runDeps ?? realRunDeps(entryRealpath(process.argv[1]), () => foregroundCredential());
77
+ const result = await runUpdate(runDeps, settings, { force: opts.force, foreground: true });
78
+ if (opts.json) {
79
+ log(JSON.stringify({ current: CLI_VERSION, requested_channel: settings.channel, ...result }, null, 2));
80
+ }
81
+ else {
82
+ for (const line of formatUpdateResult(result, runDeps.current))
83
+ log(line);
84
+ }
85
+ setExit(updateExitCode(result));
86
+ }
87
+ finally {
88
+ release();
89
+ }
90
+ }
package/dist/index.js CHANGED
@@ -34,6 +34,10 @@ import { wrapCommand } from "./commands/wrap.js";
34
34
  import { onboardCommand } from "./commands/onboard.js";
35
35
  import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
36
36
  import { usageCommand, parseNumericFlag, parseDaysFlag } from "./commands/usage.js";
37
+ import { updateCommand } from "./commands/update.js";
38
+ import { runAction } from "./lib/update/action.js";
39
+ import { configListCommand, configGetCommand, configSetCommand, configUnsetCommand, } from "./commands/config.js";
40
+ import { defaultStartupDeps, maybeRunUpdateHook } from "./lib/update/startup.js";
37
41
  program
38
42
  .name("mnemom")
39
43
  .description("Transparent AI agent tracing")
@@ -1876,6 +1880,42 @@ program
1876
1880
  process.exit(1);
1877
1881
  }
1878
1882
  });
1883
+ // ============================================================================
1884
+ // update + config — CLI auto-update (channel latest|next, mode auto|notify|off)
1885
+ //
1886
+ // A detached worker (lib/update/startup.ts → update-worker.ts) checks npm at
1887
+ // most daily (6h on next) and installs in the background; the new version takes
1888
+ // effect on the next run. `mnemom update` is the foreground twin. Preferences
1889
+ // live in ~/.mnemom/config.json via `mnemom config`.
1890
+ // ============================================================================
1891
+ program
1892
+ .command("update")
1893
+ .description("Check for and install a newer mnemom CLI (auto-update runs this in the background)")
1894
+ .option("--check", "Only report whether an update is available; never install")
1895
+ .option("--channel <channel>", "Channel for this run: latest | next (default: update.channel)")
1896
+ .option("--force", "Allow installing an OLDER version (e.g. moving from next back to latest)")
1897
+ .option("--json", "Output JSON")
1898
+ .action((options) => runAction(() => updateCommand(options)));
1899
+ const configCmd = program
1900
+ .command("config")
1901
+ .description("CLI-wide preferences in ~/.mnemom/config.json (auto-update channel and mode)");
1902
+ configCmd
1903
+ .command("list")
1904
+ .description("Show every setting, its effective value, and where it came from")
1905
+ .option("--json", "Output JSON")
1906
+ .action((options) => runAction(() => configListCommand(options)));
1907
+ configCmd
1908
+ .command("get <key>")
1909
+ .description("Print a setting's effective value (e.g. update.channel)")
1910
+ .action((key) => runAction(() => configGetCommand(key)));
1911
+ configCmd
1912
+ .command("set <key> <value>")
1913
+ .description("Set a preference, e.g. `mnemom config set update.channel next`")
1914
+ .action((key, value) => runAction(() => configSetCommand(key, value)));
1915
+ configCmd
1916
+ .command("unset <key>")
1917
+ .description("Reset a preference to its default")
1918
+ .action((key) => runAction(() => configUnsetCommand(key)));
1879
1919
  // Export the fully-assembled commander program so tooling (e.g. the
1880
1920
  // command-tree snapshot generator in scripts/gen-command-tree.mjs) can
1881
1921
  // statically introspect the command surface WITHOUT executing the CLI.
@@ -1897,6 +1937,7 @@ export function runEntrypoint(argv = process.argv.slice(2)) {
1897
1937
  });
1898
1938
  }
1899
1939
  else {
1940
+ maybeRunUpdateHook(defaultStartupDeps(argv));
1900
1941
  program.parse();
1901
1942
  }
1902
1943
  }
@@ -24,15 +24,17 @@ export declare const PROD_GATEWAY_HOST = "https://gateway.mnemom.ai";
24
24
  /**
25
25
  * The sealed-contract shape sent as `x-mnemom-contract` and re-parsed by the
26
26
  * gateway's goal-contract hook (gateway/src/goal-contract.ts `SealedContract`).
27
- * Empty arrays / empty `goal_id` are omitted, and the guardrail ceilings are
28
- * plain finite numbers.
27
+ * The three list fields are ALWAYS present (empty when no flag was given) —
28
+ * that is the shape the gateway's `parseContractHeader` materialises before it
29
+ * hashes, so the sha we print is the sha it seals (MNE-7649). An empty
30
+ * `goal_id` is omitted, and the guardrail ceilings are plain finite numbers.
29
31
  */
30
32
  export interface SealedContract {
31
33
  goal_id?: string;
32
34
  statement: string;
33
- requirements?: string[];
34
- allowed_paths?: string[];
35
- forbidden_paths?: string[];
35
+ requirements: string[];
36
+ allowed_paths: string[];
37
+ forbidden_paths: string[];
36
38
  /** Guardrail ceilings — positive finite numbers, sealed into the contract hash. */
37
39
  max_turns?: number;
38
40
  budget_usd?: number;
@@ -140,8 +142,12 @@ export interface SealedContractEnvelope {
140
142
  * - a guardrail ceiling that is not strictly positive (`parseContractHeader`
141
143
  * drops those, so a "guarded" launch would actually be unguarded).
142
144
  *
143
- * The canonical form OMITS empty arrays and an empty goal_id, so the sha printed
144
- * here matches whatever the gateway seals for the same flags.
145
+ * The canonical form always carries `requirements`, `allowed_paths` and
146
+ * `forbidden_paths` (empty arrays when unset) and omits an empty goal_id —
147
+ * exactly what the gateway's `parseContractHeader` normalises to before
148
+ * `canonicalHash`, so the sha printed here is the `x-mnemom-contract-hash` it
149
+ * returns. (Omitting the empty arrays made every `--goal`-only launch print a
150
+ * sha the gateway never sealed: MNE-7649.)
145
151
  */
146
152
  export declare function assembleContract(flags: ContractFlags): SealedContractEnvelope | null;
147
153
  /** The request header that opts a conversation into implicit goal mode. */
package/dist/lib/agent.js CHANGED
@@ -159,8 +159,12 @@ function hasText(v) {
159
159
  * - a guardrail ceiling that is not strictly positive (`parseContractHeader`
160
160
  * drops those, so a "guarded" launch would actually be unguarded).
161
161
  *
162
- * The canonical form OMITS empty arrays and an empty goal_id, so the sha printed
163
- * here matches whatever the gateway seals for the same flags.
162
+ * The canonical form always carries `requirements`, `allowed_paths` and
163
+ * `forbidden_paths` (empty arrays when unset) and omits an empty goal_id —
164
+ * exactly what the gateway's `parseContractHeader` normalises to before
165
+ * `canonicalHash`, so the sha printed here is the `x-mnemom-contract-hash` it
166
+ * returns. (Omitting the empty arrays made every `--goal`-only launch print a
167
+ * sha the gateway never sealed: MNE-7649.)
164
168
  */
165
169
  export function assembleContract(flags) {
166
170
  // Implicit goal mode seals nothing: the ceilings ride on the mode header
@@ -176,16 +180,12 @@ export function assembleContract(flags) {
176
180
  throw new Error("--goal is required when any contract flag " +
177
181
  "(--requirement/--allow/--forbid/--goal-id/--budget/--max-turns/--stall) is given.");
178
182
  }
179
- const contract = { statement };
180
- const requirements = cleanList(flags.requirement);
181
- if (requirements.length)
182
- contract.requirements = requirements;
183
- const allowed = cleanList(flags.allow);
184
- if (allowed.length)
185
- contract.allowed_paths = allowed;
186
- const forbidden = cleanList(flags.forbid);
187
- if (forbidden.length)
188
- contract.forbidden_paths = forbidden;
183
+ const contract = {
184
+ statement,
185
+ requirements: cleanList(flags.requirement),
186
+ allowed_paths: cleanList(flags.allow),
187
+ forbidden_paths: cleanList(flags.forbid),
188
+ };
189
189
  const goalId = (flags.goalId ?? "").trim();
190
190
  if (goalId)
191
191
  contract.goal_id = goalId;