flowviant 0.97.0 → 0.100.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.
Files changed (50) hide show
  1. package/README.md +15 -13
  2. package/bin/cli.mjs +108 -28
  3. package/bin/lib/agentPlan.mjs +159 -37
  4. package/bin/lib/artifacts.mjs +136 -26
  5. package/bin/lib/authReject.mjs +32 -0
  6. package/bin/lib/authproxy.mjs +52 -16
  7. package/bin/lib/childEnv.mjs +33 -1
  8. package/bin/lib/claude.mjs +166 -71
  9. package/bin/lib/config.mjs +36 -9
  10. package/bin/lib/credentials.mjs +29 -5
  11. package/bin/lib/deploy.mjs +364 -30
  12. package/bin/lib/desktopContract.mjs +227 -0
  13. package/bin/lib/env.mjs +130 -13
  14. package/bin/lib/fleet.mjs +121 -30
  15. package/bin/lib/git.mjs +131 -3
  16. package/bin/lib/hooks/readGuard.mjs +24 -0
  17. package/bin/lib/instance.mjs +129 -14
  18. package/bin/lib/knowledge.mjs +180 -60
  19. package/bin/lib/landed.mjs +38 -30
  20. package/bin/lib/launchCommand.mjs +23 -0
  21. package/bin/lib/listeners.mjs +211 -0
  22. package/bin/lib/localSessions.mjs +29 -4
  23. package/bin/lib/login.mjs +36 -12
  24. package/bin/lib/machines.mjs +24 -14
  25. package/bin/lib/mcp-cli.mjs +4 -3
  26. package/bin/lib/preflight.mjs +2 -1
  27. package/bin/lib/preview.mjs +95 -6
  28. package/bin/lib/printable.mjs +42 -0
  29. package/bin/lib/procRegistry.mjs +65 -0
  30. package/bin/lib/processes.mjs +20 -7
  31. package/bin/lib/projectTools.mjs +248 -0
  32. package/bin/lib/prompts.mjs +16 -11
  33. package/bin/lib/resources.mjs +46 -4
  34. package/bin/lib/runtimeOfferings.mjs +75 -0
  35. package/bin/lib/runtimes.mjs +71 -9
  36. package/bin/lib/shipMerge.mjs +8 -4
  37. package/bin/lib/trace.mjs +16 -6
  38. package/bin/lib/update.mjs +114 -25
  39. package/bin/lib/work.mjs +1192 -4584
  40. package/bin/lib/workAgentMerges.mjs +504 -0
  41. package/bin/lib/workAgentPlans.mjs +447 -0
  42. package/bin/lib/workAgentReview.mjs +702 -0
  43. package/bin/lib/workAgentTurns.mjs +1151 -0
  44. package/bin/lib/workDiffs.mjs +107 -0
  45. package/bin/lib/workPreviews.mjs +351 -0
  46. package/bin/lib/workProcesses.mjs +236 -0
  47. package/bin/lib/workPullRequests.mjs +313 -0
  48. package/bin/lib/workShip.mjs +385 -0
  49. package/bin/lib/worktreeDiff.mjs +99 -43
  50. package/package.json +2 -3
@@ -0,0 +1,385 @@
1
+ /** Session branch shipping and settlement. */
2
+ import { existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { git, baseBranchName, isSafePathSegment } from './git.mjs';
6
+ import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
7
+ import { c, note, ok, warn } from './ui.mjs';
8
+ import { scrub as envScrub } from './env.mjs';
9
+
10
+ export function createWorkShipper({
11
+ inPlace,
12
+ placeOf,
13
+ settleShip,
14
+ REPO_PLACE,
15
+ repoRoot,
16
+ baseDir,
17
+ gitNet,
18
+ baseRef,
19
+ placeWtFor,
20
+ turnLockedByLivePid,
21
+ sessionMetaPath,
22
+ state,
23
+ }) {
24
+ const { shipping, pendingShipReports } = state;
25
+
26
+ // Ship — a session's branch merging to main, on the human's word.
27
+ //
28
+ // --no-ff, NEVER squash: every delivered card carries commit shas as its
29
+ // receipts, and a squash would point them all at commits that no longer
30
+ // exist on main. Sequence: idempotency FIRST (a re-offered job after a lost
31
+ // report recovers its receipts and re-reports — it must never re-merge, and
32
+ // never be refused by checks that judge a merge this job already made).
33
+ // Then two paths. A LIVE session: re-open the worktree if it was retired,
34
+ // defer while a turn's CLI holds it, refuse a dirty worktree
35
+ // (auto-committing someone's mid-thought state is not shipping, it is
36
+ // guessing), refuse a worktree that left its own branch, fold main INTO the
37
+ // branch first so conflicts surface where the session can resolve them,
38
+ // then merge THE RESOLVED TIP outward through a throwaway worktree so
39
+ // nobody's checkout moves — receipts and merged ref are the same sha by
40
+ // construction. An ENDED session (absent from the roster's
41
+ // activeWorkSessions): the BRANCH is the session now — nobody can commit,
42
+ // discard, or resolve anything in its directory, so the checks whose
43
+ // remedies address a live tab don't apply; merge the tip directly through
44
+ // the throwaway, and a conflict fails honestly. Every exit reports
45
+ // ship-done exactly once — except a deliberate deferral, re-offered next
46
+ // poll; a ship that failed silently leaves the human believing their work
47
+ // is on main.
48
+ const processShipJobs = (jobs, activeIds) => {
49
+ // Field absent (older server) = no liveness signal: treat every session
50
+ // as live, which keeps the stricter checks.
51
+ const liveIds = Array.isArray(activeIds) ? new Set(activeIds) : null;
52
+ for (const job of jobs ?? []) {
53
+ if (!job || typeof job.sessionId !== 'string') continue;
54
+ if (shipping.has(job.sessionId)) continue;
55
+ // The merge already LANDED and only the report is owed — flushing
56
+ // delivers it; re-running the ship would misread its own success.
57
+ if (pendingShipReports.has(job.sessionId)) continue;
58
+ shipping.add(job.sessionId);
59
+ // A WRITER, keyed by PLACE: ship folds and merges with git in this
60
+ // directory, and no CLI running here can coordinate with that. Keyed by
61
+ // place rather than by session id, which is what this used to do while
62
+ // turns keyed on the place — so the two never shared a lock and the
63
+ // guarantee in this comment was not actually held. `shipping` (above)
64
+ // keeps overlapping polls from queueing the same job twice.
65
+ inPlace(placeOf(job.sessionId), true, async () => {
66
+ let settled = false;
67
+ let deferred = false;
68
+ const done = async (payload) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ await settleShip(job.sessionId, payload);
72
+ };
73
+ try {
74
+ if (!isSafePathSegment(job.sessionId)) {
75
+ await done({ ok: false, error: 'invalid session id' });
76
+ return;
77
+ }
78
+ note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
79
+ /**
80
+ * WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
81
+ *
82
+ * Ship used to compute `session/<id>` and then REFUSE if HEAD had
83
+ * moved: "ask it to return to its session branch, then ship again".
84
+ * That refusal is the thing this product says it never does — it had
85
+ * no reason of its own beyond bookkeeping, and in a terminal
86
+ * `git checkout -b` breaks nothing, which is the whole standard this
87
+ * surface is held to.
88
+ *
89
+ * The bug it was written for was real and is fixed properly here
90
+ * rather than frozen out: ship once merged the branch NAME while
91
+ * logging HEAD, so receipts named commits that never landed on main.
92
+ * That was TWO SOURCES OF TRUTH, not branch switching. There is one
93
+ * now, and it is the worktree's own HEAD.
94
+ *
95
+ * Resolved BEFORE the idempotency check below, and that ordering is
96
+ * load-bearing: `session/<id>` can still exist, stale and already an
97
+ * ancestor of base, while the real work sits on the branch that was
98
+ * checked out afterwards. Asking the old name first would answer
99
+ * "already merged — nothing new to ship" over unshipped commits.
100
+ *
101
+ * A directory that is gone (a retired or closed tab) cannot be asked,
102
+ * so the recorded name is the fallback — the one case where the name
103
+ * is the only thing there is.
104
+ */
105
+ // The session's PLACE — the directory it actually works in.
106
+ const shipPlace = placeOf(job.sessionId);
107
+ const wt = shipPlace === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', shipPlace);
108
+ let branch = `session/${job.sessionId}`;
109
+ let detached = false;
110
+ if (existsSync(wt)) {
111
+ try {
112
+ branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
113
+ } catch {
114
+ detached = true;
115
+ }
116
+ }
117
+ // THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
118
+ // no branch, so there is nothing to merge and nothing to record —
119
+ // that is an ambiguity in git, not a rule of ours.
120
+ if (detached) {
121
+ await done({
122
+ ok: false,
123
+ error:
124
+ 'this session is on a detached HEAD — no branch to ship. Ask it to check out a branch, then ship again',
125
+ });
126
+ return;
127
+ }
128
+ let branchExists = true;
129
+ try {
130
+ git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
131
+ } catch {
132
+ branchExists = false;
133
+ }
134
+ // "Nothing to ship" is a statement about the BRANCH. A retired
135
+ // directory is not a missing session — retirement promises that
136
+ // committed work survives, and ship re-attaches below to keep it.
137
+ if (!existsSync(wt) && !branchExists) {
138
+ await done({
139
+ ok: false,
140
+ error: 'nothing to ship — this session has no branch on this machine',
141
+ });
142
+ return;
143
+ }
144
+ try {
145
+ gitNet(['fetch', 'origin', '--quiet'], 60_000);
146
+ } catch {
147
+ /* offline fetch — merge against what we have */
148
+ }
149
+ const ancestorOfBase = (ref) => {
150
+ try {
151
+ git(['merge-base', '--is-ancestor', ref, baseRef()], repoRoot);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ };
157
+ // The machine may have no git identity, and a merge COMMIT needs
158
+ // one. Prefer the user's own config; fall back to the daemon's (the
159
+ // same fallback ship's merge keeps) so a bare machine doesn't fail
160
+ // the fold with "Please tell me who you are".
161
+ let idEnv = null;
162
+ try {
163
+ git(['config', 'user.email'], repoRoot);
164
+ } catch {
165
+ idEnv = {
166
+ GIT_AUTHOR_NAME: 'Flowviant',
167
+ GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
168
+ GIT_COMMITTER_NAME: 'Flowviant',
169
+ GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
170
+ };
171
+ }
172
+ const gitMerge = (args, cwd) =>
173
+ execFileSync('git', args, {
174
+ cwd,
175
+ encoding: 'utf8',
176
+ stdio: ['ignore', 'pipe', 'pipe'],
177
+ ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
178
+ });
179
+ // Receipts for a range: --no-merges, because fold commits describe
180
+ // plumbing, not work.
181
+ const logCommits = (range) =>
182
+ git(['log', range, '--no-merges', '--format=%H%x09%s'], repoRoot)
183
+ .split('\n')
184
+ .filter(Boolean)
185
+ .map((l) => {
186
+ const [sha, ...rest] = l.split('\t');
187
+ return { sha, subject: envScrub(rest.join('\t')).slice(0, 200) };
188
+ });
189
+ // Merge outward through a throwaway worktree so no checkout moves.
190
+ // The throwaway dies on EVERY exit — success, conflict or throw —
191
+ // or the next ship of this session trips over its corpse.
192
+ // Carry the tip out onto base and push it. See `shipMerge.mjs` for
193
+ // the throwaway-worktree shape, the one retry when two people ship at
194
+ // once, and why the operator's own branch is fast-forwarded after.
195
+ const mergeOutward = (tip, count) =>
196
+ shipMergeOutward({
197
+ tip,
198
+ count,
199
+ branch,
200
+ label: job.sessionName || job.sessionId.slice(0, 8),
201
+ git,
202
+ gitMerge,
203
+ repoRoot,
204
+ tmpDir: join(baseDir, 'ship', job.sessionId),
205
+ baseRef,
206
+ workingTree: placeWtFor(shipPlace)?.wt ?? null,
207
+ warn,
208
+ });
209
+ // Idempotency: base already contains the branch tip. A re-offered
210
+ // job after a lost report lands here — never a re-merge, and never
211
+ // "nothing to ship" AS A FAILURE for work that in fact shipped. The
212
+ // receipts must not die with the lost report: the --no-ff merge
213
+ // commit that carried the tip in holds it as its SECOND parent, so
214
+ // the original commit list is recoverable — settling with none
215
+ // would silently skip the reconciliation backstop for this branch.
216
+ if (branchExists && ancestorOfBase(branch)) {
217
+ const tip = git(['rev-parse', branch], repoRoot);
218
+ let commits = [];
219
+ try {
220
+ const m = git(['log', baseRef(), '--merges', '--format=%H %P', '-n', '500'], repoRoot)
221
+ .split('\n')
222
+ .map((l) => l.trim().split(' '))
223
+ .find((p) => p.length >= 3 && p[2] === tip);
224
+ if (m) commits = logCommits(`${m[1]}..${tip}`);
225
+ } catch {
226
+ /* recovery is best-effort — an ok ship with no receipts beats a false failure */
227
+ }
228
+ await done({
229
+ ok: true,
230
+ commits,
231
+ note: `${baseBranchName(baseRef())} already contains this session's branch — nothing new to merge`,
232
+ });
233
+ ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
234
+ return;
235
+ }
236
+ const ended = liveIds ? !liveIds.has(job.sessionId) : false;
237
+ if (ended) {
238
+ // The tab is closed: no turn can commit, discard, or resolve
239
+ // anything in the directory, so a dirty worktree must not strand
240
+ // the branch's committed work in review forever. Ship the TIP.
241
+ if (!branchExists) {
242
+ await done({
243
+ ok: false,
244
+ error: 'nothing to ship — this session has no branch on this machine',
245
+ });
246
+ return;
247
+ }
248
+ const tip = git(['rev-parse', branch], repoRoot);
249
+ const commits = logCommits(`${baseRef()}..${tip}`);
250
+ if (commits.length === 0) {
251
+ await done({
252
+ ok: false,
253
+ error: 'nothing to ship — no commits on the session branch',
254
+ });
255
+ return;
256
+ }
257
+ try {
258
+ mergeOutward(tip, commits.length);
259
+ } catch (e) {
260
+ const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
261
+ if (/conflict/i.test(detail)) {
262
+ await done({
263
+ ok: false,
264
+ error:
265
+ 'conflicts with main — the tab is closed, so open a new session from this branch to resolve them, then ship again',
266
+ });
267
+ } else {
268
+ const line = envScrub(
269
+ String(detail)
270
+ .split('\n')
271
+ .find((l) => l.trim()) ?? 'git merge failed'
272
+ );
273
+ await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
274
+ }
275
+ return;
276
+ }
277
+ await done({ ok: true, commits });
278
+ ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
279
+ return;
280
+ }
281
+ const dir = placeWtFor(shipPlace);
282
+ if (!dir) {
283
+ await done({
284
+ ok: false,
285
+ error: 'the session worktree could not be opened on this machine',
286
+ });
287
+ return;
288
+ }
289
+ // A live CLI is in this worktree — a restarted daemon's orphan
290
+ // mid-turn (in-process the chain serializes, but the lock is the
291
+ // only guarantee that survives a crash). Folding under it would
292
+ // rewrite HEAD inside a held conversation; defer like the turn
293
+ // path, and the job re-offers next poll.
294
+ if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
295
+ warn(
296
+ `a turn is still running in "${job.sessionName || job.sessionId}" — ship waits for it`
297
+ );
298
+ deferred = true;
299
+ return;
300
+ }
301
+ if (git(['status', '--porcelain'], dir.wt) !== '') {
302
+ await done({
303
+ ok: false,
304
+ error:
305
+ 'the session has uncommitted changes — ask it to commit or discard them first',
306
+ });
307
+ return;
308
+ }
309
+ // NO "return to your session branch" GUARD. `branch` was read from
310
+ // this worktree's HEAD above, so the fold, the tip and the receipts
311
+ // below all name the same thing by construction — which is what the
312
+ // old guard was really protecting, and it protected it by refusing
313
+ // instead of by measuring.
314
+ // Fold main into the branch FIRST: conflicts land here, in the
315
+ // session's own worktree, where the next turn can resolve them.
316
+ try {
317
+ gitMerge(['merge', '--no-edit', baseRef()], dir.wt);
318
+ } catch (e) {
319
+ const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
320
+ // NEVER leave the session mid-merge: a MERGE_HEAD left behind puts
321
+ // every later turn inside someone else's half-finished merge.
322
+ try {
323
+ git(['merge', '--abort'], dir.wt);
324
+ } catch {
325
+ /* nothing in progress */
326
+ }
327
+ if (/conflict/i.test(detail)) {
328
+ await done({
329
+ ok: false,
330
+ error: 'conflicts with main — ask the session to resolve them, then ship again',
331
+ });
332
+ } else {
333
+ // An honest error beats a fabricated conflict — the human can
334
+ // only fix what they are told about.
335
+ const line = envScrub(
336
+ String(detail)
337
+ .split('\n')
338
+ .find((l) => l.trim()) ?? 'git merge failed'
339
+ );
340
+ await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
341
+ }
342
+ return;
343
+ }
344
+ // Resolve the EXACT sha to merge, then compute the receipts from it:
345
+ // one X for both, so the ledger can never carry receipts for commits
346
+ // that did not land.
347
+ const tip = git(['rev-parse', branch], repoRoot);
348
+ const commits = logCommits(`${baseRef()}..${tip}`);
349
+ if (commits.length === 0) {
350
+ // Post-fold this is nearly unreachable (a zero-commit branch is an
351
+ // ancestor of base, settled above) — but if the branch's commits
352
+ // all exist on main already, say so truthfully.
353
+ if (ancestorOfBase(tip)) {
354
+ await done({ ok: true, commits: [], note: 'already merged — nothing new to ship' });
355
+ } else {
356
+ await done({
357
+ ok: false,
358
+ error: 'nothing to ship — no commits on the session branch',
359
+ });
360
+ }
361
+ return;
362
+ }
363
+ mergeOutward(tip, commits.length);
364
+ await done({ ok: true, commits });
365
+ ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
366
+ } catch (e) {
367
+ warn(`ship failed: ${e?.message ?? e}`);
368
+ await done({
369
+ ok: false,
370
+ error: envScrub(String(e?.message ?? 'the merge failed')).slice(0, 500),
371
+ });
372
+ } finally {
373
+ if (!settled && !deferred) {
374
+ // Belt over braces: NO exit path may leave the ship unreported —
375
+ // a deferral is the one deliberate exception, re-offered next poll.
376
+ await done({ ok: false, error: 'the ship did not complete — check the daemon log' });
377
+ }
378
+ shipping.delete(job.sessionId);
379
+ }
380
+ });
381
+ }
382
+ };
383
+
384
+ return { processShipJobs };
385
+ }
@@ -55,12 +55,26 @@ const MAX_COMMITS = 50;
55
55
  * a plausible id is dropped here rather than shipped: the server drops unknown
56
56
  * ids too, but a readout should not spend a request on obvious noise.
57
57
  */
58
+ /** A trailer line is short; one past this is not a trailer, and is never fed
59
+ * to a pattern at all. */
60
+ const MAX_TRAILER_LINE = 2000;
61
+
58
62
  export function taskIdsFromMessage(body) {
59
63
  const ids = [];
60
- for (const line of String(body || '').split('\n')) {
61
- const m = line.match(/^\s*Flowviant-Task\s*:\s*(.+?)\s*$/i);
64
+ for (const rawLine of String(body || '').split('\n')) {
65
+ // LINEAR, NEVER BACKTRACKING (audit 2026-09-24). This was
66
+ // `/^\s*Flowviant-Task\s*:\s*(.+?)\s*$/i`, whose lazy group against a
67
+ // trailing `\s*$` is QUADRATIC in the line: `Flowviant-Task: a` + 900k
68
+ // spaces + `b` — one commit any agent can write — held the daemon's event
69
+ // loop for minutes on every 60s sweep (and the landed observer re-parses
70
+ // it after a restart), so the machine read as offline. A length cap first,
71
+ // then an anchored prefix with no ambiguity, then plain string work.
72
+ if (rawLine.length > MAX_TRAILER_LINE) continue;
73
+ const line = rawLine.trim();
74
+ const m = /^Flowviant-Task[ \t]*:/i.exec(line);
62
75
  if (!m) continue;
63
- for (const raw of m[1].split(/[\s,]+/)) {
76
+ for (const raw of line.slice(m[0].length).split(/[\s,]+/)) {
77
+ if (!raw || raw.length > 80) continue; // an id is at most 64
64
78
  const id = raw.replace(/^[#<]+|[>,.]+$/g, '');
65
79
  if (id && id.length <= 64 && /^[A-Za-z0-9_-]+$/.test(id) && !ids.includes(id)) {
66
80
  ids.push(id);
@@ -100,58 +114,100 @@ export const stripDelims = (s) => String(s ?? '').replace(/[\x1e\x1f]/g, '');
100
114
  * sha is the same forgery wearing a real commit's name, and the delimiter
101
115
  * bytes are stripped from every surviving field.
102
116
  */
117
+ /**
118
+ * ONE COMMIT'S OWN RECORD, asked for BY SHA (audit 2026-09-24).
119
+ *
120
+ * The batched log above (`%H%x1f…%B%x1e` over the whole range) could be forged
121
+ * even with the rev-list set as authority: `git log` is newest first, so a
122
+ * commit whose body carried a fabricated record NAMING AN OLDER REAL SHA was
123
+ * parsed before that commit's own record, the "first record wins" rule kept the
124
+ * forgery, and the real one was dropped as a repeat — re-attributing somebody
125
+ * else's commit to other cards under a forged author and suppressing its real
126
+ * trailer. Asking git for one sha at a time makes the association structural:
127
+ * whatever this record says, it is about the sha we named, so a body can only
128
+ * ever speak for its own commit — which is what a trailer is.
129
+ *
130
+ * The body is the LAST field, so a NUL inside it (git refuses one in a message;
131
+ * a hand-built object might not) is body; the numstat follows it. Returns null
132
+ * when git will not answer.
133
+ */
134
+ export function commitRecord(sha, cwd, { numstat = false } = {}) {
135
+ let out;
136
+ try {
137
+ out = gitRaw(
138
+ [
139
+ 'show',
140
+ '--no-color',
141
+ ...(numstat ? ['--numstat'] : ['-s']),
142
+ '--format=%an%x00%aI%x00%B%x00',
143
+ sha,
144
+ '--',
145
+ ],
146
+ cwd
147
+ );
148
+ } catch {
149
+ return null;
150
+ }
151
+ const parts = out.split('\0');
152
+ if (parts.length < 4) return null;
153
+ const author = parts[0];
154
+ const at = parts[1];
155
+ const body = parts.slice(2, -1).join('\n');
156
+ // %s is the subject PARAGRAPH with its newlines folded, derived here from
157
+ // the body rather than asked for as a field a message could shift.
158
+ const subject = body.split(/\n[ \t]*\n/)[0].replace(/\s*\n\s*/g, ' ').trim();
159
+ let additions = 0;
160
+ let deletions = 0;
161
+ if (numstat) {
162
+ for (const l of parts[parts.length - 1].split('\n')) {
163
+ if (!l.trim()) continue;
164
+ const [a, d] = l.split('\t');
165
+ if (a === '-' || d === '-') continue; // binary
166
+ additions += Number(a) || 0;
167
+ deletions += Number(d) || 0;
168
+ }
169
+ }
170
+ return { sha, subject, author, at, body, additions, deletions };
171
+ }
172
+
103
173
  function branchCommits(wt, base) {
104
174
  if (!base) return [];
105
175
  const out = [];
106
176
  try {
107
- const real = new Set(
108
- git(['rev-list', '--no-merges', '-n', String(MAX_COMMITS), `${base}..HEAD`], wt)
177
+ const real = git(['rev-list', '--no-merges', '-n', String(MAX_COMMITS), `${base}..HEAD`], wt)
178
+ .split('\n')
179
+ .filter((x) => /^[0-9a-f]{40,64}$/.test(x));
180
+ if (real.length === 0) return [];
181
+ // One cheap look first: git's own grep names the commits whose message
182
+ // mentions the trailer at all, so a branch that never uses it pays one
183
+ // exec per sweep rather than one per commit, and no body is ever pulled
184
+ // through a pipe just to be searched. Only a HINT — each named commit is
185
+ // then read as itself below, and a sha outside the rev-list set is ignored.
186
+ const named = new Set(
187
+ git(
188
+ ['log', '--no-merges', '-i', '--grep=flowviant-task', '--format=%H', `${base}..HEAD`],
189
+ wt
190
+ )
109
191
  .split('\n')
110
192
  .filter(Boolean)
111
193
  );
112
- if (real.size === 0) return [];
113
- const raw = git(
114
- [
115
- 'log',
116
- '--no-merges',
117
- '-n',
118
- String(MAX_COMMITS),
119
- '--format=%H%x1f%s%x1f%an%x1f%aI%x1f%B%x1e',
120
- `${base}..HEAD`,
121
- ],
122
- wt
123
- );
124
- for (const rec of raw.split('\x1e')) {
125
- const line = rec.replace(/^\n+/, '');
126
- if (!line.trim()) continue;
127
- const [sha, subject, author, at, ...bodyParts] = line.split('\x1f');
128
- if (!real.has(sha)) continue;
129
- real.delete(sha);
130
- const taskIds = taskIdsFromMessage(stripDelims(bodyParts.join('\n')));
194
+ for (const sha of real) {
195
+ if (!named.has(sha)) continue;
196
+ // A commit we cannot stat (a numstat past the pipe's buffer) still names
197
+ // its cards — send it with zero counts rather than drop it, as before.
198
+ const rec = commitRecord(sha, wt, { numstat: true }) ?? commitRecord(sha, wt);
199
+ if (!rec) continue;
200
+ const taskIds = taskIdsFromMessage(stripDelims(rec.body));
131
201
  if (taskIds.length === 0) continue;
132
- let additions = 0;
133
- let deletions = 0;
134
- try {
135
- const stat = git(['show', '--numstat', '--format=', sha], wt);
136
- for (const l of stat.split('\n')) {
137
- if (!l.trim()) continue;
138
- const [a, d] = l.split('\t');
139
- if (a === '-' || d === '-') continue; // binary
140
- additions += Number(a) || 0;
141
- deletions += Number(d) || 0;
142
- }
143
- } catch {
144
- /* a commit we cannot stat still names its cards — send it anyway */
145
- }
146
202
  out.push({
147
203
  // Clamped to the server's zod caps, same rule as everything else in
148
204
  // this file: one over-cap string 400s the whole batch.
149
205
  sha: sha.slice(0, 64),
150
- subject: stripDelims(subject).slice(0, 200),
151
- author: stripDelims(author).slice(0, 80),
152
- at: stripDelims(at).slice(0, 40),
153
- additions,
154
- deletions,
206
+ subject: stripDelims(rec.subject).slice(0, 200),
207
+ author: stripDelims(rec.author).slice(0, 80),
208
+ at: stripDelims(rec.at).slice(0, 40),
209
+ additions: rec.additions,
210
+ deletions: rec.deletions,
155
211
  taskIds: taskIds.slice(0, 8),
156
212
  });
157
213
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.97.0",
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.",
3
+ "version": "0.100.0",
4
+ "description": "Run your own coding CLIs as build agents for Flowviant — 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": {
7
7
  "flowviant": "bin/cli.mjs"
@@ -19,7 +19,6 @@
19
19
  "test": "node --test bin/lib/*.test.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@anthropic-ai/claude-agent-sdk": "^0.3.0",
23
22
  "libsodium-wrappers-sumo": "^0.8.4",
24
23
  "ws": "^8.18.0"
25
24
  },