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

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 (87) hide show
  1. package/dist/commands/agent-run.d.ts +16 -6
  2. package/dist/commands/agent-run.js +52 -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 +84 -45
  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 +11 -4
  56. package/dist/lib/runner/job.js +59 -16
  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/outcome.js +2 -3
  62. package/dist/lib/runner/phases.d.ts +7 -4
  63. package/dist/lib/runner/phases.js +8 -4
  64. package/dist/lib/runner/proxy.d.ts +5 -1
  65. package/dist/lib/runner/proxy.js +7 -1
  66. package/dist/lib/scan/cost-per-pr.d.ts +93 -0
  67. package/dist/lib/scan/cost-per-pr.js +101 -0
  68. package/dist/lib/scan/record.d.ts +27 -1
  69. package/dist/lib/scan/record.js +114 -16
  70. package/dist/lib/scan/report-email.d.ts +25 -5
  71. package/dist/lib/scan/report-email.js +23 -2
  72. package/dist/lib/scan/scan.d.ts +3 -0
  73. package/dist/lib/scan/scan.js +13 -1
  74. package/dist/lib/session-key.d.ts +4 -3
  75. package/dist/vendor/durable-contract/index.js +207 -169
  76. package/dist/vendor/shared/durable-contract/doors.d.ts +26 -1
  77. package/dist/vendor/shared/durable-contract/doors.js +37 -1
  78. package/dist/vendor/shared/durable-contract/events.d.ts +1 -1
  79. package/dist/vendor/shared/durable-contract/events.js +13 -3
  80. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +2 -2
  81. package/dist/vendor/shared/durable-contract/schema.d.ts +5 -0
  82. package/dist/vendor/shared/durable-contract/schema.js +6 -0
  83. package/dist/vendor/shared/durable-contract/states.d.ts +2 -1
  84. package/dist/vendor/shared/durable-contract/states.js +2 -1
  85. package/package.json +1 -1
  86. package/dist/lib/durable/first-turn.d.ts +0 -50
  87. package/dist/lib/durable/first-turn.js +0 -102
@@ -48,7 +48,7 @@ import { isLoggedIn } from "../lib/auth.js";
48
48
  import { loadCliConfig } from "../lib/cli-config.js";
49
49
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
50
50
  import { experimentalNotice } from "../lib/experimental.js";
51
- import { conductReceiptFor } from "../lib/conduct-receipt.js";
51
+ import { conductReceiptFor, RECEIPT_DEADLINE_MS, } from "../lib/conduct-receipt.js";
52
52
  import { fmt } from "../lib/format.js";
53
53
  import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
54
54
  import { authConfigured, chooseAuth, detectClaudeLogin, readClaudeLoginState, } from "../lib/login-detect.js";
@@ -59,8 +59,8 @@ import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
59
59
  import { slackChannelArgs } from "../lib/slack-launch.js";
60
60
  import { appendPluginDirsEnv, applyPluginEnv, pluginEnabled, pluginPlanLine, resolvePluginDir, } from "../lib/claude-plugin.js";
61
61
  import { CLI_VERSION } from "../version.js";
62
- import { attachToSeat, firstTurnRow, locksAfterFirstTurn, planDurableLaunch, preReadRegistry, } from "../lib/durable/launch.js";
63
- import { liveDurableApi, liveHomeFacts, livePlanDeps, liveRegistryReads, liveSeatDeps, liveLockAfterFirstTurn, lockInsteadOfSpawn, createLockedInsteadOfSpawn, liveParentAfterChildEnded, registerDurableSession, saveTtyMode, } from "../lib/durable/live.js";
62
+ import { attachToSeat, launchRow, locksAtCreate, planDurableLaunch, preReadRegistry, trackedNoTtyLine, } from "../lib/durable/launch.js";
63
+ import { liveDurableApi, liveHomeFacts, livePlanDeps, liveRegistryReads, liveSeatDeps, lockInsteadOfSpawn, createLockedInsteadOfSpawn, liveParentAfterChildEnded, registerDurableSession, saveTtyMode, } from "../lib/durable/live.js";
64
64
  import { mayBeHandover } from "../lib/durable/handover.js";
65
65
  import { fetchTierIndex, profileMayMisfit, profileTierWarning, resolveTier, tierLaunchModel, } from "../lib/model-tier.js";
66
66
  /** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
@@ -586,8 +586,6 @@ export async function agentCommand(scenarioArg, options = {}) {
586
586
  const conversationId = continuation.conversationId;
587
587
  // The registered row of a durable launch: its child's SIGKILL may be a handover (C02-R26 step 7).
588
588
  let durableRow = null;
589
- // A lock on a new CC session waits for its first turn (dogfood B5): claude spawns attended first.
590
- let lockAfterFirstTurn = false;
591
589
  if (durablePlan) {
592
590
  options = {
593
591
  ...options,
@@ -595,25 +593,26 @@ export async function agentCommand(scenarioArg, options = {}) {
595
593
  ? [...durablePlan.passthrough, "--session-id", durablePlan.newCcSessionId]
596
594
  : durablePlan.passthrough,
597
595
  };
598
- // Register (new) and, under `always` / ask-yes, lock instead of spawning (C02-R23 c, e). A lock
599
- // refusal prints the refusal and its fix, then falls through to the tracked spawn below. A new
600
- // CC session has no transcript to resume yet: it spawns, and locks after its first turn (B5).
601
- // B5a (as amended): a new CC session on a durable-only agent is created locked in one request
602
- // and attached; it never registers tracked or runs an attended first turn.
603
- if (durablePlan.durableOnly && locksAfterFirstTurn(durablePlan)) {
604
- process.exitCode = await createLockedInsteadOfSpawn(durablePlan, scenario, servedAgentId, durableKey, { model: options.model, effort: options.effort, trimMcp: options.trimMcp });
596
+ // Under `always` / ask-yes the launch locks instead of spawning (C02-R23 c, e). A new CC session
597
+ // is created locked in one request and attached (ruling A, widening B5a to every agent): no
598
+ // attended claude runs first. A continued one registers and locks its row. A lock refusal prints
599
+ // the refusal and its fix and exits 1 for every agent (C02-R24 for a durable-only one): a launch
600
+ // that locks never falls back to an attended spawn, except on an api without migration 06a
601
+ // (create-and-lock answers null): it registers tracked and spawns as before ruling A.
602
+ const atCreate = locksAtCreate(durablePlan);
603
+ const created = atCreate
604
+ ? await createLockedInsteadOfSpawn(durablePlan, scenario, servedAgentId, durableKey, { model: options.model, effort: options.effort, trimMcp: options.trimMcp }, durablePlan.durableOnly)
605
+ : null;
606
+ if (created !== null) {
607
+ process.exitCode = created;
605
608
  return;
606
609
  }
607
610
  const row = await registerDurableSession(durablePlan, scenario, servedAgentId);
608
611
  clearLaunching(scenario, liveLaunchingDeps(MNEMOM_DIR));
609
- durableRow = firstTurnRow(row, durablePlan);
610
- lockAfterFirstTurn = locksAfterFirstTurn(durablePlan);
611
- if (durablePlan.lock && !lockAfterFirstTurn) {
612
- const locked = await lockInsteadOfSpawn(row, scenario, durableKey, durablePlan.durableOnly);
613
- if (locked !== null) {
614
- process.exitCode = locked;
615
- return;
616
- }
612
+ durableRow = launchRow(row, durablePlan);
613
+ if (durablePlan.lock && !atCreate) {
614
+ process.exitCode = await lockInsteadOfSpawn(row, scenario, durableKey, durablePlan.durableOnly);
615
+ return;
617
616
  }
618
617
  }
619
618
  // A relaunch that continues an earlier conversation resumes that Claude Code session, so the
@@ -849,8 +848,8 @@ export async function agentCommand(scenarioArg, options = {}) {
849
848
  // Record keys the lineage under the served agent's org, not the account org (lib/record-org).
850
849
  const recordOrgId = recordOrgWanted({
851
850
  receipt: experimental.includes("record"),
852
- commitReport: cli.name === "claude" && commitReportEnabled(process.env[COMMIT_REPORT_ENV], CLI_VERSION),
853
- fedStatusline: cli.name === "claude" && fedStatuslineWanted(CLI_VERSION, Boolean(goalMode), process.env),
851
+ commitReport: cli.name === "claude" && commitReportEnabled(process.env[COMMIT_REPORT_ENV]),
852
+ fedStatusline: cli.name === "claude" && fedStatuslineWanted(CLI_VERSION),
854
853
  }) && account.orgId
855
854
  ? await readServedOrg(account.orgId).then((o) => {
856
855
  if (o.fallback)
@@ -893,7 +892,6 @@ export async function agentCommand(scenarioArg, options = {}) {
893
892
  row: durableRow,
894
893
  name: scenario,
895
894
  launcherKey: options.durable.launcherKey,
896
- lockAfterFirstTurn,
897
895
  },
898
896
  }
899
897
  : {}),
@@ -903,7 +901,7 @@ export async function agentCommand(scenarioArg, options = {}) {
903
901
  orgId: recordOrgId,
904
902
  commitReport: cli.name === "claude" &&
905
903
  Boolean(account.orgId) &&
906
- commitReportEnabled(process.env[COMMIT_REPORT_ENV], CLI_VERSION),
904
+ commitReportEnabled(process.env[COMMIT_REPORT_ENV]),
907
905
  });
908
906
  return; // launchTerminal exits the process
909
907
  }
@@ -1012,6 +1010,18 @@ export function resumeHintLine(ctx) {
1012
1010
  return (`Resume with: ${resumeCommand(ctx)}` +
1013
1011
  " (keeps it governed; `claude --resume` runs outside Mnemom)");
1014
1012
  }
1013
+ /** Print trackedNoTtyLine's warning, if any, just before an interactive tracked spawn. */
1014
+ function warnNoTty(ctx) {
1015
+ const line = trackedNoTtyLine({
1016
+ name: ctx.scenario,
1017
+ gateOn: ctx.options.durable !== undefined,
1018
+ stdinIsTTY: Boolean(process.stdin.isTTY),
1019
+ cliName: ctx.cli.name,
1020
+ passthrough: ctx.options.passthrough ?? [],
1021
+ });
1022
+ if (line !== null)
1023
+ console.error(line);
1024
+ }
1015
1025
  /** An interactive Claude Code session — not `-p` / `--print`, which has nothing to resume into. */
1016
1026
  function wantsResumeHint(cli, args) {
1017
1027
  return cli.name === "claude" && !args.includes("-p") && !args.includes("--print");
@@ -1343,20 +1353,39 @@ function commandExists(cmd) {
1343
1353
  });
1344
1354
  return res.status === 0;
1345
1355
  }
1356
+ /** Probe answers per binary, keyed by path, size and mtime: a replaced binary is probed again. */
1357
+ const rcProbeMemo = new Map();
1346
1358
  /**
1347
1359
  * Does the resolved coding-agent CLI advertise the `--remote-control` startup
1348
1360
  * flag (Claude Code >= 2.1.269)? Determined by scanning its `--help`. Best-effort:
1349
1361
  * a spawn failure or timeout reads as "no". Only the claude adapter wires RC.
1362
+ * One launch asks this more than once (the shape choice, then the lock's RC naming), so the answer
1363
+ * for an unchanged binary is remembered and `--help` runs once per process (BACKBONE l.150).
1350
1364
  */
1351
1365
  export function cliSupportsRemoteControl(bin) {
1366
+ let key = null;
1367
+ try {
1368
+ const st = statSync(bin);
1369
+ key = `${bin}\0${st.size}\0${st.mtimeMs}`;
1370
+ }
1371
+ catch {
1372
+ // unstatable (e.g. a bare command name): probe every time, as before
1373
+ }
1374
+ const known = key === null ? undefined : rcProbeMemo.get(key);
1375
+ if (known !== undefined)
1376
+ return known;
1377
+ let ok;
1352
1378
  try {
1353
1379
  const res = spawnSync(bin, ["--help"], { encoding: "utf8", timeout: 10_000 });
1354
1380
  const text = `${res.stdout ?? ""}${res.stderr ?? ""}`;
1355
- return /--remote-control/.test(text);
1381
+ ok = /--remote-control/.test(text);
1356
1382
  }
1357
1383
  catch {
1358
- return false;
1384
+ ok = false;
1359
1385
  }
1386
+ if (key !== null)
1387
+ rcProbeMemo.set(key, ok);
1388
+ return ok;
1360
1389
  }
1361
1390
  /**
1362
1391
  * Can we launch the DEFAULT Remote-Control-in-terminal shape on this machine?
@@ -1446,8 +1475,6 @@ function launchTerminal(ctx) {
1446
1475
  statusline: sl !== null,
1447
1476
  orgId: ctx.orgId,
1448
1477
  version: CLI_VERSION,
1449
- goalMode: Boolean(ctx.goalMode),
1450
- env: process.env,
1451
1478
  childEnv,
1452
1479
  });
1453
1480
  // Autocompact guard (lib/autocompact-guard.ts): one --settings carries every layer. The
@@ -1473,10 +1500,8 @@ function launchTerminal(ctx) {
1473
1500
  const run = () => {
1474
1501
  const savedTty = ctx.durableHandover ? saveTtyMode() : null;
1475
1502
  const launchedSec = Math.floor(Date.now() / 1000);
1503
+ warnNoTty(ctx);
1476
1504
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
1477
- const firstTurn = ctx.durableHandover?.lockAfterFirstTurn ? ctx.durableHandover : null;
1478
- if (firstTurn)
1479
- void liveLockAfterFirstTurn(firstTurn.row, firstTurn.name, firstTurn.launcherKey, () => child.exitCode === null && child.signalCode === null);
1480
1505
  // W1.8: report the session's commits while it runs; no record state (no proxy) → no reports.
1481
1506
  const commits = ctx.commitReport && ctx.orgId && recordStatePath
1482
1507
  ? startCommitReporter({
@@ -1493,28 +1518,39 @@ function launchTerminal(ctx) {
1493
1518
  child.on("exit", (code, signal) => {
1494
1519
  void (async () => {
1495
1520
  // MNE-8245: best-effort revoke of the minted session-scoped key — a failure
1496
- // here is non-fatal (the key is 24h-TTL'd regardless); never blocks exit.
1497
- if (ctx.sessionKeyId) {
1498
- await revokeSessionGatewayKey(defaultSessionKeyDeps(say), ctx.sessionKeyId);
1499
- }
1521
+ // here is non-fatal (the key is 24h-TTL'd regardless). It starts now and is awaited only
1522
+ // before the process ends; with a receipt to print it shares the receipt's deadline, so
1523
+ // with the API down the two bounds overlap instead of adding up (W1.3).
1500
1524
  // A lock handover SIGKILLs the frozen claude once the daemon's seat runs the conversation:
1501
1525
  // attach to it instead of dying with the child (C02-R26 step 7). Never relaunches claude.
1502
1526
  const h = ctx.durableHandover;
1503
- if (mayBeHandover(h !== undefined, signal) && h) {
1527
+ const handover = mayBeHandover(h !== undefined, signal);
1528
+ // Only an exit that goes straight to the receipt shortens the revoke; a signal or a
1529
+ // handover prints none, so it keeps the full bound.
1530
+ const sharesReceiptDeadline = Boolean(ctx.receipt) && !signal && !handover;
1531
+ const revoked = ctx.sessionKeyId
1532
+ ? revokeSessionGatewayKey({
1533
+ ...defaultSessionKeyDeps(say),
1534
+ ...(sharesReceiptDeadline ? { revokeTimeoutMs: RECEIPT_DEADLINE_MS } : {}),
1535
+ }, ctx.sessionKeyId)
1536
+ : Promise.resolve();
1537
+ if (handover && h) {
1538
+ await revoked;
1504
1539
  const attached = await liveParentAfterChildEnded(h.row, h.name, h.launcherKey, savedTty);
1505
1540
  if (attached !== null)
1506
1541
  process.exit(attached);
1507
1542
  }
1508
1543
  if (signal) {
1544
+ await revoked;
1509
1545
  process.kill(process.pid, signal);
1510
1546
  return;
1511
1547
  }
1512
- // The conduct receipt and the last commit sweep run side by side, each under its own
1513
- // hard deadline inside W1.3's exit budget; neither changes the exit code.
1548
+ // The revoke, the conduct receipt and the last commit sweep run side by side, each under
1549
+ // its own hard deadline inside W1.3's exit budget; none changes the exit code.
1514
1550
  const swept = commits?.finish().catch(() => undefined);
1515
1551
  if (ctx.receipt)
1516
1552
  await sayConductReceipt({ ...ctx.receipt, statePath: recordStatePath });
1517
- await swept;
1553
+ await Promise.all([swept, revoked]);
1518
1554
  // Claude Code's own last line is "Resume this session with: claude --resume <id>",
1519
1555
  // which reopens it OUTSIDE Mnemom (ungoverned, unbilled). Ours goes after it, last.
1520
1556
  if (code === 0 && ctx.resumeHint && wantsResumeHint(ctx.cli, args)) {
@@ -1536,6 +1572,7 @@ function launchTerminal(ctx) {
1536
1572
  turnHeader,
1537
1573
  receipt: Boolean(ctx.receipt),
1538
1574
  commitReport: Boolean(ctx.commitReport),
1575
+ fedStatusline: feed !== null,
1539
1576
  })) {
1540
1577
  run();
1541
1578
  return;
@@ -1568,7 +1605,7 @@ export function launchSettingsExtra(o) {
1568
1605
  * commit reporting (W1.8) all need it; commit reporting reads the lineage key it records.
1569
1606
  */
1570
1607
  export function proxyWanted(o) {
1571
- return o.turnHeader || o.receipt || o.commitReport;
1608
+ return o.turnHeader || o.receipt || o.commitReport || Boolean(o.fedStatusline);
1572
1609
  }
1573
1610
  /**
1574
1611
  * Whether the proxy captures the gateway's record state: the conduct receipt reads it at exit,
@@ -1595,7 +1632,7 @@ export function onBaseUrlProxyReady(baseUrl, recordStateFile, childEnv, feed, st
1595
1632
  * segment off, no billing org, or {@link fedStatuslineWanted} false.
1596
1633
  */
1597
1634
  export function prepareStatuslineFeed(o) {
1598
- if (!o.statusline || !o.orgId || !fedStatuslineWanted(o.version, o.goalMode, o.env))
1635
+ if (!o.statusline || !o.orgId || !fedStatuslineWanted(o.version))
1599
1636
  return null;
1600
1637
  const feed = (o.files ?? fedStatuslineFiles)();
1601
1638
  o.childEnv[FEED_ENV] = feed.feedFile;
@@ -1610,11 +1647,12 @@ export function recordOrgWanted(o) {
1610
1647
  }
1611
1648
  /**
1612
1649
  * Whether this launch feeds the status line measured model cost and μ (W1.4): prerelease
1613
- * builds only (stable stays "mnemom", decision 14), and only when the turn proxy runs, since
1614
- * the proxy is what learns the gateway's lineage key for the session.
1650
+ * builds only (stable stays "mnemom", decision 14). Not tied to goal mode: the base-URL proxy
1651
+ * runs for a fed launch (proxyWanted) and records the lineage key whatever the goal mode, and
1652
+ * since A-W-016 a -next.N launch has no goal by default.
1615
1653
  */
1616
- export function fedStatuslineWanted(version, goalMode, env) {
1617
- return isPrerelease(version) && goalMode && turnProxyWanted(env);
1654
+ export function fedStatuslineWanted(version) {
1655
+ return isPrerelease(version);
1618
1656
  }
1619
1657
  /** A private per-session dir holding the feed (launcher → bin). */
1620
1658
  function fedStatuslineFiles() {
@@ -2077,6 +2115,7 @@ function spawnRcCli(ctx) {
2077
2115
  if (ctx.options.slack)
2078
2116
  args.push(...slackChannelArgs(rcName));
2079
2117
  say(`mnemom agent: remote-control: interactive session in THIS terminal with Remote Control on (session name '${rcName}'); it also appears under Remote Control at claude.ai/code and in the Claude app. Exit the CLI to stop everything.`);
2118
+ warnNoTty(ctx);
2080
2119
  }
2081
2120
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
2082
2121
  child.on("error", (err) => {
@@ -127,7 +127,8 @@ export interface LockFacts {
127
127
  agent: string;
128
128
  /** --own-agent: the lock moved the session onto a new agent, named by `agent` (C02-R10). */
129
129
  ownAgent: boolean;
130
- durableOnly: boolean;
130
+ /** The locked session's agent id (the new agent's under --own-agent): R17's durable-only fragment. */
131
+ agentId: string;
131
132
  sharedWith: number;
132
133
  droppedFlags: readonly string[];
133
134
  }
@@ -165,6 +166,12 @@ export interface LockDeps extends DurableVerbDeps {
165
166
  handover?: HandoverDeps;
166
167
  /** Claude Code's folder trust for the root (cwd_untrusted); absent: not checked. */
167
168
  trust?: TrustProbe;
169
+ /**
170
+ * Does a live row hold the name (the R27 fix lines' `mnemom agent done <name>` clause)? Absent:
171
+ * the row being locked is a registered one, so it holds the name unless it has ended. The launch's
172
+ * create-and-lock passes false: its row is not registered until the create lands.
173
+ */
174
+ nameHeld?: boolean;
168
175
  /** The live original's exact argv (KERN_PROCARGS2, C14-R13), or null when unreadable. */
169
176
  readOriginal?: (pid: number) => OriginalLaunch | null;
170
177
  /**
@@ -223,8 +223,9 @@ export function lockLine(f) {
223
223
  ];
224
224
  if (f.ownAgent)
225
225
  parts.push(`moved to its own agent ${f.agent}`);
226
- if (f.durableOnly)
227
- parts.push(`agent ${f.agent} durable-only: its plain keys stop`);
226
+ // R17 lists this fragment unconditionally: a lock that lands leaves its agent with a live locked
227
+ // session, which is what makes it durable-only (agent_durable_only), so its plain keys stop.
228
+ parts.push(`agent ${f.agentId} durable-only: its plain keys stop`);
228
229
  if (f.sharedWith >= 1)
229
230
  parts.push(`shares it with ${f.sharedWith} sessions (pausing pauses all)`);
230
231
  parts.push(`mnemom agent show ${f.name}`);
@@ -264,7 +265,11 @@ export function lockFactsOf(body, row, name, built = []) {
264
265
  row.agent_name ??
265
266
  row.agent_id,
266
267
  ownAgent: typeof moved === "string",
267
- durableOnly: b.durable_only === true || b.agent_durable_only === true,
268
+ agentId: (typeof moved === "string" && typeof rec(b.agent).id === "string"
269
+ ? rec(b.agent).id
270
+ : null) ??
271
+ (typeof b.agent_id === "string" ? b.agent_id : null) ??
272
+ row.agent_id,
268
273
  sharedWith: Array.isArray(shared) ? shared.length : (num(shared) ?? 0),
269
274
  // The builder's names first (C14-R12), then any the answer adds, each once.
270
275
  droppedFlags: [
@@ -407,7 +412,7 @@ export async function lockResolvedRow(row, name, opts, deps) {
407
412
  };
408
413
  }
409
414
  const launcherKey = deps.launcherKey();
410
- const held = !isEnded(row);
415
+ const held = deps.nameHeld ?? !isEnded(row);
411
416
  const ruled = cwdRule(row.cwd ?? deps.cwd(), deps.probe);
412
417
  const cwd = ruled.ok ? transcriptSlugRule(ruled.root, cc, deps.transcriptDirs) : ruled;
413
418
  if (!cwd.ok && cwd.kind === "transcript_unverifiable") {
@@ -5,9 +5,11 @@
5
5
  * arm is unit-testable without Commander (same pattern as commands/usage.ts).
6
6
  */
7
7
  import { type GitHubCredential } from "../lib/scan/credential.js";
8
- import { type ScanDeps } from "../lib/scan/scan.js";
8
+ import { type ScanDeps, type ScanResult } from "../lib/scan/scan.js";
9
+ import { selectSpend } from "../lib/scan/spend/spend-tiers.js";
9
10
  import { type AnthropicUsageDeps } from "../lib/scan/spend/anthropic-usage.js";
10
11
  import { type RecordDeps, type RecordOutcome } from "../lib/scan/record.js";
12
+ import { type CostSpend } from "../lib/scan/cost-per-pr.js";
11
13
  import { type ReportEmailDeps } from "../lib/scan/report-email.js";
12
14
  import { type ReportRecipients } from "../lib/scan/share.js";
13
15
  export interface ScanCommandOptions {
@@ -64,6 +66,15 @@ export declare function resolveRecipients(o: {
64
66
  }): Promise<ReportRecipients>;
65
67
  /** The CTO's figure in USD, or null for none. Throws fixed text only; the input is never echoed. */
66
68
  export declare function resolveSelfReportedSpend(o: ScanCommandOptions): Promise<number | null>;
69
+ /**
70
+ * The one spend the cost-per-PR figures split, matching the tier the scan chose. Null leaves them out.
71
+ * Tier 1 sends the measured Anthropic total; tier 2 NEVER does (a figure split from the stated monthly
72
+ * spend would let that number be recovered, and the tier 2 line promises it is "not sent"); tier 3 (or
73
+ * tiers off) the modelled range. Sources are never mixed.
74
+ */
75
+ export declare function resolveCostSpend(o: ScanCommandOptions, result: ScanResult, spend: ReturnType<typeof selectSpend> | null, measured: {
76
+ usd: number;
77
+ } | null): CostSpend | null;
67
78
  export declare const NO_ADMIN_KEY_TERMINAL_MSG = "Set ANTHROPIC_ADMIN_KEY (there is no terminal to ask on).";
68
79
  export declare const TIER1_FALLBACK_NOTE = "Tier 1 skipped: falling back to the next tier.";
69
80
  /**
@@ -13,6 +13,7 @@ import { ADMIN_KEY_ENV, AnthropicUsageError, fetchMeasuredUsage, isAdminKeyShape
13
13
  import { getAuthInfo } from "../lib/auth.js";
14
14
  import { listMyOrgs } from "../lib/api.js";
15
15
  import { fetchRecordTotals, isOrgId, } from "../lib/scan/record.js";
16
+ import { modelledSpend } from "../lib/scan/cost-per-pr.js";
16
17
  import { RECORD_NO_ORG_NOTE, renderRecordSection } from "../lib/scan/report.js";
17
18
  import { buildEmailBody, buildEmailReport, DRY_RUN_MSG, UNAVAILABLE_MSG, failureMessage, ReportEmailError, sendReportEmail, } from "../lib/scan/report-email.js";
18
19
  import { buildSharePayload, parseShareLevel, promptShareLevel, } from "../lib/scan/share.js";
@@ -67,6 +68,23 @@ export async function resolveSelfReportedSpend(o) {
67
68
  throw new Error(SPEND_INVALID_MSG);
68
69
  return n;
69
70
  }
71
+ /**
72
+ * The one spend the cost-per-PR figures split, matching the tier the scan chose. Null leaves them out.
73
+ * Tier 1 sends the measured Anthropic total; tier 2 NEVER does (a figure split from the stated monthly
74
+ * spend would let that number be recovered, and the tier 2 line promises it is "not sent"); tier 3 (or
75
+ * tiers off) the modelled range. Sources are never mixed.
76
+ */
77
+ export function resolveCostSpend(o, result, spend, measured) {
78
+ if (spend === null)
79
+ return modelledSpend(result.spend);
80
+ if (spend.tier === 1) {
81
+ const usd = measured?.usd ?? o.anthropicUsageUsd;
82
+ return typeof usd === "number" ? { source: "anthropic_usage", usd } : null;
83
+ }
84
+ if (spend.tier === 2)
85
+ return null;
86
+ return spend.tier === 3 ? modelledSpend(result.spend) : null;
87
+ }
70
88
  export const NO_ADMIN_KEY_TERMINAL_MSG = "Set ANTHROPIC_ADMIN_KEY (there is no terminal to ask on).";
71
89
  export const TIER1_FALLBACK_NOTE = "Tier 1 skipped: falling back to the next tier.";
72
90
  /**
@@ -194,7 +212,7 @@ export async function scanCommand(o) {
194
212
  const level = parseShareLevel(o.share) ?? (await promptShareLevel());
195
213
  // Every successful scan emails the report to the person who ran it, at every share level, no opt-out.
196
214
  // 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);
215
+ const emailFailure = await emailReport(o, emailOrg, result, level, spend, period, resolveCostSpend(o, result, spend, measured));
198
216
  if (level === "none") {
199
217
  console.log("Share level: nothing. No share payload is built; Mnemom keeps nothing (the report email is only sent to you).");
200
218
  if (emailFailure)
@@ -211,16 +229,17 @@ export async function scanCommand(o) {
211
229
  * Emails the report. Loud on every failure: prints the reason name, never the server's words, and
212
230
  * returns it so the caller exits non-zero after the local report is shown.
213
231
  */
214
- async function emailReport(o, org, result, level, period, spend) {
232
+ async function emailReport(o, org, result, level, spend, period, costSpend) {
215
233
  const fail = (f) => {
216
234
  console.log(failureMessage(f));
217
235
  return f;
218
236
  };
219
237
  const report = buildEmailReport(result, {
220
238
  orgId: org,
221
- period,
222
239
  now: o.now ?? Date.now,
223
240
  sendSpendRange: spend ? spend.shareable && spend.tier === 3 : true,
241
+ period,
242
+ costSpend,
224
243
  });
225
244
  const body = buildEmailBody(report, level, result.coverage.complete, o.ctoEmail?.trim());
226
245
  const outcome = await sendReportEmail(org, body, o.emailDeps);
@@ -41,7 +41,7 @@ const PROVIDERS = {
41
41
  label: "Google Gemini",
42
42
  keyEnv: "GOOGLE_API_KEY",
43
43
  keyHeader: "x-goog-api-key",
44
- defaultModel: "gemini-2.0-flash",
44
+ defaultModel: "gemini-2.5-flash",
45
45
  basePath: "/gemini",
46
46
  },
47
47
  };
@@ -11,7 +11,7 @@ import { executorStateOk, executorStatePath, LAUNCHER_KEYS, } from "../vendor/du
11
11
  import { getApiUrl, MNEMOM_DIR } from "../lib/config.js";
12
12
  import { resolveAuth } from "../lib/auth.js";
13
13
  import { readSessionRecords } from "../lib/agent-ledger.js";
14
- import { LAUNCHER_CONFIG_PATH } from "../lib/launcher-config.js";
14
+ import { durableLauncherKey, LAUNCHER_CONFIG_PATH } from "../lib/launcher-config.js";
15
15
  import { mnemomFetch } from "../lib/version-gate.js";
16
16
  import { httpAdoptApi } from "./adopt-api.js";
17
17
  import { liveCwdDeps } from "./cwd-rule.js";
@@ -37,21 +37,18 @@ export function ledgerFactsFrom(records, cid) {
37
37
  }
38
38
  /** The launcher key `durable` (MNEMOM_AGENT_DURABLE, then launcher.toml), default off. */
39
39
  export function launcherKeyFrom(env, tomlText) {
40
- const ok = (v) => typeof v === "string" && LAUNCHER_KEYS.includes(v);
41
- const e = env.MNEMOM_AGENT_DURABLE?.trim().toLowerCase();
42
- if (ok(e))
43
- return e;
40
+ let configured;
44
41
  if (tomlText !== null) {
45
42
  try {
46
43
  const v = parseToml(tomlText).durable;
47
- if (ok(v))
48
- return v;
44
+ if (typeof v === "string" && LAUNCHER_KEYS.includes(v))
45
+ configured = v;
49
46
  }
50
47
  catch {
51
48
  // an unparseable launcher.toml is the launcher's error to report, not adopt's
52
49
  }
53
50
  }
54
- return "off";
51
+ return durableLauncherKey(env, configured);
55
52
  }
56
53
  /** The executor state file, or null when absent or not the frozen S1 shape. */
57
54
  export function executorStateFrom(text) {
@@ -172,12 +172,20 @@ export interface AdoptPlan {
172
172
  export declare function ruleCwd(live: LiveClaude, realpath: (p: string) => string | null): string | null;
173
173
  /** The session row of a GET answer: the body itself, or its `session`; agent_id also from `agent.id`. */
174
174
  export declare function rowOf(a: ApiAnswer): SessionRow | null;
175
+ /**
176
+ * A session's fix lines name it by its registry row's own name (the row discovery matched by
177
+ * conversation id); only then does the name hold a row, so only then may a line carry the done step.
178
+ * The environment's name is the launcher's display name (agent-scenario), which no verb resolves.
179
+ */
180
+ export declare function nameHeldOf(s: Pick<PlannedSession, "name" | "_row">): boolean;
175
181
  /** What a refusal's fix line may be filled from. */
176
182
  export interface RefusalContext {
177
183
  name: string;
178
184
  launcherKey: LauncherKey;
179
185
  /** The group's agent (for agent_contained when the refusal names none). */
180
186
  agent?: string;
187
+ /** Whether the name holds a registry row (nameHeldOf): only then does the line carry the done step. */
188
+ nameHeld: boolean;
181
189
  }
182
190
  /**
183
191
  * The S1 fix line for a lock refusal answered by the check or the lock route, or null when the
@@ -76,6 +76,14 @@ export function rowOf(a) {
76
76
  return { ...inner, agent_id: inner.agent.id };
77
77
  return inner;
78
78
  }
79
+ /**
80
+ * A session's fix lines name it by its registry row's own name (the row discovery matched by
81
+ * conversation id); only then does the name hold a row, so only then may a line carry the done step.
82
+ * The environment's name is the launcher's display name (agent-scenario), which no verb resolves.
83
+ */
84
+ export function nameHeldOf(s) {
85
+ return typeof s._row?.name === "string" && s._row.name !== "" && s._row.name === s.name;
86
+ }
79
87
  async function planSession(live, deps, launcherKey) {
80
88
  const facts = deps.ledger(live.conversationId);
81
89
  const base = {
@@ -117,6 +125,8 @@ async function planSession(live, deps, launcherKey) {
117
125
  if (!row)
118
126
  return unadoptable("row_unreadable");
119
127
  Object.assign(base, { _row: row });
128
+ if (typeof row.name === "string" && row.name !== "")
129
+ base.name = row.name;
120
130
  const agentId = row.agent_id ?? facts.agentId;
121
131
  if (row.durable_mode === "locked") {
122
132
  await compareLocked(base, live, row, deps, launcherKey);
@@ -137,7 +147,7 @@ async function planSession(live, deps, launcherKey) {
137
147
  base.cwd_kind = verdict.cwd_kind;
138
148
  base.reason = `cwd_${verdict.cwd_kind}`;
139
149
  // A tracked row holds the name, so the fix carries the done clause (S1 fix-line rule).
140
- base.fix = cwdFixLine(verdict, { name: live.sessionName, launcherKey, nameHeld: true });
150
+ base.fix = cwdFixLine(verdict, { name: base.name, launcherKey, nameHeld: nameHeldOf(base) });
141
151
  base.note = cwdNote(verdict.cwd_kind, base.fix);
142
152
  return { s: base, agentId, agentLabel: facts.agentLabel };
143
153
  }
@@ -199,7 +209,8 @@ async function compareLocked(base, live, row, deps, launcherKey) {
199
209
  // read (gaps/c4-relock-sole-unlock.md): it is never offered from here.
200
210
  // launchSpecChangeFixLines' tuple type guarantees the launch_spec_change line is first.
201
211
  base.fix = launchSpecChangeFixLines({
202
- name: live.sessionName,
212
+ name: base.name,
213
+ nameHeld: nameHeldOf(base),
203
214
  flags: specUserArgv(built.spec).join(" "),
204
215
  launcherKey,
205
216
  soleLockedSession: false,
@@ -234,7 +245,7 @@ export function refusalFix(code, details, ctx) {
234
245
  params.repo = repos.join(", ");
235
246
  if (placeholdersOf(key).some((p) => params[p] === undefined))
236
247
  return null;
237
- return renderFixLine(key, { launcherKey: ctx.launcherKey, nameHeld: true, params });
248
+ return renderFixLine(key, { launcherKey: ctx.launcherKey, nameHeld: ctx.nameHeld, params });
238
249
  }
239
250
  const LOCK_REFUSAL_FIX_CODES = Object.freeze({
240
251
  durable_lock_not_entitled: true,
@@ -299,7 +310,7 @@ export async function planAdopt(deps, opts = {}) {
299
310
  const checkout = root ?? ownCheckout ?? null;
300
311
  if (checkout === null)
301
312
  continue;
302
- s.fix = cwdFixLine({ ok: false, cwd_kind: "not_git", params: { checkout } }, { name: s.name, launcherKey, nameHeld: true });
313
+ s.fix = cwdFixLine({ ok: false, cwd_kind: "not_git", params: { checkout } }, { name: s.name, launcherKey, nameHeld: nameHeldOf(s) });
303
314
  s.note = cwdNote("not_git", s.fix);
304
315
  }
305
316
  const adoptable = g.sessions.filter((s) => s.verdict === "adopt");
@@ -343,7 +354,12 @@ export async function planAdopt(deps, opts = {}) {
343
354
  else if (a.kind === "error") {
344
355
  g.code = a.code ?? `http_${a.status}`;
345
356
  g.reason = a.message;
346
- g.fix = refusalFix(a.code, a.details, { name: s.name, launcherKey, agent: g.agent });
357
+ g.fix = refusalFix(a.code, a.details, {
358
+ name: s.name,
359
+ launcherKey,
360
+ agent: g.agent,
361
+ nameHeld: nameHeldOf(s),
362
+ });
347
363
  }
348
364
  else {
349
365
  g.code = "durable_precondition_unavailable";
@@ -513,7 +529,12 @@ export async function handover(s, g, exec, deps, facts, launcherKey) {
513
529
  name: s.name,
514
530
  committed: false,
515
531
  code: a.code ?? `http_${a.status}`,
516
- fix: refusalFix(a.code, a.details, { name: s.name, launcherKey, agent: g.agent }),
532
+ fix: refusalFix(a.code, a.details, {
533
+ name: s.name,
534
+ launcherKey,
535
+ agent: g.agent,
536
+ nameHeld: nameHeldOf(s),
537
+ }),
517
538
  };
518
539
  }
519
540
  // No attempt was decisive and a request may still be in flight: only a re-read that shows the
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * The decision is pure over injected facts; liveCwdDeps is the thin driver.
19
19
  */
20
- import { type CwdKind, type LauncherKey } from "../vendor/durable-contract/index.js";
20
+ import { type CwdCheckCause, type CwdKind, type LauncherKey } from "../vendor/durable-contract/index.js";
21
21
  export interface CwdDeps {
22
22
  /** realpath(p), or null when it does not resolve. */
23
23
  realpath(p: string): string | null;
@@ -48,6 +48,14 @@ export declare function projectSlug(path: string): string;
48
48
  * not --resume it).
49
49
  */
50
50
  export declare function classifyCwd(cwd: string, deps: CwdDeps, ccSessionId?: string | null): CwdVerdict;
51
+ /**
52
+ * The daemon's spawn-time cwd check (attestation check `cwd`, C14-R04; mnemom-test #1002): the
53
+ * committed cwd's realpath must equal `git rev-parse --show-toplevel` there. Null when it holds;
54
+ * otherwise the S1 CWD_CHECK_CAUSES value: `missing` when there is no committed cwd or it does not
55
+ * resolve, `not_toplevel` when it resolves but is not a work tree's top level (a subdirectory, or
56
+ * not in a git work tree at all).
57
+ */
58
+ export declare function spawnCwdCause(cwd: string | null, deps: Pick<CwdDeps, "realpath" | "gitToplevel">): CwdCheckCause | null;
51
59
  /**
52
60
  * The S1 fix line for a cwd refusal (fix-line rule: the done clause when a non-terminal row holds
53
61
  * the name, the config suffix when the launcher key is not `always`), or null when a path the
@@ -51,6 +51,24 @@ export function classifyCwd(cwd, deps, ccSessionId = null) {
51
51
  return { ok: false, cwd_kind: "symlink_transcript", params: { realpath: real } };
52
52
  return { ok: false, cwd_kind: null, unadoptable: "no_transcript" };
53
53
  }
54
+ /**
55
+ * The daemon's spawn-time cwd check (attestation check `cwd`, C14-R04; mnemom-test #1002): the
56
+ * committed cwd's realpath must equal `git rev-parse --show-toplevel` there. Null when it holds;
57
+ * otherwise the S1 CWD_CHECK_CAUSES value: `missing` when there is no committed cwd or it does not
58
+ * resolve, `not_toplevel` when it resolves but is not a work tree's top level (a subdirectory, or
59
+ * not in a git work tree at all).
60
+ */
61
+ export function spawnCwdCause(cwd, deps) {
62
+ if (cwd === null)
63
+ return "missing";
64
+ const real = deps.realpath(cwd);
65
+ if (real === null)
66
+ return "missing";
67
+ const top = deps.gitToplevel(real);
68
+ if (top === null)
69
+ return "not_toplevel";
70
+ return (deps.realpath(top) ?? top) === real ? null : "not_toplevel";
71
+ }
54
72
  const CWD_FIX = {
55
73
  subdirectory: "cwd_subdirectory",
56
74
  not_git: "cwd_not_git",
@@ -138,6 +138,8 @@ export declare function launchSpecChangeFixLines(ctx: {
138
138
  flags: string;
139
139
  launcherKey: LauncherKey;
140
140
  soleLockedSession: boolean;
141
+ /** Whether the name holds a registry row; absent: it does (every launch path). */
142
+ nameHeld?: boolean;
141
143
  }): [string, ...string[]];
142
144
  /** The adopt Note for an adopted row (C14-R12, R21): S1 grammar, unchanged builder names. */
143
145
  export declare function adoptNote(dropped: readonly string[]): string;