@golden-frijoles/kit 0.0.0 → 0.2.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 (51) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/lib/cross-agent-cli.mjs +695 -0
  14. package/dist/lib/gh-rest.mjs +229 -0
  15. package/dist/lib/golden-onboarding.mjs +179 -0
  16. package/dist/lib/jev.mjs +334 -0
  17. package/dist/lib/log-branch.mjs +106 -0
  18. package/dist/lib/pmo-benchmarks.mjs +61 -0
  19. package/dist/lib/pmo-delivery.mjs +102 -0
  20. package/dist/lib/pmo-metrics.mjs +200 -0
  21. package/dist/lib/pmo-templates.mjs +132 -0
  22. package/dist/lib/pmo-window-log.mjs +99 -0
  23. package/dist/lib/project-root.mjs +78 -0
  24. package/dist/lib/prose-brief.mjs +191 -0
  25. package/dist/lib/prose-guard.mjs +761 -0
  26. package/dist/lib/prose-writer.mjs +410 -0
  27. package/dist/lib/report-registry.mjs +469 -0
  28. package/dist/lib/reporting-config.mjs +266 -0
  29. package/dist/lib/roadmap-contract.mjs +305 -0
  30. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  31. package/dist/lib/standup-deck.mjs +120 -0
  32. package/dist/lib/telegram-format.mjs +91 -0
  33. package/dist/live-smoke.mjs +303 -0
  34. package/dist/pmo/benchmarks.json +77 -0
  35. package/dist/pmo/templates/metrics-sheet.md +27 -0
  36. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  37. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  38. package/dist/pmo-report.mjs +338 -0
  39. package/dist/preflight.mjs +444 -0
  40. package/dist/prose/cpo-persona.md +108 -0
  41. package/dist/prose/internal.task.md +48 -0
  42. package/dist/prose/standup.task.md +38 -0
  43. package/dist/prose/weekly.task.md +36 -0
  44. package/dist/prose-draft.mjs +198 -0
  45. package/dist/prose-lessons.md +59 -0
  46. package/dist/roadmap-extract.mjs +410 -0
  47. package/dist/standup/templates/daily-story-deck.md +46 -0
  48. package/dist/standup.mjs +634 -0
  49. package/dist/vercel-prune-previews.mjs +164 -0
  50. package/dist/weekly-recap.mjs +526 -0
  51. package/package.json +33 -1
@@ -0,0 +1,410 @@
1
+ // prose-writer.mjs — the one rail every LOCAL prose surface in this repo writes through.
2
+ //
3
+ // PORTED from ~/dobby/golden-beans/scripts/lib/prose-writer.mjs (2026-07-26). What is NOT ported:
4
+ // golden-beans carries its own devin/agy spawn code because it is a separate repo. We already have
5
+ // that plumbing in `scripts/lib/cross-agent-cli.mjs`, so this file is a THIN adapter over it — a
6
+ // second spawn path would be a second place for the argv cap, the empty-output contract and the
7
+ // agy version pin to drift (sprint-1 build contract).
8
+ //
9
+ // Scope note (README D1): this is the LOCAL backend, and it is local by NECESSITY, not by choice.
10
+ // Every writer below is an interactive CLI with no headless auth — `hasCmd` returns false for all
11
+ // three inside a Claude Routine or a GitHub Actions runner — so the routines write their prose
12
+ // in-context and call the same pure guard as a separate `--post` step (D2). The guard, the persona
13
+ // and the lessons file are shared; only the writers are local-only.
14
+ //
15
+ // This is why the merge report cannot move to CI. Writing prose in a runner would need a paid
16
+ // hosted API key, and using the cheap/free model CLIs is the point of the rail — see
17
+ // Roadmap/09-platform-infra/prose-rail-headless/README.md, which records the rejected design so
18
+ // nobody re-derives it.
19
+ //
20
+ // ── The router (README D1, and the product owner's call, 2026-07-26) ──────────────────────
21
+ // **devin → agy (`gpt-oss-120b-medium`) → codex.** devin is the DEDICATED prose writer; agy is the
22
+ // fallback; codex is the last resort, added 2026-07-28.
23
+ //
24
+ // Why a third seat: devin refused 6 of 25 runs with "high demand for this model". The devin → agy
25
+ // fallback handled that correctly every time — but two pools is one bad day from no report, and agy
26
+ // shares its quota with the review layer. codex is last precisely BECAUSE it is the primary
27
+ // reviewer: prose only draws on that quota once both dedicated writers have failed.
28
+ //
29
+ // This is a division of LABOUR, not a ranking. Codex and agy are the primary code reviewers and the
30
+ // builders lean on them, so their quota is the scarce resource — and this repo has already had the
31
+ // cross-family review layer go fully dark mid-epic when codex and agy were capped in the same
32
+ // session (2026-07-24, the reason devin was installed at all). Devin was added as a third review
33
+ // seat and sits mostly idle. Giving devin prose outright keeps the review quota free.
34
+ //
35
+ // The fallbacks are not decoration: agy and codex carry genuinely separate quota pools, and this
36
+ // repo has already been bitten by a single-provider rail going quiet mid-session (LEARNINGS — wire
37
+ // the fallback to the CONDITION, not to one of its signatures).
38
+ //
39
+ // ── PROSE_MODEL: one model, and deliberately not a Gemini one ──────────────────────────────────
40
+ // `gemini-3.5-flash-high` was `prose-draft.mjs`'s default until this sprint. That is the EXACT
41
+ // constant golden-beans identified as having silently destroyed the register of every report it
42
+ // wrote: the drafts people accepted had come from GPT-OSS, on the occasions Gemini's quota was
43
+ // exhausted and the pair fell through. The router was blamed and briefly reversed; the router was
44
+ // never the problem. The fix belongs in the model constant, so:
45
+ // • ONE model. No model-level fallback, because a silent fall-through between two models with
46
+ // materially different registers is a change of voice that nothing records.
47
+ // • NOT a Gemini slug. If a future reader is about to "restore the fallback for resilience":
48
+ // resilience already exists at the WRITER level (devin → agy → codex, three separate pools). A
49
+ // second model inside the agy path buys nothing and costs the register.
50
+ //
51
+ // ── Guard-and-retry, not guard-and-fail (README D3) ───────────────────────────────────────────
52
+ // Every draft, from any writer, goes through the pure `prose-guard` checks. A rejected draft is
53
+ // not discarded — the findings are handed back as a numbered revision note and the writer tries
54
+ // again. Only after that does a draft reach a human, and it arrives labelled with which writer and
55
+ // which model produced it and whether the guard passed it clean. A flagged report a human can
56
+ // correct beats a missing one.
57
+
58
+ import { readFileSync, existsSync } from 'node:fs';
59
+ import { dirname, join } from 'node:path';
60
+ import { fileURLToPath } from 'node:url';
61
+ import { findingsToRevisionNote, judgeProse } from './prose-guard.mjs';
62
+ import { loadReportingConfig, ReportingConfigError } from './reporting-config.mjs';
63
+ import {
64
+ runAntigravity,
65
+ runDevin,
66
+ tryCodex,
67
+ hasCmd,
68
+ loadPromptBody,
69
+ AGY_ARG_LIMIT,
70
+ } from './cross-agent-cli.mjs';
71
+ import { projectAsset } from './project-root.mjs';
72
+
73
+ const __dirname = dirname(fileURLToPath(import.meta.url));
74
+ const SCRIPTS_DIR = join(__dirname, '..');
75
+ export const LESSONS_PATH = projectAsset('prose-lessons.md'); // grows per project; kit default
76
+ export const PROSE_DIR = join(SCRIPTS_DIR, 'prose');
77
+ export const PERSONA_PATH = projectAsset('prose/cpo-persona.md'); // a TEMPLATE FILL-IN the project owns
78
+
79
+ /** Per-surface task files (README D5). Plain files so both backends read the same words. */
80
+ export const SURFACES = ['standup', 'weekly', 'internal'];
81
+
82
+ // Env-overridable so a diagnosis run can pin a different model deliberately and visibly. That is a
83
+ // different thing from a hardcoded second model nobody notices firing — see the header.
84
+ export const PROSE_MODEL = process.env.PROSE_MODEL || 'gpt-oss-120b-medium';
85
+
86
+ /**
87
+ * The accumulating lessons file, injected into every prompt on BOTH backends.
88
+ *
89
+ * This is the "it improves as we go" mechanism, and it is deliberately a PLAIN MARKDOWN FILE in the
90
+ * repo rather than a model-side tuning artifact or a local CLI rule blob. Three reasons: it is
91
+ * reviewable in a diff, it applies to whichever writer is on duty, and it survives a machine change
92
+ * (D5 — `devin rules` would bind the specialization to one laptop).
93
+ *
94
+ * Not `loadPromptBody`: that one `die()`s on a missing or empty file, which is right for a prompt
95
+ * the run cannot proceed without. An absent lessons file is a legitimate state (a fresh clone, a
96
+ * deliberate reset) and must degrade to "no lessons", never kill a report.
97
+ */
98
+ export function loadLessons(deps = {}) {
99
+ const { read = readFileSync, exists = existsSync, path = LESSONS_PATH } = deps;
100
+ if (!exists(path)) return '';
101
+ const raw = read(path, 'utf8');
102
+ const cut = raw.indexOf('\n---\n');
103
+ return (cut === -1 ? raw : raw.slice(cut + 5)).trim();
104
+ }
105
+
106
+ /** The shared CPO register (D5). Fatal if missing — a report without the persona is not the artifact. */
107
+ export function loadPersona(load = loadPromptBody) {
108
+ return load(PERSONA_PATH);
109
+ }
110
+
111
+ /** The per-surface task block (D5): audience, altitude, length budget, what "done" looks like. */
112
+ export function loadTask(surface, load = loadPromptBody) {
113
+ return load(join(PROSE_DIR, `${surface}.task.md`));
114
+ }
115
+
116
+ /** Assemble the full writer prompt: house style → accumulated lessons → the task and its data. */
117
+ export function buildWriterPrompt({ style, lessons, task }) {
118
+ const parts = [String(style).trim()];
119
+ if (lessons) {
120
+ parts.push(
121
+ '\n\n## Lessons from previous drafts — these are corrections, apply every one\n\n' +
122
+ 'Each line below is a mistake a previous draft actually made and a human had to catch.\n\n' +
123
+ lessons
124
+ );
125
+ }
126
+ parts.push(`\n\n${String(task).trim()}\n`);
127
+ return parts.join('');
128
+ }
129
+
130
+ // ── Writers ─────────────────────────────────────────────────────────────────────────────────
131
+ //
132
+ // SIGNATURE ADAPTATION, and it is the one thing to get right in this file. golden-beans' runners
133
+ // return `{ ok, text, error }`. OUR `runDevin`/`runAntigravity` (cross-agent-cli.mjs) return a
134
+ // STRING, or `null` when passed `{ soft: true }` — and without `soft` they `die()` the process.
135
+ // So: always pass `soft: true`, and treat a FALSY return as failure. Reading a falsy return as
136
+ // success would make every capped, unauthenticated or crashed writer look like a clean draft, and
137
+ // the guard would then be checking an empty string.
138
+
139
+ /**
140
+ * Draft with devin. `runDevin` already rides `--prompt-file`, so there is no argv cap on this path
141
+ * — which is why devin leads on the standup/weekly surfaces that feed in a lot of source material.
142
+ *
143
+ * `retryable: true` unconditionally, and that is a DELIBERATE deviation from golden-beans. Its
144
+ * runner distinguishes "exit 0, empty stdout" (a measured transient, worth one retry) from a hard
145
+ * spawn/exit failure; ours collapses every failure to `null` with the reason printed to stderr and
146
+ * lost to the caller. Given `planWriters` has already confirmed the binary exists, what remains —
147
+ * a quota cap, an auth lapse, a transient empty response — all surface here as exit-0-with-nothing
148
+ * or a non-zero exit, and all are plausibly transient. A genuine outage still fails the second
149
+ * attempt and demotes as before, so this costs one extra spawn on a real break and preserves the
150
+ * measured fix. The alternative (never retry) throws away the better writer for a hiccup.
151
+ */
152
+ export function draftWithDevin(prompt, deps = {}) {
153
+ const { run = runDevin } = deps;
154
+ const text = run(prompt, { soft: true });
155
+ return text
156
+ ? { ok: true, text: String(text).trim(), model: 'devin' }
157
+ : {
158
+ ok: false,
159
+ text: '',
160
+ model: 'devin',
161
+ error: 'devin returned no output (quota cap, auth lapse, or a transient empty response)',
162
+ retryable: true,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Draft with agy on the single prose model.
168
+ *
169
+ * The argv cap is checked HERE rather than left to `runAntigravity`, for one reason: it must be
170
+ * marked non-retryable. A second identical attempt cannot shrink the prompt, so retrying it is pure
171
+ * latency — and worse, it delays the fall-through to devin, which has no cap at all and would have
172
+ * succeeded.
173
+ *
174
+ * No `onModel` callback (golden-beans has one) because there is nothing to report: the model chain
175
+ * is one entry long, so the model that answered is `PROSE_MODEL` by construction. That is a
176
+ * property of the single-model policy above — if anyone re-adds a second model, this attribution
177
+ * silently starts lying, which is the second reason the policy is worth keeping.
178
+ */
179
+ export function draftWithAgy(prompt, deps = {}) {
180
+ const { run = runAntigravity, limit = AGY_ARG_LIMIT } = deps;
181
+ if (Buffer.byteLength(prompt, 'utf8') > limit) {
182
+ return {
183
+ ok: false,
184
+ text: '',
185
+ model: PROSE_MODEL,
186
+ error: `prompt exceeds agy's argv cap (${Math.round(limit / 1024)} KB) — agy takes the prompt in argv, not stdin`,
187
+ retryable: false,
188
+ };
189
+ }
190
+ const text = run(prompt, { models: [PROSE_MODEL], soft: true });
191
+ return text
192
+ ? { ok: true, text: String(text).trim(), model: PROSE_MODEL }
193
+ : {
194
+ ok: false,
195
+ text: '',
196
+ model: PROSE_MODEL,
197
+ error: `agy returned no output for "${PROSE_MODEL}" (likely a quota cap)`,
198
+ retryable: true,
199
+ };
200
+ }
201
+
202
+ // ── The THIRD writer: codex (2026-07) ─────────────────────────────────────────────────────────
203
+ //
204
+ // Added because devin is the flakiest link in the rail, not a hypothetical one: the merge-report log
205
+ // shows `Permission denied: We're currently facing high demand for this model` on 6 of 25 runs.
206
+ // devin → agy already handled that correctly (the log has agy carrying a report devin refused), but
207
+ // two writers means one bad day away from no report at all — and agy shares its quota with the
208
+ // cross-family review layer, which has already gone fully dark mid-epic when codex and agy were
209
+ // capped in the same session.
210
+ //
211
+ // codex is the natural third seat: it is already installed, already wrapped in cross-agent-cli, and
212
+ // carries a THIRD independent quota pool. It sits LAST because it is the primary code REVIEWER —
213
+ // the review rail's quota is the scarce resource, and prose should only reach for it when both
214
+ // dedicated writers have failed.
215
+ //
216
+ // ── Why the prompt goes on STDIN ──────────────────────────────────────────────────────────────
217
+ // `execCodex(prompt, stdin)` puts `prompt` in ARGV, which is the exact cap that already bites agy
218
+ // (`draftWithAgy` refuses oversized prompts non-retryably for this reason). A prose prompt carries
219
+ // the persona, the accumulated lessons and a full evidence pack, so it is precisely the kind of
220
+ // payload that overflows argv. codex is designed to take its context on stdin, so the body goes
221
+ // there and argv carries only a short constant directive — no cap to hit, and nothing to guard.
222
+
223
+ /** The argv half of the codex call: a fixed, tiny directive. The real prompt rides stdin (see above),
224
+ * so this string must never grow to include the material — that would reintroduce the argv cap. */
225
+ export const CODEX_DIRECTIVE =
226
+ 'Follow the instructions provided on stdin exactly. Output only the finished text — no preamble, no commentary, no code fences.';
227
+
228
+ /**
229
+ * Draft with codex. Thin adapter over `tryCodex`, which is already the structured, never-dies
230
+ * variant — it separates an AUTH lapse and a STALE CLI from an ordinary failure, and that
231
+ * distinction is the whole reason to use it here rather than `runCodex`.
232
+ *
233
+ * **Auth lapse and stale CLI are NON-retryable.** Both mean codex genuinely cannot run on this
234
+ * machine, and a second identical attempt cannot log it in or upgrade it — retrying is pure latency
235
+ * on the last writer in the chain, which is the worst place to spend it. Everything else (a cap, a
236
+ * transient empty response) gets the one retry the rail already gives. Same reasoning as
237
+ * `draftWithAgy`'s oversized-prompt path: retry the recoverable, demote the rest.
238
+ */
239
+ export function draftWithCodex(prompt, deps = {}) {
240
+ const { run = tryCodex, directive = CODEX_DIRECTIVE } = deps;
241
+ const r = run(directive, String(prompt));
242
+
243
+ // TRIM BEFORE testing for emptiness — a whitespace-only stdout is truthy, so checking `r.text`
244
+ // raw would return `ok: true` with an empty draft and hand the guard "" to approve.
245
+ const text = String(r.text ?? '').trim();
246
+ if (r.ok && text) return { ok: true, text, model: 'codex' };
247
+
248
+ // Exit 0 with empty stdout is a real, observed codex failure mode (the same one devin has), so
249
+ // `ok` alone is not enough — an empty draft must never read as success or the guard checks "".
250
+ if (r.authFailed) {
251
+ return { ok: false, text: '', model: 'codex', error: 'codex auth has lapsed — run `codex login`', retryable: false };
252
+ }
253
+ if (r.cliOutdated) {
254
+ return { ok: false, text: '', model: 'codex', error: 'codex CLI is too old for its model — run `node scripts/cross-agent-doctor.mjs codex`', retryable: false };
255
+ }
256
+ return {
257
+ ok: false,
258
+ text: '',
259
+ model: 'codex',
260
+ error: `codex returned no output (quota cap or a transient empty response)${r.stderr ? `: ${String(r.stderr).trim().split('\n').pop()}` : ''}`,
261
+ retryable: true,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Pure routing decision — which writers to try, in order.
267
+ *
268
+ * Separated from the I/O so the policy is pinned by a test rather than by reading the orchestration
269
+ * below. `preferred` lets a caller force one writer (useful when diagnosing which one produced a
270
+ * bad draft) without editing the rail.
271
+ */
272
+ export function planWriters({ devinAvailable, agyAvailable, codexAvailable, preferred }) {
273
+ // ORDER IS THE POLICY. Devin first — see the router note at the top of this file. Changing this
274
+ // array changes who writes every report in the repo, so it is the one line to read.
275
+ //
276
+ // codex sits LAST on purpose: it is the primary code REVIEWER, and the review layer's quota is
277
+ // the scarce resource here (codex and agy have already gone dark together mid-epic). Prose only
278
+ // reaches for it once both dedicated writers have failed — which the log says does happen, so a
279
+ // third independent pool is the difference between a late report and no report.
280
+ const all = [
281
+ { name: 'devin', available: devinAvailable },
282
+ { name: 'agy', available: agyAvailable },
283
+ { name: 'codex', available: codexAvailable },
284
+ ];
285
+ if (preferred) {
286
+ const chosen = all.find((w) => w.name === preferred);
287
+ return chosen?.available ? [preferred] : [];
288
+ }
289
+ return all.filter((w) => w.available).map((w) => w.name);
290
+ }
291
+
292
+ /**
293
+ * Write prose: try each available writer, guard every draft, retry once with the findings.
294
+ *
295
+ * Returns `{ ok, text, writer, model, guard, attempts }`. `guard.ok === false` on a returned draft
296
+ * means both passes still tripped a rule — the draft is handed back anyway, WITH its findings,
297
+ * because a flawed draft a human can see and fix beats a hard failure that produces nothing (D3).
298
+ * The caller is responsible for SURFACING `guard` rather than quietly publishing the text.
299
+ */
300
+ // The project's own stack names for the prose guard (reporting.config.json → prose.extraBannedToolNames),
301
+ // applied HERE so every caller of writeProse gets them — not just the ones that remember to pass them.
302
+ // Found in review of the origin project's migration: the names moved from the guard's built-in list to
303
+ // config, only the standup and weekly recap passed them back in, and the merge report (which posts on
304
+ // every merge) silently stopped blocking "Clerk"/"Cloud Run". A project with no reporting config has no
305
+ // extra names — that is "none configured", not an error, since prose-draft runs without one.
306
+ export function projectBannedToolNames({ load = loadReportingConfig } = {}) {
307
+ try {
308
+ return load().prose.extraBannedToolNames;
309
+ } catch (e) {
310
+ if (e instanceof ReportingConfigError) return [];
311
+ throw e;
312
+ }
313
+ }
314
+
315
+ // async since jev-semantic-guards S3.2: the default guard is `judgeProse`, which may ask Jev. An injected
316
+ // `guard` may still be synchronous — it is awaited either way.
317
+ export async function writeProse({ prompt, evidence, preferred }, deps = {}) {
318
+ const {
319
+ devin = draftWithDevin,
320
+ agy = draftWithAgy,
321
+ codex = draftWithCodex,
322
+ has = hasCmd,
323
+ guard = judgeProse,
324
+ warn = (m) => process.stderr.write(`${m}\n`),
325
+ extraBannedToolNames = projectBannedToolNames,
326
+ } = deps;
327
+ // A caller-supplied list wins; otherwise the project's configured one. Resolved once per write.
328
+ evidence = { ...evidence, extraBannedToolNames: evidence?.extraBannedToolNames ?? extraBannedToolNames() };
329
+
330
+ const writers = planWriters({
331
+ devinAvailable: has('devin'),
332
+ agyAvailable: has('agy'),
333
+ codexAvailable: has('codex'),
334
+ preferred,
335
+ });
336
+ if (writers.length === 0) {
337
+ return {
338
+ ok: false,
339
+ text: '',
340
+ writer: null,
341
+ model: null,
342
+ guard: null,
343
+ attempts: 0,
344
+ error: 'no prose writer available',
345
+ };
346
+ }
347
+
348
+ const runners = { devin, agy, codex };
349
+ let attempts = 0;
350
+ let best = null;
351
+
352
+ for (const name of writers) {
353
+ let currentPrompt = prompt;
354
+
355
+ // Two passes per writer: the draft, then one revision against the guard's findings.
356
+ for (let pass = 0; pass < 2; pass++) {
357
+ attempts++;
358
+ let result = runners[name](currentPrompt);
359
+
360
+ // ── One retry on a RETRYABLE failure before demoting the writer ──────────────────────────
361
+ // Observed live at golden-beans (2026-07-25): devin returned exit 0 with empty stdout on a
362
+ // 9 KB prompt, and the identical prompt succeeded moments later — a transient, not a broken
363
+ // CLI. Falling straight through to the fallback on that costs the better writer for no
364
+ // reason. A genuine outage still fails on the second attempt and demotes as before, so this
365
+ // buys resilience without hiding a real break. An oversized prompt is NOT retryable: a second
366
+ // identical attempt cannot shrink it.
367
+ if (!result.ok && result.retryable) {
368
+ warn(`⚠ ${name}: ${result.error} — retrying once before falling back.`);
369
+ attempts++;
370
+ result = runners[name](currentPrompt);
371
+ }
372
+
373
+ if (!result.ok) {
374
+ warn(`⚠ ${name} failed: ${result.error} — trying the next writer.`);
375
+ break; // a broken writer will not be fixed by a revision note
376
+ }
377
+
378
+ const verdict = await guard(result.text, evidence);
379
+ if (verdict.ok)
380
+ return {
381
+ ok: true,
382
+ text: result.text,
383
+ writer: name,
384
+ model: result.model ?? name,
385
+ guard: verdict,
386
+ attempts,
387
+ };
388
+
389
+ // Keep the LATEST draft as the fallback, not the first. golden-beans' cross-review caught the
390
+ // original `if (!best)` here: it pinned `best` to the pass-0 draft, so when the revision pass
391
+ // also tripped a rule we returned the UNREVISED text and threw away the one written
392
+ // specifically to address the findings. The revision is strictly better informed even when it
393
+ // is still imperfect, and returning the older draft would make the retry pass pure cost.
394
+ best = { text: result.text, writer: name, model: result.model ?? name, guard: verdict };
395
+ warn(`⚠ ${name} draft rejected (${verdict.findings.map((f) => f.code).join(', ')}) — revising.`);
396
+ currentPrompt = `${prompt}\n\n---\n\n## Your previous draft\n\n${result.text}\n\n---\n\n${findingsToRevisionNote(verdict.findings)}`;
397
+ }
398
+ }
399
+
400
+ if (best) return { ok: false, ...best, attempts };
401
+ return {
402
+ ok: false,
403
+ text: '',
404
+ writer: null,
405
+ model: null,
406
+ guard: null,
407
+ attempts,
408
+ error: 'every writer failed',
409
+ };
410
+ }