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.
- package/LICENSE +202 -0
- package/README.md +173 -2
- package/bin/pgroast.mjs +19 -0
- package/data/precedents.sql.gz +0 -0
- package/docs/corpus.md +352 -0
- package/docs/design.md +540 -0
- package/docs/eval.md +245 -0
- package/docs/haas-paranoia-notes.md +223 -0
- package/invariants/README.md +15 -0
- package/invariants/nbtree.md +14 -0
- package/package.json +51 -4
- package/prompts/brief.md +32 -0
- package/prompts/committer-paranoia.md +149 -0
- package/prompts/critic.md +56 -0
- package/prompts/enrich.md +35 -0
- package/prompts/pushback-map.md +58 -0
- package/prompts/pushback-reduce.md +72 -0
- package/prompts/reflect.md +20 -0
- package/prompts/relevance.md +20 -0
- package/prompts/roaster-already-exists.md +23 -0
- package/prompts/roaster-common.md +88 -0
- package/prompts/roaster-demand.md +28 -0
- package/prompts/roaster-maintenance-fit.md +22 -0
- package/prompts/roaster-prior-attempts.md +29 -0
- package/prompts/roaster-replication-ondisk.md +24 -0
- package/prompts/roaster-security.md +21 -0
- package/prompts/roaster-standards-compat.md +21 -0
- package/prompts/supervisor.md +31 -0
- package/prompts/synth-self.md +19 -0
- package/prompts/synth.md +140 -0
- package/prompts/tone.md +21 -0
- package/prompts/writing.md +98 -0
- package/sql/schema.sql +45 -0
- package/src/cli.ts +391 -0
- package/src/corpus/cf.ts +220 -0
- package/src/corpus/cli.ts +189 -0
- package/src/corpus/db.ts +119 -0
- package/src/corpus/fetch.ts +104 -0
- package/src/corpus/reverts.ts +133 -0
- package/src/corpus/search.ts +201 -0
- package/src/corpus/util.ts +133 -0
- package/src/corpus/wiki.ts +80 -0
- package/src/eval/cases.ts +118 -0
- package/src/eval/cli.ts +105 -0
- package/src/eval/judge.ts +136 -0
- package/src/eval/report.ts +268 -0
- package/src/eval/run.ts +153 -0
- package/src/eval/score.ts +275 -0
- package/src/roast/doctor.ts +109 -0
- package/src/roast/enrich.ts +335 -0
- package/src/roast/invariants.ts +65 -0
- package/src/roast/llm.ts +601 -0
- package/src/roast/material.ts +133 -0
- package/src/roast/page.ts +266 -0
- package/src/roast/pipeline.ts +981 -0
- package/src/roast/precedents.ts +308 -0
- package/src/roast/pushback.ts +664 -0
- package/src/roast/render.ts +386 -0
- package/src/roast/sanitize.ts +140 -0
- package/src/roast/schema.ts +714 -0
- package/src/roast/tone.ts +216 -0
- package/src/thread/fetch.ts +172 -0
- package/src/thread/load.ts +227 -0
- package/src/thread/local.ts +73 -0
- package/src/thread/parse.ts +243 -0
- package/src/thread/patchprose.ts +143 -0
- package/src/thread/types.ts +120 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// pgroast <cf-id|cf-url|message-id|thread-url> [--blind N] [--before DATE] [--hint TEXT] [--out DIR]
|
|
3
|
+
// [--backend claude|codex] [--rounds N] [--self] [--dry-run]
|
|
4
|
+
// pgroast --self --proposal FILE [--patch FILE ...]
|
|
5
|
+
// pgroast page <roast.json> [--out FILE] [--site DIR [--base-url URL]]
|
|
6
|
+
// pgroast pushback <cf-id|cf-url|thread-url> [--out DIR] [--backend claude|codex] [--dry-run] [--offline]
|
|
7
|
+
// pgroast doctor
|
|
8
|
+
//
|
|
9
|
+
// Writes roast.md, roast.json and roast-summary.txt to a local directory. It never posts
|
|
10
|
+
// anything anywhere.
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { gunzipSync } from "node:zlib";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { DEFAULT_DSN, parseDsn } from "./corpus/db.ts";
|
|
17
|
+
import { renderPage, safeUrl, siteFiles } from "./roast/page.ts";
|
|
18
|
+
import type { Roast } from "./roast/schema.ts";
|
|
19
|
+
import { loadPushbackInput, pushbackSlug, renderPushbackMarkdown, runPushback } from "./roast/pushback.ts";
|
|
20
|
+
import { DEFAULT_OUT_ROOT, REPO_ROOT, stats as fetchStats } from "./thread/fetch.ts";
|
|
21
|
+
import { loadProposal, validateDate } from "./thread/load.ts";
|
|
22
|
+
import { loadLocalProposal } from "./thread/local.ts";
|
|
23
|
+
import { runDoctor } from "./roast/doctor.ts";
|
|
24
|
+
import { CliBackend, DryRunBackend, modelsFromEnv, pickBackend, type BackendName } from "./roast/llm.ts";
|
|
25
|
+
import { closePrecedents } from "./roast/precedents.ts";
|
|
26
|
+
import { runRoast, slugFor } from "./roast/pipeline.ts";
|
|
27
|
+
import { renderCompactMarkdown, renderMarkdown, renderSummary } from "./roast/render.ts";
|
|
28
|
+
|
|
29
|
+
const USAGE = `Usage: bun run src/cli.ts <cf-id|cf-url|message-id|thread-url> [options]
|
|
30
|
+
bun run src/cli.ts --self --proposal FILE [--patch FILE ...]
|
|
31
|
+
bun run src/cli.ts page <roast.json> [--out FILE] [--site DIR [--base-url URL]]
|
|
32
|
+
bun run src/cli.ts pushback <cf-id|cf-url|thread-url> [--out DIR] [--backend NAME] [--dry-run] [--offline]
|
|
33
|
+
bun run src/cli.ts doctor check the installed CLIs and their login
|
|
34
|
+
bun run src/cli.ts init-db create the precedent database and load the shipped index
|
|
35
|
+
|
|
36
|
+
--self your own proposal, before you send it: same roast, written for the author
|
|
37
|
+
("objections to address before you send": fix it, or rebut it in the cover
|
|
38
|
+
letter); the verdict stays in roast.json and moves down in roast.md
|
|
39
|
+
--proposal FILE with --self: a draft email on disk instead of a thread
|
|
40
|
+
--patch FILE with --proposal: a patch file (repeatable); only its commit message, file
|
|
41
|
+
list and doc hunks are read
|
|
42
|
+
--rounds N extra rounds after the first pass (default 0): 1 asks every roaster "What
|
|
43
|
+
did you forget?"; 2 also asks "Are you happy with the results?"; 3 and more
|
|
44
|
+
add supervisor follow-ups on areas nobody examined
|
|
45
|
+
|
|
46
|
+
--blind N read only the first N messages of the thread (evaluation: judge the idea itself)
|
|
47
|
+
--before DATE read only messages strictly before DATE (YYYY-MM-DD); also the precedent cutoff
|
|
48
|
+
--hint TEXT directed precedent lookup, e.g. "the 2018 suggestion of doing X"
|
|
49
|
+
--out DIR output directory (default: out/<slug>)
|
|
50
|
+
--backend NAME claude or codex (default: PGROAST_BACKEND, else claude if installed, else codex)
|
|
51
|
+
--no-enrich do not read precedent threads and do not filter precedents for relevance
|
|
52
|
+
--dry-run print the prompts, call no model
|
|
53
|
+
--offline use only cached pages
|
|
54
|
+
-h, --help
|
|
55
|
+
|
|
56
|
+
pushback: for a long-stuck entry, "what is the real pushback?". Reads the whole thread (every
|
|
57
|
+
thread of the CommitFest entry) in parts, one model call per part plus one to merge, and writes
|
|
58
|
+
pushback.md and pushback.json: the blocking objections with verified quotes and message links,
|
|
59
|
+
who raised them, whether they were resolved, when the thread last moved, and a bucket.
|
|
60
|
+
--dry-run prints the number of parts and calls and calls no model.
|
|
61
|
+
|
|
62
|
+
page: renders a finished roast.json, with no model call, to one self-contained HTML file
|
|
63
|
+
(default: roast.html next to the JSON; --out FILE), or with --site DIR to a static layout:
|
|
64
|
+
DIR/cf/<cf-id>.html (embeddable card), DIR/cf/<cf-id>.json, DIR/cf/<cf-id>/full.html
|
|
65
|
+
(DIR/msg/<message-id>.* for a thread without a CommitFest entry). The card links to
|
|
66
|
+
{{PGROAST_BASE_URL}}/cf/<cf-id>/full.html unless --base-url is given. Local files only:
|
|
67
|
+
nothing is uploaded or published.
|
|
68
|
+
|
|
69
|
+
The model runs through an installed Claude Code or Codex CLI on your subscription login.
|
|
70
|
+
No API key is used: API key variables are removed from the CLI's environment.
|
|
71
|
+
|
|
72
|
+
Environment: PGROAST_BACKEND; for claude: PGROAST_ROASTER_MODEL (default opus), PGROAST_CRITIC_MODEL
|
|
73
|
+
(default claude-mythos-5-2; also used for synthesis), Opus, Mythos and Fable only; for codex:
|
|
74
|
+
PGROAST_CODEX_MODEL (default: the CLI's configured model); PGROAST_CONCURRENCY (3),
|
|
75
|
+
PGROAST_LLM_TIMEOUT_MS (900000).
|
|
76
|
+
|
|
77
|
+
Output: roast.md, roast.json, roast-summary.txt (a draft of at most 10 lines, to rewrite in your
|
|
78
|
+
own words) and llm/ (every prompt and reply). Local files only.`;
|
|
79
|
+
|
|
80
|
+
export interface CliArgs {
|
|
81
|
+
input: string;
|
|
82
|
+
blind?: number;
|
|
83
|
+
before?: string;
|
|
84
|
+
hint?: string;
|
|
85
|
+
out?: string;
|
|
86
|
+
backend?: BackendName;
|
|
87
|
+
doctor?: boolean;
|
|
88
|
+
initDb?: boolean;
|
|
89
|
+
noEnrich?: boolean;
|
|
90
|
+
self?: boolean;
|
|
91
|
+
proposal?: string;
|
|
92
|
+
patches?: string[];
|
|
93
|
+
rounds?: number;
|
|
94
|
+
page?: { json: string; out?: string; site?: string; baseUrl?: string };
|
|
95
|
+
pushback?: boolean;
|
|
96
|
+
dryRun: boolean;
|
|
97
|
+
offline: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseArgs(argv: string[]): CliArgs | "help" {
|
|
101
|
+
const a: CliArgs = { input: "", dryRun: false, offline: false };
|
|
102
|
+
if (argv[0] === "page") return parsePageArgs(argv.slice(1));
|
|
103
|
+
const rest = [...argv];
|
|
104
|
+
if (rest[0] === "pushback") {
|
|
105
|
+
rest.shift();
|
|
106
|
+
a.pushback = true;
|
|
107
|
+
}
|
|
108
|
+
const value = (flag: string, inline: string | undefined): string => {
|
|
109
|
+
const v = inline ?? rest.shift();
|
|
110
|
+
if (v == null || v === "") throw new Error(`${flag} needs a value`);
|
|
111
|
+
return v;
|
|
112
|
+
};
|
|
113
|
+
while (rest.length) {
|
|
114
|
+
const tok = rest.shift()!;
|
|
115
|
+
const eq = tok.startsWith("--") && tok.includes("=") ? tok.indexOf("=") : -1;
|
|
116
|
+
const flag = eq > 0 ? tok.slice(0, eq) : tok;
|
|
117
|
+
const inline = eq > 0 ? tok.slice(eq + 1) : undefined;
|
|
118
|
+
switch (flag) {
|
|
119
|
+
case "-h":
|
|
120
|
+
case "--help":
|
|
121
|
+
return "help";
|
|
122
|
+
case "--blind": {
|
|
123
|
+
const n = Number(value(flag, inline));
|
|
124
|
+
if (!Number.isInteger(n) || n < 1) throw new Error("--blind needs a positive integer");
|
|
125
|
+
a.blind = n;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "--before":
|
|
129
|
+
a.before = validateDate(value(flag, inline));
|
|
130
|
+
break;
|
|
131
|
+
case "--hint":
|
|
132
|
+
a.hint = value(flag, inline);
|
|
133
|
+
break;
|
|
134
|
+
case "--out":
|
|
135
|
+
a.out = value(flag, inline);
|
|
136
|
+
break;
|
|
137
|
+
case "--backend": {
|
|
138
|
+
const b = value(flag, inline).toLowerCase();
|
|
139
|
+
if (b !== "claude" && b !== "codex") throw new Error("--backend must be claude or codex");
|
|
140
|
+
a.backend = b;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "--self":
|
|
144
|
+
a.self = true;
|
|
145
|
+
break;
|
|
146
|
+
case "--proposal":
|
|
147
|
+
a.proposal = value(flag, inline);
|
|
148
|
+
break;
|
|
149
|
+
case "--patch":
|
|
150
|
+
(a.patches ??= []).push(value(flag, inline));
|
|
151
|
+
break;
|
|
152
|
+
case "--rounds": {
|
|
153
|
+
const n = Number(value(flag, inline));
|
|
154
|
+
if (!Number.isInteger(n) || n < 0 || n > 6) throw new Error("--rounds needs an integer from 0 to 6");
|
|
155
|
+
a.rounds = n;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "--no-enrich":
|
|
159
|
+
a.noEnrich = true;
|
|
160
|
+
break;
|
|
161
|
+
case "--dry-run":
|
|
162
|
+
a.dryRun = true;
|
|
163
|
+
break;
|
|
164
|
+
case "--offline":
|
|
165
|
+
a.offline = true;
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
if (flag.startsWith("--")) throw new Error(`unknown option ${flag}`);
|
|
169
|
+
if (a.input) throw new Error(`unexpected argument "${tok}"`);
|
|
170
|
+
a.input = tok;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (a.pushback) {
|
|
174
|
+
if (!a.input) throw new Error("pushback needs a CommitFest patch id or URL, or a thread URL");
|
|
175
|
+
for (const [k, name] of [["blind", "--blind"], ["before", "--before"], ["hint", "--hint"], ["self", "--self"], ["proposal", "--proposal"], ["rounds", "--rounds"], ["noEnrich", "--no-enrich"]] as const) {
|
|
176
|
+
if (a[k] != null) throw new Error(`${name} does not apply to pushback (it reads the whole thread)`);
|
|
177
|
+
}
|
|
178
|
+
if (a.patches?.length) throw new Error("--patch does not apply to pushback");
|
|
179
|
+
return a;
|
|
180
|
+
}
|
|
181
|
+
if (a.input === "doctor") a.doctor = true;
|
|
182
|
+
if (a.input === "init-db") a.initDb = true;
|
|
183
|
+
if (a.proposal) {
|
|
184
|
+
if (!a.self) throw new Error("--proposal is for your own draft: use it with --self");
|
|
185
|
+
if (a.input) throw new Error("give either a thread or --proposal FILE, not both");
|
|
186
|
+
if (a.blind != null || a.before) throw new Error("--blind and --before do not apply to a draft on disk");
|
|
187
|
+
return a;
|
|
188
|
+
}
|
|
189
|
+
if (a.patches?.length) throw new Error("--patch needs --self --proposal FILE");
|
|
190
|
+
if (!a.input) throw new Error("missing input: a CommitFest patch id or URL, a message-id, or a thread URL (or --self --proposal FILE)");
|
|
191
|
+
return a;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parsePageArgs(argv: string[]): CliArgs | "help" {
|
|
195
|
+
const page: NonNullable<CliArgs["page"]> = { json: "" };
|
|
196
|
+
const rest = [...argv];
|
|
197
|
+
while (rest.length) {
|
|
198
|
+
const tok = rest.shift()!;
|
|
199
|
+
const eq = tok.startsWith("--") && tok.includes("=") ? tok.indexOf("=") : -1;
|
|
200
|
+
const flag = eq > 0 ? tok.slice(0, eq) : tok;
|
|
201
|
+
const inline = eq > 0 ? tok.slice(eq + 1) : undefined;
|
|
202
|
+
const value = (): string => {
|
|
203
|
+
const v = inline ?? rest.shift();
|
|
204
|
+
if (v == null || v === "") throw new Error(`${flag} needs a value`);
|
|
205
|
+
return v;
|
|
206
|
+
};
|
|
207
|
+
if (flag === "-h" || flag === "--help") return "help";
|
|
208
|
+
else if (flag === "--out") page.out = value();
|
|
209
|
+
else if (flag === "--site") page.site = value();
|
|
210
|
+
else if (flag === "--base-url") {
|
|
211
|
+
const u = value();
|
|
212
|
+
if (!safeUrl(u)) throw new Error("--base-url must be an http(s) URL");
|
|
213
|
+
page.baseUrl = u;
|
|
214
|
+
} else if (flag.startsWith("--")) throw new Error(`unknown option ${flag} for page`);
|
|
215
|
+
else if (page.json) throw new Error(`unexpected argument "${tok}"`);
|
|
216
|
+
else page.json = tok;
|
|
217
|
+
}
|
|
218
|
+
if (!page.json) throw new Error("page needs the path of a roast.json");
|
|
219
|
+
if (page.out && page.site) throw new Error("give --out FILE or --site DIR, not both");
|
|
220
|
+
if (page.baseUrl && !page.site) throw new Error("--base-url is for --site");
|
|
221
|
+
return { input: "page", dryRun: false, offline: false, page };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `pgroast page`: roast.json -> static HTML. No model, no network, local files only. */
|
|
225
|
+
export function runPage(page: NonNullable<CliArgs["page"]>, log: (m: string) => void = () => undefined): string[] {
|
|
226
|
+
let roast: Roast;
|
|
227
|
+
try {
|
|
228
|
+
roast = JSON.parse(readFileSync(page.json, "utf8")) as Roast;
|
|
229
|
+
} catch (e) {
|
|
230
|
+
throw new Error(`cannot read ${page.json}: ${(e as Error).message}`);
|
|
231
|
+
}
|
|
232
|
+
const missing = ["verdict", "objections", "other_surviving", "refuted", "withdrawn", "touch_points", "questions", "committability", "priority", "rounds", "invariants_loaded", "disclosure", "brief", "input"].filter(
|
|
233
|
+
(k) => (roast as unknown as Record<string, unknown>)?.[k] == null,
|
|
234
|
+
);
|
|
235
|
+
if (missing.length) throw new Error(`${page.json} is not a roast.json of this version (missing: ${missing.join(", ")}); run the roast again`);
|
|
236
|
+
const written: string[] = [];
|
|
237
|
+
const write = (file: string, content: string) => {
|
|
238
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
239
|
+
writeFileSync(file, content);
|
|
240
|
+
written.push(file);
|
|
241
|
+
log(`wrote ${file}`);
|
|
242
|
+
};
|
|
243
|
+
if (page.site) {
|
|
244
|
+
const dir = resolve(page.site);
|
|
245
|
+
for (const f of siteFiles(roast, page.baseUrl)) write(join(dir, f.path), f.content);
|
|
246
|
+
} else {
|
|
247
|
+
write(resolve(page.out ?? join(dirname(page.json), "roast.html")), renderPage(roast));
|
|
248
|
+
}
|
|
249
|
+
return written;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create the precedent database and load the shipped index. Uses createdb, gunzip and psql with
|
|
254
|
+
* the usual libpq environment (PGHOST, PGPORT, PGUSER); the database name is `pgroast`, or
|
|
255
|
+
* the dbname of PGROAST_DSN.
|
|
256
|
+
*/
|
|
257
|
+
function initDb(): number {
|
|
258
|
+
const dump = join(REPO_ROOT, "data", "precedents.sql.gz");
|
|
259
|
+
if (!existsSync(dump)) {
|
|
260
|
+
console.error(`pgroast: ${dump} is missing`);
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
const kv = parseDsn(process.env.PGROAST_DSN ?? DEFAULT_DSN);
|
|
264
|
+
const db = kv.dbname ?? "pgroast";
|
|
265
|
+
const env = { ...process.env, ...(kv.host ? { PGHOST: kv.host } : {}), ...(kv.port ? { PGPORT: kv.port } : {}), ...(kv.user ? { PGUSER: kv.user } : {}) };
|
|
266
|
+
const run = (cmd: string, argv: string[], input?: Buffer) => spawnSync(cmd, argv, { env, input, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
267
|
+
const has = run("psql", ["-XAtq", "-d", db, "-c", "select to_regclass('public.precedent') is not null"]);
|
|
268
|
+
if (has.error) {
|
|
269
|
+
console.error("pgroast: psql was not found on PATH; install the Postgres client tools");
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
if (has.status === 0 && has.stdout.trim() === "t") {
|
|
273
|
+
const n = run("psql", ["-XAtq", "-d", db, "-c", "select count(*) from precedent"]);
|
|
274
|
+
console.log(`database "${db}" already holds the precedent index (${n.stdout.trim()} rows); nothing to do`);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
if (has.status !== 0) {
|
|
278
|
+
const c = run("createdb", [db]);
|
|
279
|
+
if (c.status !== 0) {
|
|
280
|
+
console.error(`pgroast: createdb ${db} failed: ${(c.stderr || c.error?.message || "").trim()}\nIs Postgres running? Set PGROAST_DSN (for example "dbname=pgroast host=localhost") if it is not on the local socket.`);
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
console.log(`created database "${db}"`);
|
|
284
|
+
}
|
|
285
|
+
const load = run("psql", ["-Xq", "-v", "ON_ERROR_STOP=1", "-d", db], gunzipSync(readFileSync(dump)));
|
|
286
|
+
if (load.status !== 0) {
|
|
287
|
+
console.error(`pgroast: loading the index failed: ${(load.stderr || "").trim().split("\n").slice(0, 5).join("\n")}`);
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
const n = run("psql", ["-XAtq", "-d", db, "-c", "select count(*) from precedent"]);
|
|
291
|
+
console.log(`loaded the precedent index into "${db}": ${n.stdout.trim()} rows`);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function main(): Promise<number> {
|
|
296
|
+
let args: CliArgs | "help";
|
|
297
|
+
try {
|
|
298
|
+
args = parseArgs(process.argv.slice(2));
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.error(`pgroast: ${(e as Error).message}\n\n${USAGE}`);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
if (args === "help") {
|
|
304
|
+
console.log(USAGE);
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
if (args.page) {
|
|
308
|
+
runPage(args.page, (m) => console.error(m));
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
if (args.initDb) return initDb();
|
|
312
|
+
if (args.doctor) {
|
|
313
|
+
const d = await runDoctor();
|
|
314
|
+
console.log(d.text);
|
|
315
|
+
return d.ok ? 0 : 1;
|
|
316
|
+
}
|
|
317
|
+
const t0 = Date.now();
|
|
318
|
+
const log = (m: string) => console.error(m);
|
|
319
|
+
const backendName: BackendName = args.dryRun ? (args.backend ?? "claude") : pickBackend(args.backend);
|
|
320
|
+
const models = modelsFromEnv(process.env, backendName);
|
|
321
|
+
|
|
322
|
+
if (args.pushback) {
|
|
323
|
+
log(`reading ${args.input} (whole thread) ...`);
|
|
324
|
+
const input = await loadPushbackInput(args.input, { offline: args.offline, noAttachments: true });
|
|
325
|
+
const outDir = resolve(args.out ?? join(DEFAULT_OUT_ROOT, pushbackSlug(input)));
|
|
326
|
+
mkdirSync(outDir, { recursive: true });
|
|
327
|
+
const transcriptDir = join(outDir, "llm");
|
|
328
|
+
const backend = args.dryRun ? new DryRunBackend({ transcriptDir, print: () => undefined }) : new CliBackend({ backend: backendName, transcriptDir, log });
|
|
329
|
+
const result = await runPushback(input, { backend, models: { map: models.roaster, reduce: models.critic }, backendName: args.dryRun ? "dry-run" : backendName, log });
|
|
330
|
+
result.stats.wall_ms = Date.now() - t0;
|
|
331
|
+
writeFileSync(join(outDir, "pushback.json"), JSON.stringify(result, null, 2) + "\n");
|
|
332
|
+
writeFileSync(join(outDir, "pushback.md"), renderPushbackMarkdown(result));
|
|
333
|
+
log("");
|
|
334
|
+
if (args.dryRun) log(`dry run: no model was called; prompts are in ${transcriptDir}`);
|
|
335
|
+
else {
|
|
336
|
+
log(`bucket: ${result.bucket}`);
|
|
337
|
+
log(`objections: ${result.objections.length} (${result.objections.filter((o) => o.blocking).length} blocking), from ${result.candidates.length} candidates`);
|
|
338
|
+
for (const w of result.warnings) log(`warning: ${w}`);
|
|
339
|
+
}
|
|
340
|
+
log(`model calls: ${result.stats.llm_calls}; parts: ${result.stats.chunks}; wall time: ${Math.round(result.stats.wall_ms / 1000)} s`);
|
|
341
|
+
log(`wrote ${join(outDir, "pushback.md")}`);
|
|
342
|
+
log(`wrote ${join(outDir, "pushback.json")}`);
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
log(`reading ${args.proposal ?? args.input} ...`);
|
|
347
|
+
const proposal = args.proposal
|
|
348
|
+
? loadLocalProposal(args.proposal, args.patches ?? [])
|
|
349
|
+
: await loadProposal(args.input, { blind: args.blind, before: args.before, offline: args.offline });
|
|
350
|
+
log(
|
|
351
|
+
args.proposal
|
|
352
|
+
? `draft: "${proposal.thread.subject}", ${proposal.patchProse.length} patch file(s) read`
|
|
353
|
+
: `thread: "${proposal.thread.subject}", ${proposal.thread.messages.length} of ${proposal.totalMessages} messages, ${proposal.patchProse.length} patch file(s) read; fetched ${fetchStats.fetched}, cached ${fetchStats.cached}`,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const outDir = resolve(args.out ?? join(DEFAULT_OUT_ROOT, slugFor(proposal)));
|
|
357
|
+
mkdirSync(outDir, { recursive: true });
|
|
358
|
+
const transcriptDir = join(outDir, "llm");
|
|
359
|
+
const backend = args.dryRun ? new DryRunBackend({ transcriptDir }) : new CliBackend({ backend: backendName, transcriptDir, log });
|
|
360
|
+
if (!args.dryRun) log(`backend: ${backendName} CLI (subscription login, no tools)`);
|
|
361
|
+
|
|
362
|
+
const roast = await runRoast(proposal, { backend, models, backendName: args.dryRun ? "dry-run" : backendName, hint: args.hint ?? null, enrich: !args.noEnrich, mode: args.self ? "self" : "review", rounds: args.rounds ?? 0, log });
|
|
363
|
+
await closePrecedents();
|
|
364
|
+
roast.stats.wall_ms = Date.now() - t0;
|
|
365
|
+
|
|
366
|
+
writeFileSync(join(outDir, "roast.json"), JSON.stringify(roast, null, 2) + "\n");
|
|
367
|
+
writeFileSync(join(outDir, "roast.md"), renderCompactMarkdown(roast));
|
|
368
|
+
writeFileSync(join(outDir, "roast-full.md"), renderMarkdown(roast));
|
|
369
|
+
writeFileSync(join(outDir, "roast-summary.txt"), renderSummary(roast));
|
|
370
|
+
log("");
|
|
371
|
+
log(`${args.dryRun ? "DRY RUN, placeholder verdict" : "verdict"}: ${roast.verdict} (confidence: ${roast.confidence})`);
|
|
372
|
+
log(`objections: ${roast.objections.length} listed, ${roast.other_surviving.length} more survived, ${roast.refuted.length} refuted; prior attempts: ${roast.prior_attempts.length}`);
|
|
373
|
+
for (const x of roast.rounds.slice(1)) log(`round ${x.round} (${x.kind}): ${x.llm_calls} calls, ${x.new_objections} new objection(s), ${x.new_survived} survived, ${x.revised} revised, ${x.withdrawn} withdrawn`);
|
|
374
|
+
log(`LLM calls: ${roast.stats.llm_calls}; wall time: ${Math.round(roast.stats.wall_ms / 1000)} s`);
|
|
375
|
+
if (!args.dryRun) for (const w of roast.warnings) log(`warning: ${w}`);
|
|
376
|
+
else log(`dry run: prompts are in ${transcriptDir}; roast.md and roast.json hold placeholders`);
|
|
377
|
+
log(`wrote ${join(outDir, "roast.md")} (short report; roast-full.md has everything)`);
|
|
378
|
+
log(`wrote ${join(outDir, "roast.json")}`);
|
|
379
|
+
log(`wrote ${join(outDir, "roast-summary.txt")}`);
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (import.meta.main) {
|
|
384
|
+
main().then(
|
|
385
|
+
(code) => process.exit(code),
|
|
386
|
+
(e) => {
|
|
387
|
+
console.error(`pgroast: ${(e as Error).message}`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
}
|
package/src/corpus/cf.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// CommitFest source: Rejected / Returned with feedback / Withdrawn patches.
|
|
2
|
+
|
|
3
|
+
import { fetchPage, fetchStats, isCached } from "./fetch.ts";
|
|
4
|
+
import {
|
|
5
|
+
flatUrl, log, normTs, splitPeople, stripTags, touchPoints, unescapeHtml,
|
|
6
|
+
type PrecedentRecord,
|
|
7
|
+
} from "./util.ts";
|
|
8
|
+
|
|
9
|
+
export const CF_BASE = "https://commitfest.postgresql.org";
|
|
10
|
+
export const CF_STATUSES: Record<number, string> = {
|
|
11
|
+
6: "Rejected",
|
|
12
|
+
7: "Returned with feedback",
|
|
13
|
+
8: "Withdrawn",
|
|
14
|
+
};
|
|
15
|
+
const NEGATIVE = new Set(Object.values(CF_STATUSES).map((s) => s.toLowerCase()));
|
|
16
|
+
|
|
17
|
+
export interface CfInfo { id: number; name: string | null; state: string | null; start: string | null; end: string | null }
|
|
18
|
+
|
|
19
|
+
export function parseArchive(h: string): CfInfo[] {
|
|
20
|
+
const out = new Map<number, CfInfo>();
|
|
21
|
+
const re = /href="\/(\d+)\/">([^<]+)<\/a>\s*\(([^-)]+?)\s*-\s*(\d{4}-\d\d-\d\d)\s*\S\s*(\d{4}-\d\d-\d\d)\)/g;
|
|
22
|
+
for (const m of h.matchAll(re)) {
|
|
23
|
+
out.set(Number(m[1]), { id: Number(m[1]), name: m[2]!.trim(), state: m[3]!.trim(), start: m[4]!, end: m[5]! });
|
|
24
|
+
}
|
|
25
|
+
for (const m of h.matchAll(/href="\/(\d+)\/"/g)) {
|
|
26
|
+
const id = Number(m[1]);
|
|
27
|
+
if (!out.has(id)) out.set(id, { id, name: null, state: null, start: null, end: null });
|
|
28
|
+
}
|
|
29
|
+
return [...out.values()].sort((a, b) => a.id - b.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CfListRow {
|
|
33
|
+
id: number; title: string; status: string | null; tags: string[];
|
|
34
|
+
authors: string; reviewers: string; committer: string; latestMail: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseList(h: string): CfListRow[] {
|
|
38
|
+
const rows: CfListRow[] = [];
|
|
39
|
+
for (const m of h.matchAll(/<tr>\s*<td class="wrap-text patch-column">([\s\S]*?)<\/tr>/g)) {
|
|
40
|
+
const body = m[1]!;
|
|
41
|
+
const pm = /<a href="\/patch\/(\d+)\/">([\s\S]*?)<\/a>/.exec(body);
|
|
42
|
+
if (!pm) continue;
|
|
43
|
+
const st = /<span class="badge[^"]*">([^<]+)<\/span>/.exec(body);
|
|
44
|
+
const tags = [...body.matchAll(/<span class="badge" style="[^"]*"(?: title="[^"]*")?>([^<]+)<\/span>/g)]
|
|
45
|
+
.map((t) => unescapeHtml(t[1]!));
|
|
46
|
+
const lm = /title="(\d{4}-\d\d-\d\d[ \d:]*)"/.exec(body);
|
|
47
|
+
const wraps = [...body.matchAll(/<td class="wrap-text">([\s\S]*?)<\/td>/g)].map((w) => stripTags(w[1]!));
|
|
48
|
+
rows.push({
|
|
49
|
+
id: Number(pm[1]), title: stripTags(pm[2]!), status: st ? st[1]!.trim() : null, tags,
|
|
50
|
+
authors: wraps[0] ?? "", reviewers: wraps[1] ?? "", committer: wraps[2] ?? "",
|
|
51
|
+
latestMail: lm ? lm[1]!.trim() : null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CfThread { msgid: string; subject: string; firstAt: string | null; firstBy: string; latestAt: string | null }
|
|
58
|
+
export interface CfPatch {
|
|
59
|
+
title: string; created: string | null;
|
|
60
|
+
statuses: { cf: number; cfName: string; status: string }[]; // as listed (newest first)
|
|
61
|
+
tags: string[]; targetVersion: string; authors: string; reviewers: string; committer: string;
|
|
62
|
+
threads: CfThread[]; history: { when: string | null; who: string; what: string }[]; otherLinks: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fields(h: string): Map<string, string> {
|
|
66
|
+
const out = new Map<string, string>();
|
|
67
|
+
for (const m of h.matchAll(/<th[^>]*>\s*([^<]+?)\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>\s*<\/tr>/g)) {
|
|
68
|
+
if (!out.has(m[1]!.trim())) out.set(m[1]!.trim(), m[2]!);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parsePatch(h: string): CfPatch | null {
|
|
74
|
+
if (!h.includes("<th>Title</th>")) return null;
|
|
75
|
+
const f = fields(h);
|
|
76
|
+
const statuses = [...(f.get("Status") ?? "").matchAll(
|
|
77
|
+
/<a href="\/(\d+)\/">([^<]+)<\/a>[^:<]*:\s*<span class="badge[^"]*">([^<]+)<\/span>/g,
|
|
78
|
+
)].map((m) => ({ cf: Number(m[1]), cfName: m[2]!.trim(), status: m[3]!.trim() }));
|
|
79
|
+
const tags = [...(f.get("Tags") ?? "").matchAll(/<span class="badge"[^>]*>([^<]+)<\/span>/g)]
|
|
80
|
+
.map((m) => unescapeHtml(m[1]!));
|
|
81
|
+
const reviewers = (f.get("Reviewers") ?? "").replace(/<a class="btn[\s\S]*?<\/a>/g, "");
|
|
82
|
+
const threads: CfThread[] = [];
|
|
83
|
+
for (const dm of (f.get("Emails") ?? "").matchAll(/<dt>([\s\S]*?)<\/dt>\s*<dd>([\s\S]*?)<\/dd>/g)) {
|
|
84
|
+
const a = /<a href="(https:\/\/www\.postgresql\.org\/message-id\/flat\/([^"]+))">([\s\S]*?)<\/a>/.exec(dm[1]!);
|
|
85
|
+
if (!a) continue;
|
|
86
|
+
const first = /First at <a[^>]*>([^<]+)<\/a> by ([^<]*?)(?:<br|$)/.exec(dm[2]!);
|
|
87
|
+
const latest = /Latest at <a[^>]*>([^<]+)<\/a>/.exec(dm[2]!);
|
|
88
|
+
threads.push({
|
|
89
|
+
msgid: unescapeHtml(a[2]!),
|
|
90
|
+
subject: stripTags(a[3]!),
|
|
91
|
+
firstAt: first ? normTs(first[1]) : null,
|
|
92
|
+
firstBy: first ? unescapeHtml(first[2]!).replace(/\s*<.*$/, "").trim() : "",
|
|
93
|
+
latestAt: latest ? normTs(latest[1]) : null,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const history: CfPatch["history"] = [];
|
|
97
|
+
const hm = /<th>History<\/th>([\s\S]*?)<\/table>/.exec(h);
|
|
98
|
+
if (hm) {
|
|
99
|
+
for (const r of hm[1]!.matchAll(/<tr>\s*<td[^>]*>([^<]+)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/g)) {
|
|
100
|
+
history.push({ when: normTs(r[1]), who: stripTags(r[2]!), what: stripTags(r[3]!) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
title: stripTags(f.get("Title") ?? ""),
|
|
105
|
+
created: normTs(stripTags(f.get("Created") ?? "")),
|
|
106
|
+
statuses, tags,
|
|
107
|
+
targetVersion: stripTags(f.get("Target version") ?? ""),
|
|
108
|
+
authors: stripTags(f.get("Authors") ?? f.get("Author") ?? ""),
|
|
109
|
+
reviewers: stripTags(reviewers),
|
|
110
|
+
committer: stripTags(f.get("Committer") ?? ""),
|
|
111
|
+
threads, history,
|
|
112
|
+
otherLinks: [...(f.get("Links") ?? "").matchAll(/href="(https?:\/\/[^"]+)"/g)].map((m) => unescapeHtml(m[1]!)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Final status = status in the highest CF id (the site lists newest first). */
|
|
117
|
+
export function finalStatus(p: CfPatch): string | null {
|
|
118
|
+
if (!p.statuses.length) return null;
|
|
119
|
+
return p.statuses.reduce((a, b) => (b.cf > a.cf ? b : a)).status;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function patchRecord(pid: number, p: CfPatch, final: string): PrecedentRecord {
|
|
123
|
+
let close: string | null = null;
|
|
124
|
+
for (const h of p.history) {
|
|
125
|
+
const m = /^Closed in commitfest (\S+) with status: (.+)$/.exec(h.what);
|
|
126
|
+
if (m && m[2]!.trim().toLowerCase() === final.toLowerCase() && h.when && (!close || h.when > close)) close = h.when;
|
|
127
|
+
}
|
|
128
|
+
const firsts = p.threads.map((t) => t.firstAt).filter((x): x is string => !!x).sort();
|
|
129
|
+
const firstDate = firsts[0] ?? p.created;
|
|
130
|
+
const subjects = p.threads.map((t) => t.subject).filter(Boolean);
|
|
131
|
+
let summary = p.title;
|
|
132
|
+
if (p.tags.length) summary += "\nTags: " + p.tags.join(", ");
|
|
133
|
+
if (subjects.length) summary += "\nThreads: " + subjects.join("; ");
|
|
134
|
+
const url = `${CF_BASE}/patch/${pid}/`;
|
|
135
|
+
return {
|
|
136
|
+
id: `cf:${pid}`, kind: "cf", rawRef: `patch/${pid}`,
|
|
137
|
+
title: p.title, status: final, date: close, firstDate, url,
|
|
138
|
+
threadUrls: [...new Set(p.threads.map((t) => flatUrl(t.msgid)))],
|
|
139
|
+
authors: splitPeople(p.authors), reviewers: splitPeople(p.reviewers), objectors: [],
|
|
140
|
+
tags: p.tags.join(", "), summary, objection: "",
|
|
141
|
+
touchPoints: touchPoints([p.title, ...p.tags, ...subjects].join(" ")),
|
|
142
|
+
otherLinks: p.otherLinks,
|
|
143
|
+
provenance: {
|
|
144
|
+
source: "commitfest patch page", cache_url: url, committer: p.committer,
|
|
145
|
+
target_version: p.targetVersion, cf_history: p.statuses,
|
|
146
|
+
decided_at: close, first_at: firstDate,
|
|
147
|
+
date_source: close ? "history 'Closed in commitfest' line" : null,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function listOnlyRecord(pid: number, cf: number, row: CfListRow): PrecedentRecord {
|
|
153
|
+
return {
|
|
154
|
+
id: `cf:${pid}`, kind: "cf", rawRef: `patch/${pid}`,
|
|
155
|
+
title: row.title, status: row.status ?? "", date: null, firstDate: null,
|
|
156
|
+
url: `${CF_BASE}/patch/${pid}/`, threadUrls: [],
|
|
157
|
+
authors: splitPeople(row.authors), reviewers: splitPeople(row.reviewers), objectors: [],
|
|
158
|
+
tags: row.tags.join(", "),
|
|
159
|
+
summary: row.title + (row.tags.length ? "\nTags: " + row.tags.join(", ") : ""),
|
|
160
|
+
objection: "", touchPoints: touchPoints(row.title + " " + row.tags.join(" ")), otherLinks: [],
|
|
161
|
+
provenance: { source: "commitfest list page only (patch page not fetched)", cache_url: `${CF_BASE}/${cf}/`, cf, latest_mail: row.latestMail },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function crawlCommitfest(opts: { limit?: number; offline?: boolean }) {
|
|
166
|
+
const arch = await fetchPage(CF_BASE + "/archive/", opts);
|
|
167
|
+
if (!arch) throw new Error("cannot read CommitFest archive page");
|
|
168
|
+
const cfs = parseArchive(arch);
|
|
169
|
+
const order: number[] = [];
|
|
170
|
+
const latestRow = new Map<number, { cf: number; row: CfListRow }>();
|
|
171
|
+
const cov: Record<string, unknown> = { commitfests: `${cfs[0]?.id}..${cfs.at(-1)?.id} (${cfs.length})` };
|
|
172
|
+
let listOk = 0;
|
|
173
|
+
const listMissing: string[] = [];
|
|
174
|
+
for (const st of [6, 7, 8]) { // Rejected first, then RwF, then Withdrawn
|
|
175
|
+
for (const cf of cfs) {
|
|
176
|
+
const url = `${CF_BASE}/${cf.id}/?status=${st}`;
|
|
177
|
+
const h = await fetchPage(url, opts);
|
|
178
|
+
if (h === null) { listMissing.push(url); continue; }
|
|
179
|
+
listOk++;
|
|
180
|
+
for (const row of parseList(h)) {
|
|
181
|
+
if (row.status !== CF_STATUSES[st]) continue; // defensive
|
|
182
|
+
if (!latestRow.has(row.id)) order.push(row.id);
|
|
183
|
+
const prev = latestRow.get(row.id);
|
|
184
|
+
if (!prev || cf.id > prev.cf) latestRow.set(row.id, { cf: cf.id, row });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
Object.assign(cov, { list_pages_ok: listOk, list_pages_missing: listMissing, unique_patches_in_lists: order.length });
|
|
189
|
+
log(`cf: ${listOk} list pages, ${order.length} unique patches`);
|
|
190
|
+
const todo = opts.limit ? order.slice(0, opts.limit) : order;
|
|
191
|
+
const recs: PrecedentRecord[] = [];
|
|
192
|
+
const c = { patch_pages_ok: 0, patch_pages_missing: 0, patch_pages_unparsed: 0, dropped_final_not_negative: 0, list_only_rows: 0 };
|
|
193
|
+
const finalElsewhere: Record<string, number> = {};
|
|
194
|
+
let n = 0;
|
|
195
|
+
for (const pid of todo) {
|
|
196
|
+
n++;
|
|
197
|
+
const url = `${CF_BASE}/patch/${pid}/`;
|
|
198
|
+
const wasCached = isCached(url);
|
|
199
|
+
const h = await fetchPage(url, opts);
|
|
200
|
+
if (!wasCached && n % 100 === 0) log(` patch pages: ${n}/${todo.length} ${JSON.stringify(fetchStats)}`);
|
|
201
|
+
const lr = latestRow.get(pid)!;
|
|
202
|
+
const p = h ? parsePatch(h) : null;
|
|
203
|
+
if (!p) {
|
|
204
|
+
if (h) { c.patch_pages_unparsed++; log(` patch ${pid}: page did not parse`); } else c.patch_pages_missing++;
|
|
205
|
+
c.list_only_rows++;
|
|
206
|
+
recs.push(listOnlyRecord(pid, lr.cf, lr.row));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
c.patch_pages_ok++;
|
|
210
|
+
const final = finalStatus(p) ?? lr.row.status ?? "";
|
|
211
|
+
if (!NEGATIVE.has(final.toLowerCase())) {
|
|
212
|
+
c.dropped_final_not_negative++;
|
|
213
|
+
finalElsewhere[final] = (finalElsewhere[final] ?? 0) + 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
recs.push(patchRecord(pid, p, CF_STATUSES[[6, 7, 8].find((k) => CF_STATUSES[k]!.toLowerCase() === final.toLowerCase())!]!));
|
|
217
|
+
}
|
|
218
|
+
Object.assign(cov, c, { final_status_of_dropped: finalElsewhere, skipped_by_limit: order.length - todo.length });
|
|
219
|
+
return { recs, cov, complete: !opts.limit && c.patch_pages_missing === 0 };
|
|
220
|
+
}
|