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
@@ -0,0 +1,664 @@
1
+ // `pgroast pushback`: for a long-stuck entry, answer "what is the real pushback?".
2
+ // Not blind: the whole thread is read (every thread attached to the CommitFest entry).
3
+ //
4
+ // map one call per chunk of messages (roaster model): candidate blocking objections, each
5
+ // with the message that states it and one verbatim sentence; and responses to
6
+ // earlier objections. Code verifies every quote against the message's own
7
+ // (non-quoted) text and takes sender, date and link from the message.
8
+ // reduce one call (critic model): merge duplicates, decide resolved / partly resolved /
9
+ // unresolved / superseded by a redesign, name the blockers and a bucket. Code checks
10
+ // that resolving messages exist and are later than the objection.
11
+ //
12
+ // Same rules as a roast: the model has no tools, links come from code only, the tone lint runs
13
+ // before files are written, output is local files.
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { CACHE_ROOT, REPO_ROOT } from "../thread/fetch.ts";
19
+ import { loadCfEntry, loadThread, parseInputRef } from "../thread/load.ts";
20
+ import type { CfEntry, LoadOptions, ThreadMessage } from "../thread/types.ts";
21
+ import { ownText } from "./enrich.ts";
22
+ import type { LlmBackend } from "./llm.ts";
23
+ import { displayName, trimQuotes } from "./material.ts";
24
+ import { loadPrompts, outputContract } from "./pipeline.ts";
25
+ import { AllowedUrls, emptyReport, scrubUrls, verifyQuote } from "./sanitize.ts";
26
+ import { DISCLOSURE, TONE_REPAIR_SCHEMA, type JsonSchema, type SanitizerReport, type ToneLint } from "./schema.ts";
27
+ import { toneCheckFields, type ProseField } from "./tone.ts";
28
+
29
+ // ------------------------------------------------------------------------------------- input
30
+
31
+ export interface PushbackMessage extends ThreadMessage {
32
+ /** The thread this message belongs to (an entry can have several). */
33
+ threadUrl: string;
34
+ }
35
+
36
+ export interface PushbackInput {
37
+ arg: string;
38
+ cf: CfEntry | null;
39
+ threads: { url: string; subject: string; messages: number }[];
40
+ /** All messages of all threads, by date, numbered 1..N. */
41
+ messages: PushbackMessage[];
42
+ warnings: string[];
43
+ }
44
+
45
+ /** Merge threads: by date, one copy per message-id, numbered 1..N. */
46
+ export function mergeThreads(threads: { threadUrl: string; messages: ThreadMessage[] }[]): PushbackMessage[] {
47
+ const seen = new Set<string>();
48
+ const all: PushbackMessage[] = [];
49
+ for (const t of threads) {
50
+ for (const m of t.messages) {
51
+ const key = m.messageId || m.url;
52
+ if (seen.has(key)) continue;
53
+ seen.add(key);
54
+ all.push({ ...m, threadUrl: t.threadUrl });
55
+ }
56
+ }
57
+ all.sort((a, b) => a.date.localeCompare(b.date));
58
+ return all.map((m, i) => ({ ...m, index: i + 1 }));
59
+ }
60
+
61
+ export async function loadPushbackInput(arg: string, opts: LoadOptions = {}): Promise<PushbackInput> {
62
+ const ref = parseInputRef(arg);
63
+ const warnings: string[] = [];
64
+ let cf: CfEntry | null = null;
65
+ let ids: string[];
66
+ if (ref.kind === "cf") {
67
+ cf = await loadCfEntry(ref.id, opts);
68
+ if (cf.threads.length === 0) throw new Error(`CommitFest patch ${ref.id} has no mailing-list thread attached`);
69
+ ids = cf.threads.map((t) => t.messageId);
70
+ } else if (ref.kind === "msgid") ids = [ref.messageId];
71
+ else throw new Error("pushback needs a CommitFest id or a thread");
72
+
73
+ const loaded: { threadUrl: string; subject: string; messages: ThreadMessage[] }[] = [];
74
+ for (const id of ids) {
75
+ try {
76
+ const t = await loadThread(id, opts);
77
+ if (!loaded.some((x) => x.threadUrl === t.threadUrl)) loaded.push({ threadUrl: t.threadUrl, subject: t.subject, messages: t.messages });
78
+ } catch (e) {
79
+ warnings.push(`a thread of this entry could not be read and is missing from the result (${id}): ${(e as Error).message.split("\n")[0]}`);
80
+ }
81
+ }
82
+ if (loaded.length === 0) throw new Error("no thread could be read");
83
+ const messages = mergeThreads(loaded);
84
+ return { arg, cf, threads: loaded.map((t) => ({ url: t.threadUrl, subject: t.subject, messages: t.messages.length })), messages, warnings };
85
+ }
86
+
87
+ // ------------------------------------------------------------------------------------ chunks
88
+
89
+ export const MAX_MESSAGE_CHARS = 5000;
90
+ export const DEFAULT_CHUNK_CHARS = 60_000;
91
+
92
+ const clipBody = (body: string) => {
93
+ const t = trimQuotes(body).trim();
94
+ return t.length > MAX_MESSAGE_CHARS ? t.slice(0, MAX_MESSAGE_CHARS) + "\n[... message cut here ...]" : t;
95
+ };
96
+
97
+ export function renderMessage(m: PushbackMessage): string {
98
+ return `### Message #${m.index} | ${displayName(m.from)} | ${m.date.slice(0, 10)} | ${m.subject}\n\n${clipBody(m.body)}`;
99
+ }
100
+
101
+ /** Consecutive messages, each chunk at most `maxChars` of rendered text (one message always fits). */
102
+ export function chunkMessages(messages: PushbackMessage[], maxChars: number = DEFAULT_CHUNK_CHARS): PushbackMessage[][] {
103
+ const chunks: PushbackMessage[][] = [];
104
+ let cur: PushbackMessage[] = [];
105
+ let size = 0;
106
+ for (const m of messages) {
107
+ const len = renderMessage(m).length + 2;
108
+ if (cur.length && size + len > maxChars) {
109
+ chunks.push(cur);
110
+ cur = [];
111
+ size = 0;
112
+ }
113
+ cur.push(m);
114
+ size += len;
115
+ }
116
+ if (cur.length) chunks.push(cur);
117
+ return chunks;
118
+ }
119
+
120
+ // ----------------------------------------------------------------------------------- schemas
121
+
122
+ const str: JsonSchema = { type: "string" };
123
+ const nonEmpty: JsonSchema = { type: "string", minLength: 1 };
124
+ const int: JsonSchema = { type: "integer" };
125
+
126
+ export const RESPONSE_KINDS = ["new version", "rebuttal", "objector satisfied", "objector repeats", "redesign"] as const;
127
+ export const STATUSES = ["resolved", "partly resolved", "unresolved", "superseded by a redesign"] as const;
128
+ export const BUCKETS = ["dead or superseded", "stuck: needs champion", "stuck: needs design rethink", "active"] as const;
129
+
130
+ export const MAP_SCHEMA: JsonSchema = {
131
+ type: "object",
132
+ properties: {
133
+ objections: {
134
+ type: "array",
135
+ maxItems: 8,
136
+ items: {
137
+ type: "object",
138
+ properties: {
139
+ message_index: int,
140
+ objection: nonEmpty,
141
+ key_quote: { type: "string", nullable: true },
142
+ answered_in_part: { type: "boolean" },
143
+ answered_by_message_index: { type: "integer", nullable: true },
144
+ },
145
+ },
146
+ },
147
+ responses: {
148
+ type: "array",
149
+ maxItems: 8,
150
+ items: { type: "object", properties: { message_index: int, responds_to: nonEmpty, kind: { type: "string", enum: RESPONSE_KINDS }, summary: nonEmpty } },
151
+ },
152
+ state_note: str,
153
+ },
154
+ };
155
+
156
+ export const REDUCE_SCHEMA: JsonSchema = {
157
+ type: "object",
158
+ properties: {
159
+ objections: {
160
+ type: "array",
161
+ maxItems: 10,
162
+ items: {
163
+ type: "object",
164
+ properties: {
165
+ title: nonEmpty,
166
+ statement: nonEmpty,
167
+ candidate_ids: { type: "array", items: nonEmpty, minItems: 1 },
168
+ status: { type: "string", enum: STATUSES },
169
+ resolved_by_message_indexes: { type: "array", items: int },
170
+ status_reasoning: nonEmpty,
171
+ blocking: { type: "boolean" },
172
+ },
173
+ },
174
+ },
175
+ summary: nonEmpty,
176
+ bucket: { type: "string", enum: BUCKETS },
177
+ bucket_reason: nonEmpty,
178
+ superseded_by: { type: "string", nullable: true },
179
+ superseded_evidence_message_indexes: { type: "array", items: int },
180
+ },
181
+ };
182
+
183
+ interface MapReply {
184
+ objections: { message_index: number; objection: string; key_quote: string | null; answered_in_part: boolean; answered_by_message_index: number | null }[];
185
+ responses: { message_index: number; responds_to: string; kind: (typeof RESPONSE_KINDS)[number]; summary: string }[];
186
+ state_note: string;
187
+ }
188
+
189
+ interface ReduceReply {
190
+ objections: {
191
+ title: string;
192
+ statement: string;
193
+ candidate_ids: string[];
194
+ status: (typeof STATUSES)[number];
195
+ resolved_by_message_indexes: number[];
196
+ status_reasoning: string;
197
+ blocking: boolean;
198
+ }[];
199
+ summary: string;
200
+ bucket: (typeof BUCKETS)[number];
201
+ bucket_reason: string;
202
+ superseded_by: string | null;
203
+ superseded_evidence_message_indexes: number[];
204
+ }
205
+
206
+ // ------------------------------------------------------------------------------------ result
207
+
208
+ export interface MessageRef {
209
+ index: number;
210
+ from: string;
211
+ date: string;
212
+ url: string;
213
+ }
214
+
215
+ export interface Candidate {
216
+ id: string;
217
+ chunk: number;
218
+ message: MessageRef;
219
+ objection: string;
220
+ /** Verbatim from the message's own text, verified by code; null = quote unavailable. */
221
+ quote: string | null;
222
+ answered_in_part: boolean;
223
+ answered_by: MessageRef | null;
224
+ }
225
+
226
+ export interface ResponseNote {
227
+ id: string;
228
+ chunk: number;
229
+ message: MessageRef;
230
+ responds_to: string;
231
+ kind: (typeof RESPONSE_KINDS)[number];
232
+ summary: string;
233
+ }
234
+
235
+ export interface PushbackObjection {
236
+ title: string;
237
+ statement: string;
238
+ status: (typeof STATUSES)[number];
239
+ status_reasoning: string;
240
+ blocking: boolean;
241
+ /** From the merged candidates, by code. */
242
+ raised_by: string[];
243
+ first_raised: string;
244
+ last_raised: string;
245
+ candidates: Candidate[];
246
+ /** Messages that resolved or addressed it; each exists and is later than the first statement. */
247
+ resolved_by: MessageRef[];
248
+ }
249
+
250
+ export interface Pushback {
251
+ schema_version: 1;
252
+ tool: string;
253
+ generated_at: string;
254
+ input: {
255
+ arg: string;
256
+ cf: { id: number; url: string; title: string; status: string | null; authors: string } | null;
257
+ threads: { url: string; subject: string; messages: number }[];
258
+ messages: number;
259
+ first_message: string;
260
+ /** When the thread last moved. */
261
+ last_message: string;
262
+ last_message_from: string;
263
+ messages_per_year: Record<string, number>;
264
+ };
265
+ backend: string;
266
+ models: { map: string; reduce: string };
267
+ summary: string;
268
+ bucket: (typeof BUCKETS)[number];
269
+ bucket_reason: string;
270
+ /** Only with the "dead or superseded" bucket; "unknown" when no message in the thread shows it. */
271
+ superseded_by: string | null;
272
+ superseded_evidence: MessageRef[];
273
+ objections: PushbackObjection[];
274
+ candidates: Candidate[];
275
+ responses: ResponseNote[];
276
+ chunk_notes: { chunk: number; first_message: number; last_message: number; from_cache: boolean; note: string }[];
277
+ disclosure: string;
278
+ warnings: string[];
279
+ tone_lint: ToneLint;
280
+ sanitizer: SanitizerReport;
281
+ checks: { quotes_verified: number; quotes_dropped: number; candidates_dropped: number; resolving_refs_dropped: number };
282
+ stats: { chunks: number; llm_calls: number; map_from_cache: number; wall_ms: number; cost_usd: number | null; dry_run: boolean };
283
+ }
284
+
285
+ // ------------------------------------------------------------------------------------- cache
286
+
287
+ export interface MapCache {
288
+ get(key: string): unknown | null;
289
+ put(key: string, value: unknown): void;
290
+ }
291
+
292
+ /** Map replies on disk (data/cache/pushback/), keyed by the model and the exact prompt. */
293
+ export function diskMapCache(dir: string = join(CACHE_ROOT, "pushback")): MapCache {
294
+ return {
295
+ get(key) {
296
+ const f = join(dir, `${key}.json`);
297
+ if (!existsSync(f)) return null;
298
+ try {
299
+ return JSON.parse(readFileSync(f, "utf8"));
300
+ } catch {
301
+ return null;
302
+ }
303
+ },
304
+ put(key, value) {
305
+ mkdirSync(dir, { recursive: true });
306
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify(value));
307
+ },
308
+ };
309
+ }
310
+
311
+ // -------------------------------------------------------------------------------------- run
312
+
313
+ export interface PushbackOptions {
314
+ backend: LlmBackend;
315
+ models: { map: string; reduce: string };
316
+ backendName?: string;
317
+ chunkChars?: number;
318
+ /** null = no cache. Default: on disk. */
319
+ cache?: MapCache | null;
320
+ promptsDir?: string;
321
+ log?: (m: string) => void;
322
+ now?: () => Date;
323
+ }
324
+
325
+ const refOf = (m: PushbackMessage): MessageRef => ({ index: m.index, from: displayName(m.from), date: m.date, url: m.url });
326
+
327
+ /** Check one chunk's reply against the messages. Pure code. */
328
+ export function checkMapReply(
329
+ reply: MapReply,
330
+ chunk: PushbackMessage[],
331
+ chunkNo: number,
332
+ firstId: { c: number; r: number },
333
+ checks: Pushback["checks"],
334
+ ): { candidates: Candidate[]; responses: ResponseNote[] } {
335
+ const byIndex = new Map(chunk.map((m) => [m.index, m]));
336
+ const candidates: Candidate[] = [];
337
+ const responses: ResponseNote[] = [];
338
+ for (const o of reply.objections) {
339
+ const m = byIndex.get(o.message_index);
340
+ if (!m) {
341
+ checks.candidates_dropped++; // names a message outside this part: no source, no entry
342
+ continue;
343
+ }
344
+ let quote: string | null = null;
345
+ if (o.key_quote?.trim()) {
346
+ const ok = verifyQuote(o.key_quote.replace(/\.\.\.|\[\.\.\.\]|…/g, " \u0000 "), [ownText(m.body)]);
347
+ if (ok) {
348
+ quote = o.key_quote.trim().replace(/^["“]+|["”]+$/g, "");
349
+ checks.quotes_verified++;
350
+ } else checks.quotes_dropped++;
351
+ }
352
+ const ans = o.answered_by_message_index != null ? byIndex.get(o.answered_by_message_index) : undefined;
353
+ const answered = ans && ans.index > m.index ? ans : null;
354
+ if (o.answered_by_message_index != null && !answered) checks.resolving_refs_dropped++;
355
+ candidates.push({ id: `C${firstId.c++}`, chunk: chunkNo, message: refOf(m), objection: o.objection, quote, answered_in_part: o.answered_in_part && answered != null, answered_by: answered ? refOf(answered) : null });
356
+ }
357
+ for (const r of reply.responses) {
358
+ const m = byIndex.get(r.message_index);
359
+ if (!m) continue;
360
+ responses.push({ id: `R${firstId.r++}`, chunk: chunkNo, message: refOf(m), responds_to: r.responds_to, kind: r.kind, summary: r.summary });
361
+ }
362
+ return { candidates, responses };
363
+ }
364
+
365
+ export function pushbackSlug(input: PushbackInput): string {
366
+ if (input.cf) return `pushback-cf-${input.cf.id}`;
367
+ const id = input.messages[0]?.messageId ?? "thread";
368
+ return "pushback-msg-" + id.replace(/@.*$/, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
369
+ }
370
+
371
+ export async function runPushback(input: PushbackInput, opts: PushbackOptions): Promise<Pushback> {
372
+ const t0 = Date.now();
373
+ const log = opts.log ?? ((m: string) => console.error(m));
374
+ const { backend, models } = opts;
375
+ const prompts = loadPrompts(opts.promptsDir);
376
+ const read = (name: string) => readFileSync(join(opts.promptsDir ?? join(REPO_ROOT, "prompts"), name), "utf8").trim();
377
+ const mapSystem = `${read("pushback-map.md")}\n\n${prompts.writing}\n\n${outputContract(MAP_SCHEMA, false)}`;
378
+ const reduceSystem = `${read("pushback-reduce.md")}\n\n${prompts.writing}\n\n${outputContract(REDUCE_SCHEMA, false)}`;
379
+ const cache = opts.cache === undefined ? diskMapCache() : opts.cache;
380
+ const warnings = [...input.warnings];
381
+ const report = emptyReport();
382
+ const checks: Pushback["checks"] = { quotes_verified: 0, quotes_dropped: 0, candidates_dropped: 0, resolving_refs_dropped: 0 };
383
+ const msgs = input.messages;
384
+ if (msgs.length === 0) throw new Error("the thread has no messages");
385
+ const byIndex = new Map(msgs.map((m) => [m.index, m]));
386
+ const title = input.cf?.title ?? msgs[0]!.subject;
387
+
388
+ // Links the output may contain: the threads, their messages, links written in the messages.
389
+ const allowed = new AllowedUrls();
390
+ for (const t of input.threads) allowed.add(t.url);
391
+ if (input.cf) allowed.add(input.cf.url);
392
+ for (const m of msgs) {
393
+ allowed.add(m.url);
394
+ allowed.addFromText(m.body);
395
+ }
396
+
397
+ // Map: one call per chunk, in parallel (the backend limits concurrency).
398
+ const chunks = chunkMessages(msgs, opts.chunkChars ?? DEFAULT_CHUNK_CHARS);
399
+ log(`pushback: ${msgs.length} messages in ${input.threads.length} thread(s), ${chunks.length} chunk(s); at most ${chunks.length + 2} model calls (map ${chunks.length}, reduce 1, tone repair 0-1)`);
400
+ let fromCache = 0;
401
+ const replies = await Promise.all(
402
+ chunks.map(async (chunk, i) => {
403
+ const prompt = [
404
+ `Entry: ${title}`,
405
+ `Part ${i + 1} of ${chunks.length}: messages #${chunk[0]!.index} to #${chunk[chunk.length - 1]!.index} of ${msgs.length} (${chunk[0]!.date.slice(0, 10)} to ${chunk[chunk.length - 1]!.date.slice(0, 10)}).`,
406
+ `# Messages\n\n${chunk.map(renderMessage).join("\n\n")}`,
407
+ "# Task\n\nList the blocking objections and the responses in this part. JSON only.",
408
+ ].join("\n\n");
409
+ const key = createHash("sha256").update(`${models.map}\n${mapSystem}\n${prompt}`).digest("hex").slice(0, 40);
410
+ const hit = backend.dryRun ? null : (cache?.get(key) as MapReply | null | undefined);
411
+ if (hit) {
412
+ fromCache++;
413
+ return { reply: hit, cached: true };
414
+ }
415
+ try {
416
+ const reply = scrubUrls(await backend.complete<MapReply>({ label: `pushback-map-${i + 1}`, model: models.map, system: mapSystem, prompt, schema: MAP_SCHEMA }), allowed, `pushback-map-${i + 1}`, report);
417
+ if (!backend.dryRun) cache?.put(key, reply);
418
+ return { reply, cached: false };
419
+ } catch (e) {
420
+ warnings.push(`part ${i + 1} (messages #${chunk[0]!.index} to #${chunk[chunk.length - 1]!.index}) could not be read and is missing from the result: ${(e as Error).message.split("\n")[0]}`);
421
+ return { reply: null, cached: false };
422
+ }
423
+ }),
424
+ );
425
+ if (replies.every((r) => !r.reply)) throw new Error("no part of the thread could be read by the model");
426
+
427
+ const ids = { c: 1, r: 1 };
428
+ const candidates: Candidate[] = [];
429
+ const responses: ResponseNote[] = [];
430
+ const chunkNotes: Pushback["chunk_notes"] = [];
431
+ replies.forEach((r, i) => {
432
+ const chunk = chunks[i]!;
433
+ if (!r.reply) return;
434
+ const checked = checkMapReply(r.reply, chunk, i + 1, ids, checks);
435
+ candidates.push(...checked.candidates);
436
+ responses.push(...checked.responses);
437
+ chunkNotes.push({ chunk: i + 1, first_message: chunk[0]!.index, last_message: chunk[chunk.length - 1]!.index, from_cache: r.cached, note: r.reply.state_note });
438
+ });
439
+ log(`map: ${candidates.length} candidate objection(s), ${responses.length} response(s); quotes: ${checks.quotes_verified} verified, ${checks.quotes_dropped} dropped; ${fromCache} part(s) from cache`);
440
+
441
+ // Facts by code.
442
+ const first = msgs[0]!;
443
+ const last = msgs[msgs.length - 1]!;
444
+ const perYear: Record<string, number> = {};
445
+ for (const m of msgs) perYear[m.date.slice(0, 4)] = (perYear[m.date.slice(0, 4)] ?? 0) + 1;
446
+ const tailMsgs = msgs.slice(-3);
447
+
448
+ // Reduce.
449
+ log("reduce ...");
450
+ const reduce = scrubUrls(
451
+ await backend.complete<ReduceReply>({
452
+ label: "pushback-reduce",
453
+ model: models.reduce,
454
+ system: reduceSystem,
455
+ prompt: [
456
+ `Entry: ${title}`,
457
+ `# Facts (computed by code)\n\n- Messages: ${msgs.length}, in ${input.threads.length} thread(s)\n- First message: ${first.date.slice(0, 10)} (${displayName(first.from)})\n- Last message: #${last.index}, ${last.date.slice(0, 10)} (${displayName(last.from)})\n- Messages per year: ${Object.entries(perYear).map(([y, n]) => `${y}: ${n}`).join(", ")}` +
458
+ (input.cf ? `\n- CommitFest status: ${input.cf.status ?? "unknown"}; authors: ${input.cf.authors || "not given"}; reviewers: ${input.cf.reviewers || "none listed"}; committer: ${input.cf.committer || "none"}\n- CommitFest status by commitfest: ${input.cf.statuses.map((s) => `${s.cfName}: ${s.status}`).join("; ") || "not available"}` : ""),
459
+ `# Candidate objections\n\n${JSON.stringify(
460
+ candidates.map((c) => ({ id: c.id, message: c.message.index, date: c.message.date.slice(0, 10), from: c.message.from, objection: c.objection, verbatim: c.quote ?? "(quote unavailable)", answered_in_its_part: c.answered_in_part, answered_by_message: c.answered_by?.index ?? null })),
461
+ null,
462
+ 1,
463
+ )}`,
464
+ `# Responses to objections\n\n${JSON.stringify(responses.map((r) => ({ id: r.id, message: r.message.index, date: r.message.date.slice(0, 10), from: r.message.from, responds_to: r.responds_to, kind: r.kind, summary: r.summary })), null, 1)}`,
465
+ `# State of the discussion, part by part\n\n${chunkNotes.map((n) => `- Part ${n.chunk} (messages #${n.first_message} to #${n.last_message}): ${n.note}`).join("\n")}`,
466
+ `# The last messages\n\n${tailMsgs.map(renderMessage).join("\n\n")}`,
467
+ "# Task\n\nMerge, decide the status of each distinct objection, write the answer and pick the bucket. JSON only.",
468
+ ].join("\n\n"),
469
+ schema: REDUCE_SCHEMA,
470
+ }),
471
+ allowed,
472
+ "pushback-reduce",
473
+ report,
474
+ );
475
+
476
+ // Assemble: people, dates, quotes and links come from the candidates, not from the model.
477
+ const byId = new Map(candidates.map((c) => [c.id, c]));
478
+ const objections: PushbackObjection[] = [];
479
+ for (const o of reduce.objections) {
480
+ const cands = [...new Set(o.candidate_ids.map((x) => x.trim().toUpperCase()))].map((id) => byId.get(id)).filter((c): c is Candidate => c != null);
481
+ const unknown = o.candidate_ids.length - cands.length;
482
+ if (unknown > 0) report.precedent_ids_dropped.push(...o.candidate_ids.filter((x) => !byId.has(x.trim().toUpperCase())).map((id) => ({ stage: "pushback-reduce", id })));
483
+ if (cands.length === 0) {
484
+ warnings.push(`the entry "${o.title}" names no known candidate and has no source in the thread; dropped`);
485
+ continue;
486
+ }
487
+ cands.sort((a, b) => a.message.index - b.message.index);
488
+ const firstIdx = cands[0]!.message.index;
489
+ const resolved: MessageRef[] = [];
490
+ for (const n of [...new Set(o.resolved_by_message_indexes)].sort((a, b) => a - b)) {
491
+ const m = byIndex.get(n);
492
+ if (m && n > firstIdx) resolved.push(refOf(m));
493
+ else checks.resolving_refs_dropped++;
494
+ }
495
+ let status = o.status;
496
+ if ((status === "resolved" || status === "superseded by a redesign") && resolved.length === 0) {
497
+ warnings.push(`"${o.title}" was marked "${status}" with no later message that shows it; changed to "partly resolved"`);
498
+ status = "partly resolved";
499
+ }
500
+ objections.push({
501
+ title: o.title,
502
+ statement: o.statement,
503
+ status,
504
+ status_reasoning: o.status_reasoning,
505
+ blocking: o.blocking && status !== "resolved",
506
+ raised_by: [...new Set(cands.map((c) => c.message.from))],
507
+ first_raised: cands[0]!.message.date,
508
+ last_raised: cands[cands.length - 1]!.message.date,
509
+ candidates: cands,
510
+ resolved_by: resolved,
511
+ });
512
+ }
513
+
514
+ // Bucket guard: "dead or superseded" needs a message in the thread that shows it.
515
+ let bucket = reduce.bucket;
516
+ let supersededBy: string | null = null;
517
+ const evidence = [...new Set(reduce.superseded_evidence_message_indexes)].map((n) => byIndex.get(n)).filter((m): m is PushbackMessage => m != null).map(refOf);
518
+ if (bucket === "dead or superseded") {
519
+ if (evidence.length === 0) {
520
+ bucket = objections.some((o) => o.blocking && o.status === "unresolved") ? "stuck: needs design rethink" : "stuck: needs champion";
521
+ warnings.push(`the model said "dead or superseded" without a message in the thread that shows it; age and silence are not evidence, so the bucket is "${bucket}"`);
522
+ } else supersededBy = reduce.superseded_by?.trim() || "unknown";
523
+ }
524
+
525
+ const result: Pushback = {
526
+ schema_version: 1,
527
+ tool: "pgroast/0.1",
528
+ generated_at: (opts.now ?? (() => new Date()))().toISOString(),
529
+ input: {
530
+ arg: input.arg,
531
+ cf: input.cf ? { id: input.cf.id, url: input.cf.url, title: input.cf.title, status: input.cf.status, authors: input.cf.authors } : null,
532
+ threads: input.threads,
533
+ messages: msgs.length,
534
+ first_message: first.date,
535
+ last_message: last.date,
536
+ last_message_from: displayName(last.from),
537
+ messages_per_year: perYear,
538
+ },
539
+ backend: opts.backendName ?? (backend.dryRun ? "dry-run" : "claude"),
540
+ models,
541
+ summary: reduce.summary,
542
+ bucket,
543
+ bucket_reason: reduce.bucket_reason,
544
+ superseded_by: supersededBy,
545
+ superseded_evidence: bucket === "dead or superseded" ? evidence : [],
546
+ objections,
547
+ candidates,
548
+ responses,
549
+ chunk_notes: chunkNotes,
550
+ disclosure: DISCLOSURE,
551
+ warnings,
552
+ tone_lint: { fields_checked: 0, hits_found: 0, sentences_rewritten: 0, repair_call: "not needed", remaining: [] },
553
+ sanitizer: report,
554
+ checks,
555
+ stats: { chunks: chunks.length, llm_calls: 0, map_from_cache: fromCache, wall_ms: 0, cost_usd: null, dry_run: backend.dryRun },
556
+ };
557
+
558
+ result.tone_lint = await toneCheckFields(() => pushbackProse(result), {
559
+ backend,
560
+ model: models.reduce,
561
+ system: `${prompts.tone}\n\n${prompts.writing}\n\n${outputContract(TONE_REPAIR_SCHEMA, false)}`,
562
+ allowed,
563
+ report,
564
+ log,
565
+ });
566
+ if (result.tone_lint.remaining.length && !backend.dryRun) warnings.push(`tone lint: ${result.tone_lint.remaining.length} finding(s) remain after the repair call; see tone_lint in pushback.json before forwarding this text`);
567
+
568
+ const st = backend.stats();
569
+ result.stats = { ...result.stats, llm_calls: st.calls, wall_ms: Date.now() - t0, cost_usd: st.costUsd };
570
+ return result;
571
+ }
572
+
573
+ /** The model-written prose of a pushback report. Verbatim quotes are not ours and are left out. */
574
+ export function pushbackProse(r: Pushback): ProseField[] {
575
+ const out: ProseField[] = [];
576
+ const add = <T extends object>(obj: T, key: keyof T, path: string) => {
577
+ if (typeof obj[key] === "string") out.push({ path, get: () => obj[key] as string, set: (v) => void ((obj[key] as string) = v) });
578
+ };
579
+ add(r, "summary", "summary");
580
+ add(r, "bucket_reason", "bucket_reason");
581
+ if (r.superseded_by != null) add(r, "superseded_by", "superseded_by");
582
+ r.objections.forEach((o, i) => {
583
+ add(o, "title", `objections[${i}].title`);
584
+ add(o, "statement", `objections[${i}].statement`);
585
+ add(o, "status_reasoning", `objections[${i}].status_reasoning`);
586
+ });
587
+ return out;
588
+ }
589
+
590
+ // ----------------------------------------------------------------------------------- render
591
+
592
+ const day = (iso: string) => iso.slice(0, 10);
593
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
594
+ const link = (text: string, url: string) => (url ? `[${text.replace(/([[\]])/g, "\\$1")}](${url.replace(/\)/g, "%29")})` : text);
595
+ const msgLink = (m: MessageRef) => link(`#${m.index} (${m.from}, ${day(m.date)})`, m.url);
596
+
597
+ /** "message #12" in model prose becomes a link built by code; unknown numbers stay plain text. */
598
+ export function linkMessages(text: string, byIndex: Map<number, string>): string {
599
+ return text.replace(/\b(messages?) #(\d+)/gi, (all, word: string, n: string) => {
600
+ const url = byIndex.get(Number(n));
601
+ return url ? `${word} ${link(`#${n}`, url)}` : all;
602
+ });
603
+ }
604
+
605
+ export function renderPushbackMarkdown(r: Pushback): string {
606
+ const out: string[] = [];
607
+ const urls = new Map<number, string>();
608
+ for (const c of r.candidates) {
609
+ urls.set(c.message.index, c.message.url);
610
+ if (c.answered_by) urls.set(c.answered_by.index, c.answered_by.url);
611
+ }
612
+ for (const x of r.responses) urls.set(x.message.index, x.message.url);
613
+ for (const o of r.objections) for (const m of o.resolved_by) urls.set(m.index, m.url);
614
+ for (const m of r.superseded_evidence) urls.set(m.index, m.url);
615
+ const prose = (s: string) => linkMessages(oneLine(s), urls);
616
+
617
+ out.push(`# Open objections: ${oneLine(r.input.cf?.title ?? r.input.threads[0]?.subject ?? r.input.arg)}`, "");
618
+ out.push(`> ${r.disclosure}`, "");
619
+ if (r.input.cf) out.push(`- CommitFest: ${link(`patch ${r.input.cf.id}`, r.input.cf.url)}${r.input.cf.status ? ` (${r.input.cf.status})` : ""}; authors: ${r.input.cf.authors || "not given"}`);
620
+ for (const t of r.input.threads) out.push(`- Thread: ${link(oneLine(t.subject), t.url)} (${t.messages} messages)`);
621
+ const read = r.chunk_notes.reduce((n, c) => n + c.last_message - c.first_message + 1, 0);
622
+ const span = `${day(r.input.first_message)} to ${day(r.input.last_message)}; the text of the messages only, no patches and no code`;
623
+ if (read >= r.input.messages) out.push(`- Read: all ${r.input.messages} messages, ${span}`);
624
+ else out.push(`- Read: ${read} of ${r.input.messages} messages (${r.stats.chunks - r.chunk_notes.length} of ${r.stats.chunks} part(s) could not be read, see the warnings below; objections in them are missing), ${span}`);
625
+ out.push(`- Last moved: ${day(r.input.last_message)} (${r.input.last_message_from})`, "");
626
+
627
+ out.push("## What blocks it", "");
628
+ out.push(prose(r.summary), "");
629
+ const blockers = r.objections.filter((o) => o.blocking);
630
+ if (blockers.length === 0) out.push("No objection is marked as blocking.", "");
631
+ out.push(`**Recommended bucket: ${r.bucket}.** ${prose(r.bucket_reason)}`);
632
+ if (r.bucket === "dead or superseded") out.push("", `Superseded by: ${oneLine(r.superseded_by ?? "unknown")}; shown in ${r.superseded_evidence.map(msgLink).join(", ")}`);
633
+ out.push("");
634
+
635
+ const block = (o: PushbackObjection, n: number) => {
636
+ out.push(`### ${n}. ${oneLine(o.title)}`, "");
637
+ out.push(`- **Status:** ${o.status}${o.blocking ? "; blocking" : ""}`);
638
+ out.push(`- **Raised by:** ${o.raised_by.join(", ")}; first ${day(o.first_raised)}${o.last_raised !== o.first_raised ? `, last ${day(o.last_raised)}` : ""}`);
639
+ out.push(`- **Resolved or addressed in:** ${o.resolved_by.length ? o.resolved_by.map(msgLink).join(", ") : "no message"}`, "");
640
+ out.push(prose(o.statement), "");
641
+ for (const c of o.candidates) out.push(`- ${msgLink(c.message)}: ${c.quote ? `"${oneLine(c.quote)}"` : `quote unavailable. In short: ${oneLine(c.objection)}`}`);
642
+ out.push("", `**Why this status.** ${prose(o.status_reasoning)}`, "");
643
+ };
644
+ out.push("## Blocking objections", "");
645
+ if (blockers.length === 0) out.push("None.", "");
646
+ blockers.forEach((o, i) => block(o, i + 1));
647
+ const rest = r.objections.filter((o) => !o.blocking);
648
+ out.push("## Other objections raised in the thread", "");
649
+ if (rest.length === 0) out.push("None.", "");
650
+ rest.forEach((o, i) => block(o, blockers.length + i + 1));
651
+
652
+ out.push("## How this was produced", "");
653
+ out.push(`- ${r.backend} CLI on the subscription login (no API key); models: reading ${r.models.map}, merging ${r.models.reduce}; the models had no tools`);
654
+ out.push(`- The thread was read in ${r.stats.chunks} part(s) (${r.stats.map_from_cache} from cache); model calls: ${r.stats.llm_calls}; wall time: ${Math.round(r.stats.wall_ms / 1000)} s${r.stats.cost_usd != null && !r.stats.dry_run ? `; cost reported by the CLI: $${r.stats.cost_usd.toFixed(2)}` : ""}${r.stats.dry_run ? "; DRY RUN (no model was called; content is placeholder)" : ""}`);
655
+ out.push(`- Candidate objections found: ${r.candidates.length}; responses found: ${r.responses.length}; merged into ${r.objections.length}`);
656
+ out.push(`- Checks by code: ${r.checks.quotes_verified} quote(s) verified against the message's own text, ${r.checks.quotes_dropped} dropped; ${r.checks.candidates_dropped} candidate(s) without a source message dropped; ${r.checks.resolving_refs_dropped} resolving reference(s) dropped (missing or not later)`);
657
+ const t = r.tone_lint;
658
+ out.push(`- Tone check: ${t.fields_checked} text field(s) checked, ${t.hits_found} finding(s), ${t.sentences_rewritten} sentence(s) rewritten (repair call: ${t.repair_call}), ${t.remaining.length} left`);
659
+ for (const h of t.remaining) out.push(` - \`${h.path}\`: ${h.rule}${h.match ? ` ("${h.match}")` : ""}`);
660
+ out.push(`- Links: ${r.sanitizer.urls_removed.length} removed (not in the thread)`);
661
+ for (const w of r.warnings) out.push(`- Warning: ${oneLine(w)}`);
662
+ out.push(`- Generated: ${r.generated_at} by ${r.tool}`, "", "---", "", r.disclosure, "");
663
+ return out.join("\n");
664
+ }