@getmarrow/install 0.1.65 → 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
@@ -370,6 +374,10 @@ function parseBaseOptions(argv, startIndex = 0) {
370
374
  permit: process.env.MARROW_ACTION_PERMIT || '',
371
375
  target: process.env.MARROW_ACTION_TARGET || '',
372
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'),
373
381
  };
374
382
  let i = startIndex;
375
383
  for (; i < argv.length; i += 1) {
@@ -392,7 +400,10 @@ function parseBaseOptions(argv, startIndex = 0) {
392
400
  else if (arg === '--target') options.target = argv[++i] || options.target;
393
401
  else if (arg === '--sidecar-port') options.sidecarPort = argv[++i] || options.sidecarPort;
394
402
  else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
395
- 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
+ }
396
407
  else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
397
408
  else if (arg === '--key') {
398
409
  options.apiKey = argv[++i] || options.apiKey;
@@ -418,7 +429,10 @@ function parseArgs(argv) {
418
429
  if (command === 'run') {
419
430
  const parsed = parseBaseOptions(argv, 1);
420
431
  const separator = argv[parsed.index] === '--' ? parsed.index + 1 : parsed.index;
421
- 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);
422
436
  if (parsed.options.help) return { command: 'help' };
423
437
  if (childCommand.length === 0) throw new Error('marrow run requires a command after --');
424
438
  return { command, options: parsed.options, childCommand };
@@ -444,18 +458,24 @@ function parseArgs(argv) {
444
458
  }
445
459
 
446
460
  if (command === 'proof') {
447
- const parsed = parseBaseOptions(argv, 1);
448
- const options = { ...parsed.options, decisionId: '', success: true, summary: '', outcome: '' };
449
- 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) {
450
466
  const arg = argv[i];
451
- if (arg === '--decision-id') options.decisionId = argv[++i] || '';
452
- else if (arg === '--success') options.success = true;
453
- else if (arg === '--failure' || arg === '--failed') options.success = false;
454
- else if (arg === '--summary') options.summary = argv[++i] || '';
455
- else if (arg === '--outcome') options.outcome = argv[++i] || '';
456
- else if (arg === '--help' || arg === '-h') return { command: 'help' };
457
- 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);
458
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 };
459
479
  if (!options.decisionId) throw new Error('marrow proof requires --decision-id');
460
480
  return { command, options };
461
481
  }
@@ -516,7 +536,7 @@ function dataOf(json) {
516
536
  return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
517
537
  }
518
538
 
519
- async function requestJson(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
539
+ async function rawRequest(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
520
540
  if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
521
541
  const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
522
542
  method,
@@ -527,15 +547,111 @@ async function requestJson(options, method, route, body, extraHeaders = {}, { ti
527
547
  const text = await response.text();
528
548
  let json = {};
529
549
  try { json = text ? JSON.parse(text) : {}; } catch { json = { error: text.slice(0, 500) }; }
530
- if (!response.ok) {
531
- const error = new Error(json.error || json.message || `Marrow ${route} returned HTTP ${response.status}`);
532
- error.status = response.status;
533
- error.details = json.details || json;
534
- throw error;
535
- }
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);
536
563
  return dataOf(json);
537
564
  }
538
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
+
539
655
  function proofFromFile(filePath) {
540
656
  if (!filePath) return null;
541
657
  const raw = fs.readFileSync(path.resolve(filePath), 'utf8');
@@ -577,11 +693,14 @@ async function preflightRuntime(options, action, type, commandText) {
577
693
  const surfaces = inferSurfaces(commandText || action);
578
694
  const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
579
695
  const project = detectProjectSignals(options.root || process.cwd());
580
- return requestJson(options, 'POST', '/v1/agent/runtime', {
696
+ const body = {
581
697
  action,
582
698
  type,
583
699
  target,
584
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',
585
704
  harness: sourceClient(options.client),
586
705
  project: { ...project, harness: sourceClient(options.client) },
587
706
  source_meta: meta,
@@ -593,7 +712,9 @@ async function preflightRuntime(options, action, type, commandText) {
593
712
  governed: true,
594
713
  source_meta: meta,
595
714
  },
596
- });
715
+ };
716
+ return durableRequestJson(options, '/v1/agent/runtime', body,
717
+ durableIdempotencyKey(options, '/v1/agent/runtime', [JSON.stringify(body)]));
597
718
  }
598
719
 
599
720
  async function recommendGovernanceMode(options, project = detectProjectSignals()) {
@@ -643,33 +764,106 @@ async function recordGovernanceModeSelection(options, state) {
643
764
  });
644
765
  }
645
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".
646
779
  function gateDecision(runtime) {
647
- const gate = runtime?.risk_gate || {};
648
- 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 || '');
649
803
  return {
650
- decision: gate.enforcement_decision || receipt.decision || gate.decision || 'unknown',
651
- allow: gate.allow !== false,
652
- riskLevel: String(gate.risk_level || receipt.risk_level || runtime?.risk_level || '').trim().toLowerCase(),
653
- required: Boolean(receipt.required || gate.gate_required),
654
- ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required),
655
- receiptId: receipt.id || gate.gate_receipt_id || '',
656
- exactNextAction: runtime?.exact_next_action || receipt.exact_fix || gate.policy?.exact_fix || '',
657
- beforeYouAct: runtime?.before_you_act_injection?.message || runtime?.before_you_act || '',
658
- 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),
659
829
  };
660
830
  }
661
831
 
662
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;
663
836
  if (options.policy === 'audit') return false;
664
- 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;
665
840
  if (options.policy === 'warn') return false;
666
- if (decision.ownerApprovalRequired && !options.ownerApproval) return true;
667
- if (decision.decision === 'owner_approval_required' && !options.ownerApproval) return true;
668
- 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('; ');
669
863
  }
670
864
 
671
865
  function printGate(decision, runtime, stream = process.stdout) {
672
- stream.write(`Marrow gate: ${decision.decision}${decision.required ? ' (required)' : ''}\n`);
866
+ stream.write(`Marrow gate: ${decision.decision} (${gateModeText(decision)})\n`);
673
867
  if (decision.beforeYouAct) stream.write(`Before you act: ${decision.beforeYouAct}\n`);
674
868
  if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
675
869
  if (decision.proofPack?.required) {
@@ -741,7 +935,7 @@ function scopedExecutionEnv(permit) {
741
935
 
742
936
  async function createDecision(options, action, type, target, surfaces) {
743
937
  const meta = sourceMeta(options, 'think', { action, action_type: type });
744
- return requestJson(options, 'POST', '/v1/agent/think', {
938
+ const body = {
745
939
  action,
746
940
  type,
747
941
  target,
@@ -753,10 +947,19 @@ async function createDecision(options, action, type, target, surfaces) {
753
947
  governed: true,
754
948
  source_meta: meta,
755
949
  },
756
- });
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')}`;
757
960
  }
758
961
 
759
- 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 } = {}) {
760
963
  const body = {
761
964
  decision_id: decisionId,
762
965
  success,
@@ -766,12 +969,14 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
766
969
  };
767
970
  if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
768
971
  if (modelUsage) body.model_usage = modelUsage;
769
- const commitHeaders = modelUsage
770
- ? { 'Idempotency-Key': `marrow-run-${crypto.createHash('sha256')
771
- .update(`${options.agentId}\u0000${options.sessionId}\u0000${decisionId}`)
772
- .digest('hex')}` }
773
- : {};
774
- 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 };
775
980
  }
776
981
 
777
982
  async function runGoverned(parsed, execution = {}) {
@@ -784,9 +989,11 @@ async function runGoverned(parsed, execution = {}) {
784
989
  let runtime = null;
785
990
  let decision = null;
786
991
  let decisionId = '';
992
+ let decisionSource = null;
787
993
  let actionPermit = null;
788
994
  let permitVerified = false;
789
995
  let degraded = false;
996
+ let advisory = false;
790
997
  let protectedAction = risky;
791
998
  const surfaces = inferSurfaces(commandText || action);
792
999
 
@@ -806,10 +1013,15 @@ async function runGoverned(parsed, execution = {}) {
806
1013
  runtime = await preflightRuntime(options, action, type, commandText);
807
1014
  decision = gateDecision(runtime);
808
1015
  protectedAction = risky
809
- || decision?.required === true
810
- || decision?.riskLevel === 'high'
811
- || decision?.riskLevel === 'critical';
812
- 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
+ }
813
1025
  if (shouldBlock(decision, options)) {
814
1026
  return {
815
1027
  ok: false,
@@ -819,22 +1031,29 @@ async function runGoverned(parsed, execution = {}) {
819
1031
  type,
820
1032
  risky,
821
1033
  decision,
822
- message: decision.exactNextAction || 'Marrow blocked this action before execution.',
1034
+ message: blockMessage(decision),
823
1035
  };
824
1036
  }
825
1037
  const target = options.target || commandText;
826
- const think = await createDecision(options, action, type, target, surfaces);
827
- decisionId = think.decision_id || think.id || think.decision?.id || '';
828
- 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.');
829
1048
  actionPermit = await issueActionPermit(requestJson, options, {
830
1049
  action,
831
1050
  type,
832
1051
  target,
833
1052
  surfaces,
834
1053
  decisionId,
835
- gateReceiptId: decision?.receiptId || '',
1054
+ gateReceiptId: decision.receiptId,
836
1055
  ownerApproval: options.ownerApproval,
837
- proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
1056
+ proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
838
1057
  });
839
1058
  if (!actionPermit?.permit || !actionPermit?.permit_id) {
840
1059
  throw new Error('Marrow did not issue a valid action permit.');
@@ -857,7 +1076,7 @@ async function runGoverned(parsed, execution = {}) {
857
1076
  actionPermit = null;
858
1077
  permitVerified = false;
859
1078
  degraded = true;
860
- 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`);
861
1080
  } else {
862
1081
  return {
863
1082
  ok: false,
@@ -881,6 +1100,7 @@ async function runGoverned(parsed, execution = {}) {
881
1100
  : `Marrow governed command failed with exit code ${child.exitCode}.`;
882
1101
 
883
1102
  let commit = null;
1103
+ let commitState = decisionId ? 'failed' : 'not_recorded';
884
1104
  if (decisionId) {
885
1105
  try {
886
1106
  commit = await commitOutcome(
@@ -892,9 +1112,15 @@ async function runGoverned(parsed, execution = {}) {
892
1112
  decision?.receiptId || '',
893
1113
  child.modelUsage,
894
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
+ }
895
1119
  } catch (error) {
896
1120
  process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
897
1121
  }
1122
+ } else if (!degraded) {
1123
+ process.stderr.write('Marrow outcome not recorded: no decision id was returned before the command ran.\n');
898
1124
  }
899
1125
 
900
1126
  let permitClosed = null;
@@ -921,9 +1147,13 @@ async function runGoverned(parsed, execution = {}) {
921
1147
  type,
922
1148
  risky,
923
1149
  degraded,
1150
+ advisory,
924
1151
  decision,
925
1152
  decision_id: decisionId,
926
- 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,
927
1157
  permit_id: actionPermit?.permit_id || null,
928
1158
  permit_verified: permitVerified,
929
1159
  permit_closed: Boolean(permitClosed),
@@ -948,11 +1178,28 @@ async function permitOnly(parsed) {
948
1178
  const surfaces = inferSurfaces(target);
949
1179
  const runtime = await preflightRuntime(options, action, type, target);
950
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
+ }
951
1184
  if (shouldBlock(decision, options)) {
952
- 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.' };
953
1202
  }
954
- const think = await createDecision(options, action, type, target, surfaces);
955
- const decisionId = think.decision_id || think.id || think.decision?.id || '';
956
1203
  const result = await issueActionPermit(requestJson, options, {
957
1204
  action,
958
1205
  type,
@@ -961,9 +1208,9 @@ async function permitOnly(parsed) {
961
1208
  decisionId,
962
1209
  gateReceiptId: decision.receiptId,
963
1210
  ownerApproval: options.ownerApproval,
964
- proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
1211
+ proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
965
1212
  });
966
- return { ok: true, decision_id: decisionId, ...result };
1213
+ return { ok: true, decision_id: decisionId, gate_receipt_id: decision.receiptId, ...result };
967
1214
  }
968
1215
 
969
1216
  async function verifyPermitOnly(parsed) {
@@ -995,16 +1242,32 @@ async function sidecarOnly(parsed) {
995
1242
  const maintain = async () => {
996
1243
  if (lastMaintenance && Date.now() - lastMaintenanceAt < 5 * 60_000) return lastMaintenance;
997
1244
  const detection = detectEnvironment(root, process.env);
998
- 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
+ });
999
1255
  const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
1000
1256
  const repaired = changes.filter((change) => change.applied).map((change) => change.label);
1001
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 })));
1002
1260
  lastMaintenanceAt = Date.now();
1003
1261
  lastMaintenance = {
1004
- 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',
1005
1263
  checked_at: new Date(lastMaintenanceAt).toISOString(),
1006
1264
  repaired,
1007
- 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,
1008
1271
  };
1009
1272
  return lastMaintenance;
1010
1273
  };
@@ -1022,16 +1285,23 @@ async function sidecarOnly(parsed) {
1022
1285
  }
1023
1286
 
1024
1287
  async function controllerOnly(parsed) {
1025
- // Keep the local controller identity stable; only the server-facing agent id changed.
1026
- const identityAgentId = parsed.options.agentId || os.userInfo().username || 'agent';
1027
- const options = { ...parsed.options, identityAgentId, 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' };
1028
1296
  let result;
1029
- if (parsed.action === 'ensure') result = await ensureGovernanceController(options);
1297
+ if (parsed.action === 'ensure') result = await ensureCurrentGovernanceController(options);
1030
1298
  else if (parsed.action === 'start') result = await startGovernanceController(options);
1031
- else if (parsed.action === 'stop') result = await stopGovernanceController(options);
1299
+ else if (parsed.action === 'stop') result = await stopProjectControllers(options);
1032
1300
  else result = await controllerStatus(options);
1033
1301
  if (!options.json) {
1034
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`);
1035
1305
  if (result.started_at) process.stdout.write(`Started: ${result.started_at}\n`);
1036
1306
  if (result.exact_fix) process.stdout.write(`Next: ${result.exact_fix}\n`);
1037
1307
  }
@@ -1054,8 +1324,42 @@ async function gateOnly(parsed) {
1054
1324
  }
1055
1325
  const runtime = await preflightRuntime(options, action, type, action);
1056
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
+ }
1057
1339
  printGate(decision, runtime);
1058
- 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
+ };
1059
1363
  }
1060
1364
 
1061
1365
  async function proofOnly(parsed) {
@@ -1073,9 +1377,22 @@ async function proofOnly(parsed) {
1073
1377
  options.success,
1074
1378
  options.outcome || options.summary || (options.success ? 'Manual proof closeout succeeded.' : 'Manual proof closeout failed.'),
1075
1379
  proof,
1076
- '',
1380
+ options.gateReceiptId || '',
1381
+ null,
1382
+ { perInvocation: false },
1077
1383
  );
1078
- 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
+ };
1079
1396
  }
1080
1397
 
1081
1398
  async function statusOnly(parsed) {
@@ -1479,17 +1796,19 @@ function fleetPanel(snapshot) {
1479
1796
  ].filter(Boolean).join('\n');
1480
1797
  }
1481
1798
 
1482
- 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>';
1483
1802
 
1484
1803
  function localSupportedHarnesses() {
1485
1804
  const harnesses = [
1486
- { 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' },
1487
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}` },
1488
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}` },
1489
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}` },
1490
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}` },
1491
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}` },
1492
- { 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' },
1493
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' },
1494
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' },
1495
1814
  { display_name: 'Gemini CLI', client_label: 'gemini', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
@@ -1500,7 +1819,7 @@ function localSupportedHarnesses() {
1500
1819
  { display_name: 'MiniMax', client_label: 'minimax', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1501
1820
  { display_name: 'GLM', client_label: 'glm', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
1502
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}` },
1503
- { 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>' },
1504
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' },
1505
1824
  ];
1506
1825
  return harnesses.map((harness) => {
@@ -1613,8 +1932,9 @@ function localIntegrationManifest(name) {
1613
1932
  title: 'Marrow + Hermes Agent',
1614
1933
  client_label: 'hermes',
1615
1934
  command: 'hermes',
1616
- install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install hermes',
1617
- 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.',
1618
1938
  capture_points: [
1619
1939
  '/goal -> Marrow completion contract',
1620
1940
  'verification evidence -> Marrow proof pack',
@@ -1622,7 +1942,7 @@ function localIntegrationManifest(name) {
1622
1942
  '/journey -> governance timeline',
1623
1943
  'background subagents -> agent_id + source_meta.client=hermes',
1624
1944
  ],
1625
- 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.',
1626
1946
  };
1627
1947
  }
1628
1948
  if (key === 'openclaw' || key === 'open-claw') {
@@ -1632,7 +1952,7 @@ function localIntegrationManifest(name) {
1632
1952
  client_label: 'openclaw',
1633
1953
  command: 'openclaw agent',
1634
1954
  install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw',
1635
- 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',
1636
1956
  capture_points: [
1637
1957
  'agent sessions -> workflow sessions',
1638
1958
  'handoff/result files -> proof packs',
@@ -1778,7 +2098,7 @@ function governPanel(options) {
1778
2098
  '',
1779
2099
  'Recommended first commands:',
1780
2100
  ` npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile production --policy enforce -- codex`,
1781
- ` npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy`,
2101
+ ` npx @getmarrow/install run --type deploy --policy enforce -- wrangler deploy`,
1782
2102
  ` npx @getmarrow/install gate "deploy production worker after tests pass"`,
1783
2103
  '',
1784
2104
  'Protected by default: deploy, merge, publish, migrations, secrets, keys, production actions.',
@@ -2388,12 +2708,15 @@ async function runCli(argv) {
2388
2708
  result = await integrationsOnly(parsed);
2389
2709
  }
2390
2710
 
2711
+ const exitCodeCommands = ['run', 'verify-permit', 'gate', 'permit', 'proof'];
2391
2712
  if (parsed.options?.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2392
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`);
2393
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`);
2394
2717
  else if (!['run', 'fleet', 'hermes', 'openclaw', 'integrations'].includes(parsed.command)) process.stdout.write('Marrow command completed.\n');
2395
2718
 
2396
- if (parsed.command === 'run' || parsed.command === 'verify-permit' || result?.blocked) {
2719
+ if (exitCodeCommands.includes(parsed.command) || result?.blocked) {
2397
2720
  process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
2398
2721
  }
2399
2722
  }