flowviant 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -108,9 +122,27 @@ export function parseProposal(text) {
108
122
  ...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
109
123
  ? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
110
124
  : {}),
111
- ...(Array.isArray(g.waitsOn)
112
- ? { waitsOn: g.waitsOn.filter((w) => typeof w === 'string' && w).slice(0, 20) }
113
- : {}),
125
+ // NO `waitsOn`, AND ITS ABSENCE IS THE FEATURE (2026-09-16).
126
+ //
127
+ // The planner's schema used to carry it — tempIds of agents that had to
128
+ // MERGE first — and the owner deleted the idea: "whats the point of
129
+ // dividing up the agents if one of the agents rely on waiting for one to
130
+ // finish? if thats the case have it be in the same agent." An agent
131
+ // already works its cards in order, so a chain split across two agents
132
+ // buys a second worktree, a second branch and a second review and then
133
+ // idles one of them; the only thing a split buys is SIMULTANEOUS work.
134
+ // SYSTEM_PLAN no longer mentions the key at all.
135
+ //
136
+ // So a model that emits it anyway is answering a schema it was not given
137
+ // — an older prompt cached in a resumed conversation, or invention — and
138
+ // reading it would create a silently-waiting agent through the exact door
139
+ // the prompt just closed. DROPPED, not REFUSED: this file's own law is
140
+ // lenient packaging, strict shape, and a stray key is packaging. The
141
+ // proposal is otherwise good work the operator already paid for.
142
+ //
143
+ // The SERVER still accepts and honours `waitsOn` on the wire — 0.86.0
144
+ // daemons are still proposing it and existing agents still carry it. This
145
+ // is the end of PROPOSING one, not the end of reading one.
114
146
  ...(typeof g.intoAgentId === 'string' && g.intoAgentId
115
147
  ? { intoAgentId: g.intoAgentId.slice(0, 64) }
116
148
  : {}),
@@ -140,23 +172,7 @@ export function parseProposal(text) {
140
172
  * up here rather than being read as success.
141
173
  */
142
174
  export function parseTurnResult(text) {
143
- const parsedRaw = String(text ?? '');
144
- const fenced = parsedRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
145
- const candidates = [];
146
- if (fenced) candidates.push(fenced[1]);
147
- for (let i = 0; i < parsedRaw.length; i++) {
148
- if (parsedRaw[i] !== '{') continue;
149
- const end = balanced(parsedRaw, i);
150
- if (end > i) candidates.push(parsedRaw.slice(i, end + 1));
151
- }
152
- for (const body of candidates) {
153
- let v;
154
- try {
155
- v = JSON.parse(body);
156
- } catch {
157
- continue;
158
- }
159
- if (!v || typeof v !== 'object') continue;
175
+ for (const v of candidateObjects(String(text ?? ''))) {
160
176
  if (v.status === 'blocked') {
161
177
  const question = typeof v.question === 'string' ? v.question.trim() : '';
162
178
  // A "blocked" with no question is not an answer anybody can act on — it
@@ -185,3 +201,80 @@ export function parseTurnResult(text) {
185
201
  }
186
202
  return null;
187
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
+ }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
15
  import { SAFE } from './config.mjs';
16
- import { runtimeById, humanizeClaudeTool } from './runtimes.mjs';
16
+ import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
17
17
 
18
18
  // Every prompt/kickoff constant lives in prompts.mjs and is re-exported here:
19
19
  // a dozen call sites import them from claude.mjs, and none of them care where
@@ -197,12 +197,39 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
197
197
  if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
198
198
  for (const b of ev.message.content) {
199
199
  if (b.type === 'thinking' || b.type === 'redacted_thinking') {
200
- // The `thinking` text is usually redacted (signature only), so emit a
201
- // marker enough to show Claude is actively reasoning, not hung.
202
- push({ kind: 'think', label: b.thinking ? `thinking: ${oneLine(b.thinking)}` : 'thinking…' });
200
+ /**
201
+ * NO THINKING TEXT ARRIVES TODAY, AND THAT IS MEASURED (2026-09-16).
202
+ *
203
+ * This used to say the text is "usually redacted", which was a guess
204
+ * doing the work of a fact. The fact: across three real transcripts, 93
205
+ * thinking blocks, EVERY ONE of them carried an empty `thinking` — and
206
+ * a live probe with MAX_THINKING_TOKENS set and
207
+ * `--include-partial-messages` on returned an empty `thinking_delta`
208
+ * and a complete block of length zero (signature only). So the CLI
209
+ * emits the FACT that it reasoned and not a word of the reasoning, and
210
+ * "show the full thinking" cannot be conjured from this stream.
211
+ *
212
+ * THE BRANCH BELOW EXISTS ANYWAY, and deliberately. The shape is the
213
+ * whole point: when text is present it rides `full` UNCLIPPED, so the
214
+ * day a CLI release starts emitting it, the trace carries the thought
215
+ * whole with no daemon change and no version floor — the report's own
216
+ * presence is the capability. Until then every block takes the marker
217
+ * arm, and `trace.mjs` collapses a run of identical markers so the
218
+ * absence reads as one quiet step rather than forty.
219
+ */
220
+ push(
221
+ b.thinking
222
+ ? { kind: 'think', label: `thinking: ${oneLine(b.thinking)}`, full: b.thinking }
223
+ : { kind: 'think', label: THINK_MARKER }
224
+ );
203
225
  } else if (b.type === 'text' && b.text?.trim()) {
204
226
  if (!answerFromResult) appendText(b.text + '\n');
205
- push({ kind: 'say', label: oneLine(b.text) });
227
+ // `label` for the console and the pulse — one collapsed 160-char line,
228
+ // byte-identical to what it has always printed. `full` for the trace:
229
+ // a `say` is the agent NARRATING, several sentences at a time, and the
230
+ // clip at 160 was landing mid-sentence on the one thing a person opens
231
+ // the page to read.
232
+ push({ kind: 'say', label: oneLine(b.text), full: b.text });
206
233
  } else if (b.type === 'tool_use') {
207
234
  push(humanizeToolUse(b.name, b.input || {}, cwd));
208
235
  // The STRUCTURED form of the same event, for the transcript's tool
package/bin/lib/fleet.mjs CHANGED
@@ -82,6 +82,7 @@ import {
82
82
  probeSkillsOnce,
83
83
  recordSkills,
84
84
  RUNTIMES,
85
+ THINK_MARKER,
85
86
  } from './runtimes.mjs';
86
87
  import { createWorkManager } from './work.mjs';
87
88
  import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
@@ -1598,7 +1599,13 @@ export async function runFleetDaemon() {
1598
1599
  pagesSeen.add(a.path || a.label);
1599
1600
  }
1600
1601
  // Collapse runs of bare "thinking…" so the feed doesn't fill with it.
1601
- if (!(a.label === 'thinking…' && feed[feed.length - 1] === 'thinking…')) {
1602
+ // Keyed on the SHARED constant (runtimes.mjs), not on the literal: the
1603
+ // labels being compared here are the ones claude.mjs now builds from
1604
+ // that constant, so a reworded marker would leave this comparison
1605
+ // matching nothing and the 48-slot feed filling with the repeat — the
1606
+ // exact noise this line exists to stop, and silent, because a collapse
1607
+ // that stops collapsing fails no test.
1608
+ if (!(a.label === THINK_MARKER && feed[feed.length - 1] === THINK_MARKER)) {
1602
1609
  feed.push(a.label);
1603
1610
  if (feed.length > 48) feed.shift();
1604
1611
  }
@@ -588,6 +588,35 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
588
588
  * TOUCHED. Everything else — how long something will take, who should own it,
589
589
  * whether it is a good idea — is not a question a planner can answer from a
590
590
  * repository, and asking for it produces confident invention.
591
+ *
592
+ * ── THERE IS NO WAY TO SAY "THIS ONE WAITS" (2026-09-16) ──
593
+ *
594
+ * The answer schema used to carry `waitsOn`, a list of tempIds an agent had to
595
+ * see MERGE before it could start, and the owner deleted the idea outright:
596
+ *
597
+ * "for the planning agent, whats the point of dividing up the agents if one
598
+ * of the agents rely on waiting for one to finish? if thats the case have it
599
+ * be in the same agent. the point of having it divide into various agent is
600
+ * so that its capable of parallel and simultaneous work."
601
+ *
602
+ * That is not a preference about a field, it is what an agent IS. An agent is
603
+ * one CLI in one worktree working its cards IN ORDER — ordering is the thing it
604
+ * already does, for free, with no branch to merge in between. So a planner that
605
+ * splits a chain across two agents has bought nothing and paid twice: a second
606
+ * worktree, a second branch, a second review, and a second agent sitting idle
607
+ * until the first one lands. The ONLY thing a split buys is two CLIs typing at
608
+ * the same time, and work that waits cannot do that by definition.
609
+ *
610
+ * So the vocabulary is gone rather than discouraged. A rule the model can still
611
+ * express a violation of is a rule it will sometimes express a violation of;
612
+ * removing the key removes the move. Rule 1 below carries the reasoning in the
613
+ * planner's own terms, and `agentPlan.mjs` drops the key if a model on an older
614
+ * prompt sends it anyway.
615
+ *
616
+ * WHAT DID NOT CHANGE, on purpose: the SERVER still accepts `waitsOn` on the
617
+ * wire and still honours it on stored proposals, because 0.86.0 daemons are
618
+ * still running and agents created under the old prompt still exist. This is a
619
+ * change to what is PROPOSED, not to what can be read.
591
620
  */
592
621
  export const SYSTEM_PLAN = `You are the human's own Claude, planning a batch of work in their repository.
593
622
 
@@ -602,8 +631,13 @@ as a single reviewable branch.
602
631
  THE RULES THAT MATTER:
603
632
 
604
633
  1. SEQUENTIAL WORK BELONGS IN ONE AGENT. If B needs A's code to exist, put them
605
- in the same agent, A first. Splitting a chain across agents means the second
606
- one waits for the first to MERGE before it can even start.
634
+ in the same agent, A first an agent works its cards in the order you give,
635
+ so ordering is free and costs no merge in between. THE ONLY REASON TO SPLIT
636
+ IS WORK THAT CAN RUN AT THE SAME TIME. There is no way to say that one agent
637
+ waits for another, and that is deliberate: an agent that has to wait is a
638
+ split done wrong. Splitting a chain buys a second worktree, a second branch
639
+ and a second review, and the second agent sits idle until the first one
640
+ merges — slower than one agent doing both in order.
607
641
 
608
642
  2. SPLIT ONLY WHAT CAN GENUINELY RUN AT THE SAME TIME. Two agents editing the
609
643
  same files land two branches that conflict, and somebody resolves it by hand.
@@ -641,7 +675,6 @@ after it. Wrap it in a \`\`\`json fence:
641
675
  "name": "auth",
642
676
  "taskIds": ["<card id>", "<card id>"],
643
677
  "pointsBudget": 8,
644
- "waitsOn": [],
645
678
  "intoAgentId": null
646
679
  }
647
680
  ]
@@ -649,7 +682,7 @@ after it. Wrap it in a \`\`\`json fence:
649
682
  \`\`\`
650
683
 
651
684
  Every selected card id must appear EXACTLY ONCE across all agents. Use the ids
652
- exactly as given. "waitsOn" holds tempIds of agents that must MERGE first.`;
685
+ exactly as given.`;
653
686
 
654
687
  /**
655
688
  * The planner's turn.
@@ -797,13 +830,25 @@ const safeName = (n) =>
797
830
  .trim()
798
831
  .slice(0, 60) || 'agent';
799
832
 
800
- 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) =>
801
844
  `id: ${task?.id ?? ''}\n` +
802
845
  `title: ${task?.title ?? ''}\n` +
803
846
  (task?.brief ? `\nbrief:\n${task.brief}\n` : '') +
804
847
  (task?.criteria?.length ? `\ndone when:\n${task.criteria.map((c) => `- ${c}`).join('\n')}\n` : '') +
805
848
  (task?.anchors?.length ? `\nthis card owns:\n${task.anchors.map((a) => `- ${a}`).join('\n')}\n` : '');
806
849
 
850
+ const taskBlock = AGENT_TASK_SPEC;
851
+
807
852
  /**
808
853
  * A PERSON SPOKE TO THE AGENT — usually the answer to its own question.
809
854
  *
@@ -819,3 +864,126 @@ export const AGENT_HUMAN_KICKOFF = ({ agentName, message, askedByName, task, pos
819
864
  `${fence('WHAT THEY SAID', message)}\n\n` +
820
865
  (task ? `${fence('THE CARD YOU ARE ON', taskBlock(task))}\n\n` : '') +
821
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.`;