@mnemom/mnemom 0.17.34-next.2 → 0.17.34-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/commands/agent-run.d.ts +14 -6
  2. package/dist/commands/agent-run.js +44 -27
  3. package/dist/commands/agent-share-test-issuer.d.ts +67 -0
  4. package/dist/commands/agent-share-test-issuer.js +139 -0
  5. package/dist/commands/agent-share.d.ts +7 -1
  6. package/dist/commands/agent-share.js +14 -4
  7. package/dist/commands/agent.d.ts +7 -5
  8. package/dist/commands/agent.js +64 -36
  9. package/dist/commands/durable-lock.d.ts +8 -1
  10. package/dist/commands/durable-lock.js +9 -4
  11. package/dist/commands/scan.d.ts +12 -1
  12. package/dist/commands/scan.js +22 -3
  13. package/dist/commands/wrap.js +1 -1
  14. package/dist/durable/adopt-live.js +5 -8
  15. package/dist/durable/adopt.d.ts +8 -0
  16. package/dist/durable/adopt.js +27 -6
  17. package/dist/durable/cwd-rule.d.ts +9 -1
  18. package/dist/durable/cwd-rule.js +18 -0
  19. package/dist/durable/launch-spec.d.ts +2 -0
  20. package/dist/durable/launch-spec.js +1 -1
  21. package/dist/durable/test-mode.mjs +25 -1
  22. package/dist/host/client.d.ts +3 -1
  23. package/dist/host/daemon.d.ts +7 -3
  24. package/dist/host/daemon.js +13 -3
  25. package/dist/host/owner.d.ts +3 -0
  26. package/dist/host/owner.js +2 -0
  27. package/dist/host/run.d.ts +2 -3
  28. package/dist/host/run.js +5 -20
  29. package/dist/host/s1-names.d.ts +1 -0
  30. package/dist/host/s1-names.js +1 -0
  31. package/dist/host/verbs.d.ts +2 -0
  32. package/dist/host/verbs.js +11 -0
  33. package/dist/index.js +13 -9
  34. package/dist/lib/agent.d.ts +5 -3
  35. package/dist/lib/agent.js +6 -3
  36. package/dist/lib/claude-bin.d.ts +6 -0
  37. package/dist/lib/claude-bin.js +26 -0
  38. package/dist/lib/commit-report.d.ts +5 -4
  39. package/dist/lib/commit-report.js +6 -9
  40. package/dist/lib/config.d.ts +8 -1
  41. package/dist/lib/config.js +28 -0
  42. package/dist/lib/durable/launch.d.ts +28 -11
  43. package/dist/lib/durable/launch.js +33 -15
  44. package/dist/lib/durable/live.d.ts +15 -19
  45. package/dist/lib/durable/live.js +41 -43
  46. package/dist/lib/launcher-config.d.ts +6 -0
  47. package/dist/lib/launcher-config.js +11 -0
  48. package/dist/lib/runner/exec-append.d.ts +15 -0
  49. package/dist/lib/runner/exec-append.js +2 -0
  50. package/dist/lib/runner/executor-client.d.ts +2 -1
  51. package/dist/lib/runner/executor-state.d.ts +10 -0
  52. package/dist/lib/runner/executor-state.js +24 -0
  53. package/dist/lib/runner/github-base.d.ts +24 -0
  54. package/dist/lib/runner/github-base.js +27 -0
  55. package/dist/lib/runner/job.d.ts +9 -4
  56. package/dist/lib/runner/job.js +43 -15
  57. package/dist/lib/runner/oidc.d.ts +3 -2
  58. package/dist/lib/runner/oidc.js +7 -4
  59. package/dist/lib/runner/options.d.ts +6 -0
  60. package/dist/lib/runner/options.js +19 -1
  61. package/dist/lib/runner/phases.d.ts +7 -4
  62. package/dist/lib/runner/phases.js +8 -4
  63. package/dist/lib/runner/proxy.d.ts +5 -1
  64. package/dist/lib/runner/proxy.js +7 -1
  65. package/dist/lib/scan/cost-per-pr.d.ts +93 -0
  66. package/dist/lib/scan/cost-per-pr.js +101 -0
  67. package/dist/lib/scan/record.d.ts +27 -1
  68. package/dist/lib/scan/record.js +114 -16
  69. package/dist/lib/scan/report-email.d.ts +25 -5
  70. package/dist/lib/scan/report-email.js +23 -2
  71. package/dist/lib/scan/scan.d.ts +3 -0
  72. package/dist/lib/scan/scan.js +13 -1
  73. package/package.json +1 -1
  74. package/dist/lib/durable/first-turn.d.ts +0 -50
  75. package/dist/lib/durable/first-turn.js +0 -102
@@ -1,8 +1,9 @@
1
+ import { type CostPerPr, type CostSpend } from "./cost-per-pr.js";
1
2
  import type { ScanResult } from "./scan.js";
2
3
  import type { ShareLevel } from "./share.js";
3
4
  export declare const REPORT_SCHEMA_VERSION = "org-scan-report/1";
4
5
  export declare const ESTIMATE_NAME = "automatable_work_share";
5
- export declare const METHOD_URL = "https://www.mnemom.ai/method";
6
+ export declare const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
6
7
  export interface EmailReport {
7
8
  schema_version: typeof REPORT_SCHEMA_VERSION;
8
9
  org_id: string;
@@ -39,6 +40,22 @@ export interface EmailReport {
39
40
  unit: string;
40
41
  method_url: string;
41
42
  };
43
+ /**
44
+ * Optional, added after org-scan-report/1 shipped; absent whenever it cannot be computed (no spend,
45
+ * no merged PR in the period, an incomplete scan, an empty bucket). See cost-per-pr.ts.
46
+ * cost_per_merged_pr average cost of one merged PR in the period
47
+ * cost_by_pr_size { small | medium | large }: average cost of ONE PR of that size, per-bucket
48
+ * keys absent when the bucket is empty. small < 50 lines changed, medium 50..500, large > 500.
49
+ * A figure is { label: "estimate", spend_source, pr_scope, pr_count } (+ spend_scope: "whole_org" on
50
+ * anthropic_usage) plus { value_cents } (anthropic_usage) or { low_cents, high_cents } (modelled_range). Integer cents.
51
+ * pr_scope is "ai_assisted" for modelled_range (spend and size buckets split across AI-assisted PRs only;
52
+ * absent if none) and "all_merged" otherwise; pr_count counts PRs in that scope.
53
+ * Always attached when present. The API's report validator (mnemom-api src/org-scan/report-schema.ts) is
54
+ * closed, so an API that does not yet list these keys answers 400 invalid_report; sendReportEmail then
55
+ * retries once without them so the report email still goes out.
56
+ */
57
+ cost_per_merged_pr?: CostPerPr["cost_per_merged_pr"];
58
+ cost_by_pr_size?: CostPerPr["cost_by_pr_size"];
42
59
  }
43
60
  export interface ReportEmailBody {
44
61
  report: EmailReport;
@@ -56,13 +73,16 @@ export interface ReportEmailBody {
56
73
  */
57
74
  export declare function buildEmailReport(result: ScanResult, o: {
58
75
  orgId: string;
59
- period: {
60
- start: string;
61
- end: string;
62
- };
63
76
  now: () => number;
64
77
  sendSpendRange: boolean;
65
78
  scanId?: string;
79
+ /** The requested scan period (the one the spend covers), not the examined window. */
80
+ period?: {
81
+ start: string;
82
+ end: string;
83
+ };
84
+ /** The one spend source the cost figures are split from; null/absent leaves them out. */
85
+ costSpend?: CostSpend | null;
66
86
  }): EmailReport;
67
87
  export declare function buildEmailBody(report: EmailReport, level: ShareLevel, complete: boolean, ctoEmail?: string): ReportEmailBody;
68
88
  /** Closed set of failure names; each has fixed text. */
@@ -13,9 +13,10 @@
13
13
  */
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { MnemomApiError, postApi } from "../api.js";
16
+ import { buildCostPerPr } from "./cost-per-pr.js";
16
17
  export const REPORT_SCHEMA_VERSION = "org-scan-report/1";
17
18
  export const ESTIMATE_NAME = "automatable_work_share";
18
- export const METHOD_URL = "https://www.mnemom.ai/method";
19
+ export const METHOD_URL = "https://www.mnemom.ai/org-scan-method";
19
20
  const cents = (usd) => Math.max(0, Math.round(usd * 100));
20
21
  /**
21
22
  * Maps the local scan onto the route's closed report shape. Team totals stay empty (they come only from
@@ -36,11 +37,16 @@ export function buildEmailReport(result, o) {
36
37
  .filter(([, n]) => typeof n === "number" && n > 0)
37
38
  .map(([tool, count]) => ({ tool, count: count }))
38
39
  : [];
40
+ // Only a scan that read every merged PR in the period gives an average that means anything.
41
+ const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
42
+ const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
39
43
  return {
40
44
  schema_version: REPORT_SCHEMA_VERSION,
41
45
  org_id: o.orgId,
42
46
  scan_id: o.scanId ?? randomUUID(),
43
- period: o.period,
47
+ // The span the scan actually examined (coverage), never the requested period: with no --period
48
+ // that defaults to today..today, which is not what was read.
49
+ period: { start: coverage.windowStart, end: coverage.windowEnd },
44
50
  generated_at: new Date(o.now()).toISOString(),
45
51
  measured: {
46
52
  teams: [],
@@ -65,6 +71,7 @@ export function buildEmailReport(result, o) {
65
71
  },
66
72
  },
67
73
  estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
74
+ ...cost,
68
75
  };
69
76
  }
70
77
  export function buildEmailBody(report, level, complete, ctoEmail) {
@@ -111,6 +118,13 @@ function classifyStatus(status) {
111
118
  return "report_rejected";
112
119
  return "unexpected_response";
113
120
  }
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 }
126
+ : body;
127
+ }
114
128
  /** Never throws. The thrown value, the response body and the credential are never surfaced. */
115
129
  export async function sendReportEmail(orgId, body, deps = {}) {
116
130
  const post = deps.post ?? defaultPost;
@@ -121,6 +135,13 @@ export async function sendReportEmail(orgId, body, deps = {}) {
121
135
  catch (e) {
122
136
  if (e instanceof MnemomApiError && e.effectiveStatus === 404)
123
137
  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.
140
+ if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
141
+ const bare = withoutCostFigures(body);
142
+ if (bare !== body)
143
+ return sendReportEmail(orgId, bare, deps);
144
+ }
124
145
  if (e instanceof MnemomApiError) {
125
146
  // A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
126
147
  if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
@@ -7,6 +7,7 @@ import { type GitHubCredential } from "./credential.js";
7
7
  import { type ScanCaps, type StopReason } from "./caps.js";
8
8
  import { type FailureReason } from "./failure.js";
9
9
  import { type PrSource } from "./pr-source.js";
10
+ import type { PrSize } from "./cost-per-pr.js";
10
11
  import { type SpendEstimate } from "./spend/spend-estimate.js";
11
12
  export interface ScanInput {
12
13
  repos: string[];
@@ -53,6 +54,8 @@ export interface ScanResult {
53
54
  };
54
55
  byRepo: RepoTotals[];
55
56
  spend: SpendEstimate;
57
+ /** Every merged PR read in full (reverts and unreadable/oversize PRs excluded): merge day and size. In memory only. */
58
+ prSizes: PrSize[];
56
59
  }
57
60
  export type ScanOutcome = {
58
61
  status: "done";
@@ -109,6 +109,7 @@ export async function runLocalScan(deps, input) {
109
109
  };
110
110
  const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
111
111
  const spendInputs = [];
112
+ const prSizes = [];
112
113
  let skippedLarge = 0;
113
114
  let unreadable = 0;
114
115
  for (const { repo, item } of chosen) {
@@ -146,6 +147,11 @@ export async function runLocalScan(deps, input) {
146
147
  totals.ciGreen += 1;
147
148
  bucket(repo).ciGreen += 1;
148
149
  }
150
+ prSizes.push({
151
+ mergedDay: item.mergedDay,
152
+ lines: rec.additions + rec.deletions,
153
+ aiAssisted: (rec.tools?.length ?? 0) > 0,
154
+ });
149
155
  spendInputs.push({
150
156
  additions: rec.additions,
151
157
  deletions: rec.deletions,
@@ -172,7 +178,13 @@ export async function runLocalScan(deps, input) {
172
178
  };
173
179
  return {
174
180
  status: "done",
175
- result: { coverage, totals, byRepo: [...byRepo.values()], spend: estimateSpend(spendInputs) },
181
+ result: {
182
+ coverage,
183
+ totals,
184
+ byRepo: [...byRepo.values()],
185
+ spend: estimateSpend(spendInputs),
186
+ prSizes,
187
+ },
176
188
  };
177
189
  }
178
190
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.34-next.2",
3
+ "version": "0.17.34-next.3",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,50 +0,0 @@
1
- /**
2
- * Lock after the first turn (ruling dogfood B5). A launch that locks a NEW Claude Code session
3
- * (`durable = always`, or ask answered yes, with no transcript yet) cannot lock before claude has
4
- * run: the daemon's seat resumes the session's transcript, and there is none until the first turn
5
- * writes it (host/transcript.ts ensureTranscript answers lost, the session fails transcript_lost).
6
- *
7
- * So the launcher spawns claude attended and watches the transcript. Once the first turn has
8
- * completed it locks, under the running-session handover (C02-R26: freeze, commit, the seat's
9
- * hello, then the original is killed and the parent attaches to the seat). Never before. A lock
10
- * refusal at that point leaves the attended session running and prints the refusal with its fix
11
- * line.
12
- *
13
- * No I/O here: lib/durable/live.ts wires the transcript reads, the lock and the clock.
14
- */
15
- import type { LockOutcome, TranscriptDirs } from "../../commands/durable-lock.js";
16
- import { DurableRefusal } from "./refusal.js";
17
- /** How often the launcher re-reads the transcript while it waits for the first turn. */
18
- export declare const FIRST_TURN_POLL_MS = 1000;
19
- /**
20
- * Whether a Claude Code transcript (JSONL) holds a completed turn: an assistant entry whose
21
- * stop_reason ends the turn (end_turn, max_tokens, stop_sequence, refusal), or the engine's own
22
- * turn-end record (system turn_duration or stop_hook_summary). Unparseable lines (a line still
23
- * being written) are skipped.
24
- */
25
- export declare function turnCompleted(jsonl: string): boolean;
26
- /**
27
- * Reads `<projects>/<dir>/<cc>.jsonl` from the first project dir that holds it; null while none
28
- * does, when it cannot be read (it is read again on the next poll), or with no CC session id.
29
- */
30
- export declare function transcriptReader(projects: string, dirs: TranscriptDirs, cc: string | null | undefined, read: (path: string) => string): () => string | null;
31
- export interface FirstTurnLockDeps {
32
- /** The session name (the fix line of a lock that could not be sent). */
33
- name: string;
34
- /** The session's transcript text, or null while Claude Code has written none. */
35
- readTranscript(): string | null;
36
- /** The attended claude is still running. */
37
- childAlive(): boolean;
38
- /** The lock of the row under the running-session handover (lockResolvedRow). */
39
- lock(): Promise<LockOutcome>;
40
- /** Print a lock refusal and its fix line (the session stays attended). */
41
- printRefusal(r: DurableRefusal): void;
42
- sleep(ms: number): Promise<void>;
43
- }
44
- export type FirstTurnLock = "locked" | "refused" | "ended";
45
- /**
46
- * Wait for the first completed turn, then lock. "ended": claude exited first, nothing was locked.
47
- * "locked": the handover took the session (the parent's exit path attaches to the seat).
48
- * "refused": the refusal was printed and the attended session goes on.
49
- */
50
- export declare function lockAfterFirstTurn(d: FirstTurnLockDeps, pollMs?: number): Promise<FirstTurnLock>;
@@ -1,102 +0,0 @@
1
- /**
2
- * Lock after the first turn (ruling dogfood B5). A launch that locks a NEW Claude Code session
3
- * (`durable = always`, or ask answered yes, with no transcript yet) cannot lock before claude has
4
- * run: the daemon's seat resumes the session's transcript, and there is none until the first turn
5
- * writes it (host/transcript.ts ensureTranscript answers lost, the session fails transcript_lost).
6
- *
7
- * So the launcher spawns claude attended and watches the transcript. Once the first turn has
8
- * completed it locks, under the running-session handover (C02-R26: freeze, commit, the seat's
9
- * hello, then the original is killed and the parent attaches to the seat). Never before. A lock
10
- * refusal at that point leaves the attended session running and prints the refusal with its fix
11
- * line.
12
- *
13
- * No I/O here: lib/durable/live.ts wires the transcript reads, the lock and the clock.
14
- */
15
- import { join } from "node:path";
16
- import { DurableRefusal } from "./refusal.js";
17
- /** How often the launcher re-reads the transcript while it waits for the first turn. */
18
- export const FIRST_TURN_POLL_MS = 1000;
19
- /** An assistant stop that ends the turn: anything but a tool call or a paused turn. */
20
- const TURN_CONTINUES = new Set(["tool_use", "pause_turn"]);
21
- /**
22
- * Whether a Claude Code transcript (JSONL) holds a completed turn: an assistant entry whose
23
- * stop_reason ends the turn (end_turn, max_tokens, stop_sequence, refusal), or the engine's own
24
- * turn-end record (system turn_duration or stop_hook_summary). Unparseable lines (a line still
25
- * being written) are skipped.
26
- */
27
- export function turnCompleted(jsonl) {
28
- for (const line of jsonl.split("\n")) {
29
- let e;
30
- try {
31
- e = JSON.parse(line);
32
- }
33
- catch {
34
- continue;
35
- }
36
- if (e === null || typeof e !== "object")
37
- continue;
38
- const r = e;
39
- if (r.type === "system" && (r.subtype === "turn_duration" || r.subtype === "stop_hook_summary"))
40
- return true;
41
- if (r.type !== "assistant" || r.message === null || typeof r.message !== "object")
42
- continue;
43
- const stop = r.message.stop_reason;
44
- if (typeof stop === "string" && !TURN_CONTINUES.has(stop))
45
- return true;
46
- }
47
- return false;
48
- }
49
- /**
50
- * Reads `<projects>/<dir>/<cc>.jsonl` from the first project dir that holds it; null while none
51
- * does, when it cannot be read (it is read again on the next poll), or with no CC session id.
52
- */
53
- export function transcriptReader(projects, dirs, cc, read) {
54
- return () => {
55
- if (!cc)
56
- return null;
57
- const found = dirs(cc)?.[0];
58
- if (found === undefined)
59
- return null;
60
- try {
61
- return read(join(projects, found, `${cc}.jsonl`));
62
- }
63
- catch {
64
- return null;
65
- }
66
- };
67
- }
68
- /**
69
- * Wait for the first completed turn, then lock. "ended": claude exited first, nothing was locked.
70
- * "locked": the handover took the session (the parent's exit path attaches to the seat).
71
- * "refused": the refusal was printed and the attended session goes on.
72
- */
73
- export async function lockAfterFirstTurn(d, pollMs = FIRST_TURN_POLL_MS) {
74
- for (;;) {
75
- if (!d.childAlive())
76
- return "ended";
77
- const t = d.readTranscript();
78
- if (t !== null && turnCompleted(t))
79
- break;
80
- await d.sleep(pollMs);
81
- }
82
- let o;
83
- try {
84
- o = await d.lock();
85
- }
86
- catch (e) {
87
- o = {
88
- ok: false,
89
- refusal: e instanceof DurableRefusal
90
- ? e
91
- : new DurableRefusal({
92
- code: "session_registry_unavailable",
93
- message: `mnemom agent: the lock after the first turn failed (${e instanceof Error ? e.message : String(e)}); the session stays attended`,
94
- fix: `\`mnemom agent lock ${d.name}\``,
95
- }),
96
- };
97
- }
98
- if (o.ok)
99
- return "locked";
100
- d.printRefusal(o.refusal);
101
- return "refused";
102
- }