flowviant 0.96.0 → 0.99.0

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.
Files changed (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +174 -33
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +377 -45
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +146 -12
  12. package/bin/lib/fleet.mjs +125 -30
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +162 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +460 -23
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +354 -32
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +152 -11
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1261 -4476
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -0,0 +1,42 @@
1
+ /**
2
+ * PRINTABLE(): the CLI's own belt against a server-supplied string it is
3
+ * about to write to a TTY (2026-09-24, the audit's A3 CROSS 8a).
4
+ *
5
+ * `safeName` (credentials.mjs) already stripped C0/DEL/C1 for the same reason
6
+ * the server's own `parseBoxName`/`parseCheckoutPath` did — an escape
7
+ * sequence in a project or box name can clear the screen or, via OSC 52,
8
+ * rewrite the terminal's clipboard — but it stopped at C1 and never scrubbed
9
+ * the BIDI/format controls (U+200E/U+200F, U+202A–U+202E, U+2066–U+2069) that
10
+ * reorder a line to hide or forge part of it: a box named
11
+ * `evil‮txt.crt⁦` reads backwards on a bidi-aware terminal, so
12
+ * "evil" can visually swap places with an extension that was never there.
13
+ *
14
+ * THIS IS A LEAF MODULE ON PURPOSE. `machines.mjs` and `fleet.mjs` both need
15
+ * it, `machines.mjs` deliberately imports nothing from `fleet.mjs`'s world
16
+ * (its own header explains why: it runs before the auth gate, like `stop` and
17
+ * `projects`, and must not pull in the daemon's whole CLI/worktree
18
+ * machinery), and `credentials.mjs` is where `safeName` already lived. A leaf
19
+ * with zero imports is the one shape both can depend on without adding an
20
+ * edge neither wants.
21
+ *
22
+ * THE CHARACTER CLASS IS THE SERVER'S OWN (`stripDisplayControl`,
23
+ * machineBoxes.ts, 2026-09-24) — copied rather than re-derived, because the
24
+ * point of a BELT is that the two ends agree about what counts as printable
25
+ * without either one trusting the other to have applied it. The server scrub
26
+ * is the primary fix (it is what a stale stored row, upserted before this
27
+ * belt existed, is read back through); this is defence in depth, because the
28
+ * terminal is where the harm happens and the CLI should not trust that every
29
+ * string on the wire already passed through it — including a project's own
30
+ * `name`, which reaches every terminal that connects a machine (`login`, the
31
+ * banner, the picker, `projects`, `machines`) and had no scrub at all before
32
+ * `safeName`.
33
+ */
34
+ // eslint-disable-next-line no-control-regex
35
+ const DISPLAY_CONTROL = /[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
36
+
37
+ /** Strip the class above. Non-strings pass through unchanged — this is a
38
+ * scrub, not a coercion, and a caller that hands it `undefined` should get
39
+ * `undefined` back rather than the string `"undefined"`. */
40
+ export function printable(raw) {
41
+ return typeof raw === 'string' ? raw.replace(DISPLAY_CONTROL, '') : raw;
42
+ }
@@ -17,6 +17,8 @@
17
17
  * does not become a file nobody can read.
18
18
  */
19
19
 
20
+ import { execFileSync } from 'node:child_process';
21
+ import { platform, uptime } from 'node:os';
20
22
  import {
21
23
  closeSync,
22
24
  mkdirSync,
@@ -95,6 +97,69 @@ export function writeRegistry(dir, path, list) {
95
97
  }
96
98
  }
97
99
 
100
+ /**
101
+ * WHICH BOOT THIS IS — so a pid or process-group id read back off disk is only
102
+ * ever believed in the boot that wrote it.
103
+ *
104
+ * A registry outlives a reboot and pids do not: after one, the number a file
105
+ * remembers belongs to whatever the kernel handed it to next — another user's
106
+ * service, the operator's shell — and signal-0 says "alive" about a stranger.
107
+ * Linux names the boot outright (`boot_id`); elsewhere the boot INSTANT stands
108
+ * in, derived from the uptime and compared with slack, because two reads a
109
+ * second apart disagree by the rounding.
110
+ */
111
+ const BOOT_SLACK_S = 120;
112
+ export function bootMark() {
113
+ try {
114
+ const id = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
115
+ if (id) return id;
116
+ } catch {
117
+ /* not Linux, or /proc is not mounted */
118
+ }
119
+ return `bt:${Math.round(Date.now() / 1000 - uptime())}`;
120
+ }
121
+ /** Was `mark` written during this boot? An absent mark is NOT this boot —
122
+ * an entry that cannot say when it was written cannot be trusted across one. */
123
+ export function sameBoot(mark, now = bootMark()) {
124
+ if (typeof mark !== 'string' || !mark) return false;
125
+ if (mark.startsWith('bt:') && now.startsWith('bt:'))
126
+ return Math.abs(Number(mark.slice(3)) - Number(now.slice(3))) <= BOOT_SLACK_S;
127
+ return mark === now;
128
+ }
129
+
130
+ /**
131
+ * WHEN A PROCESS STARTED, as an opaque token — so a lock that remembers a pid
132
+ * can tell the process it meant from one that inherited the number.
133
+ *
134
+ * Linux: field 22 of `/proc/<pid>/stat` (start time in clock ticks since boot),
135
+ * read after the LAST `)` because the comm field may itself contain one.
136
+ * macOS: `ps -o lstart=`. Null when it cannot be read, which callers treat as
137
+ * "cannot tell" — never as a mismatch.
138
+ */
139
+ export function processStartTime(pid) {
140
+ if (!Number.isInteger(pid) || pid <= 0) return null;
141
+ try {
142
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
143
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
144
+ // `rest[0]` is field 3 (state), so field 22 is rest[19].
145
+ const st = rest[19];
146
+ return st && /^\d+$/.test(st) ? `${bootMark()}:${st}` : null;
147
+ } catch {
148
+ /* not Linux, or the pid is gone */
149
+ }
150
+ if (platform() !== 'darwin') return null;
151
+ try {
152
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
153
+ encoding: 'utf8',
154
+ stdio: ['ignore', 'pipe', 'ignore'],
155
+ timeout: 5_000,
156
+ }).trim();
157
+ return out || null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
98
163
  /** Signal-0 liveness. EPERM means alive and not ours, which is still alive. */
99
164
  export function processAlive(pid) {
100
165
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -47,7 +47,13 @@ import { execFileSync } from 'node:child_process';
47
47
  import { readFileSync, readdirSync } from 'node:fs';
48
48
  import { platform } from 'node:os';
49
49
 
50
- /** A box with more processes than this is not one we walk per sweep. */
50
+ /** Matched rows collected per scan before the walk stops early — a bound on
51
+ * the WORK done for one pathological group, never on which part of /proc is
52
+ * looked at. It used to cut the pid LIST before filtering, and readdir answers
53
+ * in string order, so a busy box kept '1', '10', '100'… and dropped a tab's
54
+ * watcher at pid 912345 while `liveGroups` (which has no cap) still called its
55
+ * group alive: the tab read "looked, found none" and a Stop on it answered
56
+ * not_found. */
51
57
  const MAX_PIDS = 4000;
52
58
  /** Rows reported per session. A watcher, a dev server and its child is three;
53
59
  * twenty is somebody's compose stack and the extra rows say nothing. */
@@ -113,25 +119,32 @@ function cmdlineOf(pid) {
113
119
  }
114
120
  }
115
121
 
116
- function scanLinux(pgids) {
122
+ /** Exported with its readers injectable ONLY so the scan-order property can be
123
+ * pinned without a box that runs four thousand processes. Nothing in the
124
+ * daemon passes `io`. */
125
+ export function scanLinux(pgids, io = {}) {
126
+ const list = io.list ?? (() => readdirSync('/proc'));
127
+ const pgrpFor = io.pgrpOf ?? pgrpOf;
128
+ const cmdFor = io.cmdlineOf ?? cmdlineOf;
129
+ const rssFor = io.rssBytes ?? rssBytes;
117
130
  let pids;
118
131
  try {
119
- pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
132
+ pids = list().filter((d) => /^\d+$/.test(d));
120
133
  } catch {
121
134
  return [];
122
135
  }
123
- if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
124
136
 
125
137
  const out = [];
126
138
  for (const raw of pids) {
139
+ if (out.length >= MAX_PIDS) break;
127
140
  const pid = Number(raw);
128
- const pgrp = pgrpOf(raw);
141
+ const pgrp = pgrpFor(raw);
129
142
  if (pgrp === null || !pgids.has(pgrp)) continue;
130
143
  // The CLI itself — its argv is the prompt. See the header.
131
144
  if (pid === pgrp) continue;
132
- const cmd = cmdlineOf(raw);
145
+ const cmd = cmdFor(raw);
133
146
  if (!cmd) continue;
134
- const rss = rssBytes(raw);
147
+ const rss = rssFor(raw);
135
148
  out.push({ pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) });
136
149
  }
137
150
  return out;
@@ -0,0 +1,248 @@
1
+ /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { delimiter, isAbsolute, join, resolve } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
6
+
7
+ const MAX_TOOLS = 40;
8
+ const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
9
+ const MAX_INSTRUCTION_FILES = 80;
10
+ const MAX_INSTRUCTION_BYTES = 512 * 1024;
11
+ const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
12
+ const toml = (value) => JSON.stringify(String(value));
13
+ const show = (root, ref, path) => {
14
+ try {
15
+ return execFileSync('git', ['show', `${ref}:${path}`], {
16
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
17
+ });
18
+ } catch { return null; }
19
+ };
20
+ const showBytes = (root, ref, path) => {
21
+ try {
22
+ return execFileSync('git', ['show', `${ref}:${path}`], {
23
+ cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
24
+ });
25
+ } catch { return null; }
26
+ };
27
+
28
+ function onPath(command, env) {
29
+ if (isAbsolute(command)) {
30
+ try { accessSync(command, constants.X_OK); return true; } catch { return false; }
31
+ }
32
+ if (command.includes('/') || command.includes('\\')) return false;
33
+ return (env.PATH ?? '').split(delimiter).some((dir) => {
34
+ try { accessSync(join(dir, command), constants.X_OK); return true; } catch { return false; }
35
+ });
36
+ }
37
+
38
+ function referenced(value) {
39
+ return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
40
+ }
41
+
42
+ /** Pure mapping used before a future tool-attach gesture is offered. */
43
+ export function runnerToolCapabilities(runner) {
44
+ return ({ claude: { mcp: true, skills: true }, codex: { mcp: true, skills: true }, antigravity: { mcp: false, skills: false } })[runner]
45
+ ?? { mcp: false, skills: false };
46
+ }
47
+
48
+ export function readBaseTools(root, ref, env = process.env) {
49
+ // Resolve once: a base branch may advance while the turn is being prepared.
50
+ const commit = execFileSync('git', ['rev-parse', '--verify', ref], {
51
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
52
+ }).trim();
53
+ const raw = show(root, commit, '.mcp.json');
54
+ let servers = {};
55
+ if (raw != null) {
56
+ try { servers = JSON.parse(raw).mcpServers ?? {}; } catch { servers = {}; }
57
+ }
58
+ const entries = Object.entries(servers).filter(([name, value]) => nameOk(name) && value && typeof value === 'object').slice(0, MAX_TOOLS);
59
+ const skills = [];
60
+ const skillFiles = [];
61
+ try {
62
+ const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, '--', '.claude/skills'], {
63
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
64
+ }).split('\n');
65
+ let bytes = 0;
66
+ for (const path of paths) {
67
+ if (!/^\.claude\/skills\/[A-Za-z0-9_-]+\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(path)) continue;
68
+ if (skillFiles.length >= 200 || bytes >= 4 * 1024 * 1024) break;
69
+ const body = showBytes(root, commit, path);
70
+ if (body == null) continue;
71
+ bytes += body.length;
72
+ if (bytes > 4 * 1024 * 1024) break;
73
+ skillFiles.push({ path, body });
74
+ if (path.endsWith('/SKILL.md') && path.split('/').length === 4 && skills.length < MAX_TOOLS) skills.push({ path, body: body.toString('utf8') });
75
+ }
76
+ } catch { /* no skills at this ref */ }
77
+ const tools = entries.map(([name, config]) => {
78
+ const vars = new Set();
79
+ for (const value of [config.command, config.url, ...(Array.isArray(config.args) ? config.args : []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {})]) {
80
+ for (const key of referenced(value)) vars.add(key);
81
+ }
82
+ const missing = [...vars].filter((key) => !env[key]).sort();
83
+ const commandMissing = typeof config.command === 'string' && !onPath(expand(config.command, env), env);
84
+ const unsupported = typeof config.command !== 'string' && typeof config.url !== 'string';
85
+ const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing ? `command ${config.command} not on PATH` : unsupported ? 'MCP configuration unsupported' : null;
86
+ return { name, config, state: reason ? 'missing' : 'ready', reason };
87
+ });
88
+ // Nested instructions are normally discovered when a CLI enters that path.
89
+ // Their worktree copies must not regain authority through that discovery.
90
+ const instructions = [];
91
+ const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit], {
92
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
93
+ }).split('\n').filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
94
+ if (paths.length > MAX_INSTRUCTION_FILES) throw new Error('Base branch has too many agent instruction files');
95
+ let instructionBytes = 0;
96
+ for (const path of paths) {
97
+ const body = show(root, commit, path);
98
+ if (body == null) throw new Error(`Cannot read base instruction ${path}`);
99
+ instructionBytes += Buffer.byteLength(body);
100
+ if (instructionBytes > MAX_INSTRUCTION_BYTES) throw new Error('Base branch agent instructions exceed the turn limit');
101
+ instructions.push({ path, body });
102
+ }
103
+ return { tools, skills, skillFiles, instructions };
104
+ }
105
+
106
+ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
107
+
108
+ /** Keep personal Codex files, but do not copy its project trust grants. An
109
+ * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
110
+ function isolatedCodexHome(dir, env, worktree) {
111
+ const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
112
+ const dest = join(dir, 'codex-home');
113
+ mkdirSync(dest, { mode: 0o700 });
114
+ if (existsSync(source)) {
115
+ for (const name of readdirSync(source)) {
116
+ if (name === 'config.toml') continue;
117
+ symlinkSync(join(source, name), join(dest, name));
118
+ }
119
+ }
120
+ const configPath = join(source, 'config.toml');
121
+ const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
122
+ const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
123
+ const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
124
+ // These sections alone grant a checkout permission to supply project
125
+ // settings. Keep every other personal setting, skill and session store.
126
+ const lines = sourceConfig.split('\n');
127
+ let inProject = false;
128
+ let config = lines.filter((line) => {
129
+ const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
130
+ if (header) {
131
+ const section = header[1] ?? header[2];
132
+ inProject = section === 'projects' || section.startsWith('projects.');
133
+ }
134
+ return !inProject;
135
+ }).join('\n');
136
+ if (worktree) {
137
+ // Codex discovers these even in an untrusted project. Disable exactly the
138
+ // worktree skill paths; the person's ~/.codex/skills and config remain.
139
+ const worktreeSkills = [];
140
+ for (const sourceDir of ['.agents/skills', '.codex/skills', '.claude/skills']) {
141
+ const skillsDir = join(worktree, sourceDir);
142
+ if (!existsSync(skillsDir)) continue;
143
+ for (const name of readdirSync(skillsDir)) {
144
+ const skill = join(skillsDir, name, 'SKILL.md');
145
+ if (existsSync(skill)) worktreeSkills.push(skill);
146
+ }
147
+ }
148
+ if (worktreeSkills.length && /^\s*skills\.config\s*=/m.test(config))
149
+ throw new Error('Cannot isolate Codex project skills with inline skills.config');
150
+ for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
151
+ }
152
+ writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
153
+ return { path: dest, personalDeveloperInstructions };
154
+ }
155
+
156
+ /** Temporary per-turn files are removed after the last retry. No personal CLI config is written. */
157
+ export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null) {
158
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
159
+ const caps = runnerToolCapabilities(runner);
160
+ const usable = snapshot.tools.filter((t) => t.state === 'ready' && caps.mcp && !(runner === 'codex' && t.config.type === 'sse'));
161
+ const mcpServers = {};
162
+ const codexArgs = [];
163
+ for (const { name, config } of usable) {
164
+ if (typeof config.command === 'string') {
165
+ const command = expand(config.command, env);
166
+ const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
167
+ const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
168
+ mcpServers[name] = { command, args, env: vars };
169
+ codexArgs.push('-c', `mcp_servers.${name}.command=${toml(command)}`);
170
+ codexArgs.push('-c', `mcp_servers.${name}.args=${JSON.stringify(args)}`);
171
+ for (const [key, value] of Object.entries(vars)) {
172
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) codexArgs.push('-c', `mcp_servers.${name}.env.${key}=${toml(value)}`);
173
+ }
174
+ } else if (typeof config.url === 'string') {
175
+ const url = expand(config.url, env);
176
+ const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v, env)]));
177
+ mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
178
+ codexArgs.push('-c', `mcp_servers.${name}.url=${toml(url)}`);
179
+ if (Object.keys(headers).length) {
180
+ const inline = Object.entries(headers).map(([key, value]) => `${toml(key)}=${toml(value)}`).join(',');
181
+ codexArgs.push('-c', `mcp_servers.${name}.http_headers={${inline}}`);
182
+ }
183
+ }
184
+ }
185
+ const mcpPath = join(dir, 'mcp.json');
186
+ writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
187
+ const pointers = [];
188
+ for (const { path, body } of [...snapshot.instructions, ...(caps.skills ? snapshot.skillFiles : [])]) {
189
+ const dest = join(dir, path);
190
+ mkdirSync(join(dest, '..'), { recursive: true });
191
+ writeFileSync(dest, body, { mode: 0o600 });
192
+ if (!path.startsWith('.claude/skills/') || path.endsWith('/SKILL.md')) pointers.push(`${path}: ${dest}`);
193
+ }
194
+ const baseInstructions = snapshot.instructions.map(({ path, body }) => `Base ${path}:\n${body}`).join('\n\n');
195
+ const instructions = [baseInstructions,
196
+ pointers.length ? `Base-branch instruction and skill copies (read the relevant nested files and skill files before acting):\n${pointers.join('\n')}` : '',
197
+ ].filter(Boolean).join('\n\n');
198
+ const pluginDir = runner === 'claude' && snapshot.skillFiles.length ? join(dir, 'reviewed-plugin') : null;
199
+ if (pluginDir) {
200
+ mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
201
+ writeFileSync(join(pluginDir, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'flowviant-reviewed', version: '1.0.0', description: 'Base-branch agent skills' }), { mode: 0o600 });
202
+ for (const { path, body } of snapshot.skillFiles) {
203
+ const dest = join(pluginDir, path.replace(/^\.claude\//, ''));
204
+ mkdirSync(join(dest, '..'), { recursive: true });
205
+ writeFileSync(dest, body, { mode: 0o600 });
206
+ }
207
+ }
208
+ let codexHome = null;
209
+ let personalDeveloperInstructions = false;
210
+ try {
211
+ if (runner === 'codex') {
212
+ const isolated = isolatedCodexHome(dir, env, worktree);
213
+ codexHome = isolated.path;
214
+ personalDeveloperInstructions = isolated.personalDeveloperInstructions;
215
+ }
216
+ } catch (error) {
217
+ rmSync(dir, { recursive: true, force: true });
218
+ throw error;
219
+ }
220
+ return {
221
+ mcpPath,
222
+ codexArgs,
223
+ instructions,
224
+ pluginDir,
225
+ codexHome,
226
+ personalDeveloperInstructions,
227
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
228
+ };
229
+ }
230
+
231
+ export function toolReadout(snapshot, runner) {
232
+ const caps = runnerToolCapabilities(runner);
233
+ const short = (reason) => reason == null ? null : String(reason).slice(0, 200);
234
+ const mcp = snapshot.tools.map(({ name, config, state, reason }) => {
235
+ const unsupported = runner === 'codex' && config.type === 'sse';
236
+ return {
237
+ name, kind: 'mcp',
238
+ state: caps.mcp && !unsupported ? state : 'missing',
239
+ reason: short(!caps.mcp ? runner === 'none' ? 'no installed CLI can take MCP' : `${runner} cannot take MCP`
240
+ : unsupported ? 'Codex cannot take SSE MCP' : reason),
241
+ };
242
+ });
243
+ const instructions = snapshot.skills.map(({ path }) => ({
244
+ name: path.split('/')[2], kind: 'instruction', state: caps.skills ? 'ready' : 'missing',
245
+ reason: short(caps.skills ? null : runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`),
246
+ }));
247
+ return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
248
+ }