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 +2 -1
- package/bin/cli.mjs +5 -0
- package/bin/lib/artifactReporter.mjs +40 -3
- package/bin/lib/artifacts.mjs +9 -4
- package/bin/lib/captureAuthority.mjs +82 -0
- package/bin/lib/claudePosture.mjs +9 -0
- package/bin/lib/hooks/orchestrator.mjs +1 -1
- package/bin/lib/intermediateArtifacts.mjs +13 -10
- package/bin/lib/mcpStdio.mjs +11 -3
- package/bin/lib/mergeBatch.mjs +52 -23
- package/bin/lib/nativeAgentTools.mjs +4 -1
- package/bin/lib/orchestrator.mjs +46 -20
- package/bin/lib/orchestratorClient.mjs +2 -2
- package/bin/lib/orchestratorPrompt.mjs +70 -0
- package/bin/lib/readOnlyRun.mjs +172 -0
- package/bin/lib/repositoryPrompts.mjs +1 -1
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeClaude.mjs +6 -5
- package/bin/lib/runtimeCodex.mjs +4 -4
- package/bin/lib/runtimeEvents.mjs +4 -2
- package/bin/lib/subagentBounds.mjs +1 -1
- package/bin/lib/trace.mjs +9 -1
- package/bin/lib/turnProfile.mjs +1 -0
- package/bin/lib/workAgentCheck.mjs +14 -10
- package/bin/lib/workAgentPlans.mjs +69 -43
- package/bin/lib/workAgentTurnExecution.mjs +3 -2
- package/bin/lib/workAgentTurnOutcome.mjs +1 -1
- package/bin/lib/workCappedTurn.mjs +4 -0
- package/bin/lib/workMergeBatch.mjs +4 -0
- package/bin/lib/workNarration.mjs +3 -2
- package/bin/lib/workPlanRecovery.mjs +2 -21
- package/bin/lib/workSessionTurns.mjs +109 -22
- package/bin/lib/workToolLog.mjs +3 -0
- package/package.json +1 -1
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)
|
|
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
|
|
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
|
};
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
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
|
|
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.');
|
package/bin/lib/mcpStdio.mjs
CHANGED
|
@@ -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 {
|
|
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`);
|
package/bin/lib/mergeBatch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
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;
|
|
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):
|