@mnemom/mnemom 0.17.0-next.7 → 0.17.0-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.
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { cardShowCommand, cardPublishCommand, cardValidateCommand, cardEditComma
10
10
  import { policyInitCommand, policyValidateCommand, policyPublishCommand, policyListCommand, policyTestCommand, policyEvaluateCommand, } from "./commands/policy.js";
11
11
  import { protectionShowCommand, protectionPublishCommand, protectionValidateCommand, protectionEditCommand, protectionDriftCommand, } from "./commands/protection.js";
12
12
  import { agentsListCommand, agentsClaimCommand, agentsMoveCommand } from "./commands/agents.js";
13
+ import { agentSessionsCommand, agentShowCommand, liveSessionDeps, } from "./commands/agent-sessions.js";
13
14
  import { orgListCommand, orgShowCommand, orgUseCommand } from "./commands/org.js";
14
15
  import { teamListCommand, teamShowCommand, teamTemplateCommand, teamPreviewComposeCommand, teamAdminGrantCommand, teamAdminRevokeCommand, teamAdminListCommand, teamCoverageCommand, } from "./commands/team.js";
15
16
  import { advisoriesListCommand, advisoriesShowCommand } from "./commands/advisories.js";
@@ -26,6 +27,7 @@ import { agentDoctorCommand } from "./commands/agent-doctor.js";
26
27
  import { agentSetupCommand } from "./commands/agent-setup.js";
27
28
  import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
28
29
  import { applyConfigToEnv, loadLauncherConfig } from "./lib/launcher-config.js";
30
+ import { resolveImplicit } from "./lib/agent.js";
29
31
  import { wrapCommand } from "./commands/wrap.js";
30
32
  import { onboardCommand } from "./commands/onboard.js";
31
33
  import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
@@ -102,7 +104,7 @@ const collectRepeatable = (value, previous) => {
102
104
  const agentCmd = program
103
105
  .command("agent [session] [passthrough...]")
104
106
  .description("Launch your governed agent (Claude Code in v1) through the Mnemom gateway — governed identity + optional sealed goal contract. Plain `mnemom agent` asks for a session name and an optional goal. " +
105
- "Sub-verbs: `setup` provisions your governed agent; `doctor` preflights prerequisites; `config …` manages persistent settings (~/.mnemom/launcher.toml); `invite <token>` redeems a Mnemom Agent invitation; `list` / `claim` / `move` manage the agents in your orgs.")
107
+ "Sub-verbs: `setup` provisions your governed agent; `doctor` preflights prerequisites; `config …` manages persistent settings (~/.mnemom/launcher.toml); `invite <token>` redeems a Mnemom Agent invitation; `list` / `claim` / `move` manage the agents in your orgs; `sessions` lists your past launches and `show [ref]` merges one with its live goal state.")
106
108
  .option("--goal <statement>", "Contract goal statement (required if any contract flag is given)")
107
109
  .option("--requirement <text>", "Contract requirement (repeatable)", collectRepeatable, [])
108
110
  .option("--allow <glob>", "Allowed path/glob (repeatable)", collectRepeatable, [])
@@ -111,6 +113,8 @@ const agentCmd = program
111
113
  .option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
112
114
  .option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
113
115
  .option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
116
+ .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)")
117
+ .option("--no-implicit", "Goal alignment off for this launch even when launcher.toml sets goal_mode = implicit")
114
118
  .option("--conversation-id <id>", "x-mnemom-conversation-id (default: auto-generated)")
115
119
  .option("--cli <name|path>", "Coding-agent CLI to launch: a name or full path (default: claude)")
116
120
  .option("--gateway <url>", "Gateway host override (default: https://gateway.mnemom.ai; or MNEMOM_AGENT_GATEWAY)")
@@ -174,10 +178,19 @@ const agentCmd = program
174
178
  return;
175
179
  }
176
180
  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());
181
+ // Implicit mode: --implicit / --no-implicit win; a --goal statement seals
182
+ // (the standing config default never overrides it); else launcher.toml
183
+ // `goal_mode`; else off. Pure + unit-tested in lib/agent.ts.
184
+ const implicit = resolveImplicit({
185
+ goal: opts.goal,
186
+ implicit: opts.implicit,
187
+ configGoalMode: cfg.goalMode,
188
+ });
189
+ // Config guardrails apply only when goal alignment is actually intended (a
190
+ // --goal is present, or implicit mode — where they ride on the mode header),
191
+ // so a saved ceiling never forces an unrequested contract (which would
192
+ // then demand --goal and fail the launch).
193
+ const contractIntended = Boolean(opts.goal && opts.goal.trim()) || implicit;
181
194
  // The governed identity rides on the program-level `--agent` (which
182
195
  // shadows a subcommand `--agent` under commander@12 — the MNE-238 class),
183
196
  // exactly as status/logs/onboard read it.
@@ -188,6 +201,7 @@ const agentCmd = program
188
201
  allow: opts.allow,
189
202
  forbid: opts.forbid,
190
203
  goalId: opts.goalId,
204
+ implicit,
191
205
  budget: opts.budget ?? (contractIntended ? numToStr(cfg.guardrails?.budgetUsd) : undefined),
192
206
  maxTurns: opts.maxTurns ?? (contractIntended ? numToStr(cfg.guardrails?.maxTurns) : undefined),
193
207
  stall: opts.stall ?? (contractIntended ? numToStr(cfg.guardrails?.stallTurns) : undefined),
@@ -685,6 +699,34 @@ agentCmd
685
699
  process.exit(1);
686
700
  }
687
701
  });
702
+ agentCmd
703
+ .command("sessions")
704
+ .description("List your Mnemom Agent sessions from the local ledger (~/.mnemom/sessions.jsonl), newest first — the last 20 unless --all")
705
+ .option("--all", "Every session, not just the newest 20")
706
+ .option("--json", "Output the rows as JSON instead of a table")
707
+ .action(async (opts) => {
708
+ try {
709
+ await agentSessionsCommand({ all: opts.all, json: opts.json }, liveSessionDeps);
710
+ }
711
+ catch (error) {
712
+ console.error("Error:", error instanceof Error ? error.message : error);
713
+ process.exit(1);
714
+ }
715
+ });
716
+ agentCmd
717
+ .command("show [ref]")
718
+ .description("Show one session — the local launch record merged with the gateway's live goal state (sealed contract, latest verdict, guardrails). " +
719
+ "`ref` is a session name (newest match wins) or a unique conversation-id prefix; omitted = the newest session")
720
+ .option("--json", "Output the merged view as JSON (each section tagged with its source)")
721
+ .action(async (ref, opts) => {
722
+ try {
723
+ await agentShowCommand(ref, { json: opts.json }, liveSessionDeps);
724
+ }
725
+ catch (error) {
726
+ console.error("Error:", error instanceof Error ? error.message : error);
727
+ process.exit(1);
728
+ }
729
+ });
688
730
  // ============================================================================
689
731
  // Organization management (ADR-044, Piece 1 of T1-3.1)
690
732
  // ============================================================================
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The local session ledger for `mnemom agent` — `~/.mnemom/sessions.jsonl`, one
3
+ * JSON line per REAL launch (never `--dry-run`), newest last.
4
+ *
5
+ * Pure module: the directory, the clock and the pid are injected so the append
6
+ * and the read are unit-testable with a temp dir. The record is deliberately a
7
+ * closed shape — the Anthropic key, the custom-headers line and the child env
8
+ * are never part of it (see `SessionRecord`), so nothing secret can ride along.
9
+ *
10
+ * Writes are best-effort: `appendSessionRecord` never throws — a failure comes
11
+ * back as `{ ok: false, error }` and the caller decides how loudly to warn.
12
+ */
13
+ import type { LaunchShape, SealedContract } from "./agent.js";
14
+ /** The ledger's file name under MNEMOM_DIR. */
15
+ export declare const LEDGER_FILE = "sessions.jsonl";
16
+ /** The ledger keeps only the newest this-many launches. */
17
+ export declare const LEDGER_CAP = 200;
18
+ /** The sealed-contract fields the ledger keeps (no goal_id — the statement is the identity). */
19
+ export interface SessionRecordContract {
20
+ statement: string;
21
+ requirements?: string[];
22
+ allowed_paths?: string[];
23
+ forbidden_paths?: string[];
24
+ max_turns?: number;
25
+ budget_usd?: number;
26
+ stall_turns?: number;
27
+ }
28
+ /** One launch, as written to the ledger. */
29
+ export interface SessionRecord {
30
+ v: 1;
31
+ /** ISO-8601 UTC. */
32
+ started_at: string;
33
+ /** The session (scenario) name. */
34
+ session: string;
35
+ conversation_id: string;
36
+ /** The x-mnemom-agent slug the launch ran under. */
37
+ agent_slug: string;
38
+ /** The agent record id when it resolved at launch, else null. */
39
+ agent_id: string | null;
40
+ org_id: string;
41
+ org_label: string;
42
+ gateway_host: string;
43
+ launch_shape: LaunchShape;
44
+ model: string | null;
45
+ effort: string | null;
46
+ /** The coding-agent CLI's basename, e.g. "claude". */
47
+ cli: string;
48
+ contract: SessionRecordContract | null;
49
+ }
50
+ /** What the call site supplies — the ledger stamps `v` and `started_at`. */
51
+ export type SessionRecordInput = Omit<SessionRecord, "v" | "started_at">;
52
+ /** The injected environment of the ledger. */
53
+ export interface LedgerDeps {
54
+ /** The directory holding the ledger (MNEMOM_DIR in production). */
55
+ dir: string;
56
+ /** The clock (stamps `started_at` and names the rewrite temp file). */
57
+ now: () => Date;
58
+ /** The writing process's pid (names the rewrite temp file). */
59
+ pid: number;
60
+ }
61
+ export type AppendResult = {
62
+ ok: true;
63
+ record: SessionRecord;
64
+ path: string;
65
+ } | {
66
+ ok: false;
67
+ error: string;
68
+ };
69
+ /**
70
+ * Project a sealed contract onto the ledger's contract shape (drops `goal_id`,
71
+ * keeps only defined fields). `null` in, `null` out.
72
+ */
73
+ export declare function toLedgerContract(sealed: SealedContract | null): SessionRecordContract | null;
74
+ /** The ledger's path under `dir`. */
75
+ export declare function ledgerPath(dir: string): string;
76
+ /**
77
+ * Append one launch to the ledger, atomically and capped:
78
+ * - `dir` is created 0o700 when missing; the file is created 0o600;
79
+ * - the new line is appended when the ledger stays within LEDGER_CAP lines,
80
+ * otherwise the newest LEDGER_CAP lines (this one included) are rewritten
81
+ * via a temp file + rename, so a reader never sees a torn file.
82
+ * Never throws — a failure is returned as `{ ok: false, error }`.
83
+ */
84
+ export declare function appendSessionRecord(input: SessionRecordInput, deps: LedgerDeps): AppendResult;
85
+ /**
86
+ * Read the ledger, oldest first (file order). A missing file is an empty
87
+ * ledger; a line that is not a v1 record object is skipped, never fatal.
88
+ */
89
+ export declare function readSessionRecords(deps: Pick<LedgerDeps, "dir">): SessionRecord[];
90
+ /** One ledger line → a record, or null when it is not a v1 record object. */
91
+ export declare function parseRecord(line: string): SessionRecord | null;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The local session ledger for `mnemom agent` — `~/.mnemom/sessions.jsonl`, one
3
+ * JSON line per REAL launch (never `--dry-run`), newest last.
4
+ *
5
+ * Pure module: the directory, the clock and the pid are injected so the append
6
+ * and the read are unit-testable with a temp dir. The record is deliberately a
7
+ * closed shape — the Anthropic key, the custom-headers line and the child env
8
+ * are never part of it (see `SessionRecord`), so nothing secret can ride along.
9
+ *
10
+ * Writes are best-effort: `appendSessionRecord` never throws — a failure comes
11
+ * back as `{ ok: false, error }` and the caller decides how loudly to warn.
12
+ */
13
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ /** The ledger's file name under MNEMOM_DIR. */
16
+ export const LEDGER_FILE = "sessions.jsonl";
17
+ /** The ledger keeps only the newest this-many launches. */
18
+ export const LEDGER_CAP = 200;
19
+ /**
20
+ * Project a sealed contract onto the ledger's contract shape (drops `goal_id`,
21
+ * keeps only defined fields). `null` in, `null` out.
22
+ */
23
+ export function toLedgerContract(sealed) {
24
+ if (!sealed)
25
+ return null;
26
+ const out = { statement: sealed.statement };
27
+ if (sealed.requirements !== undefined)
28
+ out.requirements = sealed.requirements;
29
+ if (sealed.allowed_paths !== undefined)
30
+ out.allowed_paths = sealed.allowed_paths;
31
+ if (sealed.forbidden_paths !== undefined)
32
+ out.forbidden_paths = sealed.forbidden_paths;
33
+ if (sealed.max_turns !== undefined)
34
+ out.max_turns = sealed.max_turns;
35
+ if (sealed.budget_usd !== undefined)
36
+ out.budget_usd = sealed.budget_usd;
37
+ if (sealed.stall_turns !== undefined)
38
+ out.stall_turns = sealed.stall_turns;
39
+ return out;
40
+ }
41
+ /** The ledger's path under `dir`. */
42
+ export function ledgerPath(dir) {
43
+ return join(dir, LEDGER_FILE);
44
+ }
45
+ /**
46
+ * Read the ledger text, or null when the file does not exist. Read-and-catch
47
+ * rather than exists-then-read so there is no window in which the file can
48
+ * change between the check and the read (CodeQL js/file-system-race).
49
+ */
50
+ function readLedgerText(path) {
51
+ try {
52
+ return readFileSync(path, "utf8");
53
+ }
54
+ catch (err) {
55
+ if (err.code === "ENOENT")
56
+ return null;
57
+ throw err;
58
+ }
59
+ }
60
+ /**
61
+ * Append one launch to the ledger, atomically and capped:
62
+ * - `dir` is created 0o700 when missing; the file is created 0o600;
63
+ * - the new line is appended when the ledger stays within LEDGER_CAP lines,
64
+ * otherwise the newest LEDGER_CAP lines (this one included) are rewritten
65
+ * via a temp file + rename, so a reader never sees a torn file.
66
+ * Never throws — a failure is returned as `{ ok: false, error }`.
67
+ */
68
+ export function appendSessionRecord(input, deps) {
69
+ const path = ledgerPath(deps.dir);
70
+ try {
71
+ const record = { v: 1, started_at: deps.now().toISOString(), ...input };
72
+ mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
73
+ const line = `${JSON.stringify(record)}\n`;
74
+ const text = readLedgerText(path);
75
+ const existing = text === null ? [] : nonEmptyLines(text);
76
+ if (existing.length + 1 > LEDGER_CAP) {
77
+ const kept = [...existing, JSON.stringify(record)].slice(-LEDGER_CAP);
78
+ const tmp = join(deps.dir, `.${LEDGER_FILE}.tmp-${deps.pid}-${deps.now().getTime()}`);
79
+ writeFileSync(tmp, `${kept.join("\n")}\n`, { mode: 0o600 });
80
+ renameSync(tmp, path);
81
+ }
82
+ else {
83
+ appendFileSync(path, line, { mode: 0o600 });
84
+ }
85
+ return { ok: true, record, path };
86
+ }
87
+ catch (err) {
88
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
89
+ }
90
+ }
91
+ /**
92
+ * Read the ledger, oldest first (file order). A missing file is an empty
93
+ * ledger; a line that is not a v1 record object is skipped, never fatal.
94
+ */
95
+ export function readSessionRecords(deps) {
96
+ const path = ledgerPath(deps.dir);
97
+ const text = readLedgerText(path);
98
+ if (text === null)
99
+ return [];
100
+ const out = [];
101
+ for (const line of nonEmptyLines(text)) {
102
+ const parsed = parseRecord(line);
103
+ if (parsed)
104
+ out.push(parsed);
105
+ }
106
+ return out;
107
+ }
108
+ /** One ledger line → a record, or null when it is not a v1 record object. */
109
+ export function parseRecord(line) {
110
+ let value;
111
+ try {
112
+ value = JSON.parse(line);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ if (!value || typeof value !== "object" || Array.isArray(value))
118
+ return null;
119
+ const rec = value;
120
+ if (rec.v !== 1)
121
+ return null;
122
+ return rec;
123
+ }
124
+ function nonEmptyLines(text) {
125
+ return text.split("\n").filter((l) => l.trim().length > 0);
126
+ }
@@ -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/api.d.ts CHANGED
@@ -1219,6 +1219,68 @@ export declare const CODE_NOT_ENABLED = "code_not_enabled";
1219
1219
  * for membership/transport failures.
1220
1220
  */
1221
1221
  export declare function getCodeEntitlement(orgId?: string): Promise<CodeEntitlement>;
1222
+ /** The gateway's sealed contract for a conversation (absent arrays are `[]`). */
1223
+ export interface AgentSessionSealed {
1224
+ contract: {
1225
+ goal_id?: string;
1226
+ statement: string;
1227
+ requirements: string[];
1228
+ allowed_paths: string[];
1229
+ forbidden_paths: string[];
1230
+ max_turns?: number;
1231
+ budget_usd?: number;
1232
+ stall_turns?: number;
1233
+ };
1234
+ /** The gateway's hash of the sealed contract — NOT the CLI's launch-time sha. */
1235
+ hash: string;
1236
+ sealed_at: string;
1237
+ }
1238
+ /** The latest goal-judge verdict stored for the conversation. */
1239
+ export interface AgentSessionVerdict {
1240
+ turn: number;
1241
+ verdict: "aligned" | "drifting" | "violation";
1242
+ reason: string;
1243
+ at: string;
1244
+ streak_start?: number;
1245
+ }
1246
+ export interface AgentSessionGuardrails {
1247
+ ceilings: {
1248
+ /** null = inactive */
1249
+ max_turns: number | null;
1250
+ budget_usd: number | null;
1251
+ stall_turns: number | null;
1252
+ };
1253
+ state: {
1254
+ turns: number;
1255
+ spend_usd: number;
1256
+ stall_run: number;
1257
+ fired: {
1258
+ turns?: boolean;
1259
+ budget?: boolean;
1260
+ stall?: boolean;
1261
+ };
1262
+ } | null;
1263
+ }
1264
+ /** `GET /v1/agent/sessions/{conversationId}` 200 body. Nulls mean "nothing stored" — absence is data. */
1265
+ export interface AgentSessionResponse {
1266
+ conversation_id: string;
1267
+ agent: {
1268
+ id: string;
1269
+ name: string | null;
1270
+ };
1271
+ org_id: string;
1272
+ sealed: AgentSessionSealed | null;
1273
+ verdict: AgentSessionVerdict | null;
1274
+ turn: number | null;
1275
+ guardrails: AgentSessionGuardrails | null;
1276
+ }
1277
+ /**
1278
+ * The live goal state of one `mnemom agent` session, read through mnemom-api
1279
+ * (org-scoped; the api proxies the gateway's internal read). Non-OK responses
1280
+ * keep the server's `code` + `details` (`agent_not_found`, `gateway_unavailable`)
1281
+ * so `mnemom agent show` can name the reason.
1282
+ */
1283
+ export declare function getAgentSession(conversationId: string, agentId: string, orgId: string): Promise<AgentSessionResponse>;
1222
1284
  /** `POST /v1/invites/accept` 200 body. Never carries an amount — only whether the
1223
1285
  * starter grant fired or was deferred for a later backfill. */
1224
1286
  export interface AcceptProductInviteResult {
package/dist/lib/api.js CHANGED
@@ -1538,6 +1538,17 @@ export async function getCodeEntitlement(orgId) {
1538
1538
  const q = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
1539
1539
  return codeFetch(`/v1/code/entitlement${q}`, {}, "Could not check Mnemom Agent access");
1540
1540
  }
1541
+ /**
1542
+ * The live goal state of one `mnemom agent` session, read through mnemom-api
1543
+ * (org-scoped; the api proxies the gateway's internal read). Non-OK responses
1544
+ * keep the server's `code` + `details` (`agent_not_found`, `gateway_unavailable`)
1545
+ * so `mnemom agent show` can name the reason.
1546
+ */
1547
+ export async function getAgentSession(conversationId, agentId, orgId) {
1548
+ // `org_id` is the org the launch resolved (the ledger record's), so an agent that
1549
+ // lives in a non-primary org resolves instead of 404ing against the caller's primary org.
1550
+ return codeFetch(`/v1/agent/sessions/${encodeURIComponent(conversationId)}?agent_id=${encodeURIComponent(agentId)}&org_id=${encodeURIComponent(orgId)}`, {}, "Could not read the session's live state");
1551
+ }
1541
1552
  /** Redeem a `pinv_…` product invite (a Mnemom Agent invitation) for the signed-in user. */
1542
1553
  export async function acceptProductInvite(token) {
1543
1554
  return codeFetch("/v1/invites/accept", { method: "POST", body: JSON.stringify({ token }) }, "Could not redeem the invitation");
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)
@@ -37,3 +37,9 @@ export declare const fmt: {
37
37
  */
38
38
  badge(text: string, color?: BadgeColor): string;
39
39
  };
40
+ /**
41
+ * Human-friendly age of an ISO timestamp relative to now — "<1m ago", "42m ago",
42
+ * "3h 5m ago", "2d 4h ago"; a timestamp ahead of the clock reads "in the future?".
43
+ * Shared by `mnemom validate` and `mnemom agent sessions` / `show`.
44
+ */
45
+ export declare function formatRelativeAge(startedAt: string): string;
@@ -64,3 +64,23 @@ export const fmt = {
64
64
  return colorFn(`[${text}]`);
65
65
  },
66
66
  };
67
+ /**
68
+ * Human-friendly age of an ISO timestamp relative to now — "<1m ago", "42m ago",
69
+ * "3h 5m ago", "2d 4h ago"; a timestamp ahead of the clock reads "in the future?".
70
+ * Shared by `mnemom validate` and `mnemom agent sessions` / `show`.
71
+ */
72
+ export function formatRelativeAge(startedAt) {
73
+ const ageMs = Date.now() - Date.parse(startedAt);
74
+ if (ageMs < 0)
75
+ return "in the future?";
76
+ const m = Math.floor(ageMs / 60_000);
77
+ if (m < 1)
78
+ return "<1m ago";
79
+ if (m < 60)
80
+ return `${m}m ago`;
81
+ const h = Math.floor(m / 60);
82
+ if (h < 24)
83
+ return `${h}h ${m % 60}m ago`;
84
+ const d = Math.floor(h / 24);
85
+ return `${d}d ${h % 24}h ago`;
86
+ }