@getmarrow/install 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +1 -1
- package/src/governed-runner.js +512 -7
package/README.md
CHANGED
|
@@ -11,6 +11,41 @@ npx @getmarrow/install --repair
|
|
|
11
11
|
npx @getmarrow/install doctor
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## What's New in v0.1.14
|
|
15
|
+
|
|
16
|
+
v0.1.14 adds adaptive governance mode recommendations without silent auto-switching.
|
|
17
|
+
|
|
18
|
+
- `npx @getmarrow/install govern` now detects project signals such as `package.json`, deploy/publish scripts, `wrangler` config, GitHub workflows, migrations, Cursor/Codex/Claude files, and MCP config.
|
|
19
|
+
- When `MARROW_API_KEY` is present, the TUI asks Marrow for a recommended mode: `passive`, `pilot`, or `enforce`.
|
|
20
|
+
- The TUI shows the exact reasons, confidence, and selected command before the user applies anything.
|
|
21
|
+
- User choice is explicit. Marrow logs whether the recommendation was accepted or overridden, but it does not silently switch modes.
|
|
22
|
+
- Policy profiles are supported by the backend/SDK/MCP so businesses can define rules like local=passive, staging=pilot, production deploys=enforce.
|
|
23
|
+
|
|
24
|
+
Example recommendation:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
Recommended mode: pilot
|
|
28
|
+
Reason:
|
|
29
|
+
- Node project detected
|
|
30
|
+
- Cloudflare Worker detected
|
|
31
|
+
- GitHub workflow detected
|
|
32
|
+
- No owner approval policy configured yet
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What's New in v0.1.13
|
|
36
|
+
|
|
37
|
+
v0.1.13 turns `npx @getmarrow/install govern` into an interactive terminal setup flow when run in a real TTY.
|
|
38
|
+
|
|
39
|
+
- Select Codex, Claude Code, Cursor, OpenCode, OpenClaw, CI scripts, or a custom command with arrow keys.
|
|
40
|
+
- Choose passive setup, governed pilot mode, or governed enforce mode.
|
|
41
|
+
- Run passive setup + self-test from the TUI after explicit confirmation.
|
|
42
|
+
- Check Marrow status and test the before-action gate from the same screen.
|
|
43
|
+
- Print the exact command for the selected harness/mode so users know what to run next.
|
|
44
|
+
- Exit cleanly with `q`, `Esc`, or `Ctrl+C`.
|
|
45
|
+
- CI/non-TTY usage remains stable with `npx @getmarrow/install govern --no-interactive`.
|
|
46
|
+
|
|
47
|
+
This keeps Marrow passive-first: install once, verify Marrow is active, then let agents use the runtime/gate path automatically for risky work.
|
|
48
|
+
|
|
14
49
|
## What's New in v0.1.12
|
|
15
50
|
|
|
16
51
|
v0.1.12 adds the Marrow governed runner for businesses that want agent governance without replacing their existing harness.
|
|
@@ -32,6 +67,12 @@ Preview the detected harnesses and protected command examples:
|
|
|
32
67
|
npx @getmarrow/install govern
|
|
33
68
|
```
|
|
34
69
|
|
|
70
|
+
In a real terminal, this opens the interactive setup flow. In CI or scripts, use:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx @getmarrow/install govern --no-interactive
|
|
74
|
+
```
|
|
75
|
+
|
|
35
76
|
Run a harmless command through Marrow:
|
|
36
77
|
|
|
37
78
|
```bash
|
package/package.json
CHANGED
package/src/governed-runner.js
CHANGED
|
@@ -3,9 +3,11 @@ const crypto = require('node:crypto');
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
+
const readline = require('node:readline');
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
8
9
|
const HIGH_RISK_TERMS = /\b(deploy|prod|production|publish|release|merge|migration|migrate|secret|token|key|cloudflare|wrangler|npm publish|gh pr merge|git push|terraform apply|kubectl apply|delete|destroy|drop)\b/i;
|
|
10
|
+
const GOVERN_TUI_ROW_COUNT = 7;
|
|
9
11
|
function usage() {
|
|
10
12
|
return `Usage:
|
|
11
13
|
npx @getmarrow/install run --agent deploy-agent -- npm test
|
|
@@ -14,13 +16,14 @@ function usage() {
|
|
|
14
16
|
npx @getmarrow/install proof --decision-id <id> --success --summary "smoke passed"
|
|
15
17
|
npx @getmarrow/install status
|
|
16
18
|
npx @getmarrow/install govern
|
|
19
|
+
npx @getmarrow/install govern --no-interactive
|
|
17
20
|
|
|
18
21
|
Commands:
|
|
19
22
|
run Run a command through Marrow pre-action gate and automatic outcome closure
|
|
20
23
|
gate Check Marrow runtime/gate for an action without running a command
|
|
21
24
|
proof Commit an outcome/proof for an existing decision
|
|
22
25
|
status Read /v1/agent/status
|
|
23
|
-
govern
|
|
26
|
+
govern Interactive setup TUI when run in a terminal; text panel in CI/non-TTY
|
|
24
27
|
|
|
25
28
|
Options:
|
|
26
29
|
--agent <id> Agent identity. Defaults to MARROW_FLEET_AGENT_ID, MARROW_AGENT_ID, or local user
|
|
@@ -36,6 +39,8 @@ Options:
|
|
|
36
39
|
--base-url <url> Marrow API base URL
|
|
37
40
|
--key <key> Marrow API key. Prefer MARROW_API_KEY
|
|
38
41
|
--json Print machine-readable result after completion
|
|
42
|
+
--interactive Force interactive govern TUI when possible
|
|
43
|
+
--no-interactive Print govern panel instead of opening the TUI
|
|
39
44
|
`;
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -55,13 +60,28 @@ function redact(value) {
|
|
|
55
60
|
|
|
56
61
|
function shellQuote(value) {
|
|
57
62
|
const text = String(value || '');
|
|
58
|
-
|
|
63
|
+
if (!text) return "''";
|
|
64
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
function redactedCommand(command) {
|
|
62
68
|
return command.map((part) => shellQuote(redact(part))).join(' ');
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
function displayText(value, maxLength = 120) {
|
|
72
|
+
const text = redact(String(value || ''))
|
|
73
|
+
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
|
74
|
+
.replace(/\u009d[^\u0007\u009c]*(?:\u0007|\u009c|\u001b\\)/g, '')
|
|
75
|
+
.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
|
|
76
|
+
.replace(/[\t\r\n]+/g, ' ')
|
|
77
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
|
|
78
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shellQuoteDisplay(value) {
|
|
82
|
+
return shellQuote(displayText(value, 240));
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
function inferType(text) {
|
|
66
86
|
const value = String(text || '').toLowerCase();
|
|
67
87
|
if (/\b(deploy|wrangler|cloudflare|production|prod|release)\b/.test(value)) return 'deploy';
|
|
@@ -85,6 +105,71 @@ function inferSurfaces(text) {
|
|
|
85
105
|
return [...surfaces];
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
function safeJsonFile(filePath) {
|
|
109
|
+
try {
|
|
110
|
+
if (!fs.existsSync(filePath)) return null;
|
|
111
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
112
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function detectProjectSignals(cwd = process.cwd()) {
|
|
119
|
+
const packageJsonPath = path.join(cwd, 'package.json');
|
|
120
|
+
const packageJson = safeJsonFile(packageJsonPath);
|
|
121
|
+
const packageScripts = packageJson?.scripts && typeof packageJson.scripts === 'object'
|
|
122
|
+
? Object.keys(packageJson.scripts)
|
|
123
|
+
: [];
|
|
124
|
+
const signals = new Set();
|
|
125
|
+
const frameworks = new Set();
|
|
126
|
+
const configFiles = [];
|
|
127
|
+
|
|
128
|
+
const addFile = (relative, signal) => {
|
|
129
|
+
if (fs.existsSync(path.join(cwd, relative))) {
|
|
130
|
+
configFiles.push(relative);
|
|
131
|
+
signals.add(signal);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (packageJson) {
|
|
136
|
+
signals.add('package_json');
|
|
137
|
+
if (packageJson.dependencies?.['@cloudflare/workers-types'] || packageJson.devDependencies?.['wrangler'] || packageJson.dependencies?.['hono']) {
|
|
138
|
+
frameworks.add('cloudflare-workers');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
addFile('wrangler.toml', 'wrangler_config');
|
|
142
|
+
addFile('wrangler.json', 'wrangler_config');
|
|
143
|
+
addFile('wrangler.jsonc', 'wrangler_config');
|
|
144
|
+
addFile('.github/workflows', 'github_actions');
|
|
145
|
+
addFile('Dockerfile', 'container');
|
|
146
|
+
addFile('docker-compose.yml', 'container');
|
|
147
|
+
addFile('terraform', 'terraform');
|
|
148
|
+
addFile('migrations', 'database_migrations');
|
|
149
|
+
addFile('prisma', 'database_migrations');
|
|
150
|
+
addFile('drizzle', 'database_migrations');
|
|
151
|
+
addFile('AGENTS.md', 'agent_instructions');
|
|
152
|
+
addFile('CLAUDE.md', 'agent_instructions');
|
|
153
|
+
addFile('.mcp.json', 'mcp_config');
|
|
154
|
+
addFile('.cursor', 'cursor_project');
|
|
155
|
+
|
|
156
|
+
for (const script of packageScripts) {
|
|
157
|
+
if (/\b(deploy|publish|release|migrate|migration|smoke|check|test)\b/i.test(script)) {
|
|
158
|
+
signals.add(`script:${script.toLowerCase()}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
name: packageJson?.name || path.basename(cwd),
|
|
164
|
+
key: packageJson?.name || path.basename(cwd),
|
|
165
|
+
type: packageJson ? 'node' : fs.existsSync(path.join(cwd, 'pyproject.toml')) ? 'python' : 'workspace',
|
|
166
|
+
frameworks: [...frameworks],
|
|
167
|
+
signals: [...signals],
|
|
168
|
+
package_scripts: packageScripts.slice(0, 30),
|
|
169
|
+
config_files: configFiles.slice(0, 30),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
88
173
|
function isRisky(text, type) {
|
|
89
174
|
return HIGH_RISK_TERMS.test(`${type || ''} ${text || ''}`);
|
|
90
175
|
}
|
|
@@ -104,6 +189,7 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
104
189
|
proofFile: '',
|
|
105
190
|
type: '',
|
|
106
191
|
action: '',
|
|
192
|
+
interactive: null,
|
|
107
193
|
};
|
|
108
194
|
let i = startIndex;
|
|
109
195
|
for (; i < argv.length; i += 1) {
|
|
@@ -128,6 +214,8 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
128
214
|
options.apiKey = argv[++i] || options.apiKey;
|
|
129
215
|
options.keyFromArg = true;
|
|
130
216
|
} else if (arg === '--json') options.json = true;
|
|
217
|
+
else if (arg === '--interactive') options.interactive = true;
|
|
218
|
+
else if (arg === '--no-interactive') options.interactive = false;
|
|
131
219
|
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
132
220
|
else if (arg.startsWith('--')) throw new Error(`Unknown option: ${arg}`);
|
|
133
221
|
else break;
|
|
@@ -257,6 +345,51 @@ async function preflightRuntime(options, action, type, commandText) {
|
|
|
257
345
|
});
|
|
258
346
|
}
|
|
259
347
|
|
|
348
|
+
async function recommendGovernanceMode(options, project = detectProjectSignals()) {
|
|
349
|
+
if (!options.apiKey) {
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
skipped: true,
|
|
353
|
+
reason: 'MARROW_API_KEY missing',
|
|
354
|
+
project,
|
|
355
|
+
exact_fix: 'export MARROW_API_KEY=mrw_live_... && npx @getmarrow/install govern',
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const action = 'configure Marrow governance mode for this project';
|
|
359
|
+
return requestJson(options, 'POST', '/v1/agent/mode/recommend', {
|
|
360
|
+
project,
|
|
361
|
+
workflow: {
|
|
362
|
+
action,
|
|
363
|
+
type: 'setup',
|
|
364
|
+
branch: process.env.GITHUB_REF_NAME || process.env.BRANCH_NAME || '',
|
|
365
|
+
environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
|
|
366
|
+
},
|
|
367
|
+
agent: {
|
|
368
|
+
id: options.agentId,
|
|
369
|
+
role: 'setup',
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function recordGovernanceModeSelection(options, state) {
|
|
375
|
+
if (!options.apiKey || !state.recommendation?.recommended_mode) return null;
|
|
376
|
+
const selected = selectedGovernanceMode(state.modes[state.modeIndex]);
|
|
377
|
+
return requestJson(options, 'POST', '/v1/agent/mode/recommend', {
|
|
378
|
+
project: state.project,
|
|
379
|
+
workflow: {
|
|
380
|
+
action: 'selected Marrow governance mode for this project',
|
|
381
|
+
type: 'setup',
|
|
382
|
+
environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
|
|
383
|
+
},
|
|
384
|
+
agent: {
|
|
385
|
+
id: options.agentId,
|
|
386
|
+
role: 'setup',
|
|
387
|
+
},
|
|
388
|
+
selected_mode: selected,
|
|
389
|
+
selection_source: selected === state.recommendation.recommended_mode ? 'accepted' : 'overridden',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
260
393
|
function gateDecision(runtime) {
|
|
261
394
|
const gate = runtime?.risk_gate || {};
|
|
262
395
|
const receipt = runtime?.gate_receipt || {};
|
|
@@ -439,8 +572,10 @@ function detectHarnesses(cwd = process.cwd()) {
|
|
|
439
572
|
const candidates = [
|
|
440
573
|
{ name: 'Codex', command: 'codex', detected: fs.existsSync(path.join(cwd, 'AGENTS.md')) || fs.existsSync(path.join(os.homedir(), '.codex')) },
|
|
441
574
|
{ name: 'Claude Code', command: 'claude -p', detected: fs.existsSync(path.join(cwd, 'CLAUDE.md')) || fs.existsSync(path.join(os.homedir(), '.claude.json')) },
|
|
575
|
+
{ name: 'Cursor', command: 'cursor', detected: fs.existsSync(path.join(cwd, '.cursor')) || fs.existsSync(path.join(os.homedir(), '.cursor')) },
|
|
442
576
|
{ name: 'OpenCode', command: 'opencode', detected: fs.existsSync(path.join(cwd, 'opencode.json')) || fs.existsSync(path.join(os.homedir(), '.opencode')) },
|
|
443
577
|
{ name: 'OpenClaw', command: 'openclaw agent', detected: fs.existsSync(path.join(os.homedir(), '.openclaw')) },
|
|
578
|
+
{ name: 'CI script', command: 'npm test', detected: fs.existsSync(path.join(cwd, 'package.json')) },
|
|
444
579
|
{ name: 'Custom command', command: '<your-agent-command>', detected: true },
|
|
445
580
|
];
|
|
446
581
|
return candidates;
|
|
@@ -448,20 +583,31 @@ function detectHarnesses(cwd = process.cwd()) {
|
|
|
448
583
|
|
|
449
584
|
function governPanel(options) {
|
|
450
585
|
const rows = detectHarnesses();
|
|
586
|
+
const project = detectProjectSignals();
|
|
587
|
+
const agentId = displayText(options.agentId, 80);
|
|
588
|
+
const profile = displayText(options.profile, 80);
|
|
589
|
+
const policy = displayText(options.policy, 24);
|
|
451
590
|
const lines = [
|
|
452
591
|
'Marrow Governed Runner',
|
|
453
592
|
'',
|
|
454
|
-
`Agent: ${
|
|
455
|
-
`Profile: ${
|
|
456
|
-
`Policy: ${
|
|
593
|
+
`Agent: ${agentId}`,
|
|
594
|
+
`Profile: ${profile}`,
|
|
595
|
+
`Policy: ${policy}`,
|
|
457
596
|
'',
|
|
458
597
|
'Choose where your agent runs. Marrow governs the action before it executes.',
|
|
459
598
|
'',
|
|
460
599
|
'Detected harnesses:',
|
|
461
600
|
...rows.map((row, index) => ` ${index + 1}. ${row.detected ? '[x]' : '[ ]'} ${row.name} ${row.command}`),
|
|
462
601
|
'',
|
|
602
|
+
'Detected project signals:',
|
|
603
|
+
` project=${project.name} type=${project.type}`,
|
|
604
|
+
` signals=${project.signals.length ? project.signals.join(', ') : 'none'}`,
|
|
605
|
+
options.apiKey
|
|
606
|
+
? ' recommendation: run interactive TUI or use --json status for live mode recommendation.'
|
|
607
|
+
: ' recommendation: export MARROW_API_KEY to get an account/fleet-backed mode recommendation.',
|
|
608
|
+
'',
|
|
463
609
|
'Recommended first commands:',
|
|
464
|
-
` npx @getmarrow/install run --agent ${options.agentId} --profile production --policy enforce -- codex`,
|
|
610
|
+
` npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile production --policy enforce -- codex`,
|
|
465
611
|
` npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy`,
|
|
466
612
|
` npx @getmarrow/install gate "deploy production worker after tests pass"`,
|
|
467
613
|
'',
|
|
@@ -470,6 +616,355 @@ function governPanel(options) {
|
|
|
470
616
|
return lines.join('\n');
|
|
471
617
|
}
|
|
472
618
|
|
|
619
|
+
function governModes() {
|
|
620
|
+
return [
|
|
621
|
+
{
|
|
622
|
+
id: 'passive',
|
|
623
|
+
label: 'Passive setup',
|
|
624
|
+
description: 'Install passive MCP/SDK/agent instructions, then run the installer self-test.',
|
|
625
|
+
policy: 'warn',
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
id: 'warn',
|
|
629
|
+
label: 'Governed pilot',
|
|
630
|
+
description: 'Wrap commands with Marrow, show gates, but do not block execution.',
|
|
631
|
+
policy: 'warn',
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
id: 'enforce',
|
|
635
|
+
label: 'Governed enforce',
|
|
636
|
+
description: 'Wrap risky commands and fail closed when Marrow blocks or requires owner approval.',
|
|
637
|
+
policy: 'enforce',
|
|
638
|
+
},
|
|
639
|
+
];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function selectedGovernanceMode(mode) {
|
|
643
|
+
if (!mode) return 'pilot';
|
|
644
|
+
if (mode.id === 'passive') return 'passive';
|
|
645
|
+
if (mode.id === 'enforce') return 'enforce';
|
|
646
|
+
return 'pilot';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function commandForSelection(state, options) {
|
|
650
|
+
const harness = state.harnesses[state.harnessIndex] || state.harnesses[0];
|
|
651
|
+
const mode = state.modes[state.modeIndex] || state.modes[0];
|
|
652
|
+
if (mode.id === 'passive') {
|
|
653
|
+
return 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install --yes';
|
|
654
|
+
}
|
|
655
|
+
const command = harness.command === '<your-agent-command>' ? '<your-command>' : harness.command;
|
|
656
|
+
const renderedCommand = command.split(/\s+/).filter(Boolean).map(shellQuote).join(' ');
|
|
657
|
+
return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function buildGovernState(options, cwd = process.cwd()) {
|
|
661
|
+
const harnesses = detectHarnesses(cwd);
|
|
662
|
+
const firstDetected = harnesses.findIndex((harness) => harness.detected);
|
|
663
|
+
const project = detectProjectSignals(cwd);
|
|
664
|
+
return {
|
|
665
|
+
cursor: 0,
|
|
666
|
+
harnesses,
|
|
667
|
+
harnessIndex: firstDetected >= 0 ? firstDetected : 0,
|
|
668
|
+
modes: governModes(),
|
|
669
|
+
modeIndex: 0,
|
|
670
|
+
project,
|
|
671
|
+
recommendation: null,
|
|
672
|
+
status: '',
|
|
673
|
+
lastResult: '',
|
|
674
|
+
confirmingSetup: false,
|
|
675
|
+
running: false,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function renderOptionBox(row, active) {
|
|
680
|
+
const contentWidth = 86;
|
|
681
|
+
const borderWidth = contentWidth + 2;
|
|
682
|
+
const marker = active ? '>' : ' ';
|
|
683
|
+
const borderChar = active ? '=' : '-';
|
|
684
|
+
const labelText = `[${displayText(row.label, 36)}]`;
|
|
685
|
+
const labelVisible = labelText.padEnd(38, ' ');
|
|
686
|
+
const label = `\x1b[47m\x1b[30m${labelText}\x1b[0m${' '.repeat(Math.max(0, 38 - labelText.length))}`;
|
|
687
|
+
const value = displayText(row.value, contentWidth - 39);
|
|
688
|
+
const firstLineVisible = `${labelVisible}${value}`;
|
|
689
|
+
const firstLine = `${label}${value}${' '.repeat(Math.max(0, contentWidth - firstLineVisible.length))}`;
|
|
690
|
+
const hint = displayText(row.hint, contentWidth);
|
|
691
|
+
return [
|
|
692
|
+
`${marker} +${borderChar.repeat(borderWidth)}+`,
|
|
693
|
+
`${marker} | ${firstLine} |`,
|
|
694
|
+
`${marker} | ${hint.padEnd(contentWidth, ' ')} |`,
|
|
695
|
+
`${marker} +${borderChar.repeat(borderWidth)}+`,
|
|
696
|
+
];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function renderGovernTui(state, options) {
|
|
700
|
+
const harness = state.harnesses[state.harnessIndex] || state.harnesses[0];
|
|
701
|
+
const mode = state.modes[state.modeIndex] || state.modes[0];
|
|
702
|
+
const rows = [
|
|
703
|
+
{
|
|
704
|
+
label: 'Harness',
|
|
705
|
+
value: `${harness.name}${harness.detected ? ' detected' : ' not detected'} (${harness.command})`,
|
|
706
|
+
hint: 'Left/right changes the harness.',
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
label: 'Mode',
|
|
710
|
+
value: state.recommendation?.recommended_mode
|
|
711
|
+
? `${mode.label} recommended: ${state.recommendation.recommended_mode}`
|
|
712
|
+
: mode.label,
|
|
713
|
+
hint: state.recommendation?.reasons?.length
|
|
714
|
+
? state.recommendation.reasons.slice(0, 2).join('; ')
|
|
715
|
+
: mode.description,
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
label: 'Run passive setup + self-test',
|
|
719
|
+
value: state.confirmingSetup ? 'Press Enter again to run installer --yes' : 'writes local config after confirmation',
|
|
720
|
+
hint: 'Uses the existing installer path and self-test.',
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
label: 'Check Marrow status',
|
|
724
|
+
value: options.apiKey ? 'ready' : 'needs MARROW_API_KEY',
|
|
725
|
+
hint: 'Calls GET /v1/agent/status.',
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
label: 'Test before-action gate',
|
|
729
|
+
value: options.apiKey ? 'ready' : 'needs MARROW_API_KEY',
|
|
730
|
+
hint: 'Calls POST /v1/agent/runtime for a deploy-like action.',
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
label: 'Show command and exit',
|
|
734
|
+
value: 'Print selected command',
|
|
735
|
+
hint: 'Prints the command for this selection.',
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
label: 'Exit',
|
|
739
|
+
value: 'Return to shell',
|
|
740
|
+
hint: 'Press Enter, q, Esc, or Ctrl+C to leave setup.',
|
|
741
|
+
},
|
|
742
|
+
];
|
|
743
|
+
const lines = [
|
|
744
|
+
'\x1b[2J\x1b[H',
|
|
745
|
+
'+------------------------------------------------------------+',
|
|
746
|
+
'| Marrow Governed Setup |',
|
|
747
|
+
'| Passive agent governance for day-one use |',
|
|
748
|
+
'+------------------------------------------------------------+',
|
|
749
|
+
'',
|
|
750
|
+
`Agent: ${displayText(options.agentId, 36)} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
|
|
751
|
+
`Project: ${displayText(state.project?.name || 'workspace', 36)} Signals: ${displayText((state.project?.signals || []).slice(0, 4).join(', ') || 'none', 52)}`,
|
|
752
|
+
'',
|
|
753
|
+
'Navigation: Up/Down move Left/Right change Enter select',
|
|
754
|
+
'Exit: q, Esc, or Ctrl+C',
|
|
755
|
+
'',
|
|
756
|
+
];
|
|
757
|
+
rows.forEach((row, index) => {
|
|
758
|
+
lines.push(...renderOptionBox(row, index === state.cursor), '');
|
|
759
|
+
});
|
|
760
|
+
lines.push('Recommended command:');
|
|
761
|
+
lines.push(` ${commandForSelection(state, options)}`);
|
|
762
|
+
if (state.status) {
|
|
763
|
+
lines.push('');
|
|
764
|
+
lines.push(`Status: ${displayText(state.status, 120)}`);
|
|
765
|
+
}
|
|
766
|
+
if (state.lastResult) {
|
|
767
|
+
lines.push('');
|
|
768
|
+
lines.push(displayText(state.lastResult, 500));
|
|
769
|
+
}
|
|
770
|
+
if (state.recommendation?.recommended_mode) {
|
|
771
|
+
lines.push('');
|
|
772
|
+
lines.push(`Recommended mode: ${state.recommendation.recommended_mode} confidence=${Math.round((state.recommendation.confidence || 0) * 100)}%`);
|
|
773
|
+
for (const reason of (state.recommendation.reasons || []).slice(0, 5)) lines.push(`- ${displayText(reason, 110)}`);
|
|
774
|
+
lines.push('Apply by selecting Mode or printing the command; Marrow does not auto-switch modes silently.');
|
|
775
|
+
}
|
|
776
|
+
return lines.join('\n');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function canUseInteractive(options, input = process.stdin, output = process.stdout) {
|
|
780
|
+
if (options.interactive === false) return false;
|
|
781
|
+
if (options.interactive === true) return Boolean(input.isTTY && output.isTTY);
|
|
782
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function waitForAnyKey(input = process.stdin) {
|
|
786
|
+
return new Promise((resolve) => {
|
|
787
|
+
const onKey = () => {
|
|
788
|
+
input.off('keypress', onKey);
|
|
789
|
+
resolve();
|
|
790
|
+
};
|
|
791
|
+
input.on('keypress', onKey);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function runSetupSelfTest(options, input, output) {
|
|
796
|
+
if (!options.apiKey) {
|
|
797
|
+
return 'MARROW_API_KEY is missing. Create a key in your Marrow account, export it, then rerun setup.';
|
|
798
|
+
}
|
|
799
|
+
const binPath = path.resolve(__dirname, '..', 'bin', 'marrow-install.js');
|
|
800
|
+
output.write('\x1b[2J\x1b[HRunning Marrow passive setup and self-test...\n\n');
|
|
801
|
+
if (input.setRawMode) input.setRawMode(false);
|
|
802
|
+
const result = await runChild([process.execPath, binPath, '--yes'], {
|
|
803
|
+
...process.env,
|
|
804
|
+
MARROW_API_KEY: options.apiKey,
|
|
805
|
+
MARROW_BASE_URL: options.baseUrl,
|
|
806
|
+
MARROW_FLEET_AGENT_ID: options.agentId,
|
|
807
|
+
});
|
|
808
|
+
output.write('\nPress any key to return to Marrow Governed Setup.');
|
|
809
|
+
if (input.setRawMode) input.setRawMode(true);
|
|
810
|
+
await waitForAnyKey(input);
|
|
811
|
+
return result.exitCode === 0
|
|
812
|
+
? 'Marrow passive setup completed. Self-test output above is the source of truth.'
|
|
813
|
+
: `Marrow passive setup exited with code ${result.exitCode}. Review the output above.`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function runStatusCheck(options) {
|
|
817
|
+
if (!options.apiKey) return 'MARROW_API_KEY is missing. Status check skipped.';
|
|
818
|
+
const status = await statusOnly({ options });
|
|
819
|
+
const coverage = status.capture_coverage || {};
|
|
820
|
+
const closure = status.auto_outcome_closure || {};
|
|
821
|
+
const active = status.enabled ?? status.active ?? true;
|
|
822
|
+
const missed = Array.isArray(status.missed_hooks) && status.missed_hooks.length
|
|
823
|
+
? ` missed hooks: ${status.missed_hooks.join(', ')}`
|
|
824
|
+
: '';
|
|
825
|
+
return `Marrow status: ${active ? 'active' : 'inactive'}; coverage=${coverage.status || coverage.summary || 'reported'}; outcomes=${closure.status || closure.summary || 'reported'}${missed}`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function runGateCheck(options) {
|
|
829
|
+
if (!options.apiKey) return 'MARROW_API_KEY is missing. Gate check skipped.';
|
|
830
|
+
const runtime = await preflightRuntime(options, 'deploy production worker after tests pass', 'deploy', 'wrangler deploy');
|
|
831
|
+
const decision = gateDecision(runtime);
|
|
832
|
+
const proof = decision.proofPack?.required
|
|
833
|
+
? ` Proof required${decision.proofPack.missing?.length ? `; missing ${decision.proofPack.missing.join(', ')}` : ''}.`
|
|
834
|
+
: '';
|
|
835
|
+
return `Gate: ${decision.decision}${decision.required ? ' required' : ''}.${decision.exactNextAction ? ` Next: ${decision.exactNextAction}` : ''}${proof}`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function runGovernInteractive(options, input = process.stdin, output = process.stdout) {
|
|
839
|
+
if (!canUseInteractive(options, input, output)) {
|
|
840
|
+
output.write(`${governPanel(options)}\n`);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const state = buildGovernState(options);
|
|
845
|
+
try {
|
|
846
|
+
const recommendation = await recommendGovernanceMode(options, state.project);
|
|
847
|
+
if (recommendation?.recommended_mode) {
|
|
848
|
+
state.recommendation = recommendation;
|
|
849
|
+
const recommendationModeId = recommendation.recommended_mode === 'pilot' ? 'warn' : recommendation.recommended_mode;
|
|
850
|
+
const modeIndex = state.modes.findIndex((mode) => mode.id === recommendationModeId);
|
|
851
|
+
if (modeIndex >= 0) state.modeIndex = modeIndex;
|
|
852
|
+
state.status = 'Governance recommendation loaded. Review before applying.';
|
|
853
|
+
} else if (recommendation?.skipped) {
|
|
854
|
+
state.status = `${recommendation.reason}. ${recommendation.exact_fix}`;
|
|
855
|
+
}
|
|
856
|
+
} catch (error) {
|
|
857
|
+
state.status = `Recommendation unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
858
|
+
}
|
|
859
|
+
readline.emitKeypressEvents(input);
|
|
860
|
+
input.setRawMode(true);
|
|
861
|
+
output.write('\x1b[?25l');
|
|
862
|
+
|
|
863
|
+
let cleaned = false;
|
|
864
|
+
const cleanup = () => {
|
|
865
|
+
if (cleaned) return;
|
|
866
|
+
cleaned = true;
|
|
867
|
+
if (input.setRawMode) input.setRawMode(false);
|
|
868
|
+
output.write('\x1b[?25h');
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const render = () => {
|
|
872
|
+
output.write(renderGovernTui(state, options));
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
render();
|
|
876
|
+
let keyHandler;
|
|
877
|
+
try {
|
|
878
|
+
await new Promise((resolve) => {
|
|
879
|
+
keyHandler = async (str, key = {}) => {
|
|
880
|
+
if (state.running) return;
|
|
881
|
+
if (key.ctrl && key.name === 'c') {
|
|
882
|
+
cleanup();
|
|
883
|
+
resolve();
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (key.name === 'q' || key.name === 'escape' || str === 'q') {
|
|
887
|
+
cleanup();
|
|
888
|
+
resolve();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (key.name === 'up') {
|
|
892
|
+
state.cursor = (state.cursor + GOVERN_TUI_ROW_COUNT - 1) % GOVERN_TUI_ROW_COUNT;
|
|
893
|
+
state.confirmingSetup = false;
|
|
894
|
+
render();
|
|
895
|
+
} else if (key.name === 'down') {
|
|
896
|
+
state.cursor = (state.cursor + 1) % GOVERN_TUI_ROW_COUNT;
|
|
897
|
+
state.confirmingSetup = false;
|
|
898
|
+
render();
|
|
899
|
+
} else if (key.name === 'left' || key.name === 'right') {
|
|
900
|
+
const direction = key.name === 'right' ? 1 : -1;
|
|
901
|
+
if (state.cursor === 0) state.harnessIndex = (state.harnessIndex + direction + state.harnesses.length) % state.harnesses.length;
|
|
902
|
+
if (state.cursor === 1) state.modeIndex = (state.modeIndex + direction + state.modes.length) % state.modes.length;
|
|
903
|
+
state.confirmingSetup = false;
|
|
904
|
+
render();
|
|
905
|
+
} else if (key.name === 'return') {
|
|
906
|
+
state.running = true;
|
|
907
|
+
try {
|
|
908
|
+
if (state.cursor === 0) {
|
|
909
|
+
state.harnessIndex = (state.harnessIndex + 1) % state.harnesses.length;
|
|
910
|
+
state.status = 'Harness selected.';
|
|
911
|
+
state.confirmingSetup = false;
|
|
912
|
+
} else if (state.cursor === 1) {
|
|
913
|
+
state.modeIndex = (state.modeIndex + 1) % state.modes.length;
|
|
914
|
+
state.status = 'Mode selected.';
|
|
915
|
+
state.confirmingSetup = false;
|
|
916
|
+
} else if (state.cursor === 2) {
|
|
917
|
+
if (!state.confirmingSetup) {
|
|
918
|
+
state.confirmingSetup = true;
|
|
919
|
+
state.status = 'Confirm passive setup.';
|
|
920
|
+
} else {
|
|
921
|
+
state.lastResult = await runSetupSelfTest(options, input, output);
|
|
922
|
+
state.status = 'Passive setup attempted.';
|
|
923
|
+
state.confirmingSetup = false;
|
|
924
|
+
}
|
|
925
|
+
} else if (state.cursor === 3) {
|
|
926
|
+
state.status = 'Checking Marrow status...';
|
|
927
|
+
render();
|
|
928
|
+
state.lastResult = await runStatusCheck(options);
|
|
929
|
+
state.status = 'Status check complete.';
|
|
930
|
+
state.confirmingSetup = false;
|
|
931
|
+
} else if (state.cursor === 4) {
|
|
932
|
+
state.status = 'Testing before-action gate...';
|
|
933
|
+
render();
|
|
934
|
+
state.lastResult = await runGateCheck(options);
|
|
935
|
+
state.status = 'Gate check complete.';
|
|
936
|
+
state.confirmingSetup = false;
|
|
937
|
+
} else if (state.cursor === 5) {
|
|
938
|
+
await recordGovernanceModeSelection(options, state).catch(() => null);
|
|
939
|
+
cleanup();
|
|
940
|
+
output.write(`\n${commandForSelection(state, options)}\n`);
|
|
941
|
+
resolve();
|
|
942
|
+
return;
|
|
943
|
+
} else if (state.cursor === 6) {
|
|
944
|
+
cleanup();
|
|
945
|
+
resolve();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
} catch (error) {
|
|
949
|
+
state.lastResult = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
950
|
+
state.status = 'Action failed.';
|
|
951
|
+
state.confirmingSetup = false;
|
|
952
|
+
} finally {
|
|
953
|
+
state.running = false;
|
|
954
|
+
if (!cleaned) render();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
input.on('keypress', keyHandler);
|
|
959
|
+
});
|
|
960
|
+
} finally {
|
|
961
|
+
if (keyHandler) input.off('keypress', keyHandler);
|
|
962
|
+
if (input.pause) input.pause();
|
|
963
|
+
cleanup();
|
|
964
|
+
output.write('\n');
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
473
968
|
async function runCli(argv) {
|
|
474
969
|
const parsed = parseArgs(argv);
|
|
475
970
|
if (parsed.command === 'help') {
|
|
@@ -487,7 +982,7 @@ async function runCli(argv) {
|
|
|
487
982
|
else if (parsed.command === 'proof') result = await proofOnly(parsed);
|
|
488
983
|
else if (parsed.command === 'status') result = await statusOnly(parsed);
|
|
489
984
|
else if (parsed.command === 'govern') {
|
|
490
|
-
|
|
985
|
+
await runGovernInteractive(parsed.options);
|
|
491
986
|
return;
|
|
492
987
|
}
|
|
493
988
|
|
|
@@ -504,12 +999,22 @@ module.exports = {
|
|
|
504
999
|
redactedCommand,
|
|
505
1000
|
inferType,
|
|
506
1001
|
inferSurfaces,
|
|
1002
|
+
commandForSelection,
|
|
1003
|
+
buildGovernState,
|
|
1004
|
+
detectProjectSignals,
|
|
1005
|
+
recommendGovernanceMode,
|
|
1006
|
+
recordGovernanceModeSelection,
|
|
507
1007
|
gateDecision,
|
|
508
1008
|
shouldBlock,
|
|
509
1009
|
governPanel,
|
|
1010
|
+
renderGovernTui,
|
|
1011
|
+
canUseInteractive,
|
|
510
1012
|
runGoverned,
|
|
511
1013
|
gateOnly,
|
|
512
1014
|
proofOnly,
|
|
513
1015
|
statusOnly,
|
|
1016
|
+
runStatusCheck,
|
|
1017
|
+
runGateCheck,
|
|
1018
|
+
runGovernInteractive,
|
|
514
1019
|
runCli,
|
|
515
1020
|
};
|