flowviant 0.125.13 → 0.125.16

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 CHANGED
@@ -254,7 +254,8 @@ OpenAI, GitHub, AWS, Slack, OAuth token fields, PEM private keys) with
254
254
  hostname, a lowercase hex string) is left as it is, and a secret in none of
255
255
  those is relayed as printed. What your CLI writes through Flowviant's own tools
256
256
  (such as a tab's progress messages and purpose line, or the cards a New task
257
- chat stages) goes from the CLI to Flowviant, not through the daemon, as written.
257
+ chat stages) is sent to Flowviant as written, either directly or through the
258
+ daemon's root-only capture credential relay.
258
259
  The full list, and the exact test for what looks generated, are in
259
260
  [SECURITY.md](./SECURITY.md#what-is-redacted).
260
261
 
package/bin/cli.mjs CHANGED
@@ -80,6 +80,11 @@
80
80
  // answered here before anything else loads: no config, no update check, no
81
81
  // output but the hook's. An unknown hook exits 2, which Claude Code reads as a
82
82
  // refusal — the read guard's own rule for a hook that cannot judge.
83
+ if (process.argv[2] === '__capture-mcp') {
84
+ await (await import('./lib/captureAuthority.mjs')).captureMcpMain();
85
+ process.exit(process.exitCode ?? 0);
86
+ }
87
+
83
88
  if (process.argv[2] === '__orchestrator-mcp') {
84
89
  await (await import('./lib/orchestratorClient.mjs')).orchestratorMcpMain();
85
90
  process.exit(process.exitCode ?? 0);
@@ -37,7 +37,7 @@
37
37
  * with `includeNames`, so it never attributes a sibling card's files.
38
38
  */
39
39
 
40
- import { artifactStoredName } from './artifacts.mjs';
40
+ import { artifactStoredName, artifactTypeFor, scanArtifacts } from './artifacts.mjs';
41
41
  import { fleetHeaders } from './fleetPost.mjs';
42
42
  import {
43
43
  imageRevisionProtection, iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
@@ -226,7 +226,7 @@ export function createArtifactReporter({
226
226
 
227
227
  /** Upload what this turn changed in `placeDir`. Resolves to the number of
228
228
  * bodies SENT this call (delivered or held), for the tests and the log. */
229
- const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null }) => {
229
+ const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null }) => {
230
230
  if (!placeDir || (!sessionId && !agentId)) return 0;
231
231
  const changes = iterationChanges(placeDir, before);
232
232
  const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
@@ -250,14 +250,29 @@ export function createArtifactReporter({
250
250
  pending.delete(key);
251
251
  genOf.set(key, ++generation);
252
252
  }
253
- const changed = idle ? [] : listChanged(placeDir, before).filter((e) =>
253
+ const required = new Map((requiredOutputs ?? []).map(ref => [ref.name, ref.sha256]));
254
+ // Prepared handoffs are an explicit set, independent of the newest-first
255
+ // discovery window. Re-walk safely, then verify the built bytes against
256
+ // the prepared version before sending anything to storage.
257
+ const discovered = idle ? [] : listChanged(placeDir, before);
258
+ const entries = required.size
259
+ ? [...scanArtifacts(placeDir, { all: true, includeNames: new Set(required.keys()) }),
260
+ ...discovered.filter(e => !artifactTypeFor(e.name))] // unsupported files still report by name
261
+ : discovered;
262
+ const changed = idle ? [] : entries.filter((e) =>
254
263
  !removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
255
264
  const delivered = new Map();
256
265
  const failures = before?.inventoryFailed
257
266
  ? ['Stored artifact inventory could not be measured; cleanup is incomplete. Retry.'] : [];
267
+ for (const name of required.keys()) if (!changed.some(e => e.name === name))
268
+ failures.push(`Required intermediate output ${name} is missing, unsafe or excluded from reporting; its handoff is unfinished.`);
258
269
  let n = 0;
259
270
  for (const entry of changed) {
260
271
  const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
272
+ if (required.has(entry.name) && (!body?.bytes || body.withheld || body.fields.sha256 !== required.get(entry.name))) {
273
+ failures.push(`Required intermediate output ${entry.name} is unreadable, withheld or changed since preparation. Restore its exact producer bytes and retry.`);
274
+ continue;
275
+ }
261
276
  if (!body) {
262
277
  if (requireStored) failures.push(`artifact ${entry.name} could not be read; it was not delivered`);
263
278
  continue;
@@ -327,6 +342,28 @@ export function createArtifactReporter({
327
342
  [...(before.seen ?? []), ...present, ...delivered.keys()]
328
343
  .filter((name) => owned.has(name) && !removed.has(name) && (!protectedNames.has(name) || present.has(name))));
329
344
  }
345
+ if (requireStored && required.size && !failures.length) {
346
+ // A series of successful POSTs is not proof the set still exists: the
347
+ // owner's storage window may have pruned an earlier file. Measure the
348
+ // exact uploaded versions together before allowing settlement. The
349
+ // existing inventory exposes ids, not hashes. The POST was hash-checked
350
+ // above, and the server assigns a new immutable id to changed content.
351
+ try {
352
+ const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
353
+ const response = await doFetch(`${fleetEndpoint('artifacts', fleetUrl)}?${query}`, {
354
+ method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(15_000),
355
+ });
356
+ const result = response.ok ? await response.json() : null;
357
+ if (!Array.isArray(result?.data)) throw new Error('unmeasured inventory');
358
+ for (const name of required.keys()) {
359
+ const storedName = artifactStoredName(name), id = delivered.get(storedName);
360
+ if (!id || !result.data.some(ref => ref.name === storedName && ref.id === id))
361
+ failures.push(`Required intermediate output ${name} was not retained at its uploaded producer version by artifact storage. Retry this producer; its handoff is unfinished.`);
362
+ }
363
+ } catch {
364
+ failures.push('Required intermediate output storage could not be measured. Retry this producer; its handoff is unfinished.');
365
+ }
366
+ }
330
367
  if (failures.length) throw new Error(failures.join('; '));
331
368
  return n;
332
369
  };
@@ -225,21 +225,26 @@ export function walkArtifactDir(placeDir, visit) {
225
225
  * The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
226
226
  * `[]` for a directory that does not exist, is not a directory, or is a
227
227
  * symlink — nothing to report is the only honest reading of any of those.
228
+ * `all` retains the full safe metadata walk for before/after identity and
229
+ * explicit handoffs. `includeNames` filters before the general scan window.
228
230
  */
229
- export function scanArtifacts(placeDir) {
231
+ export function scanArtifacts(placeDir, { all = false, includeNames = null } = {}) {
230
232
  const out = [];
231
233
  walkArtifactDir(placeDir, ({ name, path, kind, st }) => {
232
- if (kind === 'file') out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
234
+ if (kind === 'file' && (!includeNames || includeNames.has(name))) out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
233
235
  });
234
236
  out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
235
- return out.slice(0, ARTIFACT_MAX_FILES);
237
+ return all ? out : out.slice(0, ARTIFACT_MAX_FILES);
236
238
  }
237
239
 
238
240
  const sigOf = (e) => `${e.size}:${e.mtimeMs}`;
239
241
 
240
242
  /** name → size:mtime, taken before the CLI spawns. */
241
243
  export function snapshotArtifacts(placeDir) {
242
- return new Map(scanArtifacts(placeDir).map((e) => [e.name, sigOf(e)]));
244
+ // A bounded snapshot mistakes an unchanged older file for a new output if
245
+ // it later enters the discovery window. Keep metadata for the entire safe
246
+ // walk; the general upload window remains bounded separately.
247
+ return new Map(scanArtifacts(placeDir, { all: true }).map((e) => [e.name, sigOf(e)]));
243
248
  }
244
249
 
245
250
  /** What the turn wrote: every entry whose (name, size, mtime) is not what the
@@ -0,0 +1,82 @@
1
+ /** A capture credential never enters a delegated CLI or its children. The
2
+ * daemon relays only calls authorized by the native ROOT's trusted hook.
3
+ * Tickets bind the tool and complete arguments, and replay returns the same
4
+ * promise/result rather than performing a second staging write. */
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { fleetHeaders } from './fleetPost.mjs';
7
+ import { callOrchestrator } from './orchestratorClient.mjs';
8
+ import { mcpRequest, serveMcpStdio } from './mcpStdio.mjs';
9
+ import { captureToolName } from './nativeAgentTools.mjs';
10
+ import { DAEMON_INSTANCE } from './config.mjs';
11
+
12
+ export const CAPTURE_CALL_TICKET = '_flowviantRootCall';
13
+ const CAP = 256;
14
+ const cleanArgs = args => Object.fromEntries(Object.entries(args ?? {}).filter(([key]) => key !== CAPTURE_CALL_TICKET));
15
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
16
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
17
+ const fingerprint = (name, args) => createHash('sha256').update(JSON.stringify([name, canonical(cleanArgs(args))])).digest('hex');
18
+
19
+ export function createCaptureAuthority({ url, token, turnId, attempt, sessionId, signal, request = fetch, clock = Date.now }) {
20
+ const tickets = new Map(), calls = new Map();
21
+ const lifetime = new AbortController();
22
+ let closed = false, discovery = null;
23
+ const active = () => !closed && !signal?.aborted;
24
+ const remote = async (method, params, callId) => {
25
+ if (!active()) throw new Error('This capture turn stopped.');
26
+ const response = await request(url, { method: 'POST',
27
+ headers: { ...fleetHeaders({ token }), 'X-Flowviant-Capture-Turn': turnId,
28
+ 'X-Flowviant-Capture-Attempt': String(attempt), 'X-Flowviant-Capture-Instance': DAEMON_INSTANCE,
29
+ ...(callId ? { 'X-Flowviant-Capture-Call': callId } : {}) },
30
+ body: JSON.stringify(mcpRequest(method, params, randomUUID())),
31
+ signal: AbortSignal.any([lifetime.signal, ...(signal ? [signal] : []), AbortSignal.timeout(30_000)]),
32
+ });
33
+ const body = await response.json();
34
+ if (!response.ok || body.error || !body.result) throw new Error(body.error?.message ?? 'The capture tool did not answer.');
35
+ if (!active()) throw new Error('This capture turn stopped.');
36
+ return body.result;
37
+ };
38
+ return {
39
+ async describe() {
40
+ discovery ??= (async () => {
41
+ const init = await remote('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'flowviant-capture', version: '1' } });
42
+ const list = await remote('list', {});
43
+ if (!Array.isArray(list.tools) || list.tools.length > 64) throw new Error('The capture tool list is unavailable.');
44
+ return { ...init, tools: list.tools.map(tool => ({ ...tool, inputSchema: { ...tool.inputSchema,
45
+ properties: { ...tool.inputSchema?.properties, [CAPTURE_CALL_TICKET]: { type: 'string', description: 'Internal authorization supplied by the native root hook.' } },
46
+ } })) };
47
+ })();
48
+ return discovery;
49
+ },
50
+ authorize(event) {
51
+ if (!active()) return { deny: 'This capture turn stopped.' };
52
+ const name = captureToolName(event.tool_name), id = event.tool_use_id;
53
+ if (!name || typeof id !== 'string' || !id || id.length > 160) return { deny: 'The capture call has no native identity.' };
54
+ const args = cleanArgs(event.tool_input);
55
+ if (args.sessionId !== sessionId) return { deny: 'Capture tools belong to this conversation only.' };
56
+ const hash = fingerprint(name, args), prior = calls.get(id);
57
+ if (prior && prior.hash !== hash) return { deny: 'This native capture call already authorized different arguments.' };
58
+ if (!prior && calls.size >= CAP) return { deny: 'This capture turn has reached its tool-call bound. Finish this reply and continue in the next message.' };
59
+ const ticket = prior?.ticket ?? randomUUID();
60
+ const grant = prior ?? { ticket, hash, name, args, callId: id, at: clock(), response: null };
61
+ calls.set(id, grant); tickets.set(ticket, grant);
62
+ return { updatedInput: { ...args, [CAPTURE_CALL_TICKET]: ticket } };
63
+ },
64
+ async call({ name, args = {} }) {
65
+ if (!active()) throw new Error('This capture turn stopped.');
66
+ const grant = tickets.get(args[CAPTURE_CALL_TICKET]);
67
+ if (!grant || grant.hash !== fingerprint(name, args) || clock() - grant.at > 15 * 60_000)
68
+ throw new Error('Only the capture coordinator may call these tools. No native root authorization was supplied.');
69
+ grant.response ??= remote('call', { name, arguments: grant.args }, grant.callId);
70
+ return grant.response;
71
+ },
72
+ close() { closed = true; lifetime.abort(); tickets.clear(); calls.clear(); },
73
+ };
74
+ }
75
+
76
+ /** The credential-free stdio client. It knows only this attempt's loopback
77
+ * capability. The shared protocol loop preserves raw MCP errors and replies. */
78
+ export async function captureMcpMain() {
79
+ const description = await callOrchestrator('capture_describe', {}, 65_000);
80
+ return serveMcpStdio({ serverInfo: description.serverInfo, tools: description.tools, instructions: description.instructions,
81
+ rawResult: true, call: (name, args) => callOrchestrator('capture_call', { name, args }, 35_000) });
82
+ }
@@ -499,6 +499,15 @@ export function withNativeChildren(perm) {
499
499
  return out;
500
500
  }
501
501
 
502
+ /** A consult keeps exactly its readers and guard. The only additional MCP
503
+ * permission is the daemon's local coordination capability, never flowviant's
504
+ * board/staging tools or a project/personal server. Capture keeps its already
505
+ * allowed flowviant tools behind the daemon's root-only credential relay. */
506
+ export function withReadOnlyCoordination(perm) {
507
+ return withNativeChildren(perm).flatMap(arg => arg === '--allowedTools'
508
+ ? [arg, 'SendMessage', 'TaskStop', 'ToolSearch', 'mcp__card_orchestrator'] : [arg]);
509
+ }
510
+
502
511
  /**
503
512
  * CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
504
513
  * entry per profile Claude declares, plus plan-mode (runtimeClaude.mjs
@@ -31,7 +31,7 @@ export async function main() {
31
31
  // The names are the controller's own list (nativeAgentTools.mjs).
32
32
  // A research card's children only read (FLOWVIANT_ORCHESTRATOR_CHILDREN,
33
33
  // orchestrator.mjs): a child's write is refused here too.
34
- if (gatedSpawnEvent(event, process.env.FLOWVIANT_ORCHESTRATOR_CHILDREN)) { process.exitCode = 2; return; }
34
+ if (gatedSpawnEvent(event, process.env.FLOWVIANT_ORCHESTRATOR_CHILDREN, process.env.FLOWVIANT_ORCHESTRATOR_READ_ONLY === '1')) { process.exitCode = 2; return; }
35
35
  try { await answer({}); } catch { /* the CLI already stopped reading */ }
36
36
  }
37
37
  }
@@ -4,8 +4,8 @@
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from 'node:fs';
6
6
  import { basename, dirname, join } from 'node:path';
7
- import { artifactTypeFor, artifactMaxBytesFor, changedArtifacts, scanArtifacts } from './artifacts.mjs';
8
- import { ARTIFACT_BINARY_MODEL_MAX_BYTES } from './artifactPolicy.mjs';
7
+ import { ARTIFACT_MAX_DEPTH, artifactTypeFor, artifactMaxBytesFor, changedArtifacts, scanArtifacts } from './artifacts.mjs';
8
+ import { ARTIFACT_BINARY_MODEL_MAX_BYTES, ARTIFACT_MAX_PER_OWNER } from './artifactPolicy.mjs';
9
9
  import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
10
10
  import { scrub, secretIn } from './uplinkScrub.mjs';
11
11
  import { exportModelPage } from './modelExport.mjs';
@@ -31,7 +31,8 @@ function readRegular(path, max = ARTIFACT_BINARY_MODEL_MAX_BYTES) {
31
31
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
32
32
  try {
33
33
  const stat = fstatSync(fd);
34
- if (!stat.isFile() || stat.size > max) throw new Error('Intermediate artifact is not a bounded regular file.');
34
+ if (!stat.isFile()) throw new Error('Intermediate artifact is not a regular file.');
35
+ if (stat.size > max) throw new Error(`Intermediate artifact is ${stat.size} bytes; the per-file limit is ${max} bytes.`);
35
36
  const bytes = Buffer.alloc(stat.size);
36
37
  let offset = 0;
37
38
  while (offset < bytes.length) {
@@ -142,7 +143,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
142
143
  signal?.throwIfAborted();
143
144
  const state = load(), taskId = taskOf(job);
144
145
  const identity = { agentId, taskId, turnId, taskKind };
145
- const standing = scanArtifacts(wt);
146
+ const standing = scanArtifacts(wt, { all: true });
146
147
  const changed = changedArtifacts(before, standing);
147
148
  // Failed export/storage retries are tied to a card and exact source hash.
148
149
  // A changed page is a new version; it never reuses a previous GLB receipt.
@@ -151,11 +152,11 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
151
152
  if (changed.some(e => e.name === ref.name)) continue;
152
153
  const entry = standing.find(e => e.name === ref.name);
153
154
  let bytes;
154
- try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`); }
155
+ try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name)); }
155
156
  catch (error) { throw new Error(`Required intermediate output ${ref.name} is unavailable: ${error.message}. Restore its exact producer bytes or revise the producer explicitly, then retry.`); }
156
157
  if (hash(bytes) !== ref.sha256)
157
158
  throw new Error(`Required intermediate output ${ref.name} is missing or changed since the failed handoff. Restore it or revise the producer explicitly, then retry.`);
158
- if (!entry) throw new Error(`Required intermediate output ${ref.name} is outside the artifact scan limit. Reduce the output files and retry this producer.`);
159
+ if (!entry) throw new Error(`Required intermediate output ${ref.name} is outside the safe artifact walk (non-hidden paths with at most ${ARTIFACT_MAX_DEPTH} directories). Move it to a supported artifact path and retry this producer.`);
159
160
  changed.push(entry);
160
161
  }
161
162
  if (taskKind === 'model' && !changed.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
@@ -188,12 +189,14 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
188
189
  exports.push(receipt);
189
190
  if (!outputs.some(e => e.name === name)) outputs.push({ name });
190
191
  }
191
- const reportable = new Set(scanArtifacts(wt).map(e => e.name));
192
- const omitted = outputs.filter(e => !reportable.has(e.name));
193
- if (omitted.length) throw new Error(`The artifact scan limit excludes required output ${omitted[0].name}. Reduce the output files and retry this producer; its handoff is unfinished.`);
194
192
  state.pending.sources = outputs.map(e => ({ name: e.name,
195
193
  sha256: hash(readIntermediateFile(wt, `.flowviant/artifacts/${e.name}`, artifactMaxBytesFor(e.name))) }));
196
194
  save(state);
195
+ // The server retains 40 artifacts per owner. Discovery crowding is not a
196
+ // storage limit, but uploading a larger required set would prune one of
197
+ // this very handoff's files and then falsely claim complete delivery.
198
+ if (outputs.length > ARTIFACT_MAX_PER_OWNER)
199
+ throw new Error(`This producer requires ${outputs.length} output files including model exports; artifact storage retains at most ${ARTIFACT_MAX_PER_OWNER} files per agent. Narrow this producer's output set before retrying; its handoff is unfinished.`);
197
200
  const textures = [...state.files.flatMap(f => [...(/\.png$/i.test(f.name) ? [f] : []), ...(f.model?.textureInputs ?? [])]),
198
201
  ...outputs.filter(e => /\.png$/i.test(e.name)).map(e => snapshot(wt, e, identity, retain))]
199
202
  .map(({ name, path, sha256, taskId, turnId, agentId, taskKind }) => ({ name, path, sha256, taskId, turnId, agentId, taskKind }));
@@ -211,7 +214,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
211
214
  // Retry all prepared files even if their mtime did not change this turn.
212
215
  const uploadBefore = Object.assign(new Map(before), before);
213
216
  for (const ref of files) uploadBefore.delete(ref.name);
214
- return { modelExports: exports, uploadBefore, commit: () => {
217
+ return { modelExports: exports, requiredOutputs: files.map(({ name, sha256 }) => ({ name, sha256 })), uploadBefore, commit: () => {
215
218
  const byName = new Map(state.files.map(f => [f.name, f]));
216
219
  for (const ref of files) byName.set(ref.name, ref);
217
220
  if (byName.size > 160) throw new Error('The intermediate artifact receipt exceeds 160 files. Narrow the outputs before retrying.');
@@ -15,18 +15,26 @@
15
15
  */
16
16
  import { createInterface } from 'node:readline';
17
17
 
18
+ /** The same vocabulary for daemon-side MCP relays, without another protocol
19
+ * envelope or tools/list spelling in each client. */
20
+ export const mcpRequest = (action, params, id) => ({ jsonrpc: '2.0', id,
21
+ method: { initialize: 'initialize', list: 'tools/list', call: 'tools/call' }[action], params });
22
+
18
23
  /** `call(name, args)` answers a listed tool; what it resolves is the text result, as JSON. */
19
- export async function serveMcpStdio({ serverInfo, tools, call, input = process.stdin, write = s => process.stdout.write(s) }) {
24
+ export async function serveMcpStdio({ serverInfo, tools, call, instructions, rawResult = false, input = process.stdin, write = s => process.stdout.write(s) }) {
20
25
  for await (const line of createInterface({ input })) {
21
26
  let req;
22
27
  try {
23
28
  req = JSON.parse(line); if (req.id === undefined) continue;
24
29
  let result;
25
- if (req.method === 'initialize') result = { protocolVersion: req.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo };
30
+ if (req.method === 'initialize') result = { protocolVersion: req.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo, ...(instructions ? { instructions } : {}) };
26
31
  else if (req.method === 'ping') result = {};
27
32
  else if (req.method === 'tools/list') result = { tools };
28
33
  else if (req.method === 'tools/call' && tools.some(t => t.name === req.params?.name)) {
29
- try { result = { content: [{ type: 'text', text: JSON.stringify(await call(req.params.name, req.params.arguments ?? {})) }] }; }
34
+ try {
35
+ const answer = await call(req.params.name, req.params.arguments ?? {});
36
+ result = rawResult ? answer : { content: [{ type: 'text', text: JSON.stringify(answer) }] };
37
+ }
30
38
  catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; }
31
39
  } else { write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Unknown method' } })}\n`); continue; }
32
40
  write(`${JSON.stringify({ jsonrpc: '2.0', id: req.id, result })}\n`);
@@ -8,8 +8,21 @@ import { mergeOutward, isRaceRejection } from './shipMerge.mjs';
8
8
  import { repositoryMergeOrder } from './repositoryMerge.mjs';
9
9
  import { ghReady, ensureOpenPr, mergeAndVerifyTip, linkRepositoryPrs } from './prWorkflow.mjs';
10
10
 
11
+ /**
12
+ * ONE RUN'S CEILINGS (2026-10-22). A round whose base moved under a delivered
13
+ * integration folds the new base in with Git and validates again; only a
14
+ * conflict, or a failed validation, asks the coordinator again. The daemon's
15
+ * validation is the only full run, so its failure goes back to the coordinator
16
+ * inside the run, as the coordinator's own runs used to. Before this, every
17
+ * moved base was a whole new CLI turn that re-read everything and re-ran the
18
+ * full suite: a measured 3-agent batch spent 7 minutes integrating and
19
+ * nearly an hour validating and restarting.
20
+ */
21
+ const MAX_ROUNDS = 5;
22
+ const MAX_COORDINATIONS = 3;
23
+
11
24
  export async function integrateMergeBatch({ spec, progress = {}, previousError, entries, rootFor, baseDir, coordinate, validate, report,
12
- setup = async () => {}, gitNet = network, land = mergeOutward, pr = { ghReady, ensureOpenPr, mergeAndVerifyTip, linkRepositoryPrs } }) {
25
+ setup = async () => {}, checkCommand = () => null, gitNet = network, land = mergeOutward, pr = { ghReady, ensureOpenPr, mergeAndVerifyTip, linkRepositoryPrs } }) {
13
26
  if (!/^[a-f0-9-]{36}$/.test(spec.id)) throw new Error('Invalid merge batch identity.');
14
27
  const states = structuredClone(progress);
15
28
  const ids = [...new Set(spec.members.flatMap((m) => m.cards.flatMap((c) => c.repos)))];
@@ -56,7 +69,15 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
56
69
  }
57
70
  const worktrees = rows.map((r) => ({ wt: r.wt, branch: r.branch, repoRoot: r.repoRoot }));
58
71
  if (spec.prMode && pr.ghReady()) throw new Error('This project merges through pull requests. GitHub CLI is not installed or signed in on this machine.');
59
- for (let attempt = 0; attempt < 3; attempt++) {
72
+ // Git's own merge of a moved base into the delivered integration. Clean is
73
+ // the only answer that keeps the coordinator's work; anything else aborts.
74
+ const foldBase = (r) => {
75
+ if (isAncestor(r.baseTip, git(['rev-parse', 'HEAD'], r.wt), r.wt)) return true;
76
+ try { gitMergeIn(r.repoRoot)(['merge', '--no-edit', r.baseTip], r.wt); return true; }
77
+ catch { gitOrNull(['merge', '--abort'], r.wt); return false; }
78
+ };
79
+ let commands = null, failure = previousError ?? null, coordinations = 0;
80
+ for (let round = 0; round < MAX_ROUNDS; round++) {
60
81
  const pending = [];
61
82
  for (const r of rows) {
62
83
  fetch(r);
@@ -74,38 +95,46 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
74
95
  }
75
96
  await report(states);
76
97
  if (!pending.length) return { states, worktrees };
77
- const context = { selection: spec, previousFailure: previousError ?? null, progress: states, repositories: rows.map((r) => ({ id: r.id, name: r.name,
78
- integrationWorktree: r.wt, integrationBranch: r.branch, targetRef: r.baseRef, targetHead: r.baseTip,
79
- alreadyLanded: Boolean(states[r.id].landed), sources: r.sources.map((s) => {
80
- const mergeBase = git(['merge-base', r.baseTip, s.head], r.repoRoot);
81
- const name = createHash('sha256').update(s.agentId).digest('hex').slice(0, 16);
82
- const diffPath = resolve(r.wt, git(['rev-parse', '--git-path', `batch-source-${name}.diff`], r.wt));
83
- const fd = openSync(diffPath, 'w', 0o600);
84
- try { gitRaw(['diff', '--binary', mergeBase, s.head], r.repoRoot, { stdio: ['ignore', fd, 'pipe'] }); }
85
- finally { closeSync(fd); }
86
- const diffBytes = statSync(diffPath).size;
87
- return { agentId: s.agentId, head: s.head, branch: s.branch, mergeBase, diffPath, diffBytes,
88
- ...(diffBytes <= 64 * 1024 ? { diff: readFileSync(diffPath, 'utf8') } : {}),
89
- commits: git(['log', '--format=%H %s', `${mergeBase}..${s.head}`], r.repoRoot),
90
- files: git(['diff', '--stat', mergeBase, s.head], r.repoRoot), diffCommand: `git diff ${mergeBase} ${s.head}` };
91
- }) })) };
92
- const result = await coordinate(context);
93
- if (result?.status !== 'delivered') throw new Error(result?.question ? `${result.question}\n${result.context || ''}` : 'The coordinating CLI did not deliver a combined result.');
98
+ if (!commands || !pending.every(foldBase)) {
99
+ if (coordinations++ === MAX_COORDINATIONS) throw new Error(failure ?? 'The batch needed more coordination than one run allows. Try the merge again.');
100
+ const context = { selection: spec, previousFailure: failure, progress: states, repositories: rows.map((r) => ({ id: r.id, name: r.name,
101
+ integrationWorktree: r.wt, integrationBranch: r.branch, targetRef: r.baseRef, targetHead: r.baseTip, configuredCheck: checkCommand(r),
102
+ alreadyLanded: Boolean(states[r.id].landed), sources: r.sources.map((s) => {
103
+ const mergeBase = git(['merge-base', r.baseTip, s.head], r.repoRoot);
104
+ const name = createHash('sha256').update(s.agentId).digest('hex').slice(0, 16);
105
+ const diffPath = resolve(r.wt, git(['rev-parse', '--git-path', `batch-source-${name}.diff`], r.wt));
106
+ const fd = openSync(diffPath, 'w', 0o600);
107
+ try { gitRaw(['diff', '--binary', mergeBase, s.head], r.repoRoot, { stdio: ['ignore', fd, 'pipe'] }); }
108
+ finally { closeSync(fd); }
109
+ const diffBytes = statSync(diffPath).size;
110
+ return { agentId: s.agentId, head: s.head, branch: s.branch, mergeBase, diffPath, diffBytes,
111
+ ...(diffBytes <= 64 * 1024 ? { diff: readFileSync(diffPath, 'utf8') } : {}),
112
+ commits: git(['log', '--format=%H %s', `${mergeBase}..${s.head}`], r.repoRoot),
113
+ files: git(['diff', '--stat', mergeBase, s.head], r.repoRoot), diffCommand: `git diff ${mergeBase} ${s.head}` };
114
+ }) })) };
115
+ const result = await coordinate(context);
116
+ if (result?.status !== 'delivered') throw new Error(result?.question ? `${result.question}\n${result.context || ''}` : 'The coordinating CLI did not deliver a combined result.');
117
+ commands = new Map(pending.map((r) => [r.id, result.validations?.find((v) => v.repositoryId === r.id)?.command]));
118
+ }
94
119
  // Validate all pending trees before any new repository lands.
120
+ let failed = false;
95
121
  for (const r of pending) {
96
122
  const head = git(['rev-parse', 'HEAD'], r.wt);
97
123
  if (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], r.wt) !== r.branch ||
98
124
  git(['status', '--porcelain', '--untracked-files=no'], r.wt).trim() ||
99
125
  gitOrNull(['rev-parse', '--verify', 'MERGE_HEAD'], r.wt) ||
100
126
  !isAncestor(r.baseTip, head, r.wt) || r.sources.some((s) => !isAncestor(s.head, head, r.wt))) throw new Error(`${r.name}: the combined tree must contain the target base and every selected source, with all resolutions committed.`);
101
- const command = result.validations?.find((v) => v.repositoryId === r.id)?.command;
127
+ const command = commands.get(r.id);
102
128
  if (typeof command !== 'string' || !command.trim() || command.length > 2000) throw new Error(`${r.name}: the coordinator supplied no runnable combined validation.`);
103
129
  await report(states);
104
- await validate({ ...r, command });
130
+ try { await validate({ ...r, command }); }
131
+ catch (e) { failure = String(e?.message ?? e); commands = null; failed = true; break; }
105
132
  if (git(['rev-parse', 'HEAD'], r.wt) !== head || git(['status', '--porcelain', '--untracked-files=no'], r.wt).trim()) throw new Error(`${r.name}: the combined tree changed during validation.`);
106
133
  states[r.id] = { ...states[r.id], base: r.baseTip, head, validation: { command, head } };
107
134
  await report(states);
108
135
  }
136
+ if (failed) continue;
137
+ failure = null;
109
138
  // A sibling's validation may touch this repository too. Recheck the whole set.
110
139
  for (const r of pending) {
111
140
  if (git(['rev-parse', 'HEAD'], r.wt) !== states[r.id].head || git(['status', '--porcelain', '--untracked-files=no'], r.wt).trim()) throw new Error(`${r.name}: the combined tree changed after validation.`);
@@ -123,7 +152,7 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
123
152
  await report(states);
124
153
  for (const r of pending) await pr.linkRepositoryPrs?.(states[r.id].prUrl, { cwd: r.wt, cards: spec.members.flatMap((m) => m.cards), order, repositories: spec.repositories, states });
125
154
  }
126
- // A base race invalidates validation; send the whole set through the coordinator again.
155
+ // A base race invalidates validation; the next round folds the new base in and validates again.
127
156
  let raced = false;
128
157
  for (const id of order) {
129
158
  const r = pending.find((r) => r.id === id);
@@ -150,5 +179,5 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
150
179
  }
151
180
  if (!raced) return { states, worktrees };
152
181
  }
153
- throw new Error('The target base kept moving. Try the batch merge again; its selection and landed work have been saved.');
182
+ throw new Error(failure ?? 'The target base kept moving. Try the batch merge again; its selection and landed work have been saved.');
154
183
  }
@@ -74,14 +74,17 @@ export const isShellTool = name => nativeName(name) === 'Bash';
74
74
  export const isClaudeFollowupTool = name => NATIVE_AGENT_TOOLS.claude.followup.includes(nativeName(name));
75
75
  // The controller reads only Claude's stop: TaskStop names its child as task_id.
76
76
  export const isClaudeStopTool = name => NATIVE_AGENT_TOOLS.claude.stop.includes(nativeName(name));
77
+ /** Both runtime hook spellings for the capture server. */
78
+ export const captureToolName = name => String(name ?? '').match(/^(?:mcp__flowviant__|mcp[./]flowviant[./]|flowviant[./])([a-z_]+)$/)?.[1] ?? null;
77
79
 
78
80
  /** What the hook refuses while the controller is unreachable: every spawn and
79
81
  * follow-up the controller gates, and on a run whose children only read
80
82
  * (`children` 'read', a research card) a child's write or shell, which only
81
83
  * the controller can fence read-only. */
82
84
  export const GATED_TOOLS = Object.freeze([...SPAWN, ...FOLLOWUP]);
83
- export const gatedSpawnEvent = (event, children = 'edit') => event?.hook_event_name === 'PreToolUse' &&
85
+ export const gatedSpawnEvent = (event, children = 'edit', readOnlyRun = false) => event?.hook_event_name === 'PreToolUse' &&
84
86
  (GATED_TOOLS.includes(nativeName(event.tool_name)) ||
87
+ (readOnlyRun && (isChildWriteTool(event.tool_name) || Boolean(captureToolName(event.tool_name)) || /^(?:mcp__|mcp[./])/.test(String(event.tool_name)))) ||
85
88
  (children === 'read' && Boolean(event.agent_id) && (isChildWriteTool(event.tool_name) || isShellTool(event.tool_name))));
86
89
 
87
90
  /** Claude's PostToolUse and PostToolUseFailure matcher (orchestratorHooks.mjs):