@getmarrow/install 0.1.64 → 0.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/installer.js CHANGED
@@ -4,16 +4,28 @@ const os = require('node:os');
4
4
  const crypto = require('node:crypto');
5
5
  const { spawnSync } = require('node:child_process');
6
6
  const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
7
- const { controllerStatus, controllerSupportedPlatform, ensureGovernanceController } = require('./controller-manager');
7
+ const {
8
+ controllerStatus,
9
+ controllerSupportedPlatform,
10
+ ensureCurrentGovernanceController,
11
+ } = require('./controller-manager');
8
12
  const { firstCapturePath, harnessReloadPlan } = require('./first-hour');
9
13
  const { evidence: localControlEvidence } = require('./control-state');
14
+ const {
15
+ hermesEnvHasKey,
16
+ hermesPaths,
17
+ manualBlock: hermesManualBlock,
18
+ marrowEnvKeyNames: hermesMarrowEnvKeyNames,
19
+ planHermesMcpConfig,
20
+ } = require('./hermes-config');
21
+ const { ensureOwnerApiKey, readOwnerApiKey } = require('./owner-env');
10
22
 
11
23
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
12
24
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
13
25
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
14
- const MCP_ADAPTER_VERSION = '3.9.96';
15
- const MCP_ADAPTER_SOURCE_SHA = '031c944936271fd6e8768ad2619ee0430b68e2c7';
16
- const MCP_ADAPTER_INTEGRITY = 'sha512-kuBpuWaWAvusS+FbXXFw1fOEGja4T8ahj/vkmXTd/CWjz0t6teT6g9c1HfJhAK6KLzesZtcvUdZLn6pBErKP9Q==';
26
+ const MCP_ADAPTER_VERSION = '3.9.97';
27
+ const MCP_ADAPTER_SOURCE_SHA = 'be607e1dffd4d9e6a3c40f01151e509c115fba3f';
28
+ const MCP_ADAPTER_INTEGRITY = 'sha512-wab1kvgec8WhDDrpkajauLu2QdADXudIxkic0BAgzV96FZVHY0ME45hrOyQmqfkI+OtKriYdgIFp90peWGh2mA==';
17
29
  const SDK_ADAPTER_VERSION = '3.7.64';
18
30
  const SDK_ADAPTER_INTEGRITY = 'sha512-8qJj/8ouHEz1NnZkmujtFxUm/fWldqR/rHv62/sqabaxT0H90xCCHxodLOkV0SVxDscAtnahioZakqkeGNUwyA==';
19
31
  const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
@@ -229,10 +241,13 @@ function alignMcpRecommendationVersions(value, targetVersion, key = '', verifica
229
241
  alignMcpRecommendationVersions(entryValue, targetVersion, entryKey, verificationAction),
230
242
  ]));
231
243
  }
232
- const MCP_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp context-hook`;
233
- const MCP_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp pre-action-hook`;
234
- const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp hook`;
235
- const MCP_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp session-hook`;
244
+ // Claude Code hooks use the Claude-specific entrypoints that MCP setup also writes. MCP labels a
245
+ // hook by its entrypoint, and the bare spellings resolve to the generic mcp-client harness.
246
+ // Writing the same spelling as `marrow-mcp setup` means neither writer rewrites the other.
247
+ const MCP_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-context-hook`;
248
+ const MCP_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-pre-action-hook`;
249
+ const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-hook`;
250
+ const MCP_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-session-hook`;
236
251
  const CODEX_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-context-hook`;
237
252
  const CODEX_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-pre-action-hook`;
238
253
  const CODEX_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-hook`;
@@ -757,6 +772,7 @@ function parseArgs(argv, env = process.env) {
757
772
  options.doctor = true;
758
773
  }
759
774
  else if (arg === '--json') options.json = true;
775
+ else if (arg === '--verbose') options.verbose = true;
760
776
  else if (arg === '--no-self-test') {
761
777
  explicitOperation = true;
762
778
  options.selfTest = false;
@@ -844,6 +860,9 @@ Options:
844
860
  --agent-id <id> Agent/fleet id for self-test headers
845
861
  --no-controller Do not start the local background controller during install/repair
846
862
  --no-self-test Skip API smoke/self-test
863
+ --verbose Print the full report instead of the one-line summary and log file
864
+
865
+ The API key comes from MARROW_API_KEY, or from the owner-only ~/.marrow/env when unset.
847
866
 
848
867
  Environment:
849
868
  MARROW_TOOL_PROFILE Leave unset for primary (17 tools), or explicitly set primary, core, or full.
@@ -864,10 +883,74 @@ function detectedClient(detection) {
864
883
  if (detection.cline) return 'cline';
865
884
  if (detection.windsurf) return 'windsurf';
866
885
  if (detection.gemini) return 'gemini';
886
+ if (detection.codexProject) return 'codex';
887
+ // Home-level harnesses rank below project markers; a shared AGENTS.md is a weaker signal.
888
+ if (detection.hermes) return 'hermes';
867
889
  if (detection.codex) return 'codex';
868
890
  return 'custom';
869
891
  }
870
892
 
893
+ function executableOnPath(name, env = process.env) {
894
+ const extensions = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
895
+ for (const directory of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
896
+ for (const extension of extensions) {
897
+ try {
898
+ const stat = fs.statSync(path.join(directory, `${name}${extension}`));
899
+ if (stat.isFile() && (process.platform === 'win32' || (stat.mode & 0o111) !== 0)) return true;
900
+ } catch {
901
+ // Missing or unreadable PATH entries are not a detection signal.
902
+ }
903
+ }
904
+ }
905
+ return false;
906
+ }
907
+
908
+ // Harnesses installed for the user rather than in the project. Each detector is one bounded
909
+ // check; the list is extended one harness at a time. MARROW_CLIENT still overrides detection.
910
+ const HOME_HARNESS_DETECTORS = Object.freeze([
911
+ Object.freeze({
912
+ client: 'hermes',
913
+ detect: ({ home, env }) => exists(hermesPaths(home, env).config) || executableOnPath('hermes', env),
914
+ }),
915
+ ]);
916
+
917
+ function detectHomeHarnesses(home, env) {
918
+ return Object.fromEntries(HOME_HARNESS_DETECTORS.map((detector) => {
919
+ try {
920
+ return [detector.client, Boolean(detector.detect({ home, env }))];
921
+ } catch {
922
+ return [detector.client, false];
923
+ }
924
+ }));
925
+ }
926
+
927
+ // AGENTS.md is shared by many agents, and earlier installers created it with only the Marrow
928
+ // block, which then made every later run treat the project as Codex. Only owner content or a
929
+ // .codex directory holding more than Marrow's own hooks file is a Codex signal.
930
+ function agentsMdHasOwnerContent(filePath) {
931
+ const raw = safeRead(filePath);
932
+ if (!raw.trim()) return false;
933
+ const block = marrowManagedBlockInText(raw);
934
+ return (block ? raw.replace(block, '') : raw).trim().length > 0;
935
+ }
936
+
937
+ function codexDirectoryIsOwnerConfigured(directory) {
938
+ let entries;
939
+ try {
940
+ entries = fs.readdirSync(directory);
941
+ } catch {
942
+ return false;
943
+ }
944
+ // Marrow only ever creates .codex/hooks.json, so any other content, or an empty directory the
945
+ // owner or Codex created, is an owner signal.
946
+ if (entries.length === 0 || entries.some((entry) => entry !== 'hooks.json')) return true;
947
+ const settings = safeJsonObject(path.join(directory, 'hooks.json'));
948
+ const hooks = settings.hooks && typeof settings.hooks === 'object' ? Object.values(settings.hooks) : [];
949
+ return hooks.some((entries) => Array.isArray(entries) && entries.some((entry) => (
950
+ Array.isArray(entry?.hooks) && entry.hooks.some((hook) => !marrowHookSubcommand(hook?.command))
951
+ )));
952
+ }
953
+
871
954
  function exists(filePath) {
872
955
  return fs.existsSync(filePath);
873
956
  }
@@ -948,7 +1031,12 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
948
1031
  passiveRuntime: path.join(root, '.marrow', 'passive-runtime.mjs'),
949
1032
  passiveEnv: path.join(root, '.marrow', 'env.example'),
950
1033
  openclawJson: findUp(root, ['openclaw.json'], 4) || path.join(home, '.openclaw', 'openclaw.json'),
1034
+ hermesHome: hermesPaths(home, env).home,
1035
+ hermesConfig: hermesPaths(home, env).config,
1036
+ hermesEnv: hermesPaths(home, env).env,
951
1037
  };
1038
+ const homeHarnesses = detectHomeHarnesses(home, env);
1039
+ const codexProject = codexDirectoryIsOwnerConfigured(path.join(root, '.codex'));
952
1040
 
953
1041
  return {
954
1042
  root,
@@ -962,12 +1050,63 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
962
1050
  windsurf: exists(path.join(root, '.windsurf')),
963
1051
  gemini: exists(path.join(root, '.gemini')),
964
1052
  grok: exists(path.join(home, '.grok')) || exists(path.join(root, '.grok')),
965
- codex: exists(paths.agentsMd) || exists(path.join(root, '.codex')),
1053
+ codexProject,
1054
+ codex: codexProject || agentsMdHasOwnerContent(paths.agentsMd),
1055
+ hermes: homeHarnesses.hermes === true,
1056
+ hermesConfig: exists(paths.hermesConfig),
966
1057
  mcpConfig: exists(paths.mcpJson) || exists(paths.cursorMcp) || exists(paths.claudeSettings),
967
1058
  openclaw: exists(paths.openclawJson) || Boolean(env.OPENCLAW_HOME || env.OPENCLAW_AGENT_ID),
968
1059
  };
969
1060
  }
970
1061
 
1062
+ const MARROW_MANAGED_ROOT_FILES = Object.freeze([
1063
+ 'AGENTS.md',
1064
+ 'CLAUDE.md',
1065
+ '.mcp.json',
1066
+ path.join('.claude', 'settings.json'),
1067
+ path.join('.codex', 'hooks.json'),
1068
+ path.join('.cursor', 'hooks.json'),
1069
+ path.join('.cursor', 'mcp.json'),
1070
+ path.join('.cursor', 'rules', 'marrow.mdc'),
1071
+ path.join('.windsurf', 'hooks.json'),
1072
+ path.join('.gemini', 'settings.json'),
1073
+ path.join('.clinerules', 'hooks', 'PreToolUse'),
1074
+ path.join('.clinerules', 'hooks', 'PostToolUse'),
1075
+ path.join('.clinerules', 'hooks', 'TaskCancel'),
1076
+ path.join('.marrow', 'passive-runtime.mjs'),
1077
+ ]);
1078
+ const MARROW_MANAGED_TEXT_RE = /@getmarrow\/mcp|marrow-mcp|<!-- marrow:passive-start -->/;
1079
+
1080
+ function marrowManagedRootFiles(root) {
1081
+ return MARROW_MANAGED_ROOT_FILES.filter((relative) => {
1082
+ try {
1083
+ const filePath = path.join(path.resolve(root), relative);
1084
+ const stat = fs.lstatSync(filePath);
1085
+ if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return false;
1086
+ return relative === path.join('.marrow', 'passive-runtime.mjs')
1087
+ || MARROW_MANAGED_TEXT_RE.test(fs.readFileSync(filePath, 'utf8'));
1088
+ } catch {
1089
+ return false;
1090
+ }
1091
+ });
1092
+ }
1093
+
1094
+ function shellArgument(value) {
1095
+ return /^[A-Za-z0-9_./:@%+=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
1096
+ }
1097
+
1098
+ function assertUpdateTargetsManagedRoot(detection) {
1099
+ const root = path.resolve(detection.root);
1100
+ const home = path.resolve(detection.home);
1101
+ if (root === home || marrowManagedRootFiles(root).length > 0) return;
1102
+ if (marrowManagedRootFiles(home).length === 0) return;
1103
+ throw new Error([
1104
+ `Refusing to update ${root}: it has no Marrow-managed files, and update never creates new Marrow project files in an unmanaged directory.`,
1105
+ `Marrow-managed configuration was found at ${home}. To update it, run: ${INSTALLER_UPDATE_COMMAND} --cwd ${shellArgument(home)}`,
1106
+ `To install Marrow into ${root} deliberately, run the install command from that directory instead: npx -y @getmarrow/install@latest`,
1107
+ ].join('\n'));
1108
+ }
1109
+
971
1110
  function findLikelyEnvFiles(detection, env = process.env) {
972
1111
  const home = env.HOME || env.USERPROFILE || os.homedir();
973
1112
  const candidates = [
@@ -976,8 +1115,6 @@ function findLikelyEnvFiles(detection, env = process.env) {
976
1115
  path.join(detection.root, '.marrow', 'env'),
977
1116
  path.join(detection.root, '.marrow', 'env.local'),
978
1117
  path.join(home, '.marrow', 'env'),
979
- path.join(home, '.openclaw', 'credentials', 'marrow-mcp.env'),
980
- path.join(home, '.openclaw', 'gateway.systemd.env'),
981
1118
  ];
982
1119
  return candidates.filter((filePath) => {
983
1120
  if (!exists(filePath)) return false;
@@ -986,176 +1123,6 @@ function findLikelyEnvFiles(detection, env = process.env) {
986
1123
  });
987
1124
  }
988
1125
 
989
- function stripQuotes(value) {
990
- const trimmed = String(value || '').trim();
991
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
992
- return trimmed.slice(1, -1);
993
- }
994
- return trimmed;
995
- }
996
-
997
- function readEnvVar(filePath, name) {
998
- if (!exists(filePath)) return '';
999
- const raw = safeRead(filePath);
1000
- const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*([^\\n#]+)`, 'm');
1001
- const match = raw.match(pattern);
1002
- return match ? stripQuotes(match[1]) : '';
1003
- }
1004
-
1005
- function readFirstLineSecret(filePath) {
1006
- if (!exists(filePath)) return '';
1007
- return safeRead(filePath).split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
1008
- }
1009
-
1010
- function readNpmrcToken(filePath) {
1011
- if (!exists(filePath)) return '';
1012
- const raw = safeRead(filePath);
1013
- const match = raw.match(/\/\/registry\.npmjs\.org\/:_authToken\s*=\s*([^\s]+)/);
1014
- return match ? stripQuotes(match[1]) : '';
1015
- }
1016
-
1017
- function fingerprint(value) {
1018
- const secret = String(value || '').trim();
1019
- if (!secret) return null;
1020
- return crypto.createHash('sha256').update(secret).digest('hex').slice(0, 12);
1021
- }
1022
-
1023
- function npmTokenPaths(env = process.env) {
1024
- const home = env.HOME || env.USERPROFILE || os.homedir();
1025
- return {
1026
- home,
1027
- openclawEnv: path.join(home, '.openclaw', '.env'),
1028
- credentialFile: path.join(home, '.openclaw', 'credentials', 'npm-getmarrow-token.txt'),
1029
- npmrc: path.join(home, '.npmrc'),
1030
- };
1031
- }
1032
-
1033
- function inspectNpmTokenConfig(env = process.env) {
1034
- const paths = npmTokenPaths(env);
1035
- const openclawToken = readEnvVar(paths.openclawEnv, 'NPM_TOKEN');
1036
- const credentialToken = readFirstLineSecret(paths.credentialFile);
1037
- let npmrcToken = '';
1038
- let unsafeNpmrcPath = false;
1039
- try {
1040
- assertDirectOwnerFile(paths.home, paths.npmrc, { allowMissing: true });
1041
- npmrcToken = readNpmrcToken(paths.npmrc);
1042
- } catch {
1043
- unsafeNpmrcPath = true;
1044
- }
1045
- const sourceToken = openclawToken || credentialToken;
1046
- const mismatch = Boolean(sourceToken && npmrcToken && fingerprint(sourceToken) !== fingerprint(npmrcToken));
1047
- const missingNpmrcToken = Boolean(sourceToken && !npmrcToken);
1048
-
1049
- return {
1050
- safe: {
1051
- npm_token: {
1052
- checked: true,
1053
- repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken) && !unsafeNpmrcPath),
1054
- mismatch,
1055
- missing_npmrc_token: missingNpmrcToken,
1056
- unsafe_path: unsafeNpmrcPath,
1057
- sources: {
1058
- openclaw_env: { path: paths.openclawEnv, present: Boolean(openclawToken), fingerprint: fingerprint(openclawToken) },
1059
- credential_file: { path: paths.credentialFile, present: Boolean(credentialToken), fingerprint: fingerprint(credentialToken) },
1060
- npmrc: { path: paths.npmrc, present: Boolean(npmrcToken), fingerprint: fingerprint(npmrcToken) },
1061
- },
1062
- recommended_fix: unsafeNpmrcPath
1063
- ? 'Refusing automatic npm token repair because ~/.npmrc or its home directory is not a direct, regular owner path.'
1064
- : mismatch || missingNpmrcToken
1065
- ? 'Run npx @getmarrow/install --repair to sync ~/.npmrc from the active OpenClaw/getmarrow npm token source.'
1066
- : null,
1067
- },
1068
- },
1069
- raw: { paths, sourceToken, npmrcToken },
1070
- };
1071
- }
1072
-
1073
- function assertDirectOwnerFile(homePath, filePath, { allowMissing = false } = {}) {
1074
- const home = path.resolve(homePath);
1075
- const target = path.resolve(filePath);
1076
- if (target !== path.join(home, '.npmrc')) throw new Error('npm token repair target must be the direct owner ~/.npmrc');
1077
- if (!fs.existsSync(home)) throw new Error('npm token repair owner home does not exist');
1078
- const homeStat = fs.lstatSync(home);
1079
- if (!homeStat.isDirectory() || homeStat.isSymbolicLink() || fs.realpathSync(home) !== home) {
1080
- throw new Error('npm token repair owner home must be a direct, non-symbolic directory');
1081
- }
1082
- if (!fs.existsSync(target)) {
1083
- if (allowMissing) return;
1084
- throw new Error('npm token repair target does not exist');
1085
- }
1086
- const targetStat = fs.lstatSync(target);
1087
- if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
1088
- throw new Error('npm token repair target must be a regular file, not a symbolic link');
1089
- }
1090
- }
1091
-
1092
- function atomicWriteOwnerFile(homePath, filePath, contents) {
1093
- const home = path.resolve(homePath);
1094
- const target = path.resolve(filePath);
1095
- const allowMissing = !fs.existsSync(target);
1096
- assertDirectOwnerFile(home, target, { allowMissing });
1097
- const tempPath = path.join(home, `.npmrc.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
1098
- let descriptor;
1099
- try {
1100
- descriptor = fs.openSync(tempPath, 'wx', 0o600);
1101
- fs.writeFileSync(descriptor, contents, { encoding: 'utf8' });
1102
- fs.fsyncSync(descriptor);
1103
- fs.closeSync(descriptor);
1104
- descriptor = undefined;
1105
- assertDirectOwnerFile(home, target, { allowMissing });
1106
- fs.renameSync(tempPath, target);
1107
- fs.chmodSync(target, 0o600);
1108
- } finally {
1109
- if (descriptor !== undefined) fs.closeSync(descriptor);
1110
- if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
1111
- }
1112
- }
1113
-
1114
- function upsertNpmrcToken(homePath, filePath, token) {
1115
- assertDirectOwnerFile(homePath, filePath, { allowMissing: true });
1116
- const before = safeRead(filePath);
1117
- const tokenLine = `//registry.npmjs.org/:_authToken=${token}`;
1118
- let after;
1119
- if (/\/\/registry\.npmjs\.org\/:_authToken\s*=/.test(before)) {
1120
- after = before.replace(/\/\/registry\.npmjs\.org\/:_authToken\s*=\s*[^\n\r]+/, tokenLine);
1121
- } else {
1122
- const separator = before && !before.endsWith('\n') ? '\n' : '';
1123
- after = `${before}${separator}${tokenLine}\n`;
1124
- }
1125
- if (before !== after) {
1126
- if (before) {
1127
- const backupPath = `${filePath}.marrow-backup`;
1128
- if (fs.existsSync(backupPath) && fs.lstatSync(backupPath).isSymbolicLink()) {
1129
- throw new Error('npm token repair backup must not be a symbolic link');
1130
- }
1131
- const backupTemp = path.join(path.resolve(homePath), `.npmrc.marrow-backup-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
1132
- fs.writeFileSync(backupTemp, before, { mode: 0o600, flag: 'wx' });
1133
- fs.renameSync(backupTemp, backupPath);
1134
- fs.chmodSync(backupPath, 0o600);
1135
- }
1136
- atomicWriteOwnerFile(homePath, filePath, after);
1137
- }
1138
- return before !== after;
1139
- }
1140
-
1141
- function repairConfigDiagnostics(diagnostics, env = process.env) {
1142
- const inspection = inspectNpmTokenConfig(env);
1143
- const npm = diagnostics.npm_token;
1144
- const repairs = [];
1145
- if (npm?.repairable && inspection.raw.sourceToken) {
1146
- const changed = upsertNpmrcToken(inspection.raw.paths.home, inspection.raw.paths.npmrc, inspection.raw.sourceToken);
1147
- repairs.push({
1148
- type: 'npm_token_npmrc_sync',
1149
- changed,
1150
- path: inspection.raw.paths.npmrc,
1151
- message: changed
1152
- ? 'Synced ~/.npmrc npm token from active OpenClaw/getmarrow token source.'
1153
- : '~/.npmrc already matched the active OpenClaw/getmarrow token source.',
1154
- });
1155
- }
1156
- return repairs;
1157
- }
1158
-
1159
1126
  function passiveInstructions() {
1160
1127
  return `${MARROW_BLOCK_START}
1161
1128
  ## Marrow Passive Agent Memory
@@ -1196,7 +1163,7 @@ if (apiKey && !globalThis.__MARROW_PASSIVE_RUNTIME__) {
1196
1163
  const { MarrowClient } = await import('@getmarrow/sdk');
1197
1164
  const marrow = new MarrowClient(apiKey, {
1198
1165
  baseUrl: installedBaseUrl,
1199
- agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || installedAgentId,
1166
+ agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || installedAgentId || undefined,
1200
1167
  sessionId: process.env.MARROW_SESSION_ID,
1201
1168
  mode: process.env.MARROW_ENFORCEMENT_MODE || 'auto',
1202
1169
  });
@@ -1220,12 +1187,15 @@ if (apiKey && !globalThis.__MARROW_PASSIVE_RUNTIME__) {
1220
1187
  }
1221
1188
 
1222
1189
  function envExample(options = {}) {
1223
- const agentId = String(options.agentId || 'agent-or-fleet-id').trim() || 'agent-or-fleet-id';
1190
+ const agentId = String(options.agentId || '').trim();
1224
1191
  const client = String(options.client || 'custom').trim() || 'custom';
1225
1192
  const baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
1193
+ const agentLine = agentId
1194
+ ? `MARROW_FLEET_AGENT_ID=${JSON.stringify(agentId)}`
1195
+ : '# MARROW_FLEET_AGENT_ID is optional. Unset, Marrow uses the key\'s bound agent or the plan seat.';
1226
1196
  return `MARROW_API_KEY=mrw_live_replace_me
1227
1197
  MARROW_BASE_URL=${JSON.stringify(baseUrl)}
1228
- MARROW_FLEET_AGENT_ID=${JSON.stringify(agentId)}
1198
+ ${agentLine}
1229
1199
  MARROW_CLIENT=${JSON.stringify(client)}
1230
1200
  # MARROW_TOOL_PROFILE is intentionally unset: ordinary setup uses primary. Set core or full only as an explicit opt-in.
1231
1201
  MARROW_ENFORCEMENT_MODE=auto
@@ -1248,6 +1218,28 @@ function parseJsonObject(filePath) {
1248
1218
  return parsed;
1249
1219
  }
1250
1220
 
1221
+ function canonicalJsonValue(value) {
1222
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
1223
+ if (!value || typeof value !== 'object') return value;
1224
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJsonValue(value[key])]));
1225
+ }
1226
+
1227
+ function sameJsonDocument(left, right) {
1228
+ try {
1229
+ return JSON.stringify(canonicalJsonValue(JSON.parse(left.trim())))
1230
+ === JSON.stringify(canonicalJsonValue(JSON.parse(right.trim())));
1231
+ } catch {
1232
+ return false;
1233
+ }
1234
+ }
1235
+
1236
+ // JSON.parse keeps the last duplicate key, so equal documents keep their bytes only when the
1237
+ // raw text also pins the same MCP versions; otherwise a stale earlier duplicate is never repaired.
1238
+ function equivalentManagedJson(before, after) {
1239
+ const pinnedVersions = (text) => [...new Set(mcpVersionsInText(text))].sort().join(',');
1240
+ return sameJsonDocument(before, after) && pinnedVersions(before) === pinnedVersions(after);
1241
+ }
1242
+
1251
1243
  function upsertBlock(content, block) {
1252
1244
  if (content.includes(MARROW_BLOCK_START) && content.includes(MARROW_BLOCK_END)) {
1253
1245
  const start = content.indexOf(MARROW_BLOCK_START);
@@ -1301,14 +1293,24 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
1301
1293
  function marrowHookSubcommand(command) {
1302
1294
  if (typeof command !== 'string') return null;
1303
1295
  const match = command.trim().match(
1304
- /^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|codex|cursor|grok)-)?(context-hook|pre-action-hook|hook|session-hook)$/,
1296
+ /^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|cline|codex|cursor|gemini|grok|windsurf)-)?(context-hook|pre-action-hook|hook|session-hook)$/,
1305
1297
  );
1306
1298
  return match?.[1] || null;
1307
1299
  }
1308
1300
 
1301
+ // Which existing Marrow handler keeps its owner options (such as a timeout) when entries are
1302
+ // merged: the exact canonical entry, then a pinned entry with the same matcher (an earlier
1303
+ // version or entrypoint spelling of the canonical one), then the first Marrow handler.
1304
+ function marrowHandlerRank(hook, command, exactMatcher) {
1305
+ if (hook.command === command && exactMatcher) return 3;
1306
+ if (exactMatcher && /^npx\s+-y\s+--package=@getmarrow\/mcp@[^\s]+\s+marrow-mcp\s+/.test(String(hook.command || '').trim())) return 2;
1307
+ return 1;
1308
+ }
1309
+
1309
1310
  function reconcileMarrowCommandHook(settings, eventName, subcommand, command, matcher, handlerOptions = {}) {
1310
1311
  const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
1311
1312
  let preferredHandler = null;
1313
+ let preferredRank = 0;
1312
1314
  const retained = [];
1313
1315
  for (const entry of original) {
1314
1316
  if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
@@ -1321,8 +1323,10 @@ function reconcileMarrowCommandHook(settings, eventName, subcommand, command, ma
1321
1323
  && hook.type === 'command' ? marrowHookSubcommand(hook.command) : null;
1322
1324
  if (detected) {
1323
1325
  const exactMatcher = matcher == null ? entry.matcher === undefined : entry.matcher === matcher;
1324
- if (detected === subcommand && (!preferredHandler || (hook.command === command && exactMatcher))) {
1326
+ const rank = detected === subcommand ? marrowHandlerRank(hook, command, exactMatcher) : 0;
1327
+ if (rank > preferredRank) {
1325
1328
  preferredHandler = hook;
1329
+ preferredRank = rank;
1326
1330
  }
1327
1331
  continue;
1328
1332
  }
@@ -2072,7 +2076,7 @@ function activationProfile(detection, plan, changes, client) {
2072
2076
  ? `${sdkDependency.install_command} && npx @getmarrow/install --repair`
2073
2077
  : sdkDependency.warning
2074
2078
  : capabilityLevel === 'governed_wrapper'
2075
- ? `npx @getmarrow/install run --agent <agent-id> -- ${client}`
2079
+ ? `npx @getmarrow/install run -- ${client}`
2076
2080
  : client === 'cline' && clineConflicts.length > 0
2077
2081
  ? 'Move or remove the conflicting owner-managed Cline hook file after owner review, then run npx @getmarrow/install --repair. Marrow will never overwrite or compose it.'
2078
2082
  : client === 'gemini' && geminiHooksExplicitlyDisabled(geminiSettings)
@@ -2130,21 +2134,82 @@ function activationProfile(detection, plan, changes, client) {
2130
2134
  };
2131
2135
  }
2132
2136
 
2137
+ const MCP_ENV_PLACEHOLDER_RE = /\$\{[^}]*\}/;
2138
+ const AGENT_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
2139
+
2140
+ // Values in reports are identity labels, never secrets: a URL is reduced to its origin, so
2141
+ // credentials embedded in it are never printed.
2142
+ function safeIdentityValue(field, value) {
2143
+ if (field === 'MARROW_BASE_URL') {
2144
+ try {
2145
+ const url = new URL(value);
2146
+ return `${url.protocol}//${url.host}`;
2147
+ } catch {
2148
+ return '[unparseable URL]';
2149
+ }
2150
+ }
2151
+ return AGENT_ID_RE.test(value) ? value : '[invalid agent id]';
2152
+ }
2153
+
2154
+ function identityAllowlist(env = process.env) {
2155
+ const list = (value) => new Set(String(value || '').split(',').map((entry) => entry.trim()).filter(Boolean));
2156
+ return {
2157
+ baseUrls: list(env.MARROW_ALLOWED_BASE_URLS),
2158
+ agentIds: list(env.MARROW_ALLOWED_AGENT_IDS),
2159
+ };
2160
+ }
2161
+
2162
+ // The managed MCP entry always carries the configured identity: the base URL and agent id the
2163
+ // installer or controller runs with. An existing concrete value is kept only when it is equal
2164
+ // or on the owner's allowlist (MARROW_ALLOWED_BASE_URLS, MARROW_ALLOWED_AGENT_IDS); otherwise
2165
+ // it is replaced and the divergence is reported. A <client>-<hash> id written by an earlier
2166
+ // installer is removed without a report.
2133
2167
  function upsertMcpServerConfig(filePath, options = {}) {
2134
2168
  const agentId = String(options.agentId || '').trim();
2135
- if (!agentId) throw new Error('Refusing to generate Marrow MCP config without an agent identity');
2136
- const baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
2169
+ const derivedAgentIds = new Set(Array.isArray(options.derivedAgentIds) ? options.derivedAgentIds : []);
2170
+ const allowlist = options.allowlist || identityAllowlist();
2171
+ const report = typeof options.onDivergence === 'function' ? options.onDivergence : () => {};
2137
2172
  const config = parseJsonObject(filePath);
2138
2173
  const servers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
2139
2174
  ? config.mcpServers
2140
2175
  : {};
2176
+ const existingEnv = servers.marrow?.env && typeof servers.marrow.env === 'object' && !Array.isArray(servers.marrow.env)
2177
+ ? servers.marrow.env
2178
+ : {};
2179
+ const concrete = (value) => {
2180
+ const text = typeof value === 'string' ? value.trim() : '';
2181
+ return text && !MCP_ENV_PLACEHOLDER_RE.test(text) ? text : '';
2182
+ };
2183
+ const configuredBaseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
2184
+ const existingBaseUrl = concrete(existingEnv.MARROW_BASE_URL);
2185
+ let baseUrl = configuredBaseUrl;
2186
+ if (existingBaseUrl && existingBaseUrl !== configuredBaseUrl) {
2187
+ if (allowlist.baseUrls.has(existingBaseUrl)) baseUrl = existingBaseUrl;
2188
+ else {
2189
+ report({
2190
+ field: 'MARROW_BASE_URL',
2191
+ replaced: safeIdentityValue('MARROW_BASE_URL', existingBaseUrl),
2192
+ applied: safeIdentityValue('MARROW_BASE_URL', configuredBaseUrl),
2193
+ });
2194
+ }
2195
+ }
2196
+ const existingAgentId = concrete(existingEnv.MARROW_FLEET_AGENT_ID);
2197
+ let keptAgentId = agentId;
2198
+ if (existingAgentId && existingAgentId !== agentId && !derivedAgentIds.has(existingAgentId)) {
2199
+ if (allowlist.agentIds.has(existingAgentId)) keptAgentId = existingAgentId;
2200
+ else {
2201
+ report({
2202
+ field: 'MARROW_FLEET_AGENT_ID',
2203
+ replaced: safeIdentityValue('MARROW_FLEET_AGENT_ID', existingAgentId),
2204
+ applied: agentId ? safeIdentityValue('MARROW_FLEET_AGENT_ID', agentId) : 'unset (resolved by Marrow)',
2205
+ });
2206
+ }
2207
+ }
2141
2208
  const existingProfile = resolveToolProfile(servers.marrow?.env?.MARROW_TOOL_PROFILE).configured_profile;
2142
2209
  const requestedProfile = resolveToolProfile(options.toolProfile).configured_profile;
2143
2210
  const configuredProfile = requestedProfile === 'unset' ? existingProfile : requestedProfile;
2144
- const env = {
2145
- MARROW_BASE_URL: baseUrl,
2146
- MARROW_FLEET_AGENT_ID: agentId,
2147
- };
2211
+ const env = { MARROW_BASE_URL: baseUrl };
2212
+ if (keptAgentId) env.MARROW_FLEET_AGENT_ID = keptAgentId;
2148
2213
  if (configuredProfile !== 'unset') env.MARROW_TOOL_PROFILE = configuredProfile;
2149
2214
  servers.marrow = {
2150
2215
  command: 'npx',
@@ -2382,10 +2447,46 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
2382
2447
  });
2383
2448
  }
2384
2449
 
2450
+ const HERMES_WRITE_LABEL = 'Hermes MCP server (mcp_servers.marrow)';
2451
+
2452
+ function hermesTargetIsSafe(detection) {
2453
+ try {
2454
+ assertContainedManagedTarget(detection.paths.hermesHome, detection.paths.hermesConfig);
2455
+ return true;
2456
+ } catch {
2457
+ return false;
2458
+ }
2459
+ }
2460
+
2461
+ function derivedAgentIdsFor(root) {
2462
+ return [...SOURCE_CLIENTS].map((candidate) => stableAgentId(root, candidate));
2463
+ }
2464
+
2385
2465
  function buildPlan(detection, options) {
2386
2466
  const client = options.client || detectedClient(detection);
2387
- const agentId = String(options.agentId || '').trim() || stableAgentId(detection.root, client);
2467
+ // Server-facing configuration carries only a configured agent id. Without one, the MCP
2468
+ // server, SDK and hooks let Marrow resolve the key's bound agent or the plan seat.
2469
+ const agentId = String(options.agentId || '').trim();
2470
+ const derivedAgentIds = derivedAgentIdsFor(detection.root);
2388
2471
  const baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
2472
+ const mcpConfigOptions = {
2473
+ agentId,
2474
+ baseUrl,
2475
+ toolProfile: options.toolProfile,
2476
+ derivedAgentIds,
2477
+ allowlist: options.identityAllowlist || identityAllowlist(),
2478
+ };
2479
+ const mcpConfigWrite = (filePath, label) => {
2480
+ const write = { type: 'json-transform', path: filePath, label, identityDivergence: [] };
2481
+ write.transform = (target) => {
2482
+ write.identityDivergence = [];
2483
+ return retarget(upsertMcpServerConfig(target, {
2484
+ ...mcpConfigOptions,
2485
+ onDivergence: (divergence) => write.identityDivergence.push(divergence),
2486
+ }));
2487
+ };
2488
+ return write;
2489
+ };
2389
2490
  const mcpTargetVersion = executableMcpTarget(options).version;
2390
2491
  const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
2391
2492
  const mode = options.mode === 'auto'
@@ -2465,12 +2566,22 @@ function buildPlan(detection, options) {
2465
2566
  transform: (filePath) => retarget(upsertGrokHooks(filePath)),
2466
2567
  });
2467
2568
  }
2468
- writes.push({
2469
- type: 'json-transform',
2470
- path: detection.paths.mcpJson,
2471
- label: 'Project MCP server config',
2472
- transform: (filePath) => retarget(upsertMcpServerConfig(filePath, { agentId, baseUrl, toolProfile: options.toolProfile })),
2473
- });
2569
+ writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
2570
+ // The owner's Hermes config changes only on an explicit install or update, never from the
2571
+ // controller's background maintenance pass.
2572
+ if (detection.hermesConfig && options.maintenance !== true && hermesTargetIsSafe(detection)) {
2573
+ writes.push({
2574
+ type: 'yaml-transform',
2575
+ path: detection.paths.hermesConfig,
2576
+ root: detection.paths.hermesHome,
2577
+ label: HERMES_WRITE_LABEL,
2578
+ transform: (before) => planHermesMcpConfig(before, {
2579
+ mcpPackageSpec: `@getmarrow/mcp@${mcpTargetVersion}`,
2580
+ keyReference: hermesEnvHasKey(detection.paths.hermesEnv),
2581
+ }),
2582
+ conflict_fix: (reason) => `Marrow left ${detection.paths.hermesConfig} unchanged because it could not be edited safely (${reason}). Add this block under mcp_servers by hand, then restart Hermes:\n${hermesManualBlock({ mcpPackageSpec: `@getmarrow/mcp@${mcpTargetVersion}`, keyReference: hermesEnvHasKey(detection.paths.hermesEnv) })}`,
2583
+ });
2584
+ }
2474
2585
  if (detection.cursor) {
2475
2586
  writes.push({
2476
2587
  type: 'json-transform',
@@ -2478,12 +2589,7 @@ function buildPlan(detection, options) {
2478
2589
  label: 'Cursor native hooks',
2479
2590
  transform: (filePath) => retarget(upsertCursorHooks(filePath)),
2480
2591
  });
2481
- writes.push({
2482
- type: 'json-transform',
2483
- path: detection.paths.cursorMcp,
2484
- label: 'Cursor MCP server config',
2485
- transform: (filePath) => retarget(upsertMcpServerConfig(filePath, { agentId, baseUrl, toolProfile: options.toolProfile })),
2486
- });
2592
+ writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
2487
2593
  }
2488
2594
  }
2489
2595
 
@@ -2586,6 +2692,9 @@ function applyPlan(plan, options) {
2586
2692
  const automaticRepairSuppressed = aheadUnverifiedVersions.length > 0;
2587
2693
  let after;
2588
2694
  let hookConflict = false;
2695
+ let conflictReason = null;
2696
+ let hostedEntry = false;
2697
+ let undo = null;
2589
2698
  if (automaticRepairSuppressed) {
2590
2699
  after = before;
2591
2700
  } else if (write.type === 'file') {
@@ -2598,12 +2707,25 @@ function applyPlan(plan, options) {
2598
2707
  after = upsertBlock(before, write.block);
2599
2708
  } else if (write.type === 'json-transform') {
2600
2709
  after = write.transform(write.path);
2710
+ if (equivalentManagedJson(before, after)) after = before;
2601
2711
  } else if (write.type === 'managed-json-transform') {
2602
2712
  if (fileExists && !write.isManaged(write.path)) {
2603
2713
  after = before;
2604
2714
  hookConflict = true;
2605
2715
  } else {
2606
2716
  after = write.transform(write.path);
2717
+ if (equivalentManagedJson(before, after)) after = before;
2718
+ }
2719
+ } else if (write.type === 'yaml-transform') {
2720
+ const planned = write.transform(before);
2721
+ if (planned.action === 'refuse') {
2722
+ after = before;
2723
+ hookConflict = true;
2724
+ conflictReason = planned.reason;
2725
+ } else {
2726
+ after = planned.action === 'update' ? planned.content : before;
2727
+ hostedEntry = planned.hosted === true;
2728
+ undo = planned.action === 'update' ? planned.undo || [] : null;
2607
2729
  }
2608
2730
  } else if (write.type === 'owned-executable') {
2609
2731
  if (fileExists && before !== write.content) {
@@ -2623,6 +2745,9 @@ function applyPlan(plan, options) {
2623
2745
  before,
2624
2746
  after,
2625
2747
  hookConflict,
2748
+ conflictReason,
2749
+ hostedEntry,
2750
+ undo,
2626
2751
  modeChanged,
2627
2752
  automaticRepairSuppressed,
2628
2753
  aheadUnverifiedVersions,
@@ -2635,6 +2760,9 @@ function applyPlan(plan, options) {
2635
2760
  before,
2636
2761
  after,
2637
2762
  hookConflict,
2763
+ conflictReason,
2764
+ hostedEntry,
2765
+ undo,
2638
2766
  modeChanged,
2639
2767
  automaticRepairSuppressed,
2640
2768
  aheadUnverifiedVersions,
@@ -2655,7 +2783,14 @@ function applyPlan(plan, options) {
2655
2783
  ahead_unverified_versions: aheadUnverifiedVersions,
2656
2784
  exact_fix: mcpRegistryVerificationAction(aheadUnverifiedVersions),
2657
2785
  } : {}),
2658
- ...(hookConflict ? { exact_fix: write.conflict_fix } : {}),
2786
+ ...(hookConflict ? {
2787
+ exact_fix: typeof write.conflict_fix === 'function' ? write.conflict_fix(conflictReason) : write.conflict_fix,
2788
+ ...(conflictReason ? { conflict_reason: conflictReason } : {}),
2789
+ } : {}),
2790
+ ...(hostedEntry ? { hosted_entry_preserved: true } : {}),
2791
+ // Redacted marrow-entry lines only; the file itself is never copied.
2792
+ ...(undo && contentChanged ? { undo } : {}),
2793
+ ...(write.identityDivergence?.length && changed ? { identity_divergence: write.identityDivergence } : {}),
2659
2794
  });
2660
2795
  if (contentChanged && writeApplied) {
2661
2796
  atomicWriteManagedFile(path.resolve(write.root || root), write.path, after);
@@ -2668,8 +2803,10 @@ function applyPlan(plan, options) {
2668
2803
  return changes;
2669
2804
  }
2670
2805
 
2806
+ const SELF_TEST_READ_TIMEOUT_MS = 15_000;
2807
+
2671
2808
  async function requestJson(url, options) {
2672
- const res = await fetch(url, options);
2809
+ const res = await fetch(url, { signal: AbortSignal.timeout(SELF_TEST_READ_TIMEOUT_MS), ...options });
2673
2810
  const text = await res.text();
2674
2811
  let json = {};
2675
2812
  try {
@@ -2688,6 +2825,8 @@ const SELF_TEST_WRITE_ATTEMPTS = 3;
2688
2825
  const SELF_TEST_RETRY_DELAY_MS = 1_000;
2689
2826
  const SELF_TEST_MAX_RETRY_DELAY_MS = 2_000;
2690
2827
  const SELF_TEST_TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
2828
+ const SELF_TEST_ATTEMPT_TIMEOUT_MS = 10_000;
2829
+ const SELF_TEST_TOTAL_TIMEOUT_MS = 25_000;
2691
2830
 
2692
2831
  function selfTestRetryDelayMs(response, data, override) {
2693
2832
  if (Number.isFinite(override) && override >= 0) return override;
@@ -2697,13 +2836,33 @@ function selfTestRetryDelayMs(response, data, override) {
2697
2836
  return Math.min(delay, SELF_TEST_MAX_RETRY_DELAY_MS);
2698
2837
  }
2699
2838
 
2700
- // A first write can meet a transient 5xx, or a durable "pending" acknowledgement
2701
- // that has no decision id yet. Marrow's contract is to resend the identical request
2702
- // with the same Idempotency-Key, so a retry can never create a second record.
2703
- async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs }) {
2839
+ // A first write can meet a transient 5xx, or a durable "pending" acknowledgement.
2840
+ // Marrow's contract is to resend the identical request with the same Idempotency-Key,
2841
+ // so a retry can never create a second record. As in the MCP client, a pending
2842
+ // acknowledgement is never complete, even when it names a created decision: that id
2843
+ // is pinned and the final response must carry the same one.
2844
+ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs, attemptTimeoutMs, deadlineMs }) {
2704
2845
  let lastState = 'no response';
2846
+ let pinnedDecisionId = null;
2847
+ const perAttempt = Number.isFinite(attemptTimeoutMs) ? attemptTimeoutMs : SELF_TEST_ATTEMPT_TIMEOUT_MS;
2848
+ const deadline = Date.now() + (Number.isFinite(deadlineMs) ? deadlineMs : SELF_TEST_TOTAL_TIMEOUT_MS);
2705
2849
  for (let attempt = 1; attempt <= SELF_TEST_WRITE_ATTEMPTS; attempt += 1) {
2706
- const res = await fetch(url, { ...options, headers: { ...options.headers, 'idempotency-key': idempotencyKey } });
2850
+ const remaining = deadline - Date.now();
2851
+ if (remaining <= 0) return { failed: `deadline reached (last: ${lastState})` };
2852
+ let res;
2853
+ try {
2854
+ // Each attempt is bounded, and all attempts share one deadline, so a stalled service
2855
+ // can never hold the install open.
2856
+ res = await fetch(url, {
2857
+ ...options,
2858
+ headers: { ...options.headers, 'idempotency-key': idempotencyKey },
2859
+ signal: AbortSignal.timeout(Math.max(1, Math.min(perAttempt, remaining))),
2860
+ });
2861
+ } catch (error) {
2862
+ if (error?.name !== 'TimeoutError' && error?.name !== 'AbortError') throw error;
2863
+ lastState = 'timed out';
2864
+ continue;
2865
+ }
2707
2866
  const text = await res.text();
2708
2867
  let json = {};
2709
2868
  try {
@@ -2713,10 +2872,18 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
2713
2872
  }
2714
2873
  const data = json.data || json;
2715
2874
  if (res.ok) {
2716
- const result = complete(data);
2717
- if (result !== undefined) return result;
2718
- const pending = data?.retryable === true && data?.committed === false && data?.idempotency_key === idempotencyKey;
2719
- if (!pending) return undefined;
2875
+ if (data?.idempotency_key !== undefined && data.idempotency_key !== idempotencyKey) {
2876
+ throw new Error('self-test write returned a different idempotency key');
2877
+ }
2878
+ const decisionId = selfTestDecisionId(data);
2879
+ if (pinnedDecisionId && decisionId && decisionId !== pinnedDecisionId) {
2880
+ throw new Error('self-test write returned a different decision id than its pending acknowledgement');
2881
+ }
2882
+ if (data?.retryable !== true || data?.committed !== false) return complete(data);
2883
+ if (data.idempotency_key !== idempotencyKey) {
2884
+ throw new Error('self-test write returned a pending acknowledgement without its idempotency key');
2885
+ }
2886
+ if (decisionId && data.decision_state === 'created') pinnedDecisionId = decisionId;
2720
2887
  lastState = 'pending';
2721
2888
  } else if (SELF_TEST_TRANSIENT_STATUSES.has(res.status)) {
2722
2889
  const reason = String(json.error || json.message || '').slice(0, 200);
@@ -2725,7 +2892,8 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
2725
2892
  throw new Error(String(json.error || json.message || `HTTP ${res.status}`));
2726
2893
  }
2727
2894
  if (attempt < SELF_TEST_WRITE_ATTEMPTS) {
2728
- await new Promise((resolve) => setTimeout(resolve, selfTestRetryDelayMs(res, data, retryDelayMs)));
2895
+ const wait = Math.min(selfTestRetryDelayMs(res, data, retryDelayMs), Math.max(0, deadline - Date.now()));
2896
+ await new Promise((resolve) => setTimeout(resolve, wait));
2729
2897
  }
2730
2898
  }
2731
2899
  return { failed: lastState };
@@ -2733,9 +2901,7 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
2733
2901
 
2734
2902
  function selfTestDecisionId(data) {
2735
2903
  const decisionId = data?.decision_id || data?.decisionId;
2736
- if (typeof decisionId !== 'string' || !decisionId) return undefined;
2737
- const pending = data.retryable === true && data.committed === false;
2738
- return !pending || data.decision_state === 'created' ? decisionId : undefined;
2904
+ return typeof decisionId === 'string' && decisionId ? decisionId : undefined;
2739
2905
  }
2740
2906
 
2741
2907
  function runLoopGuardSelfTest(options = {}) {
@@ -2810,6 +2976,61 @@ function runtimeGateVerified(runtime) {
2810
2976
  return ['allow', 'warn', 'review_required', 'block'].includes(decision);
2811
2977
  }
2812
2978
 
2979
+ const ACTIVATION_UNBOUND_KEY_FIX = 'This API key is not bound to one agent, so Marrow cannot confirm activation for a specific agent. Bind the key to one agent in the Marrow dashboard, or register the agent with POST /v1/agents and set MARROW_AGENT_ID to its id, then run npx -y @getmarrow/install@latest doctor --self-test.';
2980
+
2981
+ // The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
2982
+ // unbound key has no single agent, and a locally derived id is never substituted for it.
2983
+ function serverAgentIdentity(status, runtime) {
2984
+ const identity = status?.identity && typeof status.identity === 'object' ? status.identity : {};
2985
+ const safe = (value) => (typeof value === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : '');
2986
+ const bound = Array.isArray(identity.bound_agent_ids) ? identity.bound_agent_ids.map(safe).filter(Boolean) : [];
2987
+ const fromStatus = safe(identity.agent_id) || (bound.length === 1 ? bound[0] : '');
2988
+ if (fromStatus) return { agent_id: fromStatus, source: 'server_status_identity', bound_agent_ids: bound };
2989
+ const fromRuntime = safe(runtime?.agent_id);
2990
+ if (fromRuntime) return { agent_id: fromRuntime, source: 'server_runtime', bound_agent_ids: bound };
2991
+ return { agent_id: null, source: 'unresolved', bound_agent_ids: bound };
2992
+ }
2993
+
2994
+ // A runtime call that creates a decision leaves it open unless it is closed. The self-test
2995
+ // closes its own runtime decision in the same session with the runtime authorization id as
2996
+ // gate_receipt_id. A failed close is reported, never retried as a new decision.
2997
+ async function closeSelfTestRuntimeDecision(baseUrl, headers, runtime, selfTestKey, options) {
2998
+ const authorization = runtime?.runtime_authorization && typeof runtime.runtime_authorization === 'object'
2999
+ ? runtime.runtime_authorization
3000
+ : {};
3001
+ const decisionId = typeof runtime?.decision_id === 'string' && runtime.decision_id
3002
+ ? runtime.decision_id
3003
+ : typeof authorization.decision_id === 'string' ? authorization.decision_id : '';
3004
+ if (!decisionId || authorization.decision_state === 'not_created') return { created: false, committed: null };
3005
+ const gateReceiptId = String(authorization.id || runtime?.gate_receipt?.id || runtime?.risk_gate?.gate_receipt_id || '');
3006
+ try {
3007
+ const closed = await selfTestWrite(`${baseUrl}/v1/agent/commit`, {
3008
+ method: 'POST',
3009
+ headers,
3010
+ body: JSON.stringify({
3011
+ decision_id: decisionId,
3012
+ success: true,
3013
+ outcome: 'Marrow installer self-test runtime check responded; no action was executed.',
3014
+ ...(gateReceiptId ? { gate_receipt_id: gateReceiptId } : {}),
3015
+ proof: {
3016
+ checks: ['installer self-test runtime responded'],
3017
+ outcome: 'self-test runtime decision closed',
3018
+ },
3019
+ }),
3020
+ }, {
3021
+ idempotencyKey: `${selfTestKey}:runtime-commit`,
3022
+ complete: (data) => data,
3023
+ retryDelayMs: options.selfTestRetryDelayMs,
3024
+ attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
3025
+ deadlineMs: options.selfTestDeadlineMs,
3026
+ });
3027
+ if (closed?.failed) return { created: true, decision_id: decisionId, committed: false, error: closed.failed };
3028
+ return { created: true, decision_id: decisionId, committed: closed?.committed === true };
3029
+ } catch (error) {
3030
+ return { created: true, decision_id: decisionId, committed: false, error: error instanceof Error ? error.message : String(error) };
3031
+ }
3032
+ }
3033
+
2813
3034
  async function runSelfTest(options) {
2814
3035
  const initialProfile = initialToolProfileReport(options.toolProfile);
2815
3036
  if (!options.selfTest) return { skipped: true, reason: 'disabled', mcp_tool_profile: initialProfile };
@@ -2833,7 +3054,10 @@ async function runSelfTest(options) {
2833
3054
  'x-marrow-sdk-version': SDK_ADAPTER_VERSION,
2834
3055
  'x-marrow-mcp-version': executableMcpTarget(options).version,
2835
3056
  };
2836
- if (options.agentId) headers['x-marrow-agent-id'] = options.agentId;
3057
+ // Only a configured id is sent. Without one, Marrow resolves the key's bound agent or the
3058
+ // plan seat, and the self-test reads that server answer back for activation.
3059
+ const configuredAgentId = String(options.agentId || '').trim();
3060
+ if (configuredAgentId) headers['x-marrow-agent-id'] = configuredAgentId;
2837
3061
 
2838
3062
  const baseUrl = options.baseUrl.replace(/\/+$/, '');
2839
3063
  const selfTestKey = `install-self-test:${crypto.randomUUID()}`;
@@ -2853,6 +3077,8 @@ async function runSelfTest(options) {
2853
3077
  idempotencyKey: `${selfTestKey}:think`,
2854
3078
  complete: (data) => selfTestDecisionId(data),
2855
3079
  retryDelayMs: options.selfTestRetryDelayMs,
3080
+ attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
3081
+ deadlineMs: options.selfTestDeadlineMs,
2856
3082
  });
2857
3083
 
2858
3084
  const decisionId = typeof think === 'string' ? think : undefined;
@@ -2870,8 +3096,10 @@ async function runSelfTest(options) {
2870
3096
  }),
2871
3097
  }, {
2872
3098
  idempotencyKey: `${selfTestKey}:commit`,
2873
- complete: (data) => (data?.retryable === true && data?.committed === false ? undefined : data),
3099
+ complete: (data) => data,
2874
3100
  retryDelayMs: options.selfTestRetryDelayMs,
3101
+ attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
3102
+ deadlineMs: options.selfTestDeadlineMs,
2875
3103
  });
2876
3104
  if (commit?.failed) {
2877
3105
  throw new Error(`self-test commit did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${commit.failed})`);
@@ -2886,7 +3114,7 @@ async function runSelfTest(options) {
2886
3114
  context?.primary_tool_availability,
2887
3115
  Boolean(options.activation),
2888
3116
  );
2889
- const runtime = await requestJson(`${baseUrl}/v1/agent/runtime`, {
3117
+ const runtime = await selfTestWrite(`${baseUrl}/v1/agent/runtime`, {
2890
3118
  method: 'POST',
2891
3119
  headers,
2892
3120
  body: JSON.stringify({
@@ -2900,13 +3128,26 @@ async function runSelfTest(options) {
2900
3128
  outcome: 'self-test outcome committed',
2901
3129
  },
2902
3130
  }),
3131
+ }, {
3132
+ idempotencyKey: `${selfTestKey}:runtime`,
3133
+ complete: (data) => data,
3134
+ retryDelayMs: options.selfTestRetryDelayMs,
3135
+ attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
3136
+ deadlineMs: options.selfTestDeadlineMs,
2903
3137
  });
3138
+ if (runtime?.failed) {
3139
+ throw new Error(`self-test runtime did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${runtime.failed})`);
3140
+ }
3141
+ const runtimeDecisionClosure = await closeSelfTestRuntimeDecision(baseUrl, headers, runtime, selfTestKey, options);
3142
+ const serverIdentity = serverAgentIdentity(status, runtime);
3143
+ const activationAgentId = configuredAgentId || serverIdentity.agent_id || '';
3144
+ const activationIdentityUnresolved = Boolean(options.activation && !activationAgentId);
2904
3145
  const performance = await requestJson(`${baseUrl}/v1/analytics/agent-performance?period=7`, { headers })
2905
3146
  .catch((error) => ({
2906
3147
  ok: false,
2907
3148
  error: error instanceof Error ? error.message : String(error),
2908
3149
  }));
2909
- const firstValue = await requestJson(`${baseUrl}/v1/agent/first-value`, {
3150
+ const firstValueResult = await selfTestWrite(`${baseUrl}/v1/agent/first-value`, {
2910
3151
  method: 'POST',
2911
3152
  headers,
2912
3153
  body: JSON.stringify({
@@ -2919,14 +3160,25 @@ async function runSelfTest(options) {
2919
3160
  outcome: 'first-value endpoint reached',
2920
3161
  },
2921
3162
  decision_id: decisionId,
2922
- agent_id: options.agentId,
2923
- activation: options.activation ? {
3163
+ agent_id: activationAgentId || undefined,
3164
+ activation: options.activation && activationAgentId ? {
2924
3165
  ...options.activation,
3166
+ agent_id: activationAgentId,
2925
3167
  intervention_verified: runtimeGateVerified(runtime),
2926
3168
  closure_verified: true,
2927
3169
  } : undefined,
2928
3170
  }),
3171
+ }, {
3172
+ idempotencyKey: `${selfTestKey}:first-value`,
3173
+ complete: (data) => data,
3174
+ retryDelayMs: options.selfTestRetryDelayMs,
3175
+ attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
3176
+ deadlineMs: options.selfTestDeadlineMs,
2929
3177
  });
3178
+ if (firstValueResult?.failed) {
3179
+ throw new Error(`self-test first-value did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${firstValueResult.failed})`);
3180
+ }
3181
+ const firstValue = firstValueResult;
2930
3182
  const valueProof = await requestJson(`${baseUrl}/v1/agent/value/proof?period_days=30`, { headers })
2931
3183
  .catch((error) => ({
2932
3184
  ok: false,
@@ -2938,7 +3190,7 @@ async function runSelfTest(options) {
2938
3190
  let activationReceipt = null;
2939
3191
  let activationVerified = false;
2940
3192
  let activationProfileReceipt = null;
2941
- if (options.activation) {
3193
+ if (options.activation && !activationIdentityUnresolved) {
2942
3194
  const activationAdapterVersion = options.activation.adapter_version || INSTALLER_ADAPTER_VERSION;
2943
3195
  const activationCapabilityLevel = options.activation.capability_level || 'event_contract';
2944
3196
  const activationExpectedHooks = Array.isArray(options.activation.expected_hooks) ? options.activation.expected_hooks : [];
@@ -2956,7 +3208,7 @@ async function runSelfTest(options) {
2956
3208
  && typeof activationReceipt.id === 'string'
2957
3209
  && activationReceipt.id.length > 0
2958
3210
  && activationReceipt.decision_id === decisionId
2959
- && activationReceipt.agent_id === options.agentId
3211
+ && activationReceipt.agent_id === activationAgentId
2960
3212
  && activationReceipt.outcome_success === true
2961
3213
  && isCanonicalTimestamp(activationReceipt.outcome_recorded_at)
2962
3214
  && activationReceipt.server_confirmed === true
@@ -2975,7 +3227,7 @@ async function runSelfTest(options) {
2975
3227
  event_id: `activation-${activationConfigFingerprint.slice(0, 32)}`,
2976
3228
  event_type: 'activation_profile_registered',
2977
3229
  harness: options.activation.harness,
2978
- agent_id: options.agentId,
3230
+ agent_id: activationAgentId,
2979
3231
  session_id: headers['x-marrow-session-id'],
2980
3232
  adapter_version: activationAdapterVersion,
2981
3233
  capability_level: activationCapabilityLevel,
@@ -3022,6 +3274,8 @@ async function runSelfTest(options) {
3022
3274
  skipped: false,
3023
3275
  mcp_tool_profile: toolProfile,
3024
3276
  decision_id: decisionId,
3277
+ // Only committed:true is trusted closure; anything else is reported as such.
3278
+ decision_committed: commit?.committed === true,
3025
3279
  active: Boolean(status.enabled ?? status.ok),
3026
3280
  health: status.health || null,
3027
3281
  last_event_at: status.last_event_at || null,
@@ -3031,7 +3285,13 @@ async function runSelfTest(options) {
3031
3285
  runtime_active: runtimeGateVerified(runtime),
3032
3286
  runtime_exact_next_action: runtime.exact_next_action || null,
3033
3287
  runtime_before_you_act: runtime.before_you_act || null,
3288
+ runtime_decision_closure: runtimeDecisionClosure,
3289
+ agent_id: activationAgentId || null,
3290
+ agent_id_source: configuredAgentId ? 'configured' : serverIdentity.source,
3291
+ bound_agent_ids: serverIdentity.bound_agent_ids,
3034
3292
  activation_verified: activationVerified,
3293
+ activation_identity_unresolved: activationIdentityUnresolved,
3294
+ activation_exact_fix: activationIdentityUnresolved ? ACTIVATION_UNBOUND_KEY_FIX : null,
3035
3295
  activation_scope: options.activation ? 'server_self_test_only' : null,
3036
3296
  coverage_verified: false,
3037
3297
  passive_live: false,
@@ -3185,112 +3445,118 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
3185
3445
  };
3186
3446
  }
3187
3447
 
3188
- function printReport(report) {
3189
- process.stdout.write(`Marrow passive installer\n`);
3190
- process.stdout.write(`Root: ${report.root}\n`);
3191
- process.stdout.write(`Mode: ${report.mode}\n`);
3192
- process.stdout.write(`Write mode: ${report.writeMode}\n\n`);
3448
+ function printReport(report, sink) {
3449
+ const out = typeof sink === 'function' ? sink : (text) => process.stdout.write(text);
3450
+ out(`Marrow passive installer\n`);
3451
+ out(`Root: ${report.root}\n`);
3452
+ out(`Mode: ${report.mode}\n`);
3453
+ out(`Write mode: ${report.writeMode}\n\n`);
3193
3454
 
3194
3455
  if (report.activation?.requested) {
3195
- process.stdout.write('Activation:\n');
3196
- process.stdout.write(`- agent: ${report.activation.agent_id}\n`);
3197
- process.stdout.write(`- self-test server confirmed: ${report.activation.server_confirmed ? 'yes' : 'no'}\n`);
3198
- process.stdout.write(`- scope: ${report.activation.activation_scope}\n`);
3199
- process.stdout.write(`- coverage verified: ${report.activation.coverage_verified ? 'yes' : 'no'}\n`);
3200
- process.stdout.write(`- passive live in this process: ${report.activation.passive_live ? 'yes' : 'no'}\n`);
3201
- process.stdout.write(`- reload required: ${report.activation.reload_required ? 'yes' : 'no'}\n`);
3202
- if (report.activation.next_action) process.stdout.write(`- next action: ${report.activation.next_action}\n`);
3203
- process.stdout.write('\n');
3204
- }
3205
-
3206
- process.stdout.write('Detected:\n');
3456
+ out('Activation:\n');
3457
+ out(`- agent: ${report.activation.agent_id || 'none resolved'}${report.activation.agent_id_source ? ` (${report.activation.agent_id_source})` : ''}\n`);
3458
+ if (report.activation.exact_fix) out(`- exact fix: ${report.activation.exact_fix}\n`);
3459
+ out(`- self-test server confirmed: ${report.activation.server_confirmed ? 'yes' : 'no'}\n`);
3460
+ out(`- scope: ${report.activation.activation_scope}\n`);
3461
+ out(`- coverage verified: ${report.activation.coverage_verified ? 'yes' : 'no'}\n`);
3462
+ out(`- passive live in this process: ${report.activation.passive_live ? 'yes' : 'no'}\n`);
3463
+ out(`- reload required: ${report.activation.reload_required ? 'yes' : 'no'}\n`);
3464
+ if (report.activation.next_action) out(`- next action: ${report.activation.next_action}\n`);
3465
+ out('\n');
3466
+ }
3467
+
3468
+ out('Detected:\n');
3207
3469
  for (const [key, value] of Object.entries(report.detected)) {
3208
- process.stdout.write(`- ${key}: ${value ? 'yes' : 'no'}\n`);
3470
+ out(`- ${key}: ${value ? 'yes' : 'no'}\n`);
3209
3471
  }
3210
3472
 
3211
- process.stdout.write('\nPlanned changes:\n');
3473
+ out('\nPlanned changes:\n');
3212
3474
  for (const change of report.changes) {
3213
3475
  const marker = change.automatic_repair_suppressed
3214
3476
  ? 'preserved unverified-ahead surface; repair suppressed'
3215
3477
  : change.hook_conflict ? 'preserved unmanaged owner file; review required'
3216
3478
  : change.applied ? 'wrote' : change.changed ? 'would write' : 'unchanged';
3217
- process.stdout.write(`- ${marker}: ${change.label} (${change.path})\n`);
3479
+ out(`- ${marker}: ${change.label} (${change.path})\n`);
3218
3480
  if (change.automatic_repair_suppressed && change.exact_fix) {
3219
- process.stdout.write(` exact verification: ${change.exact_fix}\n`);
3481
+ out(` exact verification: ${change.exact_fix}\n`);
3482
+ }
3483
+ if (change.hook_conflict && change.exact_fix) out(` exact fix: ${change.exact_fix}\n`);
3484
+ for (const divergence of change.identity_divergence || []) {
3485
+ out(` identity reset: ${divergence.field} ${divergence.replaced} -> ${divergence.applied}. If the previous value was intended, add it to ${divergence.field === 'MARROW_BASE_URL' ? 'MARROW_ALLOWED_BASE_URLS' : 'MARROW_ALLOWED_AGENT_IDS'} and rerun.\n`);
3220
3486
  }
3221
- if (change.hook_conflict && change.exact_fix) process.stdout.write(` exact fix: ${change.exact_fix}\n`);
3222
3487
  }
3223
3488
 
3224
- process.stdout.write('\nLocal session loop guard:\n');
3225
- process.stdout.write(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
3226
- process.stdout.write(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
3227
- process.stdout.write(`- observed in a reloaded host: ${report.loop_guard_observed ? 'yes' : 'no'}\n`);
3489
+ out('\nLocal session loop guard:\n');
3490
+ out(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
3491
+ out(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
3492
+ out(`- observed in a reloaded host: ${report.loop_guard_observed ? 'yes' : 'no'}\n`);
3228
3493
  if (!report.loop_guard_observed) {
3229
- process.stdout.write('- next: restart the host and complete its hook trust/review step before relying on live enforcement\n');
3494
+ out('- next: restart the host and complete its hook trust/review step before relying on live enforcement\n');
3230
3495
  }
3231
3496
 
3232
- process.stdout.write('\nSelf-test:\n');
3497
+ out('\nSelf-test:\n');
3233
3498
  const toolProfile = report.selfTest.mcp_tool_profile || report.toolProfile;
3234
3499
  if (toolProfile) {
3235
- process.stdout.write(`- configured tool profile: ${toolProfile.configured_profile}\n`);
3236
- process.stdout.write(`- effective tool profile: ${toolProfile.effective_profile}\n`);
3237
- process.stdout.write(`- expected visible tools: ${toolProfile.expected_visible_count == null ? 'complete catalog (awaiting reloaded MCP count)' : toolProfile.expected_visible_count}\n`);
3238
- process.stdout.write(`- actual visible tools: ${toolProfile.actual_visible_count == null ? 'unavailable until process reload' : toolProfile.actual_visible_count}\n`);
3239
- process.stdout.write(`- visible tool names: ${toolProfile.visibility_live ? toolProfile.visible_tool_names.join(', ') : 'unavailable until process reload'}\n`);
3240
- process.stdout.write(`- profile live: ${toolProfile.visibility_live ? 'yes' : 'no'}\n`);
3500
+ out(`- configured tool profile: ${toolProfile.configured_profile}\n`);
3501
+ out(`- effective tool profile: ${toolProfile.effective_profile}\n`);
3502
+ out(`- expected visible tools: ${toolProfile.expected_visible_count == null ? 'complete catalog (awaiting reloaded MCP count)' : toolProfile.expected_visible_count}\n`);
3503
+ out(`- actual visible tools: ${toolProfile.actual_visible_count == null ? 'unavailable until process reload' : toolProfile.actual_visible_count}\n`);
3504
+ out(`- visible tool names: ${toolProfile.visibility_live ? toolProfile.visible_tool_names.join(', ') : 'unavailable until process reload'}\n`);
3505
+ out(`- profile live: ${toolProfile.visibility_live ? 'yes' : 'no'}\n`);
3241
3506
  const projection = toolProfile.backend_entitlement_projection;
3242
3507
  const availability = projection?.primary_tool_availability;
3243
3508
  if (projection?.evidence_state === 'available' && availability?.entitlement_evidence?.state === 'available') {
3244
- process.stdout.write(`- backend-projected entitled tools: ${availability.counts.entitled}\n`);
3245
- process.stdout.write(`- backend-projected upgrade-required tools: ${availability.counts.upgrade_required}\n`);
3246
- process.stdout.write(`- backend projection source: ${projection.source}; authorizes calls: no\n`);
3509
+ out(`- backend-projected entitled tools: ${availability.counts.entitled}\n`);
3510
+ out(`- backend-projected upgrade-required tools: ${availability.counts.upgrade_required}\n`);
3511
+ out(`- backend projection source: ${projection.source}; authorizes calls: no\n`);
3247
3512
  } else {
3248
- process.stdout.write(`- backend-projected entitlements: unavailable (source: ${projection?.source || 'backend_projection_not_provided'}; non-authorizing)\n`);
3513
+ out(`- backend-projected entitlements: unavailable (source: ${projection?.source || 'backend_projection_not_provided'}; non-authorizing)\n`);
3249
3514
  }
3250
3515
  }
3251
3516
  if (report.selfTest.skipped) {
3252
- process.stdout.write(`- skipped: ${report.selfTest.reason}\n`);
3253
- if (report.selfTest.exact_fix) process.stdout.write(`- exact fix: ${report.selfTest.exact_fix}\n`);
3517
+ out(`- skipped: ${report.selfTest.reason}\n`);
3518
+ if (report.selfTest.exact_fix) out(`- exact fix: ${report.selfTest.exact_fix}\n`);
3254
3519
  } else {
3255
- process.stdout.write(`- active: ${report.selfTest.active ? 'yes' : 'no'}\n`);
3256
- process.stdout.write(`- decision_id: ${report.selfTest.decision_id}\n`);
3257
- process.stdout.write(`- health: ${report.selfTest.health || 'unknown'}\n`);
3258
- process.stdout.write(`- one-call runtime: ${report.selfTest.runtime_active ? 'active' : 'not verified'}\n`);
3259
- if (report.selfTest.error) process.stdout.write(`- error: ${report.selfTest.error}\n`);
3260
- if (report.selfTest.next_action) process.stdout.write(`- next action: ${report.selfTest.next_action}\n`);
3520
+ out(`- active: ${report.selfTest.active ? 'yes' : 'no'}\n`);
3521
+ out(`- decision_id: ${report.selfTest.decision_id}\n`);
3522
+ out(`- decision committed (trusted closure): ${report.selfTest.decision_committed ? 'yes' : 'no'}\n`);
3523
+ out(`- health: ${report.selfTest.health || 'unknown'}\n`);
3524
+ out(`- one-call runtime: ${report.selfTest.runtime_active ? 'active' : 'not verified'}\n`);
3525
+ if (report.selfTest.error) out(`- error: ${report.selfTest.error}\n`);
3526
+ if (report.selfTest.next_action) out(`- next action: ${report.selfTest.next_action}\n`);
3261
3527
  const update = report.selfTest.client_update;
3262
3528
  const notification = update?.notification_state || update?.notification;
3263
3529
  if (update && (update.update_available === true || update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' || notification === 'security_required')) {
3264
- process.stdout.write('\nMarrow client update:\n');
3265
- process.stdout.write(`- priority: ${notification === 'security_required' ? 'security_required' : notification === 'recommended' ? 'recommended' : update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' ? 'version_unknown' : update.priority || 'recommended'}\n`);
3266
- process.stdout.write(`- installed: ${update.installed_version || update.current_version || 'unknown'}\n`);
3267
- process.stdout.write(`- latest: ${update.latest_version || 'unknown'}\n`);
3268
- process.stdout.write('- automatic notification: yes\n');
3269
- process.stdout.write('- automatic local mutation: no; operator policy applies\n');
3270
- if (update.owner_notice) process.stdout.write(`- tell owner: ${update.owner_notice}\n`);
3271
- if (update.agent_instruction) process.stdout.write(`- agent instruction: ${update.agent_instruction}\n`);
3530
+ out('\nMarrow client update:\n');
3531
+ out(`- priority: ${notification === 'security_required' ? 'security_required' : notification === 'recommended' ? 'recommended' : update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' ? 'version_unknown' : update.priority || 'recommended'}\n`);
3532
+ out(`- installed: ${update.installed_version || update.current_version || 'unknown'}\n`);
3533
+ out(`- latest: ${update.latest_version || 'unknown'}\n`);
3534
+ out('- automatic notification: yes\n');
3535
+ out('- automatic local mutation: no; operator policy applies\n');
3536
+ if (update.owner_notice) out(`- tell owner: ${update.owner_notice}\n`);
3537
+ if (update.agent_instruction) out(`- agent instruction: ${update.agent_instruction}\n`);
3272
3538
  if (update.update_command || update.exact_update_command || update.auto_update_command) {
3273
- process.stdout.write(`- update: ${update.auto_update_command || update.update_command || update.exact_update_command}\n`);
3539
+ out(`- update: ${update.auto_update_command || update.update_command || update.exact_update_command}\n`);
3274
3540
  }
3275
- if (update.verification_command || update.exact_verification_command) process.stdout.write(`- verify: ${update.verification_command || update.exact_verification_command}\n`);
3541
+ if (update.verification_command || update.exact_verification_command) out(`- verify: ${update.verification_command || update.exact_verification_command}\n`);
3276
3542
  }
3277
3543
  if (report.selfTest.first_value_signal) {
3278
- process.stdout.write('\nFirst value:\n');
3544
+ out('\nFirst value:\n');
3279
3545
  const valueMoment = report.selfTest.install_value_moment;
3280
3546
  if (valueMoment) {
3281
- process.stdout.write(`- ${valueMoment.headline}\n`);
3282
- process.stdout.write('- First proof:\n');
3283
- for (const proof of valueMoment.proof) process.stdout.write(` - ${proof}\n`);
3284
- process.stdout.write(`- ${valueMoment.fleet_signal}\n`);
3285
- process.stdout.write(`- Try this now: ${valueMoment.try_this_now}\n`);
3286
- process.stdout.write(`- Expected: ${valueMoment.expected_response}\n`);
3547
+ out(`- ${valueMoment.headline}\n`);
3548
+ out('- First proof:\n');
3549
+ for (const proof of valueMoment.proof) out(` - ${proof}\n`);
3550
+ out(`- ${valueMoment.fleet_signal}\n`);
3551
+ out(`- Try this now: ${valueMoment.try_this_now}\n`);
3552
+ out(`- Expected: ${valueMoment.expected_response}\n`);
3287
3553
  } else {
3288
- process.stdout.write(`- ${report.selfTest.first_value_signal.headline}\n`);
3289
- process.stdout.write(`- First useful lesson: ${report.selfTest.first_value_signal.first_lesson}\n`);
3554
+ out(`- ${report.selfTest.first_value_signal.headline}\n`);
3555
+ out(`- First useful lesson: ${report.selfTest.first_value_signal.first_lesson}\n`);
3290
3556
  if (report.selfTest.first_value_signal.value_proof.length) {
3291
- process.stdout.write(`- Proof: ${report.selfTest.first_value_signal.value_proof.join('; ')}\n`);
3557
+ out(`- Proof: ${report.selfTest.first_value_signal.value_proof.join('; ')}\n`);
3292
3558
  }
3293
- process.stdout.write(`- Next: ${report.selfTest.first_value_signal.next_action}\n`);
3559
+ out(`- Next: ${report.selfTest.first_value_signal.next_action}\n`);
3294
3560
  }
3295
3561
  }
3296
3562
  if (report.selfTest.token_value_proof) {
@@ -3298,67 +3564,77 @@ function printReport(report) {
3298
3564
  const observed = proof.observed || {};
3299
3565
  const savings = proof.savings || {};
3300
3566
  const tokens = observed.tokens || {};
3301
- process.stdout.write('\nToken value proof:\n');
3302
- process.stdout.write(`- passive capture: ${proof.enabled ? 'on' : 'unknown'}\n`);
3303
- process.stdout.write(`- model calls observed: ${observed.model_calls || 0}\n`);
3304
- process.stdout.write(`- tokens observed: ${tokens.total || 0}\n`);
3305
- process.stdout.write(`- estimated tokens saved: ${savings.estimated_tokens_saved || 0}\n`);
3306
- if (savings.confidence) process.stdout.write(`- confidence: ${savings.confidence}\n`);
3307
- if (proof.proof_line) process.stdout.write(`- proof: ${proof.proof_line}\n`);
3308
- if (proof.exact_next_action) process.stdout.write(`- next: ${proof.exact_next_action}\n`);
3567
+ out('\nToken value proof:\n');
3568
+ out(`- passive capture: ${proof.enabled ? 'on' : 'unknown'}\n`);
3569
+ out(`- model calls observed: ${observed.model_calls || 0}\n`);
3570
+ out(`- tokens observed: ${tokens.total || 0}\n`);
3571
+ out(`- estimated tokens saved: ${savings.estimated_tokens_saved || 0}\n`);
3572
+ if (savings.confidence) out(`- confidence: ${savings.confidence}\n`);
3573
+ if (proof.proof_line) out(`- proof: ${proof.proof_line}\n`);
3574
+ if (proof.exact_next_action) out(`- next: ${proof.exact_next_action}\n`);
3309
3575
  }
3310
3576
  }
3311
3577
 
3312
3578
  if (report.harnessReload && report.writeMode !== 'doctor') {
3313
- process.stdout.write('\nHarness reload:\n');
3314
- process.stdout.write(`- required: ${report.harnessReload.required ? 'yes' : 'no'}\n`);
3315
- process.stdout.write(`- live in this process: ${report.harnessReload.live_in_this_process ? 'yes' : 'no'}\n`);
3316
- if (report.harnessReload.required && report.harnessReload.instruction) process.stdout.write(`- restart: ${report.harnessReload.instruction}\n`);
3317
- if (report.harnessReload.required && report.harnessReload.prove_command) process.stdout.write(`- prove after restart: ${report.harnessReload.prove_command}\n`);
3579
+ out('\nHarness reload:\n');
3580
+ out(`- required: ${report.harnessReload.required ? 'yes' : 'no'}\n`);
3581
+ out(`- live in this process: ${report.harnessReload.live_in_this_process ? 'yes' : 'no'}\n`);
3582
+ if (report.harnessReload.required && report.harnessReload.instruction) out(`- restart: ${report.harnessReload.instruction}\n`);
3583
+ if (report.harnessReload.required && report.harnessReload.prove_command) out(`- prove after restart: ${report.harnessReload.prove_command}\n`);
3318
3584
  }
3319
3585
  if (report.firstCapture) {
3320
- process.stdout.write('\nFirst capture:\n');
3321
- process.stdout.write(`- client: ${report.firstCapture.client}\n`);
3322
- process.stdout.write(`- capability: ${report.firstCapture.capability_level}\n`);
3323
- if (report.firstCapture.command) process.stdout.write(`- command: ${report.firstCapture.command}\n`);
3324
- process.stdout.write(`- ${report.firstCapture.instruction}\n`);
3586
+ out('\nFirst capture:\n');
3587
+ out(`- client: ${report.firstCapture.client}\n`);
3588
+ out(`- capability: ${report.firstCapture.capability_level}\n`);
3589
+ if (report.firstCapture.command) out(`- command: ${report.firstCapture.command}\n`);
3590
+ out(`- ${report.firstCapture.instruction}\n`);
3325
3591
  }
3326
3592
 
3327
3593
  if (report.remediation) {
3328
- process.stdout.write('\nRemediation:\n');
3329
- process.stdout.write(`- attempted: ${report.remediation.attempted ? 'yes' : 'no'}\n`);
3330
- process.stdout.write(`- fixed config: ${report.remediation.fixedConfig ? 'yes' : 'no'}\n`);
3331
- process.stdout.write(`- self-test passed: ${report.remediation.selfTestPassed ? 'yes' : 'no'}\n`);
3332
- if (report.remediation.message) process.stdout.write(`- result: ${report.remediation.message}\n`);
3594
+ out('\nRemediation:\n');
3595
+ out(`- attempted: ${report.remediation.attempted ? 'yes' : 'no'}\n`);
3596
+ out(`- fixed config: ${report.remediation.fixedConfig ? 'yes' : 'no'}\n`);
3597
+ out(`- self-test passed: ${report.remediation.selfTestPassed ? 'yes' : 'no'}\n`);
3598
+ if (report.remediation.message) out(`- result: ${report.remediation.message}\n`);
3333
3599
  }
3334
3600
 
3335
- if (report.configDiagnostics?.npm_token?.mismatch || report.configDiagnostics?.npm_token?.missing_npmrc_token) {
3336
- const npm = report.configDiagnostics.npm_token;
3337
- process.stdout.write('\nConfig diagnostics:\n');
3338
- process.stdout.write(`- npm token mismatch: ${npm.mismatch ? 'yes' : 'no'}\n`);
3339
- process.stdout.write(`- npmrc token missing: ${npm.missing_npmrc_token ? 'yes' : 'no'}\n`);
3340
- process.stdout.write(`- repairable: ${npm.repairable ? 'yes' : 'no'}\n`);
3341
- if (npm.recommended_fix) process.stdout.write(`- exact fix: ${npm.recommended_fix}\n`);
3601
+ if (report.sdkDependency?.required) {
3602
+ out('\nSDK dependency:\n');
3603
+ out(`- @getmarrow/sdk: ${report.sdkDependency.present ? 'present' : report.sdkDependency.ahead_unverified ? 'newer version requires verification' : 'missing'}\n`);
3604
+ if (report.sdkDependency.install_command) out(`- exact fix: ${report.sdkDependency.install_command}\n`);
3605
+ if (report.sdkDependency.warning) out(`- warning: ${report.sdkDependency.warning}\n`);
3342
3606
  }
3343
3607
 
3344
- if (report.configRepairs?.length) {
3345
- process.stdout.write('\nConfig repairs:\n');
3346
- for (const repair of report.configRepairs) {
3347
- process.stdout.write(`- ${repair.changed ? 'fixed' : 'checked'}: ${repair.message}\n`);
3608
+ if (report.hermes?.detected) {
3609
+ out('\nHermes:\n');
3610
+ out(`- MCP server entry: ${report.hermes.state}${report.hermes.config_path ? ` (${report.hermes.config_path})` : ''}\n`);
3611
+ if (report.hermes.undo?.length) {
3612
+ out(`- undo (no backup is kept because the file holds other servers' credentials; values redacted): in ${report.hermes.config_path} under mcp_servers.marrow\n`);
3613
+ for (const step of report.hermes.undo) {
3614
+ if (step.change === 'replaced') {
3615
+ out(' replace these lines:\n');
3616
+ for (const line of step.lines) out(` ${line}\n`);
3617
+ out(' with the original lines:\n');
3618
+ for (const line of step.replaced) out(` ${line}\n`);
3619
+ } else {
3620
+ out(' remove these added lines:\n');
3621
+ for (const line of step.lines) out(` ${line}\n`);
3622
+ }
3623
+ }
3348
3624
  }
3625
+ if (report.hermes.key_source) out(`- API key source for the Hermes MCP server: ${report.hermes.key_source}\n`);
3626
+ if (report.hermes.owner_key_storage) out(`- ~/.marrow/env key storage: ${report.hermes.owner_key_storage.state}\n`);
3627
+ if (report.hermes.restart) out(`- restart: ${report.hermes.restart}\n`);
3628
+ if (report.hermes.exact_fix) out(`- exact fix: ${report.hermes.exact_fix}\n`);
3349
3629
  }
3350
3630
 
3351
- if (report.sdkDependency?.required) {
3352
- process.stdout.write('\nSDK dependency:\n');
3353
- process.stdout.write(`- @getmarrow/sdk: ${report.sdkDependency.present ? 'present' : report.sdkDependency.ahead_unverified ? 'newer version requires verification' : 'missing'}\n`);
3354
- if (report.sdkDependency.install_command) process.stdout.write(`- exact fix: ${report.sdkDependency.install_command}\n`);
3355
- if (report.sdkDependency.warning) process.stdout.write(`- warning: ${report.sdkDependency.warning}\n`);
3356
- }
3357
-
3358
- process.stdout.write('\nAutomatic controller:\n');
3359
- process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
3360
- if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
3361
- if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
3631
+ out('\nAutomatic controller:\n');
3632
+ out(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
3633
+ if (report.controller?.installer_version) out(`- installer version: ${report.controller.installer_version}\n`);
3634
+ if (report.controller?.restarted) out(`- restarted: ${report.controller.restarted.from_versions.join(', ')} -> ${report.controller.restarted.to_version}\n`);
3635
+ if (report.controller?.started_at) out(`- started: ${report.controller.started_at}\n`);
3636
+ if (report.controller?.reason) out(`- reason: ${report.controller.reason}\n`);
3637
+ if (report.controller?.exact_fix) out(`- exact fix: ${report.controller.exact_fix}\n`);
3362
3638
 
3363
3639
  if (report.writeMode === 'doctor') {
3364
3640
  const liveHere = !report.harnessReload?.required
@@ -3369,49 +3645,113 @@ function printReport(report) {
3369
3645
  : report.doctor.active
3370
3646
  ? 'server confirmed, restart required'
3371
3647
  : 'no';
3372
- process.stdout.write('\nDoctor:\n');
3373
- process.stdout.write(`- Marrow active: ${activeLabel}\n`);
3374
- process.stdout.write(`- missing env: ${report.doctor.missingEnv.length ? report.doctor.missingEnv.join(', ') : 'none'}\n`);
3375
- if (report.doctor.envHints.length) process.stdout.write(`- possible env files: ${report.doctor.envHints.join(', ')}\n`);
3376
- process.stdout.write(`- missing hooks/config: ${report.doctor.missingHooks.length ? report.doctor.missingHooks.join('; ') : 'none'}\n`);
3377
- process.stdout.write(`- loop guard configured: ${report.doctor.loop_guard_configured ? 'yes' : 'no'}\n`);
3378
- process.stdout.write(`- loop guard isolated self-test: ${report.doctor.loop_guard_self_tested ? 'passed' : 'not passed'}\n`);
3379
- process.stdout.write(`- loop guard observed after reload/trust: ${report.doctor.loop_guard_observed ? 'yes' : 'no'}\n`);
3648
+ out('\nDoctor:\n');
3649
+ out(`- Marrow active: ${activeLabel}\n`);
3650
+ out(`- missing env: ${report.doctor.missingEnv.length ? report.doctor.missingEnv.join(', ') : 'none'}\n`);
3651
+ if (report.doctor.envHints.length) out(`- possible env files: ${report.doctor.envHints.join(', ')}\n`);
3652
+ out(`- missing hooks/config: ${report.doctor.missingHooks.length ? report.doctor.missingHooks.join('; ') : 'none'}\n`);
3653
+ out(`- loop guard configured: ${report.doctor.loop_guard_configured ? 'yes' : 'no'}\n`);
3654
+ out(`- loop guard isolated self-test: ${report.doctor.loop_guard_self_tested ? 'passed' : 'not passed'}\n`);
3655
+ out(`- loop guard observed after reload/trust: ${report.doctor.loop_guard_observed ? 'yes' : 'no'}\n`);
3380
3656
  if (report.doctor.mcpProcesses?.available) {
3381
3657
  const processes = report.doctor.mcpProcesses;
3382
- process.stdout.write(`- MCP process versions: ${processes.active_versions.length ? processes.active_versions.join(', ') : processes.active_processes ? 'unknown' : 'none'}\n`);
3383
- process.stdout.write(`- stale/mixed/version-unknown MCP clients: ${processes.healthy ? 'no' : 'yes'}\n`);
3384
- if (processes.ahead_unverified_versions.length) process.stdout.write(`- unverified-ahead MCP clients: ${processes.ahead_unverified_versions.join(', ')}\n`);
3385
- if (processes.automatic_repair_suppressed) process.stdout.write('- automatic MCP process repair: suppressed pending official registry verification\n');
3658
+ out(`- MCP process versions: ${processes.active_versions.length ? processes.active_versions.join(', ') : processes.active_processes ? 'unknown' : 'none'}\n`);
3659
+ out(`- stale/mixed/version-unknown MCP clients: ${processes.healthy ? 'no' : 'yes'}\n`);
3660
+ if (processes.ahead_unverified_versions.length) out(`- unverified-ahead MCP clients: ${processes.ahead_unverified_versions.join(', ')}\n`);
3661
+ if (processes.automatic_repair_suppressed) out('- automatic MCP process repair: suppressed pending official registry verification\n');
3386
3662
  }
3387
3663
  if (report.doctor.mcpConfigurations) {
3388
3664
  const configurations = report.doctor.mcpConfigurations;
3389
- process.stdout.write(`- configured MCP versions: ${configurations.configured_versions.length ? configurations.configured_versions.join(', ') : 'none pinned'}\n`);
3390
- process.stdout.write(`- stale/mixed/version-unknown MCP configuration: ${configurations.healthy ? 'no' : 'yes'}\n`);
3391
- if (configurations.ahead_unverified_versions.length) process.stdout.write(`- unverified-ahead MCP configuration: ${configurations.ahead_unverified_versions.join(', ')}\n`);
3392
- if (configurations.automatic_repair_suppressed) process.stdout.write('- automatic MCP configuration repair: suppressed pending official registry verification\n');
3665
+ out(`- configured MCP versions: ${configurations.configured_versions.length ? configurations.configured_versions.join(', ') : 'none pinned'}\n`);
3666
+ out(`- stale/mixed/version-unknown MCP configuration: ${configurations.healthy ? 'no' : 'yes'}\n`);
3667
+ if (configurations.ahead_unverified_versions.length) out(`- unverified-ahead MCP configuration: ${configurations.ahead_unverified_versions.join(', ')}\n`);
3668
+ if (configurations.automatic_repair_suppressed) out('- automatic MCP configuration repair: suppressed pending official registry verification\n');
3393
3669
  }
3394
- if (report.doctor.recommendedFix) process.stdout.write(`- recommended fix: ${report.doctor.recommendedFix}\n`);
3670
+ if (report.doctor.recommendedFix) out(`- recommended fix: ${report.doctor.recommendedFix}\n`);
3395
3671
  if (report.doctor.mcp_update_required) {
3396
- process.stdout.write(`- restart once after update: ${report.doctor.restart_instruction}\n`);
3397
- process.stdout.write(`- verify after restart: ${report.doctor.verification_command}\n`);
3672
+ out(`- restart once after update: ${report.doctor.restart_instruction}\n`);
3673
+ out(`- verify after restart: ${report.doctor.verification_command}\n`);
3398
3674
  }
3399
- process.stdout.write(`- live health: ${report.doctor.healthCommand}\n`);
3675
+ out(`- live health: ${report.doctor.healthCommand}\n`);
3400
3676
  }
3401
3677
 
3402
3678
  if (report.writeMode === 'dry-run') {
3403
- process.stdout.write('\nRun with --yes to write these changes.\n');
3679
+ out('\nRun with --yes to write these changes.\n');
3404
3680
  }
3405
3681
 
3406
3682
  if (report.warnings.length > 0) {
3407
- process.stdout.write('\nWarnings:\n');
3683
+ out('\nWarnings:\n');
3408
3684
  for (const warning of report.warnings) {
3409
- process.stdout.write(`- ${warning}\n`);
3685
+ out(`- ${warning}\n`);
3410
3686
  }
3411
3687
  }
3412
3688
  }
3413
3689
 
3690
+ // The local controller directory is keyed by project and this id. It never leaves the machine.
3691
+ // Hermes passes only PATH, HOME and locale variables to an MCP server, plus its env block.
3692
+ // When neither the entry nor $HERMES_HOME/.env carries the key, the Marrow MCP server reads
3693
+ // it from the owner-only ~/.marrow/env, which is written here (mode 600) only if absent.
3694
+ function hermesWiringReport(detection, changes, options, planMode) {
3695
+ const paths = detection.paths || {};
3696
+ if (!detection.hermes) return { detected: false, state: 'not_detected' };
3697
+ if (planMode && !['mcp', 'both'].includes(planMode)) return { detected: true, state: 'skipped_for_mode', config_path: paths.hermesConfig };
3698
+ const mcpTargetVersion = options.mcpTargetVersion || MCP_ADAPTER_VERSION;
3699
+ const blockOptions = { mcpPackageSpec: `@getmarrow/mcp@${mcpTargetVersion}`, keyReference: hermesEnvHasKey(paths.hermesEnv) };
3700
+ if (!detection.hermesConfig) {
3701
+ return {
3702
+ detected: true,
3703
+ state: 'config_not_found',
3704
+ config_path: paths.hermesConfig,
3705
+ exact_fix: `Run Hermes once so it creates ${paths.hermesConfig}, then rerun this command, or add this block to it:\n${hermesManualBlock(blockOptions)}`,
3706
+ };
3707
+ }
3708
+ const change = (changes || []).find((entry) => entry.label === HERMES_WRITE_LABEL);
3709
+ if (!change) {
3710
+ return {
3711
+ detected: true,
3712
+ state: 'refused',
3713
+ config_path: paths.hermesConfig,
3714
+ exact_fix: `Marrow left ${paths.hermesConfig} unchanged because its path is not a direct, regular owner file. Add this block under mcp_servers by hand, then restart Hermes:\n${hermesManualBlock(blockOptions)}`,
3715
+ };
3716
+ }
3717
+ if (change.hook_conflict) {
3718
+ return { detected: true, state: 'refused', config_path: change.path, reason: change.conflict_reason || null, exact_fix: change.exact_fix };
3719
+ }
3720
+ if (change.automatic_repair_suppressed) {
3721
+ return { detected: true, state: 'preserved_unverified_ahead', config_path: change.path, exact_fix: change.exact_fix };
3722
+ }
3723
+ if (change.hosted_entry_preserved) {
3724
+ return { detected: true, state: 'hosted_entry_preserved', config_path: change.path };
3725
+ }
3726
+ const state = change.applied ? 'configured' : change.changed ? 'would_configure' : 'already_configured';
3727
+ let keySource = 'owner_env_file';
3728
+ let keyStorage = null;
3729
+ const current = safeRead(change.path);
3730
+ const entryKeys = hermesMarrowEnvKeyNames(current);
3731
+ if (entryKeys.includes('MARROW_API_KEY') || entryKeys.includes('MARROW_KEY')) {
3732
+ keySource = /MARROW_API_KEY:\s*["']?\$\{MARROW_API_KEY\}/.test(current) ? 'hermes_env_reference' : 'hermes_entry';
3733
+ } else if (state !== 'would_configure' && options.yes && !options.dryRun && !options.doctor) {
3734
+ keyStorage = ensureOwnerApiKey(options.home || detection.home, options.apiKey);
3735
+ }
3736
+ return {
3737
+ detected: true,
3738
+ state,
3739
+ config_path: change.path,
3740
+ undo: state === 'configured' ? change.undo || [] : null,
3741
+ key_source: keySource,
3742
+ ...(keyStorage ? { owner_key_storage: { state: keyStorage.state, path: keyStorage.path } } : {}),
3743
+ restart: state === 'configured' ? 'Restart Hermes so it loads the Marrow MCP server.' : null,
3744
+ };
3745
+ }
3746
+
3747
+ function localControllerAgentId(root, client, configuredAgentId = '') {
3748
+ return String(configuredAgentId || '').trim() || stableAgentId(root, client);
3749
+ }
3750
+
3414
3751
  async function install(options) {
3752
+ // Only a configured agent id is sent to Marrow. The controller keeps a local identity for its
3753
+ // state directory, which is never sent.
3754
+ const configuredAgentId = String(options.agentId || '').trim();
3415
3755
  if (options.activate && (options.yes !== true || options.dryRun || options.doctor)) {
3416
3756
  throw new Error('activate requires write mode (--yes) because hooks must be installed during this run');
3417
3757
  }
@@ -3432,9 +3772,11 @@ async function install(options) {
3432
3772
  HOME: options.home || options.cwd,
3433
3773
  USERPROFILE: options.home || options.cwd,
3434
3774
  });
3775
+ if (options.repair || options.update) assertUpdateTargetsManagedRoot(detection);
3435
3776
  const client = detectedClient(detection);
3436
3777
  options.client = client;
3437
- options.agentId = String(options.agentId || '').trim() || stableAgentId(detection.root, client);
3778
+ options.agentId = configuredAgentId;
3779
+ const controllerIdentityAgentId = localControllerAgentId(detection.root, client, configuredAgentId);
3438
3780
  const observedMcpProcesses = inspectMcpProcesses({ commands: options.processCommands });
3439
3781
  const observedMcpConfigurations = inspectMcpConfigurations(detection, { paths: options.mcpConfigPaths });
3440
3782
  const registryMetadata = await readMcpRegistryMetadata(options);
@@ -3476,7 +3818,7 @@ async function install(options) {
3476
3818
  };
3477
3819
  options.activation = options.activate ? {
3478
3820
  harness: client,
3479
- agent_id: options.agentId,
3821
+ agent_id: configuredAgentId || null,
3480
3822
  install_surface: plan.mode,
3481
3823
  mode: options.governanceMode || 'passive',
3482
3824
  hooks_installed: changes
@@ -3497,12 +3839,7 @@ async function install(options) {
3497
3839
  intervention_verified: false,
3498
3840
  closure_verified: false,
3499
3841
  } : null;
3500
- const configInspection = inspectNpmTokenConfig();
3501
3842
  const sdkDependency = inspectSdkDependency(detection);
3502
- const configDiagnostics = configInspection.safe;
3503
- const configRepairs = options.repair && options.yes && !options.dryRun && !options.doctor
3504
- ? repairConfigDiagnostics(configDiagnostics)
3505
- : [];
3506
3843
  const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
3507
3844
  const mcpProcesses = inspectMcpProcesses({
3508
3845
  commands: options.processCommands,
@@ -3552,9 +3889,12 @@ async function install(options) {
3552
3889
  '',
3553
3890
  registryVerificationAction,
3554
3891
  );
3555
- if (options.activate && !selfTest.activation_verified) {
3892
+ // An unbound key has no single agent to activate; the self-test still verified the account,
3893
+ // and the report carries the exact fix instead of failing the one-command install.
3894
+ if (options.activate && !selfTest.activation_verified && !selfTest.activation_identity_unresolved) {
3556
3895
  throw new Error('Marrow activation failed: server confirmation was not returned');
3557
3896
  }
3897
+ const hermes = hermesWiringReport(detection, changes, options, plan.mode);
3558
3898
  const plannedHarnessReload = harnessReloadPlan(detection, options.doctor ? [] : changes);
3559
3899
  const updateNeedsRestart = Boolean(options.update
3560
3900
  && !automaticMcpRepairSuppressed
@@ -3599,12 +3939,12 @@ async function install(options) {
3599
3939
  reload_required: true,
3600
3940
  };
3601
3941
  }
3602
- const changedConfig = changes.some((change) => change.applied) || configRepairs.some((repair) => repair.changed);
3942
+ const changedConfig = changes.some((change) => change.applied);
3603
3943
  const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
3604
3944
  const controllerPlatform = options.controllerPlatform || process.platform;
3605
3945
  let controller = await controllerStatus({
3606
3946
  root: detection.root,
3607
- agentId: options.agentId,
3947
+ identityAgentId: controllerIdentityAgentId,
3608
3948
  platform: controllerPlatform,
3609
3949
  });
3610
3950
  const shouldEnsureController = options.controller !== false
@@ -3617,10 +3957,11 @@ async function install(options) {
3617
3957
  && (options.yes || options.activate || options.repair);
3618
3958
  if (shouldEnsureController) {
3619
3959
  try {
3620
- controller = await ensureGovernanceController({
3960
+ controller = await ensureCurrentGovernanceController({
3621
3961
  apiKey: options.apiKey,
3622
3962
  baseUrl: options.baseUrl,
3623
- agentId: options.agentId,
3963
+ agentId: configuredAgentId,
3964
+ identityAgentId: controllerIdentityAgentId,
3624
3965
  client,
3625
3966
  root: detection.root,
3626
3967
  mode: plan.mode,
@@ -3639,6 +3980,19 @@ async function install(options) {
3639
3980
  if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
3640
3981
  }
3641
3982
  }
3983
+ // Only a controller that is simply not running is expected while the owner has disabled
3984
+ // local control. Unsafe state, an unverified or unresponsive process, and unsupported
3985
+ // platforms keep their exact fix.
3986
+ if (localControl.state === 'disabled'
3987
+ && controllerSupportedPlatform(controllerPlatform)
3988
+ && ['stopped', 'stale'].includes(controller.state)) {
3989
+ controller = {
3990
+ ...controller,
3991
+ required: false,
3992
+ exact_fix: null,
3993
+ reason: 'Local control is disabled by the owner, so the controller is intentionally not started. It starts again on the next install or update after npx @getmarrow/install control enable.',
3994
+ };
3995
+ }
3642
3996
  const remediation = options.repair
3643
3997
  ? {
3644
3998
  attempted: true,
@@ -3676,8 +4030,10 @@ async function install(options) {
3676
4030
  },
3677
4031
  activation: {
3678
4032
  requested: options.activate,
3679
- agent_id: options.agentId,
4033
+ agent_id: selfTest.agent_id || configuredAgentId || null,
4034
+ agent_id_source: configuredAgentId ? 'configured' : selfTest.agent_id_source || 'unresolved',
3680
4035
  server_confirmed: Boolean(selfTest.activation_verified),
4036
+ ...(selfTest.activation_identity_unresolved ? { exact_fix: selfTest.activation_exact_fix } : {}),
3681
4037
  activation_scope: selfTest.activation_scope || null,
3682
4038
  coverage_verified: false,
3683
4039
  passive_live: false,
@@ -3691,7 +4047,8 @@ async function install(options) {
3691
4047
  loop_guard_observed: false,
3692
4048
  loop_guard: loopGuardSelfTest,
3693
4049
  harnessReload,
3694
- firstCapture: firstCapturePath(detection, options.agentId),
4050
+ firstCapture: firstCapturePath(detection),
4051
+ hermes,
3695
4052
  changes,
3696
4053
  doctor: {
3697
4054
  active: Boolean(!selfTest.skipped && selfTest.active),
@@ -3712,7 +4069,7 @@ async function install(options) {
3712
4069
  loop_guard_observed: false,
3713
4070
  recommendedFix: registryVerificationAction
3714
4071
  || (updateAwaitingRestart ? INSTALLER_RESTART_INSTRUCTION : null)
3715
- || mcpProcesses.exact_fix || mcpConfigurations.exact_fix || configDiagnostics.npm_token.recommended_fix || (!options.apiKey
4072
+ || mcpProcesses.exact_fix || mcpConfigurations.exact_fix || (!options.apiKey
3716
4073
  ? envHints.length
3717
4074
  ? `MARROW_API_KEY was found in a likely env file at ${envHints[0]}. Load that key from trusted secret storage, export only MARROW_API_KEY, then run npx @getmarrow/install --repair.`
3718
4075
  : 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
@@ -3720,8 +4077,6 @@ async function install(options) {
3720
4077
  healthCommand: 'npx -y --package=@getmarrow/mcp@latest marrow-mcp ping',
3721
4078
  },
3722
4079
  remediation,
3723
- configDiagnostics,
3724
- configRepairs,
3725
4080
  sdkDependency,
3726
4081
  controller,
3727
4082
  local_control: localControl,
@@ -3744,18 +4099,150 @@ async function install(options) {
3744
4099
  };
3745
4100
  }
3746
4101
 
4102
+ function activationFailureFix(message) {
4103
+ if (/Agent-bound key cannot access another agent|MARROW_AGENT_SCOPE_MISMATCH/i.test(message)) {
4104
+ return `unset MARROW_AGENT_ID and MARROW_FLEET_AGENT_ID so Marrow uses the key's own agent, then rerun ${INSTALLER_UPDATE_COMMAND}`;
4105
+ }
4106
+ return INSTALLER_DOCTOR_COMMAND;
4107
+ }
4108
+
4109
+ function installCommandFor(options) {
4110
+ return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest';
4111
+ }
4112
+
4113
+ // One summary for the one-command paths (first install and update). The full report goes to a
4114
+ // private log file. Nothing here prints the API key.
4115
+ function installSummaryLines(report, options) {
4116
+ const selfTest = report.selfTest || {};
4117
+ const activation = report.activation || {};
4118
+ const lines = [];
4119
+ const untrusted = !selfTest.skipped && !selfTest.error && selfTest.active && selfTest.decision_committed !== true;
4120
+ const selfTestFailed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
4121
+ if (selfTest.skipped) {
4122
+ lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: configuration updated; self-test skipped (${selfTest.reason}).${selfTest.exact_fix ? ` Fix: ${selfTest.exact_fix}` : ''}`);
4123
+ } else if (untrusted) {
4124
+ lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test decision ${selfTest.decision_id} was recorded without trusted closure (committed was not true). Fix: ${INSTALLER_DOCTOR_COMMAND}`);
4125
+ } else if (selfTestFailed) {
4126
+ const fix = report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
4127
+ lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test failed: ${selfTest.error || 'Marrow reported this account as inactive'}. Fix: ${fix}`);
4128
+ } else {
4129
+ const agent = activation.agent_id
4130
+ ? `; agent ${activation.agent_id} (${activation.agent_id_source === 'configured' ? 'configured' : 'resolved by Marrow'})`
4131
+ : '; this key is not bound to one agent';
4132
+ lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: healthy. Self-test decision ${selfTest.decision_id} committed${agent}.`);
4133
+ }
4134
+ if (activation.exact_fix) lines.push(`Activation: ${activation.exact_fix}`);
4135
+ const apiKey = report.api_key || {};
4136
+ if (apiKey.source === 'owner_env_file' && apiKey.path) lines.push(`API key: read from ${apiKey.path}.`);
4137
+ if (apiKey.owner_files_disagree) {
4138
+ lines.push('Warning: ~/.marrow/env.local and ~/.marrow/env hold different Marrow keys. env.local takes precedence for the installer, hooks and the MCP server; keep only the intended key.');
4139
+ }
4140
+ const controller = report.controller || {};
4141
+ if (controller.restarted) {
4142
+ lines.push(`Controller restarted: ${controller.restarted.from_versions.join(', ')} -> ${controller.restarted.to_version}.`);
4143
+ } else if (controller.active && controller.changed) {
4144
+ lines.push('Controller started.');
4145
+ } else if (report.local_control?.state === 'disabled') {
4146
+ lines.push('Local control is disabled by the owner, so the controller was left stopped.');
4147
+ } else if (options.controller !== false && !controller.active && controller.exact_fix && controller.required !== false) {
4148
+ lines.push(`Controller: ${controller.state}. Fix: ${controller.exact_fix}`);
4149
+ }
4150
+ const hermes = report.hermes || {};
4151
+ if (hermes.state === 'configured') {
4152
+ lines.push(`Hermes: added the Marrow MCP server to ${hermes.config_path}; the undo steps are in the full report. Restart Hermes to load it.`);
4153
+ } else if (hermes.state === 'already_configured') {
4154
+ lines.push('Hermes: the Marrow MCP server is already configured.');
4155
+ } else if (hermes.state === 'refused' || hermes.state === 'config_not_found' || hermes.state === 'preserved_unverified_ahead') {
4156
+ lines.push(`Hermes: ${hermes.config_path || 'config.yaml'} was not changed. The exact block to add is in the full report.`);
4157
+ }
4158
+ if (hermes.owner_key_storage?.state === 'written') {
4159
+ lines.push('Stored your API key in ~/.marrow/env (mode 600) so the Hermes MCP server can read it; Hermes passes no other environment to MCP servers.');
4160
+ } else if (hermes.owner_key_storage?.state === 'different_key_present') {
4161
+ lines.push('Note: ~/.marrow/env already holds a different Marrow key; the Hermes MCP server will use that one.');
4162
+ }
4163
+ const divergences = (report.changes || []).flatMap((change) => (change.identity_divergence || []).map((divergence) => ({ ...divergence, path: change.path })));
4164
+ if (divergences.length) {
4165
+ lines.push(`MCP config identity reset: ${divergences.map((divergence) => `${divergence.field} in ${divergence.path}`).join('; ')}. Details and the allowlist fix are in the full report.`);
4166
+ }
4167
+ const reload = report.harnessReload || {};
4168
+ if (reload.required && reload.instruction && hermes.state !== 'configured') lines.push(`Restart: ${reload.instruction}`);
4169
+ else if (reload.required && reload.clients?.some((entry) => entry.client !== 'hermes')) {
4170
+ lines.push(`Restart: ${reload.clients.filter((entry) => entry.client !== 'hermes').map((entry) => entry.restart).join(' ')}`);
4171
+ }
4172
+ return { lines, selfTestFailed };
4173
+ }
4174
+
4175
+ function writeInstallLog(home, text) {
4176
+ const directory = path.join(home, '.marrow', 'logs');
4177
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
4178
+ const stat = fs.lstatSync(directory);
4179
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe log directory');
4180
+ fs.chmodSync(directory, 0o700);
4181
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
4182
+ const filePath = path.join(directory, `install-${stamp}-${process.pid}.log`);
4183
+ fs.writeFileSync(filePath, text, { flag: 'wx', mode: 0o600 });
4184
+ return filePath;
4185
+ }
4186
+
3747
4187
  async function runCli(argv) {
3748
4188
  const options = parseArgs(argv);
3749
4189
  if (options.help) {
3750
4190
  process.stdout.write(usage());
3751
4191
  return;
3752
4192
  }
3753
- const report = await install(options);
4193
+ const stored = readOwnerApiKey(options.home);
4194
+ const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
4195
+ if (!options.apiKey && stored.apiKey) {
4196
+ options.apiKey = stored.apiKey;
4197
+ keyInfo.source = 'owner_env_file';
4198
+ keyInfo.path = stored.source;
4199
+ }
4200
+ const oneCommand = Boolean((options.activate || options.update) && !options.dryRun && !options.doctor);
4201
+ if (oneCommand && !options.apiKey) {
4202
+ // An update aimed at an unmanaged directory is refused first; it needs no key to answer.
4203
+ if (options.update) {
4204
+ assertUpdateTargetsManagedRoot(detectEnvironment(options.cwd, {
4205
+ ...process.env,
4206
+ HOME: options.home || options.cwd,
4207
+ USERPROFILE: options.home || options.cwd,
4208
+ }));
4209
+ }
4210
+ process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY=<your key> ${installCommandFor(options)} (create a key at https://getmarrow.ai)\n`);
4211
+ process.exitCode = 2;
4212
+ return;
4213
+ }
4214
+ let report;
4215
+ try {
4216
+ report = await install(options);
4217
+ } catch (error) {
4218
+ const message = error instanceof Error ? error.message : String(error);
4219
+ if (!oneCommand || options.json || !/^Marrow activation failed/.test(message)) throw error;
4220
+ process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message)}\n`);
4221
+ process.exitCode = 1;
4222
+ return;
4223
+ }
3754
4224
  if (options.json) {
3755
4225
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
3756
- } else {
4226
+ return;
4227
+ }
4228
+ if (!oneCommand || options.verbose) {
3757
4229
  printReport(report);
4230
+ return;
4231
+ }
4232
+ report.api_key = keyInfo;
4233
+ const { lines, selfTestFailed } = installSummaryLines(report, options);
4234
+ let full = '';
4235
+ printReport(report, (text) => { full += text; });
4236
+ let logPath = null;
4237
+ try {
4238
+ logPath = writeInstallLog(options.home, full);
4239
+ } catch {
4240
+ logPath = null;
3758
4241
  }
4242
+ process.stdout.write(`${lines.join('\n')}\n`);
4243
+ if (logPath) process.stdout.write(`Full report: ${logPath}\n`);
4244
+ else process.stdout.write(`\n${full}`);
4245
+ if (selfTestFailed) process.exitCode = 1;
3759
4246
  }
3760
4247
 
3761
4248
  module.exports = {
@@ -3768,13 +4255,15 @@ module.exports = {
3768
4255
  runLoopGuardSelfTest,
3769
4256
  runCli,
3770
4257
  passiveRuntimeSource,
3771
- inspectNpmTokenConfig,
3772
4258
  inspectSdkDependency,
3773
4259
  inspectMcpProcesses,
3774
4260
  inspectMcpConfigurations,
3775
4261
  buildInstallValueMoment,
3776
4262
  buildTokenValueProof,
3777
4263
  stableAgentId,
4264
+ detectedClient,
4265
+ localControllerAgentId,
4266
+ identityAllowlist,
3778
4267
  activationProfile,
3779
4268
  claudeNativeHookFingerprint,
3780
4269
  codexNativeHookFingerprint,
@@ -3792,6 +4281,7 @@ module.exports = {
3792
4281
  CURSOR_NATIVE_HOOK_MATCHER,
3793
4282
  GEMINI_NATIVE_HOOK_MATCHER,
3794
4283
  printReport,
4284
+ installSummaryLines,
3795
4285
  buildMcpToolProfileReport,
3796
4286
  resolveMcpTargetVersion,
3797
4287
  resolveToolProfile,