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/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 { runId, intentId } = claim;
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', `${base}..HEAD`], cwd);
133
- names = git(['diff', '--name-status', '--no-renames', `${base}..HEAD`], cwd);
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 >= MAX_DIFF_FILES) break;
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', `${base}..HEAD`, '--', path], cwd);
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
+ }
@@ -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) => ({ intentId: t.intentId, rss: processTreeRssBytes(t.pid) }))
125
- .filter((t) => t.intentId && t.rss),
124
+ .map((t) => ({ taskId: t.intentId, rss: processTreeRssBytes(t.pid) }))
125
+ .filter((t) => t.taskId && t.rss),
126
126
  };
127
127
  }
@@ -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
- * three `--allowedTools` lists in claude.mjs are what "build", "wiki" and
303
- * "consult" currently mean. That is a statement about where the contract was
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. Only one does.
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.40.1",
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": {