pgroast 0.0.0-stage → 0.1.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 (67) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +173 -2
  3. package/bin/pgroast.mjs +19 -0
  4. package/data/precedents.sql.gz +0 -0
  5. package/docs/corpus.md +352 -0
  6. package/docs/design.md +540 -0
  7. package/docs/eval.md +245 -0
  8. package/docs/haas-paranoia-notes.md +223 -0
  9. package/invariants/README.md +15 -0
  10. package/invariants/nbtree.md +14 -0
  11. package/package.json +51 -4
  12. package/prompts/brief.md +32 -0
  13. package/prompts/committer-paranoia.md +149 -0
  14. package/prompts/critic.md +56 -0
  15. package/prompts/enrich.md +35 -0
  16. package/prompts/pushback-map.md +58 -0
  17. package/prompts/pushback-reduce.md +72 -0
  18. package/prompts/reflect.md +20 -0
  19. package/prompts/relevance.md +20 -0
  20. package/prompts/roaster-already-exists.md +23 -0
  21. package/prompts/roaster-common.md +88 -0
  22. package/prompts/roaster-demand.md +28 -0
  23. package/prompts/roaster-maintenance-fit.md +22 -0
  24. package/prompts/roaster-prior-attempts.md +29 -0
  25. package/prompts/roaster-replication-ondisk.md +24 -0
  26. package/prompts/roaster-security.md +21 -0
  27. package/prompts/roaster-standards-compat.md +21 -0
  28. package/prompts/supervisor.md +31 -0
  29. package/prompts/synth-self.md +19 -0
  30. package/prompts/synth.md +140 -0
  31. package/prompts/tone.md +21 -0
  32. package/prompts/writing.md +98 -0
  33. package/sql/schema.sql +45 -0
  34. package/src/cli.ts +391 -0
  35. package/src/corpus/cf.ts +220 -0
  36. package/src/corpus/cli.ts +189 -0
  37. package/src/corpus/db.ts +119 -0
  38. package/src/corpus/fetch.ts +104 -0
  39. package/src/corpus/reverts.ts +133 -0
  40. package/src/corpus/search.ts +201 -0
  41. package/src/corpus/util.ts +133 -0
  42. package/src/corpus/wiki.ts +80 -0
  43. package/src/eval/cases.ts +118 -0
  44. package/src/eval/cli.ts +105 -0
  45. package/src/eval/judge.ts +136 -0
  46. package/src/eval/report.ts +268 -0
  47. package/src/eval/run.ts +153 -0
  48. package/src/eval/score.ts +275 -0
  49. package/src/roast/doctor.ts +109 -0
  50. package/src/roast/enrich.ts +335 -0
  51. package/src/roast/invariants.ts +65 -0
  52. package/src/roast/llm.ts +601 -0
  53. package/src/roast/material.ts +133 -0
  54. package/src/roast/page.ts +266 -0
  55. package/src/roast/pipeline.ts +981 -0
  56. package/src/roast/precedents.ts +308 -0
  57. package/src/roast/pushback.ts +664 -0
  58. package/src/roast/render.ts +386 -0
  59. package/src/roast/sanitize.ts +140 -0
  60. package/src/roast/schema.ts +714 -0
  61. package/src/roast/tone.ts +216 -0
  62. package/src/thread/fetch.ts +172 -0
  63. package/src/thread/load.ts +227 -0
  64. package/src/thread/local.ts +73 -0
  65. package/src/thread/parse.ts +243 -0
  66. package/src/thread/patchprose.ts +143 -0
  67. package/src/thread/types.ts +120 -0
package/docs/design.md ADDED
@@ -0,0 +1,540 @@
1
+ # PG Roast: design of the roast pipeline
2
+
3
+ PG Roast judges an idea, not code. Input: one CommitFest entry, one pgsql-hackers thread, or
4
+ (self mode) a draft email on disk. Output: local files, `roast.json`, `roast.md` and
5
+ `roast-summary.txt`. It never posts anything anywhere.
6
+
7
+ This document covers the thread input layer (`src/thread`), the roast pipeline (`src/roast`)
8
+ and the CLI (`src/cli.ts`). The precedent corpus is described in `docs/corpus.md`; the review
9
+ rubric is `prompts/committer-paranoia.md` (notes: `docs/haas-paranoia-notes.md`).
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ bun run src/cli.ts <cf-id|cf-url|message-id|thread-url> [--blind N] [--before DATE] [--hint TEXT] [--out DIR] [--backend claude|codex] [--rounds N] [--self] [--no-enrich] [--dry-run] [--offline]
15
+ bun run src/cli.ts --self --proposal FILE [--patch FILE ...]
16
+ bun run src/cli.ts page <roast.json> [--out FILE] [--site DIR [--base-url URL]]
17
+ bun run src/cli.ts doctor
18
+ ```
19
+
20
+ | Option | Meaning |
21
+ |---|---|
22
+ | `--blind N` | read only the first N messages of the thread |
23
+ | `--before YYYY-MM-DD` | read only messages strictly before the date (UTC); also the precedent cutoff |
24
+ | `--hint TEXT` | directed precedent lookup ("the 2018 suggestion of doing X") |
25
+ | `--out DIR` | output directory; default `out/<slug>` (`cf-7608`, `cf-5253-blind5`, `msg-<id>`) |
26
+ | `--backend claude\|codex` | which installed CLI runs the model; default `PGROAST_BACKEND`, else claude if on PATH, else codex |
27
+ | `--self` | your own proposal before you send it; see "Self mode" |
28
+ | `--proposal FILE`, `--patch FILE` | with `--self`: a draft email on disk and, optionally, patch files (repeatable) |
29
+ | `--rounds N` | extra rounds after the first pass, default 0; see "Rounds" |
30
+ | `--no-enrich` | skip the reading of precedent threads and the relevance pass |
31
+ | `--dry-run` | build and print every prompt, call no model; outputs hold placeholders |
32
+ | `--offline` | use only cached pages |
33
+
34
+ `doctor` reports, for each CLI: whether it is on PATH, its version, and whether a trivial
35
+ completion works; when one fails it says to run `claude` (and sign in) or `codex login`.
36
+
37
+ Environment: `PGROAST_BACKEND`; `PGROAST_ROASTER_MODEL` (default `opus`; also the brief) and
38
+ `PGROAST_CRITIC_MODEL` (default `claude-mythos-5-2`; also the synthesis) for the claude backend;
39
+ `PGROAST_CODEX_MODEL` for the codex backend (default: the model configured in the Codex CLI);
40
+ `PGROAST_CONCURRENCY` (3), `PGROAST_LLM_TIMEOUT_MS` (900000), `PGROAST_CLAUDE_BIN`,
41
+ `PGROAST_CODEX_BIN`, `PGROAST_DSN` (corpus DB).
42
+
43
+ On the claude backend only Opus, Mythos and Fable models are accepted (Fable 5.2 is the same underlying model as Mythos 5.2); anything else is an error before
44
+ any call is made. No API key is read, needed or used (see "LLM backend").
45
+
46
+ Output directory:
47
+
48
+ ```
49
+ out/<slug>/roast.json the result (type Roast, src/roast/schema.ts)
50
+ out/<slug>/roast.md the short report, rendered from roast.json by code
51
+ out/<slug>/roast-full.md everything: proposal restated, all touch points, refuted objections, run details
52
+ out/<slug>/roast-summary.txt at most 10 plain-text lines, built by code; first line
53
+ "DRAFT — rewrite in your own words before using; AI-generated"
54
+ out/<slug>/llm/NN-<stage>.prompt.txt | .response.txt | .meta.json every prompt and reply
55
+ ```
56
+
57
+ ## Pipeline
58
+
59
+ ```
60
+ input ──> thread loader ──> brief (LLM) ──> retrieval (code) ──> 7 roasters (LLM, parallel)
61
+ │
62
+ roast.md <── render (code) <── assembly (code) <── synthesis (LLM) <── critic (LLM)
63
+ ```
64
+
65
+ 10 LLM calls for the core of one roast (11 when the tone lint finds something) (9 when no roaster raises anything: the critic is
66
+ skipped), plus at most one repair call per stage when a reply is not valid JSON. Precedent
67
+ enrichment adds up to 8 calls the first time a thread is seen (0 from cache) and 1 relevance
68
+ call. `--rounds` adds more (see "Rounds").
69
+
70
+ Roast output of a live proposal stays local: `out/` is ignored by git and is never committed.
71
+
72
+ ### 0. Thread loader (`src/thread`)
73
+
74
+ - `parseInputRef`: a number or a `commitfest.postgresql.org/patch/<id>/` URL is a CommitFest
75
+ entry; a `postgresql.org/message-id/...` or `postgr.es/m/...` URL, or a bare message-id, is a
76
+ thread.
77
+ - CommitFest page → title, status per CommitFest, authors, reviewers, committer, tags, thread
78
+ links, history. When the entry has several threads, the one that started first is read and
79
+ the others are listed in a warning.
80
+ - `https://www.postgresql.org/message-id/flat/<msgid>` → messages: index, message-id, permalink,
81
+ from, date (ISO, UTC), subject, body text, attachments (name, URL, type, size).
82
+ - Attachments: only those of the first visible message that has a patch and of the thread
83
+ starter's latest visible patch set; at most 8 files, 1.5 MiB each; text files only.
84
+ `extractPatchProse` keeps the commit message of each `git format-patch` mail, the list of
85
+ touched paths, and diff hunks for `doc/`, `README*`, `*.md`, `*.sgml`, `*.rst`, `*.txt`.
86
+ Code hunks are dropped in code, before any prompt is built. There is no line-level code review.
87
+ - `--blind N` and `--before DATE` filter the messages (both may apply). Attachments are taken
88
+ from visible messages only. The CommitFest entry is redacted in these modes: status,
89
+ reviewers, committer, target version and links are removed, and the history is removed
90
+ (`--blind`) or cut at the date (`--before`).
91
+ - Fetching (`fetch.ts`): GET only, no credentials, hosts limited to `www.postgresql.org`,
92
+ `postgr.es`, `commitfest.postgresql.org`; User-Agent `pgroast/0.1`; requests to one host are
93
+ serialised with at least 1.1 s between them; 429 / 5xx / network errors are retried with
94
+ exponential backoff (5 s, 10 s, ... up to 5 min; `Retry-After` is honoured); every page is
95
+ cached under `data/cache/threads/<host>/` and a cached page is never fetched again (404s are
96
+ cached too). To refresh a live thread, delete its cache file.
97
+
98
+ ### a. Brief (1 call, roaster model)
99
+
100
+ Neutral restatement: problem, proposed design, user-visible surface, author's claims, thread
101
+ state, what is unclear, and 5 to 8 keyword queries for the precedent search. Prompt: `prompts/brief.md`.
102
+
103
+ ### b. Retrieval (code, `src/roast/precedents.ts`)
104
+
105
+ The queries go to the corpus through a thin adapter:
106
+
107
+ 1. `src/corpus/search.ts` → `searchPrecedents({query, before, limit, includeUndated})` (Postgres
108
+ database `pgroast`, `PGROAST_DSN`);
109
+ 2. when the module or the database is not there: no precedents, and the roast says "the corpus
110
+ was NOT searched", which is not "no precedent found".
111
+
112
+ Per query the top 8 rows are taken (16 for the hint). Rows are merged by reciprocal rank
113
+ (`1/(10+rank)`, summed over queries) times a recency factor (1.5 for a decision at the cutoff,
114
+ falling to 1.0 at 15 years: "don't dig too deep into history" by default). `--hint` is run as an
115
+ extra query; its rows get weight 3, doubled again when their year is a year named in the hint.
116
+ The top 15 become `P1..P15`.
117
+
118
+ Cutoff: `before` = the `--before` date, or else the date of the thread's first message. It is
119
+ passed to the backend and checked again in the adapter: a row with any date on or after the
120
+ cutoff is dropped. Undated rows (wiki "not wanted" items) are used only in a normal run, never
121
+ with `--blind` / `--before`. The proposal's own CommitFest entry and thread are removed.
122
+
123
+ ### b2. Lazy enrichment (`src/roast/enrich.ts`, up to 8 calls, roaster model)
124
+
125
+ Most CommitFest rows record an outcome and no reason. So, at roast time, the top candidates
126
+ (the pool is widened to 24) that have a thread and no recorded reason are read: at most 8 per
127
+ run, one call per thread, prompt `prompts/enrich.md`. The model returns a summary of the
128
+ proposal, why it was not accepted, who objected, one key sentence and the number of the
129
+ message it is in. Code then checks the record:
130
+
131
+ - the key sentence must occur verbatim in the named message's own text (quoted lines are
132
+ excluded; no ellipsis); else it is dropped. The message link is built by code;
133
+ - objector names must be senders in that thread; others are dropped.
134
+
135
+ Long threads: the first 3 messages, the last 8 and the messages of the people who closed the
136
+ entry, within 60 000 characters; gaps are marked.
137
+
138
+ The record is stored once in table `precedent_enrichment` (same database; key: thread URL and
139
+ decision date) and reused by every later roast. Cutoff rules: only messages dated on or before
140
+ the precedent's decision date are read, so a record holds nothing newer than what the retrieval
141
+ cutoff already admits; a precedent decided after the cutoff never reaches this step; undated
142
+ rows are not enriched with `--blind` / `--before`; the proposal's own thread is never read.
143
+
144
+ A reason that comes from enrichment is labelled "AI summary of its thread" in the prompts and
145
+ in `roast.md`, and is never accepted as the source of a quote: only the verified key sentence is.
146
+
147
+ ### b3. Relevance pass (1 call, roaster model)
148
+
149
+ Keyword search returns much noise. One call (`prompts/relevance.md`) gets the brief and the
150
+ candidate list and returns the ids of the candidates that are about the same or a similar idea.
151
+ Only those (at most 15) are shown to the roasters; the rest stay in `precedents_retrieved`
152
+ with `relevant: false`. If the call fails, the roasters get the top candidates unfiltered and
153
+ a warning says so.
154
+
155
+ ### c. Roasters (7 calls, roaster model, in parallel, concurrency 3)
156
+
157
+ One angle each: `prior-attempts`, `standards-compat`, `replication-ondisk`, `security`,
158
+ `maintenance-fit`, `already-exists`, `demand`. System prompt = `prompts/roaster-common.md` +
159
+ the rubric file read from disk, verbatim + `prompts/roaster-<angle>.md` + the output contract.
160
+ The user prompt is the same for all seven: review date, brief, material, precedent list.
161
+
162
+ Each returns at most 4 objections (zero is valid and said so): title, pyramid level (`1a`, `1b`,
163
+ `2`), rubric item, category, touch points, severity (`fatal` | `fixable`), failure scenario,
164
+ argument, precedent ids, thread message numbers, what the author must show. The prior-attempts
165
+ roaster also returns candidate prior attempts. A roaster that fails is reported in `warnings`
166
+ and `roaster_notes`; the run goes on unless all seven fail.
167
+
168
+ ### d. Critic (1 call, critic model)
169
+
170
+ Sees the same material plus all objections, and tries to refute each: wrong facts, precedent
171
+ mismatch, outdated reason, already handled, not specific, no failure scenario. One verdict per
172
+ objection: `survived` | `refuted`, with reasoning; it may change the severity of a survivor. An
173
+ objection the critic forgot stays `survived` and is flagged as unchecked.
174
+
175
+ ### e. Synthesis (1 call, synthesis model) and assembly (code)
176
+
177
+ The model picks the failing level, ranks surviving objections, picks the verdict and confidence,
178
+ marks the 15 touch points, writes at most 5 questions, the final prior-attempt list, the
179
+ committability object and the "not now" flag. Then `assemble()` enforces, in code:
180
+
181
+ - Only surviving objections can be ranked; at most 5.
182
+ - Base-up stop rule (the rubric's operating rule): with a failing level L, objections above L
183
+ are moved out of the list to `other_surviving` with the reason "not evaluated", and `levels`
184
+ reports each level as `passed`, `failed` or `not_evaluated`. A ranked fatal objection forces a
185
+ failing level if the model named none.
186
+ - Verdict guards: `LIKELY-REJECT` needs a surviving fatal objection in the list or a `same_idea`
187
+ prior attempt, else it becomes `NEEDS-DESIGN-DISCUSSION`; `NO-FUNDAMENTAL-OBJECTION` cannot
188
+ stand next to a listed fatal objection. A listed objection at level 1a or 1b makes the verdict
189
+ at least `NEEDS-DESIGN-DISCUSSION` (added 2026-11-11 after eval run `pilot-2`). Each change is
190
+ recorded in `warnings`.
191
+ - Touch points: all 15, once each, in the issue's order; a missing one is "not hit" plus a
192
+ warning; a listed objection about a touch point forces `hit`.
193
+ - Prior attempts: link, thread links, dates, status and "who objected" are copied from the
194
+ corpus row. The model supplies only the row id, the relation, "what is different" and a quote.
195
+ - The disclosure line is a constant in `schema.ts`, written by code.
196
+
197
+ Verdict, committability and "not now" are three separate things (rubric, last section):
198
+
199
+ - `verdict` is one of the issue's four values and covers the merits of the idea and design only
200
+ (levels 1a, 1b, 2).
201
+ - `committability` = `{owner, owner_note, liability, author_follow_up}`, with `owner` one of
202
+ `qualified committer engaged` | `silent` | `none obvious`. It is filled for every verdict. No
203
+ code path lets it change the verdict, and the verdict guard above means a weak
204
+ committability cannot produce `LIKELY-REJECT`: that needs a surviving fatal objection or a
205
+ same-idea prior attempt.
206
+ - `not_now` = `{applies, evidence_that_would_change_it}`: "not now, show demand", used when only
207
+ the demand / accumulation items fail. It is not a fifth verdict value.
208
+
209
+ `roast.md` is rendered from `roast.json` by `render.ts`. There is no second model call, so the
210
+ two files cannot disagree. Refuted objections stay in the JSON (`refuted`) and are listed at
211
+ the end of the Markdown with the critic's reason.
212
+
213
+ `priority` (in the synthesis reply) answers "is it worth the reviewers' time now?", apart from
214
+ the verdict: `audience` (`most users` | `a sizeable minority` | `niche` | `unclear`),
215
+ `maintenance_burden` and `review_cost` (`low` | `medium` | `high`; review cost from the patch
216
+ size and the subsystems in the touched-file list), `recommended_action` (`proceed` | `park` |
217
+ `bundle` | `needs champion` | `needs design rethink` | `withdraw and resubmit` |
218
+ `fix design first` | `close: dead or superseded`) and `reason`. For long-lived entries the
219
+ prompt separates "dead or superseded" from "stuck": age alone is never a reason to recommend
220
+ closing, and a recommendation to close must name a concrete reason (and what superseded it,
221
+ only when that is in the thread or the corpus; else "unknown"). It is
222
+ copied to the output as is. No verdict rule reads it.
223
+
224
+ `roast-summary.txt` is built by `renderSummary()` from the same JSON: the draft header, title,
225
+ verdict, the top three objections, the first prior attempt, the first question, the priority
226
+ line and "not now" when it applies; never more than 10 lines, no markup, no model call.
227
+
228
+ ## Tone (`src/roast/tone.ts`)
229
+
230
+ "Roast" is an internal name. Everything a person may forward reads as a design pre-review:
231
+ calm, respectful, evidence-first. Three layers:
232
+
233
+ 1. **Prompts.** `roaster-common.md`, `critic.md` and `synth.md` carry the same tone section: the
234
+ rubric's persona is a way of thinking, not a voice; no snark, sarcasm, hyperbole, drama,
235
+ exclamation marks or intensifiers; write about the proposal, never about the author. Each
236
+ objection ends in one question the author can answer (`author_must_show`).
237
+ 2. **Labels.** The document title is "Design pre-review: <title>"; section 3 is "Objections a
238
+ reviewer is likely to raise"; the critic's part is shown as "Counter-check". The disclosure
239
+ line stays verbatim.
240
+ 3. **Lint in code**, before any file is written. `TONE_RULES` is a word and phrase list in five
241
+ groups (intensifiers, hyperbole, sarcasm markers, internal vocabulary such as "roast" and
242
+ "beat down", remarks about the author such as "you failed to") plus exclamation marks (not
243
+ `!=`, not inside a code span). It runs over every prose field the models wrote
244
+ (`proseFields()`): objections of every status, questions, verdict reasoning, brief,
245
+ committability, priority, self advice, notes. A listed objection's `author_must_show` must
246
+ also end with a question mark. Verbatim archive quotes are not checked: they are not ours
247
+ to rewrite.
248
+
249
+ On a hit, one call (`prompts/tone.md`, synthesis model, label `tone-repair`) gets only the
250
+ flagged sentences and returns one rewrite per sentence; code replaces exactly those sentences,
251
+ after the usual link scrub. The lint runs again; what is still flagged is listed in
252
+ `tone_lint.remaining` (path, rule, match, sentence), in the run section of `roast.md`, and in
253
+ `warnings`. `pgroast page` runs the same lint on the JSON it is given and shows the count.
254
+
255
+ The list is a filter for the common cases, not a judgment of tone: a rude sentence made of
256
+ polite words passes, and a harmless use of a listed word ("the simply-named option") is
257
+ rewritten or reported.
258
+
259
+ ## Page and site layout (`pgroast page`, `src/roast/page.ts`)
260
+
261
+ `pgroast page <roast.json> [--out FILE]` renders the opt-in "full version": one self-contained
262
+ HTML file (inline CSS, no scripts, no external requests, light and dark by
263
+ `prefers-color-scheme`, readable on a phone) with every objection, including those that held
264
+ up but are not listed, those refuted in the counter-check (with the reasoning) and those
265
+ withdrawn, the evidence and archive links, touch points, questions, committability, priority,
266
+ loaded invariants and the run section. The disclosure line is at the top and at the bottom.
267
+
268
+ `--site DIR` writes the stable layout that a CommitFest app could include from:
269
+
270
+ ```
271
+ DIR/cf/<cf-id>.html embeddable fragment: a card, no <html>/<head>, classes with a
272
+ pgroast- prefix, inline styles only, no scripts: verdict, top three
273
+ questions, priority, disclosure, link to the full page
274
+ DIR/cf/<cf-id>.json the roast.json
275
+ DIR/cf/<cf-id>/full.html the full page
276
+ DIR/msg/<message-id>.* the same for a thread without a CommitFest entry (characters
277
+ outside [A-Za-z0-9._-] become "_")
278
+ ```
279
+
280
+ The card's link is `{{PGROAST_BASE_URL}}/cf/<cf-id>/full.html` until `--base-url URL` fills it
281
+ in. No index is written: an entry without a review is absent, which is a 404 on any static
282
+ host. The command makes no model call and no network request, and there is no upload, hosting
283
+ or publishing code: where the files go is a person's decision.
284
+
285
+ Everything in roast.json is untrusted in an HTML context (model output, archive text, titles,
286
+ links). Every value passes `esc()` (`& < > " ' \``), and a link is emitted only when
287
+ `safeUrl()` accepts it as a plain http(s) URL; any other value is shown as text. A test feeds a
288
+ roast in which every text and URL field is hostile and checks that the output contains only
289
+ tags from a fixed list and no href other than https.
290
+
291
+ ## Rounds (`--rounds N`, default 0)
292
+
293
+ "Go deeper" passes after the first pass. Every extra round is followed by one critic call on
294
+ what the round added or changed, and is recorded in `rounds[]` (calls, new objections, how many
295
+ of them survived, revisions, withdrawals), so the gain of each round can be measured.
296
+
297
+ | Round | What happens |
298
+ |---|---|
299
+ | 1 | each roaster gets its own earlier answer and exactly the question "What did you forget?". It can only add objections. |
300
+ | 2 | each roaster gets its answers so far with the critic's verdicts and exactly "Are you happy with the results?". It can add, restate (`revised_in_round`) or withdraw its own objections. A withdrawn objection is kept in `withdrawn[]` with the round and the reason; it is not shown to the synthesis. |
301
+ | 3 and later | a supervisor call (`prompts/supervisor.md`, critic model) reads the brief, all objections and the roasters' notes and names up to 3 areas nobody examined, each with the roaster to send back. Those roasters run again on that area only. An empty list ends the rounds. |
302
+
303
+ Both questions are constants (`FORGOT_PROMPT`, `HAPPY_PROMPT`). An objection carries the `round`
304
+ in which it was raised; ids of later rounds look like `demand-r1-1`. A roaster can revise or
305
+ withdraw only its own objections. Later rounds run under the same rules as the first: no tools,
306
+ same sanitizer, same evidence rules, and "nothing to add" is a valid answer. Cost: rounds 1 and
307
+ 2 are 8 calls each (7 roasters and the critic); a supervisor round is 2 to 5.
308
+
309
+ ## Subsystem invariants (`src/roast/invariants.ts`)
310
+
311
+ `invariants/<subsystem>.md` files hold rules harvested from senior reviewers, each with a
312
+ source link. A file is selected when one of its `paths:` prefixes (YAML front matter) matches a
313
+ file in the patch's touched-file list; without a patch nothing is selected. Selected files are
314
+ included verbatim, under the heading "Subsystem invariants to check explicitly", in the system
315
+ prompts of the `replication-ondisk` and `maintenance-fit` roasters and of the critic. The Source
316
+ links are read from the file by code and added to the allowed-URL set, so a roaster can cite
317
+ them; any other link is still removed. `invariants_loaded` lists the files in `roast.json`, and
318
+ `roast.md` names them in its header.
319
+
320
+ ## Self mode (`--self`)
321
+
322
+ The author's own check before sending. The pipeline, the evidence rules and the verdict rules
323
+ are the same; only the framing changes:
324
+
325
+ - the synthesis prompt gets `prompts/synth-self.md`: it addresses the author, and returns
326
+ `self_advice[]`: for every surviving objection `fix` (change the proposal) or `rebut` (answer
327
+ it in the cover letter) and how. Advice for an id that is not a surviving objection is dropped;
328
+ - `roast.md` leads with "Objections to address before you send", shows the advice and the
329
+ precedent threads to cite with each objection, and moves the verdict down ("How a reviewer
330
+ would likely classify it"). The verdict stays in `roast.json`;
331
+ - `--self --proposal FILE [--patch FILE ...]` (`src/thread/local.ts`) roasts a draft that has
332
+ no thread yet: the file is the single message; from a patch file only the commit message,
333
+ file list and doc hunks are read, as for attachments; a file without a diff is not read.
334
+
335
+ ## LLM backend (`src/roast/llm.ts`)
336
+
337
+ One function: `backend.complete({system, prompt, model, schema, label}) → parsed JSON`.
338
+
339
+ PG Roast runs on an installed Claude Code or Codex CLI, on the user's subscription login. It
340
+ never uses API tokens: no API key variable is read anywhere, and `ANTHROPIC_API_KEY`,
341
+ `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` and `CODEX_API_KEY` are removed from the environment of
342
+ every spawned CLI (`childEnv`). `CliBackend` runs the CLI once per call, in an empty temporary
343
+ directory, with the prompt on stdin.
344
+
345
+ ### claude backend
346
+
347
+ ```
348
+ claude -p --model <m> --system-prompt <system> --tools "" --strict-mcp-config --safe-mode \
349
+ --disable-slash-commands --no-session-persistence --output-format stream-json --verbose
350
+ ```
351
+
352
+ - `--tools ""` removes all built-in tools; `--strict-mcp-config` with no config removes all MCP
353
+ servers; `--safe-mode` removes CLAUDE.md, skills, plugins, hooks and custom agents.
354
+ - This is verified on every call, not assumed: the `init` event of the stream lists the
355
+ session's tools and MCP servers, and the call fails if either list is not empty, if any
356
+ assistant message holds a tool call, if the session reports an API key as its credential
357
+ source, or if the answering model is not Opus or Mythos.
358
+
359
+ ### codex backend
360
+
361
+ ```
362
+ codex exec --json --ephemeral --skip-git-repo-check --ignore-user-config --ignore-rules \
363
+ -s read-only -C <empty dir> -c web_search="disabled" \
364
+ --disable shell_tool --disable unified_exec --disable apps --disable plugins \
365
+ --disable browser_use --disable computer_use --disable in_app_browser \
366
+ --disable image_generation --disable tool_suggest --disable skill_search --disable memories \
367
+ [-m <PGROAST_CODEX_MODEL>] -
368
+ ```
369
+
370
+ - `codex exec` has no single "no tools" switch and no system-prompt flag. The system text is
371
+ put in front of the prompt. Tools are removed in layers: the feature flags above, no user
372
+ config (so no user MCP servers), a read-only sandbox (no network, no writes) and an empty
373
+ working directory.
374
+ - The check that counts is on the output: the JSONL event stream is read, and the call fails if
375
+ it holds any item other than a message or reasoning (a command, a web search, an MCP call, a
376
+ file change). So a reply is used only when the model looked nothing up.
377
+ - The model is `PGROAST_CODEX_MODEL` or, when unset, whatever the Codex CLI is configured to
378
+ use; PG Roast does not guess model names. The Opus/Mythos rule applies to the claude backend
379
+ only. The codex CLI reports no cost, so `stats.cost_usd` is null.
380
+ - Status: the codex backend passed `pgroast doctor` (one trivial completion). A full roast has
381
+ been run on the claude backend only; the prompts were written for and tried on Claude models.
382
+
383
+ ### Both backends
384
+
385
+ - The reply is parsed (`extractJson`: first complete JSON object, fences and prose tolerated) and
386
+ validated against the stage schema with the validator in `schema.ts`. On failure there is
387
+ exactly one repair call (the same prompt plus the validator's errors and the bad reply); a
388
+ second failure is an error.
389
+ - A semaphore limits concurrent calls (default 3); each call has a timeout (default 15 min).
390
+ - Every prompt, reply and metadata record (backend, model, wall time, cost, usage, validation
391
+ errors) is written to `out/<run>/llm/`.
392
+ - When a call fails and the output looks like a login problem, the error says which command to
393
+ run (`claude`, then sign in; or `codex login`).
394
+
395
+ `DryRunBackend` prints each prompt and returns a stub that fits the schema; in a dry run the
396
+ precedent search uses the thread subject as the query so the later prompts are realistic.
397
+
398
+ ## Schemas (`src/roast/schema.ts`)
399
+
400
+ The eval runner should import from this file: `Roast`, `FinalObjection`, `PriorAttempt`,
401
+ `Verdict`, `VERDICTS`, `TOUCH_POINTS`, `LEVELS`, `DISCLOSURE`, `validate`.
402
+
403
+ `Roast` (roast.json), main fields:
404
+
405
+ | Field | Content |
406
+ |---|---|
407
+ | `verdict` | `LIKELY-REJECT` \| `NEEDS-DESIGN-DISCUSSION` \| `NEEDS-COMMITTER-EYES` \| `NO-FUNDAMENTAL-OBJECTION` |
408
+ | `confidence` | `low` \| `medium` \| `high` |
409
+ | `verdict_reasoning`, `failing_level`, `levels` | why; first failing pyramid level or null; per level `passed` \| `failed` \| `not_evaluated` |
410
+ | `mode` | `review` \| `self` |
411
+ | `backend`, `models` | which CLI and which models ran |
412
+ | `input` | argument, CommitFest entry (redacted in eval modes), thread URL, messages read / total, `blind`, `before`, `hint`, attachments read |
413
+ | `brief` | the neutral restatement and the search queries |
414
+ | `prior_attempts[]` | `url`, `thread_urls`, `date`, `first_date`, `status`, `who_objected`, `reason`, `enriched`, `quote` (or null = unavailable), `quote_url`, `relation`, `what_is_different`; empty = no precedent found |
415
+ | `objections[]` | the ranked steelman: `id`, `angle`, `round`, `revised_in_round`, `title`, `level`, `rubric_item`, `category`, `touch_points`, `severity`, `failure_scenario`, `argument`, `author_must_show`, `precedents[]` (links from the DB), `thread_refs[]`, `critic` |
416
+ | `other_surviving[]` | survived the critic, not listed (`not_ranked_reason`) |
417
+ | `refuted[]` | with `critic.refutation_kind` and `critic.reasoning` |
418
+ | `withdrawn[]` | taken back by their own roaster in round 2: `withdrawal {round, reason}` |
419
+ | `priority` | `audience`, `maintenance_burden`, `review_cost`, `recommended_action`, `reason` |
420
+ | `self_advice[]` | self mode: `objection_id`, `action` (`fix` \| `rebut`), `how` |
421
+ | `rounds_requested`, `rounds[]` | per round: `kind`, `llm_calls`, `new_objections`, `new_survived`, `revised`, `withdrawn`, `follow_ups` |
422
+ | `tone_lint` | `fields_checked`, `hits_found`, `sentences_rewritten`, `repair_call`, `remaining[]` |
423
+ | `invariants_loaded[]` | invariant files given to the roasters |
424
+ | `enrichment` | counts: candidates, from cache, new calls, failed, quotes verified / dropped, relevance pass |
425
+ | `touch_points[15]` | `id`, `label`, `hit`, `reason` |
426
+ | `questions[≤5]` | questions the author must answer before anyone reviews code |
427
+ | `committability` | `owner` (`qualified committer engaged` \| `silent` \| `none obvious`), `owner_note`, `liability`, `author_follow_up` |
428
+ | `not_now` | `applies`, `evidence_that_would_change_it` |
429
+ | `disclosure` | the issue's line, verbatim |
430
+ | `precedents_retrieved[]`, `precedent_cutoff`, `precedent_backend` | audit of the retrieval |
431
+ | `roaster_notes[]`, `warnings[]`, `sanitizer`, `stats` | audit of the run (calls, wall time, cost) |
432
+
433
+ Stage schemas (`BRIEF_SCHEMA`, `ROASTER_SCHEMA`, `CRITIC_SCHEMA`, `SYNTH_SCHEMA`) are a small
434
+ JSON Schema subset (object, array, string with enum, integer, boolean, nullable, min/max items)
435
+ with a 60-line validator. They are also rendered into each prompt as the output contract, so the
436
+ contract in the prompt and the check in code are the same object.
437
+
438
+ For the eval runner: "found the objection that killed it" should be matched against
439
+ `objections` first, then `other_surviving`, then `refuted` (an objection the critic wrongly
440
+ refuted is a critic error, not a roaster miss).
441
+
442
+ ## Anti-hallucination guarantees
443
+
444
+ What is guaranteed by code, whatever the model writes:
445
+
446
+ 1. **The model has no tools.** It cannot search, fetch or read files; checked on every call
447
+ (see above; on the codex backend the check is "no tool was used", read from the event
448
+ stream). All retrieval is done by our code.
449
+ 2. **Precedent links come only from the corpus.** The model never sees precedent URLs: the
450
+ prompt shows `[P3]`, title, outcome, dates and recorded reason. The model cites `P3`; code
451
+ resolves it to the row and copies `url` and `thread_urls` from it. An id that is not in the
452
+ retrieved list is dropped and recorded (`sanitizer.precedent_ids_dropped`).
453
+ 3. **Any other URL is removed.** Every string of every model reply is scanned for URLs (with a
454
+ scheme, and scheme-less forms of postgresql.org archives, postgr.es, commitfest, git, wiki,
455
+ github, youtube). A URL stays only if it is in the allowed set: the thread, its messages,
456
+ links written inside the visible thread text and patch prose, the CommitFest entry, the
457
+ retrieved precedents, links in the rubric, Source links of the loaded invariant files. Otherwise it is replaced by
458
+ `[link removed: not in the retrieved set]` and recorded (`sanitizer.urls_removed`; this audit
459
+ list is the only place where a removed URL still appears).
460
+ 4. **Thread references are by message number**; code turns a number into the permalink.
461
+ Numbers outside the visible thread are dropped.
462
+ 5. **Quotes are verified.** A prior attempt's quote is kept only if it occurs verbatim
463
+ (whitespace, quote style and case folded; `...` allowed between parts) in the corpus row or
464
+ in the visible thread, or is the key sentence that enrichment verified against a message of
465
+ the precedent's thread (then it links to that message). An AI summary is never a quote
466
+ source. Otherwise the output says "quote unavailable".
467
+ 6. **"Who objected" and dates of a prior attempt are copied from the corpus row.** When the row
468
+ has none, the output says "not recorded in the corpus".
469
+ 7. **"No precedent found" is distinct from "not searched".** When the corpus is unavailable the
470
+ roast says so in section 2 and in `warnings`.
471
+ 8. **Nothing from the future.** Precedents dated on or after the cutoff are dropped twice
472
+ (backend and adapter); the proposal's own entry is dropped; in eval modes the CommitFest
473
+ status and history are redacted and the review date given to the model is the date of the
474
+ last visible message.
475
+
476
+ What is not guaranteed:
477
+
478
+ - Prose. A model can still write "this was discussed many times" or name a person's opinion
479
+ without a link. The prompts forbid evidence from memory, the critic is told to refute
480
+ unsupported claims, and precedent-backed objections are visibly different from bare ones
481
+ ("Precedents. None cited."), but free text is not fact-checked by code.
482
+ - Bare message-ids, commit hashes and CommitFest numbers written as plain text (not URLs) are
483
+ not removed.
484
+ - The match between a precedent and the proposal is a model judgment (roaster, then critic,
485
+ then synthesis). The link is real; whether it is relevant can be wrong.
486
+ - Facts about PostgreSQL internals in a failure scenario come from the model. The critic checks
487
+ them, with the same kind of model.
488
+
489
+ ## Known weaknesses
490
+
491
+ - **Training-data memory of famous threads.** The models have read pgsql-hackers. For a
492
+ well-known historical proposal the model may remember who objected and how it ended, whatever
493
+ we show it. Blind mode helps with part of this and not with the rest:
494
+ - It does help against anchoring on the visible discussion: the objections, the tone of
495
+ senior people and the outcome are not in the prompt; the CommitFest status and history are
496
+ redacted; precedents are cut off at the thread start; the model has no tools to look
497
+ anything up; the prompts say not to use memory of the thread.
498
+ - It does not make the model forget. An instruction is not a guarantee, and a match on a
499
+ famous case may be recall, not judgment. So eval results on famous historical threads are
500
+ an upper bound. Treat them with care, and prefer cases that are (a) after the model's
501
+ training cutoff, where recall is impossible, or (b) obscure. Reporting accuracy separately
502
+ for pre-cutoff and post-cutoff cases shows the size of the effect. The reverse leak also
503
+ exists: a proposal that was rejected in 2015 and committed in 2022 may be judged by what the
504
+ model knows of today's PostgreSQL.
505
+ - **Corpus coverage decides the "prior attempts" section.** Retrieval is keyword search over
506
+ titles, summaries and recorded reasons. Many CommitFest rows have no recorded objection text;
507
+ enrichment reads only the top 8 such threads per run, and of a long thread only the start, the
508
+ end and the closers' messages, so the real reason can sit in a part that was not read. The
509
+ summary is a model's reading of the thread: the link and the verified sentence are facts, the
510
+ summary is not. Ideas that died on the list without a CommitFest entry are not in the corpus.
511
+ Prior discussions that the thread itself links to are visible to the roasters (and their links
512
+ are allowed), but they are not listed as structured prior attempts.
513
+ - **Only one thread per CommitFest entry** is read (the earliest). Long-lived entries with
514
+ several threads lose the later ones.
515
+ - **Long threads are cut.** The first message is kept up to 30 000 characters, other messages up
516
+ to 6 000 with long quoted runs collapsed; above 150 000 characters in total the middle of the
517
+ thread is left out (first ten and the newest are kept) and a warning says so.
518
+ - **The cache never expires.** A live thread that got new mail is stale until its cache file is
519
+ deleted.
520
+ - **Levels 1-2 without the code are provisional** (the rubric says so). The synthesis has a
521
+ `provisional_note` for it; a design flaw that only shows in the code will be missed.
522
+ - **One critic.** The critic and the synthesis run on the same model family as the roasters and
523
+ share its blind spots. There is no vote; results vary between runs. `--rounds` adds passes,
524
+ and a reflection question also invites a model to produce something: whether rounds add
525
+ surviving objections or noise is what `rounds[]` is there to measure.
526
+ - **Invariants are only as good as the files.** A subsystem without a file gets no such check,
527
+ and selection needs a patch: a proposal sent without one loads nothing.
528
+ - **`--before` uses UTC dates** from the archive; a message sent late in the evening in a
529
+ western time zone is dated the next day.
530
+ - **Cost.** 10 calls (up to 19 with enrichment on a cold cache), each with the rubric and the full material in the prompt. A short thread
531
+ takes a few minutes; a 100-message thread is roughly 40 000 input tokens per call.
532
+
533
+ ## Tests
534
+
535
+ `bun test test/thread.test.ts test/roast.test.ts`. Thread tests use two saved pages
536
+ (`test/thread-fixtures/`: a flat thread and a CommitFest patch page) and a small synthetic
537
+ patch. Roast tests run the whole pipeline with a fake model that returns fabricated links,
538
+ unknown precedent ids, a bad quote and a broken ranking, and check that code removes them; the
539
+ real CLI backends are tested through their stream parsers, their argument lists and an injected
540
+ runner. No test calls a model or the network.