flowviant 0.87.0 → 0.89.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.
@@ -23,7 +23,16 @@
23
23
  * ── WHAT THIS IS NOT ──
24
24
  *
25
25
  * It is not a capacity meter: nothing is published ahead of the decision, and
26
- * the only time anyone hears about it is the moment it actually fires. It does
26
+ * the only time anyone hears about it is the moment it actually fires.
27
+ *
28
+ * NARROWED 2026-09-17, knowingly. The effective ceiling now rides the poll as
29
+ * `mt` and is shown in two places, and neither is a meter: project SETTINGS,
30
+ * beside the control that sets it — a dial has to say what it is currently
31
+ * worth or it is a control with no readout — and on the board ONLY while a real
32
+ * agent is being deferred, which is the "at the thing that is waiting, in the
33
+ * moment" carve-out this product already grants. What stays forbidden is the
34
+ * resting global chip and any statement of HEADROOM: "room for N more" is still
35
+ * dead, and the refusal below still names ACTIVITY and never the bound. It does
27
36
  * not kill anything — no signal is sent on Flowviant's initiative, ever. It
28
37
  * does not park an agent: a park needs a human gesture to lift, and pressure
29
38
  * clears on its own, so a deferral is simply a spawn that did not happen this
@@ -70,12 +79,98 @@
70
79
  * idempotent so doing both is the normal case rather than a bug.
71
80
  */
72
81
 
73
- import { MAX_CONCURRENT } from './config.mjs';
82
+ import { MAX_CONCURRENT, MAX_CONCURRENT_FROM_ENV } from './config.mjs';
74
83
  import { pressureVerdict } from './resources.mjs';
75
84
 
85
+ /**
86
+ * ── THE CEILING HAS THREE POSSIBLE AUTHORS (2026-09-17) ──
87
+ *
88
+ * The owner's box derived ONE, so every parallel agent serialized here with
89
+ * nothing but a per-agent pulse line to explain it: "thats why i was
90
+ * immediately confused. theres nothing telling me that i could only have one
91
+ * agent on the board." His fix was to move the dial into the product — "it
92
+ * shouldnt be a variable on the npx to make it friendly for non tech users. why
93
+ * cant it be on the web interface?" — so the roster reply may now carry
94
+ * `maxTurns`.
95
+ *
96
+ * THE ORDER IS ENV > SERVER > DERIVED, and the middle one is the new arrival:
97
+ *
98
+ * · ENV WINS because an operator who typed `FLOWVIANT_MAX_CONCURRENT=2` at the
99
+ * box is stating their last word about their own machine, and a control they
100
+ * cannot see must not silently overrule it. This is the same posture the
101
+ * daemon keeps everywhere else it is handed an instruction: the app decides
102
+ * the product, the box decides the box.
103
+ * · THE SERVER'S DIAL beats the derivation because the app is where every
104
+ * decision in this product is made, and the derivation is a GUESS about
105
+ * hardware — a good one, but one a person looking at their own machine is
106
+ * entitled to overrule without opening a terminal.
107
+ * · THE DERIVATION is the resting state, and it is what a project that has
108
+ * never touched the dial gets. `null` — not 0, not NaN — is what "nobody
109
+ * said" looks like, so the fallback is reached by ABSENCE rather than by a
110
+ * sentinel number that could be mistaken for a bound.
111
+ *
112
+ * Pure, and exported, so the order can be proved without a server, a poll or a
113
+ * box of any particular size.
114
+ */
115
+ export function pickMaxTurns({ env = null, server = null, derived = 1 } = {}) {
116
+ const clamp = (n) => {
117
+ const v = Number(n);
118
+ if (!Number.isFinite(v) || v < 1) return null;
119
+ return Math.min(Math.floor(v), 32);
120
+ };
121
+ return clamp(env) ?? clamp(server) ?? clamp(derived) ?? 1;
122
+ }
123
+
124
+ /**
125
+ * THE LAST NUMBER THE SERVER NAMED, or null.
126
+ *
127
+ * A module holder rather than a value threaded through `createWorkManager`,
128
+ * because the reader and the writer are two files apart and one poll apart: the
129
+ * POLL learns it (fleet.mjs) and every ADMISSION reads it, including the ones
130
+ * work.mjs takes in lanes fleet.mjs never sees. The alternative — passing it
131
+ * down — would mean the number that bound a spawn was whatever was current when
132
+ * the manager was CONSTRUCTED, which is exactly the frozen-at-startup bug
133
+ * `getBaseRef` and `getLeaseTtl` are getters to avoid.
134
+ */
135
+ let serverMaxTurns = null;
136
+
137
+ /**
138
+ * Record what the roster said. ANYTHING THAT IS NOT A USABLE NUMBER CLEARS IT,
139
+ * and both directions of that matter:
140
+ *
141
+ * · a MALFORMED value must not become a ceiling — `Number('')` is 0 and a
142
+ * ceiling of 0 refuses every spawn forever, which is a machine that has
143
+ * silently stopped working;
144
+ * · an ABSENT value is how "Auto" is spelled on the wire (the server simply
145
+ * does not send the key), and how an older SERVER looks. Both mean the
146
+ * derivation stands, so absence has to clear a value set by an earlier poll
147
+ * rather than leaving the last dial standing forever — otherwise turning the
148
+ * dial back to Auto would be unspellable.
149
+ */
150
+ export function setServerMaxTurns(raw) {
151
+ const v = Number(raw);
152
+ serverMaxTurns = Number.isFinite(v) && v >= 1 ? Math.min(Math.floor(v), 32) : null;
153
+ return serverMaxTurns;
154
+ }
155
+
156
+ /** THE NUMBER IN FORCE RIGHT NOW — read per admission, and reported to the app
157
+ * on the poll as `mt`, because it is the bound the refusal is about. */
158
+ export function effectiveMaxTurns() {
159
+ return pickMaxTurns({
160
+ env: MAX_CONCURRENT_FROM_ENV ? MAX_CONCURRENT : null,
161
+ server: serverMaxTurns,
162
+ derived: MAX_CONCURRENT,
163
+ });
164
+ }
165
+
76
166
  export function createAdmission({
77
167
  liveTurnCount,
78
- maxConcurrent = MAX_CONCURRENT,
168
+ /** A NUMBER OR A FUNCTION. The function form is the live one: the ceiling can
169
+ * move between two polls now, so a value captured when the manager was built
170
+ * would be the dial as it stood at daemon start for the rest of the process.
171
+ * A number is still accepted, and every test here passes one — the whole
172
+ * point of injecting it. */
173
+ maxConcurrent = effectiveMaxTurns,
79
174
  verdict = pressureVerdict,
80
175
  } = {}) {
81
176
  /** Slots taken by a decision whose process does not exist yet. A Set of
@@ -88,7 +183,18 @@ export function createAdmission({
88
183
  function admit(level) {
89
184
  const counted = Number(liveTurnCount?.() ?? 0);
90
185
  const live = (Number.isFinite(counted) ? counted : 0) + holding.size;
91
- if (live >= maxConcurrent)
186
+ /**
187
+ * READ PER ADMISSION, and falling back to the DERIVED number rather than to
188
+ * nothing. A ceiling that comes out NaN is not a permissive ceiling, it is
189
+ * NO ceiling — `live >= NaN` is false every time — which is precisely the
190
+ * unguarded spawn loop that froze somebody's computer. The local derivation
191
+ * is the one value in this process that cannot be garbage.
192
+ */
193
+ const asked =
194
+ typeof maxConcurrent === 'function' ? Number(maxConcurrent()) : Number(maxConcurrent);
195
+ const ceiling =
196
+ Number.isFinite(asked) && asked >= 1 ? Math.min(Math.floor(asked), 32) : MAX_CONCURRENT;
197
+ if (live >= ceiling)
92
198
  return {
93
199
  reason: `the machine is already running ${live} CLI turn${live === 1 ? '' : 's'}`,
94
200
  };
@@ -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
+ }
@@ -118,6 +118,16 @@ export const MACHINE = machineLimits();
118
118
  * tests want, and this process is the only party that can see the cores, the
119
119
  * RAM and the fan.
120
120
  *
121
+ * …AND SINCE 2026-09-17 THE APP MAY NAME A NUMBER TOO, which narrows that
122
+ * sentence rather than reversing it. What stays true is that the DERIVATION
123
+ * belongs here and that the enforcement is local: a ceiling that only exists as
124
+ * a request is not one. What changed is that a person may now say "run two at
125
+ * once" without going to the box, because the owner's answer to being stuck at
126
+ * one was "it shouldnt be a variable on the npx to make it friendly for non
127
+ * tech users. why cant it be on the web interface?". The precedence — env, then
128
+ * the app's dial, then this derivation — lives in `pickMaxTurns`
129
+ * (admission.mjs), and this value is what that falls back to.
130
+ *
121
131
  * Sent to the server on every roster poll so it can pace what it offers UNDER
122
132
  * this ceiling, and ENFORCED LOCALLY BESIDES — a ceiling that only exists as a
123
133
  * request is not one, and the roster can always offer more than this.
@@ -142,23 +152,56 @@ export const MACHINE = machineLimits();
142
152
  *
143
153
  * MEMORY is the bound, not cores. Cores oversubscribe gracefully (everything
144
154
  * gets slower); memory does not (something dies, and not necessarily the
145
- * offender). A task is a Claude session plus a dev server plus whatever the
146
- * test runner spawns — call it 2GB, keep 2GB back for the operating system,
147
- * and let cores cap it only when they are the scarcer thing.
148
- *
149
- * This used to be `min(4, cores/2)` half the cores because "the user is
150
- * working on this machine too", and a hard 4 because a personal Claude plan
151
- * ran out before the CPU did. Both were laptop assumptions. The machine is now
152
- * a box the project leaves running, so it reserves one core rather than half of
153
- * them, and the ceiling is what the hardware can hold rather than a guess about
154
- * somebody's plan.
155
+ * offender).
156
+ *
157
+ * BOTH HALVES WERE RETUNED 2026-09-17, after the owner's box computed ONE and
158
+ * he met it as "theres nothing telling me that i could only have one agent on
159
+ * the board". The old numbers were `floor((memGB − 2) / 2)` and `cores 1`,
160
+ * and each was a guess this file had stopped examining:
161
+ *
162
+ * · 2GB PER TURN assumed every turn drags a dev server and a test runner
163
+ * behind it. Most do not an agent turn is a CLI that reads, edits and
164
+ * commits, and it is API-bound for nearly all of its life. 1GB per turn with
165
+ * the same 2GB held back for the operating system is the honest sizing, and
166
+ * the PRESSURE guard (resources.mjs) is what catches the turn that really
167
+ * does hold gigabytes: it measures at the moment of spawning, which is a
168
+ * thing a static divisor cannot do.
169
+ * · `cores − 1` is the laptop assumption this comment already claims to have
170
+ * retired, reborn one line down. The paragraph above says cores
171
+ * OVERSUBSCRIBE GRACEFULLY and memory is what does not — so cores must not
172
+ * be the half that binds first. On a 2-core VM `cores − 1` was 1, and that
173
+ * single subtraction is the whole reason the owner's machine serialized
174
+ * every agent. `cores * 2` lets the scheduler do what it is for, and memory
175
+ * stays the bound that actually refuses.
176
+ *
177
+ * `min(32, …)` and the env override are untouched: the hard 32 is a runaway
178
+ * bound on a box, not a product decision, and an operator who typed a number at
179
+ * the machine gets exactly that number.
180
+ */
181
+ export function deriveMaxConcurrent(memBytes, cores) {
182
+ const byMem = Math.floor(memBytes / 2 ** 30 - 2);
183
+ const byCpu = Number(cores) * 2;
184
+ return Math.max(1, Math.min(32, byMem, byCpu));
185
+ }
186
+
187
+ /**
188
+ * WAS THE ENV VAR THE SOURCE? — the one fact the precedence rule cannot
189
+ * reconstruct afterwards.
190
+ *
191
+ * `FLOWVIANT_MAX_CONCURRENT=4` and a box that happens to derive 4 produce the
192
+ * identical number, and they are not the same statement: one is an operator's
193
+ * last word and must outrank the app's dial, the other is a default the app may
194
+ * override. See `pickMaxTurns` in admission.mjs for what is done with it.
155
195
  */
196
+ export const MAX_CONCURRENT_FROM_ENV = (() => {
197
+ const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
198
+ return Number.isFinite(asked) && asked >= 1;
199
+ })();
200
+
156
201
  export const MAX_CONCURRENT = (() => {
157
202
  const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
158
203
  if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
159
- const byMem = Math.floor((MACHINE.memBytes / 2 ** 30 - 2) / 2);
160
- const byCpu = MACHINE.cores - 1;
161
- return Math.max(1, Math.min(32, byMem, byCpu));
204
+ return deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
162
205
  })();
163
206
  export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
164
207
  // Live mode: after this long idle-parked on a blocker, tear the session down to
package/bin/lib/fleet.mjs CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  THINK_MARKER,
86
86
  } from './runtimes.mjs';
87
87
  import { createWorkManager } from './work.mjs';
88
+ import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
88
89
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
89
90
  import { repoState } from './repoState.mjs';
90
91
  import { claudeAuthContext } from './claudeAuth.mjs';
@@ -259,6 +260,34 @@ async function fetchRoster(
259
260
  try {
260
261
  if (churnHold !== undefined) {
261
262
  url.searchParams.set('pr', churnHold ? String(churnHold.reason).slice(0, 160) : '-');
263
+ /**
264
+ * …AND THE BOUND THE REFUSAL IS ABOUT (2026-09-17, `mt`).
265
+ *
266
+ * THIS AMENDS THE PARAGRAPH DIRECTLY ABOVE, which says the relayed
267
+ * sentence "names ACTIVITY … never the ceiling, never headroom". That
268
+ * rule was written to stop a capacity meter, and it succeeded so
269
+ * completely that a machine which could only ever run ONE turn had no way
270
+ * to say so — the owner, verbatim: "we should have ui indicating this on
271
+ * the board view and a ui indicating or showing the agent as waiting or
272
+ * queued as a result of that resource constraint. thats why i was
273
+ * immediately confused. theres nothing telling me that i could only have
274
+ * one agent on the board."
275
+ *
276
+ * So the ceiling crosses the wire, and the narrow shape that survives is
277
+ * the same carve-out the sentence beside it already lives under: it is
278
+ * shown in SETTINGS (beside the dial that sets it — a control with no
279
+ * readout is not a control) and on the BOARD only while a real agent is
280
+ * actually being deferred. Never a resting chip, and never headroom:
281
+ * nothing anywhere subtracts this from the live count to advertise room.
282
+ *
283
+ * It rides the `pr` gate deliberately — this is the bound that admission
284
+ * measured against, so a poll with no admission to ask has no effective
285
+ * ceiling to report either, and absence keeps meaning "an older daemon".
286
+ * `capacity` above is the DERIVED number and predates the dial; it is a
287
+ * dispatch-era fossil the server ignores, and the two are not the same
288
+ * fact.
289
+ */
290
+ url.searchParams.set('mt', String(effectiveMaxTurns()));
262
291
  }
263
292
  } catch {
264
293
  /* a readout — the poll must never fail on one */
@@ -1933,6 +1962,20 @@ export async function runFleetDaemon() {
1933
1962
  }
1934
1963
  }
1935
1964
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
1965
+ /**
1966
+ * HOW MANY TURNS THE APP SAYS THIS MACHINE MAY RUN (2026-09-17).
1967
+ *
1968
+ * Set on EVERY poll, including the ones that carry no key — absence is how
1969
+ * "Auto" is spelled and how an older server looks, and both mean the
1970
+ * derivation stands. Leaving a previous value in place on an absent key
1971
+ * would make turning the dial back to Auto unspellable, which is the
1972
+ * learn-only bug `listSessionPlaces` already paid for once.
1973
+ *
1974
+ * HERE, ABOVE EVERY LANE, so a number that arrived on this poll binds the
1975
+ * spawns this same reconcile is about to decide. The `mt` the NEXT poll
1976
+ * reports is therefore the value that was actually in force.
1977
+ */
1978
+ setServerMaxTurns(roster.maxTurns);
1936
1979
  if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
1937
1980
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
1938
1981
  // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
@@ -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.89.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": {