flowviant 0.48.5 → 0.50.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/fleet.mjs CHANGED
@@ -550,6 +550,7 @@ export async function runFleetDaemon() {
550
550
  flushWorkReports,
551
551
  processWorkTurns,
552
552
  processShipJobs,
553
+ processDiffJobs,
553
554
  retireWorkSessions,
554
555
  reportWorktrees,
555
556
  shutdownWork,
@@ -1173,6 +1174,10 @@ export async function runFleetDaemon() {
1173
1174
  // sessions are LIVE, and the guards above (chains, shipping) are populated
1174
1175
  // by the intake this same tick.
1175
1176
  retireWorkSessions(roster.activeWorkSessions);
1177
+ // Diffs somebody has open and is waiting on. Project-scoped rather than
1178
+ // per-session: `git show` runs from the repo ROOT, which can see a closed
1179
+ // tab's branch and a shipped commit on main alike.
1180
+ processDiffJobs(roster.diffJobs);
1176
1181
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1177
1182
  // Throttled inside, never awaited — a `git status` the human cannot run
1178
1183
  // themselves from a browser, relayed. After retirement so a directory that
@@ -254,14 +254,30 @@ THE LEDGER. This session's work is logged as CARDS as it happens, by you,
254
254
  through tools — so a four-hour churn doesn't evaporate into scrollback. The
255
255
  rules:
256
256
 
257
- 6. CLAIM WHAT YOU WORK. When they say "take the auth card" or "next", call
258
- list_cards, then claim_card the one they mean. The card you hold is the
259
- tab's "Now" it is how they and their team see what this session is doing.
260
- 7. LOG DRIFT, don't ask permission for it. "Also fix that redirect" mid-flow:
257
+ 6. SAY WHAT YOU ARE DOING, on the card. When they say "take the auth card" or
258
+ "next", call list_cards, then log_work on the one they mean that puts it on
259
+ their Working pile and their name on it. Call it AGAIN at real milestones: a
260
+ decision made, a hard part landed, a blocker found. One short line, past
261
+ tense, what a person scanning the card in a week needs. NOT every turn, and
262
+ NOT your reasoning — this tab's transcript is where prose goes; the card is
263
+ the record.
264
+ NOTHING REFUSES YOU. Several sessions may work one card and you may work
265
+ several cards; a teammate already on it is worth SAYING and is never a reason
266
+ to stop. drop_card when they change course.
267
+ 7. PUT THE CARD IN YOUR COMMITS. Every commit for a card ends its message with a
268
+ trailer on its own line:
269
+
270
+ Flowviant-Task: <the card id>
271
+
272
+ That is how the card's Changes list, and its history, learn which commits
273
+ built it — from the machine, not from your memory. One trailer per card the
274
+ commit serves. A commit that belongs to no card needs none; shipping
275
+ reconciles those anyway.
276
+ 8. LOG DRIFT, don't ask permission for it. "Also fix that redirect" mid-flow:
261
277
  do the work, and file_card it — check list_cards FIRST; if a planned card
262
- already covers it, claim that one instead of filing a twin. One card per
263
- shippable unit. Never card-ify chatter, questions, or exploration.
264
- 8. PLANNING HAPPENS HERE. When they arrive with something big — "build the
278
+ already covers it, log_work against that one instead of filing a twin. One
279
+ card per shippable unit. Never card-ify chatter, questions, or exploration.
280
+ 9. PLANNING HAPPENS HERE. When they arrive with something big — "build the
265
281
  invite flow", "scaffold the admin area" — reading the code and breaking it
266
282
  into cards is YOUR job, in this tab. There is no planning surface anywhere
267
283
  else. Work it out with them in prose first; when the shape is settled, write
@@ -274,13 +290,13 @@ rules:
274
290
  empty and nothing breaks — the forecast quietly falls back to a flat default
275
291
  and the review has less to ask about. A card you have just designed is the
276
292
  only moment anyone knows those answers.
277
- 9. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
293
+ 10. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
278
294
  one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
279
295
  OBSERVED (the merge, on their word). Never claim done, and never deliver
280
296
  work that isn't committed.
281
- 10. RAISE WHAT YOU SPOT. A design flaw, a follow-up they named for later —
297
+ 11. RAISE WHAT YOU SPOT. A design flaw, a follow-up they named for later —
282
298
  raise_card, queued, unheld. You do not start raised work.
283
- 11. BE PROPORTIONAL. A one-line typo fix inside the card you already hold is
299
+ 12. BE PROPORTIONAL. A one-line typo fix inside the card you are already on is
284
300
  that card's work, not a new card. When in doubt, fewer cards. A plan is
285
301
  slices somebody could pick up one at a time, not a work-breakdown
286
302
  structure — if a card cannot be shipped on its own, it is not a card.
@@ -325,6 +341,9 @@ MECHANICS OF THIS TAB:
325
341
  3. YOU HAVE NO FLOWVIANT TOOLS in this session — no cards, no ledger calls.
326
342
  Don't mention or simulate them. Your commits ARE your record: when this
327
343
  tab's branch ships, every commit is reconciled onto the project ledger.
344
+ If the human names a card id, put it in the commit message as a trailer on
345
+ its own line — \`Flowviant-Task: <id>\` — and that commit will show up on the
346
+ card without any tool call. It is the one ledger gesture available here.
328
347
  4. NEVER merge to main, deploy, or force-push unless the human explicitly says
329
348
  so in this conversation. Branch pushes are fine when asked. Shipping is
330
349
  their word to say, not yours to infer.
package/bin/lib/work.mjs CHANGED
@@ -91,6 +91,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
91
91
  const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
92
92
  const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
93
93
  const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
94
+ const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
94
95
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
95
96
  const workAnswering = new Set(); // turn ids currently queued/running here
96
97
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
@@ -366,6 +367,104 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
366
367
  })();
367
368
  };
368
369
 
370
+ /**
371
+ * COMMIT DIFFS, on request — the one PULL-shaped thing this daemon does.
372
+ *
373
+ * Everything else here is a push: the machine knows something and says it.
374
+ * A patch cannot work that way, because most are never opened and pushing
375
+ * every one would be storage and bandwidth for nothing. So the server leaves
376
+ * a job on the roster and this drains it.
377
+ *
378
+ * RUN FROM THE REPO ROOT, never a session worktree. A tab can be closed and
379
+ * its directory gone, but `session/<id>` outlives the tab and after a ship
380
+ * the commit is on main — the root checkout can see all of it, and a
381
+ * worktree can see only its own branch.
382
+ *
383
+ * Every failure is REPORTED rather than swallowed: "no such commit on this
384
+ * machine" is a real answer, and a viewer spinning forever is the worst thing
385
+ * this can do. The one thing never sent is a guess — an empty patch would
386
+ * tell a reader the commit changed nothing.
387
+ */
388
+ const MAX_PATCH_BYTES = 256 * 1024;
389
+ const servedDiffs = new Set();
390
+ const postDiff = async (body) => {
391
+ try {
392
+ await fetch(DIFF_DONE_URL, {
393
+ method: 'POST',
394
+ headers: {
395
+ Authorization: `Bearer ${FLEET_TOKEN}`,
396
+ 'User-Agent': USER_AGENT,
397
+ 'Content-Type': 'application/json',
398
+ },
399
+ signal: AbortSignal.timeout(30_000),
400
+ body: JSON.stringify(body),
401
+ });
402
+ } catch {
403
+ /* the row stays pending and expires; the next click re-requests */
404
+ }
405
+ };
406
+ const processDiffJobs = (jobs) => {
407
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
408
+ for (const job of jobs.slice(0, 5)) {
409
+ const sha = String(job?.sha || '').toLowerCase();
410
+ // A sha becomes a `git` argument. The server validates too; one place
411
+ // doing this check is one deploy away from being zero places.
412
+ if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
413
+ // In-flight guard, not a cache: the server stops offering a sha the
414
+ // moment it settles, so this only stops the SAME poll's job being
415
+ // started twice while its `git show` is still running.
416
+ if (servedDiffs.has(sha)) continue;
417
+ servedDiffs.add(sha);
418
+ void (async () => {
419
+ try {
420
+ let patch = '';
421
+ const files = [];
422
+ try {
423
+ // `--format=` so the body is pure diff: the subject, author and
424
+ // date already reached the card on the worktree sweep, and
425
+ // repeating them inside the patch would put a second copy above
426
+ // every hunk.
427
+ patch = git(['show', '--patch', '--format=', sha], repoRoot);
428
+ } catch (e) {
429
+ await postDiff({ sha, error: String(e?.message || e).slice(0, 500) });
430
+ return;
431
+ }
432
+ try {
433
+ const raw = git(['show', '--numstat', '--format=', sha], repoRoot);
434
+ for (const line of raw.split('\n')) {
435
+ if (!line.trim()) continue;
436
+ const [a, d, ...rest] = line.split('\t');
437
+ const path = rest.join('\t');
438
+ if (!path) continue;
439
+ const binary = a === '-' || d === '-';
440
+ files.push({
441
+ path: path.slice(0, 300),
442
+ added: binary ? 0 : Number(a) || 0,
443
+ deleted: binary ? 0 : Number(d) || 0,
444
+ ...(binary ? { binary: true } : {}),
445
+ });
446
+ }
447
+ } catch {
448
+ /* a header without counts still beats no diff */
449
+ }
450
+ const truncated = Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES;
451
+ await postDiff({
452
+ sha,
453
+ // Cut on a LINE boundary: half a hunk header renders as garbage,
454
+ // and the viewer says the patch was truncated either way.
455
+ patch: truncated
456
+ ? patch.slice(0, MAX_PATCH_BYTES).replace(/\n[^\n]*$/, '\n')
457
+ : patch,
458
+ files: files.slice(0, 200),
459
+ truncated,
460
+ });
461
+ } finally {
462
+ servedDiffs.delete(sha);
463
+ }
464
+ })();
465
+ }
466
+ };
467
+
369
468
  /**
370
469
  * FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
371
470
  *
@@ -1754,6 +1853,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1754
1853
  flushWorkReports,
1755
1854
  processWorkTurns,
1756
1855
  processShipJobs,
1856
+ processDiffJobs,
1757
1857
  retireWorkSessions,
1758
1858
  reportWorktrees,
1759
1859
  shutdownWork,
@@ -13,6 +13,12 @@
13
13
  * uncommitted work in the same total. Untracked files count too: git calls them
14
14
  * nothing until they are added, and a human calls them new work.
15
15
  *
16
+ * It also carries THIS BRANCH'S OWN COMMITS and the cards they name — see
17
+ * `branchCommits` below. That rides here rather than on a route of its own
18
+ * because this sweep is already standing in the right directory on the right
19
+ * beat, and a second endpoint would be a second poll for a fact this one is
20
+ * next to.
21
+ *
16
22
  * Everything here is best-effort and read-only. A worktree mid-rebase, a
17
23
  * deleted directory, a file that vanished between listing and reading — each
18
24
  * degrades to a smaller answer, never to a thrown error. Nothing about a
@@ -30,6 +36,109 @@ const MAX_FILES = 20;
30
36
  const MAX_UNTRACKED_SCAN = 200;
31
37
  /** Past this we call a file binary rather than counting its lines. */
32
38
  const MAX_COUNT_BYTES = 512 * 1024;
39
+ /** Commits reported per sweep. The server caps each CARD at 50; this is the
40
+ * branch-wide bound, and a session branch past it is carrying an afternoon
41
+ * nobody is going to read commit-by-commit. */
42
+ const MAX_COMMITS = 50;
43
+
44
+ /**
45
+ * WHICH CARDS A COMMIT NAMES.
46
+ *
47
+ * The convention is a git TRAILER — `Flowviant-Task: <id>` on its own line at
48
+ * the foot of the message, the same shape as `Co-authored-by:` and `Signed-off-
49
+ * by:`. Chosen over another MCP call for three reasons: it costs no write
50
+ * budget, it works on agy tabs which cannot mount MCP at all, and the commit IS
51
+ * the evidence rather than an assertion about it.
52
+ *
53
+ * Case-insensitive on the key and tolerant of several ids on one line, because
54
+ * a person or an agent writing this by hand will do both. Anything that is not
55
+ * a plausible id is dropped here rather than shipped: the server drops unknown
56
+ * ids too, but a readout should not spend a request on obvious noise.
57
+ */
58
+ function taskIdsFromMessage(body) {
59
+ const ids = [];
60
+ for (const line of String(body || '').split('\n')) {
61
+ const m = line.match(/^\s*Flowviant-Task\s*:\s*(.+?)\s*$/i);
62
+ if (!m) continue;
63
+ for (const raw of m[1].split(/[\s,]+/)) {
64
+ const id = raw.replace(/^[#<]+|[>,.]+$/g, '');
65
+ if (id && id.length <= 64 && /^[A-Za-z0-9_-]+$/.test(id) && !ids.includes(id)) {
66
+ ids.push(id);
67
+ }
68
+ }
69
+ }
70
+ return ids;
71
+ }
72
+
73
+ /**
74
+ * The commits this branch has that base does not, with the cards they name.
75
+ *
76
+ * Only commits carrying a trailer are RETURNED: an untrailered commit belongs
77
+ * to no card, and ship-time reconciliation already turns those into a bundle so
78
+ * nothing shipped is invisible. Sending them anyway would be a payload the
79
+ * server drops on every sweep.
80
+ *
81
+ * `--no-merges`, because a merge commit describes a range rather than doing
82
+ * work, and its trailer (if it has one) would double-book the range's own
83
+ * commits.
84
+ */
85
+ function branchCommits(wt, base) {
86
+ if (!base) return [];
87
+ const out = [];
88
+ try {
89
+ // %x1e between records, %x1f between fields — a subject and a body can
90
+ // contain anything a person can type, so the delimiters must be bytes they
91
+ // cannot.
92
+ const raw = git(
93
+ [
94
+ 'log',
95
+ '--no-merges',
96
+ '-n',
97
+ String(MAX_COMMITS),
98
+ '--format=%H%x1f%s%x1f%an%x1f%aI%x1f%B%x1e',
99
+ `${base}..HEAD`,
100
+ ],
101
+ wt
102
+ );
103
+ for (const rec of raw.split('\x1e')) {
104
+ const line = rec.replace(/^\n+/, '');
105
+ if (!line.trim()) continue;
106
+ const [sha, subject, author, at, body] = line.split('\x1f');
107
+ if (!sha) continue;
108
+ const taskIds = taskIdsFromMessage(body);
109
+ if (taskIds.length === 0) continue;
110
+ let additions = 0;
111
+ let deletions = 0;
112
+ try {
113
+ const stat = git(['show', '--numstat', '--format=', sha], wt);
114
+ for (const l of stat.split('\n')) {
115
+ if (!l.trim()) continue;
116
+ const [a, d] = l.split('\t');
117
+ if (a === '-' || d === '-') continue; // binary
118
+ additions += Number(a) || 0;
119
+ deletions += Number(d) || 0;
120
+ }
121
+ } catch {
122
+ /* a commit we cannot stat still names its cards — send it anyway */
123
+ }
124
+ out.push({
125
+ // Clamped to the server's zod caps, same rule as everything else in
126
+ // this file: one over-cap string 400s the whole batch.
127
+ sha: sha.slice(0, 64),
128
+ subject: (subject ?? '').slice(0, 200),
129
+ author: (author ?? '').slice(0, 80),
130
+ at: (at ?? '').slice(0, 40),
131
+ additions,
132
+ deletions,
133
+ taskIds: taskIds.slice(0, 8),
134
+ });
135
+ }
136
+ } catch {
137
+ /* no base, unborn branch, or a repo mid-rebase — report no commits */
138
+ }
139
+ // Oldest first, so a card's list reads in the order the work happened.
140
+ return out.reverse();
141
+ }
33
142
 
34
143
  /** Lines in a buffer, the way a diff counts them: a trailing newline does not
35
144
  * add a line, and a NUL byte anywhere means we are not looking at text. */
@@ -48,7 +157,9 @@ function countLines(buf) {
48
157
  * baseLabel:string, baseCommits:{sha:string, subject:string, author:string}[],
49
158
  * dirty:boolean, additions:number, deletions:number, fileCount:number,
50
159
  * truncated:number,
51
- * files:{path:string, added:number, deleted:number, binary?:boolean}[]}}
160
+ * files:{path:string, added:number, deleted:number, binary?:boolean}[],
161
+ * commits:{sha:string, subject:string, author:string, at:string,
162
+ * additions:number, deletions:number, taskIds:string[]}[]}}
52
163
  */
53
164
  export function worktreeDiff(wt, baseRef) {
54
165
  if (!wt || !existsSync(wt)) return null;
@@ -189,5 +300,6 @@ export function worktreeDiff(wt, baseRef) {
189
300
  fileCount: files.length,
190
301
  truncated: Math.max(0, files.length - MAX_FILES),
191
302
  files: files.slice(0, MAX_FILES),
303
+ commits: branchCommits(wt, base),
192
304
  };
193
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.48.5",
3
+ "version": "0.50.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {