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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Render the proposal (CommitFest entry, visible thread, patch prose) as prompt text.
|
|
2
|
+
|
|
3
|
+
import type { ProposalInput, ThreadMessage } from "../thread/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface MaterialOptions {
|
|
6
|
+
/** Total character budget for message bodies. */
|
|
7
|
+
maxThreadChars?: number;
|
|
8
|
+
maxFirstMessageChars?: number;
|
|
9
|
+
maxMessageChars?: number;
|
|
10
|
+
/** Show only the first N visible messages (used for the synthesis prompt). */
|
|
11
|
+
maxMessages?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = { maxThreadChars: 150_000, maxFirstMessageChars: 30_000, maxMessageChars: 6_000 };
|
|
15
|
+
|
|
16
|
+
/** "Name <addr>" -> "Name". Addresses are of no use to the model. */
|
|
17
|
+
export function displayName(from: string): string {
|
|
18
|
+
return from.replace(/\s*<[^>]*>\s*$/, "").trim() || from;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Collapse long runs of quoted lines: replies quote the whole thread again and again. */
|
|
22
|
+
export function trimQuotes(body: string, keep = 3): string {
|
|
23
|
+
const out: string[] = [];
|
|
24
|
+
let run = 0;
|
|
25
|
+
let trimmed = false;
|
|
26
|
+
for (const line of body.split("\n")) {
|
|
27
|
+
if (/^\s*>/.test(line)) {
|
|
28
|
+
run++;
|
|
29
|
+
if (run <= keep) out.push(line);
|
|
30
|
+
else if (!trimmed) {
|
|
31
|
+
out.push("> [... quoted text trimmed ...]");
|
|
32
|
+
trimmed = true;
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
if (line.trim() !== "") {
|
|
36
|
+
run = 0;
|
|
37
|
+
trimmed = false;
|
|
38
|
+
}
|
|
39
|
+
out.push(line);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clip(s: string, max: number): string {
|
|
46
|
+
return s.length > max ? s.slice(0, max) + "\n[... message truncated ...]" : s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderMessage(m: ThreadMessage, body: string | null): string {
|
|
50
|
+
const head = `### Message #${m.index} | ${displayName(m.from)} | ${m.date.slice(0, 10)} | ${m.subject}`;
|
|
51
|
+
const atts = m.attachments.length ? `\nAttachments: ${m.attachments.map((a) => a.name).join(", ")}` : "";
|
|
52
|
+
return `${head}\n\n${body ?? "[omitted for length]"}${atts}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderMaterial(p: ProposalInput, opts: MaterialOptions = {}): { text: string; omitted: number } {
|
|
56
|
+
const o = { ...DEFAULTS, ...opts };
|
|
57
|
+
const parts: string[] = [];
|
|
58
|
+
|
|
59
|
+
if (p.cf) {
|
|
60
|
+
const cf = p.cf;
|
|
61
|
+
const lines = [`## CommitFest entry ${cf.id}`, `Title: ${cf.title}`, `Authors: ${cf.authors || "(not given)"}`];
|
|
62
|
+
if (cf.tags.length) lines.push(`Tags: ${cf.tags.join(", ")}`);
|
|
63
|
+
if (cf.topic) lines.push(`Topic: ${cf.topic}`);
|
|
64
|
+
if (cf.redacted) {
|
|
65
|
+
lines.push("Status, reviewers, committer and history: withheld (evaluation mode).");
|
|
66
|
+
} else {
|
|
67
|
+
lines.push(`Status: ${cf.status ?? "?"}${cf.statuses.length > 1 ? ` (in ${cf.statuses.length} CommitFests so far)` : ""}`);
|
|
68
|
+
if (cf.reviewers) lines.push(`Reviewers: ${cf.reviewers}`);
|
|
69
|
+
if (cf.committer) lines.push(`Committer: ${cf.committer}`);
|
|
70
|
+
if (cf.targetVersion) lines.push(`Target version: ${cf.targetVersion}`);
|
|
71
|
+
}
|
|
72
|
+
if (cf.history.length) {
|
|
73
|
+
lines.push("History:");
|
|
74
|
+
for (const h of [...cf.history].reverse().slice(-15)) lines.push(`- ${h.when.slice(0, 10)} ${h.who}: ${h.what}`);
|
|
75
|
+
}
|
|
76
|
+
parts.push(lines.join("\n"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const all = opts.maxMessages ? p.thread.messages.slice(0, opts.maxMessages) : p.thread.messages;
|
|
80
|
+
const mode: string[] = [];
|
|
81
|
+
if (p.filters.blind != null) mode.push(`blind mode: only the first ${p.filters.blind} messages are shown`);
|
|
82
|
+
if (p.filters.before) mode.push(`only messages before ${p.filters.before} are shown`);
|
|
83
|
+
const shownNote =
|
|
84
|
+
p.filters.blind != null || p.filters.before
|
|
85
|
+
? `${all.length} messages shown; ${mode.join("; ")}. The rest of the thread is hidden on purpose: judge the idea, do not guess how the discussion went on.`
|
|
86
|
+
: all.length < p.thread.messages.length
|
|
87
|
+
? `first ${all.length} of ${p.thread.messages.length} messages shown`
|
|
88
|
+
: `all ${all.length} messages shown`;
|
|
89
|
+
|
|
90
|
+
// Budget: message #1, then the next nine, then from the newest backwards.
|
|
91
|
+
const bodies = all.map((m, i) => clip(i === 0 ? m.body : trimQuotes(m.body), i === 0 ? o.maxFirstMessageChars : o.maxMessageChars));
|
|
92
|
+
const order = [...all.keys()].slice(0, 10).concat([...all.keys()].slice(10).reverse());
|
|
93
|
+
const keep = new Set<number>();
|
|
94
|
+
let used = 0;
|
|
95
|
+
for (const i of order) {
|
|
96
|
+
if (used + bodies[i]!.length > o.maxThreadChars && keep.size > 0) continue;
|
|
97
|
+
keep.add(i);
|
|
98
|
+
used += bodies[i]!.length;
|
|
99
|
+
}
|
|
100
|
+
const omitted = all.length - keep.size;
|
|
101
|
+
|
|
102
|
+
parts.push(
|
|
103
|
+
[
|
|
104
|
+
`## Thread: ${p.thread.subject}`,
|
|
105
|
+
`List: ${p.thread.lists.join(", ") || "?"}. Started ${p.threadStart.slice(0, 10)}. ${shownNote.replace(/\.$/, "")}${omitted ? `; ${omitted} long middle messages omitted for length` : ""}.`,
|
|
106
|
+
...all.map((m, i) => renderMessage(m, keep.has(i) ? bodies[i]! : null)),
|
|
107
|
+
].join("\n\n"),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (p.patchProse.length) {
|
|
111
|
+
const lines = ["## Patch prose (commit messages, touched files, documentation hunks; code is never shown)"];
|
|
112
|
+
for (const pp of p.patchProse) {
|
|
113
|
+
lines.push(`### ${pp.attachment.name} (attached to message #${pp.fromMessageIndex}, ${pp.attachment.sizeText})`);
|
|
114
|
+
for (const cm of pp.commitMessages) lines.push(`Commit message:\n${cm.subject}\n\n${cm.body}`.trim());
|
|
115
|
+
if (pp.files.length) lines.push(`Files touched:\n${pp.files.map((f) => `- ${f}`).join("\n")}`);
|
|
116
|
+
for (const h of pp.docHunks) lines.push(`Documentation hunk (${h.path}):\n${h.text}`);
|
|
117
|
+
if (pp.commitMessages.length === 0 && pp.docHunks.length === 0) lines.push("(no commit message and no documentation changes in this file)");
|
|
118
|
+
if (pp.truncated) lines.push("(prose truncated for length)");
|
|
119
|
+
}
|
|
120
|
+
parts.push(lines.join("\n\n"));
|
|
121
|
+
} else {
|
|
122
|
+
parts.push("## Patch prose\n\nNo readable patch attachment in the visible messages.");
|
|
123
|
+
}
|
|
124
|
+
return { text: parts.join("\n\n"), omitted };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** All text of the visible proposal, for the URL allow list and quote checks. */
|
|
128
|
+
export function materialRawText(p: ProposalInput): string {
|
|
129
|
+
return [
|
|
130
|
+
...p.thread.messages.map((m) => m.body),
|
|
131
|
+
...p.patchProse.flatMap((pp) => [...pp.commitMessages.map((c) => c.subject + "\n" + c.body), ...pp.docHunks.map((h) => h.text)]),
|
|
132
|
+
].join("\n\n");
|
|
133
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// `pgroast page`: the opt-in "full version" as one self-contained static HTML file, and the
|
|
2
|
+
// site layout a CommitFest app could include from. Rendered by code from roast.json: inline
|
|
3
|
+
// CSS, no scripts, no external requests. It only writes local files; where a page goes is a
|
|
4
|
+
// person's decision, and there is no uploading or publishing code here.
|
|
5
|
+
//
|
|
6
|
+
// Everything in roast.json is untrusted in an HTML context (model output, archive text,
|
|
7
|
+
// titles, links): every value goes through esc(), and a link is emitted only for http(s).
|
|
8
|
+
|
|
9
|
+
import { messageIdOfUrl } from "./precedents.ts";
|
|
10
|
+
import { LEVEL_NAMES, TOUCH_POINTS, type FinalObjection, type Roast, type ToneHit } from "./schema.ts";
|
|
11
|
+
import { lintRoast } from "./tone.ts";
|
|
12
|
+
|
|
13
|
+
export function esc(s: unknown): string {
|
|
14
|
+
return String(s ?? "")
|
|
15
|
+
.replace(/&/g, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/'/g, "'")
|
|
20
|
+
.replace(/`/g, "`");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An href only for plain http(s) URLs; anything else (javascript:, data:, ...) is not a link. */
|
|
24
|
+
export function safeUrl(url: string | null | undefined): string | null {
|
|
25
|
+
if (!url) return null;
|
|
26
|
+
const u = url.trim();
|
|
27
|
+
if (!/^https?:\/\/[^\s"'<>`]+$/i.test(u)) return null;
|
|
28
|
+
try {
|
|
29
|
+
const p = new URL(u);
|
|
30
|
+
return p.protocol === "http:" || p.protocol === "https:" ? u : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const a = (text: string, url: string | null | undefined): string => {
|
|
37
|
+
const href = safeUrl(url);
|
|
38
|
+
return href ? `<a href="${esc(href)}" rel="noopener noreferrer nofollow">${esc(text)}</a>` : esc(text);
|
|
39
|
+
};
|
|
40
|
+
const day = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "date unknown");
|
|
41
|
+
const p = (s: string) => (s.trim() ? `<p>${esc(s.trim())}</p>` : "");
|
|
42
|
+
const touchLabel = (id: string) => TOUCH_POINTS.find((t) => t.id === id)?.label ?? id.replace(/_/g, " ");
|
|
43
|
+
const titleOf = (r: Roast) => r.input.cf?.title ?? r.brief.title ?? r.input.subject;
|
|
44
|
+
|
|
45
|
+
const CSS = `
|
|
46
|
+
:root{color-scheme:light dark;--bg:#fff;--fg:#1c1e21;--muted:#5f6670;--line:#d9dde3;--card:#f6f7f9;--link:#0b57d0;--warn:#8a4b00;--bad:#a1260d;--ok:#1a6b34}
|
|
47
|
+
@media (prefers-color-scheme:dark){:root{--bg:#15171a;--fg:#e4e6ea;--muted:#9aa3ad;--line:#343a42;--card:#1e2126;--link:#8ab4f8;--warn:#f0b35a;--bad:#ff8f7a;--ok:#7bd69a}}
|
|
48
|
+
*{box-sizing:border-box}
|
|
49
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
50
|
+
main{max-width:52rem;margin:0 auto;padding:1.25rem 1rem 3rem}
|
|
51
|
+
h1{font-size:1.5rem;line-height:1.25;margin:.5rem 0 1rem}
|
|
52
|
+
h2{font-size:1.2rem;margin:2rem 0 .75rem;padding-top:.75rem;border-top:1px solid var(--line)}
|
|
53
|
+
h3{font-size:1.05rem;margin:0 0 .5rem}
|
|
54
|
+
a{color:var(--link);overflow-wrap:anywhere}
|
|
55
|
+
p{margin:.5rem 0}
|
|
56
|
+
ul{padding-left:1.25rem}
|
|
57
|
+
.note{border:1px solid var(--line);border-left:4px solid var(--warn);background:var(--card);padding:.6rem .8rem;border-radius:4px;margin:1rem 0}
|
|
58
|
+
.card{border:1px solid var(--line);background:var(--card);border-radius:6px;padding:.9rem 1rem;margin:1rem 0;overflow-wrap:anywhere}
|
|
59
|
+
.meta{color:var(--muted);font-size:.9rem}
|
|
60
|
+
.tag{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:0 .55rem;font-size:.8rem;margin-right:.3rem;white-space:nowrap}
|
|
61
|
+
.fatal{color:var(--bad);border-color:var(--bad)}
|
|
62
|
+
.verdict{font-size:1.15rem;font-weight:600}
|
|
63
|
+
dt{font-weight:600;margin-top:.6rem}
|
|
64
|
+
dd{margin:.15rem 0 0}
|
|
65
|
+
.scroll{overflow-x:auto}
|
|
66
|
+
table{border-collapse:collapse;width:100%;font-size:.95rem}
|
|
67
|
+
th,td{border-bottom:1px solid var(--line);text-align:left;padding:.35rem .5rem;vertical-align:top}
|
|
68
|
+
.yes{color:var(--warn);font-weight:600}
|
|
69
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
|
70
|
+
footer{margin-top:2rem}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
function objectionCard(r: Roast, o: FinalObjection, heading: string): string {
|
|
74
|
+
const advice = r.mode === "self" ? r.self_advice.find((x) => x.objection_id === o.id) : undefined;
|
|
75
|
+
const status =
|
|
76
|
+
o.status === "survived"
|
|
77
|
+
? "Held up in the counter-check."
|
|
78
|
+
: o.status === "refuted"
|
|
79
|
+
? `Refuted in the counter-check (${o.critic.refutation_kind.replace(/_/g, " ")}).`
|
|
80
|
+
: `Withdrawn in round ${o.withdrawal?.round ?? "?"}.`;
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
parts.push(`<section class="card" id="${esc(o.id)}">`);
|
|
83
|
+
parts.push(`<h3>${esc(heading)}${esc(o.title)}</h3>`);
|
|
84
|
+
parts.push(
|
|
85
|
+
`<p class="meta"><span class="tag${o.severity === "fatal" ? " fatal" : ""}">${esc(o.severity)}</span><span class="tag">${esc(LEVEL_NAMES[o.level] ?? o.level)}</span><span class="tag">${esc(touchLabel(o.category))}</span><span class="tag">angle: ${esc(o.angle)}</span>${o.round > 0 ? `<span class="tag">round ${esc(o.round)}</span>` : ""} <code>${esc(o.id)}</code></p>`,
|
|
86
|
+
);
|
|
87
|
+
if (advice) parts.push(`<p><strong>Before you send: ${advice.action === "fix" ? "fix it" : "answer it in the cover letter"}.</strong> ${esc(advice.how)}</p>`);
|
|
88
|
+
parts.push("<dl>");
|
|
89
|
+
parts.push(`<dt>What can go wrong</dt><dd>${esc(o.failure_scenario)}</dd>`);
|
|
90
|
+
parts.push(`<dt>Why it applies here</dt><dd>${esc(o.argument)}</dd>`);
|
|
91
|
+
if (o.precedents.length) {
|
|
92
|
+
parts.push(
|
|
93
|
+
`<dt>Earlier discussions</dt><dd><ul>${o.precedents
|
|
94
|
+
.map((x) => `<li>${a(x.title, x.url)} (${esc(x.status || "outcome unknown")}, ${esc(day(x.date))})${x.thread_urls.map((u, i) => ` ${a(x.thread_urls.length > 1 ? `thread ${i + 1}` : "thread", u)}`).join("")}</li>`)
|
|
95
|
+
.join("")}</ul></dd>`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (o.thread_refs.length) {
|
|
99
|
+
parts.push(`<dt>Thread messages</dt><dd>${o.thread_refs.map((t) => a(`#${t.index} (${t.from}, ${day(t.date)})`, t.url)).join(", ")}</dd>`);
|
|
100
|
+
}
|
|
101
|
+
parts.push(`<dt>Question for the author</dt><dd>${esc(o.author_must_show)}</dd>`);
|
|
102
|
+
parts.push(`<dt>Counter-check</dt><dd>${esc(status)} ${esc(o.withdrawal ? o.withdrawal.reason : o.critic.reasoning)}</dd>`);
|
|
103
|
+
if (o.status === "survived" && o.not_ranked_reason) parts.push(`<dt>Why it is not in the main list</dt><dd>${esc(o.not_ranked_reason)}</dd>`);
|
|
104
|
+
parts.push("</dl></section>");
|
|
105
|
+
return parts.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The full page: every objection, including the unlisted and the refuted ones. */
|
|
109
|
+
export function renderPage(r: Roast): string {
|
|
110
|
+
const self = r.mode === "self";
|
|
111
|
+
const title = `Design pre-review${self ? " (self check)" : ""}: ${titleOf(r)}`;
|
|
112
|
+
const tone: ToneHit[] = lintRoast(r).hits;
|
|
113
|
+
const out: string[] = [];
|
|
114
|
+
out.push("<!DOCTYPE html>");
|
|
115
|
+
out.push('<html lang="en"><head><meta charset="utf-8">');
|
|
116
|
+
out.push('<meta name="viewport" content="width=device-width, initial-scale=1">');
|
|
117
|
+
out.push('<meta name="robots" content="noindex">');
|
|
118
|
+
out.push(`<title>${esc(title)}</title>`);
|
|
119
|
+
out.push(`<style>${CSS}</style></head><body><main>`);
|
|
120
|
+
out.push(`<p class="note">${esc(r.disclosure)}</p>`);
|
|
121
|
+
out.push(`<h1>${esc(title)}</h1>`);
|
|
122
|
+
out.push(
|
|
123
|
+
"<p>This page lists every objection the check produced, including those that did not hold up, with the evidence for each. It looks at the idea and the design only; no code was read. It is one-sided on purpose: it looks only for objections.</p>",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
out.push('<ul class="meta">');
|
|
127
|
+
if (r.input.cf) out.push(`<li>CommitFest: ${a(`patch ${r.input.cf.id}`, r.input.cf.url)}${r.input.cf.status ? ` (${esc(r.input.cf.status)})` : ""}; authors: ${esc(r.input.cf.authors || "not given")}</li>`);
|
|
128
|
+
if (r.input.thread_url) out.push(`<li>Thread: ${a(r.input.subject, r.input.thread_url)}, started ${esc(day(r.input.thread_start))}; messages read: ${esc(r.input.messages_read)} of ${esc(r.input.messages_total)}</li>`);
|
|
129
|
+
else out.push(`<li>Draft, not sent: ${esc(r.input.arg)}</li>`);
|
|
130
|
+
out.push(`<li>Patch prose read: ${esc(r.input.attachments_read.length ? r.input.attachments_read.join(", ") : "none")} (commit message, file list and documentation hunks only)</li>`);
|
|
131
|
+
if (r.invariants_loaded.length) out.push(`<li>Subsystem invariants checked: ${esc(r.invariants_loaded.join(", "))}</li>`);
|
|
132
|
+
out.push("</ul>");
|
|
133
|
+
|
|
134
|
+
out.push("<h2>Pre-review verdict</h2>");
|
|
135
|
+
out.push(`<p class="verdict">${esc(r.verdict)} <span class="meta">(confidence: ${esc(r.confidence)})</span></p>`);
|
|
136
|
+
out.push(p(r.verdict_reasoning));
|
|
137
|
+
out.push(`<ul>${r.levels.map((l) => `<li>${esc(l.name)}: ${esc(l.status === "not_evaluated" ? "not evaluated" : l.status)}</li>`).join("")}<li>Levels 3-5 (implementation): out of scope</li></ul>`);
|
|
138
|
+
if (r.provisional_note.trim()) out.push(`<p class="meta">Provisional: ${esc(r.provisional_note.trim())}</p>`);
|
|
139
|
+
|
|
140
|
+
out.push("<h2>The proposal, restated</h2>");
|
|
141
|
+
out.push(`<dl><dt>Problem</dt><dd>${esc(r.brief.problem)}</dd><dt>Proposed design</dt><dd>${esc(r.brief.proposed_design)}</dd></dl>`);
|
|
142
|
+
|
|
143
|
+
out.push("<h2>Questions for the author</h2>");
|
|
144
|
+
out.push(r.questions.length ? `<ol>${r.questions.map((q) => `<li>${esc(q)}</li>`).join("")}</ol>` : "<p>None.</p>");
|
|
145
|
+
|
|
146
|
+
out.push("<h2>Earlier attempts</h2>");
|
|
147
|
+
if (r.precedent_backend === "none") out.push("<p>The precedent corpus was not available, so earlier attempts were not searched. This is not a finding of \"no precedent\".</p>");
|
|
148
|
+
else if (r.prior_attempts.length === 0) out.push(`<p>No precedent found (corpus searched for items before ${esc(r.precedent_cutoff)}).</p>`);
|
|
149
|
+
for (const x of r.prior_attempts) {
|
|
150
|
+
const rel = x.relation === "same_idea" ? "same idea" : x.relation === "similar_idea" ? "similar idea" : "related";
|
|
151
|
+
out.push(`<section class="card"><h3>${a(x.title, x.url)} <span class="meta">(${esc(rel)})</span></h3><dl>`);
|
|
152
|
+
out.push(`<dt>Outcome</dt><dd>${esc(x.status || "unknown")}, ${esc(x.date ? `decided ${day(x.date)}` : "decision date unknown")}</dd>`);
|
|
153
|
+
if (x.thread_urls.length) out.push(`<dt>Thread</dt><dd>${x.thread_urls.map((u) => a(u, u)).join(", ")}</dd>`);
|
|
154
|
+
out.push(`<dt>Who objected</dt><dd>${esc(x.who_objected.trim() || "not recorded in the corpus")}</dd>`);
|
|
155
|
+
if (x.reason.trim()) out.push(`<dt>Why it was not accepted${x.enriched ? " (AI summary of the linked thread; check it there)" : ""}</dt><dd>${esc(x.reason)}</dd>`);
|
|
156
|
+
out.push(`<dt>Quote</dt><dd>${x.quote ? `“${esc(x.quote)}”${safeUrl(x.quote_url) ? ` (${a("message", x.quote_url)})` : ""}` : "quote unavailable"}</dd>`);
|
|
157
|
+
out.push(`<dt>What is different this time</dt><dd>${esc(x.what_is_different)}</dd></dl></section>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
out.push(`<h2>${self ? "Objections to address before you send" : "Objections a reviewer is likely to raise"}</h2>`);
|
|
161
|
+
if (r.objections.length === 0) out.push("<p>No objection held up strongly enough to list here.</p>");
|
|
162
|
+
r.objections.forEach((o, i) => out.push(objectionCard(r, o, `${i + 1}. `)));
|
|
163
|
+
|
|
164
|
+
out.push("<h2>Objections that held up but are not in the main list</h2>");
|
|
165
|
+
if (r.other_surviving.length === 0) out.push("<p>None.</p>");
|
|
166
|
+
for (const o of r.other_surviving) out.push(objectionCard(r, o, ""));
|
|
167
|
+
|
|
168
|
+
out.push("<h2>Objections refuted in the counter-check</h2>");
|
|
169
|
+
out.push("<p>These were raised and then refuted. They are shown so that you can see what was considered and why it was dropped.</p>");
|
|
170
|
+
if (r.refuted.length === 0) out.push("<p>None.</p>");
|
|
171
|
+
for (const o of r.refuted) out.push(objectionCard(r, o, ""));
|
|
172
|
+
|
|
173
|
+
if (r.withdrawn.length) {
|
|
174
|
+
out.push("<h2>Objections withdrawn in a later round</h2>");
|
|
175
|
+
for (const o of r.withdrawn) out.push(objectionCard(r, o, ""));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
out.push("<h2>Touch points</h2>");
|
|
179
|
+
out.push(`<div class="scroll"><table><thead><tr><th>Touch point</th><th>Touched</th><th>Why</th></tr></thead><tbody>${r.touch_points
|
|
180
|
+
.map((t) => `<tr><td>${esc(t.label)}</td><td${t.hit ? ' class="yes"' : ""}>${t.hit ? "yes" : "no"}</td><td>${t.hit ? esc(t.reason) : ""}</td></tr>`)
|
|
181
|
+
.join("")}</tbody></table></div>`);
|
|
182
|
+
|
|
183
|
+
out.push("<h2>Committability</h2>");
|
|
184
|
+
out.push("<p>“Will not get committed” is not the same as “should be rejected”. The verdict is about the merits only; this section is about who would sign for the change, and it never changes the verdict.</p>");
|
|
185
|
+
out.push(`<dl><dt>Owner</dt><dd>${esc(r.committability.owner)}${r.committability.owner_note.trim() ? `. ${esc(r.committability.owner_note)}` : ""}</dd><dt>Liability</dt><dd>${esc(r.committability.liability)}</dd><dt>Author follow-up</dt><dd>${esc(r.committability.author_follow_up)}</dd>`);
|
|
186
|
+
out.push(`<dt>Not now / show demand</dt><dd>${r.not_now.applies ? `Yes. This is an outcome, not a rejection. Evidence that would change it: ${esc(r.not_now.evidence_that_would_change_it || "(not stated)")}` : "Does not apply."}</dd></dl>`);
|
|
187
|
+
|
|
188
|
+
out.push("<h2>Priority</h2>");
|
|
189
|
+
out.push(`<dl><dt>Recommended action</dt><dd>${esc(r.priority.recommended_action)}</dd><dt>Audience</dt><dd>${esc(r.priority.audience)}</dd><dt>Maintenance burden</dt><dd>${esc(r.priority.maintenance_burden)}</dd><dt>Review cost</dt><dd>${esc(r.priority.review_cost)}</dd><dt>Why</dt><dd>${esc(r.priority.reason)}</dd></dl>`);
|
|
190
|
+
|
|
191
|
+
out.push("<h2>How this was produced</h2>");
|
|
192
|
+
const s = r.sanitizer;
|
|
193
|
+
out.push('<ul class="meta">');
|
|
194
|
+
out.push(`<li>Generated ${esc(r.generated_at)} by ${esc(r.tool)}; ${esc(r.backend)} CLI; models: ${esc(r.models.roaster)}, ${esc(r.models.critic)}, ${esc(r.models.synth)}; the models had no tools</li>`);
|
|
195
|
+
out.push(`<li>Model calls: ${esc(r.stats.llm_calls)}; wall time: ${esc(Math.round(r.stats.wall_ms / 1000))} s${r.stats.dry_run ? "; DRY RUN (no model was called; content is placeholder)" : ""}</li>`);
|
|
196
|
+
out.push(`<li>Review angles: ${r.roaster_notes.map((n) => `${esc(n.angle)} (${esc(n.objections)})`).join(", ")}</li>`);
|
|
197
|
+
if (r.rounds_requested > 0) out.push(`<li>Extra rounds: ${r.rounds.slice(1).map((x) => `round ${esc(x.round)}: ${esc(x.new_objections)} new, ${esc(x.new_survived)} held up, ${esc(x.revised)} restated, ${esc(x.withdrawn)} withdrawn`).join("; ") || "none run"}</li>`);
|
|
198
|
+
out.push(`<li>Subsystem invariants: ${esc(r.invariants_loaded.length ? r.invariants_loaded.join(", ") : "none loaded")}</li>`);
|
|
199
|
+
out.push(`<li>Earlier discussions retrieved: ${esc(r.precedents_retrieved.length)} (search cutoff ${esc(r.precedent_cutoff)}; corpus: ${esc(r.precedent_backend === "none" ? "not available" : "searched")})</li>`);
|
|
200
|
+
out.push(`<li>Link and quote checks: ${esc(s.urls_removed.length)} link(s) removed, ${esc(s.precedent_ids_dropped.length)} unknown reference(s) dropped, ${esc(s.quotes_dropped.length)} unverifiable quote(s) dropped</li>`);
|
|
201
|
+
out.push(`<li>Tone check: ${esc(tone.length)} finding(s)${tone.length ? `: ${tone.map((h) => `<code>${esc(h.path)}</code> (${esc(h.rule)})`).join(", ")}` : ""}</li>`);
|
|
202
|
+
for (const w of r.warnings) out.push(`<li>Warning: ${esc(w)}</li>`);
|
|
203
|
+
out.push("</ul>");
|
|
204
|
+
|
|
205
|
+
out.push(`<footer><p class="note">${esc(r.disclosure)}</p></footer>`);
|
|
206
|
+
out.push("</main></body></html>");
|
|
207
|
+
return out.join("\n") + "\n";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Where the full-page link of a fragment points until a person fills in the real base URL. */
|
|
211
|
+
export const BASE_URL_PLACEHOLDER = "{{PGROAST_BASE_URL}}";
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The embeddable card: no <html>/<head>, class names with a `pgroast-` prefix, inline styles
|
|
215
|
+
* only, no scripts. Colours are inherited from the host page.
|
|
216
|
+
*/
|
|
217
|
+
export function renderFragment(r: Roast, fullHref: string): string {
|
|
218
|
+
const box = "border:1px solid rgba(128,128,128,.45);border-radius:6px;padding:12px 14px;margin:8px 0;font:14px/1.5 system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;max-width:46em;overflow-wrap:anywhere";
|
|
219
|
+
const small = "margin:6px 0 0;font-size:12px;opacity:.8";
|
|
220
|
+
const qs = r.questions.slice(0, 3);
|
|
221
|
+
// The placeholder is kept as is; any other value must be a plain http(s) URL or a relative path.
|
|
222
|
+
const href = fullHref.startsWith(BASE_URL_PLACEHOLDER) || safeUrl(fullHref) || /^[A-Za-z0-9._~\/-]+$/.test(fullHref) ? fullHref : "";
|
|
223
|
+
return [
|
|
224
|
+
`<div class="pgroast-card" style="${box}">`,
|
|
225
|
+
`<p class="pgroast-disclosure" style="${small};margin-top:0">${esc(r.disclosure)}</p>`,
|
|
226
|
+
`<p class="pgroast-title" style="margin:6px 0;font-weight:600">Design pre-review: ${esc(titleOf(r))}</p>`,
|
|
227
|
+
`<p class="pgroast-verdict" style="margin:6px 0">Pre-review verdict: <strong>${esc(r.verdict)}</strong> (confidence: ${esc(r.confidence)})</p>`,
|
|
228
|
+
qs.length ? `<p class="pgroast-questions-label" style="margin:8px 0 2px">Questions for the author:</p>\n<ol class="pgroast-questions" style="margin:0;padding-left:20px">${qs.map((q) => `<li class="pgroast-question">${esc(q)}</li>`).join("")}</ol>` : "",
|
|
229
|
+
`<p class="pgroast-priority" style="margin:8px 0 0">Priority: ${esc(r.priority.recommended_action)}; audience: ${esc(r.priority.audience)}; maintenance burden: ${esc(r.priority.maintenance_burden)}; review cost: ${esc(r.priority.review_cost)}</p>`,
|
|
230
|
+
href ? `<p class="pgroast-full" style="margin:8px 0 0"><a class="pgroast-full-link" href="${esc(href)}" rel="noopener noreferrer nofollow">Full version, with every objection and its evidence</a></p>` : "",
|
|
231
|
+
`<p class="pgroast-generated" style="${small}">Idea and design only; no code was read. Generated ${esc(day(r.generated_at))}.</p>`,
|
|
232
|
+
"</div>",
|
|
233
|
+
]
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.join("\n") + "\n";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface SiteFile {
|
|
239
|
+
/** Path relative to the site directory. */
|
|
240
|
+
path: string;
|
|
241
|
+
content: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** URL-safe name for a message-id: anything outside [A-Za-z0-9._-] becomes "_". */
|
|
245
|
+
export const safeMessageId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 180);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The stable layout: cf/<id>.html (fragment), cf/<id>.json, cf/<id>/full.html; a roast of a
|
|
249
|
+
* thread without a CommitFest entry goes under msg/<message-id>. No index is written: an
|
|
250
|
+
* entry without a review is simply absent (a 404 on any static host).
|
|
251
|
+
*/
|
|
252
|
+
export function siteFiles(r: Roast, baseUrl: string = BASE_URL_PLACEHOLDER): SiteFile[] {
|
|
253
|
+
let stem: string;
|
|
254
|
+
if (r.input.cf && Number.isInteger(r.input.cf.id) && r.input.cf.id > 0) stem = `cf/${r.input.cf.id}`;
|
|
255
|
+
else {
|
|
256
|
+
const mid = r.input.thread_url ? messageIdOfUrl(r.input.thread_url) : null;
|
|
257
|
+
if (!mid) throw new Error("this roast has neither a CommitFest id nor a thread: nothing to place in a site layout");
|
|
258
|
+
stem = `msg/${safeMessageId(mid)}`;
|
|
259
|
+
}
|
|
260
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
261
|
+
return [
|
|
262
|
+
{ path: `${stem}.html`, content: renderFragment(r, `${base}/${stem}/full.html`) },
|
|
263
|
+
{ path: `${stem}.json`, content: JSON.stringify(r, null, 2) + "\n" },
|
|
264
|
+
{ path: `${stem}/full.html`, content: renderPage(r) },
|
|
265
|
+
];
|
|
266
|
+
}
|