flowviant 0.125.29 → 0.125.31

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 (72) hide show
  1. package/README.md +364 -0
  2. package/bin/cli.mjs +23 -0
  3. package/bin/lib/agentPlan.mjs +8 -0
  4. package/bin/lib/agentQueueContext.mjs +6 -0
  5. package/bin/lib/agentSplit.mjs +90 -0
  6. package/bin/lib/architectureContract.mjs +57 -0
  7. package/bin/lib/architectureReport.mjs +38 -0
  8. package/bin/lib/artifactContracts.mjs +21 -2
  9. package/bin/lib/claudeInput.mjs +1 -1
  10. package/bin/lib/claudeSubagents.mjs +46 -6
  11. package/bin/lib/claudeWorkflows.mjs +94 -0
  12. package/bin/lib/cliLimit.mjs +5 -0
  13. package/bin/lib/codexInput.mjs +191 -0
  14. package/bin/lib/codexSubagentEvents.mjs +3 -1
  15. package/bin/lib/codexSubagents.mjs +34 -9
  16. package/bin/lib/conversationOutput.mjs +14 -0
  17. package/bin/lib/crossCliChild.mjs +327 -0
  18. package/bin/lib/crossCliLaunch.mjs +92 -0
  19. package/bin/lib/crossCliPosture.mjs +55 -0
  20. package/bin/lib/crossCliResearch.mjs +19 -0
  21. package/bin/lib/environmentProcess.mjs +44 -0
  22. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  23. package/bin/lib/fleet.mjs +2 -0
  24. package/bin/lib/linuxDesktopBridge.mjs +62 -0
  25. package/bin/lib/managedBrowser.mjs +50 -10
  26. package/bin/lib/mergeBatch.mjs +1 -1
  27. package/bin/lib/nativeAgentTools.mjs +12 -5
  28. package/bin/lib/noWindowEnv.mjs +4 -2
  29. package/bin/lib/orchestrator.mjs +24 -7
  30. package/bin/lib/orchestratorClient.mjs +8 -2
  31. package/bin/lib/orchestratorPrompt.mjs +13 -5
  32. package/bin/lib/orchestratorProtocol.mjs +18 -0
  33. package/bin/lib/outsideCli.mjs +124 -0
  34. package/bin/lib/projectToolRuntimes.mjs +4 -2
  35. package/bin/lib/projectTools.mjs +2 -2
  36. package/bin/lib/prompts.mjs +7 -5
  37. package/bin/lib/repositoryPlaces.mjs +1 -1
  38. package/bin/lib/repositorySetup.mjs +15 -24
  39. package/bin/lib/reviewBridge.mjs +110 -0
  40. package/bin/lib/reviewDatabase.mjs +112 -0
  41. package/bin/lib/reviewDesktop.mjs +153 -0
  42. package/bin/lib/reviewEnvironment.mjs +286 -0
  43. package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
  44. package/bin/lib/reviewEnvironmentMain.mjs +38 -0
  45. package/bin/lib/reviewImage.mjs +58 -0
  46. package/bin/lib/reviewMcpClient.mjs +69 -0
  47. package/bin/lib/reviewProcess.mjs +51 -0
  48. package/bin/lib/reviewTargets.mjs +50 -0
  49. package/bin/lib/robloxReview.mjs +234 -0
  50. package/bin/lib/robloxStudioBridge.mjs +68 -0
  51. package/bin/lib/runTurn.mjs +30 -8
  52. package/bin/lib/runtimeClaude.mjs +7 -3
  53. package/bin/lib/runtimeCodex.mjs +12 -6
  54. package/bin/lib/runtimeLimits.mjs +59 -1
  55. package/bin/lib/runtimeReview.mjs +185 -0
  56. package/bin/lib/splitGate.mjs +115 -0
  57. package/bin/lib/subagentBounds.mjs +26 -0
  58. package/bin/lib/subagentLedger.mjs +110 -0
  59. package/bin/lib/trace.mjs +134 -6
  60. package/bin/lib/turnDeclaration.mjs +4 -4
  61. package/bin/lib/turnGaps.mjs +6 -2
  62. package/bin/lib/turnSubagents.mjs +23 -9
  63. package/bin/lib/uiReview.mjs +5 -2
  64. package/bin/lib/wikiDraft.mjs +9 -0
  65. package/bin/lib/work.mjs +22 -1
  66. package/bin/lib/workAgentCheck.mjs +24 -1
  67. package/bin/lib/workAgentResume.mjs +11 -2
  68. package/bin/lib/workAgentTools.mjs +6 -4
  69. package/bin/lib/workAgentTurnExecution.mjs +77 -9
  70. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  71. package/bin/lib/workAgentTurnReports.mjs +5 -2
  72. package/package.json +1 -1
@@ -0,0 +1,286 @@
1
+ /** Durable per-worktree test environments, independent of the model/runner. */
2
+ import { appendFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { git } from './git.mjs';
6
+ import { environmentCommand } from './environmentProcess.mjs';
7
+ import { environmentHash, readEnvironmentLocal, readEnvironmentRecipe, repositoryPath, validateEnvironmentConnectors } from './reviewEnvironmentConfig.mjs';
8
+ import { allocateReviewDatabase, environmentCredentialNames, newDatabaseReceipt, releaseReviewDatabase } from './reviewDatabase.mjs';
9
+ import { learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
10
+ import { heldToolArgv } from './heldTool.mjs';
11
+ import { workLanesStopped } from './standDownGate.mjs';
12
+
13
+ const quiet = { stdio: ['ignore', 'pipe', 'ignore'] };
14
+ const ID = /^[0-9a-f]{32}$/;
15
+ function ownDirectory(path) {
16
+ mkdirSync(path, { recursive: true, mode: 0o700 });
17
+ if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) throw new Error('The local environment store must be a real directory.');
18
+ chmodSync(path, 0o700);
19
+ return path;
20
+ }
21
+ function storeFor(root) {
22
+ const common = realpathSync(resolve(root, git(['rev-parse', '--git-common-dir'], root, quiet)));
23
+ return ownDirectory(join(common, 'flowviant-environments'));
24
+ }
25
+ function pathsFor(root, wt) {
26
+ const store = storeFor(root);
27
+ if (storeFor(wt) !== store || realpathSync(root) === realpathSync(wt)) throw new Error('Review environments need a separate worktree of this repository.');
28
+ const marker = resolve(wt, git(['rev-parse', '--git-path', 'flowviant-environment'], wt, quiet));
29
+ const key = environmentHash(dirname(marker));
30
+ return { store, marker, lock: join(store, `${key}.lock`) };
31
+ }
32
+ const save = (path, value) => {
33
+ const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
34
+ writeFileSync(temp, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
35
+ renameSync(temp, path);
36
+ };
37
+ const alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; } };
38
+ async function locked(path, action) {
39
+ try { mkdirSync(path, { mode: 0o700 }); }
40
+ catch (error) {
41
+ if (error.code !== 'EEXIST') throw error;
42
+ let owner;
43
+ try { owner = JSON.parse(readFileSync(join(path, 'owner'), 'utf8')); } catch { /* a creator may still be writing */ }
44
+ if ((owner && Number.isInteger(owner.pid) && owner.pid > 1 && !alive(owner.pid)) || (!owner && Date.now() - statSync(path).mtimeMs > 60_000)) rmSync(path, { recursive: true });
45
+ else throw new Error('This worktree environment is being prepared or cleaned up. Retry after that operation finishes.');
46
+ mkdirSync(path, { mode: 0o700 });
47
+ }
48
+ try { save(join(path, 'owner'), { pid: process.pid }); return await action(); }
49
+ finally { rmSync(path, { recursive: true, force: true }); }
50
+ }
51
+ function loadRecord(path, root) {
52
+ const record = JSON.parse(readFileSync(path, 'utf8'));
53
+ if (record.version !== 1 || !ID.test(record.id) || record.snapshot.root !== realpathSync(root) ||
54
+ !['postgres', 'command'].includes(record.database.provider) ||
55
+ (record.database.provider === 'postgres' && (record.database.name !== `fv_${record.id}` || record.database.role !== `fv_${record.id}`))) throw new Error('The local environment ownership receipt is invalid.');
56
+ return record;
57
+ }
58
+ function teach(record, local) {
59
+ const values = { ...record.env, password: record.database.password, connectionString: record.database.connectionString };
60
+ learnExtraSecrets(`environment:${record.id}:runtime`, Object.entries(values).filter(([, value]) => typeof value === 'string').map(([name, value]) => ({ name, value })));
61
+ const provider = environmentCredentialNames(record.snapshot.recipe).filter(name => typeof local[name] === 'string').map(name => ({ name, value: local[name] }));
62
+ // A later runtime-only read must not forget the administrator credential
63
+ // learned while provisioning (or a still-running provider's diagnostics).
64
+ if (provider.length) learnExtraSecrets(`environment:${record.id}:provider`, provider);
65
+ }
66
+
67
+ function runtimeBindings(record, local) {
68
+ const recipe = record.snapshot.recipe, credentials = new Set(environmentCredentialNames(recipe));
69
+ // Only explicitly selected local runtime values go into files/receipts.
70
+ let selected = {};
71
+ const file = repositoryPath(record.snapshot.root, '.flowviant/environment.local.json');
72
+ if (existsSync(file)) selected = JSON.parse(readFileSync(file, 'utf8')).env;
73
+ const env = { ...Object.fromEntries(Object.entries(recipe.env ?? {}).map(([key, value]) => [key, value.replaceAll('${ENVIRONMENT_ID}', record.id)])), ...selected };
74
+ for (const key of credentials) delete env[key];
75
+ for (const key of recipe.database.bindings) env[key] = record.database.bindings?.[key] ?? record.database.connectionString;
76
+ // Mask the administrative credential in inherited CLI/check environments.
77
+ // undefined removes it at spawn; no provider credential is given to hooks.
78
+ const childEnv = { ...local, ...env };
79
+ for (const key of credentials) childEnv[key] = undefined;
80
+ return { env, childEnv };
81
+ }
82
+
83
+ function envText(env) {
84
+ return Object.entries(env).map(([key, value]) => {
85
+ // Real quoted multiline values work in dotenv and Node's env-file reader.
86
+ // Complex values can always use the command wrapper with envFiles: [].
87
+ const quote = !value.includes("'") ? "'" : !value.includes('"') && !value.includes('\\') ? '"' : null;
88
+ if (!quote) throw new Error(`Cannot represent ${key} in a portable .env file. Use envFiles: [] and the environment command wrapper.`);
89
+ return `${key}=${quote}${value}${quote}`;
90
+ }).join('\n') + '\n';
91
+ }
92
+ function writeBindings(record, persist) {
93
+ if (!record.snapshot.recipe.envFiles.length) return;
94
+ const text = envText(record.env), hash = environmentHash(text);
95
+ record.files ??= {};
96
+ record.previousFiles ??= {}; record.pendingFiles ??= {};
97
+ // Reconcile a crash during a previous atomic replacement before starting
98
+ // another. Only receipt-owned bytes may be removed.
99
+ for (const [rel, expected] of Object.entries(record.pendingFiles)) {
100
+ const file = repositoryPath(record.wt, rel);
101
+ if (existsSync(file)) {
102
+ if (!lstatSync(file).isFile() || environmentHash(readFileSync(file)) !== expected) throw new Error('A pending generated environment file was edited. It was left alone.');
103
+ rmSync(file);
104
+ }
105
+ delete record.pendingFiles[rel];
106
+ }
107
+ persist();
108
+ for (const rel of record.snapshot.recipe.envFiles) {
109
+ const path = repositoryPath(record.wt, rel);
110
+ const tracked = git(['ls-files', '--', rel], record.wt, quiet);
111
+ if (tracked) throw new Error(`Generated environment file ${rel} is tracked. Choose an ignored path.`);
112
+ let current;
113
+ if (existsSync(path)) {
114
+ if (!lstatSync(path).isFile()) throw new Error(`Environment file ${rel} is not a regular file.`);
115
+ current = environmentHash(readFileSync(path));
116
+ if (current !== record.files[rel] && current !== record.previousFiles[rel]) throw new Error(`Environment file ${rel} already exists or was edited. Choose a different envFiles path; Flowviant will not overwrite it.`);
117
+ if (current === hash) { chmodSync(path, 0o600); delete record.previousFiles[rel]; persist(); continue; }
118
+ }
119
+ const tempRel = `${rel}.${randomBytes(8).toString('hex')}.flowviant-tmp`;
120
+ record.previousFiles[rel] = current;
121
+ record.files[rel] = hash; record.pendingFiles[tempRel] = hash; persist();
122
+ const exclude = resolve(record.wt, git(['rev-parse', '--git-path', 'info/exclude'], record.wt, quiet));
123
+ mkdirSync(dirname(exclude), { recursive: true });
124
+ const escaped = value => value.replace(/[\\ !#*?\[\]]/g, char => '\\' + char);
125
+ appendFileSync(exclude, `\n/${escaped(rel)}\n/${escaped(tempRel)}\n`);
126
+ mkdirSync(dirname(path), { recursive: true });
127
+ const temp = repositoryPath(record.wt, tempRel);
128
+ writeFileSync(temp, text, { flag: 'wx', mode: 0o600 }); renameSync(temp, path);
129
+ delete record.pendingFiles[tempRel]; delete record.previousFiles[rel]; persist();
130
+ }
131
+ }
132
+
133
+ export function readWorktreeEnvironment(wt, callerEnv = process.env, { allowPending = false } = {}) {
134
+ const marker = resolve(wt, git(['rev-parse', '--git-path', 'flowviant-environment'], wt, quiet));
135
+ if (!existsSync(marker)) return null;
136
+ const id = readFileSync(marker, 'utf8').trim();
137
+ if (!ID.test(id)) throw new Error('The worktree environment marker is invalid.');
138
+ const store = storeFor(wt), path = join(store, `${id}.json`);
139
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
140
+ const record = loadRecord(path, raw.snapshot.root);
141
+ if (record.wt !== realpathSync(wt) || record.marker !== marker || (!allowPending && record.phase !== 'ready')) throw new Error('This worktree environment is not ready.');
142
+ const env = { ...callerEnv, ...record.env };
143
+ for (const key of environmentCredentialNames(record.snapshot.recipe)) env[key] = undefined;
144
+ teach(record, record.env);
145
+ return { record, env, path, snapshot: record.snapshot };
146
+ }
147
+
148
+ /** The recipe and credentials come from the checkout; migrations/fixtures run
149
+ * against current worktree source. Hooks must be idempotent on partial failure. */
150
+ export async function prepareWorktreeEnvironment(root, wt, { snapshot = readEnvironmentRecipe(root), install = null, env = process.env, now = Date.now, reseed = false, ...options } = {}) {
151
+ if (!snapshot) return null;
152
+ validateEnvironmentConnectors(snapshot);
153
+ wt = realpathSync(wt);
154
+ const paths = pathsFor(root, wt);
155
+ if (existsSync(paths.marker)) {
156
+ const previousId = readFileSync(paths.marker, 'utf8').trim();
157
+ if (!ID.test(previousId)) throw new Error('The worktree environment marker is invalid.');
158
+ const previous = loadRecord(join(paths.store, `${previousId}.json`), root);
159
+ // A reviewed recipe change deliberately rebuilds disposable data. Current
160
+ // turns retain their lease and cannot have their database replaced.
161
+ if (previous.snapshot.hash !== snapshot.hash) await releaseWorktreeEnvironment(root, previousId, { ...options, env, now });
162
+ }
163
+ return locked(paths.lock, async () => {
164
+ const local = readEnvironmentLocal(root, env);
165
+ let record;
166
+ if (existsSync(paths.marker)) {
167
+ const id = readFileSync(paths.marker, 'utf8').trim();
168
+ if (!ID.test(id)) throw new Error('The worktree environment marker is invalid.');
169
+ record = loadRecord(join(paths.store, `${id}.json`), root);
170
+ if (record.marker !== paths.marker) throw new Error('The environment belongs to another worktree.');
171
+ // git worktree move preserves its private git dir and marker. Its data
172
+ // lease follows that identity, rather than allocating another database.
173
+ record.wt = wt;
174
+ if (record.snapshot.hash !== snapshot.hash) throw new Error('The reviewed environment recipe changed during preparation. Retry.');
175
+ } else {
176
+ const id = randomBytes(16).toString('hex');
177
+ record = { version: 1, id, wt, marker: paths.marker, lock: paths.lock, snapshot, phase: 'allocating', createdAt: now(), expiresAt: now() + snapshot.recipe.ttlHours * 3_600_000, database: newDatabaseReceipt(id, snapshot.recipe.database.provider), files: {} };
178
+ // Intent precedes every provider call; its id survives timeouts/restarts.
179
+ save(join(paths.store, `${id}.json`), record);
180
+ writeFileSync(paths.marker, id, { flag: 'wx', mode: 0o600 });
181
+ }
182
+ const path = join(paths.store, `${record.id}.json`);
183
+ const persist = () => save(path, record);
184
+ record.expiresAt = now() + snapshot.recipe.ttlHours * 3_600_000;
185
+ record.phase = 'allocating'; persist(); teach(record, local);
186
+ record.database = await allocateReviewDatabase(record, local, snapshot, options);
187
+ const bindings = runtimeBindings(record, local);
188
+ record.env = bindings.env;
189
+ persist(); teach(record, local);
190
+ writeBindings(record, persist);
191
+ if (!record.installed) {
192
+ record.phase = 'installing'; persist();
193
+ if (install) await install(bindings.childEnv);
194
+ record.installed = true; persist();
195
+ }
196
+ record.phase = 'migrating'; persist();
197
+ await environmentCommand(snapshot.recipe.migrate, { ...options, cwd: wt, env: bindings.childEnv, label: 'Review database migration', captureOutput: false, includeDiagnostics: true });
198
+ record.migrationHead = git(['rev-parse', 'HEAD'], wt, quiet);
199
+ if (!record.seeded || reseed) {
200
+ record.phase = 'seeding'; record.seeded = false; persist();
201
+ await environmentCommand(snapshot.recipe.seed, { ...options, cwd: wt, env: bindings.childEnv, label: 'Review database fixtures', captureOutput: false, includeDiagnostics: true });
202
+ record.seeded = true;
203
+ }
204
+ record.phase = 'ready'; persist();
205
+ return readWorktreeEnvironment(wt, env);
206
+ });
207
+ }
208
+
209
+ /** Held through CLI continuations and final checks. The heartbeat expires
210
+ * after a crashed daemon, rather than preserving a resource forever by pid. */
211
+ export function holdWorktreeEnvironment(environment) {
212
+ const { record, path } = environment;
213
+ const lease = `${path}.${randomBytes(8).toString('hex')}.active`;
214
+ const beat = () => save(lease, { pid: process.pid, at: Date.now() });
215
+ beat(); const timer = setInterval(() => { try { beat(); } catch { /* a removed store must not crash the daemon */ } }, 30_000); timer.unref?.();
216
+ return () => { clearInterval(timer); rmSync(lease, { force: true }); };
217
+ }
218
+ function activeEnvironment(store, id, now) {
219
+ return readdirSync(store).some(file => {
220
+ if (!file.startsWith(`${id}.json.`) || !file.endsWith('.active')) return false;
221
+ try { const lease = JSON.parse(readFileSync(join(store, file), 'utf8')); return now - lease.at < 90_000 && Number.isInteger(lease.pid) && lease.pid > 1 && alive(lease.pid); }
222
+ catch { return true; } // unknown ownership cannot justify deletion
223
+ });
224
+ }
225
+
226
+ function registeredEnvironmentWorktree(record) {
227
+ try {
228
+ // git updates this backlink on worktree move. Reading the two tiny files
229
+ // avoids a git process for every retained environment on every sweep.
230
+ const gitfile = readFileSync(join(dirname(record.marker), 'gitdir'), 'utf8').trim();
231
+ if (!lstatSync(gitfile).isFile() || lstatSync(gitfile).isSymbolicLink()) return null;
232
+ const wt = dirname(gitfile), link = readFileSync(gitfile, 'utf8').trim();
233
+ if (!link.startsWith('gitdir: ')) return null;
234
+ return join(resolve(wt, link.slice(8)), 'flowviant-environment') === record.marker ? wt : null;
235
+ } catch { return null; }
236
+ }
237
+
238
+ export async function releaseWorktreeEnvironment(root, id, { now = Date.now, ...options } = {}) {
239
+ if (!ID.test(id)) throw new Error('Invalid environment id.');
240
+ const store = storeFor(root), path = join(store, `${id}.json`);
241
+ const record = loadRecord(path, root);
242
+ return locked(record.lock, async () => {
243
+ if (activeEnvironment(store, id, now())) throw new Error('This environment is in use by a live turn.');
244
+ record.wt = registeredEnvironmentWorktree(record) ?? record.wt;
245
+ const local = readEnvironmentLocal(root, options.env ?? process.env);
246
+ teach(record, local);
247
+ await releaseReviewDatabase(record, local, record.snapshot, options);
248
+ for (const [rel, hash] of Object.entries({ ...record.files, ...record.pendingFiles })) {
249
+ const file = repositoryPath(record.wt, rel);
250
+ if (existsSync(file) && lstatSync(file).isFile() && [hash, record.previousFiles?.[rel]].includes(environmentHash(readFileSync(file)))) rmSync(file);
251
+ }
252
+ if (existsSync(record.marker) && readFileSync(record.marker, 'utf8').trim() === id) rmSync(record.marker);
253
+ for (const file of readdirSync(store)) if (file.startsWith(`${id}.json.`) && file.endsWith('.active')) rmSync(join(store, file));
254
+ rmSync(path); forgetExtraSecrets(`environment:${id}:runtime`); forgetExtraSecrets(`environment:${id}:provider`);
255
+ });
256
+ }
257
+
258
+ /** Bounded reconciliation retains receipts after provider/ownership failures.
259
+ * No database is selected for deletion by its name alone. */
260
+ export async function sweepWorktreeEnvironments(root, { now = Date.now, limit = 2, ...options } = {}) {
261
+ const store = storeFor(root), result = { released: [], errors: [] };
262
+ for (const file of readdirSync(store).filter(file => /^[0-9a-f]{32}\.json$/.test(file))) {
263
+ if (workLanesStopped() || result.released.length + result.errors.length >= limit) break;
264
+ try {
265
+ const record = loadRecord(join(store, file), root);
266
+ if (activeEnvironment(store, record.id, now())) continue;
267
+ const present = existsSync(record.marker) && readFileSync(record.marker, 'utf8').trim() === record.id && registeredEnvironmentWorktree(record) !== null;
268
+ if (present && record.expiresAt > now()) continue;
269
+ await releaseWorktreeEnvironment(root, record.id, { ...options, now });
270
+ result.released.push(record.id);
271
+ } catch (error) { result.errors.push({ id: file.slice(0, 32), error: error.message }); }
272
+ }
273
+ return result;
274
+ }
275
+
276
+ export function environmentInstructions(environment) {
277
+ const { record } = environment;
278
+ // Exact argv is represented as JSON for the agent to shell-quote, with no
279
+ // connection string, credential, or model-invented setup step in the prompt.
280
+ const argv = [process.execPath, ...heldToolArgv('__environment'), record.wt];
281
+ return `Flowviant prepared disposable environment ${record.id} (${record.database.provider}) for ${record.wt}. Its migrations completed and fixtures were seeded; these are setup facts, not passing application tests. Run app/test commands through this argv prefix, appending ["run", "--", ...commandArgv]: ${JSON.stringify(argv)}. This selects this repository's scoped values even in a multi-repository place. After editing migrations, use the same prefix with ["refresh"] before testing. After editing fixtures, use ["refresh", "--seed"] to rerun the retry-safe seeder. Ordinary refresh retries incomplete seeding, but never resets successfully seeded data. Generated files: ${record.snapshot.recipe.envFiles.join(', ') || 'none'}. The configured project MCP connectors expand the same database bindings. Personal/unlisted connectors and external services are not isolated by this database. Use test identities and sandbox or namespaced services. Never copy shared dev/prod database credentials into this worktree.`;
282
+ }
283
+
284
+ export function environmentOverlay(environment) {
285
+ return { ...environment.record.env, ...Object.fromEntries(environmentCredentialNames(environment.snapshot.recipe).map(key => [key, undefined])) };
286
+ }
@@ -0,0 +1,104 @@
1
+ /** Reviewed recipes plus machine-local bindings; never a cloud secret vault. */
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { git } from './git.mjs';
6
+
7
+ export const ENVIRONMENT_RECIPE = '.flowviant/environment.json';
8
+ export const ENVIRONMENT_LOCAL = '.flowviant/environment.local.json';
9
+ export const environmentHash = value => createHash('sha256').update(value).digest('hex');
10
+ const KEY = /^[A-Za-z_][A-Za-z0-9_]{0,99}$/;
11
+ export const environmentKey = key => KEY.test(key) && !key.startsWith('FLOWVIANT_') && !['HOME', 'PATH', 'CODEX_HOME', 'NODE_OPTIONS', 'LD_PRELOAD'].includes(key);
12
+ const command = value => Array.isArray(value) && value.length > 0 && value.length <= 32 && value.every(v => typeof v === 'string' && v.length > 0 && v.length <= 2000 && !/[\0\r\n]/.test(v));
13
+ const strings = (value, max = 40) => Array.isArray(value) && value.length <= max && value.every(v => typeof v === 'string');
14
+
15
+ export function repositoryPath(root, path) {
16
+ if (typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') || path.split('/').some(p => !p || p === '.' || p === '..') || /[\0\r\n]/.test(path)) throw new Error('Environment paths must be relative repository paths.');
17
+ const dest = resolve(root, path);
18
+ // Refuse links in any existing parent, including a linked .flowviant.
19
+ let current = resolve(root);
20
+ for (const part of path.split('/')) {
21
+ current = resolve(current, part);
22
+ try { if (lstatSync(current).isSymbolicLink()) throw new Error('Environment paths cannot contain symbolic links.'); }
23
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
24
+ }
25
+ return dest;
26
+ }
27
+
28
+ export function readEnvironmentRecipe(root, ref = 'HEAD') {
29
+ const options = { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 };
30
+ let commit;
31
+ try { commit = git(['rev-parse', '--verify', ref], root, options); }
32
+ catch (error) {
33
+ // Existing checks can lack a resolvable optional base ref. Without a
34
+ // declared recipe there is no environment feature to fail their check.
35
+ if (!git(['ls-tree', 'HEAD', '--', ENVIRONMENT_RECIPE], root, options)) return null;
36
+ throw error;
37
+ }
38
+ // Absence and a failed read are different. A malformed/unreadable declared
39
+ // recipe must never fall through to copying the checkout's shared env.
40
+ const entry = git(['ls-tree', commit, '--', ENVIRONMENT_RECIPE], root, options);
41
+ if (!entry) return null;
42
+ if (!entry.startsWith('100644 blob ') && !entry.startsWith('100755 blob ')) throw new Error('The environment recipe must be a regular tracked file.');
43
+ let recipe;
44
+ try { recipe = JSON.parse(git(['show', `${commit}:${ENVIRONMENT_RECIPE}`], root, options)); }
45
+ catch { throw new Error('The reviewed environment recipe is not valid JSON.'); }
46
+ const db = recipe?.database;
47
+ if (recipe.version !== 1 || !db || !['postgres', 'command'].includes(db.provider) || !strings(db.bindings) || db.bindings.length === 0 || db.bindings.some(k => !environmentKey(k)) || new Set(db.bindings).size !== db.bindings.length) throw new Error('The environment recipe needs version 1, a database provider and unique environment variable bindings.');
48
+ if (!command(recipe.migrate) || !command(recipe.seed)) throw new Error('The environment recipe needs migrate and seed argv arrays. Both commands must be retry-safe.');
49
+ if (db.provider === 'postgres' && (!environmentKey(db.adminUrlEnv ?? '') || db.bindings.includes(db.adminUrlEnv))) throw new Error('PostgreSQL needs a separate adminUrlEnv credential name.');
50
+ if (db.provider === 'command' && (!command(db.command) || !strings(db.credentials ?? []) || (db.credentials ?? []).some(k => !environmentKey(k) || db.bindings.includes(k)) || typeof db.source !== 'string' || !db.source.trim())) throw new Error('A database adapter needs command argv, credential names and an explicit test-data source.');
51
+ const files = recipe.envFiles ?? ['.env.local'];
52
+ if (!strings(files, 12) || new Set(files).size !== files.length) throw new Error('Environment envFiles must be unique repository paths.');
53
+ for (const file of files) {
54
+ repositoryPath(root, file);
55
+ if (!/(^|\/)(?:\.env(?:\.[A-Za-z0-9_-]+)*|\.dev\.vars(?:\.[A-Za-z0-9_-]+)*)$/.test(file)) throw new Error('Generated environment files must use an .env or .dev.vars filename.');
56
+ }
57
+ if (recipe.env != null && (!recipe.env || typeof recipe.env !== 'object' || Array.isArray(recipe.env) || Object.entries(recipe.env).some(([k, v]) => !environmentKey(k) || typeof v !== 'string' || v.length > 16_384 || v.includes('\0')))) throw new Error('Recipe env must contain environment variable names and string values.');
58
+ const ttlHours = recipe.ttlHours ?? 24;
59
+ if (!Number.isFinite(ttlHours) || ttlHours < 1 || ttlHours > 168) throw new Error('Environment ttlHours must be between 1 and 168.');
60
+ const connectors = recipe.connectors ?? [];
61
+ if (!strings(connectors) || connectors.some(n => !/^[A-Za-z0-9_-]{1,80}$/.test(n))) throw new Error('Environment connectors must name reviewed project MCP servers.');
62
+ return { recipe: { ...recipe, envFiles: files, ttlHours, connectors }, commit, root: realpathSync(root), hash: environmentHash(JSON.stringify(recipe)) };
63
+ }
64
+
65
+ export function readEnvironmentLocal(root, callerEnv = process.env) {
66
+ const path = repositoryPath(root, ENVIRONMENT_LOCAL);
67
+ if (!existsSync(path)) return { ...callerEnv };
68
+ try { git(['check-ignore', '--quiet', '--', ENVIRONMENT_LOCAL], root); }
69
+ catch { throw new Error(`${ENVIRONMENT_LOCAL} must be gitignored. Credentials stay on this machine.`); }
70
+ if (!lstatSync(path).isFile() || lstatSync(path).size > 128 * 1024 || (lstatSync(path).mode & 0o077)) throw new Error(`${ENVIRONMENT_LOCAL} must be a regular owner-only file (chmod 600).`);
71
+ let local;
72
+ try { local = JSON.parse(readFileSync(path, 'utf8')); } catch { throw new Error(`${ENVIRONMENT_LOCAL} is not valid JSON.`); }
73
+ if (!local?.env || typeof local.env !== 'object' || Array.isArray(local.env) || Object.entries(local.env).some(([key, value]) => !environmentKey(key) || typeof value !== 'string' || value.length > 16_384 || value.includes('\0'))) throw new Error(`${ENVIRONMENT_LOCAL} needs an env object with string values.`);
74
+ return { ...callerEnv, ...local.env };
75
+ }
76
+
77
+ export function reviewedProviderCommand(snapshot) {
78
+ const argv = snapshot.recipe.database.command;
79
+ // Like reviewed MCP launchers, referenced repo files must still be the
80
+ // reviewed blobs. Run providers from the checkout, never an edited worktree.
81
+ for (const arg of argv) {
82
+ if (!arg.startsWith('./') && !arg.startsWith('.flowviant/')) continue;
83
+ const path = arg.replace(/^\.\//, '');
84
+ const absolute = repositoryPath(snapshot.root, path);
85
+ const expected = git(['rev-parse', `${snapshot.commit}:${path}`], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] });
86
+ let actual;
87
+ try { actual = git(['hash-object', '--no-filters', '--', absolute], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] }); }
88
+ catch { throw new Error('A reviewed database adapter is missing from the checkout.'); }
89
+ if (actual !== expected) throw new Error('The database adapter differs from its reviewed base copy.');
90
+ }
91
+ return argv;
92
+ }
93
+
94
+ export function validateEnvironmentConnectors(snapshot) {
95
+ if (!snapshot.recipe.connectors.length) return;
96
+ let servers;
97
+ try { servers = JSON.parse(git(['show', `${snapshot.commit}:.mcp.json`], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] })).mcpServers; }
98
+ catch { throw new Error('Environment connectors need a reviewed .mcp.json.'); }
99
+ for (const name of snapshot.recipe.connectors) {
100
+ const config = servers?.[name];
101
+ const values = config && [config.command, config.url, ...(config.args ?? []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {})];
102
+ if (!values?.some(value => snapshot.recipe.database.bindings.some(key => String(value).includes('${' + key + '}')))) throw new Error(`The ${name} connector must reference an isolated database binding in .mcp.json.`);
103
+ }
104
+ }
@@ -0,0 +1,38 @@
1
+ /** Private daemon verbs, also usable by runners without MCP. */
2
+ import { spawn } from 'node:child_process';
3
+ import { killGroup, STOP_GRACE_MS } from './processGroup.mjs';
4
+ import { unattendedEnv } from './noWindowEnv.mjs';
5
+ import { prepareWorktreeEnvironment, readWorktreeEnvironment, releaseWorktreeEnvironment } from './reviewEnvironment.mjs';
6
+
7
+ export async function reviewEnvironmentMain(argv = process.argv.slice(3)) {
8
+ const controller = new AbortController();
9
+ const abort = () => controller.abort();
10
+ process.on('SIGINT', abort); process.on('SIGTERM', abort);
11
+ try {
12
+ const [wt, verb, separator, ...command] = argv;
13
+ const current = readWorktreeEnvironment(wt, process.env, { allowPending: verb === 'refresh' || verb === 'release' });
14
+ if (!current) throw new Error('No Flowviant environment has been prepared in this worktree.');
15
+ if (verb === 'refresh' && (argv.length === 2 || (argv.length === 3 && separator === '--seed'))) {
16
+ const next = await prepareWorktreeEnvironment(current.snapshot.root, wt, { snapshot: current.snapshot, reseed: separator === '--seed', signal: controller.signal });
17
+ process.stdout.write(JSON.stringify({ environment: next.record.id, state: next.record.phase, fixturesSeeded: next.record.seeded }) + '\n');
18
+ } else if (verb === 'release' && argv.length === 2) {
19
+ await releaseWorktreeEnvironment(current.snapshot.root, current.record.id, { signal: controller.signal });
20
+ process.stdout.write('Disposable environment released.\n');
21
+ } else if (verb === 'run' && separator === '--' && command.length) {
22
+ const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv(current.env), stdio: 'inherit', detached: true });
23
+ let stopTimer;
24
+ const stop = () => {
25
+ killGroup(child, 'SIGTERM');
26
+ stopTimer ??= setTimeout(() => killGroup(child), STOP_GRACE_MS);
27
+ stopTimer.unref?.();
28
+ };
29
+ controller.signal.addEventListener('abort', stop, { once: true });
30
+ if (controller.signal.aborted) stop();
31
+ await new Promise((resolve, reject) => {
32
+ child.once('error', () => reject(new Error('The environment command could not start.')));
33
+ child.once('close', code => { process.exitCode = code ?? 1; resolve(); });
34
+ }).finally(() => { clearTimeout(stopTimer); controller.signal.removeEventListener('abort', stop); killGroup(child); });
35
+ } else throw new Error('Use this worktree environment with refresh, release, or run -- <command argv>.');
36
+ } catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1; }
37
+ finally { process.off('SIGINT', abort); process.off('SIGTERM', abort); }
38
+ }
@@ -0,0 +1,58 @@
1
+ /** One network-disabled headless canvas normalizes native engine images. */
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { startReviewProcess } from './reviewProcess.mjs';
6
+ import { chromeFlags, resolveBrowser, sandboxOff } from './shot.mjs';
7
+ import { createCdpClient } from './pinnedCapture.mjs';
8
+ import { childEnv } from './childEnv.mjs';
9
+
10
+ export async function createReviewImage({ signal, onChild, timeoutMs = 620_000, detached = true, browser = resolveBrowser() } = {}) {
11
+ if (!browser || browser.viaWindows) throw new Error('Studio viewport review needs native headless Chromium on the daemon host to normalize captured images.');
12
+ const home = mkdtempSync(join(tmpdir(), 'flowviant-engine-image-'));
13
+ let process, cdp, closed = false, sessionId;
14
+ const close = async () => {
15
+ if (closed) return;
16
+ closed = true; cdp?.close('Engine image encoder closed.');
17
+ await process?.close();
18
+ try { rmSync(home, { recursive: true, force: true }); } catch { /* local cleanup */ }
19
+ };
20
+ const send = async (method, params = {}, session) => {
21
+ let timer;
22
+ try { return await Promise.race([cdp.send(method, params, session), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Engine image encoding timed out.')), 5000); })]); }
23
+ catch (error) { await close(); throw error; }
24
+ finally { clearTimeout(timer); }
25
+ };
26
+ try {
27
+ process = startReviewProcess([browser.bin, ...chromeFlags({ headlessFlag: '--headless=new', noSandbox: sandboxOff(), userDataDir: home, width: 1440, height: 900 }),
28
+ '--remote-debugging-pipe', '--disable-background-networking', '--disable-sync', '--disable-default-apps', '--proxy-server=http://127.0.0.1:9', '--proxy-bypass-list=<-loopback>', 'about:blank'],
29
+ { env: { ...childEnv(), HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home, XDG_DATA_HOME: home, XDG_CACHE_HOME: home }, signal, onChild, timeoutMs, detached, captureStdout: false, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
30
+ cdp = createCdpClient(process.child.stdio[3], process.child.stdio[4], { maxMessageBytes: 10 * 1024 * 1024 });
31
+ process.done.then(() => { cdp.close('Engine image encoder exited.'); void close(); });
32
+ await send('Browser.getVersion');
33
+ await send('Browser.setDownloadBehavior', { behavior: 'deny' });
34
+ const { targetInfos } = await send('Target.getTargets');
35
+ const target = targetInfos.find(t => t.type === 'page');
36
+ if (!target) throw new Error('No isolated engine image canvas is available.');
37
+ ({ sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }));
38
+ await send('Network.enable', {}, sessionId);
39
+ await send('Network.setBlockedURLs', { urls: ['http://*', 'https://*', 'file://*', 'ws://*', 'wss://*', 'ftp://*'] }, sessionId);
40
+ return { close, async encode(image) {
41
+ if (closed) throw new Error('Engine image encoder closed.');
42
+ if (!image || !['image/png', 'image/jpeg'].includes(image.mimeType) || typeof image.data !== 'string' || image.data.length > 8 * 1024 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) throw new Error('Studio MCP must return one bounded PNG/JPEG image content block.');
43
+ const encoded = await send('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: `new Promise((resolve,reject)=>{
44
+ const image=new Image();image.onerror=()=>reject(new Error('Invalid engine image'));
45
+ image.onload=()=>{const width=image.naturalWidth,height=image.naturalHeight;
46
+ if(!width||!height||width>4096||height>4096){reject(new Error('Engine image exceeds pixel bound'));return;}
47
+ const scale=Math.min(1440/width,900/height),w=width*scale,h=height*scale,left=(1440-w)/2,top=(900-h)/2;
48
+ const canvas=document.createElement('canvas');canvas.width=1440;canvas.height=900;
49
+ const context=canvas.getContext('2d');context.fillStyle='#000';context.fillRect(0,0,1440,900);context.drawImage(image,left,top,w,h);
50
+ resolve({data:canvas.toDataURL('image/jpeg',0.65).split(',')[1],width,height,left,top,w,h});
51
+ };image.src=${JSON.stringify(`data:${image.mimeType};base64,${image.data}`)};
52
+ })` }, sessionId);
53
+ const result = encoded.result?.value;
54
+ if (encoded.exceptionDetails || !result || result.data.length > 1_000_000) throw new Error('Studio image could not be normalized within the frame bound.');
55
+ return result;
56
+ } };
57
+ } catch (error) { await close(); throw error; }
58
+ }
@@ -0,0 +1,69 @@
1
+ /** Bounded stdio MCP client for an engine bridge, never a model service. */
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { startReviewProcess } from './reviewProcess.mjs';
4
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
5
+
6
+ export async function connectReviewMcp({ command, cwd, env, onChild, signal, requestTimeoutMs = 15_000, timeoutMs = 620_000, detached = true }) {
7
+ const process = startReviewProcess(command, { cwd, env, onChild, signal, timeoutMs, detached, captureStdout: false, stdio: ['pipe', 'pipe', 'pipe'] });
8
+ const pending = new Map(), decoder = new StringDecoder('utf8');
9
+ let serial = 0, buffer = '', closed = false;
10
+ const close = async () => {
11
+ if (closed) return;
12
+ closed = true;
13
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('Studio MCP disconnected.')); }
14
+ pending.clear(); process.child.stdin.end(); await process.close();
15
+ };
16
+ const fail = () => { void close(); };
17
+ process.done.then(fail);
18
+ process.child.stdout.on('data', bytes => {
19
+ if (closed) return;
20
+ buffer += decoder.write(bytes);
21
+ if (Buffer.byteLength(buffer) > 10 * 1024 * 1024) { fail(); return; }
22
+ let newline;
23
+ while (!closed && (newline = buffer.indexOf('\n')) >= 0) {
24
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
25
+ try {
26
+ const message = JSON.parse(line);
27
+ if (message.jsonrpc !== '2.0') throw new Error('Invalid MCP protocol.');
28
+ if (message.id != null && message.method) {
29
+ // This adapter supplies no sampling, elicitation or filesystem roots.
30
+ process.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'This review client does not support server requests.' } }) + '\n');
31
+ } else {
32
+ const entry = pending.get(message.id); if (!entry) continue;
33
+ pending.delete(message.id); clearTimeout(entry.timer);
34
+ message.error ? entry.reject(new Error(reviewDiagnostic(message.error.message))) : entry.resolve(message.result);
35
+ }
36
+ } catch { fail(); }
37
+ }
38
+ });
39
+ const request = (method, params, limit = requestTimeoutMs) => new Promise((resolve, reject) => {
40
+ if (closed) { reject(new Error('Studio MCP is disconnected.')); return; }
41
+ const id = ++serial;
42
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Studio MCP ${method} timed out.`)); }, limit);
43
+ pending.set(id, { resolve, reject, timer });
44
+ process.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', error => { if (error) fail(); });
45
+ });
46
+ try {
47
+ const hello = await request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'flowviant-runtime-review', version: '1' } });
48
+ if (!hello?.capabilities?.tools) throw new Error('The configured server exposes no MCP tools.');
49
+ process.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
50
+ const tools = []; let cursor;
51
+ for (let page = 0; page < 8; page++) {
52
+ const result = await request('tools/list', cursor ? { cursor } : {});
53
+ if (!Array.isArray(result?.tools) || tools.length + result.tools.length > 500) throw new Error('Studio MCP returned an invalid tool catalog.');
54
+ tools.push(...result.tools); cursor = result.nextCursor;
55
+ if (!cursor) break;
56
+ if (page === 7) throw new Error('Studio MCP tool catalog exceeded its page bound.');
57
+ }
58
+ return { tools, close, async call(name, args, limit) {
59
+ if (!tools.some(tool => tool.name === name)) throw new Error(`Studio MCP does not expose ${name}. Update Studio and enable its built-in MCP server.`);
60
+ const result = await request('tools/call', { name, arguments: args }, limit);
61
+ if (result?.isError) throw new Error(reviewDiagnostic((result.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n')).slice(0, 1000) || `Studio MCP ${name} failed.`);
62
+ return result;
63
+ } };
64
+ } catch (error) { await close(); throw error; }
65
+ }
66
+
67
+ export function mcpText(result) {
68
+ return (result?.content ?? []).filter(c => c.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
69
+ }
@@ -0,0 +1,51 @@
1
+ /** Own every runtime child and bound its output, cancellation and lifetime. */
2
+ import { spawn } from 'node:child_process';
3
+ import { killGroup } from './processGroup.mjs';
4
+ import { unattendedEnv } from './noWindowEnv.mjs';
5
+ import { scrub } from './uplinkScrub.mjs';
6
+ import { workLanesStopped } from './standDownGate.mjs';
7
+
8
+ export function startReviewProcess(argv, { cwd, env = process.env, display = false, input, signal, onChild = () => () => {}, timeoutMs = 600_000, binary = false, maxBytes = 64 * 1024, stdio, captureStdout = true, detached = true } = {}) {
9
+ signal?.throwIfAborted();
10
+ if (workLanesStopped()) throw new Error('The daemon is stopping. Review has not started.');
11
+ const child = spawn(argv[0], argv.slice(1), { cwd, env: unattendedEnv(env, { display }), detached, stdio: stdio ?? ['pipe', 'pipe', 'pipe'] });
12
+ let timer, untrack = () => {}, settled = false, total = 0, stdout = [], tail = '', exit;
13
+ let resolveExit;
14
+ const done = new Promise(resolve => { resolveExit = resolve; });
15
+ const finish = result => {
16
+ if (settled) return;
17
+ settled = true; exit = result; clearTimeout(timer); signal?.removeEventListener('abort', abort); untrack();
18
+ killGroup(child);
19
+ resolveExit({ ...result, output: tail, data: binary ? Buffer.concat(stdout) : undefined });
20
+ };
21
+ const terminate = reason => { killGroup(child); child.stdout?.destroy(); child.stderr?.destroy(); finish({ code: null, error: reason }); };
22
+ const abort = () => terminate('Review was interrupted.');
23
+ const diagnostics = bytes => { tail = scrub(tail + bytes.toString()).slice(-maxBytes); };
24
+ child.stdout?.on('data', bytes => {
25
+ if (!captureStdout) return;
26
+ if (binary) {
27
+ total += bytes.length;
28
+ if (total > maxBytes) { terminate('Review capture exceeded its byte bound.'); return; }
29
+ stdout.push(bytes);
30
+ } else diagnostics(bytes);
31
+ });
32
+ child.stderr?.on('data', diagnostics);
33
+ child.once('error', () => finish({ code: null, error: `Could not start ${argv[0]}. Check that it is installed.` }));
34
+ child.once('close', code => finish({ code }));
35
+ child.stdin?.on('error', () => {});
36
+ if (input !== undefined) child.stdin?.end(input);
37
+ else if (stdio == null) child.stdin?.end();
38
+ try { untrack = onChild(child) ?? (() => {}); }
39
+ catch (error) { terminate('The review process could not be tracked.'); throw error; }
40
+ timer = setTimeout(() => terminate('Review reached its time limit.'), timeoutMs); timer.unref?.();
41
+ signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
42
+ return { child, done, running: () => !settled, result: () => exit, output: () => tail,
43
+ async close() { terminate('Review stopped.'); await done; } };
44
+ }
45
+
46
+ export async function reviewExec(argv, options) {
47
+ const process = startReviewProcess(argv, options);
48
+ const result = await process.done;
49
+ if (result.error || result.code !== 0) throw new Error(result.error ?? `Review command exited ${result.code}. ${result.output}`);
50
+ return result;
51
+ }