@mnemom/mnemom 0.17.16-next.0 → 0.17.17-next.0

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;
@@ -176,7 +176,9 @@ export async function agentCommand(scenarioArg, options = {}) {
176
176
  budget: options.budget,
177
177
  maxTurns: options.maxTurns,
178
178
  stall: options.stall,
179
+ mode: options.mode,
179
180
  implicit: options.implicit,
181
+ leadDirected: options.leadDirected,
180
182
  });
181
183
  // ── implicit goal mode (no contract; the gateway infers the goal from the user's
182
184
  // messages) — the guardrail ceilings ride on the mode header instead ──
package/dist/index.js CHANGED
@@ -150,7 +150,12 @@ const agentCmd = program
150
150
  .option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
151
151
  .option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
152
152
  .option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
153
- .addOption(new Option("--implicit", "EXPERIMENTAL (`mnemom experimental enable implicit`): implicit goal alignment — the gateway infers the goal from your messages and revises it as you steer (the default while the feature is on; not combinable with --goal/--requirement/--allow/--forbid/--goal-id; the guardrail ceilings still apply)").hideHelp(hiddenUnless("implicit")))
153
+ .option("--mode <nudge|enforce>", "Sealed contract mode (MNE-8199; default nudge). Under enforce, a crossed turn/budget/time " +
154
+ "ceiling REFUSES the next request on this conversation until the owner raises it via a " +
155
+ "contract update — under nudge the ceiling only nudges, unchanged. Requires --goal.")
156
+ .option("--lead-directed", "Seal the standard lead-directed requirement: own-branch push and draft/ready PR creation, " +
157
+ "from a git worktree, on the lead's direction — not merging. Requires --goal; idempotent.")
158
+ .addOption(new Option("--implicit", "EXPERIMENTAL (`mnemom experimental enable implicit`): implicit goal alignment — the gateway infers the goal from your messages and revises it as you steer (the default while the feature is on; not combinable with --goal/--requirement/--allow/--forbid/--goal-id/--mode/--lead-directed; the guardrail ceilings still apply)").hideHelp(hiddenUnless("implicit")))
154
159
  .option("--no-implicit", "Turn implicit goal alignment OFF for this launch (overrides launcher.toml goal_mode = implicit)")
155
160
  .option("--conversation-id <id>", "x-mnemom-conversation-id (default: auto-generated)")
156
161
  .option("--cli <name|path>", "Coding-agent CLI to launch: a name or full path (default: claude)")
@@ -345,9 +350,11 @@ const agentCmd = program
345
350
  forbid: opts.forbid,
346
351
  goalId: opts.goalId,
347
352
  implicit,
353
+ leadDirected: opts.leadDirected,
348
354
  budget: opts.budget ?? (contractIntended ? numToStr(cfg.guardrails?.budgetUsd) : undefined),
349
355
  maxTurns: opts.maxTurns ?? (contractIntended ? numToStr(cfg.guardrails?.maxTurns) : undefined),
350
356
  stall: opts.stall ?? (contractIntended ? numToStr(cfg.guardrails?.stallTurns) : undefined),
357
+ mode: opts.mode,
351
358
  agent: parentOpts.agent ?? cfg.agent,
352
359
  conversationId: opts.conversationId,
353
360
  cli: opts.cli,
@@ -878,9 +885,21 @@ agentCmd
878
885
  .description("Show one session — the local launch record merged with the gateway's live goal state (sealed contract, latest verdict, guardrails). " +
879
886
  "`ref` is a session name (newest match wins) or a unique conversation-id prefix; omitted = the newest session")
880
887
  .option("--json", "Output the merged view as JSON (each section tagged with its source)")
888
+ .option("--follow", "After the snapshot, keep watching and print each new verdict as it lands (Ctrl-C to stop)")
889
+ .option("--follow-interval <seconds>", "Poll interval for --follow, in seconds (default 3; also settable via MNEMOM_AGENT_FOLLOW_INTERVAL)")
881
890
  .action(async (ref, opts) => {
882
891
  try {
883
- await agentShowCommand(ref, { json: opts.json }, liveSessionDeps);
892
+ let followIntervalMs;
893
+ if (opts.followInterval !== undefined) {
894
+ const seconds = Number(opts.followInterval);
895
+ if (!Number.isFinite(seconds) || seconds <= 0) {
896
+ console.error("Error: --follow-interval must be a positive number of seconds.");
897
+ process.exit(1);
898
+ return;
899
+ }
900
+ followIntervalMs = seconds * 1000;
901
+ }
902
+ await agentShowCommand(ref, { json: opts.json, follow: opts.follow, followIntervalMs }, liveSessionDeps);
884
903
  }
885
904
  catch (error) {
886
905
  console.error("Error:", error instanceof Error ? error.message : error);
@@ -39,6 +39,15 @@ export interface SealedContract {
39
39
  max_turns?: number;
40
40
  budget_usd?: number;
41
41
  stall_turns?: number;
42
+ /**
43
+ * `nudge` | `enforce` (MNE-8199, design D9/R9): under `enforce` a crossed turn/budget/time
44
+ * ceiling refuses the gateway's NEXT request on this conversation rather than only
45
+ * nudging. ALWAYS present once a contract is sealed (never omitted) — the gateway's
46
+ * `parseContractHeader` defaults an absent/invalid value to `"nudge"` unconditionally
47
+ * before hashing, so the CLI must set it here too (defaulting to `"nudge"`) or the sha
48
+ * printed here would disagree with the one the gateway seals (the MNE-7649 class of bug).
49
+ */
50
+ mode?: "nudge" | "enforce";
42
51
  }
43
52
  /** The raw contract/guardrail flags as commander hands them to the command. */
44
53
  export interface ContractFlags {
@@ -50,6 +59,8 @@ export interface ContractFlags {
50
59
  maxTurns?: string | number;
51
60
  budget?: string | number;
52
61
  stall?: string | number;
62
+ /** `--mode nudge|enforce` (MNE-8199) — validated and defaulted in `assembleContract`. */
63
+ mode?: string;
53
64
  /**
54
65
  * `--implicit`: goal alignment WITHOUT a sealed contract. No `x-mnemom-contract`
55
66
  * is sent; the gateway infers the goal from the user's own messages (revision 1
@@ -58,7 +69,26 @@ export interface ContractFlags {
58
69
  * of inside a contract. Cannot be combined with the sealed-contract flags.
59
70
  */
60
71
  implicit?: boolean;
72
+ /**
73
+ * `--lead-directed`: append `LEAD_DIRECTED_REQUIREMENT` to the sealed contract's
74
+ * `requirements` (MNE-8152, design `mnemom-agent-wave` D4). A lead-directed agent
75
+ * (e.g. an ADW lane lead) legitimately pushes its own branches and opens draft/ready
76
+ * PRs; under the sealed framing the judge reads only `goal`/`requirements`/fences, so
77
+ * without this line a push/PR step is flagged `violation` ("an effect of this step is
78
+ * not allowed by the requirements"). The mechanism is a requirement line, not a judge
79
+ * input — sealed mode takes no in-session steering, so the lead's direction must
80
+ * already be IN the seal. Idempotent: appended at most once, whether the flag is
81
+ * given once or the requirement text is already present (e.g. supplied verbatim via
82
+ * `--requirement`).
83
+ */
84
+ leadDirected?: boolean;
61
85
  }
86
+ /**
87
+ * The standard requirement line `--lead-directed` seals: own-branch push and draft/ready
88
+ * PR creation, from a git worktree, on the lead's direction — merging is explicitly out.
89
+ * A single constant so every launch that opts in seals byte-identical wording (MNE-8152).
90
+ */
91
+ export declare const LEAD_DIRECTED_REQUIREMENT: string;
62
92
  /** The three launch shapes the command supports (see commands/code.ts). */
63
93
  export type LaunchShape = "terminal" | "remote-control" | "server";
64
94
  /**
package/dist/lib/agent.js CHANGED
@@ -25,6 +25,13 @@ import { join } from "node:path";
25
25
  import { getGatewayUrl } from "./config.js";
26
26
  /** The us-2/prod gateway host. The default door — never any other cell silently. */
27
27
  export const PROD_GATEWAY_HOST = "https://gateway.mnemom.ai";
28
+ /**
29
+ * The standard requirement line `--lead-directed` seals: own-branch push and draft/ready
30
+ * PR creation, from a git worktree, on the lead's direction — merging is explicitly out.
31
+ * A single constant so every launch that opts in seals byte-identical wording (MNE-8152).
32
+ */
33
+ export const LEAD_DIRECTED_REQUIREMENT = "On the lead's direction, this session may push its own branches and open draft or " +
34
+ "ready pull requests, from a git worktree, never the primary checkout; it may not merge them.";
28
35
  /**
29
36
  * Resolve the gateway HOST (scheme + host, no path). An explicit, non-empty
30
37
  * `MNEMOM_AGENT_GATEWAY` wins verbatim; otherwise the gateway of the cell the rest
@@ -381,7 +388,9 @@ export function contractFlagsPresent(flags) {
381
388
  hasText(flags.goalId) ||
382
389
  flags.maxTurns !== undefined ||
383
390
  flags.budget !== undefined ||
384
- flags.stall !== undefined);
391
+ flags.stall !== undefined ||
392
+ hasText(flags.mode) ||
393
+ flags.leadDirected === true);
385
394
  }
386
395
  function hasText(v) {
387
396
  return typeof v === "string" && v.trim().length > 0;
@@ -413,11 +422,18 @@ export function assembleContract(flags) {
413
422
  const statement = (flags.goal ?? "").trim();
414
423
  if (!statement) {
415
424
  throw new Error("--goal is required when any contract flag " +
416
- "(--requirement/--allow/--forbid/--goal-id/--budget/--max-turns/--stall) is given.");
425
+ "(--requirement/--allow/--forbid/--goal-id/--budget/--max-turns/--stall/--mode/--lead-directed) is given.");
426
+ }
427
+ const requirements = cleanList(flags.requirement);
428
+ // Idempotent: append the standard line only when it is not already present, whether
429
+ // because `--lead-directed` was given more than once or because the exact wording
430
+ // was already supplied verbatim via `--requirement`.
431
+ if (flags.leadDirected === true && !requirements.includes(LEAD_DIRECTED_REQUIREMENT)) {
432
+ requirements.push(LEAD_DIRECTED_REQUIREMENT);
417
433
  }
418
434
  const contract = {
419
435
  statement,
420
- requirements: cleanList(flags.requirement),
436
+ requirements,
421
437
  allowed_paths: cleanList(flags.allow),
422
438
  forbidden_paths: cleanList(flags.forbid),
423
439
  };
@@ -442,6 +458,19 @@ export function assembleContract(flags) {
442
458
  }
443
459
  contract.stall_turns = Number(flags.stall);
444
460
  }
461
+ // MNE-8199: set only for the non-default "enforce" — the gateway's own canonicalHash
462
+ // folds `mode` into the hash the same way (see its doc comment): a contract that never
463
+ // named `--mode` must keep hashing identically to the byte-identical contract sealed
464
+ // before the field existed, or every live conversation logs `goal_contract_ignored_change`
465
+ // on deploy. Setting a defaulted "nudge" here unconditionally would instead make the SHA
466
+ // this prints disagree with the one the gateway actually seals for the exact same launch —
467
+ // the MNE-7649 class of bug, just reintroduced from the client side.
468
+ const rawMode = (flags.mode ?? "").trim();
469
+ if (rawMode && rawMode !== "nudge" && rawMode !== "enforce") {
470
+ throw new Error(`--mode must be "nudge" or "enforce" (got '${flags.mode}').`);
471
+ }
472
+ if (rawMode === "enforce")
473
+ contract.mode = "enforce";
445
474
  const canonical = canonicalJson(contract);
446
475
  return {
447
476
  contract,
@@ -756,7 +785,7 @@ export function applyGatewayHintHeaders(childEnv) {
756
785
  }
757
786
  return childEnv;
758
787
  }
759
- const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
788
+ const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id/--mode/--lead-directed";
760
789
  /**
761
790
  * `--implicit` and a sealed contract are mutually exclusive: on the gateway a
762
791
  * sealed `x-mnemom-contract` always wins and would silently switch implicit mode
@@ -768,7 +797,9 @@ function assertImplicitCompatible(flags) {
768
797
  (flags.requirement?.length ?? 0) > 0 ||
769
798
  (flags.allow?.length ?? 0) > 0 ||
770
799
  (flags.forbid?.length ?? 0) > 0 ||
771
- hasText(flags.goalId);
800
+ hasText(flags.goalId) ||
801
+ hasText(flags.mode) ||
802
+ flags.leadDirected === true;
772
803
  if (sealed) {
773
804
  throw new Error(`--implicit cannot be combined with ${SEALED_ONLY_FLAGS}: a sealed contract always wins on ` +
774
805
  "the gateway and would silently switch implicit mode off. Drop --implicit to seal a " +
package/dist/lib/api.d.ts CHANGED
@@ -1289,6 +1289,22 @@ export interface AgentSessionGuardrails {
1289
1289
  };
1290
1290
  } | null;
1291
1291
  }
1292
+ /**
1293
+ * One entry of the gateway's verdict trail (R10, MNE-8200) — up to the last 50 judged turns,
1294
+ * oldest first (same order the gateway stores and returns them in). `request_id` is the request
1295
+ * that produced this verdict, or null when the judge ran without one. `fence_breach` is `true`/
1296
+ * `false` for a conclusive mechanical allowed/forbidden-paths check, `null` when the contract has
1297
+ * no fences or the verdict predates the check. This shape is also what MNE-8234 mirrors — keep
1298
+ * the field name and member shape stable.
1299
+ */
1300
+ export interface AgentSessionVerdictTrailEntry {
1301
+ request_id: string | null;
1302
+ turn: number;
1303
+ verdict: "aligned" | "drifting" | "violation";
1304
+ reason: string;
1305
+ fence_breach: boolean | null;
1306
+ at: string;
1307
+ }
1292
1308
  /** `GET /v1/agent/sessions/{conversationId}` 200 body. Nulls mean "nothing stored" — absence is data. */
1293
1309
  export interface AgentSessionResponse {
1294
1310
  conversation_id: string;
@@ -1301,6 +1317,8 @@ export interface AgentSessionResponse {
1301
1317
  verdict: AgentSessionVerdict | null;
1302
1318
  turn: number | null;
1303
1319
  guardrails: AgentSessionGuardrails | null;
1320
+ /** Absent on an older server (back-compat) — never shown then, never an empty-array assumption. */
1321
+ verdict_trail?: AgentSessionVerdictTrailEntry[];
1304
1322
  }
1305
1323
  /**
1306
1324
  * The live goal state of one `mnemom agent` session, read through mnemom-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.16-next.0",
3
+ "version": "0.17.17-next.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "chalk": "^5.3.0",
22
22
  "commander": "^12.0.0",
23
23
  "js-yaml": "^4.3.2",
24
- "smol-toml": "^1.8.0"
24
+ "smol-toml": "^1.9.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/js-yaml": "^4.0.9",