@mnemom/mnemom 0.17.15 → 0.17.16

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
@@ -67,10 +67,41 @@ header. In implicit mode the goal lags your steering by one request: a response
67
67
  message carries `x-mnemom-goal-steer: queued` and the revision the agent acted under;
68
68
  the next request reads the new revision (`x-mnemom-goal-revision`).
69
69
 
70
+ A sealed contract takes no in-session steering: nothing said inside the session (a lead's
71
+ direction included) changes what the judge measures against — a lead's direction must
72
+ already be a requirement in the seal. `--lead-directed` seals the standard line for a
73
+ lead-directed agent (e.g. an orchestrating agent that ships the work it was given), so its
74
+ own-branch pushes and draft/ready PR
75
+ creation are read against the contract instead of flagged as an unrequested effect:
76
+
77
+ ```
78
+ mnemom agent --goal "ship the lane's cards" --lead-directed
79
+ ```
80
+
81
+ Requires `--goal`; appends the line at most once, however many times the flag (or the same
82
+ requirement text given verbatim via `--requirement`) appears. It does not permit merging.
83
+
70
84
  Make one mode the standing default with `mnemom agent config set goal_mode implicit`
71
85
  (or `off`); `--goal` still seals for that launch and `--no-implicit` turns it off for one
72
86
  launch. A sealed contract needs a per-launch statement, so it cannot be the default.
73
87
 
88
+ #### Watching a session: `mnemom agent show [ref] --follow`
89
+
90
+ `mnemom agent show` prints a session's latest state — the sealed contract, the current
91
+ verdict, guardrails, and (under `--json`) the last 50 judge verdicts. Add `--follow` to
92
+ keep the command running and print each new verdict as it lands, instead of re-running
93
+ `show` yourself:
94
+
95
+ ```bash
96
+ mnemom agent show --follow # watch the newest session
97
+ mnemom agent show my-task --follow --json # one JSON object per new verdict (NDJSON)
98
+ ```
99
+
100
+ It polls every 3 seconds by default; override with `--follow-interval <seconds>` for one
101
+ run, or `MNEMOM_AGENT_FOLLOW_INTERVAL=<seconds>` for every run in a shell. It prints each
102
+ verdict once — never a repeat on a later poll — and stops on Ctrl-C, or once the session
103
+ is no longer available.
104
+
74
105
  #### Orchestrated launches: `MNEMOM_AGENT_PREFLIGHTED=1`
75
106
 
76
107
  An orchestrator (the ADW fleet) that already checked sign-in and the µ balance before
@@ -53,8 +53,22 @@ export declare function assertCodeEnabled(orgId: string, orgLabel: string): Prom
53
53
  export type EnsureSession = (opts: {
54
54
  interactive?: boolean;
55
55
  }) => Promise<boolean>;
56
- /** Map an accept failure to the sentence the customer needs. Null = not ours to explain. */
57
- export declare function explainAcceptError(err: unknown): string | null;
56
+ /** The accept route's 403 codes (mnemom-api `product-invite-accept.ts`). */
57
+ export declare const INVITE_ACCOUNT_MISMATCH = "invite_account_mismatch";
58
+ export declare const INVITE_EMAIL_MISMATCH = "invite_email_mismatch";
59
+ /** The account's email must be confirmed first. The accept route redeems for a matching
60
+ * unconfirmed email today, but the code (and the api's general `email_not_verified`) is
61
+ * still voiced as what it is, never as "a different account". */
62
+ export declare const INVITE_EMAIL_UNVERIFIED = "invite_email_unverified";
63
+ export declare const EMAIL_NOT_VERIFIED = "email_not_verified";
64
+ /**
65
+ * Map an accept failure to the sentence the customer needs. Null = not ours to explain.
66
+ * `signedInAs` (the account's email, when known) is named in the 403 sentences so the
67
+ * customer can see which account the CLI is using.
68
+ */
69
+ export declare function explainAcceptError(err: unknown, signedInAs?: string | null): string | null;
70
+ /** The signed-in account's email, or null when it can't be read (never throws). */
71
+ export type SignedInEmail = () => Promise<string | null>;
58
72
  /** Print the outcome of a successful accept. */
59
73
  export declare function printAccepted(res: AcceptProductInviteResult): void;
60
74
  /**
@@ -65,6 +79,7 @@ export declare function printAccepted(res: AcceptProductInviteResult): void;
65
79
  export declare function agentInviteCommand(token: string | undefined, opts?: {
66
80
  interactive?: boolean;
67
81
  ensureSession?: EnsureSession;
82
+ signedInEmail?: SignedInEmail;
68
83
  }): Promise<AcceptProductInviteResult>;
69
84
  export interface AgentInvocation {
70
85
  /** The positional `[scenario]` (may be a reserved sub-verb such as "invite"). */
@@ -19,7 +19,7 @@
19
19
  * All narration goes to stderr (the `mnemom agent` convention — stdout is for
20
20
  * the dry-run plan only). Copy says "µ", never "MU".
21
21
  */
22
- import { acceptProductInvite, CODE_NOT_ENABLED, getCodeEntitlement, MnemomApiError, } from "../lib/api.js";
22
+ import { acceptProductInvite, CODE_NOT_ENABLED, getCodeEntitlement, getMe, MnemomApiError, } from "../lib/api.js";
23
23
  import { fmt } from "../lib/format.js";
24
24
  import { isInteractive } from "../lib/prompt.js";
25
25
  const say = (line = "") => console.error(line);
@@ -123,8 +123,38 @@ async function defaultEnsureSession(opts) {
123
123
  const { ensureMnemomSession } = await import("./agent-setup.js");
124
124
  return ensureMnemomSession(opts);
125
125
  }
126
- /** Map an accept failure to the sentence the customer needs. Null = not ours to explain. */
127
- export function explainAcceptError(err) {
126
+ /** The accept route's 403 codes (mnemom-api `product-invite-accept.ts`). */
127
+ export const INVITE_ACCOUNT_MISMATCH = "invite_account_mismatch";
128
+ export const INVITE_EMAIL_MISMATCH = "invite_email_mismatch";
129
+ /** The account's email must be confirmed first. The accept route redeems for a matching
130
+ * unconfirmed email today, but the code (and the api's general `email_not_verified`) is
131
+ * still voiced as what it is, never as "a different account". */
132
+ export const INVITE_EMAIL_UNVERIFIED = "invite_email_unverified";
133
+ export const EMAIL_NOT_VERIFIED = "email_not_verified";
134
+ /** ` (email)` for the signed-in account, or "" when it could not be read. */
135
+ function signedInLabel(signedInAs) {
136
+ return signedInAs ? ` (${sanitizeTerminalText(signedInAs)})` : "";
137
+ }
138
+ /** A 403 from the accept route: which one it is decides what the customer can do. */
139
+ function explainForbidden(err, signedInAs) {
140
+ const who = signedInLabel(signedInAs);
141
+ switch (err.code) {
142
+ case INVITE_EMAIL_UNVERIFIED:
143
+ case EMAIL_NOT_VERIFIED:
144
+ return `Your Mnemom account email${who} isn't confirmed yet. Open the confirmation email Mnemom sent to that address, then run the same command again.`;
145
+ case INVITE_EMAIL_MISMATCH:
146
+ case INVITE_ACCOUNT_MISMATCH:
147
+ return `This invitation was sent to a different email than the account you're signed in with${who}. Sign in with the address it was sent to (mnemom logout, then mnemom login), then run the same command again.`;
148
+ default:
149
+ return `The invitation couldn't be redeemed for the account you're signed in with${who}: ${sanitizeTerminalText(err.message)}`;
150
+ }
151
+ }
152
+ /**
153
+ * Map an accept failure to the sentence the customer needs. Null = not ours to explain.
154
+ * `signedInAs` (the account's email, when known) is named in the 403 sentences so the
155
+ * customer can see which account the CLI is using.
156
+ */
157
+ export function explainAcceptError(err, signedInAs) {
128
158
  if (!(err instanceof MnemomApiError))
129
159
  return null;
130
160
  switch (err.effectiveStatus) {
@@ -133,13 +163,25 @@ export function explainAcceptError(err) {
133
163
  case 410:
134
164
  return "This invitation has expired or was revoked — ask your Mnemom contact for a new one.";
135
165
  case 403:
136
- return "This invitation was issued to a different account — sign in with the email it was sent to (mnemom logout, then mnemom login).";
166
+ return explainForbidden(err, signedInAs);
137
167
  case 409:
138
168
  return "The invitation changed while redeeming — run the command again.";
139
169
  default:
140
170
  return null;
141
171
  }
142
172
  }
173
+ async function defaultSignedInEmail() {
174
+ try {
175
+ return (await getMe()).email ?? null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ /** The email to name in an accept error — looked up only for a 403, where it matters. */
182
+ async function signedInFor(err, lookup) {
183
+ return err instanceof MnemomApiError && err.effectiveStatus === 403 ? lookup() : null;
184
+ }
143
185
  /** Print the outcome of a successful accept. */
144
186
  export function printAccepted(res) {
145
187
  if (res.already_accepted) {
@@ -180,7 +222,8 @@ export async function agentInviteCommand(token, opts = {}) {
180
222
  result = await acceptProductInvite(trimmed);
181
223
  }
182
224
  catch (err) {
183
- const explained = explainAcceptError(err);
225
+ const signedInAs = await signedInFor(err, opts.signedInEmail ?? defaultSignedInEmail);
226
+ const explained = explainAcceptError(err, signedInAs);
184
227
  if (explained)
185
228
  throw new Error(`mnemom agent --invite: ${explained}`, { cause: err });
186
229
  throw err;
@@ -14,7 +14,7 @@
14
14
  * only the server's hash is shown, labelled "sealed hash".
15
15
  */
16
16
  import { type GoalMode, type SessionRecord } from "../lib/agent-ledger.js";
17
- import { type AgentSessionResponse } from "../lib/api.js";
17
+ import { type AgentSessionResponse, type AgentSessionVerdictTrailEntry } from "../lib/api.js";
18
18
  import { type GoalShowResponse } from "../lib/goal-api.js";
19
19
  export interface SessionCommandDeps {
20
20
  /** The ledger, newest first. */
@@ -33,6 +33,20 @@ export interface SessionCommandDeps {
33
33
  cellGateway?: () => string;
34
34
  out: (line: string) => void;
35
35
  err: (line: string) => void;
36
+ /**
37
+ * `--follow`: sleep for `ms` between polls. Injected so tests can drive the loop without a
38
+ * real timer. Defaults to a real `setTimeout` — deliberately NOT unref'd: `--follow` is a
39
+ * foreground watch loop invoked explicitly by the user, and an unref'd timer does not keep
40
+ * Node's event loop alive, so the process would exit right after the one-shot snapshot
41
+ * instead of polling (reproduced standalone: a loop of unref'd-timer sleeps prints zero
42
+ * iterations because nothing else is pending).
43
+ */
44
+ sleep?: (ms: number) => Promise<void>;
45
+ /**
46
+ * `--follow`: register a one-shot SIGINT handler, returning an unregister function. Injected so
47
+ * tests can simulate Ctrl-C without sending a real signal. Defaults to `process.once("SIGINT")`.
48
+ */
49
+ onSigint?: (handler: () => void) => () => void;
36
50
  }
37
51
  /** The production wiring (index.ts). Lazy so a test that mocks lib/api without
38
52
  * this export can still import index.ts. */
@@ -82,6 +96,41 @@ export declare function formatSessionsTable(rows: SessionRecord[], opts: {
82
96
  }): string[];
83
97
  /** `turns 3/10 (crossed)` — value against its ceiling (none ⇒ just the value). */
84
98
  export declare function gauge(label: string, value: number, ceiling: number | null, fired: boolean | undefined): string;
99
+ /**
100
+ * One verdict-trail entry, human-readable — shared by the one-shot trail section in
101
+ * `buildShowView` and by `--follow`'s streamed lines, so the two never drift apart.
102
+ */
103
+ export declare function formatTrailEntry(e: AgentSessionVerdictTrailEntry): string;
104
+ /**
105
+ * The verdict trail is a bounded 50-entry ring: once it wraps, new entries at one end push
106
+ * old ones out, so an array index does not name the same verdict across two polls. The stable
107
+ * identity is the (request id, turn) pair the gateway recorded it under — `request_id` is
108
+ * null-able (the judge can run without one), joined with a separator that can't collide with a
109
+ * turn number.
110
+ */
111
+ export declare function verdictTrailKey(e: Pick<AgentSessionVerdictTrailEntry, "request_id" | "turn">): string;
112
+ /**
113
+ * Best-effort "has this session ended" check for `--follow`'s exit condition. No field is locked
114
+ * in on the wire yet: MNE-8149 adds an `expired` seal state on the gateway's row, and a later
115
+ * mnemom-api card is expected to carry it through to this response — so this reads defensively,
116
+ * off either the top-level response or the `sealed` block, and returns null (never throws) when
117
+ * neither carries a recognized value, which is every server today.
118
+ */
119
+ export declare function sessionEndedState(remote: AgentSessionResponse): "expired" | "ended" | null;
120
+ /** Default and floor for `--follow`'s poll interval. */
121
+ export declare const DEFAULT_FOLLOW_INTERVAL_MS = 3000;
122
+ export declare const MIN_FOLLOW_INTERVAL_MS = 500;
123
+ /** Seconds; consulted only when `--follow-interval` was not passed. */
124
+ export declare const FOLLOW_INTERVAL_ENV_VAR = "MNEMOM_AGENT_FOLLOW_INTERVAL";
125
+ /** Resolve the poll interval: explicit opt > env override (seconds) > default, floored. */
126
+ export declare function resolveFollowIntervalMs(opts: {
127
+ followIntervalMs?: number;
128
+ }): number;
129
+ export declare function followVerdictTrail(record: SessionRecord, agentId: string, deps: SessionCommandDeps, opts: {
130
+ json?: boolean;
131
+ intervalMs: number;
132
+ seenKeys: Set<string>;
133
+ }): Promise<void>;
85
134
  /**
86
135
  * The human view for `mnemom agent show`: the local record, then the live
87
136
  * gateway state (or a dim "live state unavailable (<reason>)" line when
@@ -101,6 +150,8 @@ export interface ShowJson {
101
150
  verdict: AgentSessionResponse["verdict"];
102
151
  turn: AgentSessionResponse["turn"];
103
152
  guardrails: AgentSessionResponse["guardrails"];
153
+ /** R10 (MNE-8200): oldest first (the gateway's own wire order), unlike the human view. */
154
+ verdict_trail: AgentSessionResponse["verdict_trail"];
104
155
  } | {
105
156
  source: "gateway";
106
157
  available: false;
@@ -116,6 +167,10 @@ export interface AgentSessionsOptions {
116
167
  export declare function agentSessionsCommand(opts: AgentSessionsOptions, deps: SessionCommandDeps): Promise<void>;
117
168
  export interface AgentShowOptions {
118
169
  json?: boolean;
170
+ /** Keep watching after the snapshot and print each new verdict as it lands. */
171
+ follow?: boolean;
172
+ /** `--follow`'s poll interval, in ms. Undefined ⇒ {@link resolveFollowIntervalMs}'s default/env. */
173
+ followIntervalMs?: number;
119
174
  }
120
175
  /** Why the live read could not happen, from the thrown error. */
121
176
  export declare function unavailableReason(err: unknown): string;
@@ -16,7 +16,7 @@
16
16
  import { readSessionRecords } from "../lib/agent-ledger.js";
17
17
  import { getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
18
18
  import { resumeCommand, sameOrigin } from "../lib/agent.js";
19
- import { getAgentSession, MnemomApiError } from "../lib/api.js";
19
+ import { getAgentSession, MnemomApiError, } from "../lib/api.js";
20
20
  import { fmt, formatRelativeAge } from "../lib/format.js";
21
21
  import { goalControlFetch } from "../lib/goal-api.js";
22
22
  /** The production wiring (index.ts). Lazy so a test that mocks lib/api without
@@ -35,6 +35,15 @@ export const liveSessionDeps = {
35
35
  cellGateway: () => getGatewayUrl(),
36
36
  out: (line) => console.log(line),
37
37
  err: (line) => console.error(line),
38
+ // NOT unref'd: see SessionCommandDeps.sleep's doc comment above — this timer must keep the
39
+ // process alive, or --follow exits right after the one-shot snapshot instead of polling.
40
+ sleep: (ms) => new Promise((resolve) => {
41
+ setTimeout(resolve, ms);
42
+ }),
43
+ onSigint: (handler) => {
44
+ process.once("SIGINT", handler);
45
+ return () => process.removeListener("SIGINT", handler);
46
+ },
38
47
  };
39
48
  export const NO_SESSIONS_MESSAGE = "No sessions yet — run `mnemom agent` to start one.";
40
49
  export const DEFAULT_SESSIONS_LIMIT = 20;
@@ -162,6 +171,130 @@ export function gauge(label, value, ceiling, fired) {
162
171
  const ratio = ceiling === null ? String(value) : `${value}/${ceiling}`;
163
172
  return `${label} ${ratio}${fired ? " (crossed)" : ""}`;
164
173
  }
174
+ /**
175
+ * One verdict-trail entry, human-readable — shared by the one-shot trail section in
176
+ * `buildShowView` and by `--follow`'s streamed lines, so the two never drift apart.
177
+ */
178
+ export function formatTrailEntry(e) {
179
+ const fence = e.fence_breach === null ? "" : e.fence_breach ? " [fence breach]" : "";
180
+ return `turn ${e.turn} ${e.verdict.toUpperCase()} — ${e.reason}${fence} (${e.at})`;
181
+ }
182
+ /**
183
+ * The verdict trail is a bounded 50-entry ring: once it wraps, new entries at one end push
184
+ * old ones out, so an array index does not name the same verdict across two polls. The stable
185
+ * identity is the (request id, turn) pair the gateway recorded it under — `request_id` is
186
+ * null-able (the judge can run without one), joined with a separator that can't collide with a
187
+ * turn number.
188
+ */
189
+ export function verdictTrailKey(e) {
190
+ return `${e.request_id ?? ""}#${e.turn}`;
191
+ }
192
+ /**
193
+ * Best-effort "has this session ended" check for `--follow`'s exit condition. No field is locked
194
+ * in on the wire yet: MNE-8149 adds an `expired` seal state on the gateway's row, and a later
195
+ * mnemom-api card is expected to carry it through to this response — so this reads defensively,
196
+ * off either the top-level response or the `sealed` block, and returns null (never throws) when
197
+ * neither carries a recognized value, which is every server today.
198
+ */
199
+ export function sessionEndedState(remote) {
200
+ const as = (v) => v === "expired" || v === "ended" ? v : null;
201
+ const top = as(remote.state);
202
+ if (top)
203
+ return top;
204
+ if (remote.sealed) {
205
+ return as(remote.sealed.state);
206
+ }
207
+ return null;
208
+ }
209
+ /** Default and floor for `--follow`'s poll interval. */
210
+ export const DEFAULT_FOLLOW_INTERVAL_MS = 3000;
211
+ export const MIN_FOLLOW_INTERVAL_MS = 500;
212
+ /** Seconds; consulted only when `--follow-interval` was not passed. */
213
+ export const FOLLOW_INTERVAL_ENV_VAR = "MNEMOM_AGENT_FOLLOW_INTERVAL";
214
+ /** Resolve the poll interval: explicit opt > env override (seconds) > default, floored. */
215
+ export function resolveFollowIntervalMs(opts) {
216
+ if (opts.followIntervalMs !== undefined) {
217
+ return Math.max(MIN_FOLLOW_INTERVAL_MS, opts.followIntervalMs);
218
+ }
219
+ const envSeconds = Number(process.env[FOLLOW_INTERVAL_ENV_VAR]);
220
+ if (Number.isFinite(envSeconds) && envSeconds > 0) {
221
+ return Math.max(MIN_FOLLOW_INTERVAL_MS, envSeconds * 1000);
222
+ }
223
+ return DEFAULT_FOLLOW_INTERVAL_MS;
224
+ }
225
+ /**
226
+ * `--follow`: after the one-shot snapshot, keep polling the same live read-back
227
+ * (`deps.fetchSession`, the same `/internal/goal/session` route `show` already uses) and print
228
+ * each newly-landed verdict-trail entry once — a `tail -f` over the gateway's 50-entry ring.
229
+ * `opts.seenKeys` seeds the baseline (every entry already visible in the one-shot print); only
230
+ * entries whose {@link verdictTrailKey} is not yet in that set are printed, so a poll never
231
+ * repeats a verdict the ring-wrap could otherwise make look "new" by index.
232
+ *
233
+ * Exits (resolves) on SIGINT, or once {@link sessionEndedState} reads `"expired"`/`"ended"`, or
234
+ * once the gateway reports the session gone (`MnemomApiError` with `code === "agent_not_found"`).
235
+ * An auth failure (401/403 — an expired or revoked key) is NOT transient: it prints a clear
236
+ * error via `deps.err` and exits (`process.exitCode = 1`) rather than retrying forever. Any other
237
+ * fetch failure — including the documented `gateway_unavailable` — is treated as transient: the
238
+ * loop backs off and retries, and after {@link FOLLOW_FAILURE_WARNING_THRESHOLD} consecutive
239
+ * failures prints exactly one dim warning line to stderr (not one per failure) so a persistently
240
+ * broken poll isn't silent, then keeps retrying; a single success resets the counter.
241
+ */
242
+ const FOLLOW_FAILURE_WARNING_THRESHOLD = 5;
243
+ function isAuthError(e) {
244
+ return e instanceof MnemomApiError && (e.effectiveStatus === 401 || e.effectiveStatus === 403);
245
+ }
246
+ export async function followVerdictTrail(record, agentId, deps, opts) {
247
+ // NOT unref'd (see SessionCommandDeps.sleep's doc comment) — this is the foreground poll
248
+ // delay for --follow, and it must keep the process alive between polls.
249
+ const sleep = deps.sleep ??
250
+ ((ms) => new Promise((resolve) => {
251
+ setTimeout(resolve, ms);
252
+ }));
253
+ const seen = opts.seenKeys;
254
+ let stopped = false;
255
+ let consecutiveFailures = 0;
256
+ const unregister = deps.onSigint?.(() => {
257
+ stopped = true;
258
+ });
259
+ try {
260
+ while (!stopped) {
261
+ await sleep(opts.intervalMs);
262
+ if (stopped)
263
+ break;
264
+ let remote;
265
+ try {
266
+ remote = await deps.fetchSession(record.conversation_id, agentId, record.org_id);
267
+ }
268
+ catch (e) {
269
+ if (e instanceof MnemomApiError && e.code === "agent_not_found")
270
+ return;
271
+ if (isAuthError(e)) {
272
+ deps.err(`Error: the gateway rejected the credential (${e.effectiveStatus}) — this key may be expired or revoked: ${e.message}`);
273
+ process.exitCode = 1;
274
+ return;
275
+ }
276
+ consecutiveFailures += 1;
277
+ if (consecutiveFailures === FOLLOW_FAILURE_WARNING_THRESHOLD) {
278
+ deps.err(fmt.dim(`--follow: ${consecutiveFailures} consecutive polls have failed; still retrying…`));
279
+ }
280
+ continue; // transient (gateway_unavailable or anything else) — back off and retry
281
+ }
282
+ consecutiveFailures = 0;
283
+ for (const entry of remote.verdict_trail ?? []) {
284
+ const key = verdictTrailKey(entry);
285
+ if (seen.has(key))
286
+ continue;
287
+ seen.add(key);
288
+ deps.out(opts.json ? JSON.stringify(entry) : formatTrailEntry(entry));
289
+ }
290
+ if (sessionEndedState(remote))
291
+ return;
292
+ }
293
+ }
294
+ finally {
295
+ unregister?.();
296
+ }
297
+ }
165
298
  /**
166
299
  * The human view for `mnemom agent show`: the local record, then the live
167
300
  * gateway state (or a dim "live state unavailable (<reason>)" line when
@@ -223,6 +356,15 @@ export function buildShowView(local, remote, reason) {
223
356
  else {
224
357
  L.push(fmt.label("verdict: ", fmt.dim("none yet")));
225
358
  }
359
+ // R10 (MNE-8200): the last up-to-50 judged verdicts. The gateway returns them oldest first;
360
+ // shown here newest first (most recent verdict closest to the cursor) — the reverse of the
361
+ // wire order, so don't reuse `remote.verdict_trail`'s index as the turn number.
362
+ if (remote.verdict_trail && remote.verdict_trail.length > 0) {
363
+ L.push(fmt.label("verdict trail: ", `last ${remote.verdict_trail.length} (newest first)`));
364
+ for (const e of [...remote.verdict_trail].reverse()) {
365
+ L.push(fmt.label(" ", formatTrailEntry(e)));
366
+ }
367
+ }
226
368
  if (remote.guardrails) {
227
369
  const { ceilings, state } = remote.guardrails;
228
370
  if (state) {
@@ -257,6 +399,7 @@ export function buildShowJson(local, remote, reason) {
257
399
  verdict: remote.verdict,
258
400
  turn: remote.turn,
259
401
  guardrails: remote.guardrails,
402
+ verdict_trail: remote.verdict_trail,
260
403
  }
261
404
  : { source: "gateway", available: false, reason: reason ?? "unknown" },
262
405
  };
@@ -300,6 +443,22 @@ export function liveGoalLine(body) {
300
443
  const cut = statement.slice(0, LIVE_GOAL_STATEMENT_MAX - 1).trimEnd();
301
444
  return `${rev} — "${cut}…" (full: mnemom agent goal show)`;
302
445
  }
446
+ /**
447
+ * Can this session's live state (and hence `--follow`) ever be read? Two cases never resolve
448
+ * regardless of retries — no agent id was recorded at launch, or the session ran on a different
449
+ * cell than this CLI is pointed at — so they're resolved once, up front, as a named reason rather
450
+ * than inside the fetch's catch (where a genuinely transient failure also lands).
451
+ */
452
+ function resolveFollowTarget(record, cell) {
453
+ if (record.agent_id === null)
454
+ return { reason: "agent id was not resolved at launch" };
455
+ if (cell !== undefined && !sameOrigin(record.gateway_host, cell)) {
456
+ return {
457
+ reason: `this session ran on ${record.gateway_host}, not this CLI's cell (${cell}) — point the CLI at that cell to read it`,
458
+ };
459
+ }
460
+ return { agentId: record.agent_id };
461
+ }
303
462
  /** `mnemom agent show [ref] [--json]` */
304
463
  export async function agentShowCommand(ref, opts, deps) {
305
464
  const records = deps.readRecords();
@@ -325,18 +484,16 @@ export async function agentShowCommand(ref, opts, deps) {
325
484
  return;
326
485
  }
327
486
  const { record, others } = resolved;
487
+ const cell = deps.cellGateway?.();
488
+ const target = resolveFollowTarget(record, cell);
328
489
  let remote = null;
329
490
  let reason;
330
- const cell = deps.cellGateway?.();
331
- if (record.agent_id === null) {
332
- reason = "agent id was not resolved at launch";
333
- }
334
- else if (cell !== undefined && !sameOrigin(record.gateway_host, cell)) {
335
- reason = `this session ran on ${record.gateway_host}, not this CLI's cell (${cell}) — point the CLI at that cell to read it`;
491
+ if ("reason" in target) {
492
+ reason = target.reason;
336
493
  }
337
494
  else {
338
495
  try {
339
- remote = await deps.fetchSession(record.conversation_id, record.agent_id, record.org_id);
496
+ remote = await deps.fetchSession(record.conversation_id, target.agentId, record.org_id);
340
497
  }
341
498
  catch (e) {
342
499
  reason = unavailableReason(e);
@@ -344,27 +501,40 @@ export async function agentShowCommand(ref, opts, deps) {
344
501
  }
345
502
  if (opts.json) {
346
503
  deps.out(JSON.stringify(buildShowJson(record, remote, reason), null, 2));
347
- return;
348
504
  }
349
- for (const line of buildShowView(record, remote, reason))
350
- deps.out(line);
351
- if (deps.fetchGoal && resolveGoalMode(record, remote) === "implicit") {
352
- // Best effort: any failure (no key, gateway without the route, agent not found) is one dim line.
353
- let line;
354
- try {
355
- line = liveGoalLine(await deps.fetchGoal(record));
505
+ else {
506
+ for (const line of buildShowView(record, remote, reason))
507
+ deps.out(line);
508
+ if (deps.fetchGoal && resolveGoalMode(record, remote) === "implicit") {
509
+ // Best effort: any failure (no key, gateway without the route, agent not found) is one dim line.
510
+ let line;
511
+ try {
512
+ line = liveGoalLine(await deps.fetchGoal(record));
513
+ }
514
+ catch {
515
+ line = fmt.dim("(live goal unavailable)");
516
+ }
517
+ deps.out(fmt.label("live goal: ", line));
356
518
  }
357
- catch {
358
- line = fmt.dim("(live goal unavailable)");
519
+ if (others.length > 0) {
520
+ deps.out("");
521
+ deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
522
+ for (const o of others) {
523
+ deps.out(fmt.dim(` ${o.conversation_id.slice(0, 8)} ${formatRelativeAge(o.started_at)}`));
524
+ }
359
525
  }
360
- deps.out(fmt.label("live goal: ", line));
361
- }
362
- if (others.length > 0) {
363
526
  deps.out("");
364
- deps.out(fmt.dim(` ${others.length} older session(s) share this name — \`mnemom agent show <conv-id prefix>\` picks one:`));
365
- for (const o of others) {
366
- deps.out(fmt.dim(` ${o.conversation_id.slice(0, 8)} ${formatRelativeAge(o.started_at)}`));
367
- }
368
527
  }
369
- deps.out("");
528
+ if (!opts.follow)
529
+ return;
530
+ if ("reason" in target) {
531
+ deps.err(fmt.dim(`--follow: ${target.reason} — nothing to watch.`));
532
+ return;
533
+ }
534
+ const seenKeys = new Set((remote?.verdict_trail ?? []).map(verdictTrailKey));
535
+ await followVerdictTrail(record, target.agentId, deps, {
536
+ json: opts.json,
537
+ intervalMs: resolveFollowIntervalMs(opts),
538
+ seenKeys,
539
+ });
370
540
  }
@@ -40,12 +40,16 @@ export interface AgentOptions {
40
40
  budget?: string;
41
41
  maxTurns?: string;
42
42
  stall?: string;
43
+ /** `--mode nudge|enforce` (MNE-8199): see `ContractFlags.mode` (lib/agent.ts). */
44
+ mode?: string;
43
45
  /**
44
46
  * `--implicit`: goal alignment with no sealed contract — the gateway infers the
45
47
  * goal from the user's messages and revises it as they steer; the ceilings above
46
48
  * ride on the `x-mnemom-goal-mode` header. Exclusive with the contract flags.
47
49
  */
48
50
  implicit?: boolean;
51
+ /** `--lead-directed`: see `ContractFlags.leadDirected` (lib/agent.ts, MNE-8152). */
52
+ leadDirected?: boolean;
49
53
  /** x-mnemom-agent value (defaults to "code-agent"). */
50
54
  agent?: string;
51
55
  conversationId?: string;
@@ -251,6 +255,8 @@ export interface ResolvedCli {
251
255
  /** The basename, e.g. "claude" — selects the launch adapter. */
252
256
  name: string;
253
257
  }
258
+ /** The CLI asked for — `--cli`, else MNEMOM_AGENT_CLI, else "claude" — before any lookup. */
259
+ export declare function requestedCli(cliOpt: string | undefined, env: NodeJS.ProcessEnv): string;
254
260
  /**
255
261
  * Resolve which coding-agent CLI to launch. `--cli` (or MNEMOM_AGENT_CLI) is a
256
262
  * name or a full path; default "claude". A value with a path separator is used