@mnemom/mnemom 0.17.38-next.0 → 0.17.38-next.1

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.
@@ -10,7 +10,7 @@
10
10
  * (200 + the x-mnemom-verdict header that proves the governance pipeline is
11
11
  * live), Remote-Control capability (node + openssl + the --remote-control flag),
12
12
  * claude.ai login (informational — Remote Control needs it), the agent's governance
13
- * modes (off by default for new agents), and CLI auto-update status.
13
+ * modes (integrity observe, Safe House off for new agents), and CLI auto-update status.
14
14
  */
15
15
  type Mark = "ok" | "warn" | "miss" | "info";
16
16
  export interface DoctorOptions {
@@ -51,7 +51,7 @@ export interface GovernanceProbe {
51
51
  /**
52
52
  * The governance line: the composed (every layer, strictest wins) integrity and
53
53
  * Safe House modes of the agent a launch would use. Signed out, no such agent,
54
- * or an unreadable card → info with the off-by-default note, never a MISS
54
+ * or an unreadable card → info with the new-agent default note, never a MISS
55
55
  * (governance never blocks a launch). On → info naming the per-turn cost; a
56
56
  * stricter layer outranks the agent's own card (any rung up the ladder off <
57
57
  * observe < nudge < enforce, MNE-8612) → warn naming the layer.
@@ -10,7 +10,7 @@
10
10
  * (200 + the x-mnemom-verdict header that proves the governance pipeline is
11
11
  * live), Remote-Control capability (node + openssl + the --remote-control flag),
12
12
  * claude.ai login (informational — Remote Control needs it), the agent's governance
13
- * modes (off by default for new agents), and CLI auto-update status.
13
+ * modes (integrity observe, Safe House off for new agents), and CLI auto-update status.
14
14
  */
15
15
  import { spawnSync } from "node:child_process";
16
16
  import { existsSync } from "node:fs";
@@ -119,7 +119,7 @@ export async function agentDoctorCommand(options = {}) {
119
119
  add("info", "claude.ai login: could not determine");
120
120
  }
121
121
  add(gate.mark, gate.text, gate.hint);
122
- // ── governance (off by default for new agents) ──
122
+ // ── governance (integrity observe, Safe House off for new agents) ──
123
123
  const gov = await probeGovernance(options.agent);
124
124
  add(gov.mark, gov.text, gov.hint);
125
125
  // ── CLI auto-update ──
@@ -236,7 +236,7 @@ const OPT_IN_HINT = GOVERNANCE_OPT_IN_HINT.trim();
236
236
  /**
237
237
  * The governance line: the composed (every layer, strictest wins) integrity and
238
238
  * Safe House modes of the agent a launch would use. Signed out, no such agent,
239
- * or an unreadable card → info with the off-by-default note, never a MISS
239
+ * or an unreadable card → info with the new-agent default note, never a MISS
240
240
  * (governance never blocks a launch). On → info naming the per-turn cost; a
241
241
  * stricter layer outranks the agent's own card (any rung up the ladder off <
242
242
  * observe < nudge < enforce, MNE-8612) → warn naming the layer.
@@ -244,7 +244,7 @@ const OPT_IN_HINT = GOVERNANCE_OPT_IN_HINT.trim();
244
244
  export async function probeGovernance(agent) {
245
245
  const offByDefault = {
246
246
  mark: "info",
247
- text: "governance: off by default for new agents (AIP integrity analysis, Safe House screening)",
247
+ text: "governance: new agents get AIP integrity analysis (observe); Safe House screening is off by default",
248
248
  hint: `${OPT_IN_HINT} Agents set up before this default keep their cards.`,
249
249
  };
250
250
  if (!(await isLoggedIn()))
@@ -6,12 +6,13 @@
6
6
  * governed identity `agent-<handle>`. This module makes that identity REAL in
7
7
  * their Mnemom org: it births the agent through the gateway, claims it (so the
8
8
  * customer owns it), and publishes a sane default coding posture. Governance that
9
- * costs a model call is OFF by default (operator 2026-10-02: AIP integrity and
10
- * Safe House analysis are billed, so nobody pays for them until they turn them
11
- * on); governance that costs none stays ON (operator 2026-10-03: autonomy is
9
+ * costs a model call is split (operator 2026-11-04, MNE-9728, matching the API new-agent
10
+ * default): AIP integrity analysis is ON (observe) for a new agent so it is analyzed and can earn
11
+ * a reputation rating, billed at cost; Safe House screening stays OFF until asked for (operator
12
+ * 2026-10-02); governance that costs none stays ON (operator 2026-10-03: autonomy is
12
13
  * rules-only verification + policy). The goal contract is what nudges (re-anchor
13
14
  * on drift). Specifically:
14
- * - alignment card: autonomy_mode = observe, integrity_mode = off, the agent's
15
+ * - alignment card: autonomy_mode = observe, integrity_mode = observe, the agent's
15
16
  * org as principal (delegated_authority), Claude Code tool names as bounded
16
17
  * actions, coding forbidden actions, defined values, grace 0, and no default
17
18
  * escalation trigger for irreversible operations (the customer adds their own);
@@ -22,8 +23,8 @@
22
23
  * older default's content and keeps the agent's modes.
23
24
  * - goal-alignment: nudge — sealed per-conversation via the goal contract
24
25
  * (--goal …), graded and re-anchored by the gateway.
25
- * `--governance [integrity,safe-house]` opts a setup in (observe-only, billed at
26
- * cost on top of the agent fee); `--no-governance` turns every card off, autonomy
26
+ * `--governance [integrity,safe-house]` picks exactly those features on (observe-only,
27
+ * billed at cost on top of the agent fee; `--governance none` opts out of both); `--no-governance` turns every card off, autonomy
27
28
  * included; a refresh of an already-claimed agent keeps the
28
29
  * modes its own cards carry unless a --governance / --no-governance flag says otherwise.
29
30
  *
@@ -40,7 +41,8 @@ import { type CardSources, type CodeEntitlement } from "../lib/api.js";
40
41
  export type GovernanceMode = "off" | "observe";
41
42
  /**
42
43
  * The governance features a setup turns on. Each is a model call per turn that
43
- * the customer pays for at cost, so both are off unless asked for.
44
+ * the customer pays for at cost. Integrity is on for a new agent (opt out with `--governance none`
45
+ * or `--no-governance`); Safe House is off unless asked for.
44
46
  */
45
47
  export interface GovernanceOptIn {
46
48
  /**
@@ -58,7 +60,7 @@ export interface GovernanceOptIn {
58
60
  export declare const GOVERNANCE_FEATURES: readonly ["integrity", "safe-house"];
59
61
  /**
60
62
  * Parse the `--governance [features]` / `--no-governance` flag. undefined = not
61
- * given (new agents get autonomy only; a refresh keeps the agent's current modes),
63
+ * given (new agents get autonomy + integrity; a refresh keeps the agent's current modes),
62
64
  * true = every feature, false (`--no-governance`) = everything off, autonomy
63
65
  * included, a string = a comma-separated list of `integrity`, `safe-house`, `all`
64
66
  * or `none`, each keeping autonomy on. Throws on an unknown name.
@@ -140,7 +142,7 @@ interface ExistingCards {
140
142
  align: Record<string, unknown> | null;
141
143
  prot: Record<string, unknown> | null;
142
144
  }
143
- /** Printed when a setup ran without --governance: how to opt in, and what it costs. */
145
+ /** Printed when a setup ran without --governance: what is on by default, how to change it, and what it costs. */
144
146
  export declare const GOVERNANCE_OPT_IN_HINT: string;
145
147
  /**
146
148
  * The default governed-agent slug for the current customer: `agent-<handle>`,
@@ -6,12 +6,13 @@
6
6
  * governed identity `agent-<handle>`. This module makes that identity REAL in
7
7
  * their Mnemom org: it births the agent through the gateway, claims it (so the
8
8
  * customer owns it), and publishes a sane default coding posture. Governance that
9
- * costs a model call is OFF by default (operator 2026-10-02: AIP integrity and
10
- * Safe House analysis are billed, so nobody pays for them until they turn them
11
- * on); governance that costs none stays ON (operator 2026-10-03: autonomy is
9
+ * costs a model call is split (operator 2026-11-04, MNE-9728, matching the API new-agent
10
+ * default): AIP integrity analysis is ON (observe) for a new agent so it is analyzed and can earn
11
+ * a reputation rating, billed at cost; Safe House screening stays OFF until asked for (operator
12
+ * 2026-10-02); governance that costs none stays ON (operator 2026-10-03: autonomy is
12
13
  * rules-only verification + policy). The goal contract is what nudges (re-anchor
13
14
  * on drift). Specifically:
14
- * - alignment card: autonomy_mode = observe, integrity_mode = off, the agent's
15
+ * - alignment card: autonomy_mode = observe, integrity_mode = observe, the agent's
15
16
  * org as principal (delegated_authority), Claude Code tool names as bounded
16
17
  * actions, coding forbidden actions, defined values, grace 0, and no default
17
18
  * escalation trigger for irreversible operations (the customer adds their own);
@@ -22,8 +23,8 @@
22
23
  * older default's content and keeps the agent's modes.
23
24
  * - goal-alignment: nudge — sealed per-conversation via the goal contract
24
25
  * (--goal …), graded and re-anchored by the gateway.
25
- * `--governance [integrity,safe-house]` opts a setup in (observe-only, billed at
26
- * cost on top of the agent fee); `--no-governance` turns every card off, autonomy
26
+ * `--governance [integrity,safe-house]` picks exactly those features on (observe-only,
27
+ * billed at cost on top of the agent fee; `--governance none` opts out of both); `--no-governance` turns every card off, autonomy
27
28
  * included; a refresh of an already-claimed agent keeps the
28
29
  * modes its own cards carry unless a --governance / --no-governance flag says otherwise.
29
30
  *
@@ -55,7 +56,7 @@ const say = (line = "") => console.error(line);
55
56
  export const GOVERNANCE_FEATURES = ["integrity", "safe-house"];
56
57
  /**
57
58
  * Parse the `--governance [features]` / `--no-governance` flag. undefined = not
58
- * given (new agents get autonomy only; a refresh keeps the agent's current modes),
59
+ * given (new agents get autonomy + integrity; a refresh keeps the agent's current modes),
59
60
  * true = every feature, false (`--no-governance`) = everything off, autonomy
60
61
  * included, a string = a comma-separated list of `integrity`, `safe-house`, `all`
61
62
  * or `none`, each keeping autonomy on. Throws on an unknown name.
@@ -219,15 +220,16 @@ export function newAgentModes(governance) {
219
220
  const autonomy = modeOf(governance?.autonomy ?? true);
220
221
  return {
221
222
  autonomy,
222
- integrity: modeOf(governance?.integrity ?? false),
223
+ integrity: modeOf(governance?.integrity ?? true),
223
224
  protection: modeOf(governance?.safeHouse ?? false),
224
225
  allowUnmappedTools: autonomy === "observe" ? true : undefined,
225
226
  };
226
227
  }
227
228
  /**
228
229
  * The plan for refreshing an EXISTING agent: an explicit --governance /
229
- * --no-governance wins; else it keeps the modes its own cards carry. No agent-layer
230
- * card → the new-agent defaults (it never had one). A card with no mode field →
230
+ * --no-governance wins; else it keeps the modes its own cards carry. No agent-layer card → autonomy and Safe House get the
231
+ * new-agent defaults but integrity stays off (an existing agent is never silently opted in to billed
232
+ * analysis; MNE-9728). A card with no mode field →
231
233
  * observe (the prior CLI always wrote observe). A failed read → null: skip the refresh.
232
234
  */
233
235
  async function existingAgentPlan(agentId, governance) {
@@ -252,7 +254,7 @@ async function existingAgentPlan(agentId, governance) {
252
254
  return {
253
255
  modes: {
254
256
  autonomy: align ? (modeField(align, "autonomy_mode") ?? "observe") : fresh.autonomy,
255
- integrity: align ? (modeField(align, "integrity_mode") ?? "observe") : fresh.integrity,
257
+ integrity: align ? (modeField(align, "integrity_mode") ?? "observe") : "off", // an existing agent with no card of its own is not silently opted in to billed analysis (MNE-9728)
256
258
  protection: prot ? (modeField(prot, "mode") ?? "observe") : fresh.protection,
257
259
  allowUnmappedTools: align
258
260
  ? typeof ownAllow === "boolean"
@@ -263,9 +265,10 @@ async function existingAgentPlan(agentId, governance) {
263
265
  existing: { align, prot },
264
266
  };
265
267
  }
266
- /** Printed when a setup ran without --governance: how to opt in, and what it costs. */
267
- export const GOVERNANCE_OPT_IN_HINT = " Governance (AIP integrity analysis, Safe House screening) is off by default. Turn it on with " +
268
- "`mnemom agent setup --governance` (or --governance integrity / safe-house); it is billed at cost on top of the agent fee.";
268
+ /** Printed when a setup ran without --governance: what is on by default, how to change it, and what it costs. */
269
+ export const GOVERNANCE_OPT_IN_HINT = " AIP integrity analysis is on (observe) for new agents; Safe House screening is off by default. " +
270
+ "Opt out of integrity with `mnemom agent setup --governance none` (or --no-governance); turn Safe House on with " +
271
+ "`--governance integrity,safe-house`. Both are billed at cost on top of the agent fee.";
269
272
  /**
270
273
  * The default governed-agent slug for the current customer: `agent-<handle>`,
271
274
  * where <handle> is the local-part of their mnemom login email (sanitised), or
@@ -457,8 +460,8 @@ async function confirmClaimed(opts, anchorKey, fp) {
457
460
  const plan = await existingAgentPlan(agentId, opts.governance);
458
461
  if (plan) {
459
462
  if (!opts.governance) {
460
- say(fmt.dim(` Keeping its current governance (${describeModes(plan.modes)}). New agents default to off; ` +
461
- "`--governance` turns it on, `--no-governance` turns it off."));
463
+ say(fmt.dim(` Keeping its current governance (${describeModes(plan.modes)}). New agents default to integrity observe; ` +
464
+ "`--governance` picks features, `--no-governance` turns everything off."));
462
465
  }
463
466
  // The principal is the agent's own org, which a refresh may launch from another.
464
467
  const ownOrg = await agentOrgId(agentId);
@@ -511,7 +514,7 @@ async function confirmClaimed(opts, anchorKey, fp) {
511
514
  }
512
515
  say(fmt.success(`mnemom agent: claimed '${slug}' (${agentId}) in ${opts.orgLabel}.`));
513
516
  await noteSameNamedAgent(slug, agentId);
514
- // Publish the default cards (autonomy observe; integrity + Safe House off unless
517
+ // Publish the default cards (autonomy + integrity observe; Safe House off unless
515
518
  // --governance). A card write that can't complete does NOT undo the claim — the agent is
516
519
  // claimed and governed on whatever landed; we warn how to finish it.
517
520
  if (opts.setup !== false) {
@@ -1443,9 +1446,11 @@ function carryForward(card, kind) {
1443
1446
  return card;
1444
1447
  return upgradeInPlace(card, kind) ?? card;
1445
1448
  }
1446
- export function buildCodeAlignmentCard(agentId, modes = {
1449
+ export function buildCodeAlignmentCard(agentId,
1450
+ // Every production caller passes modes; the fallback's integrity matches the new-agent default (MNE-9728).
1451
+ modes = {
1447
1452
  autonomy: "off",
1448
- integrity: "off",
1453
+ integrity: "observe",
1449
1454
  }, principal = UNSPECIFIED_PRINCIPAL) {
1450
1455
  return {
1451
1456
  card_version: "unified/2026-04-26",
@@ -885,7 +885,7 @@ export async function cardEditCommand(agentName, options = {}) {
885
885
  card_version: "unified/2026-04-26",
886
886
  agent_id: agentId,
887
887
  autonomy_mode: "observe",
888
- integrity_mode: "off",
888
+ integrity_mode: "observe", // new-agent default (MNE-9728); set to off to opt out
889
889
  enforcement: { allow_unmapped_tools: true },
890
890
  principal: { type: "agent", identifier: agentId, relationship: "delegated_authority" },
891
891
  values: { declared: ["transparency", "safety", "honesty"] },
@@ -45,6 +45,7 @@ import { isInteractive, askInput, askYesNo } from "../lib/prompt.js";
45
45
  import { getWebsiteUrl } from "../lib/config.js";
46
46
  import { openBrowser } from "../lib/oauth.js";
47
47
  import { fmt } from "../lib/format.js";
48
+ import { starterIntegrityMode } from "../lib/starter-integrity.js";
48
49
  const FULL_PROOF_RE = /^[0-9a-f]{64}$/;
49
50
  /**
50
51
  * Entry point for `mnemom onboard`. Throws on a fatal error (caught by the
@@ -94,7 +95,7 @@ export async function onboardCommand(options = {}) {
94
95
  say();
95
96
  say(`${fmt.badge("alignment", "cyan")} Declaring your alignment card…`);
96
97
  try {
97
- await putAlignmentCard(agentId, JSON.stringify(buildStarterAlignmentCard(agentId)), "application/json",
98
+ await putAlignmentCard(agentId, JSON.stringify(buildStarterAlignmentCard(agentId, await starterIntegrityMode(agentId))), "application/json",
98
99
  // A STABLE key so a re-run replays the same server-side reservation
99
100
  // (idempotent no-op) rather than writing a divergent duplicate card.
100
101
  { idempotencyKey: `onboard-alignment-${agentId}` });
@@ -353,16 +354,16 @@ async function earnRating(agentId, steps, say) {
353
354
  // ── starter alignment card ──────────────────────────────────────────────────────
354
355
  /**
355
356
  * A starter alignment card: autonomy observe (rules only, no model call), integrity
356
- * `off` (alignment analysis is billed; the user turns it on) — the honest, minimal declaration
357
+ * as the agent's own card has it (observe for a new agent, MNE-9728; see lib/starter-integrity.ts) — the honest, minimal declaration
357
358
  * of intent for a freshly-onboarded agent. Mirrors the shape `wrap` seeds so the
358
359
  * two skill-runners produce a consistent first card.
359
360
  */
360
- function buildStarterAlignmentCard(agentId) {
361
+ function buildStarterAlignmentCard(agentId, integrityMode) {
361
362
  return {
362
363
  card_version: "unified/2026-04-26",
363
364
  agent_id: agentId,
364
365
  autonomy_mode: "observe",
365
- integrity_mode: "off",
366
+ integrity_mode: integrityMode, // the agent's own mode; observe for a new agent (MNE-9728)
366
367
  // No capability mappings here, so unmapped tools must pass or every turn fails.
367
368
  enforcement: { allow_unmapped_tools: true },
368
369
  principal: { type: "agent", identifier: agentId, relationship: "delegated_authority" },
@@ -13,7 +13,6 @@ import { type RecordDeps, type RecordOutcome } from "../lib/scan/record.js";
13
13
  import { type CostSpend } from "../lib/scan/cost-per-pr.js";
14
14
  import { type ReportEmailDeps } from "../lib/scan/report-email.js";
15
15
  import { type RepoListDeps } from "../lib/scan/repo-picker.js";
16
- import { type ReportRecipients } from "../lib/scan/share.js";
17
16
  export interface ScanCommandOptions {
18
17
  repos: string[];
19
18
  period?: {
@@ -21,8 +20,10 @@ export interface ScanCommandOptions {
21
20
  end: string;
22
21
  };
23
22
  share?: string;
24
- /** Report recipient. Required unless the runner is the CTO (asked when omitted). */
23
+ /** Alias of one --email (kept for older scripts). */
25
24
  ctoEmail?: string;
25
+ /** Extra recipients of the report (repeatable; comma-separated accepted). The runner and every org admin always get it. */
26
+ email?: string[];
26
27
  /** Tier 2: the CTO's own monthly figure, as typed. Needs MNEMOM_ORG_SCAN_MODE=true. */
27
28
  monthlySpend?: string;
28
29
  /** Tier 2 without a figure on the command line: ask for one on the terminal. */
@@ -71,11 +72,24 @@ export interface ScanCommandOptions {
71
72
  /** Parses repeated/comma-separated `--repo owner/name`. Returns null on any invalid slug. */
72
73
  export declare function parseRepoFlags(raw: string[] | undefined): string[] | null;
73
74
  export declare const isEmail: (v: unknown) => v is string;
74
- /** The runner's email (mnemom login, else asked) and the CTO's (flag, else "are you the CTO?"). */
75
- export declare function resolveRecipients(o: {
76
- runnerEmail?: string;
75
+ export declare const DELETE_CONTACT = "privacy@mnemom.ai";
76
+ export declare const EMAIL_ONE_MSG = "Enter one email address, like you@company.com.";
77
+ export declare const EMAIL_LIST_MSG = "Enter email addresses like you@company.com, separated by commas.";
78
+ export declare const MAX_EXTRA_EMAILS = 10;
79
+ /**
80
+ * The people to add to the report email (the runner and every org admin always get it). Comes from the
81
+ * repeatable --email flag (and its alias --cto-email), each value one address or a comma-separated list;
82
+ * on a terminal with none given, asks once (blank to skip). Plain-language errors; the value is never echoed.
83
+ */
84
+ export declare function resolveExtraEmails(o: {
85
+ email?: string[];
77
86
  ctoEmail?: string;
78
- }): Promise<ReportRecipients>;
87
+ isTty?: boolean;
88
+ }): Promise<string[]>;
89
+ /** The runner's own address from the Mnemom login, or undefined (the server resolves it for the email either way). */
90
+ export declare function runnerEmailFromLogin(o: {
91
+ runnerEmail?: string;
92
+ }): string | undefined;
79
93
  /** The CTO's figure in USD, or null for none. Throws fixed text only; the input is never echoed. */
80
94
  export declare function resolveSelfReportedSpend(o: ScanCommandOptions): Promise<number | null>;
81
95
  /**
@@ -28,29 +28,61 @@ export function parseRepoFlags(raw) {
28
28
  .filter(Boolean);
29
29
  return out.length > 0 && out.every(isValidRepoSlug) ? out : null;
30
30
  }
31
- const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{2,63}$/;
32
- export const isEmail = (v) => typeof v === "string" && v.length <= 320 && EMAIL.test(v.trim());
33
- /** The runner's email (mnemom login, else asked) and the CTO's (flag, else "are you the CTO?"). */
34
- export async function resolveRecipients(o) {
35
- if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
36
- throw new Error("--cto-email must be a valid email address");
37
- let runner = o.runnerEmail ?? getAuthInfo()?.email ?? "";
38
- if (!isEmail(runner))
39
- runner = (await askInput("Your email (the report is sent to you):")).trim();
40
- if (!isEmail(runner))
41
- throw new Error("a valid runner email is required");
42
- runner = runner.trim();
43
- if (o.ctoEmail !== undefined)
44
- return { runner_email: runner, cto_email: o.ctoEmail.trim() };
45
- const isCto = await askSelect("Are you the CTO?", ["Yes, I am the CTO", "No, someone else is"]);
46
- if (isCto === null)
47
- throw new Error("--cto-email is required when you are not the CTO");
48
- if (isCto.startsWith("Yes"))
49
- return { runner_email: runner, cto_email: runner };
50
- const cto = (await askInput("CTO email (the report is sent to them too):")).trim();
51
- if (!isEmail(cto))
52
- throw new Error("--cto-email is required when you are not the CTO");
53
- return { runner_email: runner, cto_email: cto };
31
+ /**
32
+ * Same rule as the API: isPlainEmail in mnemom/mnemom-api src/org-scan/report-email/notifier.ts (no shared
33
+ * package, so this is a copy). Drift guard: the "plain-email rule edge" table in src/__tests__/scan.test.ts;
34
+ * change both together. No spaces, control chars or <>",; ; one @; every domain label non-empty; max 254. */
35
+ const plainEmail = (a) => {
36
+ if (a.length === 0 || a.length > 254)
37
+ return false;
38
+ for (let i = 0; i < a.length; i++) {
39
+ const c = a.charCodeAt(i);
40
+ if (c <= 32 || c === 127 || '<>",;'.includes(a[i]))
41
+ return false;
42
+ }
43
+ const at = a.indexOf("@");
44
+ if (at < 1 || at !== a.lastIndexOf("@"))
45
+ return false;
46
+ const labels = a.slice(at + 1).split(".");
47
+ return labels.length >= 2 && labels.every((l) => l.length > 0);
48
+ };
49
+ export const isEmail = (v) => typeof v === "string" && plainEmail(v.trim());
50
+ export const DELETE_CONTACT = "privacy@mnemom.ai";
51
+ export const EMAIL_ONE_MSG = "Enter one email address, like you@company.com.";
52
+ export const EMAIL_LIST_MSG = "Enter email addresses like you@company.com, separated by commas.";
53
+ export const MAX_EXTRA_EMAILS = 10;
54
+ /**
55
+ * The people to add to the report email (the runner and every org admin always get it). Comes from the
56
+ * repeatable --email flag (and its alias --cto-email), each value one address or a comma-separated list;
57
+ * on a terminal with none given, asks once (blank to skip). Plain-language errors; the value is never echoed.
58
+ */
59
+ export async function resolveExtraEmails(o) {
60
+ const given = [...(o.email ?? []), ...(o.ctoEmail !== undefined ? [o.ctoEmail] : [])];
61
+ let raw = given;
62
+ if (given.length === 0) {
63
+ if (!(o.isTty ?? process.stdin.isTTY))
64
+ return [];
65
+ const typed = ((await askInput("Anyone else to email the report to? Enter addresses separated by commas, or press Enter to skip:")) ?? "").trim();
66
+ if (typed === "")
67
+ return [];
68
+ raw = [typed];
69
+ }
70
+ const list = raw
71
+ .flatMap((v) => v.split(","))
72
+ .map((v) => v.trim())
73
+ .filter(Boolean);
74
+ if (list.length === 0 || !list.every(isEmail))
75
+ throw new Error(raw.length > 1 || raw.some((v) => v.includes(",")) ? EMAIL_LIST_MSG : EMAIL_ONE_MSG);
76
+ const seen = new Set();
77
+ const unique = list.filter((a) => !seen.has(a.toLowerCase()) && seen.add(a.toLowerCase()));
78
+ if (unique.length > MAX_EXTRA_EMAILS)
79
+ throw new Error(`Add at most ${MAX_EXTRA_EMAILS} extra email addresses.`);
80
+ return unique;
81
+ }
82
+ /** The runner's own address from the Mnemom login, or undefined (the server resolves it for the email either way). */
83
+ export function runnerEmailFromLogin(o) {
84
+ const e = (o.runnerEmail ?? getAuthInfo()?.email ?? "").trim();
85
+ return isEmail(e) ? e : undefined;
54
86
  }
55
87
  /** The CTO's figure in USD, or null for none. Throws fixed text only; the input is never echoed. */
56
88
  export async function resolveSelfReportedSpend(o) {
@@ -238,16 +270,20 @@ export async function resolveRecordOutcome(o) {
238
270
  }
239
271
  export async function scanCommand(o) {
240
272
  if (o.share !== undefined && parseShareLevel(o.share) === null) {
241
- throw new Error("--share must be one of: summary, full, none");
273
+ throw new Error("--share must be one of: full, analytics, none (summary also works, same as analytics)");
242
274
  }
243
275
  const noRepoFlag = o.repos.length === 0;
276
+ const interactive = Boolean(o.isTty ?? process.stdin.isTTY);
244
277
  // No --repo: on a terminal pick from the repos this GitHub login can read; otherwise the error stays, with a hint.
245
- if (noRepoFlag && !(o.isTty ?? process.stdin.isTTY))
278
+ if (noRepoFlag && !interactive)
246
279
  throw new Error(`--repo owner/name is required (repeatable). ${REPO_PICKER_NO_TERMINAL_HINT}`);
280
+ // Consent comes first, before any data is processed (MNE-9634). The flag wins; on a terminal with no flag we
281
+ // ask (Full preselected); with no terminal and no flag the answer is Nothing (fail closed).
282
+ const level = parseShareLevel(o.share) ?? (interactive ? await promptShareLevel() : "none");
283
+ // The people added to the report email; the runner and every org admin always get it.
284
+ const extraEmails = await resolveExtraEmails(o);
247
285
  // The report is emailed on every scan, so resolve the org before any scan work; failure is loud.
248
286
  const emailOrg = await resolveScanOrg(o);
249
- if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
250
- throw new Error("--cto-email must be a valid email address");
251
287
  const getCredential = async () => {
252
288
  const c = o.credential ?? (await resolveCredential().catch(() => null));
253
289
  if (!c)
@@ -292,19 +328,23 @@ export async function scanCommand(o) {
292
328
  else if (record !== null)
293
329
  for (const line of renderRecordSection(record))
294
330
  console.log(line);
295
- const level = parseShareLevel(o.share) ?? (await promptShareLevel());
296
331
  // Every successful scan emails the report to the person who ran it, at every share level, no opt-out.
297
332
  // Mnemom keeps nothing from a share=none send (the server sends the email and does nothing else).
298
- const emailFailure = await emailReport(o, emailOrg, result, level, spend, period, resolveCostSpend(o, result, spend, measured));
299
- if (level === "none") {
300
- console.log("Share level: nothing. No share payload is built; Mnemom keeps nothing (the report email is only sent to you).");
301
- if (emailFailure)
302
- throw new ReportEmailError(emailFailure);
303
- return;
333
+ const emailResult = await emailReport(o, emailOrg, result, level, spend, period, resolveCostSpend(o, result, spend, measured), extraEmails);
334
+ const emailFailure = emailResult === "not_sent" || emailResult === null ? null : emailResult;
335
+ // What was shared with Mnemom, and how to have it deleted (MNE-9634).
336
+ if (emailResult !== null) {
337
+ console.log("Kept on file by Mnemom: nothing, because the report was not sent.");
338
+ }
339
+ else if (level === "none") {
340
+ console.log("Kept on file by Mnemom: nothing. The report was emailed and Mnemom keeps nothing on file; its email provider keeps only a delivery log.");
304
341
  }
305
- const payload = buildSharePayload(level, result, await resolveRecipients(o), spend ? (spend.shareable ? spend.line : null) : undefined);
306
- console.log(`Share level: ${level}. Upload is not wired in this build; this is what would be sent:`);
307
- console.log(JSON.stringify(payload, null, 2));
342
+ else {
343
+ const payload = buildSharePayload(level, result, { runner_email: runnerEmailFromLogin(o), extra_emails: extraEmails }, spend ? (spend.shareable ? spend.line : null) : undefined);
344
+ console.log(`Kept on file by Mnemom: ${level === "full" ? "the totals and the counts for each repo, by name" : "the team totals only, with no repo names"}. Mnemom may keep a run record of only this much on file; the full report was emailed, not stored:`);
345
+ console.log(JSON.stringify(payload, null, 2));
346
+ }
347
+ console.log(`To have anything Mnemom holds about this scan deleted, email ${DELETE_CONTACT}.`);
308
348
  if (emailFailure)
309
349
  throw new ReportEmailError(emailFailure);
310
350
  }
@@ -312,7 +352,7 @@ export async function scanCommand(o) {
312
352
  * Emails the report. Loud on every failure: prints the reason name, never the server's words, and
313
353
  * returns it so the caller exits non-zero after the local report is shown.
314
354
  */
315
- async function emailReport(o, org, result, level, spend, period, costSpend) {
355
+ async function emailReport(o, org, result, level, spend, period, costSpend, extraEmails) {
316
356
  const fail = (f) => {
317
357
  console.log(failureMessage(f));
318
358
  return f;
@@ -324,17 +364,21 @@ async function emailReport(o, org, result, level, spend, period, costSpend) {
324
364
  period,
325
365
  costSpend,
326
366
  });
327
- const body = buildEmailBody(report, level, result.coverage.complete, o.ctoEmail?.trim());
367
+ const body = buildEmailBody(report, level, result.coverage.complete, extraEmails);
328
368
  const outcome = await sendReportEmail(org, body, o.emailDeps);
329
369
  if (outcome.status === "failed")
330
370
  return fail(outcome.reason);
331
371
  if (outcome.status === "unavailable") {
332
372
  // Org Scan is off by default: the gate's 404 is not a failure and must not break the scan.
333
373
  console.log(UNAVAILABLE_MSG);
334
- return null;
374
+ return "not_sent";
335
375
  }
336
376
  console.log(outcome.status === "dry_run"
337
377
  ? DRY_RUN_MSG
338
- : `Report emailed to you (${outcome.recipients} recipient${outcome.recipients === 1 ? "" : "s"}).`);
339
- return null;
378
+ : outcome.limited
379
+ ? "Report emailed in a limited form (your Mnemom service is older than this CLI): no per-repo counts, no org admins, and at most one added address."
380
+ : `Full report emailed to you, your org admins and anyone you added (${outcome.recipients} recipient${outcome.recipients === 1 ? "" : "s"}).`);
381
+ if (outcome.status === "sent" && outcome.adminsMissed)
382
+ console.log(`${outcome.adminsMissed} org admin${outcome.adminsMissed === 1 ? "" : "s"} could not be emailed because no email address is on file.`);
383
+ return outcome.status === "dry_run" ? "not_sent" : null;
340
384
  }
@@ -20,7 +20,8 @@
20
20
  * 3. Verify the JWS signature against the JWKS.
21
21
  * 4. Fetch the transparency-log row by log_index for the freshly-computed
22
22
  * inclusion proof, plus /v1/transparency/root for the signed root.
23
- * 5. Verify the Merkle inclusion proof against the root.
23
+ * 5. Verify the signed root's Ed25519 signature (typ AAP-TransparencyRoot/v1)
24
+ * with @mnemom/verify, then the Merkle inclusion proof against that root.
24
25
  * 6. Print a care-framed result.
25
26
  *
26
27
  * --strict mode bypasses the JWKS cache and exits non-zero on any
@@ -30,6 +31,7 @@
30
31
  * would-benefit-from vocabulary; stdout carries machine-readable
31
32
  * verification status.
32
33
  */
34
+ import type * as AipVerify from "@mnemom/verify";
33
35
  interface JwkPublic {
34
36
  kty: "OKP";
35
37
  crv: "Ed25519";
@@ -69,6 +71,14 @@ interface MerkleProof {
69
71
  position: "left" | "right";
70
72
  }[];
71
73
  }
74
+ interface SignedRoot {
75
+ typ?: string;
76
+ tree_size: number;
77
+ root_hash: string;
78
+ published_at: string;
79
+ signing_key_id: string;
80
+ signature: string;
81
+ }
72
82
  export interface VerifyCardOptions {
73
83
  agentId: string;
74
84
  at?: string;
@@ -86,6 +96,19 @@ interface JwsVerification {
86
96
  error?: string;
87
97
  }
88
98
  declare function verifyJws(jws: string, jwks: Jwks): Promise<JwsVerification>;
99
+ interface RootVerification {
100
+ ok: boolean;
101
+ kid?: string;
102
+ reasons: string[];
103
+ }
104
+ /**
105
+ * Verify the detached Ed25519 signature on GET /v1/transparency/root with the
106
+ * published offline verifier. `expectedTyp` pins the statement to
107
+ * AAP-TransparencyRoot/v1, so another signed statement cannot stand in for a
108
+ * root. A key missing from a cached JWKS reports key_not_found; --no-cache or
109
+ * --strict refetches it.
110
+ */
111
+ declare function verifySignedRoot(root: SignedRoot, jwks: Jwks, load?: () => Promise<Pick<typeof AipVerify, "verifyStatement">>): Promise<RootVerification>;
89
112
  declare function verifyInclusionProof(proof: MerkleProof, expectedRoot: string): Promise<boolean>;
90
113
  declare function internalHash(left: string, right: string): Promise<string>;
91
114
  declare function base64urlDecode(s: string): Uint8Array;
@@ -98,5 +121,6 @@ export declare const __testing: {
98
121
  base64urlDecode: typeof base64urlDecode;
99
122
  base64urlDecodeString: typeof base64urlDecodeString;
100
123
  verifyJws: typeof verifyJws;
124
+ verifySignedRoot: typeof verifySignedRoot;
101
125
  };
102
126
  export {};
@@ -20,7 +20,8 @@
20
20
  * 3. Verify the JWS signature against the JWKS.
21
21
  * 4. Fetch the transparency-log row by log_index for the freshly-computed
22
22
  * inclusion proof, plus /v1/transparency/root for the signed root.
23
- * 5. Verify the Merkle inclusion proof against the root.
23
+ * 5. Verify the signed root's Ed25519 signature (typ AAP-TransparencyRoot/v1)
24
+ * with @mnemom/verify, then the Merkle inclusion proof against that root.
24
25
  * 6. Print a care-framed result.
25
26
  *
26
27
  * --strict mode bypasses the JWKS cache and exits non-zero on any
@@ -65,7 +66,16 @@ export async function verifyCardCommand(options) {
65
66
  findings.push("merkle: transparency log is empty (no root to verify against)");
66
67
  }
67
68
  else {
68
- // Step 5: verify the Merkle inclusion proof.
69
+ // Step 5a: the root is only worth folding into if Mnemom signed it.
70
+ const rootCheck = await verifySignedRoot(root, jwks);
71
+ if (!rootCheck.ok) {
72
+ findings.push(`root: signature did not verify (${rootCheck.reasons.join(", ")})`);
73
+ exitCode = 1;
74
+ }
75
+ else {
76
+ findings.push(`root: signature ok (kid=${rootCheck.kid}, tree_size=${root.tree_size})`);
77
+ }
78
+ // Step 5b: verify the Merkle inclusion proof.
69
79
  const proofOk = await verifyInclusionProof(target.inclusion_proof, root.root_hash);
70
80
  if (!proofOk) {
71
81
  findings.push(`merkle: inclusion proof did not reconstruct root (log_index=${target.entry.log_index}, tree_size=${target.inclusion_proof.tree_size})`);
@@ -227,6 +237,22 @@ async function verifyJws(jws, jwks) {
227
237
  }
228
238
  return { ok: true, kid, payload };
229
239
  }
240
+ // ── Signed root verification ─────────────────────────────────────────
241
+ const ROOT_TYP = "AAP-TransparencyRoot/v1";
242
+ /**
243
+ * Verify the detached Ed25519 signature on GET /v1/transparency/root with the
244
+ * published offline verifier. `expectedTyp` pins the statement to
245
+ * AAP-TransparencyRoot/v1, so another signed statement cannot stand in for a
246
+ * root. A key missing from a cached JWKS reports key_not_found; --no-cache or
247
+ * --strict refetches it.
248
+ */
249
+ async function verifySignedRoot(root, jwks, load = () => import("@mnemom/verify")) {
250
+ const { verifyStatement } = await load();
251
+ const r = await verifyStatement(root, jwks, {
252
+ expectedTyp: ROOT_TYP,
253
+ });
254
+ return { ok: r.valid, kid: r.signing_key_id ?? undefined, reasons: r.reasons };
255
+ }
230
256
  // ── Merkle proof verification ────────────────────────────────────────
231
257
  async function verifyInclusionProof(proof, expectedRoot) {
232
258
  let current = proof.leaf_hash;
@@ -282,6 +308,7 @@ export const __testing = {
282
308
  base64urlDecode,
283
309
  base64urlDecodeString,
284
310
  verifyJws,
311
+ verifySignedRoot,
285
312
  };
286
313
  // Silence "MNEMOM_DIR unused" if the cache code path is taken in tests.
287
314
  void MNEMOM_DIR;
@@ -20,6 +20,7 @@ import { mergeAgentConfig } from "../lib/agent-config.js";
20
20
  import { askSelect, askInput, isInteractive } from "../lib/prompt.js";
21
21
  import { getGatewayUrl } from "../lib/config.js";
22
22
  import { fmt } from "../lib/format.js";
23
+ import { starterIntegrityMode } from "../lib/starter-integrity.js";
23
24
  import { OPENAI_WRAP_DEFAULT_MODEL, openaiBirthProbeBody } from "../lib/birth-probe.js";
24
25
  const AGENT_HEADER = "x-mnemom-agent";
25
26
  const PROVIDERS = {
@@ -98,7 +99,7 @@ export async function wrapCommand(options = {}) {
98
99
  say();
99
100
  say(`${fmt.badge("alignment", "cyan")} Seeding starter alignment card…`);
100
101
  try {
101
- await putAlignmentCard(agentId, JSON.stringify(buildStarterAlignmentCard(agentId)), "application/json");
102
+ await putAlignmentCard(agentId, JSON.stringify(buildStarterAlignmentCard(agentId, await starterIntegrityMode(agentId))), "application/json");
102
103
  steps.push({ step: "alignment", status: "ok" });
103
104
  say(fmt.success("Alignment card set."));
104
105
  }
@@ -261,12 +262,12 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
261
262
  return agentId;
262
263
  }
263
264
  // ── Starter cards ─────────────────────────────────────────────────────────────
264
- function buildStarterAlignmentCard(agentId) {
265
+ function buildStarterAlignmentCard(agentId, integrityMode) {
265
266
  return {
266
267
  card_version: "unified/2026-04-26",
267
268
  agent_id: agentId,
268
269
  autonomy_mode: "observe",
269
- integrity_mode: "off",
270
+ integrity_mode: integrityMode, // the agent's own mode; observe for a new agent (MNE-9728)
270
271
  // No capability mappings here, so unmapped tools must pass or every turn fails.
271
272
  enforcement: { allow_unmapped_tools: true },
272
273
  principal: { type: "agent", identifier: agentId, relationship: "delegated_authority" },
package/dist/index.js CHANGED
@@ -204,8 +204,8 @@ const agentCmd = program
204
204
  .option("--invite <token>", "Redeem a Mnemom Agent invitation (pinv_…): unlocks Mnemom Agent for you + credits your starter µ; launches too when a session name is given")
205
205
  .option("--setup", "Re-check the served agent and refresh its default cards before launch")
206
206
  .option("--no-setup", "Skip the default-card writes (the agent is still confirmed and claimed)")
207
- .option("--governance [features]", "Turn governance ON for the agent's cards: AIP integrity analysis + Safe House screening, observe-only (default: off for new agents; autonomy policy checks are rules only and on either way). Pick features with a comma list: integrity, safe-house. Billed at cost on top of the agent fee")
208
- .option("--no-governance", "Turn governance OFF for the agent's cards, autonomy policy checks included (by default new agents run autonomy observe, rules only; a card refresh otherwise keeps an existing agent's modes)")
207
+ .option("--governance [features]", "Choose the governance features for the agent's cards, observe-only: a comma list naming exactly the features to turn on: integrity, safe-house, all or none (default for new agents: integrity on, Safe House off; `--governance none` opts out of integrity; autonomy policy checks are rules only and on either way). Billed at cost on top of the agent fee")
208
+ .option("--no-governance", "Turn governance OFF for the agent's cards, autonomy policy checks included (by default new agents run autonomy observe, rules only, plus integrity observe; a card refresh otherwise keeps an existing agent's modes)")
209
209
  .option("--no-yolo", "Do NOT pass --dangerously-skip-permissions (yolo is on by default)")
210
210
  .option("--no-context-hint", "Do NOT seed Claude Code's context-hint feature flag before launch")
211
211
  // The Mnemom plugin lands dark: off by default and not in the published package, so its two
@@ -2383,12 +2383,13 @@ program
2383
2383
  });
2384
2384
  program
2385
2385
  .command("scan")
2386
- .description("Read-only local scan of GitHub PR metadata; the report is emailed to you every time (counts only, no repo names) and Mnemom keeps nothing at share level none")
2386
+ .description("Read-only local scan of GitHub PR metadata; the full report, with the counts for each repo, is always emailed to you, your org admins and anyone you add. --share chooses what Mnemom may keep")
2387
2387
  .option("--repo <owner/name...>", "Repo to scan (repeatable or comma-separated; omit on a terminal to pick from your repos)")
2388
2388
  .option("--start <YYYY-MM-DD>", "Period start (default: today)")
2389
2389
  .option("--end <YYYY-MM-DD>", "Period end (default: today)")
2390
- .option("--share <level>", "summary | full | none (default: full when asked; none when not interactive)")
2391
- .option("--cto-email <email>", "CTO who receives the report (required unless you are the CTO; asked when omitted)")
2390
+ .option("--share <level>", "What Mnemom may keep: full (totals and per-repo counts, default when asked) | analytics (team totals only, no repo names; summary is the old name) | none (nothing; the default when not interactive)")
2391
+ .option("--email <address...>", "Also email the report to these people (repeatable or comma-separated, up to 10, on your organization's email domain or mnemom.ai). You and every org admin always get it")
2392
+ .option("--cto-email <email>", "Same as --email (kept for older scripts)")
2392
2393
  .option("--monthly-spend <usd>", "Your own approximate monthly AI spend (self-reported; never sent or saved; needs MNEMOM_ORG_SCAN_MODE=true)")
2393
2394
  .option("--anthropic-admin-key", "Ask (masked) for your Anthropic admin key and read measured usage with it, locally; or set ANTHROPIC_ADMIN_KEY. Never stored, logged or sent anywhere but api.anthropic.com. Without this flag, workload identity federation is tried first when ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID and ANTHROPIC_SERVICE_ACCOUNT_ID are set (needs MNEMOM_ORG_SCAN_MODE=true)")
2394
2395
  .option("--no-anthropic-usage", "Decline Anthropic usage and be asked for a monthly figure (needs MNEMOM_ORG_SCAN_MODE=true)")
@@ -2405,6 +2406,7 @@ program
2405
2406
  period: { start: options.start ?? options.end ?? today, end: options.end ?? today },
2406
2407
  share: options.share,
2407
2408
  ctoEmail: options.ctoEmail,
2409
+ email: options.email,
2408
2410
  monthlySpend: options.monthlySpend,
2409
2411
  noAnthropicUsage: options.anthropicUsage === false,
2410
2412
  anthropicAdminKey: options.anthropicAdminKey === true,
@@ -56,6 +56,18 @@ export interface EmailReport {
56
56
  */
57
57
  cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
58
58
  cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
59
+ /**
60
+ * The per-repo counts, by name (MNE-9634). Always sent, at every share level: the emailed report is the full
61
+ * report. Optional on the wire so an API that predates it can be retried without it. Capped at 500 repos
62
+ * (busiest first), valid names only, each name once.
63
+ */
64
+ by_repo?: Array<{
65
+ repo: string;
66
+ pr_count: number;
67
+ revert_count: number;
68
+ automatable_count: number;
69
+ examined_count: number;
70
+ }>;
59
71
  }
60
72
  export interface ReportEmailBody {
61
73
  report: EmailReport;
@@ -63,6 +75,9 @@ export interface ReportEmailBody {
63
75
  coverage: {
64
76
  complete: boolean;
65
77
  };
78
+ /** The extra recipients the runner added. Absent when none. */
79
+ emails?: string[];
80
+ /** Only set on the retry to an API that predates `emails`: it reads one extra address under this name. */
66
81
  cto_email?: string;
67
82
  }
68
83
  /**
@@ -84,9 +99,9 @@ export declare function buildEmailReport(result: ScanResult, o: {
84
99
  /** The one spend source the cost figures are split from; null/absent leaves them out. */
85
100
  costSpend?: CostSpend | null;
86
101
  }): EmailReport;
87
- export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
102
+ export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, extraEmails?: string[]): ReportEmailBody;
88
103
  /** Closed set of failure names; each has fixed text. */
89
- export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "network_error" | "unexpected_response";
104
+ export type EmailFailure = "presend_check_failed" | "email_not_sent" | "not_signed_in" | "not_a_member" | "report_rejected" | "no_recipients" | "runner_not_emailed" | "emails_rejected" | "email_domain_not_allowed" | "admins_not_found" | "network_error" | "unexpected_response";
90
105
  export declare const failureMessage: (f: EmailFailure) => string;
91
106
  export declare const DRY_RUN_MSG = "Report email is in dry-run on this environment; nothing was sent.";
92
107
  export declare const UNAVAILABLE_MSG = "Report email is not available for this org yet; nothing was sent.";
@@ -97,6 +112,8 @@ export declare class ReportEmailError extends Error {
97
112
  export type EmailOutcome = {
98
113
  status: "sent";
99
114
  recipients: number;
115
+ adminsMissed?: number;
116
+ limited?: true;
100
117
  } | {
101
118
  status: "dry_run";
102
119
  } | {
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The report email: after every successful scan the person who ran it is emailed the report, with no
3
- * opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`
4
- * (mnemom-api#4270). The server resolves the runner's address from the signed-in identity, so the
5
- * request carries no runner address; `cto_email` is sent only when the user passed --cto-email.
2
+ * The report email: after every successful scan the full report, with the counts for each repo, is
3
+ * emailed to the person who ran it, every Org Admin of the org and anyone the runner added (MNE-9634),
4
+ * with no opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`.
5
+ * The server resolves the runner's address from the signed-in identity and the Org Admins from the org,
6
+ * so the request carries neither; `emails` holds only the extra addresses the runner added.
6
7
  *
7
- * The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals
8
- * and counts only), `share_level`, `coverage.complete` and the optional `cto_email`. No Record totals,
9
- * no repo names, no author, title, branch or body text, no credential.
8
+ * The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals,
9
+ * counts, and the per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional
10
+ * `emails`. No author, title, branch or body text, no credential.
10
11
  *
11
12
  * Every failure has a fixed name and fixed text. The server's body, the thrown value and the
12
13
  * credential never reach the terminal.
@@ -40,6 +41,7 @@ export function buildEmailReport(result, o) {
40
41
  // Only a scan that read every merged PR in the period gives an average that means anything.
41
42
  const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
42
43
  const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
44
+ const byRepo = repoRows(result);
43
45
  return {
44
46
  schema_version: REPORT_SCHEMA_VERSION,
45
47
  org_id: o.orgId,
@@ -72,14 +74,38 @@ export function buildEmailReport(result, o) {
72
74
  },
73
75
  estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
74
76
  ...cost,
77
+ ...(byRepo.length > 0 ? { by_repo: byRepo } : {}),
75
78
  };
76
79
  }
77
- export function buildEmailBody(report, level, complete, ctoEmail) {
80
+ const MAX_REPOS = 500;
81
+ const REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
82
+ /** The per-repo rows for the report: valid names only, each once (any case), busiest first, at most 500. */
83
+ function repoRows(result) {
84
+ const seen = new Set();
85
+ return [...result.byRepo]
86
+ .sort((a, b) => b.prs - a.prs || a.repo.localeCompare(b.repo))
87
+ .filter((r) => {
88
+ const k = r.repo.toLowerCase();
89
+ if (!REPO_NAME.test(r.repo) || seen.has(k))
90
+ return false;
91
+ seen.add(k);
92
+ return true;
93
+ })
94
+ .slice(0, MAX_REPOS)
95
+ .map((r) => ({
96
+ repo: r.repo,
97
+ pr_count: r.prs,
98
+ revert_count: r.reverts,
99
+ automatable_count: r.ciGreen,
100
+ examined_count: r.examined,
101
+ }));
102
+ }
103
+ export function buildEmailBody(report, level, complete, extraEmails = []) {
78
104
  return {
79
105
  report,
80
106
  share_level: level,
81
107
  coverage: { complete },
82
- ...(ctoEmail !== undefined ? { cto_email: ctoEmail } : {}),
108
+ ...(extraEmails.length > 0 ? { emails: extraEmails } : {}),
83
109
  };
84
110
  }
85
111
  const FAILURE_TEXT = {
@@ -89,7 +115,10 @@ const FAILURE_TEXT = {
89
115
  not_a_member: "your account is not a member of the org passed to --org.",
90
116
  report_rejected: "Mnemom rejected the report as malformed.",
91
117
  no_recipients: "the server accepted the report but reported zero recipients.",
92
- runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the CTO. Re-run the scan, and check that your account has a valid email address.",
118
+ runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the others. Re-run the scan, and check that your account has a valid email address.",
119
+ emails_rejected: "an address you added was not accepted. Add up to 10 valid email addresses.",
120
+ email_domain_not_allowed: "an address you added is not on your organization's email domain. Add addresses on your own domain or mnemom.ai.",
121
+ admins_not_found: "Mnemom could not look up your org admins, so no email was sent. Try again in a moment.",
93
122
  network_error: "Mnemom could not be reached.",
94
123
  unexpected_response: "Mnemom answered in an unexpected way.",
95
124
  };
@@ -118,11 +147,27 @@ function classifyStatus(status) {
118
147
  return "report_rejected";
119
148
  return "unexpected_response";
120
149
  }
121
- /** The body without the optional cost figures; the same object when it carries none. */
122
- function withoutCostFigures(body) {
123
- const { cost_per_merged_pr: _a, cost_by_pr_size: _b, ...rest } = body.report;
124
- return "cost_per_merged_pr" in body.report || "cost_by_pr_size" in body.report
125
- ? { ...body, report: rest }
150
+ /**
151
+ * The body an API that predates this contract understands: no cost figures, no per-repo rows, and the
152
+ * share level under its old name (`analytics` was `summary`; the meaning is the same, never upgraded).
153
+ * The same object when nothing needs changing.
154
+ */
155
+ function legacyBody(body) {
156
+ const { cost_per_merged_pr: _a, cost_by_pr_size: _b, by_repo: _c, ...rest } = body.report;
157
+ const changed = "cost_per_merged_pr" in body.report ||
158
+ "cost_by_pr_size" in body.report ||
159
+ "by_repo" in body.report ||
160
+ body.share_level === "analytics" ||
161
+ (body.emails?.length ?? 0) > 0;
162
+ const { emails, ...top } = body;
163
+ const extra = emails?.[0];
164
+ return changed
165
+ ? {
166
+ ...top,
167
+ ...(extra ? { cto_email: extra } : {}),
168
+ report: rest,
169
+ share_level: body.share_level === "analytics" ? "summary" : body.share_level,
170
+ }
126
171
  : body;
127
172
  }
128
173
  /** Never throws. The thrown value, the response body and the credential are never surfaced. */
@@ -135,15 +180,23 @@ export async function sendReportEmail(orgId, body, deps = {}) {
135
180
  catch (e) {
136
181
  if (e instanceof MnemomApiError && e.effectiveStatus === 404)
137
182
  return { status: "unavailable" };
138
- // An API that predates the cost figures rejects the unknown keys with a 400. The email must still
139
- // go out, so retry once with the figures left off; any other failure is classified as below.
183
+ // An API that predates the cost figures, the per-repo rows or the analytics name rejects them with a 400. The email must still
184
+ // go out, so retry once in the old shape; any other failure is classified as below.
140
185
  if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
141
- const bare = withoutCostFigures(body);
142
- if (bare !== body)
143
- return sendReportEmail(orgId, bare, deps);
186
+ const bare = legacyBody(body);
187
+ if (bare !== body && !/email|admin/i.test(e.code ?? "")) {
188
+ const r = await sendReportEmail(orgId, bare, deps);
189
+ return r.status === "sent" ? { ...r, limited: true } : r;
190
+ }
144
191
  }
145
192
  if (e instanceof MnemomApiError) {
146
193
  // A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
194
+ if (e.code === "email_domain_not_allowed")
195
+ return { status: "failed", reason: "email_domain_not_allowed" };
196
+ if (/emails/i.test(e.code ?? ""))
197
+ return { status: "failed", reason: "emails_rejected" };
198
+ if (e.code === "admin_lookup_failed")
199
+ return { status: "failed", reason: "admins_not_found" };
147
200
  if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
148
201
  return { status: "failed", reason: "runner_not_emailed" };
149
202
  return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
@@ -158,8 +211,11 @@ export async function sendReportEmail(orgId, body, deps = {}) {
158
211
  return { status: "failed", reason: "unexpected_response" };
159
212
  if (recipients === 0)
160
213
  return { status: "failed", reason: "no_recipients" };
161
- // `skipped` lists the roles ("runner", "cto") that got no copy. The runner must always get the report.
214
+ // `skipped` lists the roles ("runner", "admin", "added", "cto") that got no copy. The runner must always get the report.
162
215
  if (Array.isArray(o.skipped) && o.skipped.includes("runner"))
163
216
  return { status: "failed", reason: "runner_not_emailed" };
164
- return o.status === "dry_run" ? { status: "dry_run" } : { status: "sent", recipients };
217
+ const adminsMissed = Array.isArray(o.skipped) ? o.skipped.filter((s) => s === "admin").length : 0;
218
+ return o.status === "dry_run"
219
+ ? { status: "dry_run" }
220
+ : { status: "sent", recipients, ...(adminsMissed > 0 ? { adminsMissed } : {}) };
165
221
  }
@@ -43,6 +43,8 @@ export interface RepoTotals {
43
43
  prs: number;
44
44
  reverts: number;
45
45
  ciGreen: number;
46
+ /** PRs read and counted (not reverts, not skipped); the per-repo base of the CI-green share. */
47
+ examined: number;
46
48
  }
47
49
  export interface ScanResult {
48
50
  coverage: Coverage;
@@ -104,7 +104,7 @@ export async function runLocalScan(deps, input) {
104
104
  const bucket = (repo) => {
105
105
  let b = byRepo.get(repo);
106
106
  if (!b)
107
- byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0 }));
107
+ byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0, examined: 0 }));
108
108
  return b;
109
109
  };
110
110
  const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
@@ -142,6 +142,7 @@ export async function runLocalScan(deps, input) {
142
142
  }
143
143
  totals.prs += 1;
144
144
  totals.examined += 1;
145
+ bucket(repo).examined += 1;
145
146
  bucket(repo).prs += 1;
146
147
  if (rec.ci === "success") {
147
148
  totals.ciGreen += 1;
@@ -1,20 +1,21 @@
1
1
  import type { ScanResult } from "./scan.js";
2
- export type ShareLevel = "summary" | "full" | "none";
2
+ export type ShareLevel = "full" | "analytics" | "none";
3
3
  /** The level preselected at the prompt (Enter picks it). */
4
4
  export declare const DEFAULT_SHARE_LEVEL: ShareLevel;
5
5
  export declare const SHARE_OPTIONS: ReadonlyArray<{
6
6
  level: ShareLevel;
7
7
  label: string;
8
8
  }>;
9
- /** Shown just above the share prompt, so pressing Enter is an informed choice. */
9
+ /** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
10
10
  export declare const SHARE_DISCLOSURE: string;
11
11
  /** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
12
12
  export declare function promptShareLevel(): Promise<ShareLevel>;
13
+ /** `summary` is the old name for `analytics`; the meaning is unchanged. */
13
14
  export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
14
- /** Who receives the report. Sent only with a non-"none" share level. */
15
+ /** Who receives the report. The runner comes from the Mnemom login; the server resolves the Org Admins. */
15
16
  export interface ReportRecipients {
16
- runner_email: string;
17
- cto_email: string;
17
+ runner_email?: string;
18
+ extra_emails: string[];
18
19
  }
19
20
  export interface SharePayload {
20
21
  recipients: ReportRecipients;
@@ -30,6 +31,7 @@ export interface SharePayload {
30
31
  coverage_complete: boolean;
31
32
  /** Absent when no spend line may be shown. */
32
33
  spend_line?: string;
34
+ /** Present at "full" only. */
33
35
  repos?: Array<{
34
36
  repo: string;
35
37
  prs: number;
@@ -37,6 +39,6 @@ export interface SharePayload {
37
39
  ci_green: number;
38
40
  }>;
39
41
  }
40
- /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the two report recipients. */
42
+ /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the report recipients. */
41
43
  export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
42
44
  export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;
@@ -1,32 +1,49 @@
1
1
  /**
2
- * Share level: what, if anything, may leave the machine. Asked before any output is built for sharing.
3
- * Full is the preselected choice (MNE-9619): pressing Enter at the prompt picks it. "none" yields no
4
- * payload at all, and is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
2
+ * "What may Mnemom keep on file?" (MNE-9634). This is a retention choice only; it never changes what is emailed. It is asked at
3
+ * the start, before any scan work. The scan itself is always complete: the full report, with the counts
4
+ * for each repo, is emailed to the runner, every Org Admin and anyone the runner adds, at every level.
5
+ *
6
+ * full Mnemom keeps the totals and the counts for each repo, by name, on file (the default).
7
+ * analytics Mnemom keeps the team totals only, with no repo names. `summary` is the old name for this
8
+ * and is accepted as an alias; it is never upgraded to full.
9
+ * none Mnemom keeps nothing on file. The report is still emailed.
10
+ *
11
+ * "none" is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
12
+ * Storing the shared payload on Mnemom's side is not built yet; the CLI only prints it.
5
13
  */
6
14
  import { askSelect } from "../prompt.js";
7
15
  /** The level preselected at the prompt (Enter picks it). */
8
16
  export const DEFAULT_SHARE_LEVEL = "full";
9
17
  export const SHARE_OPTIONS = [
10
- { level: "summary", label: "Summary metrics only (counts and one estimate line)" },
11
- { level: "full", label: "Full report (summary plus per-repo counts; default)" },
12
- { level: "none", label: "Nothing (keep results on this machine)" },
18
+ {
19
+ level: "full",
20
+ label: "Full report (default): Mnemom keeps the totals and the counts for each repo, by name, on file",
21
+ },
22
+ {
23
+ level: "analytics",
24
+ label: "Analytics: Mnemom keeps the team totals only on file, with no repo names",
25
+ },
26
+ { level: "none", label: "Nothing: Mnemom keeps nothing on file" },
13
27
  ];
14
- /** Shown just above the share prompt, so pressing Enter is an informed choice. */
28
+ /** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
15
29
  export const SHARE_DISCLOSURE = [
16
- "Pressing Enter shares the Full report: the whole report, including your repo names and per-repo counts.",
17
- "It is emailed to the report recipients. To share less, choose 1 (Summary: counts only, no repo names) or 3 (Nothing).",
30
+ "Whatever you choose, the full report, including repo names, is emailed to you, your org's admins and any addresses you added.",
31
+ "The next question is only about what Mnemom keeps on file. Pressing Enter chooses the Full report.",
18
32
  ].join("\n");
19
33
  /** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
20
34
  export async function promptShareLevel() {
21
35
  // eslint-disable-next-line no-console -- consent text must show on stdout directly above the prompt (askSelect also prints with console.log)
22
36
  console.log(SHARE_DISCLOSURE);
23
- const answer = await askSelect("What may leave this machine?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
37
+ const answer = await askSelect("What may Mnemom keep on file?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
24
38
  if (answer === null)
25
39
  return "none";
26
40
  return SHARE_OPTIONS.find((o) => o.label === answer)?.level ?? "none";
27
41
  }
42
+ /** `summary` is the old name for `analytics`; the meaning is unchanged. */
28
43
  export function parseShareLevel(v) {
29
- return v === "summary" || v === "full" || v === "none" ? v : null;
44
+ if (v === "summary")
45
+ return "analytics";
46
+ return v === "analytics" || v === "full" || v === "none" ? v : null;
30
47
  }
31
48
  export function buildSharePayload(level, r, recipients,
32
49
  /** undefined: the legacy line; null: no spend line; string: that line. */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The integrity_mode `wrap` and `onboard` write into their starter alignment card.
3
+ *
4
+ * Both commands re-publish the starter card on every run, including a re-run on an agent that
5
+ * already exists (wrap: the same key + name returns the same agent; onboard: an already-claimed
6
+ * agent). The card must never change an existing agent's stored mode, so the mode comes from the
7
+ * agent's own (agent-layer) card when it has one — the same rule `mnemom agent setup` applies to
8
+ * an existing agent (agent-setup.ts existingAgentPlan):
9
+ *
10
+ * - own card with an integrity_mode → keep it (off stays off; nudge/enforce stay put)
11
+ * - own card without the field → observe (what the card already resolved to)
12
+ * - no own card → off (never silently opt an agent in to billed analysis)
13
+ *
14
+ * A newly born agent has its own card: mnemom-api stamps an observe birth card (ADR-API-003), and
15
+ * the gateway's card-less bootstrap writes observe too, so a new agent keeps observe (MNE-9728).
16
+ *
17
+ * THROWS when the read fails (anything but a 404): the caller then skips the card write rather
18
+ * than guess a mode in either direction.
19
+ */
20
+ export declare function starterIntegrityMode(agentId: string): Promise<string>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The integrity_mode `wrap` and `onboard` write into their starter alignment card.
3
+ *
4
+ * Both commands re-publish the starter card on every run, including a re-run on an agent that
5
+ * already exists (wrap: the same key + name returns the same agent; onboard: an already-claimed
6
+ * agent). The card must never change an existing agent's stored mode, so the mode comes from the
7
+ * agent's own (agent-layer) card when it has one — the same rule `mnemom agent setup` applies to
8
+ * an existing agent (agent-setup.ts existingAgentPlan):
9
+ *
10
+ * - own card with an integrity_mode → keep it (off stays off; nudge/enforce stay put)
11
+ * - own card without the field → observe (what the card already resolved to)
12
+ * - no own card → off (never silently opt an agent in to billed analysis)
13
+ *
14
+ * A newly born agent has its own card: mnemom-api stamps an observe birth card (ADR-API-003), and
15
+ * the gateway's card-less bootstrap writes observe too, so a new agent keeps observe (MNE-9728).
16
+ *
17
+ * THROWS when the read fails (anything but a 404): the caller then skips the card write rather
18
+ * than guess a mode in either direction.
19
+ */
20
+ import { getCardSources } from "./api.js";
21
+ export async function starterIntegrityMode(agentId) {
22
+ const sources = await getCardSources("alignment", agentId);
23
+ const own = sources?.agent?.card_json ?? null;
24
+ if (!own)
25
+ return "off";
26
+ const mode = own.integrity_mode;
27
+ return typeof mode === "string" && mode ? mode : "observe";
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.38-next.0",
3
+ "version": "0.17.38-next.1",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@mnemom/policy-engine": "^0.3.0",
22
- "@mnemom/verify": "0.6.2",
22
+ "@mnemom/verify": "0.6.3",
23
23
  "chalk": "^5.3.0",
24
24
  "commander": "^12.0.0",
25
25
  "js-yaml": "^4.3.2",