@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 +31 -0
- package/dist/commands/agent-invite.d.ts +17 -2
- package/dist/commands/agent-invite.js +48 -5
- package/dist/commands/agent-sessions.d.ts +56 -1
- package/dist/commands/agent-sessions.js +196 -26
- package/dist/commands/agent.d.ts +4 -0
- package/dist/commands/agent.js +2 -0
- package/dist/index.js +21 -2
- package/dist/lib/agent.d.ts +30 -0
- package/dist/lib/agent.js +36 -5
- package/dist/lib/api.d.ts +18 -0
- package/package.json +2 -2
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
|
-
/**
|
|
57
|
-
export declare
|
|
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
|
-
/**
|
|
127
|
-
export
|
|
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
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
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,
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/agent.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
|
|
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);
|
package/dist/lib/agent.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
24
|
+
"smol-toml": "^1.9.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|