@computesdk/pi-loops 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/lib/judge.ts ADDED
@@ -0,0 +1,193 @@
1
+ // Judging math from the POC. Bench numbers and review verdicts come from the judge agent (extensions/loop.ts); the visual panel's
2
+ // model calls go through the injected `ask(content) -> parsed JSON` (a one-shot agent session, a stub in tests). Pure otherwise.
3
+
4
+ import type { Metric } from "./measure.ts";
5
+
6
+ /** One attempt as the judge agent measured it. */
7
+ export type Attempt = {
8
+ label: string;
9
+ bench?: { passed?: boolean; tests?: string; value?: number; error?: string };
10
+ review?: { ok?: boolean; note?: string };
11
+ vetoed?: boolean;
12
+ };
13
+
14
+ /** A bench score; `null`/`undefined` when the bench produced none. */
15
+ export type Score = number | null | undefined;
16
+ /** One candidate as the visual judge sees it: screenshots, and/or its code change as a diff. */
17
+ export type Candidate = { label: string; shots?: string[]; text?: string };
18
+ /** A part of a judge prompt: text, or whatever `img` makes of an image file. */
19
+ export type Part = { type: string; text?: string; [key: string]: unknown };
20
+ /** One judge model call: prompt parts in, parsed JSON answer out. */
21
+ export type Ask = (content: Part[]) => Promise<any>;
22
+ /** The rubric as the judge uses it: each criterion's name, its share of 100, and the text. */
23
+ export type JudgeCriterion = { name: string; pct: number; description?: string; check?: string };
24
+ export type JudgeRubric = { criteria: JudgeCriterion[] };
25
+ /** What every judge prompt is told: the subject, the goal, the rules, the rubric in force. */
26
+ export type JudgeBrief = { subject: string; goal: string; rules: string[]; rubric?: JudgeRubric };
27
+ export interface JudgeOptions extends JudgeBrief {
28
+ ask: Ask;
29
+ /** One `{ name, ask }` per council model; without it the judge scores alone. */
30
+ panel?: { name: string; ask: Ask }[];
31
+ img: (file: string) => Part;
32
+ candidates: Candidate[];
33
+ samples?: number;
34
+ pairs?: number;
35
+ scoreSlack?: number;
36
+ }
37
+ /** One candidate's head-to-head record against CURRENT. */
38
+ export type Vote = { wins: number; losses: number; ties: number; votes: number; why?: any; decidedBy?: string[] };
39
+ /** One model's answer to a compare call. */
40
+ export type Comparison = { winner: string | null; why: any; criterion: any };
41
+ /** The bench verdict: a score and a reason per label, the winner, and one line of why. */
42
+ export type BenchVerdict = { scores: Record<string, Score>; reasons: Record<string, string>; pick: string; why: string };
43
+ /** The panel verdict. */
44
+ export type PanelResult = {
45
+ scores: Record<string, number>;
46
+ reasons: Record<string, string>;
47
+ spread: Record<string, number>;
48
+ pick: string;
49
+ why: string;
50
+ h2h: Record<string, Vote>;
51
+ samples: number;
52
+ rubric?: Record<string, { scores: Record<string, number>; total: number }>;
53
+ byModel?: Record<string, Record<string, number>>;
54
+ };
55
+
56
+ const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
57
+ const round1 = (x: number) => Math.round(x * 10) / 10;
58
+ /** A missing score as a number, so it can be compared and sorted (NaN loses every comparison). */
59
+ const num = (v: Score) => v ?? NaN;
60
+
61
+ /* ---------- bench pick: check gate, benchmark scores, judge veto, minGain ---------- */
62
+ /**
63
+ * attempts: [{ label, bench: { passed, tests, value?, error? }, review?: { ok, note } }] as the judge agent measured them; cur: { value } (CURRENT re-measured by the same judge).
64
+ * Returns { scores, reasons, pick, why }.
65
+ */
66
+ export function pickWinner({ cur, attempts, metric = {}, minGain = 0.05 }: { cur: { value?: number | null }; attempts: Attempt[]; metric?: Metric; minGain?: number }): BenchVerdict {
67
+ const better = metric.better === "higher" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b;
68
+ const gain = metric.better === "higher" ? (cur: number, v: Score) => Number(v) / cur : (cur: number, v: Score) => cur / Number(v);
69
+ const unit = metric.unit ?? "";
70
+ const scores: Record<string, Score> = {}, reasons: Record<string, string> = {};
71
+ scores.CURRENT = cur.value; reasons.CURRENT = `${cur.value} ${unit} re-measured`;
72
+ for (const a of attempts) {
73
+ const m = a.bench ?? {};
74
+ if (!m.passed) { reasons[a.label] = "tests fail: " + ((m.tests ?? "").split("\n").find((l) => /FAIL|failed|Error/.test(l)) || m.tests || "").slice(0, 120); continue; }
75
+ if (m.error) { reasons[a.label] = m.error.slice(0, 120); continue; }
76
+ if (a.review && !a.review.ok) { reasons[a.label] = "vetoed: " + String(a.review.note); a.vetoed = true; continue; }
77
+ scores[a.label] = m.value;
78
+ const x = cur.value == null ? NaN : gain(cur.value, m.value);
79
+ reasons[a.label] = `${m.value} ${unit}${Number.isFinite(x) ? ` (${x >= 1 ? x.toFixed(2) + "× better" : (1 / x).toFixed(2) + "× worse"})` : ""}${a.review?.note ? ` · ${a.review.note}` : ""}`;
80
+ }
81
+ const ok = attempts.filter((a) => scores[a.label] != null).sort((x, y) => (better(num(scores[x.label]), num(scores[y.label])) ? -1 : 1));
82
+ const top = ok[0];
83
+ const wins = !!top && (cur.value == null || better(num(scores[top.label]), cur.value * (metric.better === "higher" ? 1 + minGain : 1 - minGain)));
84
+ const pick = wins && top ? top.label : "CURRENT";
85
+ const failed = attempts.length - ok.length;
86
+ const why = wins && top
87
+ ? `${top.label} is best at ${scores[top.label]} ${unit} vs ${cur.value} ${unit} now (${gain(Number(cur.value), scores[top.label]).toFixed(2)}×); tests pass${top.review?.note ? `; review: ${top.review.note}` : ""}.${failed ? ` ${failed} attempt(s) failed tests or were vetoed.` : ""}`
88
+ : top ? `Best attempt (${top.label}, ${scores[top.label]} ${unit}) is not >${minGain * 100}% better than ${cur.value} ${unit}.` : `No attempt passed tests and review.`;
89
+ return { scores, reasons, pick, why };
90
+ }
91
+
92
+ /* ---------- visual panel: scores (median of samples) + head-to-head votes against CURRENT ---------- */
93
+ const header = (o: JudgeBrief) => `${o.subject}.\nGoal: ${o.goal}\nRules (weigh these heavily):\n${o.rules.map((r) => "- " + r).join("\n")}\n` +
94
+ (o.rubric ? `\nRubric (the council wrote it; judge against these criteria and nothing else, each weighted by its share of 100):\n${o.rubric.criteria.map((c) => `- ${c.name} (${c.pct}%): ${c.description}${c.check ? ` Check: ${c.check}` : ""}`).join("\n")}\n` : "");
95
+ const scoreShape = (o: JudgeBrief) => o.rubric
96
+ ? `Return JSON: {"scores":{"<label>":number},"criteria":{"<label>":{"<criterion name>":0-10}},"reasons":{"<label>":"<=15 words: the criterion that most drove the score, and why"}}`
97
+ : `Return JSON: {"scores":{"<label>":number},"reasons":{"<label>":"<=15 words, main strength or flaw"}}`;
98
+ const compareShape = (o: JudgeBrief) => o.rubric
99
+ ? `Return JSON: {"better":"1"|"2"|"equal","criterion":"<the rubric criterion that decided it>","why":"<=20 words, citing that criterion"}`
100
+ : `Return JSON: {"better":"1"|"2"|"equal","why":"<=20 words"}`;
101
+ const cites = (why: any, criterion: any) => (criterion && why && !why.toLowerCase().includes(String(criterion).toLowerCase().slice(0, 12)) ? `${criterion}: ${why}` : why);
102
+ const shotsOf = (o: { img: (file: string) => Part }, c: Candidate): Part[] => [...(c.shots ?? []).map((f) => o.img(f)), ...(c.text ? [{ type: "text", text: c.text }] : [])];
103
+
104
+ async function scoreOnce(o: JudgeOptions) {
105
+ const content: Part[] = [{ type: "text", text: `You are the judge in a hill-climbing loop improving ${header(o)}\nCandidates follow, each with screenshot(s) and/or its code change as a diff against CURRENT. CURRENT is the best version so far. Score every candidate 0-10 against the goal and rules; be strict and consistent.\n${scoreShape(o)}` }];
106
+ for (const c of o.candidates) { content.push({ type: "text", text: `--- ${c.label}` }, ...shotsOf(o, c)); }
107
+ return o.ask(content);
108
+ }
109
+ async function compareOnce(o: JudgeOptions, first: Candidate, second: Candidate): Promise<Comparison> {
110
+ const content: Part[] = [{ type: "text", text: `You compare two versions of ${header(o)}\nTwo versions follow, "1" and "2" (the order is random). Which one better achieves the goal under the rules? If they're genuinely equal, say so.\n${compareShape(o)}` }];
111
+ for (const [i, c] of [first, second].entries()) content.push({ type: "text", text: `--- Version ${i + 1}` }, ...shotsOf(o, c));
112
+ const r = await o.ask(content);
113
+ return { winner: r.better === "1" ? first.label : r.better === "2" ? second.label : null, why: cites(r.why, o.rubric && r.criterion), criterion: o.rubric ? r.criterion : undefined };
114
+ }
115
+ /** votes for `cand` vs `cur`: both orders, `pairs` times each; a close first batch gets a second. */
116
+ export async function headToHead(o: JudgeOptions, cand: Candidate, cur: Candidate): Promise<Vote> {
117
+ const batch = async (): Promise<Comparison[]> => {
118
+ const jobs: Promise<Comparison>[] = [];
119
+ for (let i = 0; i < (o.pairs ?? 2); i++) jobs.push(compareOnce(o, cand, cur), compareOnce(o, cur, cand));
120
+ return (await Promise.allSettled(jobs)).filter((v): v is PromiseFulfilledResult<Comparison> => v.status === "fulfilled").map((v) => v.value);
121
+ };
122
+ let votes = await batch();
123
+ const w0 = votes.filter((v) => v.winner === cand.label).length, l0 = votes.filter((v) => v.winner === cur.label).length;
124
+ if (w0 > 0 && l0 > 0) votes = votes.concat(await batch());
125
+ const wins = votes.filter((v) => v.winner === cand.label).length, losses = votes.filter((v) => v.winner === cur.label).length;
126
+ const lead = votes.find((v) => v.winner === cand.label) ?? votes[0];
127
+ const decidedBy = o.rubric ? [...votes.reduce((m, v) => (v.criterion ? m.set(v.criterion, (m.get(v.criterion) ?? 0) + 1) : m), new Map<string, number>())].sort((a, b) => b[1] - a[1]).map(([c]) => c) : undefined;
128
+ return { wins, losses, ties: votes.length - wins - losses, votes: votes.length, why: lead?.why, ...(decidedBy ? { decidedBy } : {}) };
129
+ }
130
+ /**
131
+ * o: { ask, panel?: [{ name, ask }], img, subject, goal, rules, rubric?, candidates: [{label, shots?: [file], text?: diff}] incl. "CURRENT", samples?=3, pairs?=2, scoreSlack?=0.3 }
132
+ * Returns { scores, reasons, spread, pick, why, h2h, samples, rubric? }. A candidate wins only with a head-to-head majority
133
+ * (wins > 2×losses and ≥ half the votes) and a score not below CURRENT's (or a sweep within `scoreSlack`).
134
+ */
135
+ export async function panelJudge(o: JudgeOptions): Promise<PanelResult> {
136
+ const n = o.samples ?? 3;
137
+ // scoring: the panel (one `{ name, ask }` per council model, labels blind) or the judge alone; `samples` calls spread over the panel
138
+ const panel = o.panel?.length ? o.panel : [{ name: "judge", ask: o.ask }];
139
+ const per = Math.max(1, Math.ceil(n / panel.length));
140
+ const settled = await Promise.allSettled(panel.flatMap((m) => Array.from({ length: per }, () => scoreOnce({ ...o, ask: m.ask }).then((r) => ({ ...r, by: m.name })))));
141
+ const runs = settled.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled").map((r) => r.value);
142
+ if (!runs.length) throw new Error(`judge failed (all samples): ${[...new Set(settled.map((s) => String(s.status === "rejected" ? s.reason?.message ?? s.reason : "unknown").slice(0, 120)))].join(" | ")}`);
143
+ const labels = o.candidates.map((c) => c.label);
144
+ const scores: Record<string, number> = {}, spread: Record<string, number> = {}, reasons: Record<string, string> = {};
145
+ for (const l of labels) {
146
+ const xs = runs.map((r) => Number(r.scores?.[l])).filter(Number.isFinite);
147
+ if (!xs.length) continue;
148
+ scores[l] = round1(median(xs));
149
+ spread[l] = round1(Math.max(...xs) - Math.min(...xs));
150
+ const near = runs.reduce((a, r) => (Math.abs(r.scores?.[l] - scores[l]) < Math.abs(a.scores?.[l] - scores[l]) ? r : a));
151
+ reasons[l] = near.reasons?.[l];
152
+ }
153
+ let byCriterion: PanelResult["rubric"];
154
+ if (o.rubric) {
155
+ byCriterion = {};
156
+ for (const l of labels) {
157
+ const per: Record<string, number> = {};
158
+ for (const c of o.rubric.criteria) {
159
+ const xs = runs.map((r) => Number(r.criteria?.[l]?.[c.name])).filter(Number.isFinite);
160
+ if (xs.length) per[c.name] = round1(median(xs));
161
+ }
162
+ if (!Object.keys(per).length) continue;
163
+ const covered = o.rubric.criteria.filter((c) => per[c.name] != null);
164
+ byCriterion[l] = { scores: per, total: round1(covered.reduce((s, c) => s + per[c.name] * c.pct, 0) / Math.max(1, covered.reduce((s, c) => s + c.pct, 0))) };
165
+ }
166
+ }
167
+ let byModel: PanelResult["byModel"];
168
+ if (o.panel?.length) {
169
+ byModel = {};
170
+ for (const l of labels) {
171
+ const m: Record<string, number[]> = {};
172
+ for (const r of runs) { const x = Number(r.scores?.[l]); if (Number.isFinite(x)) (m[r.by] ??= []).push(x); }
173
+ if (Object.keys(m).length) byModel[l] = Object.fromEntries(Object.entries(m).map(([k, xs]) => [k, round1(median(xs))]));
174
+ }
175
+ }
176
+ const byLabel = new Map(o.candidates.map((c) => [c.label, c]));
177
+ const cur = o.candidates.find((c) => c.label === "CURRENT");
178
+ const ranked = labels.filter((l) => l !== "CURRENT" && scores[l] != null).sort((a, b) => scores[b] - scores[a]).slice(0, 2);
179
+ const h2h: Record<string, Vote> = {};
180
+ await Promise.all(ranked.map(async (l) => { const c = byLabel.get(l); h2h[l] = cur && c ? await headToHead(o, c, cur) : { wins: 1, losses: 0, ties: 0, votes: 1 }; }));
181
+ const rate = (l: string) => h2h[l].wins / Math.max(1, h2h[l].votes);
182
+ const qualifies = (h: Vote) => h.wins > 2 * h.losses && h.wins * 2 >= h.votes;
183
+ const sweep = (h: Vote) => h.losses === 0 && h.wins >= 4;
184
+ const scoreOk = (l: string) => scores.CURRENT == null || scores[l] >= scores.CURRENT || (sweep(h2h[l]) && scores[l] >= scores.CURRENT - (o.scoreSlack ?? 0.3));
185
+ const winners = ranked.filter((l) => qualifies(h2h[l]) && scoreOk(l)).sort((a, b) => rate(b) - rate(a) || scores[b] - scores[a]);
186
+ const pick = winners[0] ?? "CURRENT";
187
+ const v = (l: string) => `${h2h[l].wins}–${h2h[l].losses}${h2h[l].ties ? `, ${h2h[l].ties} even` : ""}`;
188
+ const why = pick !== "CURRENT"
189
+ ? `${pick} beat the current best head-to-head (${v(pick)} of ${h2h[pick].votes} votes${scores.CURRENT != null && scores[pick] < scores.CURRENT ? `; its score ${scores[pick]} is within noise of ${scores.CURRENT}` : ""}): ${h2h[pick].why ?? reasons[pick]}`
190
+ : ranked.some((l) => qualifies(h2h[l])) ? `Head-to-head favoured ${ranked.filter((l) => qualifies(h2h[l])).join(", ")}, but its score was clearly below the current best's (${scores.CURRENT}), so the signals disagree; kept the current best.`
191
+ : ranked.length ? `Nothing clearly beat the current best head-to-head (${ranked.map((l) => `${l} ${v(l)}`).join("; ")}).` : "No candidates.";
192
+ return { scores, reasons, spread, pick, why, h2h, samples: runs.length, ...(byCriterion ? { rubric: byCriterion } : {}), ...(byModel ? { byModel } : {}) };
193
+ }
package/lib/measure.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Git helpers around attempts: shell runner, protected-file restore, diff. `git` is injectable for tests.
2
+ import { spawn, execFileSync } from "node:child_process";
3
+
4
+ export const sh = (cwd: string, cmd: string, ms = 180_000) => new Promise<{ code: number | null; out: string }>((resolve) => {
5
+ const p = spawn("bash", ["-lc", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"] });
6
+ let out = "";
7
+ p.stdout.on("data", (d) => (out += d)); p.stderr.on("data", (d) => (out += d));
8
+ const t = setTimeout(() => p.kill("SIGKILL"), ms);
9
+ p.on("close", (code) => { clearTimeout(t); resolve({ code, out: out.trim() }); });
10
+ });
11
+ /** `git` is injectable so tests can stub it out. */
12
+ export type GitRunner = (cwd: string, ...args: string[]) => string;
13
+ export const git: GitRunner = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
14
+ /** A thrown value's message: `catch (e)` hands you `unknown`, and anything at all can be thrown. */
15
+ export const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
16
+ /** The patch between two commits for a judge: stat + body, cut at `max` chars; a patch too big even for the buffer falls back to the stat alone (never "unavailable"). */
17
+ export function diffText(cwd: string, base: string, commit: string, max = 60_000) {
18
+ const run = (...a: string[]) => execFileSync("git", ["diff", ...a, `${base}..${commit}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
19
+ let d;
20
+ try { d = run("--stat", "-p"); } catch { try { d = run("--stat") + "\n… (patch too large to show; stat only)"; } catch (e) { return `(diff unavailable: ${errText(e).slice(0, 80)})`; } }
21
+ d = d.trim();
22
+ return d.length > max ? d.slice(0, max) + "\n… (diff truncated)" : d || "(no change)";
23
+ }
24
+
25
+ export function restoreProtected({ cwd, protect = [], baseCommit, git: g = git }: { cwd: string; protect?: string[]; baseCommit: string; git?: GitRunner }) {
26
+ if (!protect.length) return false;
27
+ g(cwd, "checkout", baseCommit, "--", ...protect);
28
+ try { g(cwd, "commit", "-q", "-m", "restore protected files", "--", ...protect); return true; } catch { return false; }
29
+ }
30
+
31
+ /** The diff the judge sees: attempt vs the version it started from, protected files excluded. */
32
+ export const diffFor = ({ cwd, from, to = "HEAD", protect = [], git: g = git, max = 30000 }: { cwd: string; from: string; to?: string; protect?: string[]; git?: GitRunner; max?: number }) =>
33
+ g(cwd, "diff", from, to, "--", ".", ...protect.map((f) => `:!${f}`)).slice(0, max);
34
+
35
+ /** Which direction counts as better for a metric. */
36
+ export type Metric = { key?: string; unit?: string; better?: "higher" | "lower" };
37
+
38
+ export const better = (metric: Metric = {}) => (metric.better === "higher" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b);
package/lib/rubric.ts ADDED
@@ -0,0 +1,159 @@
1
+ // The judging rubric: written by the council before round 1, extended by the human's `rules`.
2
+ // Pure data + prompts; `ask(role, prompt)` is injected (one-shot agent session in the extension, a stub in tests).
3
+
4
+ /** A criterion as proposed by a council member, a rule, or a human; `normalise` fills in the rest. */
5
+ export type CriterionInput = { name?: unknown; weight?: unknown; description?: unknown; check?: unknown; source?: string; was?: Criterion };
6
+ /** A criterion after `normalise`: text cleaned, weight clamped, `pct` set so the column sums to 100. */
7
+ export type Criterion = { name: string; weight: number; description: string; check: string; source: string; pct: number; was?: Criterion };
8
+ export type RubricEntry = { t: number; kind: string } & Record<string, unknown>;
9
+ export type Rubric = { v: number; criteria: Criterion[]; summary: string; source: string; members?: number; history: RubricEntry[] };
10
+ /** What the council is told: the goal, the rules to cover, and the rubric in force. */
11
+ export type RubricContext = { goal: string; kind?: string; rules?: string[]; subject?: string; history?: string; current?: Criterion[] };
12
+
13
+ const WEIGHT_RANGE: [number, number] = [1, 5];
14
+ const clamp = (x: unknown, [lo, hi]: [number, number]) => (Number.isFinite(Number(x)) ? Math.min(hi, Math.max(lo, Math.round(Number(x)))) : hi);
15
+ const trim = (s: unknown, n: number) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
16
+ const key = (s: unknown) => trim(s, 80).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
17
+ const entry = (kind: string, extra: Record<string, unknown>): RubricEntry => ({ t: Date.now(), kind, ...extra });
18
+
19
+ /** JSON from a model answer: the whole text, else the fenced block, else the first balanced {…} / […]. */
20
+ export function parseJson(text: unknown): any {
21
+ let s = String(text ?? "").trim();
22
+ const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
23
+ if (fence) s = fence[1].trim();
24
+ try { return JSON.parse(s); } catch {}
25
+ const start = s.search(/[{[]/);
26
+ if (start < 0) throw new Error(`no JSON in model answer: ${s.slice(0, 120)}`);
27
+ const open = s[start], close = open === "{" ? "}" : "]";
28
+ let depth = 0, inStr = false, esc = false;
29
+ for (let i = start; i < s.length; i++) {
30
+ const c = s[i];
31
+ if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; }
32
+ if (c === '"') inStr = true;
33
+ else if (c === "{" || c === "[") depth++;
34
+ else if (c === "}" || c === "]") { depth--; if (depth === 0) { if (c !== close) break; return JSON.parse(s.slice(start, i + 1)); } }
35
+ }
36
+ throw new Error(`unbalanced JSON in model answer: ${s.slice(start, start + 120)}`);
37
+ }
38
+
39
+ /** Clean a criteria list: drop empties, merge duplicate names (first wins, weight = max), clamp weights, set `pct` to sum 100. */
40
+ export function normalise(criteria: CriterionInput[] | undefined): Criterion[] {
41
+ const out: Criterion[] = [];
42
+ for (const c of criteria ?? []) {
43
+ const name = trim(c?.name, 60);
44
+ if (!name) continue;
45
+ const prev = out.find((x) => key(x.name) === key(name));
46
+ const w = clamp(c.weight, WEIGHT_RANGE);
47
+ if (prev) { prev.weight = Math.max(prev.weight, w); prev.description ||= trim(c.description, 300); prev.check ||= trim(c.check, 300); continue; }
48
+ out.push({ name, weight: w, description: trim(c.description, 300), check: trim(c.check, 300), source: c.source || "council", pct: 0, ...(c.was ? { was: c.was } : {}) });
49
+ }
50
+ const total = out.reduce((s, c) => s + c.weight, 0) || 1;
51
+ let given = 0;
52
+ for (const c of out) { c.pct = Math.floor((c.weight / total) * 100); given += c.pct; }
53
+ for (let i = 0; given < 100 && out.length; i = (i + 1) % out.length) { out[i].pct++; given++; }
54
+ out.sort((a, b) => b.weight - a.weight);
55
+ return out;
56
+ }
57
+
58
+ const CRITERION_SHAPE = `{"name":"<=6 words","weight":1-5,"description":"what good looks like, one sentence","check":"how a judge verifies it from what it is shown, one sentence"}`;
59
+ export function proposePrompt(o: RubricContext) {
60
+ const bench = o.kind === "bench";
61
+ return `Several coding agents will each produce a version of ${o.subject ?? "the work"}; a judge then ${bench ? "checks each version's diff against the rules (the benchmark itself measures speed, so the rubric is about correctness, rule-following and code quality, not about being fast)" : "scores screenshots of each version against the goal and rules"}. The judge can only use this rubric, so criteria must be observable${bench ? " in a diff and test output" : " in a screenshot"} and distinct from each other.
62
+ Goal: ${o.goal}
63
+ Rules already stated (every rule must be covered by some criterion):
64
+ ${(o.rules ?? []).map((r) => "- " + r).join("\n") || "- (none)"}
65
+ ${o.history ? `\nWhat has happened in the loop so far (use it to sharpen or add criteria the current rubric missed):\n${o.history}\n${o.current ? `Current rubric:\n${rubricText({ criteria: o.current })}\n` : ""}` : ""}
66
+ Propose 4 to 7 criteria. Weight 5 = decisive, 1 = nice to have.
67
+ Return JSON: {"criteria":[${CRITERION_SHAPE}]}`;
68
+ }
69
+ export function synthPrompt(o: RubricContext, proposals: any[]) {
70
+ return `You chair the council that writes the judging rubric for a hill-climbing loop improving ${o.subject ?? "the work"}.
71
+ Goal: ${o.goal}
72
+ Rules stated (each must be covered):
73
+ ${(o.rules ?? []).map((r) => "- " + r).join("\n") || "- (none)"}
74
+
75
+ ${proposals.length} council member(s) proposed criteria:
76
+ ${proposals.map((p, i) => `--- Member ${i + 1}\n${JSON.stringify(p.criteria)}`).join("\n")}
77
+ ${o.history ? `\nLoop history so far:\n${o.history}\n${o.current ? `The rubric in force (prefer small edits to it over a rewrite; keep criteria marked source "human"):\n${JSON.stringify(o.current)}\n` : ""}` : ""}
78
+ Merge them into one rubric of 4 to 7 criteria: combine duplicates, keep the sharpest wording of "description" and "check", drop anything the judge cannot observe, keep weights on the 1–5 scale (5 = decisive). Then write a one-sentence summary of how the judge will decide.
79
+ Return JSON: {"criteria":[${CRITERION_SHAPE}],"summary":"<=30 words"}`;
80
+ }
81
+
82
+ /**
83
+ * Convene the council: `members` proposals (role "council"), one synthesis (role "judge").
84
+ * `ask(role, prompt)` returns the parsed JSON answer. `history`/`current` make it a re-convene.
85
+ * Returns { criteria, summary, source: "council", members }.
86
+ */
87
+ export async function writeRubric({ goal, kind, rules, subject, history, current, members = 2, ask }: {
88
+ goal: string; kind?: string; rules?: string[]; subject?: string; history?: string;
89
+ current?: Criterion[]; members?: number; ask: (role: string, prompt: string, i?: number) => Promise<any>;
90
+ }) {
91
+ const o: RubricContext = { goal, kind, rules, subject, history, current };
92
+ const settled = await Promise.allSettled(Array.from({ length: members }, (_, i) => ask("council", proposePrompt(o), i)));
93
+ const proposals = settled
94
+ .filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled" && Array.isArray(r.value?.criteria) && r.value.criteria.length)
95
+ .map((r) => r.value);
96
+ if (!proposals.length) throw new Error(`council failed: ${settled.map((r) => (r.status === "rejected" ? r.reason?.message ?? String(r.reason) : "no criteria")).join("; ").slice(0, 200)}`);
97
+ const s = await ask("judge", synthPrompt(o, proposals));
98
+ let criteria = normalise(Array.isArray(s.criteria) && s.criteria.length ? s.criteria : proposals.flatMap((p) => p.criteria));
99
+ if (criteria.length < 2) criteria = normalise(proposals.flatMap((p) => p.criteria));
100
+ for (const h of (current ?? []).filter((c) => c.source === "human")) {
101
+ const same = criteria.find((c) => key(c.name) === key(h.name));
102
+ if (same) same.source = "human"; else criteria = normalise([...criteria, h]);
103
+ }
104
+ return { criteria, summary: trim(s.summary, 240) || `${criteria.length} criteria, weighted by the council`, source: "council", members: proposals.length };
105
+ }
106
+
107
+ export function newRubric(r: { criteria: CriterionInput[]; summary?: string; members?: number }, source: string): Rubric {
108
+ const criteria = normalise(r.criteria.map((c) => ({ ...c, source: c.source || source })));
109
+ return { v: 1, criteria, summary: r.summary || `${criteria.length} criteria from the ${source}`, source, ...(r.members ? { members: r.members } : {}), history: [entry(source, { criteria })] };
110
+ }
111
+ /** The fallback when no council member answers: every rule is a decisive criterion. */
112
+ export const fromRules = (rules: string[], why: string): Rubric => ({ ...newRubric({ criteria: rules.map((text) => ruleCriterion(text, "rule")), summary: "Each rule weighs equally (the council was unavailable)." }, "rule"), history: [entry("rule", { why })] });
113
+
114
+ const shortName = (text: unknown) => { const t = trim(text, 300).replace(/[.;:,]+$/, ""); if (t.length <= 80) return t; const cut = t.slice(0, 80); return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), 40))}…`; };
115
+ const ruleCriterion = (text: unknown, source: string): CriterionInput => ({ name: shortName(text), weight: 5, description: trim(text, 300), check: `Does the version follow this rule: ${trim(text, 200)}?`, source });
116
+ /** A human rule becomes a decisive criterion (replacing a council criterion of the same name, remembered in `was`). */
117
+ export function addRule(rubric: Rubric, text: string, by = "you") {
118
+ if (rubric.criteria.some((c) => c.source === "human" && c.description === trim(text, 300))) return rubric;
119
+ const rule = ruleCriterion(text, "human");
120
+ const same = rubric.criteria.find((c) => key(c.name) === key(rule.name));
121
+ rubric.criteria = normalise(same ? rubric.criteria.map((c) => (c === same ? { ...rule, name: same.name, was: { ...same } } : c)) : [...rubric.criteria, rule]);
122
+ rubric.history.push(entry("rule", { text, by }));
123
+ return rubric;
124
+ }
125
+ /** Drop the rule with that text wherever it sits (restoring what it took over); falls back to the criterion name. */
126
+ export function removeRule(rubric: Rubric, text: string) {
127
+ const t = trim(text, 300);
128
+ const byText = (c: Criterion | undefined) => c?.description === t;
129
+ const byName = (c: Criterion | undefined) => !!c && key(c.name) === key(t);
130
+ const chain = (c: Criterion | undefined): Criterion[] => (c ? [c, ...chain(c.was)] : []);
131
+ const drop = (c: Criterion): Criterion => {
132
+ const w = c.was;
133
+ if (w && byText(w)) return { ...c, was: w.was };
134
+ return w ? { ...c, was: drop(w) } : { ...c };
135
+ };
136
+ let hit = false;
137
+ const take = (match: (c: Criterion | undefined) => boolean) => (c: Criterion): Criterion[] => {
138
+ if (match(c)) { hit = true; return c.was ? [c.was] : []; }
139
+ if (match === byText && chain(c.was).some(byText)) { hit = true; return [drop(c)]; }
140
+ return [c];
141
+ };
142
+ let next = rubric.criteria.flatMap(take(byText));
143
+ if (!hit) next = rubric.criteria.flatMap(take(byName));
144
+ if (!hit) return rubric;
145
+ rubric.criteria = normalise(next);
146
+ rubric.history.push(entry("unrule", { text }));
147
+ return rubric;
148
+ }
149
+ /** A re-convened council's proposal replaces the criteria (history keeps the old ones). */
150
+ export function applyRevision(rubric: Rubric, r: { criteria: CriterionInput[]; summary?: string; members?: number }) {
151
+ const criteria = normalise(r.criteria);
152
+ const changed = JSON.stringify(criteria.map(({ name, weight }) => [name, weight])) !== JSON.stringify(rubric.criteria.map(({ name, weight }) => [name, weight]));
153
+ rubric.criteria = criteria;
154
+ if (r.summary) rubric.summary = r.summary;
155
+ rubric.history.push(entry("revisit", { criteria, members: r.members, changed }));
156
+ return { rubric, changed };
157
+ }
158
+ /** One line per criterion, the way both the judge prompt and the needs-you card show it. */
159
+ export const rubricText = (rubric: { criteria: Criterion[] }) => rubric.criteria.map((c) => `- ${c.name} (${c.pct}%${c.source === "human" ? ", yours" : ""})${c.description && c.description !== c.name ? `: ${c.description}` : ""}${c.check ? ` Check: ${c.check}` : ""}`).join("\n");
package/lib/sheet.ts ADDED
@@ -0,0 +1,33 @@
1
+ // A side-by-side sheet of candidate screenshots with labels, via ImageMagick's montage (returns null when it isn't installed).
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export function haveMontage() { return spawnSync("montage", ["-version"], { encoding: "utf8" }).status === 0; }
7
+
8
+ /** ImageMagick's default label font (helvetica) is often missing; pick one it actually has. */
9
+ function pickFont() {
10
+ const list = spawnSync("convert", ["-list", "font"], { encoding: "utf8" }).stdout ?? "";
11
+ const fonts = [...list.matchAll(/^\s*Font:\s*(\S+)/gm)].map((m) => m[1]);
12
+ return fonts.find((f) => /^(DejaVu-Sans|Liberation-Sans|Helvetica|Arial|Noto-Sans)$/i.test(f)) ?? fonts.find((f) => /sans/i.test(f)) ?? fonts[0];
13
+ }
14
+
15
+ type Tile = { label: string; files?: string[] };
16
+
17
+ /** tiles: [{ label, files: [png...] }] → one PNG at `out`; one row per screenshot index, one column per candidate. */
18
+ export function sheet({ tiles, out, tileWidth = 480 }: { tiles: Tile[]; out: string; tileWidth?: number }) {
19
+ const cols = tiles.filter((t): t is Tile & { files: string[] } => !!t.files?.length);
20
+ if (!cols.length || !haveMontage()) return null;
21
+ const rows = Math.max(...cols.map((t) => t.files.length));
22
+ const args = [];
23
+ for (let r = 0; r < rows; r++) for (const t of cols) {
24
+ const f = t.files[r];
25
+ if (f && fs.existsSync(f)) args.push("-label", r === 0 ? t.label : path.basename(f), f);
26
+ else args.push("-label", r === 0 ? t.label : "", "xc:#222222");
27
+ }
28
+ const font = pickFont();
29
+ args.push("-tile", `${cols.length}x${rows}`, "-geometry", `${tileWidth}x+8+8`, "-background", "#111111", "-fill", "#eeeeee", "-pointsize", "22", ...(font ? ["-font", font] : []), out);
30
+ fs.mkdirSync(path.dirname(out), { recursive: true });
31
+ const r = spawnSync("montage", args, { encoding: "utf8", timeout: 120000 });
32
+ return r.status === 0 && fs.existsSync(out) ? out : null;
33
+ }
package/lib/shot.ts ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // Screenshot a route of a web app with headless Chrome, starting the app first when asked. No dependencies.
3
+ // node shot.ts --url /pricing --out shots/pricing.png --serve "npm start" (app is given $PORT)
4
+ // node shot.ts --url http://localhost:3000/ --out shots/home.png (already running)
5
+ // Options: --port N (default: a free one), --width 1440 --height 900, --wait ms (settle time after load, default 1500),
6
+ // --timeout s (how long to wait for the server, default 180), --chrome <path> (or $LOOPS_CHROME / $CHROME).
7
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import net from "node:net";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const CHROMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "brave-browser", "microsoft-edge",
14
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium",
15
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"];
16
+
17
+ export function findChrome(explicit?: string): string {
18
+ const cands = [explicit, process.env.LOOPS_CHROME, process.env.CHROME, process.env.PUPPETEER_EXECUTABLE_PATH, ...CHROMES].filter((c): c is string => !!c);
19
+ for (const c of cands) {
20
+ if (c.includes("/")) { if (fs.existsSync(c)) return c; continue; }
21
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { const p = path.join(dir, c); if (fs.existsSync(p)) return p; }
22
+ }
23
+ throw new Error("no Chrome/Chromium found; install one or set LOOPS_CHROME=/path/to/chrome");
24
+ }
25
+
26
+ export const freePort = () => new Promise<number>((res, rej) => { const s = net.createServer(); s.unref(); s.on("error", rej); s.listen(0, "127.0.0.1", () => { const addr = s.address(); const port = typeof addr === "object" && addr ? addr.port : 0; s.close(() => res(port)); }); });
27
+
28
+ async function waitFor(url: string, timeoutMs: number, child: ChildProcess | null) {
29
+ const t0 = Date.now();
30
+ while (Date.now() - t0 < timeoutMs) {
31
+ if (child && child.exitCode != null) throw new Error(`server exited with code ${child.exitCode} before answering`);
32
+ try { const r = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) }); if (r.status < 500) return; } catch {}
33
+ await new Promise((r) => setTimeout(r, 500));
34
+ }
35
+ throw new Error(`server did not answer at ${url} within ${timeoutMs / 1000}s`);
36
+ }
37
+
38
+ export async function shot({ url, out, serve, port, width = 1440, height = 900, wait = 1500, timeout = 180, chrome, cwd = process.cwd(), log = () => {} }: {
39
+ url?: string; out?: string; serve?: string; port?: number; width?: number; height?: number;
40
+ wait?: number; timeout?: number; chrome?: string; cwd?: string; log?: (msg: string) => void;
41
+ }) {
42
+ if (!url || !out) throw new Error("shot needs --url and --out");
43
+ const bin = findChrome(chrome);
44
+ let child: ChildProcess | null = null, logFile: string | null = null;
45
+ if (serve) {
46
+ port ??= await freePort();
47
+ logFile = path.join(cwd, `.loops-serve-${port}.log`);
48
+ const fd = fs.openSync(logFile, "w");
49
+ child = spawn("sh", ["-c", serve], { cwd, env: { ...process.env, PORT: String(port) }, stdio: ["ignore", fd, fd], detached: true });
50
+ fs.closeSync(fd);
51
+ log(`serving on :${port} (${serve})`);
52
+ }
53
+ const target = /^https?:\/\//.test(url) ? url : `http://localhost:${port ?? (() => { throw new Error("a relative --url needs --serve or --port"); })()}${url.startsWith("/") ? "" : "/"}${url}`;
54
+ try {
55
+ if (child || port) await waitFor(target, timeout * 1000, child);
56
+ fs.mkdirSync(path.dirname(path.resolve(cwd, out)), { recursive: true });
57
+ const args = ["--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", "--disable-dev-shm-usage", `--window-size=${width},${height}`,
58
+ `--virtual-time-budget=${wait}`, `--screenshot=${path.resolve(cwd, out)}`, target];
59
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", timeout: 120000 });
60
+ if (r.status !== 0 || !fs.existsSync(path.resolve(cwd, out))) throw new Error(`chrome failed (${r.status}): ${(r.stderr || "").split("\n").filter((l) => !/dbus|gpu|vulkan|fontconfig/i.test(l)).slice(-3).join(" ")}`);
61
+ return path.resolve(cwd, out);
62
+ } finally {
63
+ if (child) {
64
+ if (child.pid) { try { process.kill(-child.pid, "SIGTERM"); } catch {} }
65
+ await new Promise((r) => setTimeout(r, 1500));
66
+ if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch {} }
67
+ try { if (logFile) fs.rmSync(logFile, { force: true }); } catch {}
68
+ }
69
+ }
70
+ }
71
+
72
+ type ShotArgs = Partial<{ url: string; out: string; serve: string; port: number; width: number; height: number; wait: number; timeout: number; chrome: string }>;
73
+
74
+ function parseArgs(argv: string[]): ShotArgs {
75
+ const o: Record<string, unknown> = {};
76
+ for (let i = 0; i < argv.length; i++) {
77
+ const a = argv[i];
78
+ if (!a.startsWith("--")) continue;
79
+ const k = a.slice(2); const v = argv[i + 1];
80
+ if (["width", "height", "wait", "timeout", "port"].includes(k)) { o[k] = Number(v); i++; }
81
+ else { o[k] = v; i++; }
82
+ }
83
+ return o as ShotArgs;
84
+ }
85
+
86
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
87
+ shot({ ...parseArgs(process.argv.slice(2)), log: (m: string) => console.error(m) })
88
+ .then((f) => { console.log(f); })
89
+ .catch((e) => { console.error(`shot: ${e.message}`); process.exit(1); });
90
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@computesdk/pi-loops",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Hill-climbing loops as Pi units: one `loop` tool, style agents, forked attempt sessions in git worktrees, a judge keeps the best.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension",
9
+ "loops",
10
+ "hill-climbing",
11
+ "agents",
12
+ "git-worktree",
13
+ "judge",
14
+ "benchmark",
15
+ "optimisation"
16
+ ],
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/computesdk/pi-extensions.git",
22
+ "directory": "packages/pi-loops"
23
+ },
24
+ "homepage": "https://github.com/computesdk/pi-extensions/tree/main/packages/pi-loops#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/computesdk/pi-extensions/issues"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.18.0"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/loop.ts"
37
+ ],
38
+ "prompts": [
39
+ "./prompts/*.md"
40
+ ]
41
+ },
42
+ "files": [
43
+ "extensions",
44
+ "lib",
45
+ "agents",
46
+ "prompts",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "scripts": {
51
+ "check": "tsc --noEmit",
52
+ "test": "tsc --noEmit && node test/rubric.ts && node test/judge.ts && node test/measure.ts && node test/loop.ts"
53
+ },
54
+ "peerDependencies": {
55
+ "@earendil-works/pi-coding-agent": "*",
56
+ "@earendil-works/pi-tui": "*",
57
+ "typebox": "*"
58
+ },
59
+ "devDependencies": {
60
+ "@earendil-works/pi-coding-agent": "catalog:",
61
+ "@earendil-works/pi-tui": "catalog:",
62
+ "@types/node": "catalog:",
63
+ "typebox": "catalog:",
64
+ "typescript": "catalog:"
65
+ }
66
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: Set this project up for loops (serve, check, bench, protect) — written once to .pi/loops.json
3
+ ---
4
+ $INIT
5
+
6
+ If it comes back "change: …", revise as asked and call loop_init again; if "discuss", talk it through with me here first. When it is saved, say so in one line.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Run a hill-climbing round — three forked attempts, a judge keeps the best
3
+ argument-hint: <goal>
4
+ ---
5
+ Goal for a hill-climbing loop: $@
6
+
7
+ $PROJECT
8
+
9
+ Write the brief the attempts get. Read, don't run — read tools only, no bash, no edits; the attempts and the judge run things. The brief:
10
+
11
+ goal: what good looks like for this ask, one or two sentences an attempt can aim at
12
+ rules: what the judge must enforce, as short testable statements (truthfulness, scope, style, what must not change), or none
13
+ directions: a goal-specific angle for each of safe / bold / wild, or none for the standard ones
14
+ routes: when the ask is about how something looks — the route(s) to screenshot (needs the project's serve); else none
15
+ bench: the project's bench command, only when the ask is about its number; else none
16
+
17
+ Then call the `loop` tool once with exactly those fields (`goal`, `rules`, `directions`, `routes`, `bench` only where the brief has them; check / protect come from the project setup, pass them only to override) — don't paste the brief in chat first; the tool shows it to me and asks approve / change / discuss. If it comes back "change: …", revise and call it again; if "discuss", talk it through with me here and call it again when we agree. Once approved it returns as soon as the round has started; the round card (table, climb, winner's diff stat) arrives here as a message when the judge is done — show it as-is and tell me how to run another round (`loop({ from })`). While the round runs, act on what I say with the steering tools: `loop_status` when I ask how it is going, `loop_talk` to tell one attempt something, `loop_rule` to add a rule for every attempt and the judge, `loop_pick` when I decide the winner myself, `loop_stop` to abort it; after the card, `loop_apply` when I say to take the winner.