@getmarrow/install 0.1.35 → 0.1.37

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
@@ -3,13 +3,14 @@ const path = require('node:path');
3
3
  const os = require('node:os');
4
4
  const crypto = require('node:crypto');
5
5
  const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
6
+ const { controllerStatus, controllerSupportedPlatform, ensureGovernanceController } = require('./controller-manager');
6
7
 
7
8
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
8
9
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
9
10
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
10
- const MCP_ADAPTER_VERSION = '3.9.51';
11
- const SDK_ADAPTER_VERSION = '3.7.50';
12
- const SDK_ADAPTER_INTEGRITY = 'sha512-TyCXUKZAaKRPdfVqXEuIvWnOt4GLcIUbwhpX+EopzdALNzq+t5wUGHrqJlkNSvopppPTTdFVLJ7O1vtwofqFqw==';
11
+ const MCP_ADAPTER_VERSION = '3.9.53';
12
+ const SDK_ADAPTER_VERSION = '3.7.52';
13
+ const SDK_ADAPTER_INTEGRITY = 'sha512-dMo5rMXP5sFTRsiRI+Oe3SOWgSsi8TTt9VrYL9gC71EQFN2UTtuH914CkYEpcS627sb02jXrUDiXxznX/2fWgA==';
13
14
  const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
14
15
  const MCP_PACKAGE_SPEC = `@getmarrow/mcp@${MCP_ADAPTER_VERSION}`;
15
16
  const MCP_CONTEXT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} context-hook`;
@@ -18,7 +19,7 @@ const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} hook`;
18
19
  const MCP_SESSION_END_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} session-hook`;
19
20
  const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|mcp__(?!marrow_).*';
20
21
  const NATIVE_EXPECTED_HOOKS = ['prompt', 'pre_action', 'action_result', 'session_end'];
21
- const SOURCE_CLIENTS = new Set(['claude-code', 'cursor', 'composer', 'windsurf', 'openclaw', 'codex', 'gemini', 'grok', 'deepseek', 'qwen', 'kimi', 'minimax', 'cline', 'opencode', 'hermes', 'glm', 'custom', 'unknown']);
22
+ const SOURCE_CLIENTS = new Set(['claude-code', 'cursor', 'composer', 'windsurf', 'openclaw', 'codex', 'gemini', 'grok', 'deepseek', 'qwen', 'kimi', 'minimax', 'cline', 'opencode', 'hermes', 'glm', 'mcp', 'ci', 'custom', 'unknown']);
22
23
  const HARNESS_CAPABILITY_REGISTRY = Object.freeze([
23
24
  { client: 'claude-code', capability_level: 'native_hooks', automatic: ['prompt', 'pre_action', 'action_result', 'session_end'], install_surface: 'mcp' },
24
25
  { client: 'cursor', capability_level: 'mcp', automatic: ['mcp_tool_calls'], install_surface: 'mcp' },
@@ -36,6 +37,8 @@ const HARNESS_CAPABILITY_REGISTRY = Object.freeze([
36
37
  { client: 'kimi', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
37
38
  { client: 'minimax', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
38
39
  { client: 'glm', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
40
+ { client: 'mcp', capability_level: 'mcp', automatic: ['mcp_tool_calls'], install_surface: 'mcp' },
41
+ { client: 'ci', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
39
42
  { client: 'custom', capability_level: 'event_contract', automatic: [], install_surface: 'event_contract' },
40
43
  ]);
41
44
 
@@ -64,6 +67,9 @@ function sourceClient() {
64
67
  hermes: 'hermes',
65
68
  'hermes-agent': 'hermes',
66
69
  glm: 'glm',
70
+ mcp: 'mcp',
71
+ ci: 'ci',
72
+ 'github-actions': 'ci',
67
73
  };
68
74
  return aliases[raw] || (SOURCE_CLIENTS.has(raw) ? raw : 'custom');
69
75
  }
@@ -83,6 +89,7 @@ function parseArgs(argv) {
83
89
  selfTestExplicitlyDisabled: false,
84
90
  json: false,
85
91
  activate: false,
92
+ controller: true,
86
93
  };
87
94
 
88
95
  for (let i = 0; i < argv.length; i += 1) {
@@ -104,6 +111,7 @@ function parseArgs(argv) {
104
111
  options.selfTest = false;
105
112
  options.selfTestExplicitlyDisabled = true;
106
113
  }
114
+ else if (arg === '--no-controller') options.controller = false;
107
115
  else if (arg === '--self-test') options.selfTest = true;
108
116
  else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
109
117
  else if (arg === '--mode') options.mode = argv[++i] || options.mode;
@@ -157,6 +165,7 @@ Options:
157
165
  --key <key> Marrow API key for self-test. Prefer MARROW_API_KEY because CLI args can appear in process listings.
158
166
  --base-url <url> Marrow API base URL
159
167
  --agent-id <id> Agent/fleet id for self-test headers
168
+ --no-controller Do not start the local background controller during install/repair
160
169
  --no-self-test Skip API smoke/self-test
161
170
  `;
162
171
  }
@@ -291,6 +300,7 @@ function fingerprint(value) {
291
300
  function npmTokenPaths(env = process.env) {
292
301
  const home = env.HOME || env.USERPROFILE || os.homedir();
293
302
  return {
303
+ home,
294
304
  openclawEnv: path.join(home, '.openclaw', '.env'),
295
305
  credentialFile: path.join(home, '.openclaw', 'credentials', 'npm-getmarrow-token.txt'),
296
306
  npmrc: path.join(home, '.npmrc'),
@@ -301,7 +311,14 @@ function inspectNpmTokenConfig(env = process.env) {
301
311
  const paths = npmTokenPaths(env);
302
312
  const openclawToken = readEnvVar(paths.openclawEnv, 'NPM_TOKEN');
303
313
  const credentialToken = readFirstLineSecret(paths.credentialFile);
304
- const npmrcToken = readNpmrcToken(paths.npmrc);
314
+ let npmrcToken = '';
315
+ let unsafeNpmrcPath = false;
316
+ try {
317
+ assertDirectOwnerFile(paths.home, paths.npmrc, { allowMissing: true });
318
+ npmrcToken = readNpmrcToken(paths.npmrc);
319
+ } catch {
320
+ unsafeNpmrcPath = true;
321
+ }
305
322
  const sourceToken = openclawToken || credentialToken;
306
323
  const mismatch = Boolean(sourceToken && npmrcToken && fingerprint(sourceToken) !== fingerprint(npmrcToken));
307
324
  const missingNpmrcToken = Boolean(sourceToken && !npmrcToken);
@@ -310,15 +327,18 @@ function inspectNpmTokenConfig(env = process.env) {
310
327
  safe: {
311
328
  npm_token: {
312
329
  checked: true,
313
- repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken)),
330
+ repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken) && !unsafeNpmrcPath),
314
331
  mismatch,
315
332
  missing_npmrc_token: missingNpmrcToken,
333
+ unsafe_path: unsafeNpmrcPath,
316
334
  sources: {
317
335
  openclaw_env: { path: paths.openclawEnv, present: Boolean(openclawToken), fingerprint: fingerprint(openclawToken) },
318
336
  credential_file: { path: paths.credentialFile, present: Boolean(credentialToken), fingerprint: fingerprint(credentialToken) },
319
337
  npmrc: { path: paths.npmrc, present: Boolean(npmrcToken), fingerprint: fingerprint(npmrcToken) },
320
338
  },
321
- recommended_fix: mismatch || missingNpmrcToken
339
+ recommended_fix: unsafeNpmrcPath
340
+ ? 'Refusing automatic npm token repair because ~/.npmrc or its home directory is not a direct, regular owner path.'
341
+ : mismatch || missingNpmrcToken
322
342
  ? 'Run npx @getmarrow/install --repair to sync ~/.npmrc from the active OpenClaw/getmarrow npm token source.'
323
343
  : null,
324
344
  },
@@ -327,7 +347,49 @@ function inspectNpmTokenConfig(env = process.env) {
327
347
  };
328
348
  }
329
349
 
330
- function upsertNpmrcToken(filePath, token) {
350
+ function assertDirectOwnerFile(homePath, filePath, { allowMissing = false } = {}) {
351
+ const home = path.resolve(homePath);
352
+ const target = path.resolve(filePath);
353
+ if (target !== path.join(home, '.npmrc')) throw new Error('npm token repair target must be the direct owner ~/.npmrc');
354
+ if (!fs.existsSync(home)) throw new Error('npm token repair owner home does not exist');
355
+ const homeStat = fs.lstatSync(home);
356
+ if (!homeStat.isDirectory() || homeStat.isSymbolicLink() || fs.realpathSync(home) !== home) {
357
+ throw new Error('npm token repair owner home must be a direct, non-symbolic directory');
358
+ }
359
+ if (!fs.existsSync(target)) {
360
+ if (allowMissing) return;
361
+ throw new Error('npm token repair target does not exist');
362
+ }
363
+ const targetStat = fs.lstatSync(target);
364
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
365
+ throw new Error('npm token repair target must be a regular file, not a symbolic link');
366
+ }
367
+ }
368
+
369
+ function atomicWriteOwnerFile(homePath, filePath, contents) {
370
+ const home = path.resolve(homePath);
371
+ const target = path.resolve(filePath);
372
+ const allowMissing = !fs.existsSync(target);
373
+ assertDirectOwnerFile(home, target, { allowMissing });
374
+ const tempPath = path.join(home, `.npmrc.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
375
+ let descriptor;
376
+ try {
377
+ descriptor = fs.openSync(tempPath, 'wx', 0o600);
378
+ fs.writeFileSync(descriptor, contents, { encoding: 'utf8' });
379
+ fs.fsyncSync(descriptor);
380
+ fs.closeSync(descriptor);
381
+ descriptor = undefined;
382
+ assertDirectOwnerFile(home, target, { allowMissing });
383
+ fs.renameSync(tempPath, target);
384
+ fs.chmodSync(target, 0o600);
385
+ } finally {
386
+ if (descriptor !== undefined) fs.closeSync(descriptor);
387
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
388
+ }
389
+ }
390
+
391
+ function upsertNpmrcToken(homePath, filePath, token) {
392
+ assertDirectOwnerFile(homePath, filePath, { allowMissing: true });
331
393
  const before = safeRead(filePath);
332
394
  const tokenLine = `//registry.npmjs.org/:_authToken=${token}`;
333
395
  let after;
@@ -338,14 +400,17 @@ function upsertNpmrcToken(filePath, token) {
338
400
  after = `${before}${separator}${tokenLine}\n`;
339
401
  }
340
402
  if (before !== after) {
341
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
342
403
  if (before) {
343
404
  const backupPath = `${filePath}.marrow-backup`;
344
- fs.writeFileSync(backupPath, before, { mode: 0o600 });
405
+ if (fs.existsSync(backupPath) && fs.lstatSync(backupPath).isSymbolicLink()) {
406
+ throw new Error('npm token repair backup must not be a symbolic link');
407
+ }
408
+ const backupTemp = path.join(path.resolve(homePath), `.npmrc.marrow-backup-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
409
+ fs.writeFileSync(backupTemp, before, { mode: 0o600, flag: 'wx' });
410
+ fs.renameSync(backupTemp, backupPath);
345
411
  fs.chmodSync(backupPath, 0o600);
346
412
  }
347
- fs.writeFileSync(filePath, after, { mode: 0o600 });
348
- fs.chmodSync(filePath, 0o600);
413
+ atomicWriteOwnerFile(homePath, filePath, after);
349
414
  }
350
415
  return before !== after;
351
416
  }
@@ -355,7 +420,7 @@ function repairConfigDiagnostics(diagnostics, env = process.env) {
355
420
  const npm = diagnostics.npm_token;
356
421
  const repairs = [];
357
422
  if (npm?.repairable && inspection.raw.sourceToken) {
358
- const changed = upsertNpmrcToken(inspection.raw.paths.npmrc, inspection.raw.sourceToken);
423
+ const changed = upsertNpmrcToken(inspection.raw.paths.home, inspection.raw.paths.npmrc, inspection.raw.sourceToken);
359
424
  repairs.push({
360
425
  type: 'npm_token_npmrc_sync',
361
426
  changed,
@@ -866,10 +931,69 @@ function buildPlan(detection, options) {
866
931
  });
867
932
  }
868
933
 
869
- return { mode, writes };
934
+ return { mode, root: detection.root, writes };
935
+ }
936
+
937
+ function assertContainedManagedTarget(root, targetPath) {
938
+ const resolvedRoot = path.resolve(root);
939
+ const resolvedTarget = path.resolve(targetPath);
940
+ if (resolvedTarget === resolvedRoot || !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
941
+ throw new Error(`Refusing installer write outside project root: ${resolvedTarget}`);
942
+ }
943
+ if (!fs.existsSync(resolvedRoot)) throw new Error(`Project root does not exist: ${resolvedRoot}`);
944
+ const rootStat = fs.lstatSync(resolvedRoot);
945
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
946
+ throw new Error(`Refusing installer write through unsafe project root: ${resolvedRoot}`);
947
+ }
948
+ const realRoot = fs.realpathSync(resolvedRoot);
949
+ const relativeParent = path.relative(resolvedRoot, path.dirname(resolvedTarget));
950
+ let current = resolvedRoot;
951
+ for (const segment of relativeParent.split(path.sep).filter(Boolean)) {
952
+ current = path.join(current, segment);
953
+ if (!fs.existsSync(current)) break;
954
+ const stat = fs.lstatSync(current);
955
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
956
+ throw new Error(`Refusing installer write through unsafe path component: ${current}`);
957
+ }
958
+ const realCurrent = fs.realpathSync(current);
959
+ if (realCurrent !== realRoot && !realCurrent.startsWith(`${realRoot}${path.sep}`)) {
960
+ throw new Error(`Refusing installer write outside resolved project root: ${current}`);
961
+ }
962
+ }
963
+ if (fs.existsSync(resolvedTarget)) {
964
+ const targetStat = fs.lstatSync(resolvedTarget);
965
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
966
+ throw new Error(`Refusing installer write to unsafe managed target: ${resolvedTarget}`);
967
+ }
968
+ }
969
+ return { resolvedRoot, resolvedTarget };
970
+ }
971
+
972
+ function atomicWriteManagedFile(root, targetPath, contents) {
973
+ const { resolvedTarget } = assertContainedManagedTarget(root, targetPath);
974
+ const parent = path.dirname(resolvedTarget);
975
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
976
+ assertContainedManagedTarget(root, resolvedTarget);
977
+ const existingMode = fs.existsSync(resolvedTarget)
978
+ ? fs.lstatSync(resolvedTarget).mode & 0o777
979
+ : 0o600;
980
+ const tempPath = path.join(
981
+ parent,
982
+ `.${path.basename(resolvedTarget)}.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}`,
983
+ );
984
+ try {
985
+ fs.writeFileSync(tempPath, contents, { flag: 'wx', mode: existingMode });
986
+ assertContainedManagedTarget(root, resolvedTarget);
987
+ fs.renameSync(tempPath, resolvedTarget);
988
+ } finally {
989
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
990
+ }
870
991
  }
871
992
 
872
993
  function applyPlan(plan, options) {
994
+ if (!Array.isArray(plan?.writes) || plan.writes.length === 0) return [];
995
+ const root = path.resolve(plan.root || path.dirname(plan.writes[0].path));
996
+ for (const write of plan.writes) assertContainedManagedTarget(root, write.path);
873
997
  const prepared = plan.writes.map((write) => {
874
998
  const before = safeRead(write.path);
875
999
  let after;
@@ -902,8 +1026,7 @@ function applyPlan(plan, options) {
902
1026
  already_present: !changed,
903
1027
  });
904
1028
  if (changed && writeApplied) {
905
- fs.mkdirSync(path.dirname(write.path), { recursive: true });
906
- fs.writeFileSync(write.path, after);
1029
+ atomicWriteManagedFile(root, write.path, after);
907
1030
  }
908
1031
  }
909
1032
  return changes;
@@ -1149,8 +1272,13 @@ async function runSelfTest(options) {
1149
1272
  avoided_mistakes: performance.avoided_mistakes ?? performance.avoided_repeated_mistakes ?? 0,
1150
1273
  reused_winning_decisions: performance.reused_winning_decisions ?? 0,
1151
1274
  prevented_bad_actions: performance.prevented_bad_actions ?? 0,
1152
- estimated_tokens_saved: performance.token_time_saved_estimate?.estimated_tokens_saved ?? 0,
1153
- estimated_minutes_saved: performance.token_time_saved_estimate?.estimated_minutes_saved ?? 0,
1275
+ estimated_tokens_saved: tokenValueProof?.savings?.estimated_tokens_saved ?? null,
1276
+ estimated_minutes_saved: tokenValueProof?.savings?.estimated_minutes_saved ?? null,
1277
+ token_savings_available: Number(tokenValueProof?.observed?.model_calls || 0) > 0
1278
+ && Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0,
1279
+ token_savings_source: 'agent_model_usage_events',
1280
+ token_savings_method: tokenValueProof?.savings?.method || 'warming_up',
1281
+ token_savings_confidence: tokenValueProof?.savings?.confidence || 'none',
1154
1282
  reliability_score: performance.agent_reliability_score ?? null,
1155
1283
  } : null,
1156
1284
  };
@@ -1173,10 +1301,22 @@ function buildTokenValueProof(valueProof = {}) {
1173
1301
  return modelUsage;
1174
1302
  }
1175
1303
 
1304
+ function tokenValueProofLine(tokenValueProof) {
1305
+ const calls = Number(tokenValueProof?.observed?.model_calls || 0);
1306
+ const saved = Number(tokenValueProof?.savings?.estimated_tokens_saved || 0);
1307
+ if (calls > 0 && saved > 0) {
1308
+ const method = tokenValueProof.savings?.method || 'unspecified';
1309
+ const confidence = tokenValueProof.savings?.confidence || 'unknown';
1310
+ return `Marrow observed ${calls} model call${calls === 1 ? '' : 's'} and estimates ~${saved} tokens saved (${method}, ${confidence} confidence)`;
1311
+ }
1312
+ return tokenValueProof?.proof_line || null;
1313
+ }
1314
+
1176
1315
  function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {}, performance = {}, firstValue = {}, tokenValueProof = null) {
1177
1316
  if (firstValue && firstValue.ok !== false && firstValue.first_value) {
1178
1317
  const proof = Array.isArray(firstValue.first_value.proof) ? [...firstValue.first_value.proof] : [];
1179
- if (tokenValueProof?.proof_line && !proof.includes(tokenValueProof.proof_line)) proof.push(tokenValueProof.proof_line);
1318
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1319
+ if (tokenProofLine && !proof.includes(tokenProofLine)) proof.push(tokenProofLine);
1180
1320
  return {
1181
1321
  headline: firstValue.headline || firstValue.first_value.headline || 'Your agent is no longer starting from zero.',
1182
1322
  proof,
@@ -1201,7 +1341,7 @@ function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {
1201
1341
  'Closed the outcome successfully',
1202
1342
  'Runtime gate is ' + (firstValueSignal.active ? 'active' : 'installed'),
1203
1343
  runtimeLesson ? 'Future risky work now gets a pre-action brief' : 'Future risky work now gets checked before action',
1204
- tokenValueProof?.proof_line || 'Token usage proof is active and warming up after the first model call',
1344
+ tokenValueProofLine(tokenValueProof) || 'Token usage proof is active and warming up after the first model call',
1205
1345
  ],
1206
1346
  fleet_signal: hasFleetSignal
1207
1347
  ? 'Marrow already found signal: ' + proof.join('; ') + '.'
@@ -1220,9 +1360,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
1220
1360
  if (Number(proof.avoided_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes} avoided mistake(s)`);
1221
1361
  if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
1222
1362
  if (Number(proof.prevented_bad_actions || 0) > 0) proofBits.push(`${proof.prevented_bad_actions} prevented risky action(s)`);
1223
- if (Number(proof.estimated_tokens_saved || 0) > 0) proofBits.push(`~${proof.estimated_tokens_saved} tokens saved`);
1224
- if (Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0) proofBits.push(`~${tokenValueProof.savings.estimated_tokens_saved} measured model tokens saved`);
1225
- else if (tokenValueProof?.proof_line) proofBits.push(tokenValueProof.proof_line);
1363
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1364
+ if (tokenProofLine) proofBits.push(tokenProofLine);
1226
1365
  return {
1227
1366
  active: Boolean(firstValue.active),
1228
1367
  headline: `Marrow active: ${(capture.surfaces || ['decisions']).join(', ')} captured.`,
@@ -1248,10 +1387,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
1248
1387
  if (Number(proof.avoided_mistakes || proof.avoided_repeated_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes || proof.avoided_repeated_mistakes} avoided mistake(s)`);
1249
1388
  if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
1250
1389
  if (Number(proof.prevented_bad_actions || 0) > 0) proofBits.push(`${proof.prevented_bad_actions} prevented risky action(s)`);
1251
- const tokens = proof.token_time_saved_estimate?.estimated_tokens_saved || 0;
1252
- if (tokens > 0) proofBits.push(`~${tokens} tokens saved`);
1253
- if (Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0) proofBits.push(`~${tokenValueProof.savings.estimated_tokens_saved} measured model tokens saved`);
1254
- else if (tokenValueProof?.proof_line) proofBits.push(tokenValueProof.proof_line);
1390
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1391
+ if (tokenProofLine) proofBits.push(tokenProofLine);
1255
1392
 
1256
1393
  const firstLesson = runtime.before_you_act
1257
1394
  || runtime.before_you_act_injection?.message
@@ -1381,6 +1518,11 @@ function printReport(report) {
1381
1518
  if (report.sdkDependency.warning) process.stdout.write(`- warning: ${report.sdkDependency.warning}\n`);
1382
1519
  }
1383
1520
 
1521
+ process.stdout.write('\nAutomatic controller:\n');
1522
+ process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
1523
+ if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
1524
+ if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
1525
+
1384
1526
  if (report.writeMode === 'doctor') {
1385
1527
  process.stdout.write('\nDoctor:\n');
1386
1528
  process.stdout.write(`- Marrow active: ${report.doctor.active ? 'yes' : 'no'}\n`);
@@ -1459,6 +1601,43 @@ async function install(options) {
1459
1601
  }
1460
1602
  const changedConfig = changes.some((change) => change.applied) || configRepairs.some((repair) => repair.changed);
1461
1603
  const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
1604
+ const controllerPlatform = options.controllerPlatform || process.platform;
1605
+ let controller = await controllerStatus({
1606
+ root: detection.root,
1607
+ agentId: options.agentId,
1608
+ platform: controllerPlatform,
1609
+ });
1610
+ const shouldEnsureController = options.controller !== false
1611
+ && controllerSupportedPlatform(controllerPlatform)
1612
+ && Boolean(options.apiKey)
1613
+ && selfTestPassed
1614
+ && !options.dryRun
1615
+ && !options.doctor
1616
+ && (options.yes || options.activate || options.repair);
1617
+ if (shouldEnsureController) {
1618
+ try {
1619
+ controller = await ensureGovernanceController({
1620
+ apiKey: options.apiKey,
1621
+ baseUrl: options.baseUrl,
1622
+ agentId: options.agentId,
1623
+ client,
1624
+ root: detection.root,
1625
+ mode: plan.mode,
1626
+ profile: options.governanceMode || 'default',
1627
+ policy: options.governancePolicy || 'warn',
1628
+ platform: controllerPlatform,
1629
+ });
1630
+ } catch (error) {
1631
+ const message = error instanceof Error ? error.message : String(error);
1632
+ controller = {
1633
+ active: false,
1634
+ state: 'error',
1635
+ exact_fix: 'Run npx @getmarrow/install controller ensure after correcting the reported local controller error.',
1636
+ error: message,
1637
+ };
1638
+ if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
1639
+ }
1640
+ }
1462
1641
  const remediation = options.repair
1463
1642
  ? {
1464
1643
  attempted: true,
@@ -1500,16 +1679,17 @@ async function install(options) {
1500
1679
  missingEnv: options.apiKey ? [] : ['MARROW_API_KEY'],
1501
1680
  envHints,
1502
1681
  missingHooks: changes.filter((change) => change.changed).map((change) => change.label),
1503
- recommendedFix: configDiagnostics.npm_token.recommended_fix || selfTest.recommended_fix || (!options.apiKey
1682
+ recommendedFix: configDiagnostics.npm_token.recommended_fix || (!options.apiKey
1504
1683
  ? envHints.length
1505
1684
  ? `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.`
1506
1685
  : 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
1507
- : null),
1686
+ : controller.exact_fix || selfTest.recommended_fix || null),
1508
1687
  },
1509
1688
  remediation,
1510
1689
  configDiagnostics,
1511
1690
  configRepairs,
1512
1691
  sdkDependency,
1692
+ controller,
1513
1693
  selfTest,
1514
1694
  warnings: options.keyFromArg
1515
1695
  ? ['Avoid --key in shared shells because command-line arguments can be visible in process listings. Prefer MARROW_API_KEY in your environment or secret manager.']