@mnemom/mnemom 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/oauth.js CHANGED
@@ -39,6 +39,14 @@ const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
39
39
  // both interactive login poll loops emit, so a multi-minute browser sign-in
40
40
  // doesn't read as a hung CLI (matches the card-write retry tick in try-me).
41
41
  const LOGIN_HEARTBEAT_MS = 15 * 1000;
42
+ // Refresh-token retry policy (R6 / MNE-7394). A transient AS 5xx or a dropped
43
+ // network connection during refresh must be retried with bounded exponential
44
+ // backoff rather than collapsed into the re-login signal — a one-second AS
45
+ // hiccup or a flaky Wi-Fi moment must not invalidate a valid, long-lived
46
+ // session. Per-attempt delay is REFRESH_BACKOFF_BASE_MS * 2**attemptIndex →
47
+ // ~1s / 2s / 4s across REFRESH_MAX_ATTEMPTS attempts.
48
+ const REFRESH_MAX_ATTEMPTS = 3;
49
+ const REFRESH_BACKOFF_BASE_MS = 1000;
42
50
  let cachedMetadata = null;
43
51
  /**
44
52
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
@@ -359,40 +367,101 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
359
367
  // Refresh (RFC 6749 §6)
360
368
  // ============================================================================
361
369
  /**
362
- * Exchange a refresh token for a fresh access token. Returns null if refresh is
363
- * not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
364
- * so callers can fall back to prompting for re-login rather than crashing.
370
+ * Exchange a refresh token for a fresh access token.
371
+ *
372
+ * Failure classification (R6 / MNE-7394) a transient failure must never be
373
+ * indistinguishable from a revoked credential:
374
+ * - 2xx → return the fresh tokens (carrying the prior refresh token forward
375
+ * per RFC 6749 §6 when the AS omits a new one).
376
+ * - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
377
+ * return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
378
+ * errors that mean the presented grant/client credential is genuinely no
379
+ * longer valid, so a fresh login is the correct remedy. No retry.
380
+ * - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
381
+ * error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
382
+ * the credentials are revoked, so we must not discard the session.
383
+ * - `res.status >= 500` or a fetch/network exception → retry with bounded
384
+ * exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
385
+ * retries are exhausted, throw. Exhausting transient retries must NOT
386
+ * masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
387
+ * preserves the stored session so a later invocation can succeed once the
388
+ * AS recovers.
389
+ *
390
+ * `null` is therefore narrowed to exactly the two re-login errors; every other
391
+ * outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
392
+ * so tests drive the backoff without real timers.
365
393
  */
366
- export async function refreshTokens(refreshToken, clientId) {
394
+ export async function refreshTokens(refreshToken, clientId, opts) {
367
395
  if (!refreshToken || !clientId)
368
396
  return null;
369
- try {
370
- const meta = await discover();
371
- const res = await fetch(meta.token_endpoint, {
372
- method: "POST",
373
- headers: {
374
- "Content-Type": "application/x-www-form-urlencoded",
375
- Accept: "application/json",
376
- },
377
- body: new URLSearchParams({
378
- grant_type: "refresh_token",
379
- refresh_token: refreshToken,
380
- client_id: clientId,
381
- }),
382
- });
383
- if (!res.ok)
397
+ const sleep = opts?.sleep ?? defaultSleep;
398
+ // Discovery is not part of the refresh retry budget; a discovery failure
399
+ // propagates as it does today (it is already thrown by discover()).
400
+ const meta = await discover();
401
+ let lastTransient = "";
402
+ for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
403
+ let res;
404
+ try {
405
+ res = await fetch(meta.token_endpoint, {
406
+ method: "POST",
407
+ headers: {
408
+ "Content-Type": "application/x-www-form-urlencoded",
409
+ Accept: "application/json",
410
+ },
411
+ body: new URLSearchParams({
412
+ grant_type: "refresh_token",
413
+ refresh_token: refreshToken,
414
+ client_id: clientId,
415
+ }),
416
+ });
417
+ }
418
+ catch (err) {
419
+ // Network/fetch exception (DNS failure, connection reset, timeout) — this
420
+ // is unrelated to the validity of the refresh token, so retry it.
421
+ lastTransient = err instanceof Error ? err.message : String(err);
422
+ if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
423
+ await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
424
+ continue;
425
+ }
426
+ break;
427
+ }
428
+ if (res.ok) {
429
+ const data = (await res.json());
430
+ const tokens = tokensFromResponse(data);
431
+ // Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in
432
+ // which case the old one remains valid — preserve it so the next refresh
433
+ // works.
434
+ if (!tokens.refreshToken)
435
+ tokens.refreshToken = refreshToken;
436
+ return tokens;
437
+ }
438
+ if (res.status >= 500) {
439
+ // Transient AS outage — retry with backoff, then fall through to the
440
+ // "exhausted" throw once no attempts remain.
441
+ lastTransient = `HTTP ${res.status}`;
442
+ if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
443
+ await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
444
+ continue;
445
+ }
446
+ break;
447
+ }
448
+ // 4xx — a definitive client-side rejection, never retried. Only
449
+ // invalid_grant / invalid_client map to the null re-login signal; every
450
+ // other 4xx surfaces as a thrown error rather than a silent null that would
451
+ // wrongly discard a valid session.
452
+ const body = (await res.json().catch(() => ({})));
453
+ if (body.error === "invalid_grant" || body.error === "invalid_client") {
384
454
  return null;
385
- const data = (await res.json());
386
- const tokens = tokensFromResponse(data);
387
- // Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in which
388
- // case the old one remains valid — preserve it so the next refresh works.
389
- if (!tokens.refreshToken)
390
- tokens.refreshToken = refreshToken;
391
- return tokens;
392
- }
393
- catch {
394
- return null;
455
+ }
456
+ const detail = body.error
457
+ ? body.error + (body.error_description ? ` ${body.error_description}` : "")
458
+ : `HTTP ${res.status}`;
459
+ throw new Error(`Token refresh failed: ${detail}`);
395
460
  }
461
+ // Retries exhausted on a 5xx or a network exception (fail-closed, MNE-442):
462
+ // throw rather than return null so the session is preserved for a later retry.
463
+ throw new Error(`Token refresh failed after ${REFRESH_MAX_ATTEMPTS} attempts ` +
464
+ `(last error: ${lastTransient || "unknown transient failure"}).`);
396
465
  }
397
466
  // ============================================================================
398
467
  // Helpers
@@ -0,0 +1,117 @@
1
+ /**
2
+ * protection-drift.ts — pure comparison between a COMMITTED protection-card
3
+ * snapshot (a file under `cards/`) and the LIVE canonical protection card
4
+ * returned by the platform.
5
+ *
6
+ * Split follows the convention `mnemom-api/src/composition/reconcile-flat-columns.ts`
7
+ * established: the decision logic is a pure, network-free module so it can be
8
+ * unit-tested exhaustively; the thin driver that actually fetches the live card
9
+ * lives in the command layer (`commands/protection.ts` → `mnemom protection drift`).
10
+ *
11
+ * WHAT IS BEING COMPARED (read this before extending):
12
+ * - `committed` is a repo SNAPSHOT of the agent-scope card — human-authored,
13
+ * declared intent. See `cards/README.md`.
14
+ * - `live` is the CANONICAL COMPOSED card (`GET /v1/protection/agent/:id`,
15
+ * via `getProtectionCard()`), i.e. the OUTPUT of
16
+ * `composeProtectionCard(platform, org, teams…, agent)` with strictest-wins
17
+ * mode resolution.
18
+ * These are NOT the same object. A snapshot field matching the composed card
19
+ * means "the effective posture is what the repo says it should be" — it does
20
+ * NOT mean "publishing this snapshot would reproduce the live card". A
21
+ * stricter scope upstream (org / team / platform floor) can raise the composed
22
+ * mode above whatever the agent-scope card asks for.
23
+ */
24
+ /**
25
+ * Fields on the canonical card that are re-derived on every compose and
26
+ * therefore carry no posture meaning. Comparing them would make any drift check
27
+ * permanently red.
28
+ *
29
+ * - `card_version`: `compose.ts` sets it to
30
+ * `protection/${new Date().toISOString().slice(0, 10)}` — a wall-clock stamp
31
+ * of the LAST COMPOSE, not a semantic version. It changes whenever anything
32
+ * recomposes, and never changes while the card sits untouched.
33
+ * - `content_hash` / `composed_at` / `agent_protection_card_id` /
34
+ * `updated_at` / `created_at` / `needs_recompose`: storage + provenance
35
+ * bookkeeping written by the composer and the canonical-write RPC.
36
+ *
37
+ * The first version of this list was written from the composer's own field
38
+ * names. That was the wrong source: the list has to match what the SERIALIZER
39
+ * emits. A live read of `GET /v1/protection/agent/:id` on us-1 (2026-07-28)
40
+ * returns exactly:
41
+ *
42
+ * mode, card_id, agent_id, issued_at, thresholds, card_version,
43
+ * screen_surfaces, trusted_sources, protected_surface, _composition,
44
+ * content_hash, version
45
+ *
46
+ * so `composed_at`, `created_at`, `updated_at`, `needs_recompose` and
47
+ * `agent_protection_card_id` never appear at all (kept below anyway — harmless,
48
+ * and they do appear on the raw DB row some callers pass in), while four
49
+ * derived fields the serializer DOES emit were missing: `card_id` (the
50
+ * server-stamped canonical id, provenance `kind: server_stamped`), `issued_at`
51
+ * (a compose-time stamp), `_composition` (the whole provenance envelope,
52
+ * `layer: derived` throughout, and itself containing `_composition.composed_at`)
53
+ * and `version` (the serializer's envelope version, not a posture field).
54
+ * Without them `--strict` reports four permanent `unrecorded` holes that no
55
+ * snapshot can ever close, which is precisely the state that would make an
56
+ * operator stop trusting the check.
57
+ *
58
+ * KNOWN COST of ignoring `version`: it is the serializer's envelope version, so
59
+ * a schema bump (2 → 3) passes silently here. That is the intended trade — a
60
+ * serializer version is not a posture claim, and an envelope change that alters
61
+ * a posture field will still surface as a mismatch or an unrecorded field. If
62
+ * the envelope version ever starts carrying posture semantics, drop it from
63
+ * this list.
64
+ */
65
+ export declare const VOLATILE_FIELDS: readonly string[];
66
+ export interface FieldMismatch {
67
+ /** Dotted path, e.g. `mode` or `screen_surfaces.tool_calls`. */
68
+ path: string;
69
+ committed: unknown;
70
+ live: unknown;
71
+ }
72
+ export interface DriftResult {
73
+ /**
74
+ * A field is present in BOTH the snapshot and the live card but the values
75
+ * differ. This is unambiguous drift: the repo's declared posture is not the
76
+ * posture actually in force. Always a failure.
77
+ */
78
+ mismatches: FieldMismatch[];
79
+ /**
80
+ * A top-level field the LIVE card carries that the snapshot does not record
81
+ * at all. Not a value conflict — a COVERAGE hole: that part of the live
82
+ * posture has no committed representation, so it could change with no diff.
83
+ * Reported always; fatal only under `strict`.
84
+ */
85
+ unrecorded: string[];
86
+ /**
87
+ * A top-level field the SNAPSHOT declares that the live card does not carry.
88
+ * The declared intent is simply not in force. Always a failure — treated as a
89
+ * mismatch against `undefined` would lose the distinction, so it is its own
90
+ * bucket.
91
+ */
92
+ missingLive: string[];
93
+ /** Volatile fields skipped, for transparency in the report. */
94
+ ignored: string[];
95
+ }
96
+ /**
97
+ * Compare a committed snapshot against the live canonical card.
98
+ *
99
+ * Pure: no I/O, no clock, no env. Both arguments are already-parsed objects.
100
+ */
101
+ export declare function compareProtectionCards(committed: Record<string, unknown>, live: Record<string, unknown>): DriftResult;
102
+ /**
103
+ * Policy: does this result fail the check?
104
+ *
105
+ * `mismatches` and `missingLive` always fail — the repo says one thing and the
106
+ * live posture is another. `unrecorded` is a coverage gap: reported by default,
107
+ * fatal only under `--strict`, so the check can go green today on a partial
108
+ * snapshot while still surfacing exactly which parts of the live posture remain
109
+ * unversioned. See `cards/README.md`.
110
+ */
111
+ export declare function isDrift(result: DriftResult, opts?: {
112
+ strict?: boolean;
113
+ }): boolean;
114
+ /** Human-readable multi-line report. Returned, not printed, so it is testable. */
115
+ export declare function formatDriftReport(result: DriftResult, opts?: {
116
+ strict?: boolean;
117
+ }): string;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * protection-drift.ts — pure comparison between a COMMITTED protection-card
3
+ * snapshot (a file under `cards/`) and the LIVE canonical protection card
4
+ * returned by the platform.
5
+ *
6
+ * Split follows the convention `mnemom-api/src/composition/reconcile-flat-columns.ts`
7
+ * established: the decision logic is a pure, network-free module so it can be
8
+ * unit-tested exhaustively; the thin driver that actually fetches the live card
9
+ * lives in the command layer (`commands/protection.ts` → `mnemom protection drift`).
10
+ *
11
+ * WHAT IS BEING COMPARED (read this before extending):
12
+ * - `committed` is a repo SNAPSHOT of the agent-scope card — human-authored,
13
+ * declared intent. See `cards/README.md`.
14
+ * - `live` is the CANONICAL COMPOSED card (`GET /v1/protection/agent/:id`,
15
+ * via `getProtectionCard()`), i.e. the OUTPUT of
16
+ * `composeProtectionCard(platform, org, teams…, agent)` with strictest-wins
17
+ * mode resolution.
18
+ * These are NOT the same object. A snapshot field matching the composed card
19
+ * means "the effective posture is what the repo says it should be" — it does
20
+ * NOT mean "publishing this snapshot would reproduce the live card". A
21
+ * stricter scope upstream (org / team / platform floor) can raise the composed
22
+ * mode above whatever the agent-scope card asks for.
23
+ */
24
+ /**
25
+ * Fields on the canonical card that are re-derived on every compose and
26
+ * therefore carry no posture meaning. Comparing them would make any drift check
27
+ * permanently red.
28
+ *
29
+ * - `card_version`: `compose.ts` sets it to
30
+ * `protection/${new Date().toISOString().slice(0, 10)}` — a wall-clock stamp
31
+ * of the LAST COMPOSE, not a semantic version. It changes whenever anything
32
+ * recomposes, and never changes while the card sits untouched.
33
+ * - `content_hash` / `composed_at` / `agent_protection_card_id` /
34
+ * `updated_at` / `created_at` / `needs_recompose`: storage + provenance
35
+ * bookkeeping written by the composer and the canonical-write RPC.
36
+ *
37
+ * The first version of this list was written from the composer's own field
38
+ * names. That was the wrong source: the list has to match what the SERIALIZER
39
+ * emits. A live read of `GET /v1/protection/agent/:id` on us-1 (2026-07-28)
40
+ * returns exactly:
41
+ *
42
+ * mode, card_id, agent_id, issued_at, thresholds, card_version,
43
+ * screen_surfaces, trusted_sources, protected_surface, _composition,
44
+ * content_hash, version
45
+ *
46
+ * so `composed_at`, `created_at`, `updated_at`, `needs_recompose` and
47
+ * `agent_protection_card_id` never appear at all (kept below anyway — harmless,
48
+ * and they do appear on the raw DB row some callers pass in), while four
49
+ * derived fields the serializer DOES emit were missing: `card_id` (the
50
+ * server-stamped canonical id, provenance `kind: server_stamped`), `issued_at`
51
+ * (a compose-time stamp), `_composition` (the whole provenance envelope,
52
+ * `layer: derived` throughout, and itself containing `_composition.composed_at`)
53
+ * and `version` (the serializer's envelope version, not a posture field).
54
+ * Without them `--strict` reports four permanent `unrecorded` holes that no
55
+ * snapshot can ever close, which is precisely the state that would make an
56
+ * operator stop trusting the check.
57
+ *
58
+ * KNOWN COST of ignoring `version`: it is the serializer's envelope version, so
59
+ * a schema bump (2 → 3) passes silently here. That is the intended trade — a
60
+ * serializer version is not a posture claim, and an envelope change that alters
61
+ * a posture field will still surface as a mismatch or an unrecorded field. If
62
+ * the envelope version ever starts carrying posture semantics, drop it from
63
+ * this list.
64
+ */
65
+ export const VOLATILE_FIELDS = [
66
+ "_composition",
67
+ "agent_protection_card_id",
68
+ "card_id",
69
+ "card_version",
70
+ "composed_at",
71
+ "content_hash",
72
+ "created_at",
73
+ "issued_at",
74
+ "needs_recompose",
75
+ "updated_at",
76
+ "version",
77
+ ];
78
+ function isPlainObject(v) {
79
+ return typeof v === "object" && v !== null && !Array.isArray(v);
80
+ }
81
+ /** Structural equality good enough for card values (scalars, arrays, nested objects). */
82
+ function deepEqual(a, b) {
83
+ if (a === b)
84
+ return true;
85
+ if (Array.isArray(a) && Array.isArray(b)) {
86
+ return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
87
+ }
88
+ if (isPlainObject(a) && isPlainObject(b)) {
89
+ const ka = Object.keys(a).sort();
90
+ const kb = Object.keys(b).sort();
91
+ return ka.length === kb.length && ka.every((k, i) => k === kb[i] && deepEqual(a[k], b[k]));
92
+ }
93
+ return false;
94
+ }
95
+ /**
96
+ * Recurse into nested objects so a one-surface flip reports as
97
+ * `screen_surfaces.tool_calls` rather than dumping both whole objects.
98
+ * Keys the live side has but the snapshot does not, BELOW the top level, are
99
+ * reported as nested `unrecorded` paths (same coverage-hole semantics).
100
+ */
101
+ function walk(committed, live, prefix, out) {
102
+ for (const key of Object.keys(committed)) {
103
+ const path = prefix ? `${prefix}.${key}` : key;
104
+ if (!prefix && VOLATILE_FIELDS.includes(key)) {
105
+ out.ignored.push(path);
106
+ continue;
107
+ }
108
+ const c = committed[key];
109
+ const l = live[key];
110
+ if (!(key in live)) {
111
+ out.missingLive.push(path);
112
+ continue;
113
+ }
114
+ if (isPlainObject(c) && isPlainObject(l)) {
115
+ walk(c, l, path, out);
116
+ continue;
117
+ }
118
+ if (!deepEqual(c, l)) {
119
+ out.mismatches.push({ path, committed: c, live: l });
120
+ }
121
+ }
122
+ for (const key of Object.keys(live)) {
123
+ const path = prefix ? `${prefix}.${key}` : key;
124
+ if (!prefix && VOLATILE_FIELDS.includes(key)) {
125
+ if (!out.ignored.includes(path))
126
+ out.ignored.push(path);
127
+ continue;
128
+ }
129
+ if (!(key in committed))
130
+ out.unrecorded.push(path);
131
+ }
132
+ }
133
+ /**
134
+ * Compare a committed snapshot against the live canonical card.
135
+ *
136
+ * Pure: no I/O, no clock, no env. Both arguments are already-parsed objects.
137
+ */
138
+ export function compareProtectionCards(committed, live) {
139
+ const out = { mismatches: [], unrecorded: [], missingLive: [], ignored: [] };
140
+ walk(committed, live, "", out);
141
+ out.mismatches.sort((a, b) => a.path.localeCompare(b.path));
142
+ out.unrecorded.sort();
143
+ out.missingLive.sort();
144
+ out.ignored.sort();
145
+ return out;
146
+ }
147
+ /**
148
+ * Policy: does this result fail the check?
149
+ *
150
+ * `mismatches` and `missingLive` always fail — the repo says one thing and the
151
+ * live posture is another. `unrecorded` is a coverage gap: reported by default,
152
+ * fatal only under `--strict`, so the check can go green today on a partial
153
+ * snapshot while still surfacing exactly which parts of the live posture remain
154
+ * unversioned. See `cards/README.md`.
155
+ */
156
+ export function isDrift(result, opts = {}) {
157
+ if (result.mismatches.length > 0 || result.missingLive.length > 0)
158
+ return true;
159
+ return opts.strict === true && result.unrecorded.length > 0;
160
+ }
161
+ /** Human-readable multi-line report. Returned, not printed, so it is testable. */
162
+ export function formatDriftReport(result, opts = {}) {
163
+ const lines = [];
164
+ for (const m of result.mismatches) {
165
+ lines.push(`MISMATCH ${m.path}: committed=${JSON.stringify(m.committed)} live=${JSON.stringify(m.live)}`);
166
+ }
167
+ for (const p of result.missingLive) {
168
+ lines.push(`MISSING ${p}: declared in the committed snapshot, absent from the live card`);
169
+ }
170
+ for (const p of result.unrecorded) {
171
+ const tag = opts.strict === true ? "UNRECORDED" : "unrecorded";
172
+ lines.push(`${tag.padEnd(9)} ${p}: present on the live card, not represented in the snapshot`);
173
+ }
174
+ if (result.ignored.length > 0) {
175
+ lines.push(`ignored volatile/derived fields: ${result.ignored.join(", ")}`);
176
+ }
177
+ if (lines.length === 0)
178
+ lines.push("no differences");
179
+ return lines.join("\n");
180
+ }
@@ -42,23 +42,36 @@ export const SKILLS = [
42
42
  },
43
43
  {
44
44
  name: "onboard",
45
- status: "planned",
46
- ref: "MNE-933",
47
- summary: "Self-onboard the calling agent end-to-end (scan claim declare badge).",
48
- usage: "mnemom onboard",
45
+ status: "available",
46
+ summary: "Self-onboard the calling agent end-to-end (scan → claim → declare → rating → badge).",
47
+ usage: "mnemom onboard [--agent <id>] [--key <key>] [--hash-proof <hex>] [--yes] [--json] [--no-open]",
49
48
  description: "Runs the sovereignty path for the calling agent itself: scan its trust posture, " +
50
- "claim its identity, declare an alignment card, and earn a verifiable Trust Rating — " +
51
- "one command, no manifest. Planned (MNE-933).",
49
+ "claim its identity (as its own device-grant principal), declare a starter alignment " +
50
+ "card, and surface a Trust Rating + public badge URL — one command, no manifest. " +
51
+ "The Trust Rating is provisional until the observer pipeline generates traces (the " +
52
+ "signed rating + rendered badge are computed server-side, post-hoc). Idempotent: " +
53
+ "re-running skips an already-claimed identity and replays an unchanged card.",
54
+ examples: [
55
+ "npx @mnemom/mnemom@latest onboard",
56
+ "mnemom onboard --json",
57
+ "mnemom onboard --agent mnm-... --key <agent-api-key>",
58
+ ],
52
59
  },
53
60
  {
54
61
  name: "wrap",
55
- status: "planned",
56
- ref: "MNE-935",
62
+ status: "available",
57
63
  summary: "Instrument an existing production agent through the Mnemom gateway.",
58
- usage: "mnemom wrap",
59
- description: "Points an existing agent's provider calls at the Mnemom gateway, claims/births its " +
60
- "identity, and seeds starter alignment + protection cards bring-your-own-agent " +
61
- "onboarding. Planned (MNE-935).",
64
+ usage: "mnemom wrap [--provider <anthropic|openai|gemini>] [--framework <python|node>] " +
65
+ "[--name <name>] [--provider-key <key>] [--yes] [--json]",
66
+ description: "Points an existing agent's provider calls at the Mnemom gateway " +
67
+ "(`gateway.mnemom.ai/<provider>` + `x-mnemom-agent` header), births its identity, " +
68
+ "and seeds starter alignment + protection cards — bring-your-own-agent onboarding. " +
69
+ "Emits a drop-in code snippet for your provider SDK and next-step claim instructions.",
70
+ examples: [
71
+ "npx @mnemom/mnemom@latest wrap",
72
+ "mnemom wrap --provider anthropic --framework python --name my-agent",
73
+ "mnemom wrap --json",
74
+ ],
62
75
  },
63
76
  ];
64
77
  /** All registered skills, in registry order. */
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Minimum-CLI-version enforcement.
3
+ *
4
+ * The Mnemom API advertises the lowest fully-supported CLI version on every
5
+ * response via the `X-Mnemom-Min-CLI` header (mnemom-api MIN_CLI_VERSION). This
6
+ * module reads that header off any API response and, if THIS CLI is below the
7
+ * floor, prints a loud upgrade instruction and hard-exits.
8
+ *
9
+ * Why hard-fail: an outdated CLI can silently drop request fields a newer server
10
+ * expects and produce a wrong-but-successful result with no error. The canonical
11
+ * case that motivated this: a CLI predating claim-to-org (ADR-062) accepted
12
+ * `--org` but never sent `org_id`, so `agents claim … --org <slug>` cheerfully
13
+ * landed the agent in the caller's personal org. Failing loudly beats a silent
14
+ * wrong outcome.
15
+ */
16
+ /**
17
+ * Compare dotted numeric versions. Returns true iff `current` is strictly below
18
+ * `floor`. Prerelease/build suffixes (after `-` or `+`) are ignored — the floor
19
+ * is expressed as a plain release, and a prerelease of that release is treated
20
+ * as the release for gating purposes (we don't block `-rc` builds of a good ver).
21
+ */
22
+ export declare function isBelowVersion(current: string, floor: string): boolean;
23
+ /**
24
+ * Enforce the server-advertised minimum CLI version against `response`.
25
+ * No-op when the header is absent (older server, or a non-API response) or when
26
+ * this CLI is at/above the floor. Otherwise writes a clear message to stderr and
27
+ * exits with code 1.
28
+ *
29
+ * @param exit injectable for tests; defaults to process.exit
30
+ * @param write injectable for tests; defaults to process.stderr.write
31
+ */
32
+ export declare function enforceMinCliVersion(response: Pick<Response, "headers">, exit?: (code: number) => never, write?: (chunk: string) => void): void;
33
+ /**
34
+ * Drop-in wrapper around global fetch for Mnemom API calls: issues the request,
35
+ * enforces the advertised minimum CLI version on the response, then returns it.
36
+ */
37
+ export declare function mnemomFetch(input: string | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Minimum-CLI-version enforcement.
3
+ *
4
+ * The Mnemom API advertises the lowest fully-supported CLI version on every
5
+ * response via the `X-Mnemom-Min-CLI` header (mnemom-api MIN_CLI_VERSION). This
6
+ * module reads that header off any API response and, if THIS CLI is below the
7
+ * floor, prints a loud upgrade instruction and hard-exits.
8
+ *
9
+ * Why hard-fail: an outdated CLI can silently drop request fields a newer server
10
+ * expects and produce a wrong-but-successful result with no error. The canonical
11
+ * case that motivated this: a CLI predating claim-to-org (ADR-062) accepted
12
+ * `--org` but never sent `org_id`, so `agents claim … --org <slug>` cheerfully
13
+ * landed the agent in the caller's personal org. Failing loudly beats a silent
14
+ * wrong outcome.
15
+ */
16
+ import { CLI_VERSION } from "../version.js";
17
+ const MIN_CLI_HEADER = "x-mnemom-min-cli";
18
+ /**
19
+ * Compare dotted numeric versions. Returns true iff `current` is strictly below
20
+ * `floor`. Prerelease/build suffixes (after `-` or `+`) are ignored — the floor
21
+ * is expressed as a plain release, and a prerelease of that release is treated
22
+ * as the release for gating purposes (we don't block `-rc` builds of a good ver).
23
+ */
24
+ export function isBelowVersion(current, floor) {
25
+ const parse = (v) => v
26
+ .trim()
27
+ .split(/[-+]/)[0]
28
+ .split(".")
29
+ .map((n) => {
30
+ const parsed = Number.parseInt(n, 10);
31
+ return Number.isNaN(parsed) ? 0 : parsed;
32
+ });
33
+ const c = parse(current);
34
+ const f = parse(floor);
35
+ const len = Math.max(c.length, f.length);
36
+ for (let i = 0; i < len; i++) {
37
+ const cv = c[i] ?? 0;
38
+ const fv = f[i] ?? 0;
39
+ if (cv < fv)
40
+ return true;
41
+ if (cv > fv)
42
+ return false;
43
+ }
44
+ return false; // equal → supported
45
+ }
46
+ // Guard so a command issuing several requests exits exactly once.
47
+ let exited = false;
48
+ /**
49
+ * Enforce the server-advertised minimum CLI version against `response`.
50
+ * No-op when the header is absent (older server, or a non-API response) or when
51
+ * this CLI is at/above the floor. Otherwise writes a clear message to stderr and
52
+ * exits with code 1.
53
+ *
54
+ * @param exit injectable for tests; defaults to process.exit
55
+ * @param write injectable for tests; defaults to process.stderr.write
56
+ */
57
+ export function enforceMinCliVersion(response, exit = process.exit, write = (chunk) => void process.stderr.write(chunk)) {
58
+ // Defensive: some callers/tests provide a Response-like object without a real
59
+ // Headers instance. Treat a missing/incompatible headers bag as "no floor".
60
+ const headers = response?.headers;
61
+ const floor = typeof headers?.get === "function" ? headers.get(MIN_CLI_HEADER) : null;
62
+ if (!floor)
63
+ return;
64
+ if (!isBelowVersion(CLI_VERSION, floor))
65
+ return;
66
+ if (exited)
67
+ return;
68
+ exited = true;
69
+ write(`\n✖ Your Mnemom CLI (v${CLI_VERSION}) is too old for this server (minimum v${floor}).\n` +
70
+ ` Old CLIs can silently drop newer request fields — e.g. \`--org\` on \`mnemom agents claim\`.\n\n` +
71
+ ` Upgrade: npm i -g @mnemom/mnemom@latest\n\n`);
72
+ exit(1);
73
+ }
74
+ /**
75
+ * Drop-in wrapper around global fetch for Mnemom API calls: issues the request,
76
+ * enforces the advertised minimum CLI version on the response, then returns it.
77
+ */
78
+ export async function mnemomFetch(input, init) {
79
+ // Preserve the exact call shape: fetch(input) when no init, so callers and
80
+ // tests that assert a single-arg fetch stay valid.
81
+ const response = init === undefined ? await fetch(input) : await fetch(input, init);
82
+ enforceMinCliVersion(response);
83
+ return response;
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "smoltbot": "./dist/smoltbot-shim.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "npm install && npm install --prefix ../shared/policy-engine && npm run build --prefix ../shared/policy-engine && tsc",
11
+ "build": "tsc",
12
12
  "dev": "tsx src/index.ts",
13
13
  "gen:command-tree": "tsx scripts/gen-command-tree.mjs",
14
14
  "check:command-tree": "tsx scripts/gen-command-tree.mjs --check",
@@ -19,15 +19,15 @@
19
19
  "@mnemom/policy-engine": "^0.3.0",
20
20
  "chalk": "^5.3.0",
21
21
  "commander": "^12.0.0",
22
- "js-yaml": "^4.1.1"
22
+ "js-yaml": "^4.3.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/js-yaml": "^4.0.9",
26
26
  "@types/node": "^20.10.0",
27
- "@vitest/coverage-v8": "^1.6.1",
28
- "tsx": "^4.7.0",
29
- "typescript": "^5.3.3",
30
- "vitest": "^1.2.0"
27
+ "@vitest/coverage-v8": "^4.1.10",
28
+ "tsx": "^4.23.1",
29
+ "typescript": "^6.0.3",
30
+ "vitest": "^4.1.10"
31
31
  },
32
32
  "files": [
33
33
  "dist",