flowviant 0.40.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/claude.mjs +203 -3
- package/bin/lib/fleet.mjs +557 -68
- package/bin/lib/live.mjs +39 -5
- package/bin/lib/patch.mjs +73 -5
- package/bin/lib/resources.mjs +2 -2
- package/bin/lib/runtimes.mjs +48 -7
- package/package.json +1 -1
package/bin/lib/live.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
restoreWip,
|
|
44
44
|
clearWip,
|
|
45
45
|
} from './git.mjs';
|
|
46
|
-
import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
|
|
46
|
+
import { applyPatch, commitHistory, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
|
|
47
47
|
import { RUNTIMES, runtimeById, drivableHere, mediated } from './runtimes.mjs';
|
|
48
48
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
49
49
|
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
@@ -88,7 +88,7 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
88
88
|
'Content-Type': 'application/json',
|
|
89
89
|
},
|
|
90
90
|
signal: AbortSignal.timeout(30_000),
|
|
91
|
-
body: JSON.stringify({ intentId, kind, url, ttlMinutes: PREVIEW_TTL_MINUTES }),
|
|
91
|
+
body: JSON.stringify({ taskId: intentId, kind, url, ttlMinutes: PREVIEW_TTL_MINUTES }),
|
|
92
92
|
});
|
|
93
93
|
} catch {
|
|
94
94
|
/* best-effort — the tunnel still works; it just isn't linked in the app */
|
|
@@ -108,7 +108,7 @@ function clearLiveTarget(intentId, kind) {
|
|
|
108
108
|
'Content-Type': 'application/json',
|
|
109
109
|
},
|
|
110
110
|
signal: AbortSignal.timeout(5_000),
|
|
111
|
-
body: JSON.stringify({ intentId, kind }),
|
|
111
|
+
body: JSON.stringify({ taskId: intentId, kind }),
|
|
112
112
|
}).catch(() => {});
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -127,7 +127,7 @@ function postPreviewNote(intentId, text) {
|
|
|
127
127
|
signal: AbortSignal.timeout(10_000),
|
|
128
128
|
// Scrub: preview failure reasons can quote dev-server output, which can
|
|
129
129
|
// echo env values.
|
|
130
|
-
body: JSON.stringify({ intentId, text: envScrub(text) }),
|
|
130
|
+
body: JSON.stringify({ taskId: intentId, text: envScrub(text) }),
|
|
131
131
|
}).catch(() => {});
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -316,6 +316,33 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
316
316
|
// The flowviant MCP endpoint handles tools/call statelessly with a bearer
|
|
317
317
|
// worker token — no handshake — so this is all the daemon needs.
|
|
318
318
|
let rpcId = 0;
|
|
319
|
+
/**
|
|
320
|
+
* Push this task's commits + real diffs to the control plane.
|
|
321
|
+
*
|
|
322
|
+
* The server used to fetch exactly this from github.com with a GitHub App
|
|
323
|
+
* installation token — the app existed largely for it. We are standing in the
|
|
324
|
+
* worktree that produced these commits, so we send them: the thread's diff
|
|
325
|
+
* timeline, the review quiz and the merge gate's approved-head pin all read
|
|
326
|
+
* what lands here.
|
|
327
|
+
*
|
|
328
|
+
* Best-effort by design. A failure here must never fail the run — the work is
|
|
329
|
+
* committed and the PR is open either way, and the next push reports again.
|
|
330
|
+
* What it costs when it does fail is visible rather than silent: the thread
|
|
331
|
+
* shows no diffs, which is the same thing it showed when GitHub was unreachable.
|
|
332
|
+
*/
|
|
333
|
+
async function reportCommits({ mcpUrl, token, runId, cwd, baseRef }) {
|
|
334
|
+
try {
|
|
335
|
+
const base = baseRef ?? 'HEAD';
|
|
336
|
+
const commits = commitHistory(cwd, base);
|
|
337
|
+
if (commits.length === 0) return;
|
|
338
|
+
const headSha = commits[commits.length - 1].sha;
|
|
339
|
+
const res = await mcpCall(mcpUrl, token, 'report_commits', { runId, headSha, commits });
|
|
340
|
+
if (res?.ok === false) warn(`report_commits rejected: ${res.reason ?? 'unknown'}`);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
warn(`report_commits skipped: ${e?.message ?? String(e)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
319
346
|
async function mcpCall(mcpUrl, token, name, args) {
|
|
320
347
|
const res = await fetch(mcpUrl, {
|
|
321
348
|
method: 'POST',
|
|
@@ -1023,6 +1050,7 @@ async function driveMediated({
|
|
|
1023
1050
|
...(result.branch ? { branch: String(result.branch) } : {}),
|
|
1024
1051
|
}).catch((e) => ({ ok: false, reason: e?.message ?? String(e) }));
|
|
1025
1052
|
if (attached?.ok === false) warn(`attach_pr rejected: ${attached.reason ?? 'unknown'}`);
|
|
1053
|
+
else await reportCommits({ mcpUrl, token, runId, cwd, baseRef });
|
|
1026
1054
|
}
|
|
1027
1055
|
}
|
|
1028
1056
|
clearTaskMarker(cwd);
|
|
@@ -1274,7 +1302,13 @@ export async function runLiveTask({
|
|
|
1274
1302
|
runtimes: DRIVABLE_HERE,
|
|
1275
1303
|
}).catch(() => null);
|
|
1276
1304
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
1277
|
-
const
|
|
1305
|
+
const runId = claim.runId;
|
|
1306
|
+
// New name first: the server returns `taskId` natively and mirrors
|
|
1307
|
+
// `intentId` beside it for exactly this read. Reading taskId is what lets
|
|
1308
|
+
// that mirror (and the fleet routes' intentId compat) retire once
|
|
1309
|
+
// daemon:min passes this release. The variable keeps the old spelling —
|
|
1310
|
+
// it is the daemon's internal word, not a wire field.
|
|
1311
|
+
const intentId = claim.taskId ?? claim.intentId;
|
|
1278
1312
|
const brief = claim.brief ?? {};
|
|
1279
1313
|
const title = brief.title ?? 'a task';
|
|
1280
1314
|
|
package/bin/lib/patch.mjs
CHANGED
|
@@ -125,12 +125,15 @@ const DIFF_STATUS = { A: 'added', D: 'removed', M: 'modified' };
|
|
|
125
125
|
* pass back to `git diff -- <path>`. A rename showing up as a delete plus an add
|
|
126
126
|
* is a slightly longer diff and a correct one.
|
|
127
127
|
*/
|
|
128
|
-
export function fileDiffs(cwd, base) {
|
|
128
|
+
export function fileDiffs(cwd, base, { range, maxFiles = MAX_DIFF_FILES } = {}) {
|
|
129
|
+
// `range` lets the per-COMMIT walk reuse this (`sha^..sha`); without it the
|
|
130
|
+
// original meaning holds — everything the agent did since `base`.
|
|
131
|
+
const rev = range ?? `${base}..HEAD`;
|
|
129
132
|
let numstat = '';
|
|
130
133
|
let names = '';
|
|
131
134
|
try {
|
|
132
|
-
numstat = git(['diff', '--numstat', '--no-renames',
|
|
133
|
-
names = git(['diff', '--name-status', '--no-renames',
|
|
135
|
+
numstat = git(['diff', '--numstat', '--no-renames', rev], cwd);
|
|
136
|
+
names = git(['diff', '--name-status', '--no-renames', rev], cwd);
|
|
134
137
|
} catch {
|
|
135
138
|
return [];
|
|
136
139
|
}
|
|
@@ -144,7 +147,7 @@ export function fileDiffs(cwd, base) {
|
|
|
144
147
|
|
|
145
148
|
const out = [];
|
|
146
149
|
for (const line of numstat.split('\n')) {
|
|
147
|
-
if (out.length >=
|
|
150
|
+
if (out.length >= maxFiles) break;
|
|
148
151
|
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line.replace(/\n$/, ''));
|
|
149
152
|
if (!m) continue;
|
|
150
153
|
const path = m[3].trim();
|
|
@@ -154,7 +157,7 @@ export function fileDiffs(cwd, base) {
|
|
|
154
157
|
let patch = null;
|
|
155
158
|
if (!binary) {
|
|
156
159
|
try {
|
|
157
|
-
const full = git(['diff',
|
|
160
|
+
const full = git(['diff', rev, '--', path], cwd);
|
|
158
161
|
// Drop git's own "diff --git a/… b/…" preamble; the card shows the path.
|
|
159
162
|
const at = full.indexOf('@@');
|
|
160
163
|
const hunks = at === -1 ? full : full.slice(at);
|
|
@@ -293,3 +296,68 @@ export function revertPatch({ repoRoot, shas }) {
|
|
|
293
296
|
return { ok: false, error: e?.message ?? String(e) };
|
|
294
297
|
}
|
|
295
298
|
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
// How many commits of a task's branch we carry across. The server used to read
|
|
302
|
+
// this from GitHub and capped at 50 for the same reason: each commit costs a
|
|
303
|
+
// diff, and a runaway branch must not fan out unbounded work or produce a row
|
|
304
|
+
// too big to read on every card render. Truncation keeps the MOST RECENT
|
|
305
|
+
// commits — the tail is what a reviewer is looking at.
|
|
306
|
+
const MAX_COMMITS = 50;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A task branch's commits with their real per-file diffs, in the exact shape
|
|
310
|
+
* the server's GitHub read used to return (`TaskCommit[]`).
|
|
311
|
+
*
|
|
312
|
+
* This is the function that let the GitHub App die. The server used to resolve
|
|
313
|
+
* the project's linked repo, mint an installation token, fetch
|
|
314
|
+
* `GET /pulls/{n}/commits` and then run an N+1 of `GET /commits/{sha}` for the
|
|
315
|
+
* per-file patches — up to ~52 API calls to describe work THIS process had just
|
|
316
|
+
* performed, in a checkout it is standing in. Now the daemon reports it through
|
|
317
|
+
* `report_commits` and the server reads a row.
|
|
318
|
+
*
|
|
319
|
+
* Oldest → newest, because the thread appends chronologically.
|
|
320
|
+
*/
|
|
321
|
+
export function commitHistory(cwd, base) {
|
|
322
|
+
let log = '';
|
|
323
|
+
try {
|
|
324
|
+
// %x1f/%x1e are unit/record separators: a commit subject can contain
|
|
325
|
+
// anything, tabs and pipes included, so the delimiters have to be bytes a
|
|
326
|
+
// human will never type.
|
|
327
|
+
log = git(
|
|
328
|
+
['log', '--reverse', `--max-count=${MAX_COMMITS}`, '--format=%H%x1f%s%x1f%an%x1f%aI%x1e', `${base}..HEAD`],
|
|
329
|
+
cwd,
|
|
330
|
+
);
|
|
331
|
+
} catch {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const record of log.split('\x1e')) {
|
|
337
|
+
const line = record.trim();
|
|
338
|
+
if (!line) continue;
|
|
339
|
+
const [sha, message, authorName, committedAt] = line.split('\x1f');
|
|
340
|
+
if (!sha) continue;
|
|
341
|
+
// First-parent range for the commit itself. A root commit has no `^`, in
|
|
342
|
+
// which case git's empty-tree hash gives us the whole thing as an add.
|
|
343
|
+
let range = `${sha}^..${sha}`;
|
|
344
|
+
try {
|
|
345
|
+
git(['rev-parse', `${sha}^`], cwd);
|
|
346
|
+
} catch {
|
|
347
|
+
range = `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${sha}`;
|
|
348
|
+
}
|
|
349
|
+
const files = fileDiffs(cwd, null, { range });
|
|
350
|
+
out.push({
|
|
351
|
+
sha,
|
|
352
|
+
message: (message ?? '').slice(0, 500),
|
|
353
|
+
authorName: (authorName ?? '').slice(0, 200),
|
|
354
|
+
authorLogin: null,
|
|
355
|
+
committedAt: committedAt ?? new Date().toISOString(),
|
|
356
|
+
url: null,
|
|
357
|
+
additions: files.reduce((n, f) => n + f.additions, 0),
|
|
358
|
+
deletions: files.reduce((n, f) => n + f.deletions, 0),
|
|
359
|
+
files,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
package/bin/lib/resources.mjs
CHANGED
|
@@ -121,7 +121,7 @@ export function machineSnapshot({ worktreeDir, tasks = [] } = {}) {
|
|
|
121
121
|
diskTotal: disk?.total ?? null,
|
|
122
122
|
// Per-task, so "the box is full" can be traced to the task that filled it.
|
|
123
123
|
tasks: tasks
|
|
124
|
-
.map((t) => ({
|
|
125
|
-
.filter((t) => t.
|
|
124
|
+
.map((t) => ({ taskId: t.intentId, rss: processTreeRssBytes(t.pid) }))
|
|
125
|
+
.filter((t) => t.taskId && t.rss),
|
|
126
126
|
};
|
|
127
127
|
}
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -299,11 +299,11 @@ export const RUNTIMES = {
|
|
|
299
299
|
live: true,
|
|
300
300
|
/**
|
|
301
301
|
* Every profile, because every profile is DEFINED in its vocabulary: the
|
|
302
|
-
*
|
|
303
|
-
* "consult" currently mean. That is a statement about where the
|
|
304
|
-
* written, not a claim that only Claude could ever satisfy it.
|
|
302
|
+
* four `--allowedTools` lists in claude.mjs are what "build", "wiki",
|
|
303
|
+
* "consult" and "plan" currently mean. That is a statement about where the
|
|
304
|
+
* contract was written, not a claim that only Claude could ever satisfy it.
|
|
305
305
|
*/
|
|
306
|
-
profiles: ['build', 'wiki', 'consult'],
|
|
306
|
+
profiles: ['build', 'wiki', 'consult', 'plan'],
|
|
307
307
|
mcp: claudeMcp,
|
|
308
308
|
/**
|
|
309
309
|
* Claude takes the operating contract as a real system prompt, which is the
|
|
@@ -349,7 +349,7 @@ export const RUNTIMES = {
|
|
|
349
349
|
* and Windows are UNTESTED; if this daemon starts running there, re-verify
|
|
350
350
|
* before trusting the consult posture on those platforms.
|
|
351
351
|
*/
|
|
352
|
-
profiles: ['build', 'consult', 'wiki'],
|
|
352
|
+
profiles: ['build', 'consult', 'wiki', 'plan'],
|
|
353
353
|
mcp: codexMcp,
|
|
354
354
|
/**
|
|
355
355
|
* Codex has NO system-prompt flag. The contract therefore rides inside the
|
|
@@ -418,6 +418,30 @@ export const RUNTIMES = {
|
|
|
418
418
|
// we are asserting on their behalf — silently, and on the one turn whose
|
|
419
419
|
// prompt comes from someone else's typing.
|
|
420
420
|
a.push('--ignore-user-config', '--ignore-rules');
|
|
421
|
+
} else if (profile === 'plan') {
|
|
422
|
+
// A PLANNING SESSION. Read-only on the filesystem, exactly like a
|
|
423
|
+
// consult — the writes it makes go through the control plane, not
|
|
424
|
+
// through this box — so the kernel sandbox is the same one, and for the
|
|
425
|
+
// same reason: this turn's prompt is steered by anything a project
|
|
426
|
+
// editor can type.
|
|
427
|
+
//
|
|
428
|
+
// Everything the consult branch above closes stays closed, and the
|
|
429
|
+
// reasoning is unchanged, so it is not restated: web_search egresses
|
|
430
|
+
// server-side at OpenAI where no local sandbox reaches it, sub-agents
|
|
431
|
+
// would be a turn whose posture nobody here chose, and a user's own
|
|
432
|
+
// config or MCP servers must not widen a posture we are asserting on
|
|
433
|
+
// their behalf.
|
|
434
|
+
//
|
|
435
|
+
// What differs from a consult is the ONE thing this profile exists for:
|
|
436
|
+
// an MCP config IS passed, carrying the plan principal's token. That
|
|
437
|
+
// token's whole tool set is the five plan tools — the server refuses
|
|
438
|
+
// anything else on it — so the control plane being open here does not
|
|
439
|
+
// widen what a hijacked turn could reach beyond the plan it is already
|
|
440
|
+
// sitting in.
|
|
441
|
+
a.push('--sandbox', 'read-only');
|
|
442
|
+
a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
|
|
443
|
+
a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
|
|
444
|
+
a.push('--ignore-user-config', '--ignore-rules');
|
|
421
445
|
} else if (profile === 'wiki' && vaultDir) {
|
|
422
446
|
// THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
|
|
423
447
|
//
|
|
@@ -572,6 +596,17 @@ export const RUNTIMES = {
|
|
|
572
596
|
* MCP connection and the CLI just returns schema-enforced JSON via
|
|
573
597
|
* `--json-schema`), which needs no per-invocation MCP config from the vendor
|
|
574
598
|
* at all.
|
|
599
|
+
*
|
|
600
|
+
* PLAN IS ABSENT ON PURPOSE, and NOT because a planning turn is beyond it —
|
|
601
|
+
* it reads code as well as anything here. A plan session is a conversation
|
|
602
|
+
* that makes many control-plane calls as it goes (spawn a slice, re-shape
|
|
603
|
+
* it, drop it, rewrite the spec), and mediation turns a turn into ONE
|
|
604
|
+
* schema-enforced form the daemon then applies. That shape fits a build,
|
|
605
|
+
* whose outcome is a single structured result; it does not yet fit an
|
|
606
|
+
* argument. Note that `canRun` would otherwise say yes via `mediated()` and
|
|
607
|
+
* hand this runtime a job it cannot finish — declaring the profile is the
|
|
608
|
+
* only thing standing between here and that. Mediated planning is a real
|
|
609
|
+
* design (the session returns its writes as a batch), just not a built one.
|
|
575
610
|
*/
|
|
576
611
|
profiles: ['build', 'wiki', 'consult'],
|
|
577
612
|
mcp: null,
|
|
@@ -687,7 +722,7 @@ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
|
|
|
687
722
|
* disagree the daemon either claims work it cannot build or refuses work it can.
|
|
688
723
|
*/
|
|
689
724
|
/**
|
|
690
|
-
* WHICH PROFILES NEED THE MCP CONTROL PLANE.
|
|
725
|
+
* WHICH PROFILES NEED THE MCP CONTROL PLANE. Two do.
|
|
691
726
|
*
|
|
692
727
|
* A BUILD has to claim work, report a blocker, attach a PR and complete — that
|
|
693
728
|
* is the control plane, and a runtime that cannot reach it cannot participate.
|
|
@@ -696,11 +731,17 @@ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
|
|
|
696
731
|
* included — check the two call sites in fleet.mjs, they hand `runTurn` no
|
|
697
732
|
* `mcpArgs` at all.
|
|
698
733
|
*
|
|
734
|
+
* A PLAN is the second one, and it is the reason the consult stopped being the
|
|
735
|
+
* whole story: a planning session does not answer a question, it WRITES the plan
|
|
736
|
+
* — spawns the slices, re-shapes them, drops them, maintains the spec. Every one
|
|
737
|
+
* of those is a control-plane call, so a runtime that cannot reach MCP cannot
|
|
738
|
+
* host a session, however well it reads code.
|
|
739
|
+
*
|
|
699
740
|
* Conflating them cost Antigravity every capability it has: `mcp && args` was
|
|
700
741
|
* the single drivability test, so a machine-wide MCP config disqualified it from
|
|
701
742
|
* two jobs that never open an MCP connection.
|
|
702
743
|
*/
|
|
703
|
-
const PROFILE_NEEDS_MCP = { build: true, wiki: false, consult: false };
|
|
744
|
+
const PROFILE_NEEDS_MCP = { build: true, wiki: false, consult: false, plan: true };
|
|
704
745
|
|
|
705
746
|
/**
|
|
706
747
|
* A build needs the control plane, but NOT necessarily an MCP config of its own.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|