flowviant 0.87.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * THE CARD SPECS AN AGENT WAS ACTUALLY GIVEN, kept on the box that gave them
3
+ * (2026-09-16).
4
+ *
5
+ * The AI pre-review reads a branch at review entry and has to answer "does this
6
+ * diff do what each card asked for" — which needs the cards. Nothing on the
7
+ * machine holds them: an agent turn is fed ONE card at a time, the server types
8
+ * the next when the previous lands, and the daemon composes the prompt and
9
+ * forgets it. By the time the queue empties, the only trace of card one on this
10
+ * disk is whatever its commits happen to say about themselves, and a commit
11
+ * message is a CLAIM about the work rather than the work's specification.
12
+ *
13
+ * So each turn appends the spec it typed. It is the daemon's own text — the same
14
+ * `AGENT_TASK_SPEC` block the agent read, so the reviewer reads what the agent
15
+ * read rather than a second rendering of the card that can drift from it.
16
+ *
17
+ * ── WHERE IT LIVES, AND WHY THAT IS THE WHOLE LIFECYCLE ──
18
+ *
19
+ * The worktree's PRIVATE git dir (`sessionMetaPath`), scoped by agent id. Three
20
+ * properties come free with that choice and none of them needs code:
21
+ *
22
+ * · it is INVISIBLE to `git status`, so a stash can never make a worktree
23
+ * dirty — which would refuse a ship, the exact trap a marker file in the
24
+ * working tree fell into;
25
+ * · it DIES with `git worktree remove`, so retiring an agent's worktree
26
+ * retires its stash. There is no sweep to write and none to forget;
27
+ * · it is per-BOX by construction, which is the honest answer to a machine
28
+ * handover: a box that adopted an agent mid-run holds only the prompts IT
29
+ * typed. The reviewer prompt SAYS how many it is missing rather than
30
+ * inventing the specs it does not have.
31
+ *
32
+ * ── IT IS A STASH, NOT A LEDGER ──
33
+ *
34
+ * Nothing reads it but the precheck, nothing is decided by it, and losing it
35
+ * costs one label nobody was promised. Every failure here is swallowed for that
36
+ * reason: a turn must never fail because a note about it could not be written.
37
+ */
38
+
39
+ import { appendFileSync, readFileSync } from 'node:fs';
40
+
41
+ /**
42
+ * How many card specs one agent may accumulate.
43
+ *
44
+ * A bound on a MACHINE — an agent's queue is a handful of cards, and this exists
45
+ * so a pathological agent (a card re-delivered fifty times, an agent grown past
46
+ * its budget) cannot turn a prompt into a file read. The NEWEST are kept, which
47
+ * is the same tail-is-what-matters rule the trace keeps.
48
+ */
49
+ export const MAX_STASHED_CARDS = 40;
50
+ /** The most one spec may contribute. A brief is written by whoever filed the
51
+ * card and the server caps it, but this file is composed into a prompt and a
52
+ * bound it owns is a bound that cannot be argued away upstream. */
53
+ export const MAX_SPEC_CHARS = 8_000;
54
+
55
+ /**
56
+ * Write down the spec this turn is about to hand the agent.
57
+ *
58
+ * ONE JSON OBJECT PER LINE, APPENDED. Append rather than rewrite because two
59
+ * turns of one agent never run at once (an agent's place is taken as a WRITER)
60
+ * but a crash between read and write of a whole-file rewrite would lose every
61
+ * earlier card — and because an append is atomic enough at this size that a
62
+ * half-written line is the only damage a kill can do, which the reader drops.
63
+ *
64
+ * A re-delivered card appends a SECOND line for the same id; the reader keeps
65
+ * the last, because that is the spec the agent most recently worked from.
66
+ */
67
+ export function stashCard(path, taskId, spec) {
68
+ if (!path) return false;
69
+ const id = String(taskId ?? '').trim();
70
+ const text = String(spec ?? '');
71
+ if (!id || !text.trim()) return false;
72
+ try {
73
+ appendFileSync(
74
+ path,
75
+ JSON.stringify({ taskId: id.slice(0, 64), prompt: text.slice(0, MAX_SPEC_CHARS) }) + '\n'
76
+ );
77
+ return true;
78
+ } catch {
79
+ // A stash that could not be written costs the precheck one card's spec,
80
+ // which it will say it could not check rather than guess at.
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * What this box holds, newest spec per card, in the order the cards were worked.
87
+ *
88
+ * MALFORMED LINES ARE DROPPED ALONE — the boundary rule this repo states for
89
+ * every relayed list: one truncated line (a daemon killed mid-append) must not
90
+ * throw away the thirty-nine good specs beside it.
91
+ */
92
+ export function readStash(path) {
93
+ if (!path) return [];
94
+ let raw;
95
+ try {
96
+ raw = readFileSync(path, 'utf8');
97
+ } catch {
98
+ return []; // no file: this agent has run no card turn on this box
99
+ }
100
+ /** taskId → spec. A Map, so the LAST write per card wins while the insertion
101
+ * order stays the order the cards were first handed out — which is the order
102
+ * the branch was built in, and the order a reviewer reads them in. */
103
+ const byId = new Map();
104
+ for (const line of raw.split('\n')) {
105
+ if (!line.trim()) continue;
106
+ let v;
107
+ try {
108
+ v = JSON.parse(line);
109
+ } catch {
110
+ continue;
111
+ }
112
+ if (!v || typeof v !== 'object') continue;
113
+ const taskId = typeof v.taskId === 'string' ? v.taskId.trim() : '';
114
+ const prompt = typeof v.prompt === 'string' ? v.prompt : '';
115
+ if (!taskId || !prompt.trim()) continue;
116
+ byId.set(taskId, prompt.slice(0, MAX_SPEC_CHARS));
117
+ }
118
+ const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt }));
119
+ // The NEWEST cards when there are too many — a reviewer reading a grown
120
+ // agent's branch is reading the work at its end.
121
+ return all.slice(-MAX_STASHED_CARDS);
122
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
- * READING A PLANNER'S ANSWER.
2
+ * READING A MODEL'S ANSWER, in the agent lane's three shapes: a planner's
3
+ * PROPOSAL, an agent's own TURN RESULT, and (2026-09-16) the AI pre-review's
4
+ * TRIAGE.
3
5
  *
4
6
  * The scratch agent behind a Deploy press is asked for one JSON object and
5
7
  * nothing else. This is what turns its final message into a proposal, and it is
@@ -31,38 +33,50 @@ const MAX_NAME = 80;
31
33
  const MAX_NOTE = 1000;
32
34
 
33
35
  /**
34
- * Find the object.
36
+ * Find the objects.
35
37
  *
36
38
  * A fenced block first, because that is what was asked for. Otherwise every `{`
37
39
  * in the text is tried as a start, and its BALANCED end is found by counting
38
- * braces while skipping string literals — the first candidate that parses into
39
- * something with an `agents` array wins.
40
+ * braces while skipping string literals — each caller then takes the first
41
+ * candidate whose SHAPE is the one it asked for.
40
42
  *
41
43
  * The obvious cheap version — first `{` to last `}` — is wrong in a way a test
42
44
  * caught: a planner that writes "I looked at {the auth module} first" before its
43
45
  * JSON produces a span starting at the wrong brace, and the whole plan is lost
44
46
  * to a sentence. Scanning candidates costs nothing at this size and cannot be
45
47
  * defeated by prose.
48
+ *
49
+ * ONE SCANNER FOR ALL THREE READERS in this file (2026-09-16). It was written
50
+ * twice — once here and once inline in `parseTurnResult` — and a third copy was
51
+ * about to be written for the precheck. A brace scanner that skips string
52
+ * literals is exactly the kind of thing where two copies quietly stop agreeing
53
+ * about escapes and nobody notices, because the disagreement only shows up on a
54
+ * card title with a quote in it.
46
55
  */
47
- function extract(raw) {
56
+ function candidateObjects(raw) {
48
57
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
49
- const candidates = [];
50
- if (fenced) candidates.push(fenced[1]);
58
+ const bodies = [];
59
+ if (fenced) bodies.push(fenced[1]);
51
60
  for (let i = 0; i < raw.length; i++) {
52
61
  if (raw[i] !== '{') continue;
53
62
  const end = balanced(raw, i);
54
- if (end > i) candidates.push(raw.slice(i, end + 1));
63
+ if (end > i) bodies.push(raw.slice(i, end + 1));
55
64
  }
56
- for (const body of candidates) {
65
+ const out = [];
66
+ for (const body of bodies) {
57
67
  if (!body.trim()) continue;
58
68
  try {
59
69
  const v = JSON.parse(body);
60
- if (v && typeof v === 'object' && Array.isArray(v.agents)) return v;
70
+ if (v && typeof v === 'object') out.push(v);
61
71
  } catch {
62
72
  /* the next candidate may be the object */
63
73
  }
64
74
  }
65
- return null;
75
+ return out;
76
+ }
77
+
78
+ function extract(raw) {
79
+ return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
66
80
  }
67
81
 
68
82
  /** The index of the `}` that closes the `{` at `from`, or -1. Skips string
@@ -158,23 +172,7 @@ export function parseProposal(text) {
158
172
  * up here rather than being read as success.
159
173
  */
160
174
  export function parseTurnResult(text) {
161
- const parsedRaw = String(text ?? '');
162
- const fenced = parsedRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
163
- const candidates = [];
164
- if (fenced) candidates.push(fenced[1]);
165
- for (let i = 0; i < parsedRaw.length; i++) {
166
- if (parsedRaw[i] !== '{') continue;
167
- const end = balanced(parsedRaw, i);
168
- if (end > i) candidates.push(parsedRaw.slice(i, end + 1));
169
- }
170
- for (const body of candidates) {
171
- let v;
172
- try {
173
- v = JSON.parse(body);
174
- } catch {
175
- continue;
176
- }
177
- if (!v || typeof v !== 'object') continue;
175
+ for (const v of candidateObjects(String(text ?? ''))) {
178
176
  if (v.status === 'blocked') {
179
177
  const question = typeof v.question === 'string' ? v.question.trim() : '';
180
178
  // A "blocked" with no question is not an answer anybody can act on — it
@@ -203,3 +201,80 @@ export function parseTurnResult(text) {
203
201
  }
204
202
  return null;
205
203
  }
204
+
205
+ /** The precheck's own bounds — mirrored at the server boundary, which caps
206
+ * again. A note is one sentence of triage and `overall` is one paragraph; the
207
+ * numbers are the ones SYSTEM_PRECHECK asks for, stated here so a model that
208
+ * ignores them cannot make the row bigger than the surface can render. */
209
+ const MAX_PRECHECK_CARDS = 60;
210
+ const MAX_PRECHECK_NOTE = 400;
211
+ const MAX_PRECHECK_OVERALL = 1200;
212
+
213
+ /**
214
+ * READING THE AI PRE-REVIEW's ANSWER (2026-09-16).
215
+ *
216
+ * Same law as its two neighbours — LENIENT ON PACKAGING, STRICT ON SHAPE — and
217
+ * here the strict half has a sharper consequence than usual: this text is about
218
+ * to be rendered on the surface where somebody decides whether a branch reaches
219
+ * main. A half-read answer is a plausible-looking triage nobody wrote.
220
+ *
221
+ * NULL IS THE SAFE ANSWER AND THE COMMON ONE. A precheck that came back
222
+ * unparseable posts NOTHING, and the absence renders nothing: the human's review
223
+ * is exactly what it was before this feature existed. That is the whole reason
224
+ * this may be strict where `parseProposal` cannot be — a lost proposal wastes a
225
+ * press somebody made, a lost precheck costs a label nobody was promised.
226
+ *
227
+ * ONE ENTRY PER CARD, and the FIRST one wins: a model that judges a card twice
228
+ * has contradicted itself, and rendering two notes on one card face would ask
229
+ * the reviewer to arbitrate between them. An unknown verdict word is dropped
230
+ * rather than coerced — `ok` is a claim ("I looked and found nothing"), and
231
+ * guessing it from a word nobody listed would be the parser making that claim.
232
+ *
233
+ * `scrub` RIDES IN, AND IT RUNS BEFORE EVERY CUT (review, 2026-09-17).
234
+ *
235
+ * The caller used to scrub afterwards — `envScrub(cd.note).slice(0, 400)` over
236
+ * a note this function had ALREADY cut to 400. `scrub` replaces EXACT full
237
+ * values, so a credential straddling the cut arrived here pre-severed, matched
238
+ * nothing, and its surviving prefix was stored and rendered to every member of
239
+ * the project. That is byte-for-byte the bug `runCheck`'s output lane records
240
+ * learning the expensive way, and the reviewer this parses reads a worktree
241
+ * holding the project's materialized dev secrets — a note quoting a `.env`
242
+ * line is the ordinary way to reach it. The fix is `toolEventOf`'s: the scrub
243
+ * rides INTO the builder and runs over the whole field, before the cap.
244
+ *
245
+ * DEFAULTED TO IDENTITY so the parser stays testable on its own, and so a
246
+ * caller that forgets loses redaction rather than the whole reading — but the
247
+ * ONE production caller passes `envScrub`, and `work.test.mjs` pins the order.
248
+ */
249
+ export function parsePrecheck(text, scrub = (s) => s) {
250
+ const parsed = candidateObjects(String(text ?? '')).find((v) => Array.isArray(v.cards));
251
+ if (!parsed) return null;
252
+
253
+ const cards = [];
254
+ const seen = new Set();
255
+ for (const c of parsed.cards.slice(0, MAX_PRECHECK_CARDS)) {
256
+ if (!c || typeof c !== 'object') continue;
257
+ const taskId = typeof c.taskId === 'string' ? c.taskId.trim().slice(0, 64) : '';
258
+ if (!taskId || seen.has(taskId)) continue;
259
+ if (c.verdict !== 'ok' && c.verdict !== 'concerns') continue;
260
+ seen.add(taskId);
261
+ // SCRUB, THEN CUT — see the docblock. The whole field is in hand here, so an
262
+ // exact-value match still finds a secret that spans the cap.
263
+ const note =
264
+ typeof c.note === 'string' ? scrub(c.note.trim()).slice(0, MAX_PRECHECK_NOTE) : '';
265
+ cards.push({ taskId, verdict: c.verdict, ...(note ? { note } : {}) });
266
+ }
267
+ const overall =
268
+ typeof parsed.overall === 'string'
269
+ ? scrub(parsed.overall.trim()).slice(0, MAX_PRECHECK_OVERALL)
270
+ : '';
271
+ /**
272
+ * AN ANSWER THAT SAYS NOTHING IS NOT AN ANSWER. No readable card verdict and
273
+ * no overall means the model produced the right punctuation and no content —
274
+ * posting that would put an empty "Claude's pre-review" heading on the deck,
275
+ * which reads as a feature that ran and found the branch unremarkable. It did
276
+ * not run.
277
+ */
278
+ if (cards.length === 0 && !overall) return null;
279
+ return { cards, ...(overall ? { overall } : {}) };
280
+ }
@@ -830,13 +830,25 @@ const safeName = (n) =>
830
830
  .trim()
831
831
  .slice(0, 60) || 'agent';
832
832
 
833
- const taskBlock = (task) =>
833
+ /**
834
+ * A CARD'S SPEC, WRITTEN DOWN ONCE.
835
+ *
836
+ * EXPORTED (2026-09-16) because a second reader now needs the identical text:
837
+ * the AI pre-review is composed from the card specs the daemon STASHED as it
838
+ * typed each turn's prompt, and the whole claim of that surface is that the
839
+ * reviewer read what the agent read. Two builders for one thing is two
840
+ * renderings of a card that can drift — and the drift would be invisible,
841
+ * because nobody reads both prompts side by side.
842
+ */
843
+ export const AGENT_TASK_SPEC = (task) =>
834
844
  `id: ${task?.id ?? ''}\n` +
835
845
  `title: ${task?.title ?? ''}\n` +
836
846
  (task?.brief ? `\nbrief:\n${task.brief}\n` : '') +
837
847
  (task?.criteria?.length ? `\ndone when:\n${task.criteria.map((c) => `- ${c}`).join('\n')}\n` : '') +
838
848
  (task?.anchors?.length ? `\nthis card owns:\n${task.anchors.map((a) => `- ${a}`).join('\n')}\n` : '');
839
849
 
850
+ const taskBlock = AGENT_TASK_SPEC;
851
+
840
852
  /**
841
853
  * A PERSON SPOKE TO THE AGENT — usually the answer to its own question.
842
854
  *
@@ -852,3 +864,126 @@ export const AGENT_HUMAN_KICKOFF = ({ agentName, message, askedByName, task, pos
852
864
  `${fence('WHAT THEY SAID', message)}\n\n` +
853
865
  (task ? `${fence('THE CARD YOU ARE ON', taskBlock(task))}\n\n` : '') +
854
866
  `Carry on, and end with the JSON object as usual.`;
867
+
868
+ /**
869
+ * THE AI PRE-REVIEW — a FRESH Claude reads the branch before the human does
870
+ * (2026-09-16).
871
+ *
872
+ * The owner asked for it in these words: "before having the user manually check,
873
+ * can we have the daemon … spawn an agent to review the work so basically we get
874
+ * an ai to look at the review before a human looks at it for a double check."
875
+ *
876
+ * ── FRESH EYES, AND THAT IS THE ENTIRE DESIGN ──
877
+ *
878
+ * This is NOT the agent's own conversation asked to check itself. An agent that
879
+ * has spent four turns arguing itself into a design defends that design; asked
880
+ * whether its work meets the card, it answers from the same context that
881
+ * produced the work and finds it good. So the precheck is a NEW `claude -p` with
882
+ * no resumed conversation, standing in the agent's worktree because it needs the
883
+ * code and the diff, under the READ-ONLY profile the scratch planner and the
884
+ * capture chat already run behind, with no MCP at all. It reads; it cannot
885
+ * write; it has no control plane to reach even if the repository it is reading
886
+ * tries to steer it.
887
+ *
888
+ * ── IT LABELS AND NEVER BLOCKS ──
889
+ *
890
+ * The project's own check states this law and this obeys it identically:
891
+ * Approve, the verdicts and the ship quiz do not know this exists. A precheck
892
+ * that failed, timed out, or was never run posts NOTHING, and the absence
893
+ * renders nothing — ignorance never withholds a human's review, the same
894
+ * three-state rule every readout in this product keeps.
895
+ *
896
+ * ── AND IT IS ASKED FOR A TRIAGE, NOT A VERDICT ──
897
+ *
898
+ * The one thing a second reader can do that the first cannot is say WHERE TO
899
+ * LOOK FIRST. Asked to approve or reject, a model produces a confident judgment
900
+ * nobody asked it for and somebody will eventually treat as one. Asked what a
901
+ * reviewer should check first, it produces a list of places — which is useful
902
+ * whether it is right or wrong, because the human is about to look anyway.
903
+ */
904
+ export const SYSTEM_PRECHECK = `You are a SECOND reviewer with fresh eyes, reading a branch an agent has just
905
+ finished. You did not write this code and you were not in the conversation that
906
+ produced it. That is the whole point of you.
907
+
908
+ A PERSON REVIEWS THIS NEXT, and your job is to tell them what to look at first.
909
+ You are not approving or rejecting anything: nothing you say gates the merge,
910
+ nothing you say is shown to the agent, and nobody is waiting on a decision from
911
+ you.
912
+
913
+ YOU ARE READ-ONLY. You cannot write, edit or create files, and you have no tools
914
+ beyond reading this repository. Do not try.
915
+
916
+ HOW TO READ IT:
917
+
918
+ 1. READ THE DIFF. A commit message is a CLAIM about the work; the diff is the
919
+ work. Run the diff command you are given and read what actually changed
920
+ before you say anything about it.
921
+ 2. VERIFY EACH CARD AGAINST ITS OWN ACCEPTANCE CRITERIA. For every card you are
922
+ given, decide FROM THE DIFF whether what was asked for is actually there.
923
+ "ok" means you looked and found nothing a reviewer needs warning about.
924
+ "concerns" means there is something specific you would want them to check
925
+ first.
926
+ 3. BE SPECIFIC OR SAY NOTHING. "Looks reasonable" helps nobody. A concern names
927
+ a file, a function or a behaviour and says what about it worries you. If you
928
+ cannot point at something, the verdict is "ok".
929
+ 4. NEVER INVENT. If a card's spec was not given to you, judge it from the diff
930
+ and the commits and SAY in your note what you could not check it against.
931
+ Never assume a file exists, a test passes, or a criterion was met because a
932
+ commit message says so.
933
+ 5. YOU ARE NOT A STYLE GUIDE. Correctness, missing pieces, things the criteria
934
+ asked for that the diff does not show, changes that reach further than the
935
+ card did. Not formatting, not naming preferences, not the rewrite you would
936
+ have preferred.
937
+
938
+ END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
939
+
940
+ \`\`\`json
941
+ {
942
+ "cards": [
943
+ { "taskId": "<card id, exactly as given>", "verdict": "ok", "note": "" },
944
+ { "taskId": "<card id, exactly as given>", "verdict": "concerns", "note": "what a reviewer should look at first, and why" }
945
+ ],
946
+ "overall": "what you would tell the reviewer before they start reading"
947
+ }
948
+ \`\`\`
949
+
950
+ One entry per card, AT MOST ONE note each, and use the card ids exactly as
951
+ given. A note is at most 400 characters and "overall" at most 1200: you are
952
+ writing the first paragraph of somebody's review, not the review.`;
953
+
954
+ /**
955
+ * The precheck's turn.
956
+ *
957
+ * THE CARDS AND THE COMMITS ARE FENCED. A card's title, brief and criteria are
958
+ * written by whoever files cards in this project; a commit subject is written by
959
+ * a model that has just been editing files. Both are untrusted content this turn
960
+ * reads, and the instruction that matters — "read the diff and triage it" — is
961
+ * ours and sits outside the fence.
962
+ *
963
+ * `missingSpecs` IS MEASURED, NOT GUESSED. A box that adopted this agent
964
+ * mid-run (the machine moved, or an older turn ran elsewhere) holds only the
965
+ * prompts IT typed, so the stash can be short of what the branch carries. The
966
+ * count is the difference between the `Flowviant-Task:` trailers on the branch
967
+ * and the specs on this disk — so the reviewer is told what it could not read
968
+ * rather than being handed a silent gap, and never told a card exists that
969
+ * nothing measured.
970
+ */
971
+ export const AGENT_PRECHECK_KICKOFF = ({
972
+ agentName,
973
+ cards,
974
+ missingSpecs = 0,
975
+ commits,
976
+ diffCommand,
977
+ }) =>
978
+ `The agent "${safeName(agentName)}" has finished its queue. A person is about to ` +
979
+ `review this branch; you are reading it first.\n\n` +
980
+ `${fence('THE CARDS IT WAS GIVEN', cards || '(none of this branch’s card specs are on this machine)')}\n\n` +
981
+ (missingSpecs > 0
982
+ ? `${missingSpecs} earlier card${missingSpecs === 1 ? "'s spec is" : "s' specs are"} ` +
983
+ `not on this box — review ${missingSpecs === 1 ? 'it' : 'them'} from the diff and the ` +
984
+ `commits below, and say in your note what you could not check them against.\n\n`
985
+ : '') +
986
+ `${fence('THE COMMITS ON THIS BRANCH', commits || '(none)')}\n\n` +
987
+ `Read the diff yourself before you judge any of it:\n\n` +
988
+ ` ${diffCommand}\n\n` +
989
+ `Then answer with the JSON object and nothing else.`;
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
  *
@@ -5009,9 +5043,10 @@ export function createWorkManager({
5009
5043
  branch,
5010
5044
  worktree: wt,
5011
5045
  });
5012
- // The queue just emptied. Run the project's own check HERE, in the
5013
- // worktree we are already standing in and still hold the lock on.
5014
- 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);
5015
5050
  });
5016
5051
  /**
5017
5052
  * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
@@ -5102,8 +5137,8 @@ export function createWorkManager({
5102
5137
  // body — never the CLI, which would spend the operator's quota again
5103
5138
  // and write a second set of commits. The reply can still carry the one
5104
5139
  // instruction a settle can (`review: true`, the queue just emptied),
5105
- // so the project's check runs from here too, under the same writer
5106
- // 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.
5107
5142
  agentTurns.add(id);
5108
5143
  void (async () => {
5109
5144
  const reply = await postAgentTurn(held.body);
@@ -5111,7 +5146,7 @@ export function createWorkManager({
5111
5146
  const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
5112
5147
  if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
5113
5148
  await inPlace(place, place.startsWith('a-'), () =>
5114
- runCheck(String(job.agentId || ''), wt)
5149
+ runReviewEntry(String(job.agentId || ''), wt, job.agentName)
5115
5150
  );
5116
5151
  }
5117
5152
  })()
@@ -5361,6 +5396,363 @@ export function createWorkManager({
5361
5396
  });
5362
5397
  };
5363
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
+
5364
5756
  // ── THE MERGE ──────────────────────────────────────────────────────────────
5365
5757
  //
5366
5758
  // LEASED, because two `git merge --no-ff` and two pushes over one branch is
@@ -5514,9 +5906,12 @@ export function createWorkManager({
5514
5906
  });
5515
5907
  return;
5516
5908
  }
5517
- // The branch changed, so the previous check answered about a different
5518
- // tree. Re-run it before anything merges.
5519
- 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);
5520
5915
  }
5521
5916
  // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
5522
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.87.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": {