@mnemom/mnemom 0.17.32 → 0.17.34-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.
Files changed (88) hide show
  1. package/dist/commands/agent-fuel.d.ts +9 -0
  2. package/dist/commands/agent-fuel.js +36 -2
  3. package/dist/commands/agent-run.d.ts +50 -0
  4. package/dist/commands/agent-run.js +221 -0
  5. package/dist/commands/agent-setup.d.ts +8 -3
  6. package/dist/commands/agent-setup.js +32 -9
  7. package/dist/commands/agent-share-fixture.d.ts +3 -1
  8. package/dist/commands/agent-share-fixture.js +11 -7
  9. package/dist/commands/agent-share-render.d.ts +23 -0
  10. package/dist/commands/agent-share-render.js +72 -0
  11. package/dist/commands/agent-share.d.ts +15 -2
  12. package/dist/commands/agent-share.js +30 -9
  13. package/dist/commands/agent.d.ts +34 -4
  14. package/dist/commands/agent.js +116 -20
  15. package/dist/commands/protection.js +1 -1
  16. package/dist/commands/scan.d.ts +97 -0
  17. package/dist/commands/scan.js +238 -0
  18. package/dist/index.js +55 -0
  19. package/dist/lib/api.d.ts +13 -0
  20. package/dist/lib/api.js +25 -1
  21. package/dist/lib/auth.d.ts +6 -0
  22. package/dist/lib/auth.js +161 -14
  23. package/dist/lib/experimental.d.ts +1 -1
  24. package/dist/lib/experimental.js +13 -0
  25. package/dist/lib/oauth.d.ts +7 -0
  26. package/dist/lib/oauth.js +13 -0
  27. package/dist/lib/runner/bundle.d.ts +129 -0
  28. package/dist/lib/runner/bundle.js +319 -0
  29. package/dist/lib/runner/contract-header.d.ts +46 -0
  30. package/dist/lib/runner/contract-header.js +85 -0
  31. package/dist/lib/runner/executor-client.d.ts +71 -0
  32. package/dist/lib/runner/executor-client.js +97 -0
  33. package/dist/lib/runner/job.d.ts +70 -0
  34. package/dist/lib/runner/job.js +463 -0
  35. package/dist/lib/runner/loop.d.ts +45 -0
  36. package/dist/lib/runner/loop.js +104 -0
  37. package/dist/lib/runner/oidc.d.ts +38 -0
  38. package/dist/lib/runner/oidc.js +89 -0
  39. package/dist/lib/runner/options.d.ts +41 -0
  40. package/dist/lib/runner/options.js +47 -0
  41. package/dist/lib/runner/outcome.d.ts +47 -0
  42. package/dist/lib/runner/outcome.js +89 -0
  43. package/dist/lib/runner/phases.d.ts +49 -0
  44. package/dist/lib/runner/phases.js +99 -0
  45. package/dist/lib/runner/proxy.d.ts +45 -0
  46. package/dist/lib/runner/proxy.js +153 -0
  47. package/dist/lib/runner/settings.d.ts +31 -0
  48. package/dist/lib/runner/settings.js +44 -0
  49. package/dist/lib/scan/caps.d.ts +35 -0
  50. package/dist/lib/scan/caps.js +67 -0
  51. package/dist/lib/scan/credential.d.ts +37 -0
  52. package/dist/lib/scan/credential.js +104 -0
  53. package/dist/lib/scan/failure.d.ts +10 -0
  54. package/dist/lib/scan/failure.js +43 -0
  55. package/dist/lib/scan/pr-source.d.ts +57 -0
  56. package/dist/lib/scan/pr-source.js +130 -0
  57. package/dist/lib/scan/record.d.ts +49 -0
  58. package/dist/lib/scan/record.js +85 -0
  59. package/dist/lib/scan/report-email.d.ts +92 -0
  60. package/dist/lib/scan/report-email.js +136 -0
  61. package/dist/lib/scan/report.d.ts +13 -0
  62. package/dist/lib/scan/report.js +30 -0
  63. package/dist/lib/scan/scan.d.ts +64 -0
  64. package/dist/lib/scan/scan.js +182 -0
  65. package/dist/lib/scan/share.d.ts +38 -0
  66. package/dist/lib/scan/share.js +47 -0
  67. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  68. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  69. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  70. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  71. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  72. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  73. package/dist/lib/scan/spend/prices.d.ts +33 -0
  74. package/dist/lib/scan/spend/prices.js +57 -0
  75. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  76. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  77. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  78. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  79. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  80. package/dist/lib/scan/spend/token-rate.js +54 -0
  81. package/dist/lib/session-key.d.ts +41 -0
  82. package/dist/lib/session-key.js +124 -0
  83. package/dist/lib/statusline-feed.js +10 -11
  84. package/dist/lib/statusline.d.ts +6 -0
  85. package/dist/lib/statusline.js +19 -9
  86. package/dist/rc-key-401.mjs +75 -0
  87. package/dist/rc-proxy.mjs +290 -30
  88. package/package.json +2 -1
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `mnemom scan`: a read-only local scan of GitHub PR metadata. Runs entirely on this machine; nothing is
3
+ * uploaded in this build. The share-level prompt gates what a later upload step may send, and the chosen
4
+ * payload is printed so the user sees exactly what would leave. Pure logic is exported so each reject
5
+ * arm is unit-testable without Commander (same pattern as commands/usage.ts).
6
+ */
7
+ import { isValidRepoSlug, resolveCredential, } from "../lib/scan/credential.js";
8
+ import { failureText } from "../lib/scan/failure.js";
9
+ import { runLocalScan } from "../lib/scan/scan.js";
10
+ import { askInput, askSelect } from "../lib/prompt.js";
11
+ import { isSpendTiersEnabled, NOT_ENABLED_MSG, parseMonthlySpendUsd, selectSpend, SPEND_INVALID_MSG, SPEND_NO_TERMINAL_MSG, } from "../lib/scan/spend/spend-tiers.js";
12
+ import { ADMIN_KEY_ENV, AnthropicUsageError, fetchMeasuredUsage, isAdminKeyShape, usageFailureText, } from "../lib/scan/spend/anthropic-usage.js";
13
+ import { getAuthInfo } from "../lib/auth.js";
14
+ import { listMyOrgs } from "../lib/api.js";
15
+ import { fetchRecordTotals, isOrgId, } from "../lib/scan/record.js";
16
+ import { RECORD_NO_ORG_NOTE, renderRecordSection } from "../lib/scan/report.js";
17
+ import { buildEmailBody, buildEmailReport, DRY_RUN_MSG, UNAVAILABLE_MSG, failureMessage, ReportEmailError, sendReportEmail, } from "../lib/scan/report-email.js";
18
+ import { buildSharePayload, parseShareLevel, promptShareLevel, } from "../lib/scan/share.js";
19
+ /** Parses repeated/comma-separated `--repo owner/name`. Returns null on any invalid slug. */
20
+ export function parseRepoFlags(raw) {
21
+ const out = (raw ?? [])
22
+ .flatMap((r) => r.split(","))
23
+ .map((r) => r.trim())
24
+ .filter(Boolean);
25
+ return out.length > 0 && out.every(isValidRepoSlug) ? out : null;
26
+ }
27
+ const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{2,63}$/;
28
+ export const isEmail = (v) => typeof v === "string" && v.length <= 320 && EMAIL.test(v.trim());
29
+ /** The runner's email (mnemom login, else asked) and the CTO's (flag, else "are you the CTO?"). */
30
+ export async function resolveRecipients(o) {
31
+ if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
32
+ throw new Error("--cto-email must be a valid email address");
33
+ let runner = o.runnerEmail ?? getAuthInfo()?.email ?? "";
34
+ if (!isEmail(runner))
35
+ runner = (await askInput("Your email (the report is sent to you):")).trim();
36
+ if (!isEmail(runner))
37
+ throw new Error("a valid runner email is required");
38
+ runner = runner.trim();
39
+ if (o.ctoEmail !== undefined)
40
+ return { runner_email: runner, cto_email: o.ctoEmail.trim() };
41
+ const isCto = await askSelect("Are you the CTO?", ["Yes, I am the CTO", "No, someone else is"]);
42
+ if (isCto === null)
43
+ throw new Error("--cto-email is required when you are not the CTO");
44
+ if (isCto.startsWith("Yes"))
45
+ return { runner_email: runner, cto_email: runner };
46
+ const cto = (await askInput("CTO email (the report is sent to them too):")).trim();
47
+ if (!isEmail(cto))
48
+ throw new Error("--cto-email is required when you are not the CTO");
49
+ return { runner_email: runner, cto_email: cto };
50
+ }
51
+ /** The CTO's figure in USD, or null for none. Throws fixed text only; the input is never echoed. */
52
+ export async function resolveSelfReportedSpend(o) {
53
+ if (o.monthlySpend === undefined && !o.noAnthropicUsage)
54
+ return null;
55
+ if (!isSpendTiersEnabled(o.env))
56
+ throw new Error(NOT_ENABLED_MSG);
57
+ let text = o.monthlySpend;
58
+ if (text === undefined) {
59
+ if (!(o.isTty ?? process.stdin.isTTY))
60
+ throw new Error(SPEND_NO_TERMINAL_MSG);
61
+ text = await askInput("Approximate monthly AI spend in USD (blank to skip; kept in memory only):");
62
+ if (text.trim() === "")
63
+ return null;
64
+ }
65
+ const n = parseMonthlySpendUsd(text);
66
+ if (n === null)
67
+ throw new Error(SPEND_INVALID_MSG);
68
+ return n;
69
+ }
70
+ export const NO_ADMIN_KEY_TERMINAL_MSG = "Set ANTHROPIC_ADMIN_KEY (there is no terminal to ask on).";
71
+ export const TIER1_FALLBACK_NOTE = "Tier 1 skipped: falling back to the next tier.";
72
+ /**
73
+ * Tier 1: read measured usage with the CTO's Anthropic admin key, locally. The key comes from
74
+ * ANTHROPIC_ADMIN_KEY or a masked prompt (--anthropic-admin-key; never a value on argv), lives only in this
75
+ * call, and goes only to api.anthropic.com. Returns null for "no key" and for every failure, after printing a
76
+ * fixed sentence: the key, the thrown value and the response never reach the terminal. The caller then falls
77
+ * back to tier 2, then tier 3.
78
+ */
79
+ export async function resolveMeasuredUsage(o, period) {
80
+ if (o.anthropicAdminKey && !isSpendTiersEnabled(o.env))
81
+ throw new Error(NOT_ENABLED_MSG);
82
+ if (!isSpendTiersEnabled(o.env) || o.noAnthropicUsage)
83
+ return null;
84
+ // An explicit typed figure is the user's choice of tier 2: an ambient env key must not override it.
85
+ if (o.monthlySpend !== undefined && !o.anthropicAdminKey)
86
+ return null;
87
+ let key = (o.env ?? process.env)[ADMIN_KEY_ENV];
88
+ if (o.anthropicAdminKey) {
89
+ if (!(o.isTty ?? process.stdin.isTTY))
90
+ throw new Error(NO_ADMIN_KEY_TERMINAL_MSG);
91
+ key = await askInput("Anthropic admin key (kept in memory only, sent only to Anthropic):", true);
92
+ }
93
+ if (key === undefined || key.trim() === "")
94
+ return null;
95
+ if (!isAdminKeyShape(key)) {
96
+ console.log(usageFailureText("key_invalid"));
97
+ console.log(TIER1_FALLBACK_NOTE);
98
+ return null;
99
+ }
100
+ try {
101
+ return await fetchMeasuredUsage(key, period, o.anthropicDeps);
102
+ }
103
+ catch (e) {
104
+ console.log(usageFailureText(e instanceof AnthropicUsageError ? e.reason : "upstream"));
105
+ console.log(TIER1_FALLBACK_NOTE);
106
+ return null;
107
+ }
108
+ }
109
+ /**
110
+ * --org is optional only because it can be inferred: the report is emailed on every scan, so the scan
111
+ * needs an org. Inference uses the signed-in user's own memberships (GET /v1/orgs, personal org
112
+ * excluded): exactly one org is used; none, several, or a failed read stops the scan loudly BEFORE any
113
+ * scan work, naming the fix. It never guesses between orgs and never skips the email.
114
+ */
115
+ export async function resolveScanOrg(o) {
116
+ if (o.org !== undefined) {
117
+ if (!isOrgId(o.org))
118
+ throw new Error(INVALID_ORG_MSG);
119
+ return o.org;
120
+ }
121
+ let orgs;
122
+ try {
123
+ orgs = (await (o.listOrgs ?? listMyOrgs)()).filter((x) => !x.is_personal);
124
+ }
125
+ catch {
126
+ throw new Error("Could not look up your org to email the report to. Sign in with `mnemom login`, or pass --org <org_id>.");
127
+ }
128
+ if (orgs.length === 1 && isOrgId(orgs[0].org_id))
129
+ return orgs[0].org_id;
130
+ if (orgs.length === 0)
131
+ throw new Error("You belong to no organization, so the report has nowhere to be emailed from. Pass --org <org_id>.");
132
+ throw new Error(`You belong to ${orgs.length} organizations; pass --org <org_id> (${orgs.map((x) => x.org_id).join(", ")}).`);
133
+ }
134
+ export const INVALID_ORG_MSG = "--org must be an org id like org-1a2b3c4d.";
135
+ /**
136
+ * The Record read for the preview: null when the flag gate is off (no read, no output, as before).
137
+ * With the gate on and no --org it is a note, not a guess. 404 becomes a note; 401/403 throw
138
+ * RecordAuthError (fail closed); every other failure becomes a fixed note. Nothing is stored.
139
+ */
140
+ export async function resolveRecordOutcome(o) {
141
+ if (!isSpendTiersEnabled(o.env))
142
+ return null;
143
+ if (o.org === undefined)
144
+ return "no_org";
145
+ if (!isOrgId(o.org))
146
+ throw new Error(INVALID_ORG_MSG);
147
+ return fetchRecordTotals(o.org, o.recordDeps);
148
+ }
149
+ export async function scanCommand(o) {
150
+ if (o.share !== undefined && parseShareLevel(o.share) === null) {
151
+ throw new Error("--share must be one of: summary, full, none");
152
+ }
153
+ if (o.repos.length === 0)
154
+ throw new Error("--repo owner/name is required (repeatable)");
155
+ // The report is emailed on every scan, so resolve the org before any scan work; failure is loud.
156
+ const emailOrg = await resolveScanOrg(o);
157
+ if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
158
+ throw new Error("--cto-email must be a valid email address");
159
+ const selfReported = await resolveSelfReportedSpend(o);
160
+ const now = o.now ?? Date.now;
161
+ const end = new Date(now()).toISOString().slice(0, 10);
162
+ const period = o.period ?? { start: end, end };
163
+ const measured = await resolveMeasuredUsage(o, period);
164
+ const credential = o.credential ?? (await resolveCredential().catch(() => null));
165
+ if (!credential)
166
+ throw new Error(failureText("credential"));
167
+ // Before the local scan: a refused credential fails closed without spending the GitHub reads.
168
+ const record = await resolveRecordOutcome(o);
169
+ const outcome = await runLocalScan({ credential, now, ...o.deps }, { repos: o.repos, period });
170
+ if (outcome.status === "withheld")
171
+ throw new Error(`Scan withheld: ${failureText(outcome.reason)}`);
172
+ const { result } = outcome;
173
+ console.log("Local scan (read-only)");
174
+ console.log(`PRs examined: ${result.totals.examined} of ${result.totals.prs} merged (${result.coverage.windowStart} to ${result.coverage.windowEnd})`);
175
+ console.log(`Coverage: ${result.coverage.complete ? "complete" : `partial${result.coverage.stoppedBy ? ` (stopped by ${result.coverage.stoppedBy})` : ""}`}`);
176
+ // Flag off: the line as before. Flag on: the tier chosen, or no line at all when evidence is thin.
177
+ const tiers = isSpendTiersEnabled(o.env);
178
+ const spend = tiers
179
+ ? selectSpend({
180
+ measuredUsd: measured?.usd ?? o.anthropicUsageUsd,
181
+ over200kShare: measured ? measured.over200kShare : o.anthropicOver200kShare,
182
+ selfReportedUsd: selfReported,
183
+ estimate: result.spend,
184
+ })
185
+ : null;
186
+ const spendLine = spend ? spend.line : result.spend.line;
187
+ if (spendLine !== null)
188
+ console.log(spendLine);
189
+ if (record === "no_org")
190
+ console.log(RECORD_NO_ORG_NOTE);
191
+ else if (record !== null)
192
+ for (const line of renderRecordSection(record))
193
+ console.log(line);
194
+ const level = parseShareLevel(o.share) ?? (await promptShareLevel());
195
+ // Every successful scan emails the report to the person who ran it, at every share level, no opt-out.
196
+ // Mnemom keeps nothing from a share=none send (the server sends the email and does nothing else).
197
+ const emailFailure = await emailReport(o, emailOrg, result, level, period, spend);
198
+ if (level === "none") {
199
+ console.log("Share level: nothing. No share payload is built; Mnemom keeps nothing (the report email is only sent to you).");
200
+ if (emailFailure)
201
+ throw new ReportEmailError(emailFailure);
202
+ return;
203
+ }
204
+ const payload = buildSharePayload(level, result, await resolveRecipients(o), spend ? (spend.shareable ? spend.line : null) : undefined);
205
+ console.log(`Share level: ${level}. Upload is not wired in this build; this is what would be sent:`);
206
+ console.log(JSON.stringify(payload, null, 2));
207
+ if (emailFailure)
208
+ throw new ReportEmailError(emailFailure);
209
+ }
210
+ /**
211
+ * Emails the report. Loud on every failure: prints the reason name, never the server's words, and
212
+ * returns it so the caller exits non-zero after the local report is shown.
213
+ */
214
+ async function emailReport(o, org, result, level, period, spend) {
215
+ const fail = (f) => {
216
+ console.log(failureMessage(f));
217
+ return f;
218
+ };
219
+ const report = buildEmailReport(result, {
220
+ orgId: org,
221
+ period,
222
+ now: o.now ?? Date.now,
223
+ sendSpendRange: spend ? spend.shareable && spend.tier === 3 : true,
224
+ });
225
+ const body = buildEmailBody(report, level, result.coverage.complete, o.ctoEmail?.trim());
226
+ const outcome = await sendReportEmail(org, body, o.emailDeps);
227
+ if (outcome.status === "failed")
228
+ return fail(outcome.reason);
229
+ if (outcome.status === "unavailable") {
230
+ // Org Scan is off by default: the gate's 404 is not a failure and must not break the scan.
231
+ console.log(UNAVAILABLE_MSG);
232
+ return null;
233
+ }
234
+ console.log(outcome.status === "dry_run"
235
+ ? DRY_RUN_MSG
236
+ : `Report emailed to you (${outcome.recipients} recipient${outcome.recipients === 1 ? "" : "s"}).`);
237
+ return null;
238
+ }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { agentShareFixtureEnabled, fixtureAgentShareParts, } from "./commands/ag
19
19
  import { agentSessionsCommand, agentShowCommand, liveSessionDeps, } from "./commands/agent-sessions.js";
20
20
  import { agentGoalPinCommand, agentGoalResetCommand, agentGoalShowCommand, liveGoalDeps, } from "./commands/agent-goal.js";
21
21
  import { agentFuelCommand, liveFuelDeps } from "./commands/agent-fuel.js";
22
+ import { runAgentRun } from "./commands/agent-run.js";
22
23
  import { orgListCommand, orgShowCommand, orgUseCommand } from "./commands/org.js";
23
24
  import { teamListCommand, teamShowCommand, teamTemplateCommand, teamPreviewComposeCommand, teamAdminGrantCommand, teamAdminRevokeCommand, teamAdminListCommand, teamCoverageCommand, } from "./commands/team.js";
24
25
  import { advisoriesListCommand, advisoriesShowCommand } from "./commands/advisories.js";
@@ -48,6 +49,7 @@ import { wrapCommand } from "./commands/wrap.js";
48
49
  import { onboardCommand } from "./commands/onboard.js";
49
50
  import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
50
51
  import { usageCommand, parseNumericFlag, parseDaysFlag } from "./commands/usage.js";
52
+ import { scanCommand, parseRepoFlags } from "./commands/scan.js";
51
53
  import { updateCommand } from "./commands/update.js";
52
54
  import { runAction } from "./lib/update/action.js";
53
55
  import { configListCommand, configGetCommand, configSetCommand, configUnsetCommand, } from "./commands/config.js";
@@ -339,6 +341,8 @@ const agentCmd = program
339
341
  configAgentId: cfg.agentId,
340
342
  }),
341
343
  governance,
344
+ // Setup provisions for the launch, so it authenticates the way the launch will.
345
+ auth: opts.auth,
342
346
  });
343
347
  return;
344
348
  }
@@ -919,6 +923,7 @@ agentCmd
919
923
  .command("share", { hidden: true })
920
924
  .description("Build a shareable card from lines of your signed statements and print the share-page link (card in the URL fragment; nothing is posted). Needs MNEMOM_AGENT_SHARE=1")
921
925
  .option("--lines <ids>", "Comma-separated line ids to include (skips the picker)")
926
+ .option("--period", "Period card: always include reverts (refused if none are available)")
922
927
  .option("--list", "List the lines available to share and exit")
923
928
  .option("--json", "Print JSON instead of text")
924
929
  .action(async (opts) => {
@@ -1019,6 +1024,22 @@ agentCmd
1019
1024
  process.exit(1);
1020
1025
  }
1021
1026
  });
1027
+ // `mnemom agent run --watch|--once` — the thin runner (ruling 34.12; Doors W-L13): claim served-phase
1028
+ // jobs from the org's ready pool and run their signed phases. Exit 0 done/no_job, 1 failed,
1029
+ // 2 usage, 3 refused.
1030
+ agentCmd
1031
+ .command("run", { hidden: hiddenUnless("dispatch") })
1032
+ .description("EXPERIMENTAL (`mnemom experimental enable dispatch`): run served-phase jobs from your org's pool. " +
1033
+ "The executor key comes from MNEMOM_EXECUTOR_KEY (or --oidc in GitHub Actions); each job prints one outcome line")
1034
+ .option("--watch", "Keep claiming jobs until SIGTERM or SIGINT (the container form)")
1035
+ .option("--once", "Run at most one job, then exit (the CI form)")
1036
+ .option("--fail-mode <mode>", "closed (default) or open; also MNEMOM_RUNNER_FAIL_MODE")
1037
+ .option("--json", "Print only outcome lines on stdout; logs go to stderr")
1038
+ .option("--oidc", "With --once: exchange the GitHub Actions ID token for a one-job key")
1039
+ .hook("preAction", () => requireExperimental("`mnemom agent run`", "dispatch"))
1040
+ .action(async (opts) => {
1041
+ process.exitCode = await runAgentRun(opts);
1042
+ });
1022
1043
  // `mnemom agent fuel <file>` — the fuel step (Doors W3.2): one confirmation seals a batch of job
1023
1044
  // contracts through the batch-seal endpoint and commits them to the repo.
1024
1045
  agentCmd
@@ -2176,6 +2197,40 @@ program
2176
2197
  process.exit(1);
2177
2198
  }
2178
2199
  });
2200
+ program
2201
+ .command("scan")
2202
+ .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")
2203
+ .option("--repo <owner/name...>", "Repo to scan (repeatable or comma-separated)")
2204
+ .option("--start <YYYY-MM-DD>", "Period start (default: today)")
2205
+ .option("--end <YYYY-MM-DD>", "Period end (default: today)")
2206
+ .option("--share <level>", "summary | full | none (default: ask)")
2207
+ .option("--cto-email <email>", "CTO who receives the report (required unless you are the CTO; asked when omitted)")
2208
+ .option("--monthly-spend <usd>", "Your own approximate monthly AI spend (self-reported; never sent or saved; needs MNEMOM_ORG_SCAN_MODE=true)")
2209
+ .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 (needs MNEMOM_ORG_SCAN_MODE=true)")
2210
+ .option("--no-anthropic-usage", "Decline Anthropic usage and be asked for a monthly figure (needs MNEMOM_ORG_SCAN_MODE=true)")
2211
+ .option("--org <org_id>", "Your org id; the report is emailed to you for this org (default: your only organization, else the scan stops and asks). Also selects the Record totals preview (needs MNEMOM_ORG_SCAN_MODE=true)")
2212
+ .action(async (options) => {
2213
+ try {
2214
+ const repos = parseRepoFlags(options.repo);
2215
+ if (!repos)
2216
+ throw new Error("--repo owner/name is required (repeatable)");
2217
+ const today = new Date().toISOString().slice(0, 10);
2218
+ await scanCommand({
2219
+ repos,
2220
+ period: { start: options.start ?? options.end ?? today, end: options.end ?? today },
2221
+ share: options.share,
2222
+ ctoEmail: options.ctoEmail,
2223
+ monthlySpend: options.monthlySpend,
2224
+ noAnthropicUsage: options.anthropicUsage === false,
2225
+ anthropicAdminKey: options.anthropicAdminKey === true,
2226
+ org: options.org,
2227
+ });
2228
+ }
2229
+ catch (error) {
2230
+ console.error("Error:", error instanceof Error ? error.message : "scan failed");
2231
+ process.exit(1);
2232
+ }
2233
+ });
2179
2234
  // ============================================================================
2180
2235
  // update + config — CLI auto-update (channel latest|next, mode auto|notify|off)
2181
2236
  //
package/dist/lib/api.d.ts CHANGED
@@ -450,6 +450,19 @@ export interface RecordTotals {
450
450
  row_count: number;
451
451
  }
452
452
  export declare function getRecordTotals(orgId: string, lineageKey: string, signal?: AbortSignal): Promise<RecordTotals>;
453
+ /** The window form of the totals read (contract i2-totals-window v1.2), the fields the scan preview uses. */
454
+ export interface RecordWindowTotals {
455
+ model_cost_mmu: string;
456
+ mu_charged_mmu: string | null;
457
+ row_count: number;
458
+ as_of: string | null;
459
+ }
460
+ /**
461
+ * GET /v1/orgs/{org_id}/record/totals?from=&to= — the org's total over a window (at most 30 days,
462
+ * `to` not in the future). Returns the raw body: the caller shape-checks it. A 404 means the org is
463
+ * not in the Record cohort.
464
+ */
465
+ export declare function getRecordWindowTotals(orgId: string, from: string, to: string, signal?: AbortSignal): Promise<unknown>;
453
466
  /**
454
467
  * Report one commit to Record's commit index (Doors W1.8, lib/commit-report.ts):
455
468
  * `POST /v1/orgs/{org}/record/commits`, body RecordCommit/v1. Returns the HTTP
package/dist/lib/api.js CHANGED
@@ -212,7 +212,23 @@ export async function getAgent(id) {
212
212
  */
213
213
  export async function listAgents() {
214
214
  const data = await gFetch("/v1/agents?limit=100", {}, { fallback: "Failed to list agents" });
215
- return data.agents ?? [];
215
+ return (data.agents ?? []).map(toListItem);
216
+ }
217
+ /**
218
+ * An org-scoped API key gets its org's fleet from `GET /v1/agents` (`get_org_agent_fleet`), whose
219
+ * rows carry `agent_id`/`agent_name`; a personal key gets `id`/`name`. Read both as one shape.
220
+ */
221
+ function toListItem(a) {
222
+ if (!("agent_id" in a))
223
+ return a;
224
+ return {
225
+ id: a.agent_id,
226
+ name: a.agent_name ?? null,
227
+ email: a.owner_email ?? null,
228
+ created_at: a.created_at,
229
+ last_seen: a.last_seen ?? null,
230
+ containment_status: a.containment_status ?? null,
231
+ };
216
232
  }
217
233
  /**
218
234
  * Every agent the caller OWNS, across all their orgs — `GET /v1/agents?scope=all`: rows whose
@@ -411,6 +427,14 @@ export async function getMe() {
411
427
  export async function getRecordTotals(orgId, lineageKey, signal) {
412
428
  return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/record/totals?lineage_key=${encodeURIComponent(lineageKey)}`, signal ? { signal } : {}, { notFoundLabel: "Record totals", fallback: "Could not read record totals" });
413
429
  }
430
+ /**
431
+ * GET /v1/orgs/{org_id}/record/totals?from=&to= — the org's total over a window (at most 30 days,
432
+ * `to` not in the future). Returns the raw body: the caller shape-checks it. A 404 means the org is
433
+ * not in the Record cohort.
434
+ */
435
+ export async function getRecordWindowTotals(orgId, from, to, signal) {
436
+ return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/record/totals?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, signal ? { signal } : {}, { notFoundLabel: "Record totals", fallback: "Could not read record totals" });
437
+ }
414
438
  /**
415
439
  * Report one commit to Record's commit index (Doors W1.8, lib/commit-report.ts):
416
440
  * `POST /v1/orgs/{org}/record/commits`, body RecordCommit/v1. Returns the HTTP
@@ -67,6 +67,12 @@ export declare function getAccessToken(): Promise<string | null>;
67
67
  * actual exp, or server-side revocation). Force a refresh and retry once.
68
68
  */
69
69
  export declare function forceRefreshAccessToken(): Promise<string | null>;
70
+ /**
71
+ * True when auth.json holds a session whose access token has expired. After
72
+ * getAccessToken() returned null this means the refresh was rejected (or was
73
+ * impossible), so the remedy is `mnemom login`, not creating an account.
74
+ */
75
+ export declare function hasExpiredStoredSession(): boolean;
70
76
  /**
71
77
  * Get a valid access token or exit with a helpful message.
72
78
  */
package/dist/lib/auth.js CHANGED
@@ -15,11 +15,24 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { MNEMOM_DIR } from "./config.js";
18
- import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, } from "./oauth.js";
18
+ import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, refreshWorstCaseMs, } from "./oauth.js";
19
19
  // ============================================================================
20
20
  // Auth Store (persisted to ~/.mnemom/auth.json)
21
21
  // ============================================================================
22
22
  const AUTH_FILE = path.join(MNEMOM_DIR, "auth.json");
23
+ /**
24
+ * Held while one process refreshes the stored tokens (MNE-9232). The AS rotates
25
+ * refresh tokens, so two processes refreshing with the same one race: the first
26
+ * wins and the second gets invalid_grant. The lock serialises them, and the
27
+ * waiter re-reads auth.json under it and picks up the winner's tokens.
28
+ */
29
+ const AUTH_LOCK_FILE = `${AUTH_FILE}.lock`;
30
+ const AUTH_LOCK_POLL_MS = 100;
31
+ /** A stored access token this close to expiry (seconds) is treated as expired. */
32
+ const EXPIRY_BUFFER_S = 60;
33
+ function nowSeconds() {
34
+ return Math.floor(Date.now() / 1000);
35
+ }
23
36
  function loadAuthStore() {
24
37
  try {
25
38
  if (!fs.existsSync(AUTH_FILE))
@@ -112,9 +125,7 @@ export async function getAccessToken() {
112
125
  const auth = getAuthInfo();
113
126
  if (!auth)
114
127
  return null;
115
- // Check expiry (with 60s buffer)
116
- const now = Math.floor(Date.now() / 1000);
117
- if (auth.expiresAt > now + 60) {
128
+ if (auth.expiresAt > nowSeconds() + EXPIRY_BUFFER_S) {
118
129
  return auth.accessToken;
119
130
  }
120
131
  // Auto-refresh. A *thrown* error from refreshStoredTokens() (transient
@@ -152,6 +163,15 @@ export async function forceRefreshAccessToken() {
152
163
  const refreshed = await refreshStoredTokens(auth);
153
164
  return refreshed?.accessToken ?? null;
154
165
  }
166
+ /**
167
+ * True when auth.json holds a session whose access token has expired. After
168
+ * getAccessToken() returned null this means the refresh was rejected (or was
169
+ * impossible), so the remedy is `mnemom login`, not creating an account.
170
+ */
171
+ export function hasExpiredStoredSession() {
172
+ const auth = getAuthInfo();
173
+ return auth !== null && auth.expiresAt <= nowSeconds() + EXPIRY_BUFFER_S;
174
+ }
155
175
  /**
156
176
  * Get a valid access token or exit with a helpful message.
157
177
  */
@@ -232,11 +252,113 @@ export async function loginWithDeviceFlow() {
232
252
  // ============================================================================
233
253
  // Token refresh (OAuth refresh_token grant)
234
254
  // ============================================================================
255
+ /** True when `pid` names a running process (EPERM: it runs as another user). */
256
+ function pidAlive(pid) {
257
+ try {
258
+ process.kill(pid, 0);
259
+ return true;
260
+ }
261
+ catch (err) {
262
+ return err.code === "EPERM";
263
+ }
264
+ }
265
+ /**
266
+ * A lock is stale when its holder has exited or it has been held longer than a
267
+ * refresh can take. Age and holder are read through one descriptor so both
268
+ * describe the same lock file. A lock that vanished first is not stale: the
269
+ * next create attempt decides.
270
+ */
271
+ function authLockIsStale(staleMs) {
272
+ let mtimeMs;
273
+ let holder;
274
+ let fd;
275
+ try {
276
+ fd = fs.openSync(AUTH_LOCK_FILE, "r");
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ try {
282
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
283
+ holder = fs.readFileSync(fd, "utf-8");
284
+ }
285
+ catch {
286
+ return false;
287
+ }
288
+ finally {
289
+ fs.closeSync(fd);
290
+ }
291
+ if (Date.now() - mtimeMs >= staleMs)
292
+ return true;
293
+ const pid = Number(holder.trim());
294
+ return Number.isInteger(pid) && pid > 0 && !pidAlive(pid);
295
+ }
296
+ /**
297
+ * Take the refresh lock with O_EXCL (the update.lock pattern in
298
+ * lib/update/state.ts), waiting while another live process holds it. A stale
299
+ * lock is taken over. Waiting past the longest a refresh can take throws, which
300
+ * callers treat as a transient failure, never as revoked credentials.
301
+ *
302
+ * Two waiters can both judge one lock stale and both take it over. That only
303
+ * puts back the pre-lock race, which the invalid_grant re-read in
304
+ * refreshStoredTokens() still recovers from.
305
+ */
306
+ async function acquireAuthLock() {
307
+ const staleMs = refreshWorstCaseMs();
308
+ const deadline = Date.now() + staleMs;
309
+ const owner = String(process.pid);
310
+ for (;;) {
311
+ try {
312
+ fs.mkdirSync(MNEMOM_DIR, { recursive: true, mode: 0o700 });
313
+ fs.writeFileSync(AUTH_LOCK_FILE, owner, { flag: "wx", mode: 0o600 });
314
+ break;
315
+ }
316
+ catch (err) {
317
+ if (err.code !== "EEXIST")
318
+ throw err;
319
+ }
320
+ if (authLockIsStale(staleMs)) {
321
+ fs.rmSync(AUTH_LOCK_FILE, { force: true });
322
+ continue;
323
+ }
324
+ if (Date.now() >= deadline) {
325
+ throw new Error(`Timed out waiting for another mnemom process to refresh the Mnemom session ` +
326
+ `(${AUTH_LOCK_FILE}). Re-run the command.`);
327
+ }
328
+ await new Promise((resolve) => setTimeout(resolve, AUTH_LOCK_POLL_MS));
329
+ }
330
+ return () => {
331
+ // Remove the lock only while it is still ours: if this process stalled past
332
+ // staleMs another process may have taken it over.
333
+ try {
334
+ if (fs.readFileSync(AUTH_LOCK_FILE, "utf-8") === owner) {
335
+ fs.rmSync(AUTH_LOCK_FILE, { force: true });
336
+ }
337
+ }
338
+ catch {
339
+ /* already gone */
340
+ }
341
+ };
342
+ }
343
+ /** True when `candidate` holds a different, unexpired access token than `seen`. */
344
+ function isNewerSession(candidate, seen) {
345
+ return (candidate.accessToken !== seen.accessToken &&
346
+ candidate.expiresAt > nowSeconds() + EXPIRY_BUFFER_S);
347
+ }
235
348
  /**
236
349
  * Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
237
350
  * refresh token and the client_id they were issued to; returns null (rather
238
351
  * than throwing) when refresh isn't possible so callers degrade to re-login.
239
352
  *
353
+ * Runs under AUTH_LOCK_FILE and works from auth.json as it is once the lock is
354
+ * held, not from the caller's earlier read (MNE-9232):
355
+ * - another process already refreshed (a different, unexpired access token
356
+ * on disk) → return its tokens, no network call;
357
+ * - the session was removed (`mnemom logout`) → null, never resurrected;
358
+ * - invalid_grant/invalid_client → re-read once; if a writer that does not
359
+ * take this lock (an older CLI) stored a different refresh token meanwhile,
360
+ * use its session or refresh once with its refresh token.
361
+ *
240
362
  * Note: oauthRefreshTokens() returns null ONLY on a definitive revoke
241
363
  * (invalid_grant/invalid_client) or missing credentials; on transient
242
364
  * exhaustion (repeated 5xx / network faults) or any other non-revoke 4xx it
@@ -250,14 +372,39 @@ export async function loginWithDeviceFlow() {
250
372
  async function refreshStoredTokens(auth) {
251
373
  if (!auth.refreshToken || !auth.clientId)
252
374
  return null;
253
- const refreshed = await oauthRefreshTokens(auth.refreshToken, auth.clientId);
254
- if (!refreshed)
255
- return null;
256
- const stored = fromOAuthTokens(refreshed, auth.clientId);
257
- // Preserve any legacy identity fields so whoami/status keep working across a
258
- // refresh of a token set that still carries them.
259
- stored.userId = auth.userId;
260
- stored.email = auth.email;
261
- saveAuthTokens(stored);
262
- return stored;
375
+ const release = await acquireAuthLock();
376
+ try {
377
+ const current = getAuthInfo();
378
+ if (current && isNewerSession(current, auth))
379
+ return current;
380
+ if (!current?.refreshToken || !current.clientId)
381
+ return null;
382
+ let base = current;
383
+ let clientId = current.clientId;
384
+ let refreshed = await oauthRefreshTokens(current.refreshToken, current.clientId);
385
+ if (!refreshed) {
386
+ const latest = getAuthInfo();
387
+ if (!latest?.refreshToken || !latest.clientId)
388
+ return null;
389
+ if (latest.refreshToken === current.refreshToken)
390
+ return null;
391
+ if (isNewerSession(latest, current))
392
+ return latest;
393
+ base = latest;
394
+ clientId = latest.clientId;
395
+ refreshed = await oauthRefreshTokens(latest.refreshToken, latest.clientId);
396
+ if (!refreshed)
397
+ return null;
398
+ }
399
+ const stored = fromOAuthTokens(refreshed, clientId);
400
+ // Preserve any legacy identity fields so whoami/status keep working across a
401
+ // refresh of a token set that still carries them.
402
+ stored.userId = base.userId;
403
+ stored.email = base.email;
404
+ saveAuthTokens(stored);
405
+ return stored;
406
+ }
407
+ finally {
408
+ release();
409
+ }
263
410
  }
@@ -12,7 +12,7 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable" | "dispatch";
16
16
  /**
17
17
  * Features that used to be experimental and are now on by default for everyone. Their ids are
18
18
  * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
@@ -120,6 +120,19 @@ export const EXPERIMENTAL_FEATURES = [
120
120
  env: [],
121
121
  },
122
122
  },
123
+ {
124
+ // The thin runner (Doors W-L13, ruling 34.12 (4)). Off: `agent run` exits 2 with the gate
125
+ // pointer before reading any credential.
126
+ id: "dispatch",
127
+ summary: "The thin runner: `mnemom agent run --watch|--once` claims served-phase jobs and runs them",
128
+ since: "0.17.32",
129
+ gates: {
130
+ flags: [],
131
+ configKeys: [],
132
+ commands: ["mnemom agent run"],
133
+ env: [],
134
+ },
135
+ },
123
136
  ];
124
137
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
125
138
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";