flowviant 0.86.0 → 0.88.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/work.mjs CHANGED
@@ -52,7 +52,8 @@ import { listenersIn, measureListeners, listenersSupported } from './listeners.m
52
52
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
53
53
  import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
54
54
  import { createPlaceLock } from './placeLock.mjs';
55
- import { parseProposal, parseTurnResult } from './agentPlan.mjs';
55
+ import { parseProposal, parsePrecheck, parseTurnResult } from './agentPlan.mjs';
56
+ import { readStash, stashCard } from './agentCards.mjs';
56
57
  import { sweepMergedBranch } from './shipSweep.mjs';
57
58
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
58
59
  import { openTunnel } from './preview.mjs';
@@ -68,8 +69,11 @@ import {
68
69
  SYSTEM_PLAN,
69
70
  AGENT_PLAN_KICKOFF,
70
71
  SYSTEM_AGENT,
72
+ SYSTEM_PRECHECK,
71
73
  AGENT_TASK_KICKOFF,
74
+ AGENT_TASK_SPEC,
72
75
  AGENT_HUMAN_KICKOFF,
76
+ AGENT_PRECHECK_KICKOFF,
73
77
  } from './prompts.mjs';
74
78
  import {
75
79
  materializeInto,
@@ -84,6 +88,7 @@ import {
84
88
  pickRuntimeFor,
85
89
  recordSkills,
86
90
  toolEventOf,
91
+ removeProbeTranscript,
87
92
  CLAUDE_TOOL_PROSE_KINDS,
88
93
  RUNTIMES,
89
94
  } from './runtimes.mjs';
@@ -222,6 +227,7 @@ export function createWorkManager({
222
227
  const AGENT_TRACE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-trace');
223
228
  const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
224
229
  const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
230
+ const AGENT_PRECHECK_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-precheck');
225
231
  const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
226
232
  const AGENT_MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-done');
227
233
  // What arrived on base, whichever road it took — observed after every beat
@@ -4810,6 +4816,34 @@ export function createWorkManager({
4810
4816
  */
4811
4817
  const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
4812
4818
 
4819
+ /**
4820
+ * WRITE THE CARD DOWN BEFORE HANDING IT OVER — the material the AI
4821
+ * pre-review reads at review entry (see agentCards.mjs).
4822
+ *
4823
+ * HERE rather than at review entry because here is the ONLY moment this
4824
+ * machine holds the card at all: the server feeds an agent one card per
4825
+ * prompt and keeps no copy on this disk, so by the time the queue empties
4826
+ * card one exists locally as nothing but its own commit messages — which
4827
+ * are a claim about the work, not the specification it was judged against.
4828
+ *
4829
+ * BEFORE the CLI runs rather than after, so a turn that crashes still
4830
+ * leaves the spec behind: the card really was given to the agent, the
4831
+ * commits it made are on the branch, and a reviewer is entitled to read
4832
+ * what was asked for either way.
4833
+ *
4834
+ * ONE SPEC BUILDER (`AGENT_TASK_SPEC`) shared with the kickoff below, so
4835
+ * what the reviewer reads is byte-identical to what the agent read.
4836
+ * Failure is swallowed inside `stashCard`: a note about a turn may never
4837
+ * cost the turn.
4838
+ */
4839
+ if (job.kind === 'task' && job.task) {
4840
+ stashCard(
4841
+ sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
4842
+ job.task.id,
4843
+ AGENT_TASK_SPEC(job.task)
4844
+ );
4845
+ }
4846
+
4813
4847
  /**
4814
4848
  * THE WHOLE STREAM, not just its latest line — see trace.mjs.
4815
4849
  *
@@ -4888,7 +4922,16 @@ export function createWorkManager({
4888
4922
  // seconds. Two channels, one stream, and the drop-sampler stays a
4889
4923
  // drop-sampler — buffering the pulse would make a stale line look
4890
4924
  // fresh, which is the one thing it exists to answer.
4891
- if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, line);
4925
+ //
4926
+ // …AND THE TRACE TAKES THE WHOLE LINE. `label` is a 160-char
4927
+ // console readout; `full` is what the CLI actually said, when the
4928
+ // parser had more than the label could hold (see runtimes.mjs).
4929
+ // The fallback is not a degradation — it is what every activity
4930
+ // without a fuller form carries, and what an entire pre-0.87.0
4931
+ // daemon carried for all of them. The PULSE below is deliberately
4932
+ // still `line`: it is one overwritten line on a board, and a
4933
+ // paragraph there would be a paragraph nobody can read.
4934
+ if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, a.full ?? line);
4892
4935
  const now = Date.now();
4893
4936
  if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
4894
4937
  lastAgentBeat.set(agentId, now);
@@ -5000,9 +5043,10 @@ export function createWorkManager({
5000
5043
  branch,
5001
5044
  worktree: wt,
5002
5045
  });
5003
- // The queue just emptied. Run the project's own check HERE, in the
5004
- // worktree we are already standing in and still hold the lock on.
5005
- if (reply?.review === true) await runCheck(agentId, wt);
5046
+ // The queue just emptied. Run the project's own check and the AI
5047
+ // pre-review HERE, in the worktree we are already standing in and still
5048
+ // hold the lock on see `runReviewEntry`.
5049
+ if (reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
5006
5050
  });
5007
5051
  /**
5008
5052
  * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
@@ -5093,8 +5137,8 @@ export function createWorkManager({
5093
5137
  // body — never the CLI, which would spend the operator's quota again
5094
5138
  // and write a second set of commits. The reply can still carry the one
5095
5139
  // instruction a settle can (`review: true`, the queue just emptied),
5096
- // so the project's check runs from here too, under the same writer
5097
- // lock the turn itself would have held.
5140
+ // so the project's check and the AI pre-review run from here too, under
5141
+ // the same writer lock the turn itself would have held.
5098
5142
  agentTurns.add(id);
5099
5143
  void (async () => {
5100
5144
  const reply = await postAgentTurn(held.body);
@@ -5102,7 +5146,7 @@ export function createWorkManager({
5102
5146
  const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
5103
5147
  if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
5104
5148
  await inPlace(place, place.startsWith('a-'), () =>
5105
- runCheck(String(job.agentId || ''), wt)
5149
+ runReviewEntry(String(job.agentId || ''), wt, job.agentName)
5106
5150
  );
5107
5151
  }
5108
5152
  })()
@@ -5352,6 +5396,363 @@ export function createWorkManager({
5352
5396
  });
5353
5397
  };
5354
5398
 
5399
+ // ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
5400
+ //
5401
+ // A FRESH Claude reads the branch before the human does. The owner asked for
5402
+ // it in these words: "before having the user manually check, can we have the
5403
+ // daemon … spawn an agent to review the work so basically we get an ai to look
5404
+ // at the review before a human looks at it for a double check."
5405
+ //
5406
+ // IT IS NOT THE AGENT CHECKING ITSELF. `runTurn` is called with no `resume`,
5407
+ // so there is no conversation to inherit: an agent that spent four turns
5408
+ // arguing itself into a design defends that design, and asked whether its work
5409
+ // meets the card it answers from the very context that produced the work. The
5410
+ // reviewer stands IN the agent's worktree because it needs the code and the
5411
+ // diff, under `readOnly` (CONSULT_PERM — Read, Grep, Glob and a few git reads)
5412
+ // with NO MCP passed at all, so there is no control plane on this turn even if
5413
+ // the repository it reads tries to steer it.
5414
+ //
5415
+ // IT LABELS AND NEVER BLOCKS — the check's own law, one function up. Approve,
5416
+ // the per-card verdicts and the ship quiz do not know this exists. Every exit
5417
+ // below POSTS NOTHING, and the server renders an absent precheck as nothing:
5418
+ // a failed, timed-out, skipped or unparseable read leaves the human's review
5419
+ // exactly as it was before this feature existed. Ignorance never withholds.
5420
+ //
5421
+ // NO VERSION FLOOR. This is a daemon→server report on a NEW endpoint, so an
5422
+ // older daemon simply never posts, and an older SERVER 404s — which `postPre`
5423
+ // treats as delivered-and-done for the agent-trace reason stated there.
5424
+ //
5425
+ // IT RIDES THE BEAT THE CHECK ALREADY OWNS (`runReviewEntry`), AFTER it: the
5426
+ // check is a local command and this is a model call, so the cheap answer lands
5427
+ // on the row first and a wedged reviewer cannot delay it.
5428
+ /**
5429
+ * FIVE MINUTES, and `runTurn` has no timer of its own.
5430
+ *
5431
+ * Half the planner's cap, because this turn is strictly smaller — it reads one
5432
+ * branch's diff and answers, where a planner reads a repository to decide
5433
+ * whether a batch of work collides. And it is HELD INSIDE THE PLACE WRITER
5434
+ * LOCK by the beat it rides, so every minute here is a minute the agent's next
5435
+ * turn (or its merge) is waiting: a generous cap on a label would be spending
5436
+ * the work's time on a note about the work.
5437
+ */
5438
+ const PRECHECK_TIMEOUT_MS = 5 * 60_000;
5439
+ /** Commit subjects handed to the reviewer. A bound on the prompt, not on the
5440
+ * branch — the reviewer reads the diff itself, and the log is context. */
5441
+ const PRECHECK_LOG_LINES = 80;
5442
+
5443
+ /**
5444
+ * ONE PRE-REVIEW, POSTED.
5445
+ *
5446
+ * Resolves TRUE for a permanent refusal as well as a success, and that is
5447
+ * deliberate — the `postAgentTrace` rule, for the same reason: a server with
5448
+ * no such route 404s this body and will 404 every retry of it, so re-sending
5449
+ * would be a wedge wearing a retry's clothes. A NETWORK error resolves false
5450
+ * and is retried ONCE, because unlike a trace batch this body cost a whole
5451
+ * model call and losing it to a blip means the operator paid for a label
5452
+ * nobody ever sees.
5453
+ */
5454
+ const postPre = async (body) => {
5455
+ try {
5456
+ const res = await fetch(AGENT_PRECHECK_URL, {
5457
+ method: 'POST',
5458
+ headers: {
5459
+ Authorization: `Bearer ${FLEET_TOKEN}`,
5460
+ 'User-Agent': USER_AGENT,
5461
+ 'Content-Type': 'application/json',
5462
+ },
5463
+ signal: AbortSignal.timeout(30_000),
5464
+ body: JSON.stringify(body),
5465
+ });
5466
+ return (
5467
+ res.ok ||
5468
+ (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
5469
+ );
5470
+ } catch {
5471
+ return false; // a blip — worth one more attempt at a model call's answer
5472
+ }
5473
+ };
5474
+
5475
+ /**
5476
+ * THE BRANCH'S OWN COMMITS, subject + trailers, and the card ids they name.
5477
+ *
5478
+ * MEASURED, never asserted — the same reason `commitsBetween` exists. The
5479
+ * trailer ids are what let the prompt say "N earlier cards' specs are not on
5480
+ * this box" honestly: the difference between the cards this branch claims and
5481
+ * the specs this disk holds is a fact, and a box that adopted the agent
5482
+ * mid-run is exactly the case where it is non-zero.
5483
+ *
5484
+ * IT CANNOT THROW. `baseRef()` is free text an owner typed and may name no
5485
+ * ref at all; an unreadable range costs the reviewer its context, never the
5486
+ * review-entry beat it is standing in.
5487
+ */
5488
+ const branchLog = (wt) => {
5489
+ let out;
5490
+ try {
5491
+ out = git(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
5492
+ } catch {
5493
+ return { text: '', taskIds: [] };
5494
+ }
5495
+ if (typeof out !== 'string') return { text: '', taskIds: [] };
5496
+ const taskIds = new Set();
5497
+ for (const m of out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)) {
5498
+ taskIds.add(m[1].slice(0, 64));
5499
+ }
5500
+ const text = out
5501
+ .split('\n')
5502
+ .filter((l) => l.trim())
5503
+ .slice(0, PRECHECK_LOG_LINES)
5504
+ .join('\n');
5505
+ return { text, taskIds: [...taskIds] };
5506
+ };
5507
+
5508
+ const runPrecheck = async (agentId, wt, agentName) => {
5509
+ /**
5510
+ * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
5511
+ * planner uses, and for the same reason: this prompt was written against
5512
+ * Claude, and a machine with no read-only-capable runtime simply does not
5513
+ * produce a precheck. Nothing is posted and nothing is said on the row.
5514
+ */
5515
+ const rt = pickRuntimeFor('consult');
5516
+ if (!rt) return;
5517
+ /**
5518
+ * THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
5519
+ * asks it. `churn` and never `interactive`: nobody is watching this, and a
5520
+ * label is the first thing that should not be started on a box that is
5521
+ * struggling. Deferring here does NOT queue anything — there is no job and
5522
+ * no re-offer — so a precheck skipped under pressure is simply a precheck
5523
+ * that did not happen, which is what absence already means.
5524
+ */
5525
+ const hold = admit('churn');
5526
+ if (hold) {
5527
+ note(`${c.cyan('pre-review')} ${c.dim(`— skipped: ${hold.reason}`)}`);
5528
+ return;
5529
+ }
5530
+ const releaseSlot = admit.reserve();
5531
+
5532
+ // THE HEAD THE READING BELONGS TO, taken BEFORE the turn — the
5533
+ // `checkFingerprint` shape, so a commit landing after this voids it rather
5534
+ // than letting an old reading label a branch it never saw. Optional: an
5535
+ // unreadable head costs the staleness comparison, not the precheck.
5536
+ let headSha = null;
5537
+ try {
5538
+ headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
5539
+ } catch {
5540
+ headSha = null;
5541
+ }
5542
+
5543
+ const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
5544
+ const log = branchLog(wt);
5545
+ /** Cards the BRANCH names that this box has no spec for. Measured, not
5546
+ * guessed — see agentCards.mjs on why a stash is per-box. */
5547
+ const held = new Set(stash.map((s) => s.taskId));
5548
+ const missingSpecs = log.taskIds.filter((id) => !held.has(id)).length;
5549
+
5550
+ let out = '';
5551
+ let child = null;
5552
+ let timer = null;
5553
+ /** The cap fired: the CLI was still running when this machine stopped it. */
5554
+ let wedged = false;
5555
+ /**
5556
+ * THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
5557
+ * to DELETE the transcript it leaves behind (review, 2026-09-17).
5558
+ *
5559
+ * This is the first thing in the daemon to run a second `claude -p` inside
5560
+ * an AGENT's worktree, and the agent's own resume is `--continue`, which is
5561
+ * CWD-KEYED — the invariant `runAgentTurn` states in words ("ONE AGENT IS
5562
+ * ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly right here").
5563
+ * Leaving this turn's `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place
5564
+ * makes the read-only stranger the newest conversation in that directory,
5565
+ * so the agent's NEXT turn — a send-back's re-queued card, a merge-resolve,
5566
+ * a human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
5567
+ * READ-ONLY" instead of its own four-turn context, under build permissions.
5568
+ * That is the 0.69.0 Workbench cross-resume and codex's `resume --last`,
5569
+ * arriving a third time by a third route.
5570
+ *
5571
+ * `removeProbeTranscript` is exported for precisely this and already serves
5572
+ * the skills probe and the dev-command resolver.
5573
+ */
5574
+ let preSession = null;
5575
+ try {
5576
+ /**
5577
+ * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
5578
+ * the kill — the shape the project check and the planner both keep. A
5579
+ * SIGKILLed process whose stdio a grandchild still holds can be slow to
5580
+ * emit `close`, or never emit it, and this promise is inside the place's
5581
+ * writer lock.
5582
+ */
5583
+ let stopWaiting = () => {};
5584
+ const capped = new Promise((r) => {
5585
+ stopWaiting = r;
5586
+ });
5587
+ const turn = runTurn({
5588
+ prompt: AGENT_PRECHECK_KICKOFF({
5589
+ agentName,
5590
+ cards: stash.map((s) => s.prompt).join('\n---\n'),
5591
+ missingSpecs,
5592
+ commits: log.text,
5593
+ diffCommand: `git diff ${baseRef()}...HEAD`,
5594
+ }),
5595
+ system: SYSTEM_PRECHECK,
5596
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
5597
+ // passed empty, so there is no control plane on this turn at all.
5598
+ readOnly: true,
5599
+ cwd: wt,
5600
+ runtime: rt,
5601
+ // NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
5602
+ // agent grading its own homework out of its own context; this is a
5603
+ // stranger reading a diff.
5604
+ streamJson: true,
5605
+ answerFromResult: true,
5606
+ label: c.cyan('[pre-review]'),
5607
+ // The id the CLI reports at `system.init` — harvested off the stream
5608
+ // this turn already parses, no probe and no extra spawn. Held only so
5609
+ // the `finally` below can delete this turn's transcript; see
5610
+ // `preSession`.
5611
+ onInit: (i) => {
5612
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
5613
+ preSession = i.sessionId.trim();
5614
+ },
5615
+ onSpawn: (ch) => {
5616
+ child = ch;
5617
+ // No task id: a pre-review belongs to no card, and the machine
5618
+ // snapshot's per-task rows must not invent one. It still COUNTS
5619
+ // against the machine's ceiling — see liveTurnCount.
5620
+ workChildren.set(ch, null);
5621
+ releaseSlot();
5622
+ // ARMED AT THE SPAWN, not at entry: time spent getting here is not a
5623
+ // wedged CLI. The CHILD and never its group — a read-only turn starts
5624
+ // no server, so there is nothing behind it worth signalling and
5625
+ // everything to lose by signalling somebody else's.
5626
+ timer = setTimeout(() => {
5627
+ wedged = true;
5628
+ try {
5629
+ ch.kill('SIGKILL');
5630
+ } catch {
5631
+ /* already gone */
5632
+ }
5633
+ stopWaiting('');
5634
+ }, PRECHECK_TIMEOUT_MS);
5635
+ timer.unref?.();
5636
+ },
5637
+ });
5638
+ out = await Promise.race([turn, capped]);
5639
+ } catch {
5640
+ return; // a label may never fail the beat it rides
5641
+ } finally {
5642
+ if (timer) clearTimeout(timer);
5643
+ if (child) workChildren.delete(child);
5644
+ releaseSlot();
5645
+ /**
5646
+ * DELETE THIS READING'S TRANSCRIPT, on EVERY exit — settled, wedged and
5647
+ * killed, or thrown — because every one of them leaves the file behind
5648
+ * and the agent's `--continue` reads the newest one in the directory.
5649
+ *
5650
+ * AFTER the kill and ON A DELAY, the skills probe's own shape: the
5651
+ * transcript is the CHILD's file, so removing it while the child is still
5652
+ * dying races a recreate. Unref'd — it must not hold the process open.
5653
+ */
5654
+ if (preSession) setTimeout(() => removeProbeTranscript(wt, preSession), 750).unref?.();
5655
+ }
5656
+
5657
+ // A WEDGED READING POSTS NOTHING. There is no row to settle and nobody
5658
+ // waiting on an answer, so the honest record is that no pre-review exists —
5659
+ // the same silence a machine that never ran one leaves.
5660
+ if (wedged) {
5661
+ note(`${c.cyan('pre-review')} ${c.dim('— ran past five minutes and was stopped')}`);
5662
+ return;
5663
+ }
5664
+
5665
+ /**
5666
+ * SCRUBBED ON THE WAY OUT, every string — AND SCRUBBED BEFORE IT IS CUT,
5667
+ * which is the order that matters and the reason `envScrub` rides INTO the
5668
+ * parser rather than being applied to what comes back out.
5669
+ *
5670
+ * This turn read the repository with `cat` and `git show` in a worktree
5671
+ * holding the project's materialized dev secrets, and its answer is about to
5672
+ * be stored and rendered to every member of the project. `scrub` replaces
5673
+ * EXACT full values, so a note capped first and scrubbed second hands the
5674
+ * scrub a credential already cut in half: it matches nothing and the
5675
+ * surviving prefix ships. The check's output lane learned exactly that the
5676
+ * expensive way, and this lane relearned it in review (2026-09-17) — see
5677
+ * `parsePrecheck`, which now caps only what it has already redacted.
5678
+ */
5679
+ const result = parsePrecheck(out, envScrub);
5680
+
5681
+ /**
5682
+ * A QUOTA LIMIT SKIPS THE PRE-REVIEW AND PARKS NOTHING — AND A LIMIT IS
5683
+ * ONLY A LIMIT WHEN THE READING PRODUCED NOTHING.
5684
+ *
5685
+ * `limitLine` is a literal phrase match over the CLI's whole output, and
5686
+ * under `answerFromResult` that output IS the reviewer's answer — so a
5687
+ * pre-review OF rate-limiting code, or any triage that quotes the phrase,
5688
+ * read as a quota failure and threw a perfectly good reading away. The
5689
+ * agent-turn lane fixed this exact false positive once (`const limit = res
5690
+ * ? null : limitLine(out)`); gating on "nothing parsed" is what makes the
5691
+ * match mean what it says.
5692
+ *
5693
+ * And when it IS a limit, nothing parks. `postAgentParked` stops EVERY
5694
+ * agent on the project, because the CLI login is shared — the right answer
5695
+ * when the thing that hit the limit was somebody's actual work, the wrong
5696
+ * one here: parking a whole fleet because a LABEL could not be written
5697
+ * would let an optional readout take the product's primary lane down. The
5698
+ * branch is still reviewable; it just has no note on it.
5699
+ */
5700
+ if (!result) {
5701
+ if (limitLine(out)) {
5702
+ note(`${c.cyan('pre-review')} ${c.dim('— skipped: the CLI reported a limit')}`);
5703
+ }
5704
+ // UNPARSEABLE POSTS NOTHING. A half-read triage is a plausible-looking
5705
+ // paragraph nobody wrote, rendered on the surface where somebody decides
5706
+ // whether a branch reaches main — see parsePrecheck.
5707
+ return;
5708
+ }
5709
+
5710
+ const body = {
5711
+ agentId,
5712
+ ...(headSha ? { headSha } : {}),
5713
+ cards: result.cards.map((cd) => ({
5714
+ taskId: cd.taskId,
5715
+ verdict: cd.verdict,
5716
+ ...(cd.note ? { note: cd.note } : {}),
5717
+ })),
5718
+ ...(result.overall ? { overall: result.overall } : {}),
5719
+ };
5720
+ // ONE RETRY, and only for a network error — see `postPre`. A permanent
5721
+ // refusal is an older server, and asking it again changes nothing.
5722
+ if (!(await postPre(body))) await postPre(body);
5723
+ };
5724
+
5725
+ /**
5726
+ * REVIEW ENTRY — everything this machine does the moment an agent's queue
5727
+ * empties, in one place.
5728
+ *
5729
+ * It exists so the two readings cannot drift apart at the three call sites
5730
+ * that own this beat (the settle reply, the held body's re-POST, and the
5731
+ * stale-merge re-read after base is folded in). Each of those used to call
5732
+ * `runCheck` directly; a second thing to run at the same moment is a second
5733
+ * thing three call sites can forget.
5734
+ *
5735
+ * THE CHECK FIRST, ALWAYS. It is a local command whose answer the board wants
5736
+ * on the row immediately; the pre-review is a model call that may take
5737
+ * minutes. Ordering them the other way would put a label behind a label.
5738
+ *
5739
+ * NEITHER MAY THROW PAST THIS POINT. Both are optional readouts and both run
5740
+ * INSIDE the place's writer lock on a path whose callers settle real work —
5741
+ * `runAgentMerge` in particular reports a claimed merge after this returns.
5742
+ */
5743
+ const runReviewEntry = async (agentId, wt, agentName) => {
5744
+ try {
5745
+ await runCheck(agentId, wt);
5746
+ } catch {
5747
+ /* the row keeps its previous check answer, which is null the first time */
5748
+ }
5749
+ try {
5750
+ await runPrecheck(agentId, wt, agentName);
5751
+ } catch {
5752
+ /* no pre-review is posted, and absence renders nothing */
5753
+ }
5754
+ };
5755
+
5355
5756
  // ── THE MERGE ──────────────────────────────────────────────────────────────
5356
5757
  //
5357
5758
  // LEASED, because two `git merge --no-ff` and two pushes over one branch is
@@ -5505,9 +5906,12 @@ export function createWorkManager({
5505
5906
  });
5506
5907
  return;
5507
5908
  }
5508
- // The branch changed, so the previous check answered about a different
5509
- // tree. Re-run it before anything merges.
5510
- await runCheck(agentId, wt);
5909
+ // The branch changed, so the previous check and the previous
5910
+ // pre-review — answered about a different tree. Re-read it before
5911
+ // anything merges: a failed merge sends the agent BACK to review, which
5912
+ // is a review-entry beat like any other, and a stale reading standing
5913
+ // over a rebased branch is exactly what `precheckSha` exists to void.
5914
+ await runReviewEntry(agentId, wt, job.agentName);
5511
5915
  }
5512
5916
  // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
5513
5917
  // a detached HEAD — so the guard below was unreachable and the throw
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.86.0",
3
+ "version": "0.88.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": {