flowviant 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -0,0 +1,702 @@
1
+ /** Agent review entry, project checks, and read-only pre-review turns. */
2
+ import { readFileSync, lstatSync, rmSync, readdirSync } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
6
+ import { git } from './git.mjs';
7
+ import { parsePrecheck } from './agentPlan.mjs';
8
+ import { readStash } from './agentCards.mjs';
9
+ import { c, note, warn } from './ui.mjs';
10
+ import { runTurn } from './claude.mjs';
11
+ import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
12
+ import { ARTIFACT_DIR, artifactTypeFor } from './artifacts.mjs';
13
+ import { scrub as envScrub } from './env.mjs';
14
+ import { pickRuntimeFor, removeProbeTranscript } from './runtimes.mjs';
15
+ import { taskIdsFromMessage } from './worktreeDiff.mjs';
16
+
17
+ /**
18
+ * CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
19
+ * THE PROJECT'S CHECK RUNS OVER THE WORKTREE (2026-09-24).
20
+ *
21
+ * A design or research turn may write under `.flowviant/artifacts/` and
22
+ * nowhere else — that posture's whole safety claim is that such a card
23
+ * changes no repository file. But the check is the repo's own command, run
24
+ * unattended in the same worktree the moment the queue empties, and a test
25
+ * runner's DEFAULT discovery reaches into that directory: vitest collected and
26
+ * ran `.flowviant/artifacts/pwn.test.js` with no include override. So a card
27
+ * steered by text it read could plant a test and have the machine execute it,
28
+ * before any person looked — and the directory is git-excluded, so the file
29
+ * never appears in the diff a reviewer reads.
30
+ *
31
+ * What an artifact IS is already a closed list (`ARTIFACT_TYPES`), and the
32
+ * scan only ever shows TOP-LEVEL regular files: anything else there is
33
+ * nothing the product will show, so removing it before the check costs
34
+ * nothing a person could see. Symlinks and subdirectories go whole; a
35
+ * `.flowviant` that is not a real directory is left alone (that is the
36
+ * repository's own content, not something a turn wrote). Returns the names
37
+ * removed, for the log line.
38
+ */
39
+ export function clearNonArtifacts(wt) {
40
+ const removed = [];
41
+ try {
42
+ const parent = lstatSync(join(wt, '.flowviant'));
43
+ if (parent.isSymbolicLink() || !parent.isDirectory()) return removed;
44
+ const dir = join(wt, ARTIFACT_DIR);
45
+ const st = lstatSync(dir);
46
+ if (st.isSymbolicLink() || !st.isDirectory()) {
47
+ rmSync(dir, { force: true, recursive: false });
48
+ removed.push(ARTIFACT_DIR);
49
+ return removed;
50
+ }
51
+ for (const name of readdirSync(dir)) {
52
+ const p = join(dir, name);
53
+ let e;
54
+ try {
55
+ e = lstatSync(p);
56
+ } catch {
57
+ continue; // vanished between the list and the stat
58
+ }
59
+ const keep = e.isFile() && !name.startsWith('.') && artifactTypeFor(name) !== null;
60
+ if (keep) continue;
61
+ try {
62
+ rmSync(p, { recursive: true, force: true });
63
+ removed.push(name);
64
+ } catch {
65
+ /* best-effort — reported by omission */
66
+ }
67
+ }
68
+ } catch {
69
+ /* no artifacts directory — the ordinary case */
70
+ }
71
+ return removed;
72
+ }
73
+
74
+ /**
75
+ * THE ENVIRONMENT THE PROJECT'S CHECK RUNS UNDER: the daemon's own, minus the
76
+ * machine credential — the same rule `cliEnv` (claude.mjs) applies to a turn,
77
+ * repeated here rather than imported so the check never depends on the CLI
78
+ * module's spawn helpers. Nothing a check runs needs the credential the daemon
79
+ * authenticates with; everything else is what the agent's own turn saw when it
80
+ * ran the same tests.
81
+ */
82
+ const CHECK_DROPPED_ENV = ['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET'];
83
+ export function checkEnv(env = process.env) {
84
+ const out = { ...env };
85
+ for (const k of CHECK_DROPPED_ENV) delete out[k];
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * DID THE ACCOUNT HIT A LIMIT?
91
+ *
92
+ * Deliberately a LITERAL MATCH on the few sentences the CLIs actually print,
93
+ * and the matched line is relayed VERBATIM. It is a trigger, not a
94
+ * classifier: nothing here decides what an error "means" or writes a sentence
95
+ * of its own, because the product's own rule is that it relays and never
96
+ * infers. The honest limit is that a phrasing nobody listed reads as an
97
+ * ordinary failed turn — which lands the agent in Stuck with the CLI's words
98
+ * attached, and is a perfectly survivable second-best.
99
+ */
100
+ const LIMIT_PHRASES = [
101
+ /usage limit reached/i,
102
+ /rate limit/i,
103
+ /you've reached your .* limit/i,
104
+ /quota exceeded/i,
105
+ /insufficient_quota/i,
106
+ ];
107
+ export const limitLine = (text) => {
108
+ for (const line of String(text ?? '').split('\n')) {
109
+ const t = line.trim();
110
+ if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
111
+ }
112
+ return null;
113
+ };
114
+
115
+ export function createWorkAgentReview({
116
+ repoRoot,
117
+ postBestEffort,
118
+ baseRef,
119
+ admit,
120
+ sessionMetaPath,
121
+ state,
122
+ }) {
123
+ const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
124
+ const AGENT_PRECHECK_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-precheck');
125
+
126
+ const { workChildren, groupKillChildren } = state;
127
+
128
+ // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
129
+ //
130
+ // The check runs in the agent's own worktree the moment its queue empties, so
131
+ // a reviewer knows before they start reading whether they are reviewing
132
+ // working code. It LABELS the review row; it never blocks it.
133
+ //
134
+ // IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
135
+ // beside `deploy.json`, because a check travels with the code and changes
136
+ // with it — a setting in the app would go stale the first time somebody
137
+ // renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
138
+ // plenty of projects have no single command that means "is this alright".
139
+ //
140
+ // It runs through a shell, and that is no wider than what already happens in
141
+ // that directory: every turn in this worktree spawns a CLI with build
142
+ // permissions, so a repository that can run arbitrary code during a turn can
143
+ // run it here too. What this is NOT is the deleted dev-run supervisor —
144
+ // nothing here resolves a command, guesses a stack, or starts a server.
145
+ const CHECK_TIMEOUT_MS = 10 * 60_000;
146
+ const CHECK_OUTPUT_CAP = 4000;
147
+
148
+ const readCheckCommand = () => {
149
+ try {
150
+ const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
151
+ const cfg = JSON.parse(raw);
152
+ const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
153
+ return cmd ? cmd.slice(0, 500) : null;
154
+ } catch {
155
+ return null; // absent, unreadable or not JSON — all mean "no check"
156
+ }
157
+ };
158
+
159
+ const postCheck = async (body) => {
160
+ // Same bounded retry as postAgentPlan — see postBestEffort. On the
161
+ // final failure the row simply keeps its previous answer, which is
162
+ // null the first time.
163
+ await postBestEffort(AGENT_CHECK_DONE_URL, body);
164
+ };
165
+
166
+ const runCheck = async (agentId, wt) => {
167
+ const cmd = readCheckCommand();
168
+ const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
169
+ if (!cmd) {
170
+ await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
171
+ return;
172
+ }
173
+ const cleared = clearNonArtifacts(wt);
174
+ if (cleared.length)
175
+ warn(
176
+ `agent ${agentId}: removed ${cleared.length} non-artifact file(s) from ${ARTIFACT_DIR} before the check — ${cleared.slice(0, 5).join(', ')}`
177
+ );
178
+ const out = await new Promise((resolve) => {
179
+ let text = '';
180
+ let done = false;
181
+ const finish = (status) => {
182
+ if (done) return;
183
+ done = true;
184
+ resolve({ status, text });
185
+ };
186
+ let child;
187
+ try {
188
+ // DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
189
+ // always a shell that spawns the real runner, and signalling the shell
190
+ // alone leaves the runner holding the worktree — and this place's
191
+ // WRITER lock — for as long as it likes.
192
+ //
193
+ // AND WITHOUT THE MACHINE CREDENTIAL. The check is the repo's command
194
+ // run with nobody watching, and it inherited `FLOWVIANT_MACHINE_TOKEN`
195
+ // — so anything the check executes (a test a turn wrote, a
196
+ // dependency's postinstall) could read the credential the whole
197
+ // machine authenticates with. It is `checkEnv()`, the environment the
198
+ // agent's own turn ran under (`cliEnv`'s rule), NOT `childEnv`'s
199
+ // allowlist: the agent runs these same tests in its turn, and a check
200
+ // stripped of `JAVA_HOME`, `DATABASE_URL` or the rest of the operator's
201
+ // shell would FAIL where the agent's own run passed — a "Check failed"
202
+ // the product manufactured and then relayed as the project's verdict.
203
+ // The planted-file path is closed by `clearNonArtifacts` above.
204
+ child = spawn(cmd, {
205
+ cwd: wt,
206
+ shell: true,
207
+ detached: true,
208
+ stdio: ['ignore', 'pipe', 'pipe'],
209
+ env: checkEnv(),
210
+ });
211
+ /**
212
+ * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
213
+ *
214
+ * A check is a full test or build run in the agent's worktree, and it
215
+ * was the one long-lived child the daemon spawned without telling its
216
+ * own teardown about it. `shutdownWork` signalled the CLI children and
217
+ * left this one — so restarting the daemon, or a same-repo takeover,
218
+ * orphaned a running test suite inside a worktree the sweep may then
219
+ * try to remove. The ten-minute timer would eventually kill it, but by
220
+ * then it belongs to no daemon and nothing on any surface names it.
221
+ *
222
+ * Registered for a GROUP kill: with `shell: true` the child is
223
+ * `/bin/sh`, and signalling it leaves the runner it started behind —
224
+ * which is the process actually holding the worktree. See
225
+ * `groupKillChildren` for why this one is exempt from the
226
+ * never-signal-the-group rule.
227
+ */
228
+ workChildren.set(child, agentId);
229
+ groupKillChildren.add(child);
230
+ } catch (e) {
231
+ // TEXT BEFORE FINISH: `finish` captures `text` by value into the
232
+ // resolved object, so assigning afterwards threw the spawn error away
233
+ // and the surface showed an empty failure.
234
+ text = String(e?.message || e);
235
+ finish('failed');
236
+ return;
237
+ }
238
+ /**
239
+ * The TAIL, not the head: a failing check says why at the end. And
240
+ * SCRUBBED BEFORE IT IS CUT, which is the order that matters.
241
+ *
242
+ * It used to slice first: `text = (text + buf).slice(-CAP)`, with a
243
+ * single `envScrub` at the very end. `envScrub` replaces EXACT full
244
+ * values, so any credential straddling either boundary — the rolling
245
+ * window's, or a chunk's — was already cut in half by the time it was
246
+ * looked at, matched nothing, and the surviving tail was written to
247
+ * `agent.checkOutput` and shown to every member of the project. A failing
248
+ * integration test that dumps its environment is an ordinary way to reach
249
+ * that, and the partial is enough where the prefix of the key is a
250
+ * publicly known constant.
251
+ *
252
+ * Scrubbing on every chunk fixes both straddles at once: the accumulated
253
+ * text always holds the previous kept tail plus the whole new chunk, so a
254
+ * value split across chunks is whole here, and a value near the window
255
+ * edge is redacted before anything is discarded. Bounded work — the
256
+ * string is never longer than the cap plus one chunk.
257
+ *
258
+ * The one case it cannot cover is a secret LONGER than the cap itself,
259
+ * which can never sit in the window whole. The final scrub below stays as
260
+ * the second pass over what actually ships.
261
+ */
262
+ const keep = (buf) => {
263
+ text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
264
+ };
265
+ child.stdout?.on('data', keep);
266
+ child.stderr?.on('data', keep);
267
+ const timer = setTimeout(() => {
268
+ try {
269
+ // The GROUP, not the child: killing the shell leaves whatever it
270
+ // started running, which is the thing actually taking ten minutes.
271
+ process.kill(-child.pid, 'SIGKILL');
272
+ } catch {
273
+ try {
274
+ child.kill('SIGKILL');
275
+ } catch {
276
+ /* already gone */
277
+ }
278
+ }
279
+ text += '\n[flowviant] the check ran past ten minutes and was stopped';
280
+ // RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
281
+ // hangs: if the group is already gone the event never arrives, and this
282
+ // promise holds the place's writer lock forever.
283
+ finish('failed');
284
+ }, CHECK_TIMEOUT_MS);
285
+ child.on('error', (e) => {
286
+ clearTimeout(timer);
287
+ workChildren.delete(child);
288
+ text += String(e?.message || e);
289
+ finish('failed');
290
+ });
291
+ child.on('close', (code) => {
292
+ clearTimeout(timer);
293
+ workChildren.delete(child);
294
+ finish(code === 0 ? 'passed' : 'failed');
295
+ });
296
+ });
297
+ await postCheck({
298
+ agentId,
299
+ status: out.status,
300
+ output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
301
+ ...(headSha ? { headSha } : {}),
302
+ });
303
+ };
304
+
305
+ // ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
306
+ //
307
+ // A FRESH Claude reads the branch before the human does. The owner asked for
308
+ // it in these words: "before having the user manually check, can we have the
309
+ // daemon … spawn an agent to review the work so basically we get an ai to look
310
+ // at the review before a human looks at it for a double check."
311
+ //
312
+ // IT IS NOT THE AGENT CHECKING ITSELF. `runTurn` is called with no `resume`,
313
+ // so there is no conversation to inherit: an agent that spent four turns
314
+ // arguing itself into a design defends that design, and asked whether its work
315
+ // meets the card it answers from the very context that produced the work. The
316
+ // reviewer stands IN the agent's worktree because it needs the code and the
317
+ // diff, under `readOnly` (`consultPermFor` — the fenced Read/Glob rules plus
318
+ // a few path-validated git reads) with NO MCP passed at all, so there is no
319
+ // control plane on this turn even if the repository it reads tries to steer
320
+ // it.
321
+ //
322
+ // IT LABELS AND NEVER BLOCKS — the check's own law, one function up. Approve,
323
+ // the per-card verdicts and the ship quiz do not know this exists. Every exit
324
+ // below POSTS NOTHING, and the server renders an absent precheck as nothing:
325
+ // a failed, timed-out, skipped or unparseable read leaves the human's review
326
+ // exactly as it was before this feature existed. Ignorance never withholds.
327
+ //
328
+ // NO VERSION FLOOR. This is a daemon→server report on a NEW endpoint, so an
329
+ // older daemon simply never posts, and an older SERVER 404s — which `postPre`
330
+ // treats as delivered-and-done for the agent-trace reason stated there.
331
+ //
332
+ // IT RIDES THE BEAT THE CHECK ALREADY OWNS (`runReviewEntry`), AFTER it: the
333
+ // check is a local command and this is a model call, so the cheap answer lands
334
+ // on the row first and a wedged reviewer cannot delay it.
335
+ /**
336
+ * FIVE MINUTES, and `runTurn` has no timer of its own.
337
+ *
338
+ * Half the planner's cap, because this turn is strictly smaller — it reads one
339
+ * branch's diff and answers, where a planner reads a repository to decide
340
+ * whether a batch of work collides. And it is HELD INSIDE THE PLACE WRITER
341
+ * LOCK by the beat it rides, so every minute here is a minute the agent's next
342
+ * turn (or its merge) is waiting: a generous cap on a label would be spending
343
+ * the work's time on a note about the work.
344
+ */
345
+ const PRECHECK_TIMEOUT_MS = 5 * 60_000;
346
+ /** Commit subjects handed to the reviewer. A bound on the prompt, not on the
347
+ * branch — the reviewer reads the diff itself, and the log is context. */
348
+ const PRECHECK_LOG_LINES = 80;
349
+
350
+ /**
351
+ * ONE PRE-REVIEW, POSTED.
352
+ *
353
+ * Resolves TRUE for a permanent refusal as well as a success, and that is
354
+ * deliberate — the `postAgentTrace` rule, for the same reason: a server with
355
+ * no such route 404s this body and will 404 every retry of it, so re-sending
356
+ * would be a wedge wearing a retry's clothes. A NETWORK error resolves false
357
+ * and is retried ONCE, because unlike a trace batch this body cost a whole
358
+ * model call and losing it to a blip means the operator paid for a label
359
+ * nobody ever sees.
360
+ *
361
+ * THE RETRY IS NOT A GUARANTEE THAT THE FIRST ATTEMPT FAILED, and since the
362
+ * body started carrying `usage` (0.90.0) that matters. The `catch` below
363
+ * takes the 30-second `AbortSignal` with everything else, so a server that
364
+ * was merely SLOW — and committed — receives this identical body twice. The
365
+ * store was always idempotent; the token counts riding it were not, and a
366
+ * timed-out-but-committed post added them twice. The SERVER closes that: its
367
+ * write refuses a body whose text already sits on the row, so a re-send
368
+ * stores the same reading and charges nothing for it. Nothing here needs to
369
+ * change, and nothing here may start assuming a thrown fetch means the
370
+ * server never saw this.
371
+ */
372
+ const postPre = async (body) => {
373
+ try {
374
+ const res = await fetch(AGENT_PRECHECK_URL, {
375
+ method: 'POST',
376
+ headers: {
377
+ Authorization: `Bearer ${FLEET_TOKEN}`,
378
+ 'User-Agent': USER_AGENT,
379
+ 'Content-Type': 'application/json',
380
+ },
381
+ signal: AbortSignal.timeout(30_000),
382
+ body: JSON.stringify(body),
383
+ });
384
+ return (
385
+ res.ok ||
386
+ (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
387
+ );
388
+ } catch {
389
+ return false; // a blip — worth one more attempt at a model call's answer
390
+ }
391
+ };
392
+
393
+ /**
394
+ * THE BRANCH'S OWN COMMITS, subject + trailers, and the card ids they name.
395
+ *
396
+ * MEASURED, never asserted — the same reason `commitsBetween` exists. The
397
+ * trailer ids are what let the prompt say "N earlier cards' specs are not on
398
+ * this box" honestly: the difference between the cards this branch claims and
399
+ * the specs this disk holds is a fact, and a box that adopted the agent
400
+ * mid-run is exactly the case where it is non-zero.
401
+ *
402
+ * IT CANNOT THROW. `baseRef()` is free text an owner typed and may name no
403
+ * ref at all; an unreadable range costs the reviewer its context, never the
404
+ * review-entry beat it is standing in.
405
+ */
406
+ const branchLog = (wt) => {
407
+ let out;
408
+ try {
409
+ out = git(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
410
+ } catch {
411
+ return { text: '', taskIds: [] };
412
+ }
413
+ if (typeof out !== 'string') return { text: '', taskIds: [] };
414
+ // LINEAR, never backtracking (audit 2026-09-24) — this was
415
+ // `out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)`, whose `\s*$` is
416
+ // quadratic against a long trailing run of whitespace on one line
417
+ // (measured ~1s at 40KB), and a branch's own commit log is exactly the
418
+ // kind of text an agent's commits can grow past that. `taskIdsFromMessage`
419
+ // is the same line-split, length-capped reader every other trailer scan
420
+ // in this codebase uses.
421
+ const taskIds = taskIdsFromMessage(out);
422
+ const text = out
423
+ .split('\n')
424
+ .filter((l) => l.trim())
425
+ .slice(0, PRECHECK_LOG_LINES)
426
+ .join('\n');
427
+ return { text, taskIds: [...taskIds] };
428
+ };
429
+
430
+ const runPrecheck = async (agentId, wt, agentName) => {
431
+ /**
432
+ * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
433
+ * planner uses, and for the same reason: this prompt was written against
434
+ * Claude, and a machine with no read-only-capable runtime simply does not
435
+ * produce a precheck. Nothing is posted and nothing is said on the row.
436
+ */
437
+ const rt = pickRuntimeFor('consult');
438
+ if (!rt) return;
439
+ /**
440
+ * THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
441
+ * asks it. `churn` and never `interactive`: nobody is watching this, and a
442
+ * label is the first thing that should not be started on a box that is
443
+ * struggling. Deferring here does NOT queue anything — there is no job and
444
+ * no re-offer — so a precheck skipped under pressure is simply a precheck
445
+ * that did not happen, which is what absence already means.
446
+ */
447
+ const hold = admit('churn');
448
+ if (hold) {
449
+ note(`${c.cyan('pre-review')} ${c.dim(`— skipped: ${hold.reason}`)}`);
450
+ return;
451
+ }
452
+ const releaseSlot = admit.reserve();
453
+
454
+ // THE HEAD THE READING BELONGS TO, taken BEFORE the turn — the
455
+ // `checkFingerprint` shape, so a commit landing after this voids it rather
456
+ // than letting an old reading label a branch it never saw. Optional: an
457
+ // unreadable head costs the staleness comparison, not the precheck.
458
+ let headSha = null;
459
+ try {
460
+ headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
461
+ } catch {
462
+ headSha = null;
463
+ }
464
+
465
+ const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
466
+ const log = branchLog(wt);
467
+ /** Cards the BRANCH names that this box has no spec for. Measured, not
468
+ * guessed — see agentCards.mjs on why a stash is per-box. */
469
+ const held = new Set(stash.map((s) => s.taskId));
470
+ const missingSpecs = log.taskIds.filter((id) => !held.has(id)).length;
471
+
472
+ let out = '';
473
+ let child = null;
474
+ let timer = null;
475
+ /** The cap fired: the CLI was still running when this machine stopped it. */
476
+ let wedged = false;
477
+ /**
478
+ * WHAT THE READING SPENT — the agent-turn lane's own relay, on the one
479
+ * other lane that charges an AGENT (2026-09-19).
480
+ *
481
+ * It rides the agent and never a card link: a pre-review belongs to no card
482
+ * (the same reason `workChildren` gets a null task id below), so the four
483
+ * counters it adds to are the container's alone. That is also why an
484
+ * agent's links can never sum to its total, and the schema says so.
485
+ *
486
+ * A WEDGED OR UNPARSEABLE READING POSTS NOTHING AT ALL, so it charges
487
+ * nothing either — which under-reports a turn that really did spend. That
488
+ * is the honest direction: there is no row to put it on, and inventing a
489
+ * settle to carry a number would be a post whose only content is a bill.
490
+ */
491
+ let usage = null;
492
+ /**
493
+ * THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
494
+ * to DELETE the transcript it leaves behind (review, 2026-09-17).
495
+ *
496
+ * This is the first thing in the daemon to run a second `claude -p` inside
497
+ * an AGENT's worktree, and the agent's own resume is `--continue`, which is
498
+ * CWD-KEYED — the invariant `runAgentTurn` states in words ("ONE AGENT IS
499
+ * ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly right here").
500
+ * Leaving this turn's `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place
501
+ * makes the read-only stranger the newest conversation in that directory,
502
+ * so the agent's NEXT turn — a send-back's re-queued card, a merge-resolve,
503
+ * a human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
504
+ * READ-ONLY" instead of its own four-turn context, under build permissions.
505
+ * That is the 0.69.0 Workbench cross-resume and codex's `resume --last`,
506
+ * arriving a third time by a third route.
507
+ *
508
+ * `removeProbeTranscript` is exported for precisely this and already serves
509
+ * the skills probe and the dev-command resolver.
510
+ */
511
+ let preSession = null;
512
+ try {
513
+ /**
514
+ * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
515
+ * the kill — the shape the project check and the planner both keep. A
516
+ * SIGKILLed process whose stdio a grandchild still holds can be slow to
517
+ * emit `close`, or never emit it, and this promise is inside the place's
518
+ * writer lock.
519
+ */
520
+ let stopWaiting = () => {};
521
+ const capped = new Promise((r) => {
522
+ stopWaiting = r;
523
+ });
524
+ const turn = runTurn({
525
+ prompt: AGENT_PRECHECK_KICKOFF({
526
+ agentName,
527
+ cards: stash.map((s) => s.prompt).join('\n---\n'),
528
+ missingSpecs,
529
+ commits: log.text,
530
+ diffCommand: `git diff ${baseRef()}...HEAD`,
531
+ }),
532
+ system: SYSTEM_PRECHECK,
533
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
534
+ // passed empty, so there is no control plane on this turn at all.
535
+ readOnly: true,
536
+ cwd: wt,
537
+ runtime: rt,
538
+ // NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
539
+ // agent grading its own homework out of its own context; this is a
540
+ // stranger reading a diff.
541
+ streamJson: true,
542
+ answerFromResult: true,
543
+ label: c.cyan('[pre-review]'),
544
+ // The id the CLI reports at `system.init` — harvested off the stream
545
+ // this turn already parses, no probe and no extra spawn. Held only so
546
+ // the `finally` below can delete this turn's transcript; see
547
+ // `preSession`.
548
+ onInit: (i) => {
549
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
550
+ preSession = i.sessionId.trim();
551
+ },
552
+ // The CLI's own count for this reading. Set, never accumulated: one
553
+ // turn is one `result` event, and the adding-up is the server's.
554
+ // Tagged with the CLI, so a Claude pre-review of a Codex agent is
555
+ // charged as Claude's and not folded into the agent's own figure.
556
+ onUsage: (u) => {
557
+ usage = { ...u, runtime: rt };
558
+ },
559
+ onSpawn: (ch) => {
560
+ child = ch;
561
+ // No task id: a pre-review belongs to no card, and the machine
562
+ // snapshot's per-task rows must not invent one. It still COUNTS
563
+ // against the machine's ceiling — see liveTurnCount.
564
+ workChildren.set(ch, null);
565
+ releaseSlot();
566
+ // ARMED AT THE SPAWN, not at entry: time spent getting here is not a
567
+ // wedged CLI. The CHILD and never its group — a read-only turn starts
568
+ // no server, so there is nothing behind it worth signalling and
569
+ // everything to lose by signalling somebody else's.
570
+ timer = setTimeout(() => {
571
+ wedged = true;
572
+ try {
573
+ ch.kill('SIGKILL');
574
+ } catch {
575
+ /* already gone */
576
+ }
577
+ stopWaiting('');
578
+ }, PRECHECK_TIMEOUT_MS);
579
+ timer.unref?.();
580
+ },
581
+ });
582
+ out = await Promise.race([turn, capped]);
583
+ } catch {
584
+ return; // a label may never fail the beat it rides
585
+ } finally {
586
+ if (timer) clearTimeout(timer);
587
+ if (child) workChildren.delete(child);
588
+ releaseSlot();
589
+ /**
590
+ * DELETE THIS READING'S TRANSCRIPT, on EVERY exit — settled, wedged and
591
+ * killed, or thrown — because every one of them leaves the file behind
592
+ * and the agent's `--continue` reads the newest one in the directory.
593
+ *
594
+ * AFTER the kill and ON A DELAY, the skills probe's own shape: the
595
+ * transcript is the CHILD's file, so removing it while the child is still
596
+ * dying races a recreate. Unref'd — it must not hold the process open.
597
+ */
598
+ if (preSession) setTimeout(() => removeProbeTranscript(wt, preSession), 750).unref?.();
599
+ }
600
+
601
+ // A WEDGED READING POSTS NOTHING. There is no row to settle and nobody
602
+ // waiting on an answer, so the honest record is that no pre-review exists —
603
+ // the same silence a machine that never ran one leaves.
604
+ if (wedged) {
605
+ note(`${c.cyan('pre-review')} ${c.dim('— ran past five minutes and was stopped')}`);
606
+ return;
607
+ }
608
+
609
+ /**
610
+ * SCRUBBED ON THE WAY OUT, every string — AND SCRUBBED BEFORE IT IS CUT,
611
+ * which is the order that matters and the reason `envScrub` rides INTO the
612
+ * parser rather than being applied to what comes back out.
613
+ *
614
+ * This turn read the repository with `cat` and `git show` in a worktree
615
+ * holding the project's materialized dev secrets, and its answer is about to
616
+ * be stored and rendered to every member of the project. `scrub` replaces
617
+ * EXACT full values, so a note capped first and scrubbed second hands the
618
+ * scrub a credential already cut in half: it matches nothing and the
619
+ * surviving prefix ships. The check's output lane learned exactly that the
620
+ * expensive way, and this lane relearned it in review (2026-09-17) — see
621
+ * `parsePrecheck`, which now caps only what it has already redacted.
622
+ */
623
+ const result = parsePrecheck(out, envScrub);
624
+
625
+ /**
626
+ * A QUOTA LIMIT SKIPS THE PRE-REVIEW AND PARKS NOTHING — AND A LIMIT IS
627
+ * ONLY A LIMIT WHEN THE READING PRODUCED NOTHING.
628
+ *
629
+ * `limitLine` is a literal phrase match over the CLI's whole output, and
630
+ * under `answerFromResult` that output IS the reviewer's answer — so a
631
+ * pre-review OF rate-limiting code, or any triage that quotes the phrase,
632
+ * read as a quota failure and threw a perfectly good reading away. The
633
+ * agent-turn lane fixed this exact false positive once (`const limit = res
634
+ * ? null : limitLine(out)`); gating on "nothing parsed" is what makes the
635
+ * match mean what it says.
636
+ *
637
+ * And when it IS a limit, nothing parks. `postAgentParked` stops EVERY
638
+ * agent on the project, because the CLI login is shared — the right answer
639
+ * when the thing that hit the limit was somebody's actual work, the wrong
640
+ * one here: parking a whole fleet because a LABEL could not be written
641
+ * would let an optional readout take the product's primary lane down. The
642
+ * branch is still reviewable; it just has no note on it.
643
+ */
644
+ if (!result) {
645
+ if (limitLine(out)) {
646
+ note(`${c.cyan('pre-review')} ${c.dim('— skipped: the CLI reported a limit')}`);
647
+ }
648
+ // UNPARSEABLE POSTS NOTHING. A half-read triage is a plausible-looking
649
+ // paragraph nobody wrote, rendered on the surface where somebody decides
650
+ // whether a branch reaches main — see parsePrecheck.
651
+ return;
652
+ }
653
+
654
+ const body = {
655
+ agentId,
656
+ ...(headSha ? { headSha } : {}),
657
+ ...(usage ? { usage } : {}),
658
+ cards: result.cards.map((cd) => ({
659
+ taskId: cd.taskId,
660
+ verdict: cd.verdict,
661
+ ...(cd.note ? { note: cd.note } : {}),
662
+ })),
663
+ ...(result.overall ? { overall: result.overall } : {}),
664
+ };
665
+ // ONE RETRY, and only for a network error — see `postPre`. A permanent
666
+ // refusal is an older server, and asking it again changes nothing.
667
+ if (!(await postPre(body))) await postPre(body);
668
+ };
669
+
670
+ /**
671
+ * REVIEW ENTRY — everything this machine does the moment an agent's queue
672
+ * empties, in one place.
673
+ *
674
+ * It exists so the two readings cannot drift apart at the three call sites
675
+ * that own this beat (the settle reply, the held body's re-POST, and the
676
+ * stale-merge re-read after base is folded in). Each of those used to call
677
+ * `runCheck` directly; a second thing to run at the same moment is a second
678
+ * thing three call sites can forget.
679
+ *
680
+ * THE CHECK FIRST, ALWAYS. It is a local command whose answer the board wants
681
+ * on the row immediately; the pre-review is a model call that may take
682
+ * minutes. Ordering them the other way would put a label behind a label.
683
+ *
684
+ * NEITHER MAY THROW PAST THIS POINT. Both are optional readouts and both run
685
+ * INSIDE the place's writer lock on a path whose callers settle real work —
686
+ * `runAgentMerge` in particular reports a claimed merge after this returns.
687
+ */
688
+ const runReviewEntry = async (agentId, wt, agentName) => {
689
+ try {
690
+ await runCheck(agentId, wt);
691
+ } catch {
692
+ /* the row keeps its previous check answer, which is null the first time */
693
+ }
694
+ try {
695
+ await runPrecheck(agentId, wt, agentName);
696
+ } catch {
697
+ /* no pre-review is posted, and absence renders nothing */
698
+ }
699
+ };
700
+
701
+ return { runReviewEntry };
702
+ }