@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.
@@ -16,9 +16,9 @@ const {
16
16
  const { startGovernanceSidecar } = require('./governance-sidecar');
17
17
  const {
18
18
  controllerStatus,
19
- ensureGovernanceController,
19
+ ensureCurrentGovernanceController,
20
20
  startGovernanceController,
21
- stopGovernanceController,
21
+ stopProjectControllers,
22
22
  } = require('./controller-manager');
23
23
  const {
24
24
  ADAPTER_PROVENANCE,
@@ -26,6 +26,8 @@ const {
26
26
  applyPlan,
27
27
  buildPlan,
28
28
  detectEnvironment,
29
+ detectedClient,
30
+ localControllerAgentId,
29
31
  } = require('./installer');
30
32
  const { createHostUsageCapture } = require('./usage-telemetry');
31
33
  const { readLocalControlState, recordGovernedBypass } = require('./control-state');
@@ -91,10 +93,10 @@ const GOVERN_TUI_ROW_COUNT = 7;
91
93
  const FLEET_TUI_ROW_COUNT = 12;
92
94
  function usage() {
93
95
  return `Usage:
94
- npx @getmarrow/install run --agent deploy-agent -- npm test
95
- npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy
96
+ npx @getmarrow/install run -- npm test
97
+ npx @getmarrow/install run --type deploy --policy enforce -- wrangler deploy
96
98
  npx @getmarrow/install gate "deploy production worker"
97
- npx @getmarrow/install proof --decision-id <id> --success --summary "smoke passed"
99
+ npx @getmarrow/install proof --decision-id <id> --gate-receipt <id> --success --summary "smoke passed"
98
100
  npx @getmarrow/install status
99
101
  npx @getmarrow/install permit --action "deploy production" --type deploy
100
102
  MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --action "deploy production" --type deploy
@@ -125,7 +127,8 @@ Commands:
125
127
  openclaw Show and verify the Marrow add-on path for OpenClaw
126
128
 
127
129
  Options:
128
- --agent <id> Agent identity. Defaults to MARROW_FLEET_AGENT_ID, MARROW_AGENT_ID, or local user
130
+ --agent <id> Registered agent id. Defaults to MARROW_FLEET_AGENT_ID or MARROW_AGENT_ID.
131
+ When unset, Marrow uses the API key's bound agent or the plan's agent seat.
129
132
  --session <id> Session id. Defaults to marrow-run-<timestamp>
130
133
  --type <type> Action type. Inferred from action/command when omitted
131
134
  --action <text> Human-readable action. Defaults to the redacted command
@@ -138,6 +141,7 @@ Options:
138
141
  --target <text> Protected target binding, such as repository/environment
139
142
  --sidecar-port <port> Loopback sidecar port. Default: ephemeral
140
143
  --proof-file <path> JSON proof to include on outcome commit
144
+ --gate-receipt <id> proof only: the gate receipt printed by gate, for decisions the gate created
141
145
  --client <label> Harness/client label. Defaults to MARROW_CLIENT, MARROW_HARNESS, or MARROW_AGENT_CLIENT
142
146
  --base-url <url> Marrow API base URL
143
147
  --key <key> Marrow API key. Prefer MARROW_API_KEY
@@ -352,7 +356,9 @@ function parseBaseOptions(argv, startIndex = 0) {
352
356
  const options = {
353
357
  apiKey: process.env.MARROW_API_KEY || process.env.MARROW_KEY || '',
354
358
  baseUrl: process.env.MARROW_BASE_URL || DEFAULT_BASE_URL,
355
- agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || os.userInfo().username || 'agent',
359
+ // A local username is never a registered agent. With no configured id, the server
360
+ // resolves the key-bound agent or the plan seat instead.
361
+ agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || '',
356
362
  sessionId: process.env.MARROW_SESSION_ID || '',
357
363
  profile: process.env.MARROW_GOVERN_PROFILE || 'default',
358
364
  policy: process.env.MARROW_GOVERN_POLICY || 'enforce',
@@ -368,6 +374,10 @@ function parseBaseOptions(argv, startIndex = 0) {
368
374
  permit: process.env.MARROW_ACTION_PERMIT || '',
369
375
  target: process.env.MARROW_ACTION_TARGET || '',
370
376
  sidecarPort: process.env.MARROW_SIDECAR_PORT || '0',
377
+ // One random nonce per CLI invocation. Retries inside this invocation reuse it, so a retry
378
+ // replays only its own request; a later run, gate or permit in the same session gets new
379
+ // keys and is never answered with an earlier invocation's stored response.
380
+ invocationNonce: crypto.randomBytes(16).toString('hex'),
371
381
  };
372
382
  let i = startIndex;
373
383
  for (; i < argv.length; i += 1) {
@@ -390,7 +400,10 @@ function parseBaseOptions(argv, startIndex = 0) {
390
400
  else if (arg === '--target') options.target = argv[++i] || options.target;
391
401
  else if (arg === '--sidecar-port') options.sidecarPort = argv[++i] || options.sidecarPort;
392
402
  else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
393
- else if (arg === '--client' || arg === '--harness') options.client = sourceClient(argv[++i] || options.client);
403
+ else if (arg === '--client' || arg === '--harness') {
404
+ options.client = sourceClient(argv[++i] || options.client);
405
+ options.clientExplicit = true;
406
+ }
394
407
  else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
395
408
  else if (arg === '--key') {
396
409
  options.apiKey = argv[++i] || options.apiKey;
@@ -416,7 +429,10 @@ function parseArgs(argv) {
416
429
  if (command === 'run') {
417
430
  const parsed = parseBaseOptions(argv, 1);
418
431
  const separator = argv[parsed.index] === '--' ? parsed.index + 1 : parsed.index;
419
- const childCommand = argv.slice(separator);
432
+ let childCommand = argv.slice(separator);
433
+ // npx forwards both separators of the documented `run -- -- <command>` form. No real
434
+ // command is named `--`, so one extra leading separator is dropped instead of spawned.
435
+ if (childCommand[0] === '--') childCommand = childCommand.slice(1);
420
436
  if (parsed.options.help) return { command: 'help' };
421
437
  if (childCommand.length === 0) throw new Error('marrow run requires a command after --');
422
438
  return { command, options: parsed.options, childCommand };
@@ -442,18 +458,24 @@ function parseArgs(argv) {
442
458
  }
443
459
 
444
460
  if (command === 'proof') {
445
- const parsed = parseBaseOptions(argv, 1);
446
- const options = { ...parsed.options, decisionId: '', success: true, summary: '', outcome: '' };
447
- for (let i = parsed.index; i < argv.length; i += 1) {
461
+ // Proof flags are taken out first; the shared parser rejects options it does not know,
462
+ // which made every documented `proof --decision-id ...` call fail before 0.1.66.
463
+ const proofOptions = { decisionId: '', gateReceiptId: '', success: true, summary: '', outcome: '' };
464
+ const baseArgv = [command];
465
+ for (let i = 1; i < argv.length; i += 1) {
448
466
  const arg = argv[i];
449
- if (arg === '--decision-id') options.decisionId = argv[++i] || '';
450
- else if (arg === '--success') options.success = true;
451
- else if (arg === '--failure' || arg === '--failed') options.success = false;
452
- else if (arg === '--summary') options.summary = argv[++i] || '';
453
- else if (arg === '--outcome') options.outcome = argv[++i] || '';
454
- else if (arg === '--help' || arg === '-h') return { command: 'help' };
455
- else throw new Error(`Unknown proof option: ${arg}`);
467
+ if (arg === '--decision-id') proofOptions.decisionId = argv[++i] || '';
468
+ else if (arg === '--gate-receipt' || arg === '--gate-receipt-id') proofOptions.gateReceiptId = argv[++i] || '';
469
+ else if (arg === '--success') proofOptions.success = true;
470
+ else if (arg === '--failure' || arg === '--failed') proofOptions.success = false;
471
+ else if (arg === '--summary') proofOptions.summary = argv[++i] || '';
472
+ else if (arg === '--outcome') proofOptions.outcome = argv[++i] || '';
473
+ else baseArgv.push(arg);
456
474
  }
475
+ const parsed = parseBaseOptions(baseArgv, 1);
476
+ if (parsed.options.help) return { command: 'help' };
477
+ if (parsed.index < baseArgv.length) throw new Error(`Unknown proof option: ${baseArgv[parsed.index]}`);
478
+ const options = { ...parsed.options, ...proofOptions };
457
479
  if (!options.decisionId) throw new Error('marrow proof requires --decision-id');
458
480
  return { command, options };
459
481
  }
@@ -481,7 +503,6 @@ function headers(options) {
481
503
  const h = {
482
504
  Authorization: `Bearer ${options.apiKey}`,
483
505
  'Content-Type': 'application/json',
484
- 'X-Marrow-Agent-Id': options.agentId,
485
506
  'X-Marrow-Session-Id': options.sessionId,
486
507
  'X-Marrow-Client': sourceClient(options.client),
487
508
  'X-Marrow-Package': '@getmarrow/install',
@@ -491,6 +512,7 @@ function headers(options) {
491
512
  'X-Marrow-MCP-Version': ADAPTER_PROVENANCE.mcp.version,
492
513
  'User-Agent': '@getmarrow/install governed-runner',
493
514
  };
515
+ if (options.agentId) h['X-Marrow-Agent-Id'] = options.agentId;
494
516
  return h;
495
517
  }
496
518
 
@@ -501,7 +523,7 @@ function sourceMeta(options, channel, extra = {}) {
501
523
  client,
502
524
  harness: client,
503
525
  runner: '@getmarrow/install',
504
- agent_id: options.agentId,
526
+ ...(options.agentId ? { agent_id: options.agentId } : {}),
505
527
  session_id: options.sessionId,
506
528
  profile: options.profile,
507
529
  governed: true,
@@ -514,25 +536,122 @@ function dataOf(json) {
514
536
  return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
515
537
  }
516
538
 
517
- async function requestJson(options, method, route, body, extraHeaders = {}) {
539
+ async function rawRequest(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
518
540
  if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
519
541
  const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
520
542
  method,
521
543
  headers: { ...headers(options), ...extraHeaders },
522
544
  body: body === undefined ? undefined : JSON.stringify(body),
545
+ ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
523
546
  });
524
547
  const text = await response.text();
525
548
  let json = {};
526
549
  try { json = text ? JSON.parse(text) : {}; } catch { json = { error: text.slice(0, 500) }; }
527
- if (!response.ok) {
528
- const error = new Error(json.error || json.message || `Marrow ${route} returned HTTP ${response.status}`);
529
- error.status = response.status;
530
- error.details = json.details || json;
531
- throw error;
532
- }
550
+ return { response, json };
551
+ }
552
+
553
+ function responseError(route, response, json) {
554
+ const error = new Error(json.error || json.message || `Marrow ${route} returned HTTP ${response.status}`);
555
+ error.status = response.status;
556
+ error.details = json.details || json;
557
+ return error;
558
+ }
559
+
560
+ async function requestJson(options, method, route, body, extraHeaders = {}, requestOptions = {}) {
561
+ const { response, json } = await rawRequest(options, method, route, body, extraHeaders, requestOptions);
562
+ if (!response.ok) throw responseError(route, response, json);
533
563
  return dataOf(json);
534
564
  }
535
565
 
566
+ const DURABLE_WRITE_ATTEMPTS = 3;
567
+ const DURABLE_RETRY_DELAY_MS = 1_000;
568
+ const DURABLE_MAX_RETRY_DELAY_MS = 2_000;
569
+ const DURABLE_ATTEMPT_TIMEOUT_MS = 10_000;
570
+ const DURABLE_TOTAL_TIMEOUT_MS = 25_000;
571
+ const DURABLE_TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
572
+
573
+ function durableIdempotencyKey(options, route, parts) {
574
+ const digest = crypto.createHash('sha256')
575
+ .update([options.invocationNonce, options.sessionId, route, ...parts].map((part) => String(part ?? '')).join('\u0000'))
576
+ .digest('hex');
577
+ return `marrow-run-${route.split('/').pop()}-${digest.slice(0, 48)}`;
578
+ }
579
+
580
+ function durableRetryDelayMs(response, data, options) {
581
+ if (Number.isFinite(options.retryDelayMs) && options.retryDelayMs >= 0) return options.retryDelayMs;
582
+ const hinted = Number(data?.retry_after_ms);
583
+ const header = Number(response?.headers?.get?.('retry-after')) * 1000;
584
+ const delay = hinted > 0 ? hinted : header > 0 ? header : DURABLE_RETRY_DELAY_MS;
585
+ return Math.min(delay, DURABLE_MAX_RETRY_DELAY_MS);
586
+ }
587
+
588
+ function isTimeout(error) {
589
+ return error?.name === 'TimeoutError' || error?.name === 'AbortError';
590
+ }
591
+
592
+ // Runtime, think and commit are durable Marrow writes. A transient 429/502/503/504, a timed-out
593
+ // attempt, or a pending acknowledgement (retryable:true, committed:false) is resent unchanged
594
+ // with the same Idempotency-Key, at most three attempts within one overall deadline, so a retry
595
+ // can never create a second record and a hung service never holds the command forever. A
596
+ // pending acknowledgement that names a created decision pins that id; the final answer must
597
+ // carry the same one. Client errors and connection failures are not retried.
598
+ async function durableRequestJson(options, route, body, idempotencyKey) {
599
+ const attemptTimeoutMs = Number.isFinite(options.requestTimeoutMs) ? options.requestTimeoutMs : DURABLE_ATTEMPT_TIMEOUT_MS;
600
+ const deadline = Date.now() + (Number.isFinite(options.requestDeadlineMs) ? options.requestDeadlineMs : DURABLE_TOTAL_TIMEOUT_MS);
601
+ let lastState = 'no response';
602
+ let pinnedDecisionId = null;
603
+ const exhausted = (status) => {
604
+ const error = new Error(`Marrow ${route} did not complete after ${DURABLE_WRITE_ATTEMPTS} attempts (last: ${lastState}).`);
605
+ error.status = status;
606
+ return error;
607
+ };
608
+ for (let attempt = 1; attempt <= DURABLE_WRITE_ATTEMPTS; attempt += 1) {
609
+ const remaining = deadline - Date.now();
610
+ if (remaining <= 0) {
611
+ const error = new Error(`Marrow ${route} did not complete before its deadline (last: ${lastState}).`);
612
+ error.status = 504;
613
+ throw error;
614
+ }
615
+ let response;
616
+ let json;
617
+ try {
618
+ ({ response, json } = await rawRequest(options, 'POST', route, body, { 'Idempotency-Key': idempotencyKey }, {
619
+ timeoutMs: Math.max(1, Math.min(attemptTimeoutMs, remaining)),
620
+ }));
621
+ } catch (error) {
622
+ if (!isTimeout(error)) throw error;
623
+ lastState = 'timed out';
624
+ if (attempt === DURABLE_WRITE_ATTEMPTS) throw exhausted(504);
625
+ continue;
626
+ }
627
+ const data = dataOf(json);
628
+ if (response.ok) {
629
+ const decisionId = typeof data?.decision_id === 'string' && data.decision_id ? data.decision_id : null;
630
+ if (pinnedDecisionId && decisionId && decisionId !== pinnedDecisionId) {
631
+ throw new Error(`Marrow ${route} returned a different decision id than its pending acknowledgement.`);
632
+ }
633
+ if (data?.retryable !== true || data?.committed !== false) return data;
634
+ if (decisionId && data.decision_state === 'created') pinnedDecisionId = decisionId;
635
+ lastState = `pending${data.reconciliation_state ? ` (${String(data.reconciliation_state).slice(0, 80)})` : ''}`;
636
+ } else if (DURABLE_TRANSIENT_STATUSES.has(response.status)) {
637
+ const reason = String(json.error || json.message || '').slice(0, 200);
638
+ lastState = reason ? `HTTP ${response.status}: ${reason}` : `HTTP ${response.status}`;
639
+ if (attempt === DURABLE_WRITE_ATTEMPTS) {
640
+ const error = responseError(route, response, json);
641
+ error.message = `Marrow ${route} did not complete after ${DURABLE_WRITE_ATTEMPTS} attempts (last: ${lastState}).`;
642
+ throw error;
643
+ }
644
+ } else {
645
+ throw responseError(route, response, json);
646
+ }
647
+ if (attempt < DURABLE_WRITE_ATTEMPTS) {
648
+ const wait = Math.min(durableRetryDelayMs(response, data, options), Math.max(0, deadline - Date.now()));
649
+ await new Promise((resolve) => setTimeout(resolve, wait));
650
+ }
651
+ }
652
+ throw exhausted(202);
653
+ }
654
+
536
655
  function proofFromFile(filePath) {
537
656
  if (!filePath) return null;
538
657
  const raw = fs.readFileSync(path.resolve(filePath), 'utf8');
@@ -574,11 +693,14 @@ async function preflightRuntime(options, action, type, commandText) {
574
693
  const surfaces = inferSurfaces(commandText || action);
575
694
  const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
576
695
  const project = detectProjectSignals(options.root || process.cwd());
577
- return requestJson(options, 'POST', '/v1/agent/runtime', {
696
+ const body = {
578
697
  action,
579
698
  type,
580
699
  target,
581
700
  surfaces,
701
+ // Package clients get the slim runtime shape unless they ask for expanded. The runner reads
702
+ // risk_gate, gate_receipt and runtime_authorization, as the install self-test does.
703
+ response_mode: 'expanded',
582
704
  harness: sourceClient(options.client),
583
705
  project: { ...project, harness: sourceClient(options.client) },
584
706
  source_meta: meta,
@@ -590,7 +712,9 @@ async function preflightRuntime(options, action, type, commandText) {
590
712
  governed: true,
591
713
  source_meta: meta,
592
714
  },
593
- });
715
+ };
716
+ return durableRequestJson(options, '/v1/agent/runtime', body,
717
+ durableIdempotencyKey(options, '/v1/agent/runtime', [JSON.stringify(body)]));
594
718
  }
595
719
 
596
720
  async function recommendGovernanceMode(options, project = detectProjectSignals()) {
@@ -613,7 +737,7 @@ async function recommendGovernanceMode(options, project = detectProjectSignals()
613
737
  environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
614
738
  },
615
739
  agent: {
616
- id: options.agentId,
740
+ id: options.agentId || undefined,
617
741
  role: 'setup',
618
742
  },
619
743
  source_meta: sourceMeta(options, 'mode_recommend', { action }),
@@ -631,7 +755,7 @@ async function recordGovernanceModeSelection(options, state) {
631
755
  environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
632
756
  },
633
757
  agent: {
634
- id: options.agentId,
758
+ id: options.agentId || undefined,
635
759
  role: 'setup',
636
760
  },
637
761
  selected_mode: selected,
@@ -640,33 +764,106 @@ async function recordGovernanceModeSelection(options, state) {
640
764
  });
641
765
  }
642
766
 
767
+ function recordOf(value) {
768
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
769
+ }
770
+
771
+ function lowerText(value) {
772
+ return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
773
+ }
774
+
775
+ // The runtime answers in two shapes. Expanded responses carry risk_gate, gate_receipt and
776
+ // runtime_authorization. Slim responses, the default for package clients, carry top-level
777
+ // decision, enforcement_decision, risk_gate_enforced, gate_receipt_id and gate_required.
778
+ // Both normalize to one gate, so the runner never reads a real answer as "unknown".
643
779
  function gateDecision(runtime) {
644
- const gate = runtime?.risk_gate || {};
645
- const receipt = runtime?.gate_receipt || {};
780
+ const value = recordOf(runtime);
781
+ const gate = recordOf(value.risk_gate);
782
+ const receipt = recordOf(value.gate_receipt);
783
+ const authorization = recordOf(value.runtime_authorization);
784
+ const completion = recordOf(value.completion_contract);
785
+ const enforced = typeof gate.enforced === 'boolean'
786
+ ? gate.enforced
787
+ : typeof value.risk_gate_enforced === 'boolean'
788
+ ? value.risk_gate_enforced
789
+ : null;
790
+ const enforcementDecision = lowerText(gate.enforcement_decision) || lowerText(value.enforcement_decision);
791
+ const enforcementAsDecision = enforcementDecision === 'owner_approval_required'
792
+ ? 'review_required'
793
+ : enforcementDecision === 'advisory' ? '' : enforcementDecision;
794
+ const decision = lowerText(gate.decision) || lowerText(value.decision) || lowerText(receipt.decision)
795
+ || enforcementAsDecision;
796
+ const recognized = Boolean(decision || enforcementDecision || enforced !== null);
797
+ // An observation-only answer records outcomes but never authorizes execution.
798
+ const observationOnly = [decision, enforcementDecision, lowerText(authorization.kind), lowerText(receipt.kind),
799
+ lowerText(authorization.decision_state)].includes('outcome_observation_only');
800
+ const authorizationWithheld = gate.authorization_granted === false || receipt.authorization_granted === false;
801
+ const decisionState = lowerText(authorization.decision_state) || lowerText(completion.decision_state);
802
+ const createdDecisionId = String(value.decision_id || authorization.decision_id || completion.decision_id || '');
646
803
  return {
647
- decision: gate.enforcement_decision || receipt.decision || gate.decision || 'unknown',
648
- allow: gate.allow !== false,
649
- riskLevel: String(gate.risk_level || receipt.risk_level || runtime?.risk_level || '').trim().toLowerCase(),
650
- required: Boolean(receipt.required || gate.gate_required),
651
- ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required),
652
- receiptId: receipt.id || gate.gate_receipt_id || '',
653
- exactNextAction: runtime?.exact_next_action || receipt.exact_fix || gate.policy?.exact_fix || '',
654
- beforeYouAct: runtime?.before_you_act_injection?.message || runtime?.before_you_act || '',
655
- proofPack: runtime?.proof_pack || null,
804
+ recognized,
805
+ decision: recognized ? decision || 'proceed' : 'none',
806
+ enforcementDecision,
807
+ enforced,
808
+ observationOnly,
809
+ allow: authorizationWithheld || observationOnly ? false : typeof gate.allow === 'boolean'
810
+ ? gate.allow
811
+ : typeof value.allow === 'boolean' ? value.allow : decision !== 'block',
812
+ riskLevel: lowerText(gate.risk_level) || lowerText(receipt.risk_level) || lowerText(value.risk_level),
813
+ required: Boolean(receipt.required || gate.gate_required || value.gate_required || completion.gate_receipt_required),
814
+ ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required
815
+ || completion.owner_approval_required === true || enforcementDecision === 'owner_approval_required'),
816
+ receiptId: String(receipt.id || gate.gate_receipt_id || value.gate_receipt_id || authorization.id
817
+ || completion.gate_receipt_id || ''),
818
+ // A runtime that already created the decision is closed with that id and its receipt;
819
+ // think is called only when the runtime says decision creation is still required.
820
+ runtimeDecisionId: decisionState === 'not_created' ? '' : createdDecisionId,
821
+ exactNextAction: value.exact_next_action || receipt.exact_fix || recordOf(gate.policy).exact_fix
822
+ || completion.exact_next_action || '',
823
+ beforeYouAct: recordOf(value.before_you_act_injection).message || value.before_you_act || '',
824
+ proofPack: value.proof_pack || (value.proof_required ? {
825
+ required: true,
826
+ required_fields: Array.isArray(completion.required_proof_fields) ? completion.required_proof_fields : [],
827
+ missing: Array.isArray(completion.missing_proof_fields) ? completion.missing_proof_fields : [],
828
+ } : null),
656
829
  };
657
830
  }
658
831
 
659
832
  function shouldBlock(decision, options) {
833
+ // Marrow withheld authorization (allow:false, or an observation-only answer): blocked under
834
+ // every plan mode and local policy.
835
+ if (decision.observationOnly || decision.allow === false) return true;
660
836
  if (options.policy === 'audit') return false;
661
- if (decision.decision === 'block' || decision.allow === false) return true;
837
+ // An advisory plan (enforced:false) never blocks locally; the runner shows the warning.
838
+ if (decision.enforced === false) return false;
839
+ if (decision.decision === 'block') return true;
662
840
  if (options.policy === 'warn') return false;
663
- if (decision.ownerApprovalRequired && !options.ownerApproval) return true;
664
- if (decision.decision === 'owner_approval_required' && !options.ownerApproval) return true;
665
- return false;
841
+ const approvalRequired = decision.ownerApprovalRequired
842
+ || decision.decision === 'review_required'
843
+ || decision.decision === 'owner_approval_required'
844
+ || decision.enforcementDecision === 'owner_approval_required';
845
+ return Boolean(approvalRequired && !options.ownerApproval);
846
+ }
847
+
848
+ function blockMessage(decision) {
849
+ if (decision.observationOnly) return 'Marrow answered in observation-only mode, which cannot authorize this action. Retry for a fresh gate.';
850
+ return decision.exactNextAction || 'Marrow blocked this action before execution.';
851
+ }
852
+
853
+ function gateModeText(decision) {
854
+ const parts = [decision.enforced === true
855
+ ? 'enforced'
856
+ : decision.enforced === false
857
+ ? 'advisory, not enforced on this plan'
858
+ : 'enforcement not reported'];
859
+ if (decision.riskLevel) parts.push(`risk ${decision.riskLevel}`);
860
+ if (decision.required) parts.push('receipt required');
861
+ if (decision.ownerApprovalRequired) parts.push('owner approval required');
862
+ return parts.join('; ');
666
863
  }
667
864
 
668
865
  function printGate(decision, runtime, stream = process.stdout) {
669
- stream.write(`Marrow gate: ${decision.decision}${decision.required ? ' (required)' : ''}\n`);
866
+ stream.write(`Marrow gate: ${decision.decision} (${gateModeText(decision)})\n`);
670
867
  if (decision.beforeYouAct) stream.write(`Before you act: ${decision.beforeYouAct}\n`);
671
868
  if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
672
869
  if (decision.proofPack?.required) {
@@ -738,7 +935,7 @@ function scopedExecutionEnv(permit) {
738
935
 
739
936
  async function createDecision(options, action, type, target, surfaces) {
740
937
  const meta = sourceMeta(options, 'think', { action, action_type: type });
741
- return requestJson(options, 'POST', '/v1/agent/think', {
938
+ const body = {
742
939
  action,
743
940
  type,
744
941
  target,
@@ -750,10 +947,19 @@ async function createDecision(options, action, type, target, surfaces) {
750
947
  governed: true,
751
948
  source_meta: meta,
752
949
  },
753
- });
950
+ };
951
+ return durableRequestJson(options, '/v1/agent/think', body,
952
+ durableIdempotencyKey(options, '/v1/agent/think', [JSON.stringify(body)]));
953
+ }
954
+
955
+ // `run` folds its invocation nonce into the commit key. `proof` keeps a stable key per
956
+ // decision, so repeating the same proof command replays instead of recording twice.
957
+ function commitIdempotencyKey(options, decisionId, { perInvocation = true } = {}) {
958
+ const parts = [options.agentId, options.sessionId, decisionId, ...(perInvocation ? [options.invocationNonce] : [])];
959
+ return `marrow-run-${crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex')}`;
754
960
  }
755
961
 
756
- async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null) {
962
+ async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null, { perInvocation = true } = {}) {
757
963
  const body = {
758
964
  decision_id: decisionId,
759
965
  success,
@@ -763,12 +969,14 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
763
969
  };
764
970
  if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
765
971
  if (modelUsage) body.model_usage = modelUsage;
766
- const commitHeaders = modelUsage
767
- ? { 'Idempotency-Key': `marrow-run-${crypto.createHash('sha256')
768
- .update(`${options.agentId}\u0000${options.sessionId}\u0000${decisionId}`)
769
- .digest('hex')}` }
770
- : {};
771
- return requestJson(options, 'POST', '/v1/agent/commit', body, commitHeaders);
972
+ return durableRequestJson(options, '/v1/agent/commit', body, commitIdempotencyKey(options, decisionId, { perInvocation }));
973
+ }
974
+
975
+ async function decisionForAction(options, decision, action, type, target, surfaces) {
976
+ if (decision?.runtimeDecisionId) return { decisionId: decision.runtimeDecisionId, source: 'runtime' };
977
+ const think = await createDecision(options, action, type, target, surfaces);
978
+ const decisionId = think.decision_id || think.id || think.decision?.id || '';
979
+ return { decisionId, source: decisionId ? 'think' : null };
772
980
  }
773
981
 
774
982
  async function runGoverned(parsed, execution = {}) {
@@ -781,9 +989,11 @@ async function runGoverned(parsed, execution = {}) {
781
989
  let runtime = null;
782
990
  let decision = null;
783
991
  let decisionId = '';
992
+ let decisionSource = null;
784
993
  let actionPermit = null;
785
994
  let permitVerified = false;
786
995
  let degraded = false;
996
+ let advisory = false;
787
997
  let protectedAction = risky;
788
998
  const surfaces = inferSurfaces(commandText || action);
789
999
 
@@ -803,10 +1013,15 @@ async function runGoverned(parsed, execution = {}) {
803
1013
  runtime = await preflightRuntime(options, action, type, commandText);
804
1014
  decision = gateDecision(runtime);
805
1015
  protectedAction = risky
806
- || decision?.required === true
807
- || decision?.riskLevel === 'high'
808
- || decision?.riskLevel === 'critical';
809
- printGate(decision, runtime);
1016
+ || decision.required === true
1017
+ || decision.riskLevel === 'high'
1018
+ || decision.riskLevel === 'critical';
1019
+ if (!decision.recognized) {
1020
+ if (protectedAction) throw new Error('Marrow runtime returned no gate decision, so this protected action cannot be checked.');
1021
+ process.stderr.write('Marrow gate: no decision returned; continuing because this action is not protected.\n');
1022
+ } else {
1023
+ printGate(decision, runtime);
1024
+ }
810
1025
  if (shouldBlock(decision, options)) {
811
1026
  return {
812
1027
  ok: false,
@@ -816,22 +1031,29 @@ async function runGoverned(parsed, execution = {}) {
816
1031
  type,
817
1032
  risky,
818
1033
  decision,
819
- message: decision.exactNextAction || 'Marrow blocked this action before execution.',
1034
+ message: blockMessage(decision),
820
1035
  };
821
1036
  }
822
1037
  const target = options.target || commandText;
823
- const think = await createDecision(options, action, type, target, surfaces);
824
- decisionId = think.decision_id || think.id || think.decision?.id || '';
825
- if (protectedAction) {
1038
+ ({ decisionId, source: decisionSource } = await decisionForAction(options, decision, action, type, target, surfaces));
1039
+ // Permits exist only where the plan enforces the gate. An advisory plan shows its warning,
1040
+ // runs the command and still records the outcome; it never asks for a permit it cannot get.
1041
+ const permitRequired = protectedAction && decision.enforced !== false;
1042
+ if (protectedAction && !permitRequired) {
1043
+ advisory = true;
1044
+ process.stderr.write(`Marrow advisory: this ${type} action is not enforced on this plan (gate ${decision.decision}). Running it and recording the outcome.\n`);
1045
+ }
1046
+ if (permitRequired) {
1047
+ if (!decision.receiptId) throw new Error('Marrow runtime returned no gate receipt, so no action permit can be issued.');
826
1048
  actionPermit = await issueActionPermit(requestJson, options, {
827
1049
  action,
828
1050
  type,
829
1051
  target,
830
1052
  surfaces,
831
1053
  decisionId,
832
- gateReceiptId: decision?.receiptId || '',
1054
+ gateReceiptId: decision.receiptId,
833
1055
  ownerApproval: options.ownerApproval,
834
- proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
1056
+ proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
835
1057
  });
836
1058
  if (!actionPermit?.permit || !actionPermit?.permit_id) {
837
1059
  throw new Error('Marrow did not issue a valid action permit.');
@@ -842,6 +1064,7 @@ async function runGoverned(parsed, execution = {}) {
842
1064
  target,
843
1065
  surfaces,
844
1066
  permit: actionPermit.permit,
1067
+ protocolVersion: actionPermit.protocol_version,
845
1068
  });
846
1069
  if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
847
1070
  permitVerified = true;
@@ -853,7 +1076,7 @@ async function runGoverned(parsed, execution = {}) {
853
1076
  actionPermit = null;
854
1077
  permitVerified = false;
855
1078
  degraded = true;
856
- process.stderr.write(`Marrow degraded: ${error.message}. Continuing because fail-open/non-risky policy allows it.\n`);
1079
+ process.stderr.write(`Marrow degraded: ${error.message}. Continuing because this action is not protected${decisionId ? '' : '; its outcome will not be recorded'}.\n`);
857
1080
  } else {
858
1081
  return {
859
1082
  ok: false,
@@ -877,6 +1100,7 @@ async function runGoverned(parsed, execution = {}) {
877
1100
  : `Marrow governed command failed with exit code ${child.exitCode}.`;
878
1101
 
879
1102
  let commit = null;
1103
+ let commitState = decisionId ? 'failed' : 'not_recorded';
880
1104
  if (decisionId) {
881
1105
  try {
882
1106
  commit = await commitOutcome(
@@ -888,9 +1112,15 @@ async function runGoverned(parsed, execution = {}) {
888
1112
  decision?.receiptId || '',
889
1113
  child.modelUsage,
890
1114
  );
1115
+ commitState = commit?.committed === true ? 'committed' : 'not_committed';
1116
+ if (commitState === 'not_committed') {
1117
+ process.stderr.write(`Marrow recorded this outcome without trusted closure (committed:${String(commit?.committed ?? 'missing')}).\n`);
1118
+ }
891
1119
  } catch (error) {
892
1120
  process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
893
1121
  }
1122
+ } else if (!degraded) {
1123
+ process.stderr.write('Marrow outcome not recorded: no decision id was returned before the command ran.\n');
894
1124
  }
895
1125
 
896
1126
  let permitClosed = null;
@@ -899,6 +1129,7 @@ async function runGoverned(parsed, execution = {}) {
899
1129
  permitClosed = await closeActionPermit(requestJson, options, {
900
1130
  permit: actionPermit.permit,
901
1131
  permitId: actionPermit.permit_id,
1132
+ protocolVersion: actionPermit.protocol_version,
902
1133
  decisionId,
903
1134
  success,
904
1135
  evidence: proof,
@@ -916,9 +1147,13 @@ async function runGoverned(parsed, execution = {}) {
916
1147
  type,
917
1148
  risky,
918
1149
  degraded,
1150
+ advisory,
919
1151
  decision,
920
1152
  decision_id: decisionId,
921
- outcome_committed: Boolean(commit),
1153
+ decision_source: decisionSource,
1154
+ gate_receipt_id: decision?.receiptId || null,
1155
+ outcome_committed: commitState === 'committed',
1156
+ outcome_commit_state: commitState,
922
1157
  permit_id: actionPermit?.permit_id || null,
923
1158
  permit_verified: permitVerified,
924
1159
  permit_closed: Boolean(permitClosed),
@@ -943,11 +1178,28 @@ async function permitOnly(parsed) {
943
1178
  const surfaces = inferSurfaces(target);
944
1179
  const runtime = await preflightRuntime(options, action, type, target);
945
1180
  const decision = gateDecision(runtime);
1181
+ if (!decision.recognized) {
1182
+ return { ok: false, blocked: true, exitCode: 13, decision, message: 'Marrow runtime returned no gate decision, so no permit was issued.' };
1183
+ }
946
1184
  if (shouldBlock(decision, options)) {
947
- return { ok: false, blocked: true, exitCode: 12, decision, message: decision.exactNextAction };
1185
+ return { ok: false, blocked: true, exitCode: 12, decision, message: blockMessage(decision) };
1186
+ }
1187
+ const { decisionId } = await decisionForAction(options, decision, action, type, target, surfaces);
1188
+ if (decision.enforced === false) {
1189
+ return {
1190
+ ok: true,
1191
+ advisory: true,
1192
+ decision,
1193
+ decision_id: decisionId,
1194
+ gate_receipt_id: decision.receiptId || null,
1195
+ permit: null,
1196
+ permit_id: null,
1197
+ message: `This plan runs Marrow gates in advisory mode (gate ${decision.decision}), so no action permit is issued. Record the outcome with proof after the action.`,
1198
+ };
1199
+ }
1200
+ if (!decision.receiptId) {
1201
+ return { ok: false, blocked: true, exitCode: 13, decision, message: 'Marrow runtime returned no gate receipt, so no permit was issued.' };
948
1202
  }
949
- const think = await createDecision(options, action, type, target, surfaces);
950
- const decisionId = think.decision_id || think.id || think.decision?.id || '';
951
1203
  const result = await issueActionPermit(requestJson, options, {
952
1204
  action,
953
1205
  type,
@@ -956,9 +1208,9 @@ async function permitOnly(parsed) {
956
1208
  decisionId,
957
1209
  gateReceiptId: decision.receiptId,
958
1210
  ownerApproval: options.ownerApproval,
959
- proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
1211
+ proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
960
1212
  });
961
- return { ok: true, decision_id: decisionId, ...result };
1213
+ return { ok: true, decision_id: decisionId, gate_receipt_id: decision.receiptId, ...result };
962
1214
  }
963
1215
 
964
1216
  async function verifyPermitOnly(parsed) {
@@ -990,16 +1242,32 @@ async function sidecarOnly(parsed) {
990
1242
  const maintain = async () => {
991
1243
  if (lastMaintenance && Date.now() - lastMaintenanceAt < 5 * 60_000) return lastMaintenance;
992
1244
  const detection = detectEnvironment(root, process.env);
993
- const plan = buildPlan(detection, { mode: managedMode });
1245
+ // Maintenance re-applies the controller's own configured agent id and base URL. A different
1246
+ // value found in managed MCP config is replaced unless the owner allowlisted it, and the
1247
+ // divergence is reported. It never edits the owner's Hermes config.
1248
+ const plan = buildPlan(detection, {
1249
+ mode: managedMode,
1250
+ agentId: options.agentId,
1251
+ baseUrl: options.baseUrl,
1252
+ client: options.client,
1253
+ maintenance: true,
1254
+ });
994
1255
  const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
995
1256
  const repaired = changes.filter((change) => change.applied).map((change) => change.label);
996
1257
  const remaining = changes.filter((change) => change.changed && !change.applied).map((change) => change.label);
1258
+ const identityDivergence = changes.flatMap((change) => (change.identity_divergence || [])
1259
+ .map((divergence) => ({ ...divergence, path: change.path })));
997
1260
  lastMaintenanceAt = Date.now();
998
1261
  lastMaintenance = {
999
- state: remaining.length > 0 ? 'attention_required' : repaired.length > 0 ? 'repaired' : 'clear',
1262
+ state: remaining.length > 0 || identityDivergence.length > 0 ? 'attention_required' : repaired.length > 0 ? 'repaired' : 'clear',
1000
1263
  checked_at: new Date(lastMaintenanceAt).toISOString(),
1001
1264
  repaired,
1002
- exact_fix: remaining.length > 0 ? 'Run npx @getmarrow/install --repair in the managed project.' : null,
1265
+ ...(identityDivergence.length ? { identity_divergence: identityDivergence } : {}),
1266
+ exact_fix: remaining.length > 0
1267
+ ? 'Run npx @getmarrow/install --repair in the managed project.'
1268
+ : identityDivergence.length > 0
1269
+ ? `Marrow reset ${identityDivergence.map((divergence) => divergence.field).join(', ')} in managed MCP config to the controller's configured values. If the previous value was intended, add it to MARROW_ALLOWED_BASE_URLS or MARROW_ALLOWED_AGENT_IDS and rerun npx -y @getmarrow/install@latest update.`
1270
+ : null,
1003
1271
  };
1004
1272
  return lastMaintenance;
1005
1273
  };
@@ -1017,14 +1285,23 @@ async function sidecarOnly(parsed) {
1017
1285
  }
1018
1286
 
1019
1287
  async function controllerOnly(parsed) {
1020
- const options = { ...parsed.options, root: process.cwd(), mode: 'auto' };
1288
+ // The controller CLI uses the same project root and local identity as install and update, so
1289
+ // `controller ensure|stop|status` act on the controller those commands started. The local
1290
+ // identity is never sent to Marrow.
1291
+ const detection = detectEnvironment(process.cwd(), process.env);
1292
+ const envClient = process.env.MARROW_CLIENT || process.env.MARROW_HARNESS || process.env.MARROW_AGENT_CLIENT;
1293
+ const client = parsed.options.clientExplicit || envClient ? parsed.options.client : detectedClient(detection);
1294
+ const identityAgentId = localControllerAgentId(detection.root, client, parsed.options.agentId);
1295
+ const options = { ...parsed.options, client, identityAgentId, root: detection.root, mode: 'auto' };
1021
1296
  let result;
1022
- if (parsed.action === 'ensure') result = await ensureGovernanceController(options);
1297
+ if (parsed.action === 'ensure') result = await ensureCurrentGovernanceController(options);
1023
1298
  else if (parsed.action === 'start') result = await startGovernanceController(options);
1024
- else if (parsed.action === 'stop') result = await stopGovernanceController(options);
1299
+ else if (parsed.action === 'stop') result = await stopProjectControllers(options);
1025
1300
  else result = await controllerStatus(options);
1026
1301
  if (!options.json) {
1027
1302
  process.stdout.write(`Marrow controller: ${result.active ? 'active' : result.state}.\n`);
1303
+ if (result.installer_version) process.stdout.write(`Installer version: ${result.installer_version}\n`);
1304
+ if (result.restarted) process.stdout.write(`Restarted: ${result.restarted.from_versions.join(', ')} -> ${result.restarted.to_version}\n`);
1028
1305
  if (result.started_at) process.stdout.write(`Started: ${result.started_at}\n`);
1029
1306
  if (result.exact_fix) process.stdout.write(`Next: ${result.exact_fix}\n`);
1030
1307
  }
@@ -1047,8 +1324,42 @@ async function gateOnly(parsed) {
1047
1324
  }
1048
1325
  const runtime = await preflightRuntime(options, action, type, action);
1049
1326
  const decision = gateDecision(runtime);
1327
+ if (!decision.recognized) {
1328
+ return {
1329
+ ok: false,
1330
+ allowed: false,
1331
+ blocked: true,
1332
+ exitCode: 13,
1333
+ action,
1334
+ type,
1335
+ decision,
1336
+ message: 'Marrow runtime returned no gate decision. Run npx -y @getmarrow/install@latest update, then retry.',
1337
+ };
1338
+ }
1050
1339
  printGate(decision, runtime);
1051
- return { ok: !shouldBlock(decision, options), action, type, decision };
1340
+ if (decision.runtimeDecisionId) {
1341
+ const receipt = decision.receiptId ? ` --gate-receipt ${shellQuote(decision.receiptId)}` : '';
1342
+ process.stdout.write(`Decision: ${decision.runtimeDecisionId}. After the action, record its outcome: npx @getmarrow/install proof --session ${shellQuote(options.sessionId)} --decision-id ${shellQuote(decision.runtimeDecisionId)}${receipt} --success|--failure --summary "<what happened>"\n`);
1343
+ }
1344
+ // A gate used as `gate ... && deploy` must fail the shell chain when Marrow blocks.
1345
+ const blocked = shouldBlock(decision, options);
1346
+ return {
1347
+ ok: !blocked,
1348
+ allowed: !blocked,
1349
+ blocked,
1350
+ exitCode: blocked ? 12 : 0,
1351
+ action,
1352
+ type,
1353
+ decision,
1354
+ decision_id: decision.runtimeDecisionId || null,
1355
+ gate_receipt_id: decision.receiptId || null,
1356
+ session_id: options.sessionId,
1357
+ ...(blocked ? {
1358
+ message: decision.observationOnly
1359
+ ? blockMessage(decision)
1360
+ : decision.exactNextAction || `Marrow gate ${decision.decision}: this action needs owner approval or a policy change before it runs.`,
1361
+ } : {}),
1362
+ };
1052
1363
  }
1053
1364
 
1054
1365
  async function proofOnly(parsed) {
@@ -1066,9 +1377,22 @@ async function proofOnly(parsed) {
1066
1377
  options.success,
1067
1378
  options.outcome || options.summary || (options.success ? 'Manual proof closeout succeeded.' : 'Manual proof closeout failed.'),
1068
1379
  proof,
1069
- '',
1380
+ options.gateReceiptId || '',
1381
+ null,
1382
+ { perInvocation: false },
1070
1383
  );
1071
- return { ok: true, decision_id: options.decisionId, committed: true, result };
1384
+ // Only committed:true is trusted closure; an accepted observation is reported, not claimed.
1385
+ const committed = result?.committed === true;
1386
+ return {
1387
+ ok: committed,
1388
+ decision_id: options.decisionId,
1389
+ committed,
1390
+ exitCode: committed ? 0 : 1,
1391
+ result,
1392
+ ...(committed ? {} : {
1393
+ message: `Marrow did not commit this outcome as trusted closure (committed:${String(result?.committed ?? 'missing')}). Runtime-created decisions need --gate-receipt and the --session printed by gate.`,
1394
+ }),
1395
+ };
1072
1396
  }
1073
1397
 
1074
1398
  async function statusOnly(parsed) {
@@ -1441,7 +1765,7 @@ function fleetPanel(snapshot) {
1441
1765
  return [
1442
1766
  'Marrow Fleet Operator',
1443
1767
  '',
1444
- `Agent: ${snapshot.agent_id}`,
1768
+ `Agent: ${snapshot.agent_id || 'resolved by API key'}`,
1445
1769
  `Snapshot: ${snapshot.generated_at}`,
1446
1770
  '',
1447
1771
  `Live agents: ${snapshot.live_agents ?? 'unknown'}`,
@@ -1472,17 +1796,19 @@ function fleetPanel(snapshot) {
1472
1796
  ].filter(Boolean).join('\n');
1473
1797
  }
1474
1798
 
1475
- const GENERIC_GOVERNED_COMMAND = 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent <agent-id> --profile production --policy warn -- <harness-command>';
1799
+ // Governed commands never name an invented agent id. MARROW_AGENT_ID, when set, is a registered
1800
+ // agent; otherwise Marrow resolves the key's bound agent or the plan seat.
1801
+ const GENERIC_GOVERNED_COMMAND = 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy warn -- <harness-command>';
1476
1802
 
1477
1803
  function localSupportedHarnesses() {
1478
1804
  const harnesses = [
1479
- { display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent codex-prod -- codex' },
1805
+ { display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run -- codex' },
1480
1806
  { display_name: 'Claude Code', client_label: 'claude-code', category: 'agent_harness', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1481
1807
  { display_name: 'Cursor', client_label: 'cursor', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1482
1808
  { display_name: 'Cursor Composer', client_label: 'composer', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1483
1809
  { display_name: 'Windsurf', client_label: 'windsurf', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1484
1810
  { display_name: 'Cline', client_label: 'cline', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1485
- { display_name: 'OpenCode', client_label: 'opencode', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent opencode-prod -- opencode' },
1811
+ { display_name: 'OpenCode', client_label: 'opencode', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run -- opencode' },
1486
1812
  { display_name: 'Hermes Agent', client_label: 'hermes', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install hermes' },
1487
1813
  { display_name: 'OpenClaw', client_label: 'openclaw', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw' },
1488
1814
  { display_name: 'Gemini CLI', client_label: 'gemini', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
@@ -1493,7 +1819,7 @@ function localSupportedHarnesses() {
1493
1819
  { display_name: 'MiniMax', client_label: 'minimax', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1494
1820
  { display_name: 'GLM', client_label: 'glm', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1495
1821
  { display_name: 'MCP-compatible clients', client_label: 'mcp', category: 'mcp_client', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
1496
- { display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent ci-release --profile production --policy enforce -- <ci-or-deploy-command>' },
1822
+ { display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- <ci-or-deploy-command>' },
1497
1823
  { display_name: 'Custom shell/API harness', client_label: 'custom', category: 'custom_runner', support_level: 'event_contract', install_command: 'POST /v1/agent/integrations/events with harness, event_type, agent_id, and action' },
1498
1824
  ];
1499
1825
  return harnesses.map((harness) => {
@@ -1606,8 +1932,9 @@ function localIntegrationManifest(name) {
1606
1932
  title: 'Marrow + Hermes Agent',
1607
1933
  client_label: 'hermes',
1608
1934
  command: 'hermes',
1609
- install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install hermes',
1610
- governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent hermes-prod --profile production --policy enforce -- hermes',
1935
+ install_command: 'MARROW_API_KEY=mrw_live_xxx npx -y @getmarrow/install@latest update',
1936
+ governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- hermes',
1937
+ agent_identity: 'Leave MARROW_AGENT_ID unset: Marrow uses the API key\'s bound agent or the Free plan seat. On paid plans, register the agent with POST /v1/agents or bind the key to exactly one agent before setting MARROW_AGENT_ID.',
1611
1938
  capture_points: [
1612
1939
  '/goal -> Marrow completion contract',
1613
1940
  'verification evidence -> Marrow proof pack',
@@ -1615,7 +1942,7 @@ function localIntegrationManifest(name) {
1615
1942
  '/journey -> governance timeline',
1616
1943
  'background subagents -> agent_id + source_meta.client=hermes',
1617
1944
  ],
1618
- exact_next_action: 'Run the Hermes add-on, then call Marrow runtime before deploy, merge, publish, migration, credential, or customer-facing work.',
1945
+ exact_next_action: 'Run the one-line update: it adds the pinned Marrow MCP server to ~/.hermes/config.yaml and runs the self-test. Restart Hermes, then call marrow_agent_runtime before deploy, merge, publish, migration, credential, or customer-facing work.',
1619
1946
  };
1620
1947
  }
1621
1948
  if (key === 'openclaw' || key === 'open-claw') {
@@ -1625,7 +1952,7 @@ function localIntegrationManifest(name) {
1625
1952
  client_label: 'openclaw',
1626
1953
  command: 'openclaw agent',
1627
1954
  install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw',
1628
- governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent openclaw-release --profile production --policy enforce -- openclaw agent',
1955
+ governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- openclaw agent',
1629
1956
  capture_points: [
1630
1957
  'agent sessions -> workflow sessions',
1631
1958
  'handoff/result files -> proof packs',
@@ -1747,7 +2074,7 @@ async function integrationsOnly(parsed) {
1747
2074
  function governPanel(options) {
1748
2075
  const rows = detectHarnesses();
1749
2076
  const project = detectProjectSignals();
1750
- const agentId = displayText(options.agentId, 80);
2077
+ const agentId = displayText(options.agentId, 80) || 'resolved by API key';
1751
2078
  const profile = displayText(options.profile, 80);
1752
2079
  const policy = displayText(options.policy, 24);
1753
2080
  const lines = [
@@ -1770,8 +2097,8 @@ function governPanel(options) {
1770
2097
  : ' recommendation: export MARROW_API_KEY to get an account/fleet-backed mode recommendation.',
1771
2098
  '',
1772
2099
  'Recommended first commands:',
1773
- ` npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile production --policy enforce -- codex`,
1774
- ` npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy`,
2100
+ ` npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile production --policy enforce -- codex`,
2101
+ ` npx @getmarrow/install run --type deploy --policy enforce -- wrangler deploy`,
1775
2102
  ` npx @getmarrow/install gate "deploy production worker after tests pass"`,
1776
2103
  '',
1777
2104
  'Protected by default: deploy, merge, publish, migrations, secrets, keys, production actions.',
@@ -1817,7 +2144,7 @@ function commandForSelection(state, options) {
1817
2144
  }
1818
2145
  const command = harness.command === '<your-agent-command>' ? '<your-command>' : harness.command;
1819
2146
  const renderedCommand = command.split(/\s+/).filter(Boolean).map(shellQuote).join(' ');
1820
- return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
2147
+ return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
1821
2148
  }
1822
2149
 
1823
2150
  function buildGovernState(options, cwd = process.cwd()) {
@@ -1910,7 +2237,7 @@ function renderGovernTui(state, options) {
1910
2237
  '| Passive agent governance for day-one use |',
1911
2238
  '+------------------------------------------------------------+',
1912
2239
  '',
1913
- `Agent: ${displayText(options.agentId, 36)} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
2240
+ `Agent: ${displayText(options.agentId, 36) || 'resolved by API key'} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
1914
2241
  `Project: ${displayText(state.project?.name || 'workspace', 36)} Signals: ${displayText((state.project?.signals || []).slice(0, 4).join(', ') || 'none', 52)}`,
1915
2242
  '',
1916
2243
  'Navigation: Up/Down move Left/Right change Enter select',
@@ -2036,7 +2363,7 @@ function renderFleetTui(state) {
2036
2363
  '| Live fleet health, proof debt, gates, and exact fixes |',
2037
2364
  '+------------------------------------------------------------+',
2038
2365
  '',
2039
- `Agent: ${displayText(snapshot.agent_id, 36)} Snapshot: ${displayText(snapshot.generated_at, 36)}`,
2366
+ `Agent: ${displayText(snapshot.agent_id, 36) || 'resolved by API key'} Snapshot: ${displayText(snapshot.generated_at, 36)}`,
2040
2367
  `API: ${displayText(snapshot.base_url, 60)}`,
2041
2368
  '',
2042
2369
  'Navigation: Up/Down move Left/Right select agent Enter inspect/print',
@@ -2381,12 +2708,15 @@ async function runCli(argv) {
2381
2708
  result = await integrationsOnly(parsed);
2382
2709
  }
2383
2710
 
2711
+ const exitCodeCommands = ['run', 'verify-permit', 'gate', 'permit', 'proof'];
2384
2712
  if (parsed.options?.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2385
2713
  else if (result?.blocked) process.stderr.write(`BLOCKED: ${result.message || 'Marrow blocked this action.'}\n`);
2714
+ else if (result?.ok === false && result?.message && exitCodeCommands.includes(parsed.command)) process.stderr.write(`Marrow: ${result.message}\n`);
2386
2715
  else if (parsed.command === 'status') process.stdout.write(`${statusPanel(result)}\n`);
2716
+ else if (result?.advisory && result?.message) process.stdout.write(`${result.message}\n`);
2387
2717
  else if (!['run', 'fleet', 'hermes', 'openclaw', 'integrations'].includes(parsed.command)) process.stdout.write('Marrow command completed.\n');
2388
2718
 
2389
- if (parsed.command === 'run' || parsed.command === 'verify-permit' || result?.blocked) {
2719
+ if (exitCodeCommands.includes(parsed.command) || result?.blocked) {
2390
2720
  process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
2391
2721
  }
2392
2722
  }
@@ -2419,6 +2749,7 @@ module.exports = {
2419
2749
  verifyPermitOnly,
2420
2750
  coverageOnly,
2421
2751
  sidecarOnly,
2752
+ requestJson,
2422
2753
  controllerOnly,
2423
2754
  actionBinding,
2424
2755
  gateOnly,