CI reports metrics charted on insights; .forge/approvals rules by path; forge critique agent reviews signed into refs/notes/review; blame and history on file pages.
revisions2r2: behavior change
| r2 | cat | 350f662 | what changed since r1 behavior change | |
| r1 | cat | ec2b191 |
No comments yet: forge comment 33 adds one.
1/3 CI reports metrics, and a repo's insights charts them by commit
.forge/ci writes "name number [unit]" lines to $FORGE_METRICS; forge attest signs them into the statement as metric lines. The lake keeps them in ci_metrics, and commits gains the tree, so a repo's insights page charts each metric on its default branch, a reworded commit keeping its values. A lake made before a column existed gets it when next opened to write.
| M | README.md | 32 | +++++++++++++++++-- |
| M | bin/ | 26 | +++++++++++++++- |
| M | demo/ | 3 | ++ |
| M | lib/ | 11 | ++++++- |
| M | lib/ | 31 | ++++++++++++++++++- |
| M | lib/ | 26 | ++++++++++++---- |
| M | lib/ | 26 | ++++++++++++--- |
| M | lib/ | 8 | +++++ |
| A | site/ | bin | |
| M | site/ | 11 | ++++++ |
| M | site/ | 36 | ++++++++++++++++++++- |
| A | test/ | 51 | ++++++++++++++++++++++++++++++ |
@@ -524,6 +524,23 @@ dishonest key holder is not caught. To make the claim stronger:
524524 - have a second person run `forge attest` on their machine. Attestations from525525 several signers stack up on the same tree.526526 527+`.forge/ci` can also report numbers about the commit: bundle size, test528+count, benchmark times. It writes them to the file named by `$FORGE_METRICS`,529+a line each, as a name, a number and an optional unit:530+531+```sh532+echo "bundle_kb $(du -k dist/app.js | cut -f1) KB" >>"$FORGE_METRICS"533+echo "tests $(grep -c '^ok' test.log)" >>"$FORGE_METRICS"534+```535+536+They go into the statement as `metric bundle_kb 412 KB` lines, before the537+log, so they are signed with the result. A name is lowercase letters, digits538+and `_ . -`, starting with a letter; a unit is up to 16 letters, `%` or `/`.539+`forge attest` leaves out, with a warning, a line that isn't one, a name540+seen before and anything past the 100th. Like the result, they are what the541+signer's machine said. The repo's insights page charts each one by commit on542+the default branch (see [Insights pages](#insights-pages)).543+527544 To require a verified pass before `main` moves, set528545 `git config forge.requireci tip` (or `all`, for every new commit) in the bare529546 repo.@@ -832,7 +849,9 @@ Tables: `events` (pushes, comments, approvals, builds), `builds` and
832849 `build_repos` (each line of `metrics.jsonl`), `reviews` (each line of a833850 review log: created, revisions, approvals, deleted), `review_ends` (landed834851 or closed, and when) and `ci` (each attestation: commit, tree, result,835-duration, runner, signer; `ci.parquet` leaves the runner out). A review counts as landed when its branch is852+duration, runner, signer; `ci.parquet` leaves the runner out), `ci_metrics`853+(each metric an attestation reported, with its id) and `commits` (each854+commit on a default branch: time, author, subject and tree). A review counts as landed when its branch is836855 deleted with its tip in the default branch, as `forge land` leaves it; one837856 whose tip reaches the default branch while the branch stays counts from838857 when the lake saw it.@@ -852,6 +871,8 @@ when the lake saw it.
852871 one waits for it (`FORGE_LAKE_WAIT` seconds, 300) or gives up and says853872 so. Builds start it the way they start the mirror, `FORGE_LAKE_DELAY`854873 seconds (10) after the last one; `FORGE_LAKE=0` stops that.874+- **New columns.** A table that gains a column in a newer forge gets it875+ when the lake is next opened to write, empty in the rows it had.855876 - **Small writes.** DuckLake keeps inserts under 10 rows in the catalog856877 rather than writing a Parquet file for each; about once a day857878 (`FORGE_LAKE_CHECKPOINT` hours) a run checkpoints, which writes them out@@ -875,12 +896,17 @@ not R2 itself.
875896 876897 `/insights/` covers the forge, and each repo has `/<owner>/<repo>/insights/`877898 (the Insights tab, `i`): stat tiles, then build time, pushes, time to land,878-revisions per review, CI results and CI duration. The pages are static and899+revisions per review, CI results and CI duration. A repo whose CI reports900+metrics also gets a chart for each, its value at each commit on the default901+branch (the latest 400): a commit takes the value from an attestation of its902+tree, so a reworded commit keeps it, and a verified, passing one comes903+first. The forge's page has none, since a metric's name means something904+only in its repo. The pages are static and879905 built once; their script (`insights.js`, on these pages only) fetches880906 `insights.json` and draws the charts as SVG, light and dark, with a tooltip881907 on hover and keyboard focus and a table of each chart's numbers. After each882908 run, `forge lake` writes that JSON and the rows behind it as Parquet883-(`events`, `builds`, `reviews`, `ci`) into `.forge/lake/export` and straight909+(`events`, `builds`, `reviews`, `ci`, `ci_metrics`) into `.forge/lake/export` and straight884910 into the pages, so they update without a build, and the mirror picks them885911 up. A repo's exports sit in its page directory, which builds keep. DuckDB886912 reads the Parquet files over HTTP in byte ranges:@@ -270,6 +270,29 @@ tree_passed() {
270270 END { exit !found }'271271 }272272 273+# ci_metrics <file>: the statement's metric lines from what .forge/ci wrote274+# to $FORGE_METRICS, a line each: a name, a number and an optional unit275+# ("bundle_kb 412", "tests 318", "p50 41.5 ms"). A name is lowercase276+# letters, digits and _ . -, starting with a letter, at most 64 characters;277+# a unit is up to 16 letters, % or /. A line that isn't one, a name seen278+# before and anything past the 100th are left out, with a warning.279+ci_metrics() {280+ LC_ALL=C awk '281+ function warn(m) { printf "forge: FORGE_METRICS: %s\n", m > "/dev/stderr" }282+ { sub(/\r$/, ""); sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") }283+ $0 == "" || /^#/ { next }284+ {285+ bad = NF < 2 || NF > 3 || length($1) > 64 || $1 !~ /^[a-z][a-z0-9_.-]*$/ ||286+ $2 !~ /^-?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][-+]?[0-9]+)?$/ ||287+ (NF == 3 && (length($3) > 16 || $3 !~ /^[A-Za-z%\/]+$/))288+ if (bad) { warn("line " NR " is not \"name number [unit]\"; left out"); next }289+ if ($1 in seen) { warn($1 " twice; the first is kept"); next }290+ if (++n > 100) { if (n == 101) warn("more than 100 metrics; the rest are left out"); next }291+ seen[$1] = 1292+ print "metric " $1 " " $2 (NF == 3 ? " " $3 : "")293+ }' "$1"294+}295+273296 attest_one() {274297 c=$1 key=$2 force=$3 clean=${4:-0}275298 sha=$(git rev-parse "$c")@@ -295,7 +318,7 @@ attest_one() {
295318 { (296319 set +e297320 cd "$ci_wt" || exit298- FORGE_CI=1 FORGE_COMMIT=$sha $run </dev/null321+ FORGE_CI=1 FORGE_COMMIT=$sha FORGE_METRICS=$tmp/metrics $run </dev/null299322 echo $? >"$tmp/code"300323 ) 2>&1 | tee "$tmp/log" | sed 's/^/ | /' >&2; } || true301324 code=$(cat "$tmp/code" 2>/dev/null || echo 255)@@ -316,6 +339,7 @@ attest_one() {
316339 echo "runner $(whoami_at)"317340 echo "system $(uname -srm)"318341 echo "env $envdesc"342+ [ ! -f "$tmp/metrics" ] || ci_metrics "$tmp/metrics"319343 echo "log sha256:$(sha256 "$tmp/log") $(wc -l <"$tmp/log" | tr -d ' ') lines"320344 tail -n "${FORGE_LOG_LINES:-60}" "$tmp/log" | sanitize | sed 's/^/|/'321345 } >"$tmp/statement"@@ -113,6 +113,9 @@ cat >.forge/ci <<'EOF'
113113 set -e114114 echo "sh: $(command -v sh)"115115 sh test.sh116+# Numbers about the commit, which the insights page charts.117+echo "tests $(grep -c . tests.txt)" >>"$FORGE_METRICS"118+echo "script_bytes $(wc -c <greet.sh | tr -d ' ') B" >>"$FORGE_METRICS"116119 EOF117120 chmod +x .forge/ci118121 commit "add tests and a local CI script" "CI runs on your own machine: forge attest signs the result@@ -9,7 +9,7 @@ import { createHash } from 'node:crypto';
99 import fs from 'node:fs';1010 import os from 'node:os';1111 import path from 'node:path';12-import type { Attestation, CiStatus, Verification } from './types.ts';12+import type { Attestation, CiStatus, Metric, Verification } from './types.ts';1313 1414 export const NAMESPACE = 'forge-ci';1515 const BLOCK = /^forge-attestation 1\n[\s\S]*?^-----END SSH SIGNATURE-----$/gm;@@ -23,11 +23,19 @@ export function parseNote(text: string): Attestation[] {
2323 const payload = block.slice(0, at);2424 const fields: Record<string, string> = {};2525 const log: string[] = [];26+ const metrics: Metric[] = [];2627 for (const line of payload.split('\n').slice(1)) {2728 if (line.startsWith('|')) {2829 log.push(line.slice(1));2930 continue;3031 }32+ // metric <name> <number> [<unit>], as forge attest writes them.33+ const m = /^metric ([a-z][a-z0-9_.-]{0,63}) (\S+)(?: ([A-Za-z%/]{1,16}))?$/.exec(line);34+ if (m) {35+ const value = Number(m[2]);36+ if (Number.isFinite(value) && !metrics.some((x) => x.name === m[1])) metrics.push({ name: m[1], value, unit: m[3] ?? null });37+ continue;38+ }3139 const sp = line.indexOf(' ');3240 if (sp > 0) fields[line.slice(0, sp)] = line.slice(sp + 1);3341 }@@ -46,6 +54,7 @@ export function parseNote(text: string): Attestation[] {
4654 script: fields.script,4755 logHash: fields.log,4856 log,57+ metrics,4958 verification: { ok: false, signer: null, reason: 'not checked' },5059 });5160 }@@ -22,11 +22,13 @@ import { listRepos } from './lake-sources.ts';
2222 import { lit, type Lake, type LakeConfig } from './lake.ts';2323 import type { RepoActivityExport } from './activity.ts';2424 25-export const FILES = ['insights.json', 'activity.json', 'events.parquet', 'builds.parquet', 'reviews.parquet', 'ci.parquet'];25+export const FILES = ['insights.json', 'activity.json', 'events.parquet', 'builds.parquet', 'reviews.parquet', 'ci.parquet', 'ci_metrics.parquet'];2626 // How far back the activity exports go: a year's grid, and some.2727 const DAYS = 400;2828 // The most points a chart gets; older ones are only in the Parquet files.2929 const CAP = 1000;30+// The most points a metric's chart gets: there can be many metrics.31+const METRIC_CAP = 400;3032 3133 const inList = (col: string, xs: string[]): string => (xs.length ? `${col} IN (${xs.map(lit).join(', ')})` : 'false');3234 const n = (v: unknown): number | null => (v == null ? null : Number(v));@@ -84,6 +86,7 @@ async function exportScope(lake: Lake, s: Scope, publicRepos: string[]): Promise
8486 reviews: reviews.map(([repo, id, c, e, how, revs, appr]) => [repo, n(id), n(c), n(e), how ?? null, n(revs), n(appr)]),8587 ci: ci.map(([t, d, p]) => [n(t), n(d), p ? 1 : 0]),8688 };89+ if (s.slug) out.metrics = await metrics(lake, s.slug);8790 if (!s.slug) {8891 const per = await rows(`8992 WITH p AS (SELECT repo, count(*) FILTER (type = 'push') AS pushes, max(t) FILTER (type = 'push') AS last FROM lake.events WHERE ${s.where} GROUP BY repo),@@ -107,6 +110,9 @@ async function exportScope(lake: Lake, s: Scope, publicRepos: string[]): Promise
107110 UNION ALL SELECT repo, review, kind, ts, "user", NULL, sha, NULL, NULL, NULL, NULL, observed FROM lake.review_ends WHERE ${s.where}108111 ORDER BY repo, review, ts`);109112 await copy('ci.parquet', `SELECT repo, "commit", tree, result, exit, started, duration_s, system, env, signer, verified FROM lake.ci WHERE ${s.where} ORDER BY started`);113+ await copy('ci_metrics.parquet', `114+ SELECT m.repo, m."commit", m.tree, m.name, m.value, m.unit, c.started, c.result, c.signer, c.verified115+ FROM (SELECT * FROM lake.ci_metrics WHERE ${s.where}) m JOIN lake.ci c USING (id) ORDER BY c.started, m.name`);110116 out.files = FILES.slice(2).map((f) => [f, fs.statSync(path.join(tmp, f)).size]);111117 if (s.slug) fs.writeFileSync(path.join(tmp, 'activity.json'), JSON.stringify(await repoActivity(lake, s.slug)) + '\n');112118 fs.writeFileSync(path.join(tmp, 'insights.json'), JSON.stringify(out) + '\n');@@ -120,6 +126,29 @@ async function exportScope(lake: Lake, s: Scope, publicRepos: string[]): Promise
120126 fs.rmSync(old, { recursive: true, force: true });121127 }122128 129+// A repo's metrics on its default branch, for a chart each: [name, unit,130+// points], by name, where a point is [commit time (ms), value, commit,131+// subject], oldest first. A commit gets the value of an attestation of its132+// tree (or of itself, for commits the lake has no tree of), the verified133+// and passing one first, then the latest; its unit is the latest point's.134+// Commits made the same second go in the order they were attested.135+async function metrics(lake: Lake, slug: string): Promise<[string, string | null, [number, number, string, string][]][]> {136+ const got = await lake.rows(`137+ WITH p AS (138+ SELECT m.name, m.value, m.unit, k.sha, k.t, k.subject, c.started139+ FROM lake.ci_metrics m JOIN lake.ci c USING (id)140+ JOIN lake.commits k ON k.repo = m.repo AND (k.tree = m.tree OR (k.tree IS NULL AND k.sha = m."commit"))141+ WHERE m.repo = ${lit(slug)} AND k.t IS NOT NULL142+ QUALIFY row_number() OVER (PARTITION BY m.name, k.sha ORDER BY c.verified DESC, c.result = 'pass' DESC, c.started DESC NULLS LAST) = 1),143+ q AS (SELECT *, row_number() OVER (PARTITION BY name ORDER BY t DESC, started DESC NULLS LAST, sha) AS i FROM p)144+ SELECT name, arg_max(unit, t), list([epoch_ms(t)::VARCHAR, value::VARCHAR, sha, left(subject, 100)] ORDER BY t, started NULLS FIRST, sha)145+ FROM q WHERE i <= ${METRIC_CAP} GROUP BY name ORDER BY name`);146+ return got.map(([name, unit, pts]) => [147+ String(name), unit == null ? null : String(unit),148+ (pts as string[][]).map(([t, v, sha, subj]) => [Number(t), Number(v), sha, subj ?? ''] as [number, number, string, string]),149+ ]);150+}151+123152 const since = (): string => {124153 const now = Number(process.env.FORGE_NOW) * 1000 || Date.now();125154 return lit(new Date(now - DAYS * 86400000).toISOString().slice(0, 10));@@ -348,17 +348,29 @@ export interface CiRow {
348348 verified: boolean;349349 }350350 351+export interface CiMetricRow {352+ id: string;353+ repo: string;354+ commit: string | null;355+ tree: string | null;356+ name: string;357+ value: number;358+ unit: string | null;359+}360+351361 // The tip of a repo's CI notes, so a run skips repos whose notes didn't move.352362 export const notesTip = (dir: string): string | null => tryRun(dir, ['rev-parse', '-q', '--verify', 'refs/notes/ci^{commit}'])?.trim() || null;353363 354364 // The attestations in a repo's refs/notes/ci that aren't in `known` (ids are355365 // a hash of the signed statement and its signature, so a note that gained a356366 // block only adds that block). Signatures are checked as they are read.357-export function ciRows(slug: string, dir: string, known: Set<string>, verifier: Verifier): CiRow[] {367+// Each one's metrics come with it, keyed by the same id.368+export function ciRows(slug: string, dir: string, known: Set<string>, verifier: Verifier): { rows: CiRow[]; metrics: CiMetricRow[] } {358369 const list = tryRun(dir, ['ls-tree', '-r', 'refs/notes/ci']);359- if (!list) return [];370+ if (!list) return { rows: [], metrics: [] };360371 const blobs = list.split('\n').filter(Boolean).map((l) => l.split(/\s+/)).filter((f) => f[1] === 'blob').map((f) => f[2]);361372 const out: CiRow[] = [];373+ const metrics: CiMetricRow[] = [];362374 const seen = new Set(known);363375 for (const b of catFileBatch(dir, blobs).values()) {364376 for (const a of parseNote(b.data.toString('utf8'))) {@@ -373,9 +385,10 @@ export function ciRows(slug: string, dir: string, known: Set<string>, verifier:
373385 started: a.started ? ts(Date.parse(a.started)) : null, duration_s: dur ? Number(dur[1]) : null,374386 runner: a.runner ?? null, system: a.system ?? null, env: a.env ?? null, signer: v.signer, verified: v.ok,375387 });388+ for (const m of a.metrics) metrics.push({ id, repo: slug, commit: a.commit ?? null, tree: a.tree ?? null, name: m.name, value: m.value, unit: m.unit });376389 }377390 }378- return out;391+ return { rows: out, metrics };379392 }380393 381394 export interface CommitRow {@@ -384,15 +397,16 @@ export interface CommitRow {
384397 t: string | null;385398 author: string | null;386399 subject: string | null;400+ tree: string | null;387401 }388402 389403 // The commits reachable from tip and not from since (all of them without390404 // it), first parent or not: every commit the default branch took in.391405 export function commitRows(slug: string, dir: string, tip: string, since: string | null): CommitRow[] {392- const out = tryRun(dir, ['log', '-z', '--no-color', '--format=%H%x1f%at%x1f%an%x1f%s', tip, ...(since ? [`^${since}`] : []), '--']);406+ const out = tryRun(dir, ['log', '-z', '--no-color', '--format=%H%x1f%at%x1f%an%x1f%T%x1f%s', tip, ...(since ? [`^${since}`] : []), '--']);393407 if (!out) return [];394408 return out.split('\0').filter(Boolean).map((rec) => {395- const [sha, at, author, subject] = rec.replace(/^\n/, '').split('\x1f');396- return { repo: slug, sha, t: ts(Number(at) * 1000), author: author || null, subject: subject ?? null };409+ const [sha, at, author, tree, subject] = rec.replace(/^\n/, '').split('\x1f');410+ return { repo: slug, sha, t: ts(Number(at) * 1000), author: author || null, subject: subject ?? null, tree: tree || null };397411 });398412 }@@ -28,7 +28,7 @@ import { Verifier } from './attest.ts';
2828 import { isAncestor, tryRun } from './git.ts';2929 import {3030 buildRows, ciRows, commitRows, eventRow, listRepos, notesTip, readNew, reviewEnd, reviewLogs, reviewRows,31- type BuildRepoRow, type BuildRow, type CiRow, type CommitRow, type EventRow, type Offset, type Read, type ReviewEnd, type ReviewRow,31+ type BuildRepoRow, type BuildRow, type CiMetricRow, type CiRow, type CommitRow, type EventRow, type Offset, type Read, type ReviewEnd, type ReviewRow,3232 } from './lake-sources.ts';3333 import { exportInsights, publishInsights } from './insights.ts';3434 @@ -87,10 +87,15 @@ const TABLES: Record<string, [string, string][]> = {
8787 reviews: [['repo', 'VARCHAR'], ['review', 'INTEGER'], ['kind', 'VARCHAR'], ['ts', 'TIMESTAMP'], ['user', 'VARCHAR'], ['rev', 'INTEGER'], ['sha', 'VARCHAR'], ['base', 'VARCHAR'], ['tree', 'VARCHAR'], ['branch', 'VARCHAR'], ['title', 'VARCHAR']],8888 review_ends: [['repo', 'VARCHAR'], ['review', 'INTEGER'], ['kind', 'VARCHAR'], ['ts', 'TIMESTAMP'], ['user', 'VARCHAR'], ['sha', 'VARCHAR'], ['observed', 'BOOLEAN']],8989 // Commits on each repo's default branch, as it moved: its author's time90- // (the day it counts for on activity grids) and subject.91- commits: [['repo', 'VARCHAR'], ['sha', 'VARCHAR'], ['t', 'TIMESTAMP'], ['author', 'VARCHAR'], ['subject', 'VARCHAR']],90+ // (the day it counts for on activity grids), subject and tree (which CI91+ // results and metrics are keyed by; commits added before it was kept92+ // have none).93+ commits: [['repo', 'VARCHAR'], ['sha', 'VARCHAR'], ['t', 'TIMESTAMP'], ['author', 'VARCHAR'], ['subject', 'VARCHAR'], ['tree', 'VARCHAR']],9294 // refs/notes/ci: one row an attestation.9395 ci: [['id', 'VARCHAR'], ['repo', 'VARCHAR'], ['commit', 'VARCHAR'], ['tree', 'VARCHAR'], ['result', 'VARCHAR'], ['exit', 'INTEGER'], ['started', 'TIMESTAMP'], ['duration_s', 'INTEGER'], ['runner', 'VARCHAR'], ['system', 'VARCHAR'], ['env', 'VARCHAR'], ['signer', 'VARCHAR'], ['verified', 'BOOLEAN']],96+ // The metrics in each attestation (.forge/ci writes them to97+ // $FORGE_METRICS): one row a metric, id the attestation's in ci.98+ ci_metrics: [['id', 'VARCHAR'], ['repo', 'VARCHAR'], ['commit', 'VARCHAR'], ['tree', 'VARCHAR'], ['name', 'VARCHAR'], ['value', 'DOUBLE'], ['unit', 'VARCHAR']],9499 // Where each source was read up to (lib/lake-sources.ts), written in the95100 // same transaction as its rows.96101 sources: [['source', 'VARCHAR'], ['ino', 'BIGINT'], ['pos', 'BIGINT'], ['head', 'VARCHAR'], ['tail', 'VARCHAR'], ['at', 'TIMESTAMP']],@@ -153,6 +158,14 @@ export class Lake {
153158 for (const [t, cols] of Object.entries(TABLES)) {154159 await lake.run(`CREATE TABLE IF NOT EXISTS lake.${t} (${cols.map(([c, ty]) => `${ident(c)} ${ty}`).join(', ')})`);155160 }161+ // Columns added since a lake was made: at the end of their table,162+ // empty in the rows it already has.163+ const have = new Set((await lake.rows(`SELECT table_name || ' ' || column_name FROM information_schema.columns WHERE table_catalog = 'lake'`)).map((r) => String(r[0])));164+ for (const [t, cols] of Object.entries(TABLES)) {165+ for (const [c, ty] of cols) {166+ if (!have.has(`${t} ${c}`)) await lake.run(`ALTER TABLE lake.${t} ADD COLUMN ${ident(c)} ${ty}`);167+ }168+ }156169 await lake.run('COMMIT');157170 }158171 } catch (e) {@@ -238,7 +251,7 @@ export async function forget(lake: Lake, slug: string): Promise<number> {
238251 const s = lit(slug);239252 const where: Record<string, string> = {240253 events: `repo = ${s}`, builds: `scope = ${s}`, build_repos: `repo = ${s}`, reviews: `repo = ${s}`,241- review_ends: `repo = ${s}`, commits: `repo = ${s}`, ci: `repo = ${s}`,254+ review_ends: `repo = ${s}`, commits: `repo = ${s}`, ci: `repo = ${s}`, ci_metrics: `repo = ${s}`,242255 sources: `source IN (${['main', 'commits', 'notes'].map((k) => lit(`${k} ${slug}`)).join(', ')}) OR starts_with(source, ${lit(`review ${slug} `)})`,243256 };244257 let n = 0;@@ -273,6 +286,7 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
273286 const redo: [string, number][] = [];274287 const ends: ReviewEnd[] = [];275288 const ci: CiRow[] = [];289+ const ciMetrics: CiMetricRow[] = [];276290 const commits: CommitRow[] = [];277291 278292 const jsonl = <T>(name: string, file: string, parse: (line: string) => T | null, take: (row: T) => void): void => {@@ -351,8 +365,9 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
351365 known.get(String(r))!.add(String(id));352366 }353367 }354- const rows = ciRows(repo.slug, repo.dir, known.get(repo.slug) ?? new Set(), verifier);368+ const { rows, metrics } = ciRows(repo.slug, repo.dir, known.get(repo.slug) ?? new Set(), verifier);355369 ci.push(...rows);370+ ciMetrics.push(...metrics);356371 if (rows.length) sum.touched.add(repo.slug);357372 next.set(nkey, { ino: 0, pos: 0, head: notes, tail: [] });358373 }@@ -378,6 +393,7 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
378393 await lake.insert('reviews', reviews, scratch);379394 await lake.insert('review_ends', ends, scratch);380395 await lake.insert('ci', ci, scratch);396+ await lake.insert('ci_metrics', ciMetrics, scratch);381397 await lake.insert('commits', commits, scratch);382398 const names = [...next.keys()];383399 for (let i = 0; i < names.length; i += 500) {@@ -67,6 +67,12 @@ export interface Verification {
6767 reason?: string;6868 }6969 70+export interface Metric {71+ name: string;72+ value: number;73+ unit: string | null;74+}75+7076 export interface Attestation {7177 payload: string;7278 signature: string;@@ -82,6 +88,8 @@ export interface Attestation {
8288 script?: string;8389 logHash?: string;8490 log: string[];91+ // What .forge/ci wrote to $FORGE_METRICS, in its order.92+ metrics: Metric[];8593 object?: string;8694 via?: string | null;8795 verification: Verification;Binary file.
@@ -31,6 +31,7 @@
3131 .insights svg .s1 { fill: var(--viz-1); }3232 .insights svg .fail { fill: var(--viz-fail); }3333 .insights svg .dot { stroke: var(--raised); stroke-width: 2; }34+.insights svg .line { fill: none; stroke: var(--viz-1); stroke-width: 1.5; stroke-linejoin: round; }3435 .insights svg .hair { stroke: var(--muted); stroke-width: 1; shape-rendering: crispEdges; }3536 .insights svg .on { filter: brightness(1.15); }3637 .insights svg .mark-label { fill: var(--fg); paint-order: stroke; stroke: var(--raised); stroke-width: 3px; stroke-linejoin: round; }@@ -50,6 +51,7 @@
5051 .insights pre.cmd { margin: 0; overflow-x: auto; white-space: pre; padding: var(--space-3); border-top: var(--hairline) solid var(--border); font-size: var(--text-sm); }5152 @media (forced-colors: active) {5253 .insights svg .s1, .insights svg .fail { fill: CanvasText; }54+ .insights svg .line { stroke: CanvasText; }5355 }5456 {% endcss %}5557 <div class="insights" data-insights="{{ base }}insights.json"{% if forgeWide %} data-forge="1"{% endif %}>@@ -70,6 +72,14 @@
7072 <figure class="panel" data-chart="ci"><figcaption class="panel-head"><h3>ci results</h3><span class="grow">attestations a <span class="ins-bucket">day</span>, passed and failed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>7173 <figure class="panel" data-chart="ciTime"><figcaption class="panel-head"><h3>ci duration</h3><span class="grow">seconds per run of <code>.forge/ci</code></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>7274 </div>75+{% if not forgeWide %}76+{# A chart a metric (insights.ts draws them from insights.json's metrics),77+ shown once there is one. #}78+<section id="ins-metrics" hidden>79+{{ ui.heading('metrics', null, 'what .forge/ci reported for each commit on ' + p.repo.defaultBranch) }}80+<div class="charts"></div>81+</section>82+{% endif %}7383 {% if forgeWide %}7484 {% call ui.panel('<b>by repo</b>') %}7585 <table class="list nav" id="ins-repos">@@ -86,6 +96,7 @@
8696 <tr><td class="mono nowrap"><a href="{{ base }}builds.parquet">builds.parquet</a></td><td class="main">each page build: time spent, pages written</td></tr>8797 <tr><td class="mono nowrap"><a href="{{ base }}reviews.parquet">reviews.parquet</a></td><td class="main">each review's lifecycle: created, revisions, approvals, landed or closed</td></tr>8898 <tr><td class="mono nowrap"><a href="{{ base }}ci.parquet">ci.parquet</a></td><td class="main">each CI attestation: commit, tree, result, duration, system, signer</td></tr>99+<tr><td class="mono nowrap"><a href="{{ base }}ci_metrics.parquet">ci_metrics.parquet</a></td><td class="main">each metric an attestation reported: name, value, unit, with its commit and result</td></tr>89100 {% endcall %}90101 <p class="desc">DuckDB reads them over HTTP in byte ranges, so a query fetches only what it needs:</p>91102 <pre class="cmd">duckdb -c "SELECT result, count(*), median(duration_s) FROM '{{ forge.config.baseUrl or 'https://<this forge>' }}{{ base }}ci.parquet' GROUP BY result"</pre>@@ -21,6 +21,8 @@
2121 reviews: ReviewRow[];2222 ci: [number, number | null, number][];2323 repos?: [string, number, number, number | null, number, number, number, number | null][];24+ // [name, unit, [[commit time, value, commit, subject]...]], a repo's only.25+ metrics?: [string, string | null, [number, number, string, string][]][];2426 }2527 2628 // ---------------------------------------------------------------- formats@@ -29,6 +31,8 @@
2931 const secs = (s: number): string => (s < 10 ? `${Math.round(s * 100) / 100}s` : s < 60 ? `${r1(s)}s` : s < 3600 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${r1(s / 3600)}h`);3032 const hours = (h: number): string => (h < 1 ? `${Math.round(h * 60)}m` : h < 48 ? `${r1(h)}h` : `${r1(h / 24)}d`);3133 const count = (n: number): string => (n >= 1e4 ? `${Math.round(n / 1e3)}K` : n >= 1e3 ? `${r1(n / 1e3)}K` : String(n));34+ // A metric's value: whole numbers grouped, others to three significant digits.35+ const num = (v: number): string => (Number.isInteger(v) ? v.toLocaleString() : Math.abs(v) >= 1000 ? Math.round(v).toLocaleString() : String(Number(v.toPrecision(3))));3236 const plural = (n: number, w: string): string => `${count(n)} ${w}${n === 1 ? '' : 's'}`;3337 const when = (ms: number): string => new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });3438 const dayOf = (ms: number): string => new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });@@ -212,8 +216,9 @@
212216 rows: [string, string, string?][];213217 }214218 215- // Dots over time, one per fact. The last one carries its value.216- function dots(plot: HTMLElement, list: Dot[], yFmt: (v: number) => string): void {219+ // Dots over time, one per fact. The last one carries its value. A line220+ // joins them, oldest first, for a metric's values commit by commit.221+ function dots(plot: HTMLElement, list: Dot[], yFmt: (v: number) => string, joined = false): void {217222 const max = Math.max(...list.map((d) => d.v));218223 const f = frame(plot, max, yFmt);219224 const a = Math.min(...list.map((d) => d.t));@@ -221,6 +226,10 @@
221226 const pad = (b - a) * 0.03 || 3600e3;222227 const x = (t: number): number => PAD.left + ((t - (a - pad)) / (b - a + 2 * pad)) * f.plotW;223228 xLabels(f, timeTicks(a - pad, b + pad, Math.max(2, Math.floor(f.plotW / 90))).map((k) => ({ x: x(k.at), label: k.label })));229+ if (joined && list.length > 1) {230+ const by = [...list].sort((p, q) => p.t - q.t);231+ svgEl('path', { class: 'line', d: by.map((d, i) => `${i ? 'L' : 'M'}${x(d.t).toFixed(1)},${f.y(d.v).toFixed(1)}`).join('') }, f.svg);232+ }224233 const marks: Mark[] = list.map((d) => {225234 const c = svgEl('circle', { class: `dot ${d.fail ? 'fail' : 's1'}`, cx: x(d.t), cy: f.y(d.v), r: 4 }, f.svg);226235 return { cx: x(d.t), cy: f.y(d.v), els: [c], head: d.head, rows: d.rows };@@ -439,6 +448,29 @@
439448 table(fig, ['started', 'took', 'result'], () => [...runs].reverse().map(([t, s, p]) => [when(t), secs(s!), p ? 'passed' : 'failed']));440449 });441450 451+ // A chart a metric, made here since the page doesn't know their names.452+ const box = root.querySelector<HTMLElement>('#ins-metrics');453+ const grid = box?.querySelector('.charts');454+ if (box && grid && d.metrics?.length) {455+ box.hidden = false;456+ for (const [name, unit, pts] of d.metrics) {457+ const fig = el('figure', 'panel');458+ fig.dataset.chart = `metric:${name}`;459+ const head = el('figcaption', 'panel-head');460+ head.append(el('h3', 'mono', name), el('span', 'grow', unit ? `in ${unit}, ` : ''));461+ head.lastElementChild!.append(plural(pts.length, 'commit'));462+ const det = el('details', 'table');463+ det.appendChild(el('summary', '', 'table'));464+ fig.append(head, el('div', 'plot'), det);465+ grid.appendChild(fig);466+ const fmt = (v: number): string => num(v) + (unit ? ` ${unit}` : '');467+ chart(`metric:${name}`, (f, plot) => {468+ dots(plot, pts.map(([t, v, sha, subj]) => ({ t, v, head: `${sha.slice(0, 7)} ${subj}`, rows: [[fmt(v), name], [when(t), 'committed']] })), num, true);469+ table(f, ['commit', 'subject', 'committed', name], () => [...pts].reverse().map(([t, v, sha, subj]) => [sha.slice(0, 7), subj, when(t), fmt(v)]));470+ });471+ }472+ }473+442474 const repos = root.querySelector('#ins-repos tbody');443475 if (repos && d.repos) {444476 for (const [slug, pushes, builds, buildMs, landed, ci, pass, last] of d.repos) {@@ -0,0 +1,51 @@
1+# Metrics: .forge/ci writes "name number [unit]" lines to $FORGE_METRICS,2+# forge attest signs them into the statement, and the lake keeps a row a3+# metric in ci_metrics. A repo's insights.json charts each one by commit on4+# its default branch, matched by tree, so a commit reworded after it was5+# attested keeps its values.6+. "$(dirname "$0")/../lib.sh"7+user dami8+repo dami/greet9+clone dami dami/greet "$T/w/d"10+mkdir -p "$T/w/d/.forge"11+cat >"$T/w/d/.forge/ci" <<'EOF'12+#!/bin/sh13+n=$(git rev-list --count HEAD)14+{15+ echo "files $n"16+ echo "bundle_kb 41.5 KB"17+ echo "Not A Metric"18+ echo "files 9"19+} >"$FORGE_METRICS"20+EOF21+chmod +x "$T/w/d/.forge/ci"22+at "$T/w/d" commit "ci reports metrics"23+ok at "$T/w/d" "$FORGE" attest24+has "$T_LOG" 'FORGE_METRICS: line 3 is not "name number \[unit\]"; left out'25+has "$T_LOG" 'FORGE_METRICS: files twice; the first is kept'26+note=$(git -C "$T/w/d" notes --ref=ci show HEAD)27+case $note in *"metric files 128+metric bundle_kb 41.5 KB29+log sha256:"*) ;; *) fail "the statement lacks its metric lines: $note" ;; esac30+# Reworded after the attestation: same tree, another commit.31+git -C "$T/w/d" commit -q --amend -m "ci reports metrics, reworded"32+ok push_as dami "$T/w/d" origin main refs/notes/ci33+ok forge lake34+eq "$(lq "SELECT string_agg(name || '=' || value || coalesce(unit, ''), ';' ORDER BY name) FROM ci_metrics")" 'bundle_kb=41.5KB;files=1.0'35+eq "$(lq "SELECT count(*) FROM ci_metrics m JOIN ci USING (id) WHERE ci.verified AND m.tree = ci.tree")" 236+eq "$(lq "SELECT count(*) FROM commits WHERE tree IS NOT NULL")" 137+# A second commit: the chart has two points.38+at "$T/w/d" commit "another file"39+ok at "$T/w/d" "$FORGE" attest40+ok push_as dami "$T/w/d" origin main refs/notes/ci41+build dami/greet42+ok forge lake43+j=$FORGE_WWW/dami/greet/insights/insights.json44+has "$j" '"metrics":\[\["bundle_kb","KB",\[\[[0-9]+,41.5,"[0-9a-f]{40}","[a-z ,]+"\],\[[0-9]+,41.5,'45+has "$j" '\["files",null,\[(\[[0-9]+,[12],"[0-9a-f]{40}","[a-z ,]+"\],?){2}\]\]'46+has "$j" '\[[0-9]+,1,"[0-9a-f]{40}","ci reports metrics, reworded"\]'47+has "$j" '\[[0-9]+,2,"[0-9a-f]{40}","another file"\]'48+[ "$(head -c 4 "$FORGE_WWW/dami/greet/insights/ci_metrics.parquet")" = PAR1 ] || fail "ci_metrics.parquet isn't Parquet"49+# The forge's insights have no metrics chart: names mean something per repo.50+lacks "$FORGE_WWW/insights/insights.json" '"metrics"'51+has "$FORGE_WWW/dami/greet/insights/index.html" 'id="ins-metrics" hidden'2/3 Approval rules by path, and agent reviews signed like CI
.forge/approvals on the default branch says how many approvals a change to it needs, and from whom, by the files it touches; the rules are read from the branch before the change, so a review can't loosen its own. forge.requireapproval holds the gate to them, forge land checks the same verdict, and a live review's page opens with what landing needs. forge critique runs the reviewer the target branch names (.forge/review) over a review on your machine. Its findings become comments marked agent and blocking, and a statement of what it reviewed and what is still open is signed into refs/notes/review; later runs review only what changed and carry open findings until they are resolved. A rule's "+ agent" wants one of the tip's tree with nothing blocking open.
| M | .forge/ | 6 | +- |
| M | README.md | 153 | +++++++++++++- |
| M | bin/ | 239 | +++++++++++++++++++-- |
| M | demo/ | 47 | ++++ |
| M | guide/ | 17 | +- |
| M | guide/ | 2 | +- |
| M | lib/ | 14 | +- |
| A | lib/ | 343 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 4 | +- |
| M | lib/ | 2 | + |
| M | lib/ | 4 | + |
| M | lib/ | 32 | +++- |
| A | lib/ | 304 | +++++++++++++++++++++++++++ |
| M | lib/ | 32 | +++ |
| M | site/ | 5 | +- |
| M | site/ | 29 | ++- |
| A | test/ | 49 | ++++ |
| A | test/ | 103 | +++++++++ |
| A | test/ | 48 | ++++ |
| A | test/ | 146 | +++++++++++++ |
| M | test/ | 2 | +- |
@@ -32,7 +32,11 @@ grep -q 'data-search="/dami/greet/search.json"' $www/dami/greet/index.html
3232 # Insights: the demo runs forge lake before its build, which puts the exports next to3333 # the insights pages; the forge's leave out nothing public.3434 grep -q '"scope":"dami/greet".*"landed":1,"closed":1,' $www/dami/greet/insights/insights.json35-grep -q '"ci":17,"ci_pass":16,' $www/dami/greet/insights/insights.json35+grep -q '"ci":18,"ci_pass":17,' $www/dami/greet/insights/insights.json36+# CI metrics, approval rules and an agent review (demo/make-demo.sh).37+grep -q '"metrics":\[\["script_bytes","B",' $www/dami/greet/insights/insights.json38+grep -q '<span class="tag blocking">blocking</span>' $www/dami/greet/reviews/7/index.html39+grep -q '<b>agent review</b><span class="has">: demo-rules, signed by ana, 2 findings, <b>1 blocking open</b>' $www/dami/greet/reviews/7/index.html3640 grep -q '"repos":\[\["ana/dotfiles",' $www/insights/insights.json3741 for f in events builds reviews ci; do test "$(head -c 4 $www/insights/$f.parquet)" = PAR1; done3842 # Templates render the same without 11ty (lib/render-page.ts), with the@@ -25,13 +25,19 @@ It adds what stagit doesn't have, borrowing from [args.io](https://args.io):
2525 - **Discussion from the terminal, or the page.** `forge comment` and2626 `forge approve` add comments (on a line or the whole review) and2727 approvals, which the review page shows; signed in, the page adds them28- too. Approvals are keyed by tree like CI, and `main` can require one from29- someone other than the author. `forge land` checks both and30- fast-forwards.28+ too. Approvals are keyed by tree like CI, and `main` can require them29+ from someone other than the author, as many and from whom30+ `.forge/approvals` says for the files a change touches. `forge land`31+ checks both and fast-forwards.32+- **Agent reviews, signed like CI.** `forge critique` runs the reviewer the33+ target branch names (`.forge/review`) on your machine, posts what it finds34+ as comments, and signs what is still open into a git note. An approval35+ rule can ask for one with nothing blocking.3136 - **CI runs on your machine.** `forge attest` runs `.forge/ci` in a clean3237 worktree. It signs the result with the SSH key you push with and stores it3338 as a git note. Pages show the result for each commit, and `main` can require34- a verified pass to accept a push.39+ a verified pass to accept a push. CI can report numbers (bundle size,40+ test count) that a repo's insights chart by commit.3541 - **Sites ship with repos.** Docs, Docusaurus builds or anything static is3642 committed to `refs/sites/<name>` and served from the forge. Each review3743 branch gets its own preview. The wiki is markdown in `wiki/`.@@ -414,12 +420,12 @@ ssh git@git.example.com land cat/greet 7 # the forge lands it itself; no clone
414420 reordering commits keeps them; if the tree changes, the page shows415421 "approved r1, changed since". Authors can't approve their own review.416422 - **Gate:** `git config forge.requireapproval true` in a bare repo means a417- push that moves the default branch needs an approval of the new tip's tree418- from someone other than that review's author. It works alongside419- `forge.requireci`.423+ push that moves the default branch needs approvals of the new tip's tree424+ from someone other than that review's author: one, or what the approval425+ rules (below) say. It works alongside `forge.requireci`.420426 - **Landing:** `forge land [<id>|<branch>]` checks that every commit between421427 the target and the branch has a verified passing attestation and, if the422- repo requires it, an approval (`--force` turns the local checks into428+ repo requires it, the approvals the rules ask for (`--force` turns the local checks into423429 warnings; the server's gates still apply). It refuses anything that isn't a424430 fast-forward, pushes the branch to its target (the target of the stack's425431 lowest layer), then deletes the landed branch and the stack layers below@@ -442,6 +448,45 @@ land a review pushed a moment ago. The ssh commands live in
442448 `git-shell-commands/`; on an existing server, run `forge server init` again to443449 add them (the container does that on every boot).444450 451+#### Approval rules452+453+`.forge/approvals` on the default branch says how much approval a change to454+it needs, by the files the change touches (`lib/rules.ts`):455+456+```457+# paths rule458+.forge/ bin/forge 2459+lib/identity.ts lib/login.ts 1 from fox460+lib/ 1 + agent461+*.md guide/ wiki/ none462+@mechanical none463+* 1464+```465+466+- **Matching.** Each changed file takes the first line it matches. A467+ pattern without a slash matches a file's name in any directory; with one,468+ from the top. `*` is any run of characters but `/`, `**` any run at all,469+ `?` one character, and a trailing `/` everything under that directory.470+ `@mechanical` matches files Jev judged mechanical (the ones review pages471+ fold), from its stored answers; a file with none isn't. A file no line472+ matches needs 1, as it would without the file. A rename counts both its473+ paths, so moving a file out of a guarded directory needs that rule.474+- **Rules.** `none`, or a number of approvals, optionally `from` a475+ comma-separated list of people (that many of them), optionally `+ agent`:476+ a verified agent review of the tree with no blocking finding open (see477+ [Agent reviews](#agent-reviews)). A change needs every rule its files478+ took. A line the parser can't read (or `3 from fox`, which can never be479+ met) is an error the gate reports with its line, and holds.480+- **Whose rules.** The default branch's, as they were before the push: a481+ review can't loosen its own, so a change to `.forge/approvals` lands under482+ the old ones (and `.forge/` above makes that need two).483+- **Where they show.** The gate (`pre-receive`, with484+ `forge.requireapproval`) prints what is missing. `forge land` asks the485+ forge (`ssh ... approvals`) for the same verdict before it pushes. A live486+ review's page starts with a **to land** panel: CI, each rule its files487+ take, who approved, and its latest agent review. Without488+ `forge.requireapproval` the panel says approval is optional there.489+445490 #### On the web446491 447492 Signed in with a passkey (see [Private pages](#private-pages)), a review@@ -545,6 +590,98 @@ To require a verified pass before `main` moves, set
545590 `git config forge.requireci tip` (or `all`, for every new commit) in the bare546591 repo.547592 593+### Agent reviews594+595+`forge critique [<id>|<branch>]` reviews a review with an agent, on your596+machine, the way `forge attest` runs CI (`lib/critique.ts`):597+598+1. It reads the reviewer, `.forge/review`, and its rules,599+ `.forge/review.md`, from the review's **target** branch, so a change600+ can't bring its own reviewer or loosen the rules.601+2. It runs the reviewer in a clean worktree of the review's pushed tip,602+ with the diff on stdin. The first time that is everything since the603+ target. After that, while the last reviewed tip is still in the branch,604+ only what changed since. Files Jev judged mechanical are left out.605+3. It posts each finding as a comment, with `agent <model>` and606+ `severity blocking|note` in its header: the page marks them **agent** and607+ **blocking**. A finding on a file the review doesn't change, or past a608+ file's end, goes on the whole review or the file's first line, with a609+ warning.610+4. It signs a statement into `refs/notes/review` on the tip, with611+ `ssh-keygen -Y sign -n forge-review`, and pushes it: what it reviewed,612+ which reviewer and rules (by blob), the model, how many findings and how613+ many blocking ones are still **open**, carried over from earlier runs.614+ It exits 1 while any is.615+616+```617+forge-review 1618+review cat/greet#7619+commit 2aa8af0...620+tree 58945d0...621+since 3c1e2aa...622+scope incremental623+script .forge/review 99a0d5e...624+rules .forge/review.md 1b2c3d4...625+model claude-opus-5-5626+reviewer cat@gentle-yak-2e627+started 2026-11-04T10:00:00Z628+findings 1629+open 0630+|{"id":"2aa8af0-1","severity":"note","path":"lib/a.ts","line":12,"body":"..."}631+|{"resolved":"9f8e7d6-1","by":"agent"}632+-----BEGIN SSH SIGNATURE-----633+```634+635+The reviewer is any program. It gets the diff on stdin, and636+`FORGE_REVIEW_ID`, `FORGE_REVIEW_BASE` and `FORGE_REVIEW_TIP` (the commits637+the diff is between), `FORGE_REVIEW_SCOPE` (`full` or `incremental`),638+`FORGE_REVIEW_RULES` (a file with `.forge/review.md`, or empty),639+`FORGE_REVIEW_OPEN` (the open blocking findings, a JSON object a line),640+`FORGE_REVIEW_SKIPPED` (the paths left out) and `FORGE_REVIEW_FIX` (1 with641+`--fix`). It prints JSON objects, a line each; anything else is ignored:642+643+```644+{"model":"claude-opus-5-5"}645+{"severity":"blocking","path":"lib/a.ts","line":12,"body":"markdown"}646+{"severity":"note","body":"about the whole change"}647+{"resolved":"9f8e7d6-1"}648+```649+650+At most 50 findings a run, each up to 4,000 characters. `resolved` names an651+open finding the change dealt with. A reviewer with Claude Code, for one:652+653+```sh654+#!/bin/sh655+# .forge/review: Claude Code, headless and read-only, in the worktree of the tip.656+diff=$(cat)657+{658+ echo "Review this change. The project's rules:"659+ cat "${FORGE_REVIEW_RULES:-/dev/null}"660+ echo "Findings still open from earlier runs (print {\"resolved\":id} for any the change fixes):"661+ cat "$FORGE_REVIEW_OPEN"662+ echo "Answer with JSON objects only, one a line:"663+ echo '{"severity":"blocking"|"note","path":"...","line":N,"body":"markdown"}'664+ echo "The diff:"665+ printf '%s\n' "$diff"666+} | claude -p --model claude-opus-5-5 --allowedTools Read Grep Glob667+echo '{"model":"claude-opus-5-5"}'668+```669+670+- **Again on the same tree** does nothing but say what is open; `--full`671+ reviews everything again.672+- **Dismissing.** `--resolve <id>,...` signs a statement without those673+ findings, naming you as who resolved them, without running the reviewer674+ (on the same tree) or along with it.675+- **Fixes.** With `--fix` the reviewer may change files in the worktree;676+ what it changed becomes a commit on `<you>/fix-<id>`, pushed as a review677+ stacked on this one. The review itself is untouched.678+- **What it proves.** Like an attestation: the key holder claims this679+ reviewer, with these rules, said this about this tree. The review page's680+ **to land** panel shows the latest one, and an approval rule's `+ agent`681+ wants one of the tip's tree, verified, with `open 0`. Keys may sign them682+ once `forge keys sync` (which `forge server init` and the container's683+ boot run) writes `forge-review` into `allowed_signers`.684+548685 ### Sites549686 550687 A site deploy is a commit on `refs/sites/<name>` whose tree is the built@@ -11,6 +11,7 @@
1111 # forge approve [<id>|<branch>] approve a review's current tree1212 # forge land [--force] [<id>|<branch>] check CI and approval, fast-forward the target, delete landed branches1313 # forge land --server [<id>|<branch>] the same, done by the forge itself (ssh land), no local checks14+# forge critique [--full] [--fix] [--resolve id,...] [<id>|<branch>] run .forge/review over a review, post its findings, sign1415 # forge site deploy [--here] [name...] build .forge/sites entries and publish them1516 # forge site push <dir> <name> publish a prebuilt directory1617 # forge release publish <tag> <file>... publish files for a tag, with a signed SHA256SUMS@@ -56,6 +57,8 @@ FORGE_BIN=$FORGE_HOME/bin/forge
5657 NS=forge-ci5758 # What SHA256SUMS.sig of a release is signed for (forge release publish).5859 RNS=forge-release60+# What agent reviews are signed for (forge critique, lib/critique.ts).61+CNS=forge-review5962 ZERO=00000000000000000000000000000000000000006063 # ssh commands for the forge's admins only (is_admin): the operator's6164 # forge user invite, user invites and users, and motd.@@ -411,13 +414,15 @@ cmd_verify() {
411414 412415 # Merge the remote's CI notes into ours so the push fast-forwards.413416 sync_notes() {414- if git fetch -q "$1" "+refs/notes/ci:refs/notes/forge-remote-ci" 2>/dev/null; then415- if git rev-parse -q --verify refs/notes/ci >/dev/null; then416- git notes --ref=ci merge -q -s union refs/notes/forge-remote-ci417- else418- git update-ref refs/notes/ci refs/notes/forge-remote-ci417+ for n in ${2:-ci review}; do418+ if git fetch -q "$1" "+refs/notes/$n:refs/notes/forge-remote-$n" 2>/dev/null; then419+ if git rev-parse -q --verify "refs/notes/$n" >/dev/null; then420+ git notes --ref="$n" merge -q -s union "refs/notes/forge-remote-$n"421+ else422+ git update-ref "refs/notes/$n" "refs/notes/forge-remote-$n"423+ fi419424 fi420- fi425+ done421426 }422427 423428 cmd_push() {@@ -449,7 +454,9 @@ cmd_push() {
449454 fi450455 sync_notes "$r"451456 notes=452- git rev-parse -q --verify refs/notes/ci >/dev/null && notes=refs/notes/ci:refs/notes/ci457+ for n in ci review; do458+ git rev-parse -q --verify "refs/notes/$n" >/dev/null && notes="$notes refs/notes/$n:refs/notes/$n"459+ done453460 for b in $branches; do454461 set --455462 if [ "$b" = "$branch" ]; then@@ -996,12 +1003,20 @@ cmd_land() {
9961003 say "warning: landing without CI on $bad commit(s)"9971004 fi9981005 999- appr=$(forge_ssh approvals "$repo" "$(git rev-parse "$tip^{tree}")" </dev/null) || die "land: could not check approvals"1000- who=$(printf '%s\n' "$appr" | sed 1d | tr '\n' ' ' | sed 's/ *$//')1001- if [ -n "$who" ]; then1006+ # A forge older than approval rules takes the tree alone.1007+ appr=$(forge_ssh approvals "$repo" "$(git rev-parse "$tip^{tree}")" "$tip" "$(shq "$onto")" </dev/null 2>/dev/null) ||1008+ appr=$(forge_ssh approvals "$repo" "$(git rev-parse "$tip^{tree}")" </dev/null) || die "land: could not check approvals"1009+ who=$(printf '%s\n' "$appr" | sed 1d | grep -v '^|' | tr '\n' ' ' | sed 's/ *$//')1010+ # What the approval rules say (.forge/approvals on the target), a line each.1011+ rules=$(printf '%s\n' "$appr" | sed -n 's/^|//p')1012+ if [ "$(printf '%s\n' "$appr" | head -n 1)" = required ]; then1013+ printf '%s\n' "$rules" | sed '/^$/d; s/^/ /' >&21014+ if printf '%s\n' "$rules" | grep -q '^NEED '; then1015+ [ "$force" = 1 ] || die "land: #$id isn't approved enough to land on $onto (forge approve $id, by someone other than $author)"1016+ say "warning: landing #$id without the approvals it needs; the forge will still refuse it"1017+ fi1018+ elif [ -n "$who" ]; then10021019 say "approved by $who"1003- elif [ "$(printf '%s\n' "$appr" | head -n 1)" = required ] && [ "$force" = 0 ]; then1004- die "land: $onto needs an approval of #$id from someone other than $author (forge approve $id)"10051020 else10061021 say "warning: #$id is not approved"10071022 fi@@ -1015,6 +1030,138 @@ cmd_land() {
10151030 git push -q "$r" --delete $gone && say "deleted $gone"10161031 }10171032 1033+# critique [--full] [--fix] [--resolve id,...] [<id>|<branch>]: an agent1034+# review (lib/critique.ts). The reviewer is .forge/review from the review's1035+# target branch, so a change can't swap it or its rules (.forge/review.md)1036+# for its own. It runs in a clean worktree of the review's pushed tip, gets1037+# the diff on stdin, and prints findings as JSON lines; they are posted as1038+# comments (with the model's name, and blocking or note), and a statement1039+# of what it reviewed and what is still open is signed into1040+# refs/notes/review on the tip, for approval rules' "+ agent". With --fix,1041+# what the reviewer changed in the worktree becomes a commit on a branch1042+# stacked on the review, pushed for review.1043+cmd_critique() {1044+ full=0 fix=0 resolve=1045+ while [ $# -gt 0 ]; do1046+ case $1 in1047+ --full) full=1 ;;1048+ --fix) fix=1 ;;1049+ --resolve) [ $# -ge 2 ] || die "critique: --resolve needs finding ids, comma-separated"; resolve=$2; shift ;;1050+ -*) die "critique: unknown flag $1" ;;1051+ *) break ;;1052+ esac1053+ shift1054+ done1055+ [ $# -le 1 ] || die "usage: forge critique [--full] [--fix] [--resolve id,...] [<id>|<branch>]"1056+ which=${1:-}1057+ [ -n "$which" ] || which=$(git symbolic-ref -q --short HEAD) || die "critique: HEAD is detached; name the review"1058+ r=$(remote)1059+ load_rows1060+ row=$(review_row "$which")1061+ [ -n "$row" ] || die "critique: no review $which (forge reviews lists them)"1062+ tab=$(printf '\t')1063+ IFS=$tab read -r id status branch onto _ _ author title <<-EOF1064+ $row1065+ EOF1066+ case $status in open | draft) ;; *) die "critique: #$id is $status" ;; esac1067+ git fetch -q "$r" "+refs/heads/*:refs/remotes/$r/*" || die "critique: fetch from $r failed"1068+ sync_notes "$r" review1069+ tip=$(git rev-parse -q --verify "refs/remotes/$r/$branch^{commit}") || die "critique: $branch is gone from $r"1070+ target=$(git rev-parse -q --verify "refs/remotes/$r/$onto^{commit}") || die "critique: $r has no $onto"1071+ base=$(git merge-base "$target" "$tip") || die "critique: $branch has nothing in common with $onto"1072+ script=$(git rev-parse -q --verify "$target:.forge/review" 2>/dev/null) || die "critique: $onto has no .forge/review (the reviewer is read from the target branch)"1073+ rules=$(git rev-parse -q --verify "$target:.forge/review.md" 2>/dev/null || true)1074+ key=$(signing_key)1075+ tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-critique.XXXXXX")1076+ # The forge's signers first: a copy made before agent reviews existed1077+ # doesn't let keys sign them.1078+ if forge_ssh signers </dev/null >"$tmp/signers" 2>/dev/null && [ -s "$tmp/signers" ]; then1079+ signers=$tmp/signers1080+ else1081+ signers=$(git config --get forge.allowedsigners || git config --get gpg.ssh.allowedSignersFile || true)1082+ case $signers in "~/"*) signers=$HOME/${signers#"~/"} ;; esac1083+ fi1084+ # Jev's mechanical files stay out of the diff; the reviewer is told which.1085+ forge_ssh mechanical "$repo" "$base" "$tip" </dev/null >"$tmp/skip.in" 2>/dev/null || : >"$tmp/skip.in"1086+ set -- prepare --git-dir "$(git rev-parse --git-dir)" --review "$repo#$id" --base "$base" --tip "$tip" --out "$tmp"1087+ [ -z "$signers" ] || set -- "$@" --signers "$signers"1088+ [ "$full" = 0 ] || set -- "$@" --full1089+ read -r scope since carried <<-EOF1090+ $(node "$FORGE_HOME/lib/critique.ts" "$@")1091+ EOF1092+ [ -n "$scope" ] || { rm -rf "$tmp"; die "critique: could not work out what to review"; }1093+ if [ "$scope" = same ]; then1094+ if [ -z "$resolve" ]; then1095+ rm -rf "$tmp"1096+ say "#$id $(git rev-parse --short "$tip"): this tree was already reviewed, with $carried blocking finding(s) open; --full reviews it again, --resolve <id> dismisses one"1097+ [ "$carried" = 0 ]1098+ return1099+ fi1100+ fi1101+ git cat-file blob "$script" >"$tmp/review"1102+ chmod +x "$tmp/review"1103+ : >"$tmp/rules.md"1104+ [ -z "$rules" ] || git cat-file blob "$rules" >"$tmp/rules.md"1105+ started=$(date -u +%Y-%m-%dT%H:%M:%SZ)1106+ code=0 fixed=1107+ if [ "$scope" = same ]; then1108+ # Dismissing by hand: the reviewer doesn't run; the model stays the last one's.1109+ : >"$tmp/out.jsonl"1110+ else1111+ ci_checkout "$tip" 01112+ what="everything since $onto"1113+ [ "$scope" = full ] || what="what changed since $(git rev-parse --short "$since")"1114+ say "#$id $(git rev-parse --short "$tip") reviewing $what: $title"1115+ [ "$carried" = 0 ] || say " $carried blocking finding(s) still open from the last agent review"1116+ (1117+ cd "$ci_wt" || exit1118+ FORGE_REVIEW_ID=$id FORGE_REVIEW_BASE=$since FORGE_REVIEW_TIP=$tip FORGE_REVIEW_SCOPE=$scope \1119+ FORGE_REVIEW_RULES=$([ -s "$tmp/rules.md" ] && echo "$tmp/rules.md") FORGE_REVIEW_OPEN=$tmp/open.jsonl \1120+ FORGE_REVIEW_SKIPPED=$tmp/skipped FORGE_REVIEW_FIX=$fix "$tmp/review" <"$tmp/diff" >"$tmp/out.jsonl"1121+ ) || code=$?1122+ if [ "$fix" = 1 ] && [ "$code" = 0 ] && [ -n "$(git -C "$ci_wt" status --porcelain)" ]; then1123+ git -C "$ci_wt" add -A1124+ git -C "$ci_wt" commit -q -m "Fix what the agent review of #$id found" -m "forge critique --fix on $(git rev-parse --short "$tip"): $title"1125+ fixed=$(git -C "$ci_wt" rev-parse HEAD)1126+ fi1127+ ci_release1128+ fi1129+ if [ "$code" != 0 ]; then1130+ rm -rf "$tmp"1131+ die "critique: .forge/review exited $code; nothing was posted or signed"1132+ fi1133+ set -- finish --git-dir "$(git rev-parse --git-dir)" --out "$tmp" --by "$(forge_user)" --reviewer "$(whoami_at)" --started "$started" --script ".forge/review $script"1134+ [ -z "$rules" ] || set -- "$@" --rules ".forge/review.md $rules"1135+ [ -z "$resolve" ] || set -- "$@" --resolve "$resolve"1136+ read -r posts open <<-EOF1137+ $(node "$FORGE_HOME/lib/critique.ts" "$@")1138+ EOF1139+ [ -n "$posts" ] || { rm -rf "$tmp"; die "critique: could not read what the reviewer said"; }1140+ model=$(cat "$tmp/model")1141+ ssh-keygen -Y sign -n "$CNS" -f "$key" "$tmp/statement" 2>"$tmp/sign.err" || { cat "$tmp/sign.err" >&2; rm -rf "$tmp"; die "signing failed"; }1142+ cat "$tmp/statement" "$tmp/statement.sig" >"$tmp/note"1143+ strip=1144+ git notes add -h 2>&1 | grep -q -- 'stripspace' && strip=--no-stripspace1145+ git notes --ref=review append $strip -F "$tmp/note" "$tip"1146+ git push -q "$r" refs/notes/review:refs/notes/review || { rm -rf "$tmp"; die "critique: signed, but pushing refs/notes/review to $r failed; forge push sends it"; }1147+ sent=01148+ for f in "$tmp"/post/*; do1149+ [ -f "$f" ] || continue1150+ sev=$(sed -n 's/^severity //p;1q' "$f")1151+ where=$(sed -n '2s/^where //p' "$f")1152+ set -- comment "$repo" "$id" --at "$tip" --agent "$(shq "$model")" --severity "$sev"1153+ [ -z "$where" ] || set -- "$@" --line "$(shq "$where")"1154+ if sed '1,/^$/d' "$f" | forge_ssh "$@" >/dev/null; then sent=$((sent + 1)); else say " a finding could not be posted: $(sed '1,/^$/d' "$f" | head -n 1)"; fi1155+ done1156+ rm -rf "$tmp"1157+ say "#$id: $posts finding(s), $sent posted; $open blocking open; signed ($model)"1158+ if [ -n "$fixed" ]; then1159+ fb=$(forge_user)/fix-$id1160+ git push -q "$r" "$fixed:refs/heads/$fb" && say "the reviewer's fixes are on $fb, stacked on #$id: forge checkout $fb"1161+ fi1162+ [ "$open" = 0 ]1163+}1164+10181165 # ---------------------------------------------------------------- server side10191166 10201167 load_config() {@@ -1062,7 +1209,7 @@ cmd_server_init() {
10621209 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/10631210 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/10641211 EOF1065- for c in new ls whoami signers site-url reviews comment approve approvals land private public readers collaborators who release rm passkey passkeys sessions token tokens profile help; do1212+ for c in new ls whoami signers site-url reviews comment approve approvals mechanical land private public readers collaborators who release rm passkey passkeys sessions token tokens profile help; do10661213 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10671214 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10681215 done@@ -1269,8 +1416,8 @@ EOF
12691416 12701417 # keys holds "name ssh-ed25519 AAAA... comment" lines; it is the only source12711418 # of identity. authorized_keys forces every key through `forge shell <name>`,1272-# and allowed_signers lets the same keys sign CI attestations, site deploys1273-# and release checksums.1419+# and allowed_signers lets the same keys sign CI attestations, site deploys,1420+# release checksums and agent reviews.12741421 cmd_keys_sync() {12751422 load_config12761423 mkdir -p "$(dirname "$FORGE_AUTHORIZED_KEYS")"@@ -1281,7 +1428,7 @@ cmd_keys_sync() {
12811428 while read -r name key; do12821429 case $name in '' | '#'*) continue ;; esac12831430 printf 'restrict,command="%s shell %s" %s\n' "$FORGE_BIN" "$name" "$key" >>"$ak"1284- printf '%s namespaces="%s,%s,git" %s\n' "$name" "$NS" "$RNS" "$key" >>"$as"1431+ printf '%s namespaces="%s,%s,%s,git" %s\n' "$name" "$NS" "$RNS" "$CNS" "$key" >>"$as"12851432 done <"$FORGE_STATE/keys"12861433 chmod 600 "$ak"12871434 mv "$ak" "$FORGE_AUTHORIZED_KEYS"@@ -1874,12 +2021,23 @@ review_changed() {
18742021 sc_comment() {18752022 [ $# -ge 2 ] || die "usage: comment <owner>/<repo> <id|branch> [--at <sha>] [--line <path>:<line>] <message"18762023 open_review "$1" "$2"1877- slug=$1 at= where=2024+ slug=$1 at= where= agent= severity=18782025 shift 218792026 while [ $# -gt 0 ]; do18802027 case $1 in18812028 --at) [ $# -ge 2 ] || die "comment: --at needs a commit"; at=$2; shift ;;18822029 --line) [ $# -ge 2 ] || die "comment: --line needs <path>:<line>"; where=$2; shift ;;2030+ # From forge critique: which model wrote it, and how much it matters.2031+ --agent)2032+ [ $# -ge 2 ] || die "comment: --agent needs a model name"2033+ case $2 in '' | *[!A-Za-z0-9._:/@-]* | ?????????????????????????????????????????????????????????????????*) die "comment: --agent wants a plain model name" ;; esac2034+ agent=$22035+ shift2036+ ;;2037+ --severity)2038+ case ${2:-} in blocking | note) severity=$2 ;; *) die "comment: --severity is blocking or note" ;; esac2039+ shift2040+ ;;18832041 *) die "comment: unknown argument $1" ;;18842042 esac18852043 shift@@ -1919,6 +2077,8 @@ sc_comment() {
19192077 printf 'time %s\nuser %s\nsha %s\n' "${FORGE_NOW:-$(date +%s)}" "$user" "$sha"19202078 if [ -n "$rev" ]; then printf 'rev %s\n' "$rev"; fi19212079 if [ -n "$path" ]; then printf 'path %s\nline %s\ntext %s\n' "$path" "$line" "$text"; fi2080+ if [ -n "$agent" ]; then printf 'agent %s\n' "$agent"; fi2081+ if [ -n "$severity" ]; then printf 'severity %s\n' "$severity"; fi19222082 echo19232083 cat "$tmp.body"19242084 } >"$tmp"@@ -2286,11 +2446,25 @@ cmd_shell_cmd() {
22862446 approve) sc_approve "$@" ;;22872447 land) sc_land "$@" ;;22882448 approvals)2289- # For forge land: whether the repo requires an approval, then who approved this tree.2290- [ $# -eq 2 ] || die "usage: approvals <owner>/<repo> <tree>"2449+ # For forge land: whether the repo requires an approval, then who2450+ # approved this tree. With the review's tip and target, then what2451+ # the approval rules say (lib/rules.ts), each line after a "|".2452+ [ $# -eq 2 ] || [ $# -eq 4 ] || die "usage: approvals <owner>/<repo> <tree> [<tip> <target>]"22912453 open_repo "$1"22922454 if [ "$(git --git-dir="$RDIR" config --bool forge.requireapproval || echo false)" = true ]; then echo required; else echo optional; fi22932455 approvers "$RDIR" "$2"2456+ if [ $# -eq 4 ]; then2457+ tip=$(git --git-dir="$RDIR" rev-parse -q --verify "$3^{commit}") || die "approvals: no commit $3 in $1"2458+ base=$(git --git-dir="$RDIR" rev-parse -q --verify "refs/heads/$4^{commit}") || die "approvals: $1 has no $4"2459+ node "$FORGE_HOME/lib/rules.ts" check --git-dir "$RDIR" --base "$base" --tip "$tip" --signers "$FORGE_STATE/allowed_signers" 2>&1 | sed 's/^/|/' || true2460+ fi2461+ ;;2462+ mechanical)2463+ # For forge critique: the files of a change Jev judged mechanical.2464+ [ $# -eq 3 ] || die "usage: mechanical <owner>/<repo> <base> <tip>"2465+ open_repo "$1"2466+ for c in "$2" "$3"; do git --git-dir="$RDIR" rev-parse -q --verify "$c^{commit}" >/dev/null || die "mechanical: no commit $c in $1"; done2467+ node "$FORGE_HOME/lib/rules.ts" mechanical --git-dir "$RDIR" --base "$2" --tip "$3"22942468 ;;22952469 private) sc_private "$@" ;;22962470 public) sc_public "$@" ;;@@ -2322,7 +2496,7 @@ cmd_shell_cmd() {
23222496 [ -z "$r" ] || open_repo "$r"23232497 credentials token "$@"23242498 ;;2325- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\n profile [bio <text>|link <url>|pin <repo>...] what your page says above your repos: print it or set one (--clear clears one)\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"2499+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals, mechanical used by forge comment, approve, land and critique\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\n profile [bio <text>|link <url>|pin <repo>...] what your page says above your repos: print it or set one (--clear clears one)\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"23262500 if is_admin "$user"; then23272501 printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n motd [<text>|--clear|--default] the line under the header on the home page: print it, set it (72 characters at most), hide it, or go back to the default\n'23282502 fi@@ -2431,7 +2605,7 @@ hook_pre_receive() {
24312605 [ -z "$USER_" ] || can_read "$GIT_DIR" "$USER_" || deny "no repo $OWNER/$NAME"24322606 # The default branch first, so a refused push explains that one first.24332607 updates=$(awk -v d="refs/heads/$DEFAULT" '$3 == d { print; next } { r = r $0 "\n" } END { printf "%s", r }')2434- notes_new= releases=2608+ notes_new= review_notes= releases=24352609 while read -r old new ref; do24362610 [ -n "$ref" ] || continue24372611 mine=0@@ -2464,6 +2638,15 @@ hook_pre_receive() {
24642638 fi24652639 notes_new=$new24662640 ;;2641+ refs/notes/review)2642+ # Agent reviews (forge critique): signed, like attestations.2643+ if [ "$ADMIN" = 0 ]; then2644+ [ "$new" = "$ZERO" ] && deny "refs/notes/review cannot be deleted"2645+ [ "$old" = "$ZERO" ] || git merge-base --is-ancestor "$old" "$new" ||2646+ deny "refs/notes/review must fast-forward (forge push merges notes for you)"2647+ fi2648+ review_notes=$new2649+ ;;24672650 *) [ "$ADMIN" = 1 ] || deny "$ref: not allowed" ;;24682651 esac24692652 done <<-EOF@@ -2483,12 +2666,19 @@ hook_pre_receive() {
24832666 fi24842667 24852668 # Approvals are keyed by tree, like CI, and must come from someone other2486- # than the review's author (see approvers).2669+ # than the review's author (see approvers). How many, and from whom, is2670+ # .forge/approvals on the default branch as it was before this push2671+ # (lib/rules.ts); without one, a change needs one approval.24872672 if [ "$(git config --bool forge.requireapproval || echo false)" = true ]; then24882673 while read -r old new ref; do24892674 [ "$ref" = "refs/heads/$DEFAULT" ] && [ "$new" != "$ZERO" ] || continue2490- [ -n "$(approvers "$GIT_DIR" "$(git rev-parse "$new^{tree}")")" ] ||2491- deny "$DEFAULT: $(git rev-parse --short "$new") is not approved; someone other than the review's author has to run forge approve <review>"2675+ [ "$review_notes" != "$ZERO" ] || review_notes=2676+ if ! verdict=$(node "$FORGE_HOME/lib/rules.ts" check --git-dir "$GIT_DIR" --base "$old" --tip "$new" \2677+ --signers "$FORGE_STATE/allowed_signers" ${review_notes:+--review-notes "$review_notes"} 2>&1); then2678+ short=$(git rev-parse --short "$new")2679+ printf '%s\n' "$verdict" | sed -n 's/^NEED / needs /p; /^\.forge\/approvals:[0-9]*:/s/^/ /p' >&22680+ deny "$DEFAULT: $short is not approved enough; someone other than the review's author has to run forge approve <review>"2681+ fi24922682 done <<-EOF24932683 $updates24942684 EOF@@ -3950,6 +4140,7 @@ checkout) cmd_checkout "$@" ;;
39504140 comment) cmd_comment "$@" ;;39514141 approve) cmd_approve "$@" ;;39524142 land) cmd_land "$@" ;;4143+critique) cmd_critique "$@" ;;39534144 site) cmd_site "$@" ;;39544145 release) cmd_release "$@" ;;39554146 server)@@ -523,6 +523,53 @@ commit "messages: notes for the later regions" "Long lines on purpose: the revie
523523 "$forge" attest524524 "$forge" push -t "Regional notes"525525 526+step "dami: approval rules and an agent reviewer; ana runs it over Regional notes"527+cd "$work/dami"528+git switch -q main529+mkdir -p .forge530+cat >.forge/approvals <<'EOF'531+# What a change to main needs, by the files it changes (forge.requireapproval).532+.forge/ 2533+web/ 1 + agent534+*.md wiki/ docs/ none535+* 1536+EOF537+cat >.forge/review.md <<'EOF'538+- A font stack names at most four emoji fonts.539+- Lines over 160 characters need a reason.540+EOF541+cat >.forge/review <<'EOF'542+#!/bin/sh543+# The demo's reviewer: .forge/review.md's two rules in awk, so it needs no544+# model. A real one hands the diff and the rules to an agent (README.md).545+echo '{"model":"demo-rules"}'546+awk '547+ /^\+\+\+ b\// { p = substr($0, 7); long = 0; next }548+ /^@@/ { match($0, /\+[0-9]+/); n = substr($0, RSTART + 1, RLENGTH - 1) - 1; next }549+ /^\+/ { n++550+ x = $0551+ if (p ~ /\.css$/ && (k = gsub(/Emoji|Symbol/, "", x)) > 4)552+ printf "{\"severity\":\"blocking\",\"path\":\"%s\",\"line\":%d,\"body\":\"This stack names %d emoji fonts; `.forge/review.md` allows four. Which of them does a reader without Noto Color Emoji actually have?\"}\n", p, n, k553+ else if (length($0) > 161 && !long++)554+ printf "{\"severity\":\"note\",\"path\":\"%s\",\"line\":%d,\"body\":\"%d characters. If the length is the point (it is, here), say so in the commit message.\"}\n", p, n, length($0) - 1555+ next }556+ /^ / { n++ }'557+EOF558+chmod +x .forge/review559+commit "approval rules, and a reviewer for forge critique"560+"$forge" attest561+git push -q origin main refs/notes/ci562+# forge critique asks the forge for the review list, which the page build writes.563+"$forge" build dami/greet >/dev/null564+cd "$work/ana"565+export FORGE_USER=ana GIT_SSH_COMMAND=$work/ssh566+git remote set-url origin git@forge.local:dami/greet.git567+git fetch -q origin568+"$forge" critique dami/regions || echo " (expected) a blocking finding is open"569+git remote set-url origin "$FORGE_ROOT/dami/greet.git"570+unset GIT_SSH_COMMAND571+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true572+526573 step "dami: forge's own guide, a site whose build copies in the forge's fonts"527574 export FORGE_USER=dami528575 "$forge" repo new dami/forge "a personal git forge"@@ -157,6 +157,19 @@
157157 <span class="dim">$</span> forge approve 2</pre>158158 <div class="prose">159159 <p>An approval is for the code as it is: rewording a commit keeps it, changing the code marks it stale. You can't approve your own review. After a new revision, older comments are marked outdated and keep the line they quoted. A repo can require someone else's approval before <code>main</code> moves.</p>160+<h3>Approval rules</h3>161+<p>How much approval, and from whom, can depend on what a change touches. <code>.forge/approvals</code> on <code>main</code> gives each path a rule; a change needs every rule its files take. The rules are read from <code>main</code> as it was, so a review can't loosen its own.</p>162+</div>163+<pre class="code"><span class="dim"># paths rule</span>164+.forge/ bin/forge 2165+lib/identity.ts 1 from fox166+lib/ 1 + agent167+*.md wiki/ none168+* 1</pre>169+<div class="prose">170+<p>A review page starts with what landing needs: CI, each rule and who has approved, and the latest agent review.</p>171+<h3>Agent reviews</h3>172+<p><code>forge critique</code> runs the reviewer <code>main</code> names, <code>.forge/review</code>, over a review on your machine: Claude Code, Codex or a script of your own. It gets the diff and the project's rules in <code>.forge/review.md</code>. Its findings become comments marked <b>agent</b>, some <b>blocking</b>, and it signs what is still open into a git note, as CI does. The next run looks only at what changed since and carries the open findings forward until the reviewer, or you with <code>--resolve</code>, says they are dealt with. A rule's <code>+ agent</code> wants one with nothing blocking.</p>160173 <h3>Landing</h3>161174 <p><code>forge land</code> checks CI (and approval, if required), fast-forwards <code>main</code> and deletes the landed branches. Over ssh the forge does it itself, no clone needed:</p>162175 </div>@@ -204,6 +217,7 @@ log sha256:6cc79019... 4 lines
204217 <div class="prose">205218 <p>To make it stronger, keep the key in hardware: a Secure Enclave key through <a href="https://github.com/maxgoedjen/secretive">Secretive</a>, or a FIDO <code>sk-</code> key. Add a <code>flake.nix</code> so CI runs under <code>nix develop</code>. Have a second person run <code>forge attest</code> too; results from several people add up.</p>206219 <p>A repo can require a verified pass before <code>main</code> moves (for the new tip or every new commit) and for a release's tag. Pushes and lands without one are refused.</p>220+<p>CI can also report numbers about a commit, a line each to <code>$FORGE_METRICS</code>: <code>bundle_kb 412 KB</code>, <code>tests 318</code>. They are signed with the result, and the repo's insights chart each one commit by commit on <code>main</code>.</p>207221 </div>208222 </section>209223 @@ -306,7 +320,8 @@ collaborator fox</span></pre>
306320 <div><dt>forge reviews</dt><dd>Open and draft reviews; <code>forge checkout <id></code> fetches one.</dd></div>307321 <div><dt>forge comment [<id>] [path:line] -m "..."</dt><dd>Comment on a review, or on one line of it. Without <code>-m</code>, reads stdin or opens <code>$EDITOR</code>.</dd></div>308322 <div><dt>forge approve [<id>]</dt><dd>Approve the review as it is now.</dd></div>309-<div><dt>forge land [<id>]</dt><dd>Check CI and approval, move <code>main</code> forward, delete the landed branches.</dd></div>323+<div><dt>forge critique [<id>]</dt><dd>An agent review: run <code>main</code>'s <code>.forge/review</code>, post its findings, sign what is open. <code>--full</code>, <code>--fix</code>, <code>--resolve <id></code>.</dd></div>324+<div><dt>forge land [<id>]</dt><dd>Check CI and the approval rules, move <code>main</code> forward, delete the landed branches.</dd></div>310325 <div><dt>ssh git@src.rest land cat/greet 7</dt><dd>Have the forge land review 7, no clone needed, with the same checks. <code>forge land --server</code> does it from a clone.</dd></div>311326 </dl></section>312327 <section class="panel" aria-labelledby="cs-ci"><h3 class="panel-head" id="cs-ci">ci</h3><dl>@@ -150,7 +150,7 @@ deleted 1788292812 cat <span class="dim"># only o
150150 <div class="by">forge keys sync</div>151151 <div class="outs">152152 <div class="box"><span class="file">.ssh/authorized_keys</span><code>restrict,command="/<wbr>opt/<wbr>forge/<wbr>bin/<wbr>forge <wbr>shell <wbr>fox" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who is pushing: hooks see <code>FORGE_USER=fox</code>.</p></div>153-<div class="box"><span class="file">.forge/allowed_signers</span><code>fox <wbr>namespaces="forge-ci,forge-release,git" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who signed a CI result, a deploy or a release's checksums. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>153+<div class="box"><span class="file">.forge/allowed_signers</span><code>fox <wbr>namespaces="forge-ci,forge-release,forge-review,git" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who signed a CI result, a deploy, a release's checksums or an agent review. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>154154 </div>155155 <figcaption><b>fig. 3</b>one line in, two generated files out</figcaption>156156 </figure>@@ -63,15 +63,19 @@ export function parseNote(text: string): Attestation[] {
6363 6464 // Verifies statements against an allowed_signers file, memoized on disk by6565 // statement hash (statements and signatures are immutable).66+// The namespace is what the statements were signed for: forge-ci for CI67+// attestations, forge-review for agent reviews (lib/critique.ts).6668 export class Verifier {6769 allowedSigners: string | null;6870 cacheFile: string | null;71+ namespace: string;6972 cache: Record<string, Verification> = {};7073 dirty = false;7174 72- constructor({ allowedSigners, cacheFile }: { allowedSigners: string | null; cacheFile: string | null }) {75+ constructor({ allowedSigners, cacheFile, namespace = NAMESPACE }: { allowedSigners: string | null; cacheFile: string | null; namespace?: string }) {7376 this.allowedSigners = allowedSigners && fs.existsSync(allowedSigners) ? allowedSigners : null;7477 this.cacheFile = cacheFile;78+ this.namespace = namespace;7579 if (cacheFile && fs.existsSync(cacheFile)) {7680 try {7781 this.cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));@@ -81,9 +85,9 @@ export class Verifier {
8185 }8286 }8387 84- verify(att: Attestation): Verification {88+ verify(att: { payload: string; signature: string }): Verification {8589 if (!this.allowedSigners) return { ok: false, signer: null, reason: 'no allowed_signers configured' };86- const key = createHash('sha256').update(this.allowedSigners + '\0' + fs.statSync(this.allowedSigners).mtimeMs + '\0' + att.payload + att.signature).digest('hex');90+ const key = createHash('sha256').update(this.allowedSigners + '\0' + fs.statSync(this.allowedSigners).mtimeMs + '\0' + this.namespace + '\0' + att.payload + att.signature).digest('hex');8791 const hit = this.cache[key];8892 if (hit) return hit;8993 const res = this.check(att, this.allowedSigners);@@ -92,7 +96,7 @@ export class Verifier {
9296 return res;9397 }9498 95- private check(att: Attestation, signers: string): Verification {99+ private check(att: { payload: string; signature: string }, signers: string): Verification {96100 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-att-'));97101 try {98102 const sig = path.join(dir, 'sig');@@ -100,7 +104,7 @@ export class Verifier {
100104 const find = spawnSync('ssh-keygen', ['-Y', 'find-principals', '-s', sig, '-f', signers], { encoding: 'utf8' });101105 const principal = find.status === 0 ? find.stdout.split('\n')[0].trim() : null;102106 if (!principal) return { ok: false, signer: null, reason: 'signing key not in allowed_signers' };103- const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', principal, '-n', NAMESPACE, '-s', sig], { input: att.payload, encoding: 'utf8' });107+ const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', principal, '-n', this.namespace, '-s', sig], { input: att.payload, encoding: 'utf8' });104108 if (v.status !== 0) return { ok: false, signer: principal, reason: 'bad signature' };105109 return { ok: true, signer: principal, key: /key (\S+ SHA256:\S+)/.exec(v.stdout + v.stderr)?.[1] ?? null };106110 } finally {@@ -0,0 +1,343 @@
1+// Agent reviews: forge critique runs a reviewer (.forge/review, from the2+// review's target branch) over a review's change on your machine, posts what3+// it finds as comments, and signs a statement into refs/notes/review on the4+// tip, like a CI attestation:5+//6+// forge-review 17+// review cat/greet#78+// commit 2aa8af0...9+// tree 58945d0...10+// since 3c1e2aa... (what it reviewed from: the base, or the last reviewed tip)11+// scope full|incremental|resolved (resolved: findings dismissed by hand, the reviewer not run)12+// script .forge/review 99a0d5e...13+// rules .forge/review.md 1b2c3d4... (or none)14+// model claude-opus-5-515+// reviewer cat@gentle-wren-4c16+// started 2026-11-04T10:00:00Z17+// findings 3 (new this run)18+// open 1 (blocking findings still open, carried ones too)19+// |{"id":"2aa8af0-1","severity":"blocking","path":"a.js","line":12,"body":"..."}20+// |{"resolved":"9f8e7d6-2","by":"agent"}21+// -----BEGIN SSH SIGNATURE----- (ssh-keygen -Y sign -n forge-review)22+//23+// The | lines are the open blocking findings and this run's notes, as JSON,24+// and what was resolved since the last statement. Each statement is25+// self-contained: "open 0" is what an approval rule's "+ agent" asks for26+// (lib/rules.ts). Like CI, a statement is the signer's claim about a tree.27+//28+// The reviewer protocol: .forge/review gets the diff on stdin and runs in a29+// clean worktree of the tip. Environment:30+//31+// FORGE_REVIEW_ID 732+// FORGE_REVIEW_BASE the commit the diff is from33+// FORGE_REVIEW_TIP the commit it is to34+// FORGE_REVIEW_SCOPE full, or incremental (only what changed since the last agent review)35+// FORGE_REVIEW_RULES a file with .forge/review.md from the target branch, or empty36+// FORGE_REVIEW_OPEN a file of the open blocking findings, a JSON object a line37+// FORGE_REVIEW_SKIPPED a file of paths left out of the diff (mechanical, by Jev), a line each38+//39+// It prints JSON objects, a line each (anything else is ignored):40+//41+// {"model":"claude-opus-5-5"}42+// {"severity":"blocking","path":"lib/a.ts","line":12,"body":"markdown"}43+// {"severity":"note","body":"about the whole change"}44+// {"resolved":"9f8e7d6-2"} (an open finding the change has dealt with)45+//46+// node lib/critique.ts prepare --git-dir . --review <owner>/<repo>#<id> --base <c> --tip <c> --out <dir> [--full] [--signers <f>]47+// node lib/critique.ts finish --git-dir . --out <dir> [--resolve id,id] [--by <user>]48+import fs from 'node:fs';49+import path from 'node:path';50+import { fileURLToPath } from 'node:url';51+import { catFileBatch, isAncestor, tryRun } from './git.ts';52+import { Verifier } from './attest.ts';53+import type { Verification } from './types.ts';54+55+export const REVIEW_NAMESPACE = 'forge-review';56+export const NOTES = 'refs/notes/review';57+const BLOCK = /^forge-review 1\n[\s\S]*?^-----END SSH SIGNATURE-----$/gm;58+// Caps on what a reviewer may say in one run.59+export const MAX_FINDINGS = 50;60+export const MAX_BODY = 4000;61+62+export type Severity = 'blocking' | 'note';63+64+export interface Finding {65+ id: string;66+ severity: Severity;67+ path: string | null;68+ line: number | null;69+ body: string;70+}71+72+export interface AgentReview {73+ payload: string;74+ signature: string;75+ review: string;76+ commit: string;77+ tree: string;78+ since: string | null;79+ scope: string;80+ script: string | null;81+ rules: string | null;82+ model: string;83+ reviewer: string | null;84+ started: string;85+ findings: number;86+ open: number;87+ items: Finding[];88+ resolved: { id: string; by: string }[];89+ verification: Verification;90+ ok: boolean;91+}92+93+export function parseReviewNote(text: string): AgentReview[] {94+ const out: AgentReview[] = [];95+ for (const m of text.matchAll(BLOCK)) {96+ const block = m[0];97+ const at = block.search(/^-----BEGIN SSH SIGNATURE-----$/m);98+ if (at < 0) continue;99+ const payload = block.slice(0, at);100+ const f: Record<string, string> = {};101+ const items: Finding[] = [];102+ const resolved: { id: string; by: string }[] = [];103+ for (const line of payload.split('\n').slice(1)) {104+ if (line.startsWith('|')) {105+ try {106+ const o = JSON.parse(line.slice(1));107+ if (typeof o.resolved === 'string') resolved.push({ id: o.resolved, by: String(o.by ?? 'agent') });108+ else if (typeof o.id === 'string' && typeof o.body === 'string') {109+ items.push({ id: o.id, severity: o.severity === 'blocking' ? 'blocking' : 'note', path: typeof o.path === 'string' ? o.path : null, line: Number.isInteger(o.line) ? o.line : null, body: o.body });110+ }111+ } catch {112+ // Not one of ours; the signature still covers it.113+ }114+ continue;115+ }116+ const sp = line.indexOf(' ');117+ if (sp > 0) f[line.slice(0, sp)] = line.slice(sp + 1);118+ }119+ if (!f.commit || !f.tree) continue;120+ const verification: Verification = { ok: false, signer: null, reason: 'not checked' };121+ out.push({122+ payload, signature: block.slice(at) + '\n', review: f.review ?? '', commit: f.commit, tree: f.tree, since: f.since ?? null,123+ scope: f.scope ?? 'full', script: f.script ?? null, rules: f.rules && f.rules !== 'none' ? f.rules : null, model: f.model ?? 'unknown',124+ reviewer: f.reviewer ?? null, started: f.started ?? '', findings: Number(f.findings) || 0, open: Number.isInteger(Number(f.open)) ? Number(f.open) : NaN,125+ items, resolved, verification, ok: false,126+ });127+ }128+ return out;129+}130+131+// Every agent review in a repo's notes (or in the notes commit `notes`, for132+// a push that brings them), verified, by tree.133+export function loadAgentReviews(gitDir: string, verifier: Verifier, notes: string = NOTES): Map<string, AgentReview[]> {134+ const byTree = new Map<string, AgentReview[]>();135+ const list = tryRun(gitDir, ['ls-tree', '-r', notes]);136+ if (!list) return byTree;137+ const blobs = list.split('\n').filter(Boolean).map((l) => l.split(/\s+/)).filter((x) => x[1] === 'blob').map((x) => x[2]);138+ for (const b of catFileBatch(gitDir, blobs).values()) {139+ for (const r of parseReviewNote(b.data.toString('utf8'))) {140+ r.verification = verifier.verify(r);141+ r.ok = r.verification.ok && Number.isFinite(r.open);142+ byTree.set(r.tree, [...(byTree.get(r.tree) ?? []), r]);143+ }144+ }145+ for (const l of byTree.values()) l.sort((a, b) => b.started.localeCompare(a.started));146+ return byTree;147+}148+149+export function agentReviews(gitDir: string, tree: string, allowedSigners: string | null, notes: string = NOTES): AgentReview[] {150+ const v = new Verifier({ allowedSigners, cacheFile: null, namespace: REVIEW_NAMESPACE });151+ return loadAgentReviews(gitDir, v, notes).get(tree) ?? [];152+}153+154+// The latest verified agent review of a review (owner/repo#id) among them.155+export function latestFor(all: Iterable<AgentReview[]>, review: string): AgentReview | null {156+ let best: AgentReview | null = null;157+ for (const l of all) for (const r of l) if (r.ok && r.review === review && (!best || r.started > best.started)) best = r;158+ return best;159+}160+161+// ---------------------------------------------------------------- prepare and finish162+163+const write = (dir: string, f: string, s: string): void => fs.writeFileSync(path.join(dir, f), s);164+const read = (dir: string, f: string): string => (fs.existsSync(path.join(dir, f)) ? fs.readFileSync(path.join(dir, f), 'utf8') : '');165+166+interface State {167+ review: string;168+ base: string;169+ tip: string;170+ tree: string;171+ since: string;172+ // same: the last agent review was of this tree; nothing new to review.173+ scope: 'full' | 'incremental' | 'same';174+ carried: Finding[];175+ paths: string[];176+ // The last agent review's model, for a statement that only dismisses.177+ model: string | null;178+}179+180+// What to review: everything from base, or, after an earlier agent review of181+// this review whose tip is still an ancestor, only what came since. Writes182+// the diff, the open findings and the skipped paths into out for the183+// reviewer, and state.json for finish.184+export function prepare(o: { gitDir: string; review: string; base: string; tip: string; out: string; full: boolean; signers: string | null; skipped: string[] }): State {185+ const tree = tryRun(o.gitDir, ['rev-parse', `${o.tip}^{tree}`])?.trim() ?? '';186+ const v = new Verifier({ allowedSigners: o.signers, cacheFile: null, namespace: REVIEW_NAMESPACE });187+ const all = loadAgentReviews(o.gitDir, v);188+ const last = latestFor(all.values(), o.review);189+ const carried = last ? last.items.filter((f) => f.severity === 'blocking') : [];190+ let since = o.base;191+ let scope: State['scope'] = 'full';192+ if (!o.full && last && last.tree === tree) {193+ since = last.since ?? o.base;194+ scope = 'same';195+ } else if (!o.full && last && last.tree !== tree && tryRun(o.gitDir, ['cat-file', '-e', `${last.commit}^{commit}`]) != null && isAncestor(o.gitDir, o.base, last.commit) && isAncestor(o.gitDir, last.commit, o.tip)) {196+ since = last.commit;197+ scope = 'incremental';198+ }199+ const skip = o.skipped.map((p) => `:(exclude,literal)${p}`);200+ const diff = tryRun(o.gitDir, ['diff', '--no-color', '-M', since, o.tip, '--', '.', ...skip]) ?? '';201+ const paths = (tryRun(o.gitDir, ['diff', '--name-only', '-z', '-M', o.base, o.tip]) ?? '').split('\0').filter(Boolean);202+ fs.mkdirSync(o.out, { recursive: true });203+ write(o.out, 'diff', diff);204+ write(o.out, 'open.jsonl', carried.map((f) => JSON.stringify(f)).join('\n') + (carried.length ? '\n' : ''));205+ write(o.out, 'skipped', o.skipped.map((p) => p + '\n').join(''));206+ const state: State = { review: o.review, base: o.base, tip: o.tip, tree, since, scope, carried, paths, model: last?.model ?? null };207+ write(o.out, 'state.json', JSON.stringify(state));208+ return state;209+}210+211+export interface Post {212+ finding: Finding;213+ where: string | null;214+}215+216+// Read what the reviewer printed, check it, and write the statement to sign217+// (out/statement) and the comments to post (out/post/<n>: a header of218+// "where <path>:<line>" and "severity <s>", a blank line, the body).219+export function finish(o: { gitDir: string; out: string; resolve: string[]; by: string; reviewer: string; started: string; script: string; rules: string | null }): { posts: Post[]; open: number; warnings: string[]; model: string } {220+ const st = JSON.parse(read(o.out, 'state.json')) as State;221+ const warnings: string[] = [];222+ let model = st.scope === 'same' && st.model ? st.model : 'unknown';223+ const fresh: Finding[] = [];224+ const resolvedByAgent = new Set<string>();225+ const changed = new Set(st.paths);226+ const lines = new Map<string, number>();227+ const lineCount = (p: string): number => {228+ if (!lines.has(p)) {229+ const blob = tryRun(o.gitDir, ['cat-file', 'blob', `${st.tip}:${p}`]);230+ lines.set(p, blob == null ? 0 : blob.split('\n').length - (blob.endsWith('\n') ? 1 : 0));231+ }232+ return lines.get(p)!;233+ };234+ read(o.out, 'out.jsonl').split('\n').forEach((raw, i) => {235+ const t = raw.trim();236+ if (!t.startsWith('{')) return;237+ let x: Record<string, unknown>;238+ try {239+ x = JSON.parse(t);240+ } catch {241+ return void warnings.push(`line ${i + 1} isn't JSON; left out`);242+ }243+ if (typeof x.model === 'string' && Object.keys(x).length === 1) {244+ if (/^[A-Za-z0-9._:/@-]{1,64}$/.test(x.model)) model = x.model;245+ else warnings.push(`model "${x.model.slice(0, 40)}" isn't a plain name; left out`);246+ return;247+ }248+ if (typeof x.resolved === 'string') {249+ if (st.carried.some((f) => f.id === x.resolved)) resolvedByAgent.add(x.resolved);250+ else warnings.push(`resolved "${x.resolved}" isn't an open finding`);251+ return;252+ }253+ if (typeof x.body !== 'string' || !x.body.trim()) return void warnings.push(`line ${i + 1} has no body; left out`);254+ if (fresh.length >= MAX_FINDINGS) {255+ if (fresh.length === MAX_FINDINGS) warnings.push(`more than ${MAX_FINDINGS} findings; the rest are left out`);256+ return;257+ }258+ let p = typeof x.path === 'string' && x.path ? x.path : null;259+ let line = Number.isInteger(x.line) && (x.line as number) > 0 ? (x.line as number) : null;260+ if (p && (!changed.has(p) || /[\0-\x1f]/.test(p))) {261+ warnings.push(`${p.slice(0, 80)} isn't a file the review changes; posted as a comment on the whole review`);262+ p = null;263+ line = null;264+ }265+ if (p && !lineCount(p)) {266+ warnings.push(`${p} isn't in the tip (deleted, or empty); posted as a comment on the whole review`);267+ p = null;268+ line = null;269+ }270+ if (p && line && line > lineCount(p)) {271+ warnings.push(`${p}:${line} is past the end of the file; posted on the file's first line`);272+ line = 1;273+ }274+ if (p && !line) line = 1;275+ fresh.push({ id: `${st.tip.slice(0, 7)}-${fresh.length + 1}`, severity: x.severity === 'blocking' ? 'blocking' : 'note', path: p, line, body: x.body.trim().slice(0, MAX_BODY) });276+ });277+ const byHand = new Set(o.resolve.filter(Boolean));278+ for (const id of byHand) if (!st.carried.some((f) => f.id === id)) warnings.push(`--resolve ${id}: no such open finding`);279+ const resolved = [280+ ...[...resolvedByAgent].filter((id) => !byHand.has(id)).map((id) => ({ resolved: id, by: 'agent' })),281+ ...[...byHand].filter((id) => st.carried.some((f) => f.id === id)).map((id) => ({ resolved: id, by: o.by })),282+ ];283+ const gone = new Set(resolved.map((r) => r.resolved));284+ const stillOpen = st.carried.filter((f) => !gone.has(f.id));285+ const items = [...stillOpen, ...fresh];286+ const open = items.filter((f) => f.severity === 'blocking').length;287+ const statement = [288+ 'forge-review 1',289+ `review ${st.review}`,290+ `commit ${st.tip}`,291+ `tree ${st.tree}`,292+ `since ${st.since}`,293+ `scope ${st.scope === 'same' ? 'resolved' : st.scope}`,294+ `script ${o.script}`,295+ `rules ${o.rules ?? 'none'}`,296+ `model ${model}`,297+ `reviewer ${o.reviewer}`,298+ `started ${o.started}`,299+ `findings ${fresh.length}`,300+ `open ${open}`,301+ ...items.map((f) => '|' + JSON.stringify(f)),302+ ...resolved.map((r) => '|' + JSON.stringify(r)),303+ ].join('\n') + '\n';304+ write(o.out, 'statement', statement);305+ const posts = fresh.map((f) => ({ finding: f, where: f.path ? `${f.path}:${f.line}` : null }));306+ fs.mkdirSync(path.join(o.out, 'post'), { recursive: true });307+ posts.forEach((p, i) => write(path.join(o.out, 'post'), String(i + 1).padStart(3, '0'), `severity ${p.finding.severity}\nwhere ${p.where ?? ''}\n\n${p.finding.body}\n`));308+ write(o.out, 'model', model);309+ return { posts, open, warnings, model };310+}311+312+// ---------------------------------------------------------------- cli313+314+// Run directly (realpath: /tmp is a symlink on macOS, and import.meta.url is resolved).315+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {316+ const args = process.argv.slice(2);317+ const opt = (k: string): string | null => {318+ const i = args.indexOf(k);319+ return i >= 0 ? (args[i + 1] ?? null) : null;320+ };321+ const need = (k: string): string => {322+ const v = opt(k);323+ if (v == null) {324+ process.stderr.write(`critique: ${args[0]} needs ${k}\n`);325+ process.exit(2);326+ }327+ return v;328+ };329+ if (args[0] === 'prepare') {330+ const out = need('--out');331+ const skipped = fs.existsSync(path.join(out, 'skip.in')) ? fs.readFileSync(path.join(out, 'skip.in'), 'utf8').split('\n').filter(Boolean) : [];332+ const signers = opt('--signers');333+ const st = prepare({ gitDir: need('--git-dir'), review: need('--review'), base: need('--base'), tip: need('--tip'), out, full: args.includes('--full'), signers: signers && fs.existsSync(signers) ? signers : null, skipped });334+ process.stdout.write(`${st.scope} ${st.since} ${st.carried.length}\n`);335+ } else if (args[0] === 'finish') {336+ const r = finish({ gitDir: need('--git-dir'), out: need('--out'), resolve: (opt('--resolve') ?? '').split(','), by: need('--by'), reviewer: need('--reviewer'), started: need('--started'), script: need('--script'), rules: opt('--rules') });337+ for (const w of r.warnings) process.stderr.write(`forge: critique: ${w}\n`);338+ process.stdout.write(`${r.posts.length} ${r.open}\n`);339+ } else {340+ process.stderr.write('usage: node lib/critique.ts prepare|finish ...\n');341+ process.exit(2);342+ }343+}@@ -3,7 +3,8 @@
33 //44 // A comment is a file, <repo>.git/forge/comments/<review>/<n>: header lines55 // ("time", "user", "sha", "rev", and for a line comment "path", "line",6-// "text"), a blank line, then the message. An approval is a line in the6+// "text"; from forge critique, "agent" and "severity"), a blank line, then7+// the message. An approval is a line in the78 // review's log, "approve <ts> <user> <sha> <tree>", and like CI it is keyed by89 // tree, so rewording or reordering commits keeps it.910 import fs from 'node:fs';@@ -30,6 +31,7 @@ export function readComments(gitDir: string, id: number): ReviewComment[] {
3031 n, ts: Number(head.time), user: head.user ?? '', sha: head.sha ?? '', rev: head.rev ? Number(head.rev) : null,3132 path: head.path || null, line: head.line ? Number(head.line) : null, text: head.text ?? null,3233 body: blank < 0 ? '' : text.slice(blank + 2).replace(/\s+$/, ''), outdated: false, inDiff: false,34+ agent: head.agent || null, severity: head.severity === 'blocking' || head.severity === 'note' ? head.severity : null,3335 };3436 });3537 }@@ -398,6 +398,8 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
398398 stack: stack.map((x) => [x.review.id, x.review.status, x.review.title, x.review.branch, x.review.layer.length, x.review.ci, x.depth]),399399 live: r.live,400400 discussion: [r.opts.description, r.comments.map((c) => [c.n, c.rev, c.outdated]), r.approvals.map((a) => [a.user, a.rev, a.current])],401+ // The to land panel: the rules' verdict and the latest agent review.402+ gate: r.gate ?? null, agent: r.agent ?? null, ci: r.ci,401403 jev,402404 });403405 const p = page(u.review(r.id).path, `#${r.id} ${r.title} - ${repo.slug}`, 'reviews', { review: r, stack, stackAt: stack.findIndex((x) => x.review === r) });@@ -333,6 +333,10 @@ const kindOf = (store: JevStore, key: string): Kind | null => {
333333 return c && c in WORDS ? (c as Kind) : null;334334 };335335 336+// Whether Jev judged the change from blob a to blob b mechanical (approval337+// rules' @mechanical, lib/rules.ts). No stored answer is no.338+export const mechanicalPair = (store: JevStore, a: string, b: string): boolean => (noulOf(store.get('file.v1', `${a}.${b}`), 'mechanical') ?? 0) >= MECHANICAL;339+336340 // The hook for diff(): whether Jev judged a file's change mechanical.337341 export function isMechanical(gitDir: string): (f: DiffFile) => boolean {338342 const store = new JevStore(gitDir);@@ -10,7 +10,9 @@ import path from 'node:path';
1010 import { randomBytes } from 'node:crypto';1111 import { run, tryRun, revList, catFileBatch, isBinary } from './git.ts';1212 import { parseDiff } from './diff.ts';13-import { parseNote, ciStatus, type Verifier } from './attest.ts';13+import { parseNote, ciStatus, Verifier } from './attest.ts';14+import { approverMap, check, needText } from './rules.ts';15+import { latestFor, loadAgentReviews, REVIEW_NAMESPACE } from './critique.ts';1416 import { readReviewLogs, buildReviews } from './reviews.ts';1517 import { settle } from './discussion.ts';1618 import { loadSites } from './sites.ts';@@ -266,6 +268,34 @@ export function loadRepo(cfg: Config, summary: RepoSummary, verifier: Verifier,
266268 // Comments and approvals against the tip: outdated, stale.267269 for (const r of reviews) settle(r, commits.get(r.tip)?.tree ?? tryRun(dir, ['rev-parse', `${r.tip}^{tree}`])?.trim() ?? null);268270 271+ // What landing each live review needs, by the approval rules on its272+ // target (lib/rules.ts), and its latest agent review (lib/critique.ts).273+ spans.time('gates', () => {274+ const live = reviews.filter((r) => r.live && (r.status === 'open' || r.status === 'draft'));275+ if (!live.length) return;276+ const required = tryRun(dir, ['config', '--bool', 'forge.requireapproval'])?.trim() === 'true';277+ const agents = loadAgentReviews(dir, new Verifier({ allowedSigners: cfg.allowedSigners, cacheFile: null, namespace: REVIEW_NAMESPACE }));278+ const approvers = approverMap(dir);279+ for (const r of live) {280+ const tree = commits.get(r.tip)?.tree ?? '';281+ const last = latestFor(agents.values(), `${summary.slug}#${r.id}`);282+ r.agent = last ? {283+ sha: last.commit, model: last.model, signer: last.verification.signer, started: last.started, scope: last.scope,284+ findings: last.findings, open: last.open, current: last.tree === tree, resolved: last.resolved.length,285+ } : null;286+ const target = branchMap.get(r.target);287+ if (!target) continue;288+ // From where the review left the target: a review behind it doesn't289+ // change what the target gained since.290+ const base = tryRun(dir, ['merge-base', target, r.tip])?.trim() || target;291+ const v = check({ gitDir: dir, base, tip: r.tip, rulesAt: target, approvers: (t) => approvers.get(t) ?? [], agent: (t) => agents.get(t) ?? [] });292+ r.gate = {293+ required, ok: v.ok, rules: v.found, errors: v.errors, free: v.files.length > 0 && !v.needs.length,294+ needs: v.needs.map((n) => ({ line: n.rule.line, text: needText(n.rule), paths: n.paths, have: n.have, agent: n.rule.agent, agentOk: n.agentOk, ok: n.ok })),295+ };296+ }297+ });298+269299 const trees = new Map<string, Tree>();270300 spans.time('trees', () => {271301 for (const b of branches) trees.set(b.name, loadTree(dir, b.sha, cfg.maxFiles));@@ -0,0 +1,304 @@
1+// Approval rules by path: .forge/approvals on a repo's default branch says2+// how much approval a change to main needs, by the files it changes. The3+// pre-receive gate (with forge.requireapproval), forge land and the review4+// pages all ask this module, so they never disagree.5+//6+// # paths rule7+// .forge/** bin/forge 28+// lib/identity.ts lib/login.ts 1 from fox9+// lib/** 1 + agent10+// *.md guide/** wiki/** none11+// @mechanical none12+// * 113+//14+// Each changed file takes the first line it matches. A rule is "none", or a15+// number of approvals, optionally "from" a comma-separated list of people16+// (then that many of them), optionally "+ agent": a verified agent review17+// of the tree with no blocking findings (forge critique). A change needs18+// every rule its files took. A file no line matches needs 1, as without a19+// file. @mechanical matches files Jev judged mechanical (the review page20+// folds them); a file with no stored answer isn't. A rename counts both21+// its paths, so moving a file out of a guarded directory needs that rule.22+//23+// Paths: a pattern without a slash matches a file's name in any directory;24+// one with a slash matches from the top. * is any run of characters but /,25+// ** any run at all, ? one character but /, and a trailing / is everything26+// under that directory.27+//28+// The rules come from the default branch as it is before the change (the29+// tip a push would move), never from the change itself, so a review can't30+// loosen its own rules: a change to .forge/approvals lands under the old31+// ones.32+//33+// node lib/rules.ts check --git-dir <dir> --base <commit|-> --tip <commit> [--rules-at <commit|->] [--json]34+//35+// prints what the change needs and has, and exits 1 if it isn't met.36+import fs from 'node:fs';37+import { fileURLToPath } from 'node:url';38+import { tryRun } from './git.ts';39+import { JevStore } from './jev.ts';40+import { mechanicalPair } from './jev-reviews.ts';41+import { readReviewLogs } from './reviews.ts';42+import { agentReviews, type AgentReview } from './critique.ts';43+44+export const RULES_FILE = '.forge/approvals';45+46+export interface Rule {47+ // 1-based line in the file; 0 for the default.48+ line: number;49+ text: string;50+ patterns: string[];51+ mechanical: boolean;52+ // Approvals needed (0 with none).53+ need: number;54+ from: string[] | null;55+ agent: boolean;56+ res: RegExp[];57+}58+59+export const DEFAULT_RULE: Rule = { line: 0, text: '1 (the default)', patterns: [], mechanical: false, need: 1, from: null, agent: false, res: [] };60+61+// A glob as a regex over the whole path.62+export function globRe(p: string): RegExp {63+ let g = p.endsWith('/') ? `${p}**` : p;64+ const anchored = g.slice(0, -1).includes('/') || g.startsWith('/');65+ g = g.replace(/^\//, '');66+ let re = '';67+ for (let i = 0; i < g.length; i++) {68+ const c = g[i];69+ if (c === '*' && g[i + 1] === '*') {70+ i++;71+ if (g[i + 1] === '/') {72+ i++;73+ re += '(?:.*/)?';74+ } else re += '.*';75+ } else if (c === '*') re += '[^/]*';76+ else if (c === '?') re += '[^/]';77+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');78+ }79+ return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${re}$`);80+}81+82+const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;83+84+export function parseRules(text: string): { rules: Rule[]; errors: string[] } {85+ const rules: Rule[] = [];86+ const errors: string[] = [];87+ text.split('\n').forEach((raw, i) => {88+ const line = raw.replace(/#.*/, '').trim();89+ if (!line) return;90+ const words = line.split(/\s+/);91+ const at = words.findIndex((w) => w === 'none' || /^\d+$/.test(w));92+ const bad = (why: string): void => void errors.push(`${RULES_FILE}:${i + 1}: ${why}`);93+ if (at <= 0) return bad('expected paths, then a rule: none, or a number of approvals');94+ const patterns = words.slice(0, at);95+ const rest = words.slice(at);96+ let need = 0;97+ let from: string[] | null = null;98+ let agent = false;99+ if (rest[0] === 'none') {100+ if (rest.length > 1) return bad('nothing may follow none');101+ } else {102+ need = Number(rest[0]);103+ let j = 1;104+ if (rest[j] === 'from') {105+ from = (rest[j + 1] ?? '').split(',').filter(Boolean);106+ if (!from.length || !from.every((n) => NAME.test(n))) return bad('from takes a comma-separated list of names');107+ if (need > from.length) return bad(`${need} approvals from ${from.length} ${from.length === 1 ? 'person' : 'people'} can never be met`);108+ j += 2;109+ }110+ if (rest[j] === '+' && rest[j + 1] === 'agent') {111+ agent = true;112+ j += 2;113+ }114+ if (j < rest.length) return bad(`unexpected "${rest.slice(j).join(' ')}"; a rule is none, or N [from a,b] [+ agent]`);115+ if (need === 0 && !agent) return bad('0 approvals: write none');116+ }117+ const mechanical = patterns.includes('@mechanical');118+ const globs = patterns.filter((p) => p !== '@mechanical');119+ if (patterns.some((p) => p.startsWith('@') && p !== '@mechanical')) return bad('the only @ pattern is @mechanical');120+ rules.push({ line: i + 1, text: rest.join(' '), patterns, mechanical, need, from, agent, res: globs.map(globRe) });121+ });122+ return { rules, errors };123+}124+125+export function ruleFor(rules: Rule[], file: string, mechanical: boolean): Rule {126+ return rules.find((r) => (r.mechanical && mechanical) || r.res.some((re) => re.test(file))) ?? DEFAULT_RULE;127+}128+129+export interface Need {130+ rule: Rule;131+ paths: string[];132+ // Approvers who count for it.133+ have: string[];134+ agentOk: boolean;135+ ok: boolean;136+}137+138+export interface Verdict {139+ ok: boolean;140+ // Rules a change needs, in file order (none left out).141+ needs: Need[];142+ // Each changed path and the line of the rule it took (0: the default).143+ files: [string, number][];144+ errors: string[];145+}146+147+export interface Changed {148+ path: string;149+ mechanical: boolean;150+}151+152+export function judge(rules: Rule[], files: Changed[], approvers: string[], agentOk: boolean, errors: string[] = []): Verdict {153+ const byLine = new Map<number, Need>();154+ const out: [string, number][] = [];155+ for (const f of files) {156+ const r = ruleFor(rules, f.path, f.mechanical);157+ out.push([f.path, r.line]);158+ if (!r.need && !r.agent) continue;159+ let n = byLine.get(r.line);160+ if (!n) {161+ const have = r.from ? approvers.filter((a) => r.from!.includes(a)) : approvers;162+ n = { rule: r, paths: [], have, agentOk, ok: have.length >= r.need && (!r.agent || agentOk) };163+ byLine.set(r.line, n);164+ }165+ n.paths.push(f.path);166+ }167+ const needs = [...byLine.values()];168+ return { ok: needs.every((n) => n.ok) && !errors.length, needs, files: out, errors };169+}170+171+// What a need says, in a sentence's middle: "2 approvals", "1 from fox",172+// "1 approval and an agent review".173+export function needText(r: Rule): string {174+ const parts: string[] = [];175+ if (r.need) parts.push(r.from ? `${r.need} from ${r.from.join(r.need === r.from.length && r.need > 1 ? ' and ' : ' or ')}` : `${r.need} ${r.need === 1 ? 'approval' : 'approvals'}`);176+ if (r.agent) parts.push('an agent review');177+ return parts.join(' and ');178+}179+180+// ---------------------------------------------------------------- git181+182+export function rulesAt(gitDir: string, commit: string | null): { rules: Rule[]; errors: string[]; found: boolean } {183+ const text = commit ? tryRun(gitDir, ['cat-file', 'blob', `${commit}:${RULES_FILE}`]) : null;184+ if (text == null) return { rules: [], errors: [], found: false };185+ return { ...parseRules(text), found: true };186+}187+188+// The files a change from base (null: none) to tip touches, both paths of189+// a rename, and whether Jev judged each change mechanical.190+export function changedFiles(gitDir: string, base: string | null, tip: string): Changed[] {191+ const raw = base192+ ? tryRun(gitDir, ['diff', '--raw', '-z', '-M', '--abbrev=40', '--no-color', base, tip])193+ : tryRun(gitDir, ['diff', '--raw', '-z', '--abbrev=40', '--no-color', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', tip]);194+ if (!raw) return [];195+ const store = new JevStore(gitDir);196+ const parts = raw.split('\0');197+ const out: Changed[] = [];198+ const seen = new Set<string>();199+ for (let i = 0; i < parts.length; i++) {200+ const head = parts[i];201+ if (!head.startsWith(':')) continue;202+ const [, , a, b, status] = head.slice(1).split(' ');203+ const paths = status.startsWith('R') || status.startsWith('C') ? [parts[++i], parts[++i]] : [parts[++i]];204+ const mechanical = mechanicalPair(store, a, b);205+ for (const f of paths) {206+ if (f == null || seen.has(f)) continue;207+ seen.add(f);208+ out.push({ path: f, mechanical });209+ }210+ }211+ return out;212+}213+214+// Who approved each tree on a review they didn't open (bin/forge's approvers).215+export function approverMap(gitDir: string): Map<string, string[]> {216+ const by = new Map<string, Set<string>>();217+ for (const r of readReviewLogs(gitDir, false)) {218+ for (const a of r.approvals) {219+ if (a.user === r.created?.user) continue;220+ if (!by.has(a.tree)) by.set(a.tree, new Set());221+ by.get(a.tree)!.add(a.user);222+ }223+ }224+ return new Map([...by].map(([t, s]) => [t, [...s].sort()]));225+}226+227+export const approversOf = (gitDir: string, tree: string): string[] => approverMap(gitDir).get(tree) ?? [];228+229+export interface CheckOptions {230+ gitDir: string;231+ base: string | null;232+ tip: string;233+ // Where the rules are read: the default branch before the change.234+ rulesAt: string | null;235+ allowedSigners?: string | null;236+ // The review notes to read: a push can bring new ones (pre-receive).237+ reviewNotes?: string;238+ // Given by the page build, which has them: who approved a tree, and its239+ // agent reviews.240+ approvers?: (tree: string) => string[];241+ agent?: (tree: string) => AgentReview[];242+}243+244+// Does a tree have what "+ agent" asks for: a verified agent review with no245+// blocking finding open?246+export const agentPasses = (list: AgentReview[]): boolean => list.some((r) => r.ok && r.open === 0);247+248+export function check(o: CheckOptions): Verdict & { found: boolean } {249+ const { rules, errors, found } = rulesAt(o.gitDir, o.rulesAt);250+ const tree = tryRun(o.gitDir, ['rev-parse', `${o.tip}^{tree}`])?.trim() ?? '';251+ const files = changedFiles(o.gitDir, o.base, o.tip);252+ const needsAgent = files.some((f) => ruleFor(rules, f.path, f.mechanical).agent);253+ const agentOk = needsAgent && !!tree && agentPasses(o.agent ? o.agent(tree) : agentReviews(o.gitDir, tree, o.allowedSigners ?? null, o.reviewNotes));254+ const approvers = o.approvers ? o.approvers(tree) : approversOf(o.gitDir, tree);255+ return { ...judge(rules, files, approvers, agentOk, errors), found };256+}257+258+// Lines for a terminal: what each rule needs and has.259+export function report(v: Verdict & { found: boolean }): string[] {260+ const lines: string[] = [...v.errors];261+ if (!v.needs.length) lines.push(v.files.length ? 'no approval needed: every changed file is under a "none" rule' : 'no approval needed: no files changed');262+ for (const n of v.needs) {263+ const what = n.rule.line ? `${RULES_FILE}:${n.rule.line}` : 'the default';264+ const shown = n.paths.slice(0, 3).join(', ') + (n.paths.length > 3 ? `, ${n.paths.length - 3} more` : '');265+ const has = [n.rule.need ? (n.have.length ? `approved by ${n.have.join(', ')}` : 'no approvals') : '', n.rule.agent ? (n.agentOk ? 'an agent review' : 'no passing agent review') : ''].filter(Boolean).join(', ');266+ lines.push(`${n.ok ? 'ok ' : 'NEED'} ${needText(n.rule)} (${what}) for ${shown}: ${has}`);267+ }268+ return lines;269+}270+271+// ---------------------------------------------------------------- cli272+273+// Run directly (realpath: /tmp is a symlink on macOS, and import.meta.url is resolved).274+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {275+ const args = process.argv.slice(2);276+ const opt = (k: string): string | null => {277+ const i = args.indexOf(k);278+ return i >= 0 ? (args[i + 1] ?? null) : null;279+ };280+ const commit = (k: string): string | null => {281+ const v = opt(k);282+ return !v || v === '-' || /^0+$/.test(v) ? null : v;283+ };284+ if (args[0] === 'mechanical' && opt('--git-dir') && opt('--tip')) {285+ // The changed paths Jev judged mechanical, for forge critique to skip.286+ for (const f of changedFiles(opt('--git-dir')!, commit('--base'), opt('--tip')!)) if (f.mechanical) process.stdout.write(f.path + '\n');287+ process.exit(0);288+ }289+ if (args[0] !== 'check' || !opt('--git-dir') || !opt('--tip')) {290+ process.stderr.write('usage: node lib/rules.ts check --git-dir <dir> --base <commit|-> --tip <commit> [--rules-at <commit|->] [--signers <file>] [--json]\n');291+ process.exit(2);292+ }293+ const gitDir = opt('--git-dir')!;294+ const base = commit('--base');295+ const signers = opt('--signers');296+ const v = check({297+ gitDir, base, tip: opt('--tip')!, rulesAt: args.includes('--rules-at') ? commit('--rules-at') : base,298+ allowedSigners: signers && fs.existsSync(signers) ? signers : null, reviewNotes: commit('--review-notes') ?? undefined,299+ });300+ if (args.includes('--json')) {301+ process.stdout.write(JSON.stringify({ ok: v.ok, found: v.found, errors: v.errors, needs: v.needs.map((n) => ({ line: n.rule.line, need: needText(n.rule), paths: n.paths, have: n.have, agent: n.rule.agent, agentOk: n.agentOk, ok: n.ok })) }) + '\n');302+ } else process.stdout.write(report(v).join('\n') + '\n');303+ process.exit(v.ok ? 0 : 1);304+}@@ -180,6 +180,36 @@ export interface ReviewComment {
180180 inDiff: boolean;181181 // An outdated comment Jev says a later revision likely addressed: that revision.182182 addressed?: number | null;183+ // From forge critique: the model that wrote it, and blocking or note.184+ agent: string | null;185+ severity: 'blocking' | 'note' | null;186+}187+188+// What landing a live review on its target needs (lib/rules.ts) and has.189+export interface ReviewGate {190+ // forge.requireapproval: the forge refuses a landing that falls short.191+ required: boolean;192+ ok: boolean;193+ // .forge/approvals is on the target.194+ rules: boolean;195+ needs: { line: number; text: string; paths: string[]; have: string[]; agent: boolean; agentOk: boolean; ok: boolean }[];196+ // Every changed file is under a "none" rule.197+ free: boolean;198+ errors: string[];199+}200+201+// The latest verified agent review of a review (lib/critique.ts).202+export interface ReviewAgent {203+ sha: string;204+ model: string;205+ signer: string | null;206+ started: string;207+ scope: string;208+ findings: number;209+ open: number;210+ // It reviewed the tip's tree.211+ current: boolean;212+ resolved: number;183213 }184214 185215 export interface ReviewLog {@@ -224,6 +254,8 @@ export interface Review extends ReviewLog {
224254 combinedStats: Stats;225255 combined?: DiffFile[];226256 previews: Preview[];257+ gate?: ReviewGate | null;258+ agent?: ReviewAgent | null;227259 }228260 229261 export interface SiteDeploy {@@ -19,6 +19,9 @@ ul.approvals .date time { font-size: inherit; }
1919 .comment-head .date time { font-size: inherit; }2020 .comment-head .rev { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }2121 .comment .tag { font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--warn); }22+/* From forge critique: the model in the mono, blocking in the fail colour. */23+.comment .agent { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }24+.comment .tag.blocking { color: var(--fail); }2225 .comment .quote { margin: var(--space-2) var(--space-3) 0; border: var(--hairline) solid var(--border); background: var(--tint); overflow: hidden; }2326 .comment .quote .where { padding: var(--space-1) var(--space-3); font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); border-bottom: var(--hairline) solid var(--border); overflow-wrap: anywhere; }2427 .comment .quote pre { margin: 0; padding: var(--space-1) var(--space-3); font: var(--fw-regular) var(--text-code)/1.55 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; }@@ -48,7 +51,7 @@ ul.approvals .date time { font-size: inherit; }
4851 <ol class="rows comments">4952 {% for c in rv.comments %}5053 <li class="comment{% if c.outdated %} outdated{% endif %}" id="comment-{{ c.n }}">51-<div class="comment-head"><b>{{ c.user }}</b> <a class="date" href="#comment-{{ c.n }}">{{ c.ts | ago }}</a>{% if c.rev %} <span class="rev">r{{ c.rev }}</span>{% endif %}{% if c.outdated %} <span class="tag">outdated</span>{% endif %}{% if c.addressed %} <span class="tag jev-addressed" title="According to Jev, the change since this comment addresses it">likely addressed in r{{ c.addressed }}</span>{% endif %}</div>54+<div class="comment-head"><b>{{ c.user }}</b>{% if c.agent %} <span class="agent" title="written by an agent review (forge critique)">agent {{ c.agent }}</span>{% endif %}{% if c.severity == 'blocking' %} <span class="tag blocking">blocking</span>{% endif %} <a class="date" href="#comment-{{ c.n }}">{{ c.ts | ago }}</a>{% if c.rev %} <span class="rev">r{{ c.rev }}</span>{% endif %}{% if c.outdated %} <span class="tag">outdated</span>{% endif %}{% if c.addressed %} <span class="tag jev-addressed" title="According to Jev, the change since this comment addresses it">likely addressed in r{{ c.addressed }}</span>{% endif %}</div>5255 {% if c.path %}5356 {% if c.inDiff %}{% set href = u.reviewDiff(rv.id).href ~ '#comment-' ~ c.n %}{% elif not c.outdated and rv.live %}{% set href = u.blob(rv.branch, c.path).href ~ '#l' ~ c.line %}{% else %}{% set href = '' %}{% endif %}5457 <div class="quote"><div class="where">{% if href %}<a href="{{ href }}">{{ c.path }}:{{ c.line }}</a>{% else %}{{ c.path }}:{{ c.line }}{% endif %}</div><pre>{{ c.text or '' }}</pre></div>@@ -22,6 +22,11 @@ pagination:
2222 .stacked > h3 a { color: var(--fg); }2323 .stacked > .byline { margin: 0 0 var(--space-3); font-size: var(--text-sm); }2424 #stack tr.this td { font-weight: var(--fw-strong); }25+/* To land: a row a requirement, its mark, what it is, what it has. */26+#to-land td.state { width: 1.5em; padding-right: 0; }27+#to-land td.main b { font-weight: var(--fw-medium); }28+#to-land .paths, #to-land .has { color: var(--muted); }29+#to-land .warn { color: var(--warn); }2530 /* The sticky bar: layers of the stack, commits of the layer. It has a fixed2631 height so file headers can stick right below it. */2732 .revnav { position: sticky; top: 0; z-index: 3; height: 2.75rem; display: flex; align-items: center; gap: var(--space-3); margin: var(--space-8) 0 0; background: var(--bg); border-bottom: var(--hairline) solid var(--border); white-space: nowrap; overflow-x: auto; scrollbar-width: none; font: var(--fw-regular) var(--text-xs) var(--mono); }@@ -51,6 +56,27 @@ pagination:
5156 {% call ui.panel(head, 'review-desc') %}<div class="markdown">{{ rv.opts.description | reviewMarkdown(p.repo, rv, true) | safe }}</div>{% endcall %}5257 {% endif %}5358 59+{% if rv.live and (rv.status == 'open' or rv.status == 'draft') and rv.gate %}60+{% set g = rv.gate %}61+{% set head %}<b>to land</b><span class="grow">on <span class="mono">{{ rv.target }}</span>{% if g.required %}, which the forge holds to these{% else %}; approval is optional here{% endif %}</span>{% if g.rules %}<span class="end"><a href="{{ u.blob(rv.target, '.forge/approvals').href }}">approval rules</a></span>{% endif %}{% endset %}62+{% call ui.panel(head) %}63+<table class="list" id="to-land">64+<tbody>65+<tr><td class="state">{% if rv.ci %}<span class="ci {{ rv.ci.state }}">{{ '✓' if rv.ci.state == 'pass' else ('✗' if rv.ci.state == 'fail' else '–') }}</span>{% else %}<span class="ci partial">–</span>{% endif %}</td><td class="main"><b>ci</b><span class="has">: {% if not rv.ci %}no attestations yet, <code>forge attest --stack</code>{% elif rv.ci.state == 'pass' %}{{ 'its commit' if rv.ci.total == 1 else 'every commit' }} passed{% else %}{{ rv.ci['pass'] }} of {{ rv.ci.total }} commits passed{% endif %}</span></td></tr>66+{% for n in g.needs %}67+<tr><td class="state"><span class="ci {{ 'pass' if n.ok else 'partial' }}">{{ '✓' if n.ok else '–' }}</span></td><td class="main">{% if n.line %}<a href="{{ u.blob(rv.target, '.forge/approvals').href }}#l{{ n.line }}">{{ n.text }}</a>{% else %}{{ n.text }}{% endif %} <span class="paths">for <span class="mono">{{ n.paths.slice(0, 3) | join(', ') }}</span>{% if n.paths.length > 3 %} and {{ (n.paths.length - 3) | plural('more file') }}{% endif %}</span><span class="has">: {% if n.have.length %}approved by {{ n.have | join(', ') }}{% elif n.text != 'an agent review' %}not approved yet{% endif %}{% if n.agent %}{% if n.have.length or n.text != 'an agent review' %}; {% endif %}{{ 'an agent review with nothing blocking' if n.agentOk else 'no agent review with nothing blocking' }}{% endif %}</span></td></tr>68+{% endfor %}69+{% if g.free %}<tr><td class="state"><span class="ci pass">✓</span></td><td class="main"><b>no approval needed</b><span class="has">: every file it changes is under a <code>none</code> rule</span></td></tr>{% endif %}70+{% for e in g.errors %}<tr><td class="state"><span class="ci fail">✗</span></td><td class="main warn">{{ e }}</td></tr>{% endfor %}71+{% if rv.agent %}72+{% set a = rv.agent %}73+<tr><td class="state"><span class="ci {{ 'pass' if a.open == 0 and a.current else ('fail' if a.open else 'partial') }}">{{ '✓' if a.open == 0 and a.current else ('✗' if a.open else '–') }}</span></td><td class="main"><b>agent review</b><span class="has">: {{ a.model }}{% if a.signer %}, signed by {{ a.signer }}{% endif %}, {{ a.findings | plural('finding') }}{% if a.open %}, <b>{{ a.open }} blocking open</b>{% endif %}{% if not a.current %}, of <span class="sha">{{ a.sha | commitLink(p.repo) | safe }}</span>, an earlier tree: <code>forge critique {{ rv.id }}</code>{% endif %}</span></td></tr>74+{% endif %}75+</tbody>76+</table>77+{% endcall %}78+{% endif %}79+5480 {% if p.stack.length > 1 %}5581 {% set head %}<b>stack</b><span class="n">{{ p.stack.length }}</span>{% endset %}5682 {% call ui.panel(head) %}@@ -95,9 +121,10 @@ pagination:
95121 <pre>git fetch {{ u.cloneSsh }} {{ rv.branch }} && git switch --detach FETCH_HEAD96122 forge checkout {{ rv.id }} # the same, on a local tracking branch97123 forge attest --stack # CI for each commit, on your machine124+forge critique {{ rv.id }} # an agent review: .forge/review on {{ rv.target }}, signed98125 forge comment {{ rv.id }} -m "..." # add <path>:<line> to comment on a line99126 forge approve {{ rv.id }}100-forge land {{ rv.id }} # checks CI and approval, then fast-forwards127+forge land {{ rv.id }} # checks CI and the approval rules, then fast-forwards101128 ssh {{ forge.config.ssh }} land {{ p.repo.slug }} {{ rv.id }} # the forge lands it, with the same gates: no clone needed</pre>102129 {% endcall %}103130 {% endif %}@@ -0,0 +1,49 @@
1+# The approval rules where people meet them: a review page's "to land"2+# panel says what landing needs and has, and forge land checks the same3+# verdict before it pushes (the forge's gate still decides).4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+repo dami/greet8+G=$FORGE_ROOT/dami/greet.git9+clone dami dami/greet "$T/w/seed"10+mkdir -p "$T/w/seed/.forge"11+printf '.forge/ 2\n*.md none\n* 1\n' >"$T/w/seed/.forge/approvals"12+printf '#!/bin/sh\ntrue\n' >"$T/w/seed/.forge/ci"13+chmod +x "$T/w/seed/.forge/ci"14+at "$T/w/seed" commit "first, and the rules" a.txt15+ok push_as dami "$T/w/seed" origin main16+git --git-dir="$G" config forge.requireapproval true17+18+clone_ssh dami dami/greet "$T/w/d"19+cl() { (cd "$T/w/d" && FORGE_USER=dami GIT_SSH_COMMAND="$T/ssh" "$FORGE" "$@"); }20+at "$T/w/d" git switch -q -c dami/two21+echo x >"$T/w/d/a.txt"22+echo x >"$T/w/d/notes.md"23+at "$T/w/d" git add -A24+at "$T/w/d" git commit -q -m "change a and the notes"25+ok cl attest26+ok cl push27+build dami/greet28+page=$FORGE_WWW/dami/greet/reviews/1/index.html29+has "$page" '<b>to land</b><span class="grow">on <span class="mono">main</span>, which the forge holds to these</span>'30+has "$page" '<a href="/dami/greet/blob/main/.forge/approvals/#l3">1 approval</a> <span class="paths">for <span class="mono">a.txt</span></span><span class="has">: not approved yet</span>'31+# notes.md is under "none": it needs nothing, so it isn't listed.32+lacks "$page" 'class="mono">notes.md'33+fails_with "land: #1 isn't approved enough to land on main" cl land 134+has "$T_LOG" 'NEED 1 approval \(\.forge/approvals:3\) for a\.txt: no approvals'35+ok ssh_as ana approve dami/greet 136+build dami/greet37+has "$page" '<span class="ci pass">✓</span></td><td class="main"><a href="/dami/greet/blob/main/.forge/approvals/#l3">1 approval</a>.*<span class="has">: approved by ana</span>'38+ok cl land 139+has "$T_LOG" 'ok 1 approval \(\.forge/approvals:3\) for a\.txt: approved by ana'40+has "$T_LOG" 'landed #1 on main'41+42+# Without forge.requireapproval the panel says approval is optional.43+git --git-dir="$G" config forge.requireapproval false44+at "$T/w/d" git switch -q -c dami/three origin/main45+echo y >"$T/w/d/a.txt"46+at "$T/w/d" git commit -q -am "again"47+ok push_as dami "$T/w/d" origin dami/three48+build dami/greet49+has "$FORGE_WWW/dami/greet/reviews/2/index.html" '; approval is optional here</span>'@@ -0,0 +1,103 @@
1+# .forge/approvals on the default branch says how much approval a change2+# to it needs, by path (lib/rules.ts); forge.requireapproval makes the forge3+# hold landings to it. The rules are read from main before the change, so4+# a review can't loosen its own.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user fox9+repo dami/greet10+G=$FORGE_ROOT/dami/greet.git11+clone dami dami/greet "$T/w/d"12+mkdir -p "$T/w/d/.forge" "$T/w/d/lib" "$T/w/d/docs"13+cat >"$T/w/d/.forge/approvals" <<'EOF'14+# paths rule15+.forge/ 216+lib/secret.sh 1 from fox17+*.md docs/ none18+* 119+EOF20+echo a >"$T/w/d/lib/secret.sh"21+echo a >"$T/w/d/lib/plain.sh"22+echo a >"$T/w/d/README.md"23+at "$T/w/d" git add -A24+at "$T/w/d" git commit -q -m "rules"25+ok push_as dami "$T/w/d" origin main26+git --git-dir="$G" config forge.requireapproval true27+28+# lib/rules.ts on its own: what each file takes, and what a change needs.29+eq "$(node "$T_HOME/lib/rules.ts" check --git-dir "$G" --base - --tip main --rules-at main --json | sed 's/"have":\[\][^}]*//g' | tr -d '\n' | grep -o '"need":"[^"]*","paths":\[[^]]*\]' | tr '\n' ' ')" \30+ '"need":"2 approvals","paths":[".forge/approvals"] "need":"1 approval","paths":["lib/plain.sh"] "need":"1 from fox","paths":["lib/secret.sh"] '31+32+# Docs only: no approval needed, even with forge.requireapproval.33+at "$T/w/d" git switch -q -c dami/docs34+echo b >>"$T/w/d/README.md"35+mkdir -p "$T/w/d/docs" && echo x >"$T/w/d/docs/guide.txt"36+at "$T/w/d" git add -A37+at "$T/w/d" git commit -q -m "docs"38+ok push_as dami "$T/w/d" origin dami/docs39+ok push_as dami "$T/w/d" origin dami/docs:main40+41+# A plain file needs one approval from someone else.42+echo b >>"$T/w/d/lib/plain.sh"43+at "$T/w/d" git commit -q -am "plain"44+ok push_as dami "$T/w/d" origin HEAD:dami/plain45+fails_with 'needs 1 approval \(\.forge/approvals:5\) for lib/plain.sh: no approvals' push_as dami "$T/w/d" origin HEAD:main46+ok ssh_as ana approve dami/greet dami/plain47+ok push_as dami "$T/w/d" origin HEAD:main48+49+# lib/secret.sh needs fox: ana's approval isn't enough.50+echo b >>"$T/w/d/lib/secret.sh"51+at "$T/w/d" git commit -q -am "secret"52+ok push_as dami "$T/w/d" origin HEAD:dami/secret53+ok ssh_as ana approve dami/greet dami/secret54+fails_with 'needs 1 from fox \(\.forge/approvals:3\) for lib/secret.sh: no approvals' push_as dami "$T/w/d" origin HEAD:main55+ok ssh_as fox approve dami/greet dami/secret56+ok push_as dami "$T/w/d" origin HEAD:main57+58+# Moving a file out of a guarded path needs that path's rule too.59+at "$T/w/d" git mv lib/secret.sh lib/moved.sh60+at "$T/w/d" git commit -q -m "move"61+ok push_as dami "$T/w/d" origin HEAD:dami/move62+ok ssh_as ana approve dami/greet dami/move63+fails_with 'needs 1 from fox' push_as dami "$T/w/d" origin HEAD:main64+at "$T/w/d" git reset -q --hard origin/main65+66+# A review that loosens the rules lands under the old ones: .forge/ needs 2.67+printf '* none\n' >"$T/w/d/.forge/approvals"68+echo c >>"$T/w/d/lib/plain.sh"69+at "$T/w/d" git commit -q -am "loosen"70+ok push_as dami "$T/w/d" origin HEAD:dami/loosen71+fails_with 'needs 2 approvals \(\.forge/approvals:2\) for \.forge/approvals' push_as dami "$T/w/d" origin HEAD:main72+ok ssh_as ana approve dami/greet dami/loosen73+fails_with 'needs 2 approvals .*: approved by ana *$' push_as dami "$T/w/d" origin HEAD:main74+ok ssh_as fox approve dami/greet dami/loosen75+ok push_as dami "$T/w/d" origin HEAD:main76+# Now they are loose: nothing needs approval.77+echo d >>"$T/w/d/lib/plain.sh"78+at "$T/w/d" git commit -q -am "free"79+ok push_as dami "$T/w/d" origin HEAD:main80+81+# A file with a mistake in it: the gate says where, and holds.82+printf 'lib/ 3 from fox\n* 1 from\n* none\n' >"$T/w/d/.forge/approvals"83+at "$T/w/d" git commit -q -am "broken rules"84+ok push_as dami "$T/w/d" origin HEAD:main85+echo e >>"$T/w/d/README.md"86+at "$T/w/d" git commit -q -am "after the broken rules"87+fails_with '\.forge/approvals:1: 3 approvals from 1 person can never be met' push_as dami "$T/w/d" origin HEAD:main88+git --git-dir="$G" config forge.requireapproval false89+ok push_as dami "$T/w/d" origin HEAD:main90+91+# @mechanical: a file Jev judged mechanical (an answer stored for its blobs).92+printf '@mechanical none\n* 1\n' >"$T/w/d/.forge/approvals"93+at "$T/w/d" git commit -q -am "mechanical rules"94+ok push_as dami "$T/w/d" origin HEAD:main95+git --git-dir="$G" config forge.requireapproval true96+old=$(git -C "$T/w/d" rev-parse HEAD:lib/plain.sh)97+echo f >>"$T/w/d/lib/plain.sh"98+at "$T/w/d" git commit -q -am "a version bump"99+new=$(git -C "$T/w/d" rev-parse HEAD:lib/plain.sh)100+mkdir -p "$G/forge/jev/file.v1"101+printf '{"model":"t","at":0,"answers":{"mechanical":{"noul":0.95}},"tokens":null}' >"$G/forge/jev/file.v1/$old.$new"102+ok push_as dami "$T/w/d" origin HEAD:dami/bump103+ok push_as dami "$T/w/d" origin HEAD:main@@ -0,0 +1,48 @@
1+# forge critique --fix: what the reviewer changes in the worktree becomes a2+# commit on <user>/fix-<id>, stacked on the review. Files Jev judged3+# mechanical stay out of the diff, and the reviewer is told which.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+repo dami/greet8+G=$FORGE_ROOT/dami/greet.git9+clone dami dami/greet "$T/w/seed"10+mkdir -p "$T/w/seed/.forge"11+cat >"$T/w/seed/.forge/review" <<'EOF'12+#!/bin/sh13+echo '{"model":"fixer"}'14+grep -q '^+++ b/package-lock.json' && echo "the mechanical file is in the diff" >&215+echo "skipped: $(cat "$FORGE_REVIEW_SKIPPED")" >&216+if [ "$FORGE_REVIEW_FIX" = 1 ] && grep -q 'eval' run.sh; then17+ sed 's/eval "\$1"/sh -c "$1"/' run.sh >run.sh.new && mv run.sh.new run.sh18+ echo '{"severity":"note","path":"run.sh","line":1,"body":"replaced eval; see the fix branch"}'19+fi20+EOF21+chmod +x "$T/w/seed/.forge/review"22+echo '{}' >"$T/w/seed/package-lock.json"23+at "$T/w/seed" commit "seed" README24+ok push_as dami "$T/w/seed" origin main25+26+clone_ssh ana dami/greet "$T/w/a"27+cl() { (cd "$T/w/a" && FORGE_USER=ana GIT_SSH_COMMAND="$T/ssh" "$FORGE" "$@"); }28+at "$T/w/a" git switch -q -c ana/run29+printf 'eval "$1"\n' >"$T/w/a/run.sh"30+old=$(git -C "$T/w/a" rev-parse HEAD:package-lock.json)31+echo '{"v":2}' >"$T/w/a/package-lock.json"32+at "$T/w/a" git add -A33+at "$T/w/a" git commit -q -m "run"34+new=$(git -C "$T/w/a" rev-parse HEAD:package-lock.json)35+mkdir -p "$G/forge/jev/file.v1"36+printf '{"model":"t","at":0,"answers":{"mechanical":{"noul":0.96}},"tokens":null}' >"$G/forge/jev/file.v1/$old.$new"37+ok push_as ana "$T/w/a" origin ana/run38+build dami/greet39+40+ok cl critique --fix41+lacks "$T_LOG" 'the mechanical file is in the diff'42+has "$T_LOG" 'skipped: package-lock.json'43+has "$T_LOG" "the reviewer's fixes are on ana/fix-1, stacked on #1"44+eq "$(git --git-dir="$G" show ana/fix-1:run.sh)" 'sh -c "$1"'45+eq "$(git --git-dir="$G" rev-parse ana/fix-1^)" "$(git --git-dir="$G" rev-parse ana/run)"46+eq "$(git --git-dir="$G" log -1 --format=%s ana/fix-1)" "Fix what the agent review of #1 found"47+# The review itself is untouched.48+eq "$(git --git-dir="$G" show ana/run:run.sh)" 'eval "$1"'@@ -0,0 +1,146 @@
1+# forge critique: the reviewer is .forge/review from the review's target2+# branch, run on the contributor's machine; its findings become comments3+# with the model's name and severity, and a signed statement in4+# refs/notes/review says what is still open. An approval rule's "+ agent"5+# wants a verified one of the tip's tree with nothing blocking open.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+repo dami/greet10+G=$FORGE_ROOT/dami/greet.git11+clone dami dami/greet "$T/w/seed"12+mkdir -p "$T/w/seed/.forge" "$T/w/seed/lib"13+# A reviewer that needs no model: in lib/, eval is blocking, TODO a note; an open14+# finding whose line no longer says eval is resolved.15+cat >"$T/w/seed/.forge/review" <<'EOF'16+#!/bin/sh17+echo '{"model":"fake-1"}'18+[ -z "$FORGE_REVIEW_RULES" ] || echo "rules: $(cat "$FORGE_REVIEW_RULES")" >&219+echo "scope $FORGE_REVIEW_SCOPE, skipped: $(cat "$FORGE_REVIEW_SKIPPED" | tr '\n' ' ')" >&220+awk '21+ /^\+\+\+ b\// { p = substr($0, 7); next }22+ /^@@/ { match($0, /\+[0-9]+/); n = substr($0, RSTART + 1, RLENGTH - 1) - 1; next }23+ /^\+/ { n++24+ if (p !~ /^lib\//) next25+ if (index($0, "eval")) printf "{\"severity\":\"blocking\",\"path\":\"%s\",\"line\":%d,\"body\":\"no `eval` here\"}\n", p, n26+ else if (index($0, "TODO")) printf "{\"severity\":\"note\",\"path\":\"%s\",\"line\":%d,\"body\":\"a TODO\"}\n", p, n27+ next }28+ /^ / { n++ }'29+node -e '30+ const fs = require("fs");31+ for (const l of fs.readFileSync(process.env.FORGE_REVIEW_OPEN, "utf8").split("\n").filter(Boolean)) {32+ const f = JSON.parse(l);33+ const text = fs.existsSync(f.path) ? fs.readFileSync(f.path, "utf8") : "";34+ if (!text.includes("eval")) console.log(JSON.stringify({ resolved: f.id }));35+ }'36+echo "not json, ignored"37+EOF38+chmod +x "$T/w/seed/.forge/review"39+echo "Be strict about shell." >"$T/w/seed/.forge/review.md"40+printf '#!/bin/sh\ntrue\n' >"$T/w/seed/.forge/ci"41+chmod +x "$T/w/seed/.forge/ci"42+printf 'lib/ 1 + agent\n* 1\n' >"$T/w/seed/.forge/approvals"43+at "$T/w/seed" commit "seed" README44+ok push_as dami "$T/w/seed" origin main45+git --git-dir="$G" config forge.requireapproval true46+47+clone_ssh ana dami/greet "$T/w/a"48+cl() { (cd "$T/w/a" && FORGE_USER=ana GIT_SSH_COMMAND="$T/ssh" "$FORGE" "$@"); }49+at "$T/w/a" git switch -q -c ana/run50+mkdir -p "$T/w/a/lib"51+printf '#!/bin/sh\n# TODO: flags\neval "$1"\n' >"$T/w/a/lib/run.sh"52+# The review's own reviewer would let anything through; it isn't the one used.53+printf '#!/bin/sh\necho "{\\"model\\":\\"lenient\\"}"\n' >"$T/w/a/.forge/review"54+at "$T/w/a" git add -A55+at "$T/w/a" git commit -q -m "run things"56+ok push_as ana "$T/w/a" origin ana/run57+build dami/greet58+59+fails cl critique60+has "$T_LOG" 'reviewing everything since main: run things'61+has "$T_LOG" 'rules: Be strict about shell\.'62+has "$T_LOG" 'scope full, skipped: $'63+has "$T_LOG" '#1: 2 finding\(s\), 2 posted; 1 blocking open; signed \(fake-1\)'64+c=$G/forge/comments/165+has "$c/1" '^agent fake-1$'66+has "$c/1" '^severity note$'67+has "$c/1" '^path lib/run.sh$'68+has "$c/1" '^line 2$'69+has "$c/2" '^severity blocking$'70+has "$c/2" '^line 3$'71+has "$c/2" '^no `eval` here$'72+note=$(git --git-dir="$G" notes --ref=review show ana/run)73+case $note in *"review dami/greet#1"*"scope full"*"model fake-1"*"findings 274+open 1"*'|{"id":"'*'-2","severity":"blocking","path":"lib/run.sh","line":3'*"-----END SSH SIGNATURE-----"*) ;; *) fail "unexpected statement: $note" ;; esac75+case $note in *"rules .forge/review.md "*) ;; *) fail "the statement doesn't name the rules: $note" ;; esac76+77+# The page: the agent's comments say so, and to land says what is open.78+build dami/greet79+page=$FORGE_WWW/dami/greet/reviews/1/index.html80+has "$page" '<b>ana</b> <span class="agent" title="written by an agent review \(forge critique\)">agent fake-1</span> <span class="tag blocking">blocking</span>'81+has "$page" '<b>agent review</b><span class="has">: fake-1, signed by ana, 2 findings, <b>1 blocking open</b></span>'82+has "$page" '1 approval and an agent review</a> <span class="paths">for <span class="mono">lib/run.sh</span>'83+84+# Approved, but the agent review has a blocking finding open: refused.85+ok ssh_as dami approve dami/greet 186+fails_with 'needs 1 approval and an agent review \(\.forge/approvals:1\) for lib/run.sh: approved by dami, no passing agent review' push_as dami "$T/w/a" origin ana/run:main87+88+# The same tree again: nothing new to review.89+fails cl critique90+has "$T_LOG" 'this tree was already reviewed, with 1 blocking finding\(s\) open'91+92+# Fixed: the next run reviews only what changed, and the reviewer resolves it.93+printf '#!/bin/sh\n# TODO: flags\nsh -c "$1"\n' >"$T/w/a/lib/run.sh"94+at "$T/w/a" git checkout -q origin/main -- .forge/review95+at "$T/w/a" git commit -q -am "no eval, and main's reviewer back"96+ok push_as ana "$T/w/a" origin ana/run97+build dami/greet98+ok cl critique99+has "$T_LOG" 'reviewing what changed since [0-9a-f]{7}: run things'100+has "$T_LOG" '1 blocking finding\(s\) still open from the last agent review'101+has "$T_LOG" 'scope incremental'102+has "$T_LOG" '#1: 0 finding\(s\), 0 posted; 0 blocking open; signed \(fake-1\)'103+note=$(git --git-dir="$G" notes --ref=review show ana/run)104+case $note in *"scope incremental"*"open 0"*'|{"resolved":"'*'-2","by":"agent"}'*) ;; *) fail "unexpected statement: $note" ;; esac105+ok ssh_as dami approve dami/greet 1106+ok push_as dami "$T/w/a" origin ana/run:main107+108+# Dismissed by hand: --resolve signs a statement without the finding,109+# naming who dismissed it; the reviewer doesn't run.110+at "$T/w/a" git switch -q -c ana/again origin/main111+printf 'eval "$2"\n' >"$T/w/a/lib/again.sh"112+at "$T/w/a" git add -A113+at "$T/w/a" git commit -q -m "again"114+ok push_as ana "$T/w/a" origin ana/again115+build dami/greet116+fails cl critique ana/again117+id=$(git --git-dir="$G" notes --ref=review show ana/again | sed -n 's/^|{"id":"\([^"]*\)","severity":"blocking".*/\1/p')118+[ -n "$id" ] || fail "no blocking finding in the statement"119+fails_with "no such open finding" cl critique --resolve nope ana/again120+ok cl critique --resolve "$id" ana/again121+has "$T_LOG" '0 blocking open'122+case $(git --git-dir="$G" notes --ref=review show ana/again) in *"scope resolved"*"\"by\":\"ana\""*) ;; *) fail "the dismissal isn't recorded" ;; esac123+124+# A finding on a file the review doesn't change goes on the whole review.125+at "$T/w/a" git switch -q -c ana/third origin/main126+echo hi >"$T/w/a/notes.txt"127+at "$T/w/a" git add -A128+at "$T/w/a" git commit -q -m "third"129+ok push_as ana "$T/w/a" origin ana/third130+git -C "$T/w/seed" pull -q origin main131+cat >"$T/w/seed/.forge/review" <<'EOF'132+#!/bin/sh133+cat >/dev/null134+echo '{"severity":"note","path":"elsewhere.sh","line":4,"body":"about another file"}'135+echo '{"severity":"note","path":"notes.txt","line":99,"body":"past the end"}'136+EOF137+at "$T/w/seed" git commit -q -am "another reviewer"138+git --git-dir="$G" config forge.requireapproval false139+ok push_as dami "$T/w/seed" origin main140+build dami/greet141+ok cl critique ana/third142+has "$T_LOG" "elsewhere.sh isn't a file the review changes; posted as a comment on the whole review"143+has "$T_LOG" 'notes.txt:99 is past the end of the file; posted on the file.s first line'144+n=$(ls "$G/forge/comments/3" | sort -n | head -n 1)145+lacks "$G/forge/comments/3/$n" '^path '146+has "$G/forge/comments/3/$((n + 1))" '^line 1$'@@ -7,7 +7,7 @@
77 user dami88 user ana99 repo dami/greet10-has "$FORGE_ROOT/.forge/allowed_signers" '^dami namespaces="forge-ci,forge-release,git" ssh-ed25519 '10+has "$FORGE_ROOT/.forge/allowed_signers" '^dami namespaces="forge-ci,forge-release,forge-review,git" ssh-ed25519 '1111 clone dami dami/greet "$T/w/d"1212 at "$T/w/d" commit "first"1313 at "$T/w/d" git tag v13/3 A file on the default branch shows its blame and history
Each file page on the default branch gets a blame.json beside it: the commit that last changed each line, and the commits that changed the file, each with the review it landed in. The page draws them on demand, blame as a column before the line numbers, history as a panel. A build runs git blame only for files commits touched since the last one.
| M | .forge/ | 4 | +- |
| M | README.md | 14 | +++ |
| M | guide/ | 1 | + |
| A | lib/ | 164 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 16 | +++- |
| A | site/ | 158 | +++++++++++++++++++++++++++++ |
| M | site/ | 21 | +++- |
| A | test/ | 66 | ++++++++++++ |
@@ -33,10 +33,12 @@ grep -q 'data-search="/dami/greet/search.json"' $www/dami/greet/index.html
3333 # the insights pages; the forge's leave out nothing public.3434 grep -q '"scope":"dami/greet".*"landed":1,"closed":1,' $www/dami/greet/insights/insights.json3535 grep -q '"ci":18,"ci_pass":17,' $www/dami/greet/insights/insights.json36-# CI metrics, approval rules and an agent review (demo/make-demo.sh).36+# CI metrics, approval rules, an agent review and blame (demo/make-demo.sh).3737 grep -q '"metrics":\[\["script_bytes","B",' $www/dami/greet/insights/insights.json3838 grep -q '<span class="tag blocking">blocking</span>' $www/dami/greet/reviews/7/index.html3939 grep -q '<b>agent review</b><span class="has">: demo-rules, signed by ana, 2 findings, <b>1 blocking open</b>' $www/dami/greet/reviews/7/index.html40+grep -q '"ranges":\[\[1,' $www/dami/greet/blob/main/greet.sh/blame.json41+grep -q 'data-blame-src="/dami/greet/blob/main/greet.sh/blame.json"' $www/dami/greet/blob/main/greet.sh/index.html4042 grep -q '"repos":\[\["ana/dotfiles",' $www/insights/insights.json4143 for f in events builds reviews ci; do test "$(head -c 4 $www/insights/$f.parquet)" = PAR1; done4244 # Templates render the same without 11ty (lib/render-page.ts), with the@@ -45,6 +45,9 @@ It adds what stagit doesn't have, borrowing from [args.io](https://args.io):
4545 puts a tag's binaries on `refs/releases/v1.0` with a `SHA256SUMS` signed4646 by your key; the forge checks both and serves the files.4747 - **Keyboard navigation and clean URLs**, e.g. `/cat/greet/tree/main/greet.sh/`.48+- **Blame and history.** A file on the default branch shows who last changed49+ each line and every commit that changed it, each with the review it50+ landed in.4851 - **Search and syntax highlighting.** `/` searches a repo's commits, reviews,4952 wiki and file paths from a static index, and file contents from a SQLite5053 index the browser reads in byte ranges; code is highlighted at build time.@@ -1600,6 +1603,17 @@ relative ("3h ago", then "Sep 1") by the browser, with the local time on
16001603 hover, so static pages don't go stale. On phones, long code lines wrap unless16011604 you turn it off with the Wrap button, which is remembered.16021605 1606+A file on the default branch has **blame** and **history** buttons. Blame1607+puts the commit that last changed each line, and when, in a column before1608+the line numbers, one band per run of lines from a commit; clicking it1609+opens that commit. History lists the commits that changed the file, newest1610+first (the latest 200), each with the review it landed in. Both come from1611+the file's `blame.json` beside its page (`lib/blame.ts`), fetched the1612+first time either is asked for, so a file adds no pages. A build computes1613+it with `git blame` and `git log` for the files commits touched since the1614+last one, and keeps the rest in `$FORGE_CACHE/blame/`. Blame stays on from1615+file to file once turned on.1616+16031617 Keyboard shortcuts (`?` lists them):16041618 16051619 | Key | Does |@@ -109,6 +109,7 @@
109109 <li><strong>Live.</strong> An open page updates itself after a push, without losing your place.</li>110110 <li><strong>Feeds.</strong> Every repo has an Atom feed of its newest commits.</li>111111 <li><strong>Diagrams.</strong> <code>```mermaid</code> fences in any markdown file are drawn as diagrams, without JavaScript.</li>112+<li><strong>Blame and history.</strong> A file on <code>main</code> shows who last changed each line, and every commit that changed it with the review it landed in.</li>112113 </ul>113114 </div>114115 </section>@@ -0,0 +1,164 @@
1+// Blame and history for the files on a repo's default branch: for each file,2+// which commit last changed each line, and the commits that changed the3+// file, newest first, each with the review it landed in. The file page4+// fetches its blame.json and draws them (site/assets/blame.ts), so no page5+// is added per file.6+//7+// { "v": 1,8+// "commits": [[sha, subject, author, time (s), review id | null, review title | null, has a page], ...],9+// "ranges": [[first line, lines, commit index], ...], in line order, covering the file10+// "history": [commit index, ...], newest first, at most HISTORY11+// "more": true } when the history is longer12+//13+// A file's blame only changes when a commit changes it, so each one is kept14+// in $FORGE_CACHE/blame/<owner>/<repo>/ by path, with the blob and the tip it15+// was computed at. A build reuses it while that tip is still in the branch's16+// history and no commit since touched the file; otherwise it runs git blame (fast17+// with the commit-graph's changed-path filters, which builds write) and git18+// log for that file alone. A push recomputes only the files it changed.19+import fs from 'node:fs';20+import path from 'node:path';21+import { createHash } from 'node:crypto';22+import { isAncestor, tryRun } from './git.ts';23+import { MAX_HIGHLIGHT } from './highlight.ts';24+import { IMAGE } from './repo.ts';25+import type { Config, Repo, TreeEntry } from './types.ts';26+27+const VERSION = 2;28+export const HISTORY = 200;29+// The most files a repo's first build blames; the rest wait for later builds.30+const FIRST = Number(process.env.FORGE_BLAME_MAX || 3000);31+32+// [sha, subject, author, time (s), review id, review title, has a page (1 or 0)]33+type CommitRow = [string, string, string, number, number | null, string | null, number];34+export interface BlameFile {35+ v: number;36+ commits: CommitRow[];37+ ranges: [number, number, number][];38+ history: number[];39+ more?: boolean;40+}41+42+interface Cached {43+ v: number;44+ oid: string;45+ tip: string;46+ data: BlameFile;47+}48+49+// The porcelain of git blame --incremental: groups of "sha src dst n", with50+// a commit's headers the first time it appears.51+export function parseIncremental(text: string): { ranges: [number, number, string][]; info: Map<string, { author: string; time: number; summary: string }> } {52+ const ranges: [number, number, string][] = [];53+ const info = new Map<string, { author: string; time: number; summary: string }>();54+ let cur: string | null = null;55+ for (const line of text.split('\n')) {56+ const m = /^([0-9a-f]{40}) \d+ (\d+) (\d+)$/.exec(line);57+ if (m) {58+ cur = m[1];59+ ranges.push([Number(m[2]), Number(m[3]), cur]);60+ if (!info.has(cur)) info.set(cur, { author: '', time: 0, summary: '' });61+ continue;62+ }63+ if (!cur) continue;64+ const i = info.get(cur)!;65+ if (line.startsWith('author ')) i.author = line.slice(7);66+ else if (line.startsWith('author-time ')) i.time = Number(line.slice(12));67+ else if (line.startsWith('summary ')) i.summary = line.slice(8);68+ }69+ ranges.sort((a, b) => a[0] - b[0]);70+ return { ranges, info };71+}72+73+function compute(dir: string, tip: string, file: string, review: (sha: string) => [number, string] | null, hasPage: (sha: string) => boolean): BlameFile | null {74+ const out = tryRun(dir, ['blame', '--incremental', tip, '--', file]);75+ if (out == null) return null;76+ const { ranges, info } = parseIncremental(out);77+ const log = tryRun(dir, ['log', '-z', `-n${HISTORY + 1}`, '--format=%H%x1f%an%x1f%at%x1f%s', tip, '--', file]) ?? '';78+ const hist = log.split('\0').filter(Boolean).map((r) => r.replace(/^\n/, '').split('\x1f'));79+ const commits: CommitRow[] = [];80+ const index = new Map<string, number>();81+ const add = (sha: string, subject: string, author: string, time: number): number => {82+ let i = index.get(sha);83+ if (i == null) {84+ const rv = review(sha);85+ i = commits.push([sha, subject, author, time, rv?.[0] ?? null, rv?.[1] ?? null, hasPage(sha) ? 1 : 0]) - 1;86+ index.set(sha, i);87+ }88+ return i;89+ };90+ const history = hist.slice(0, HISTORY).map(([sha, author, at, subject]) => add(sha, subject ?? '', author ?? '', Number(at)));91+ const merged: [number, number, number][] = [];92+ for (const [start, n, sha] of ranges) {93+ const c = info.get(sha)!;94+ const i = add(sha, c.summary, c.author, c.time);95+ const last = merged.at(-1);96+ if (last && last[2] === i && last[0] + last[1] === start) last[1] += n;97+ else merged.push([start, n, i]);98+ }99+ return { v: VERSION, commits, ranges: merged, history, ...(hist.length > HISTORY ? { more: true } : {}) };100+}101+102+// Which files get a blame: text the file page shows as code.103+export const blamable = (e: TreeEntry, maxBlob: number): boolean =>104+ e.size != null && e.size > 0 && e.size <= Math.min(maxBlob, MAX_HIGHLIGHT) && !IMAGE.test(e.path) && e.mode !== '120000' && e.mode !== '160000';105+106+// The blame of every blamable file on the default branch, by path, as the107+// bytes of its blame.json. Returns how many were computed (not reused).108+export function blames(cfg: Config, repo: Repo, entries: TreeEntry[]): { files: Map<string, Buffer>; computed: number; skipped: number } {109+ const files = new Map<string, Buffer>();110+ const tip = repo.branches.find((b) => b.isDefault)?.sha;111+ if (!tip) return { files, computed: 0, skipped: 0 };112+ const dir = path.join(cfg.cacheDir, 'blame', repo.owner, repo.name);113+ fs.mkdirSync(dir, { recursive: true });114+ // Commits that landed in a review: its number and title.115+ const landed = new Map<string, [number, string]>();116+ for (const r of repo.reviews) if (r.status === 'merged') for (const c of r.commits) if (!landed.has(c)) landed.set(c, [r.id, r.title]);117+ const review = (sha: string): [number, string] | null => landed.get(sha) ?? null;118+ // The paths commits changed since a cached tip, once per tip; null when119+ // that tip has left the branch's history (a force push), so all is stale.120+ const since = new Map<string, Set<string> | null>();121+ const changedSince = (t: string): Set<string> | null => {122+ if (!since.has(t)) {123+ const ok = t === tip || isAncestor(repo.dir, t, tip);124+ const names = ok && t !== tip ? tryRun(repo.dir, ['log', '-z', '--format=', '--name-only', '--no-renames', `${t}..${tip}`]) : '';125+ since.set(t, ok ? new Set((names ?? '').split('\0').map((n) => n.replace(/^\n/, '')).filter(Boolean)) : null);126+ }127+ return since.get(t)!;128+ };129+ let computed = 0;130+ let skipped = 0;131+ const keep = new Set<string>();132+ for (const e of entries) {133+ if (!blamable(e, cfg.maxBlob)) continue;134+ const key = createHash('sha1').update(e.path).digest('hex');135+ keep.add(`${key}.json`);136+ const file = path.join(dir, `${key}.json`);137+ let c: Cached | null = null;138+ try {139+ c = JSON.parse(fs.readFileSync(file, 'utf8')) as Cached;140+ } catch {141+ c = null;142+ }143+ const changed = c && c.v === VERSION && c.oid === e.oid ? changedSince(c.tip) : null;144+ // A commit since may have changed the file and changed it back.145+ if (c && changed && !changed.has(e.path)) {146+ files.set(e.path, Buffer.from(JSON.stringify(c.data)));147+ continue;148+ }149+ if (computed >= FIRST) {150+ skipped++;151+ continue;152+ }153+ const data = compute(repo.dir, tip, e.path, review, (sha) => repo.commits.has(sha));154+ if (!data) continue;155+ computed++;156+ const tmp = `${file}.${process.pid}`;157+ fs.writeFileSync(tmp, JSON.stringify({ v: VERSION, oid: e.oid, tip, data } satisfies Cached));158+ fs.renameSync(tmp, file);159+ files.set(e.path, Buffer.from(JSON.stringify(data)));160+ }161+ // Files that left the branch.162+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.json') && !keep.has(f)) fs.rmSync(path.join(dir, f), { force: true });163+ return { files, computed, skipped };164+}@@ -18,6 +18,7 @@ import { Spans, build, codeVersion } from './metrics.ts';
1818 import { RepoCache } from './cache.ts';1919 import { repoIndex, reposIndex } from './search.ts';2020 import { codeIndex } from './code-index.ts';21+import { blames } from './blame.ts';2122 import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';2223 import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';2324 import { HighlightCache } from './highlight.ts';@@ -316,6 +317,10 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
316317 for (const kind of ['tree', 'log']) pages.branchMenus.push({ permalink: u.branchMenu(kind).path, repo: head, kind, branches: repo.branches });317318 318319 const wantBlobs = new Set<string>();320+ // Blame and history of the default branch's files (lib/blame.ts), a321+ // blame.json beside each file page, which the page fetches.322+ const blame = rootTree ? spans.time('blame', () => blames(cfg, repo, rootTree.blobs)) : null;323+ if (blame) spans.count('blamed', blame.computed);319324 for (const b of repo.branches) {320325 const commits = (repo.logs.get(b.name) ?? []).map((sha) => repo.commits.get(sha)).filter((c): c is Commit => !!c);321326 add(pages.logs, page(u.log(b.name).path, `Log - ${b.name} - ${repo.slug}`, 'log', { branch: b, commits }),@@ -346,8 +351,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
346351 filesHref: u.files(b.name).href,347352 sourceHref: markdown ? u.blobSource(b.name, e.path).href : null,348353 renderedHref: markdown ? u.blob(b.name, e.path).href : null,354+ blameHref: null as string | null,355+ commitBase: u.home.href + 'commit/',356+ reviewBase: u.reviews.href,349357 };350- const key = `blob ${b.name} ${e.path} ${e.oid} ${e.mode}`;358+ const bl = b.isDefault ? blame?.files.get(e.path) : undefined;359+ if (bl) {360+ const at = `${u.blob(b.name, e.path).path}blame.json`;361+ info.blameHref = `${u.blob(b.name, e.path).href}blame.json`;362+ if (want(at, `blame ${fingerprint(bl.toString('utf8'))}`)) raw.push({ path: at, data: bl });363+ }364+ const key = `blob ${b.name} ${e.path} ${e.oid} ${e.mode}${bl ? ' blame.v1' : ''}`;351365 // Markdown files render as documents; their source is a page of its own.352366 const p = page(u.blob(b.name, e.path).path, `${e.path} - ${b.name} - ${repo.slug}`, 'files', { ...info, markdown });353367 if (want(p.permalink, key)) {@@ -0,0 +1,158 @@
1+// Blame and history on a file page (site/repo/blob.njk), from the file's2+// blame.json (lib/blame.ts), fetched the first time either is asked for.3+// Blame puts a column before each line: the commit that last changed it, on4+// the first line of each run, and when. Clicking it opens the commit (lines5+// are links to themselves, so the column can't hold a link of its own; the6+// history panel has the same commits as links). History lists the commits7+// that changed the file, newest first, with the review each landed in.8+(() => {9+ const viewer = document.querySelector<HTMLElement>('[data-blame-src]');10+ const code = viewer?.querySelector<HTMLElement>('.code.blob');11+ const blameBtn = document.querySelector<HTMLButtonElement>('button[data-blame]');12+ const historyBtn = document.querySelector<HTMLButtonElement>('button[data-history]');13+ const panel = document.querySelector<HTMLElement>('#history');14+ if (!viewer || !code || !blameBtn || !historyBtn || !panel) return;15+ const src = viewer.dataset.blameSrc!;16+ const commitBase = viewer.dataset.commitBase!;17+ const reviewBase = viewer.dataset.reviewBase!;18+19+ // [sha, subject, author, time (s), review, review title, has a page]20+ type Row = [string, string, string, number, number | null, string | null, number];21+ interface Data {22+ commits: Row[];23+ ranges: [number, number, number][];24+ history: number[];25+ more?: boolean;26+ }27+ let data: Promise<Data> | null = null;28+ const load = (): Promise<Data> => (data ??= fetch(src).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))));29+30+ // Compact, as times in a column are elsewhere (ui.ts); the full time on hover.31+ const full = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });32+ const day = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });33+ const compact = (t: Date): string => {34+ const s = (Date.now() - t.getTime()) / 1000;35+ if (s < 60) return 'now';36+ if (s < 3600) return `${Math.floor(s / 60)}m`;37+ if (s < 86400) return `${Math.floor(s / 3600)}h`;38+ if (s < 7 * 86400) return `${Math.floor(s / 86400)}d`;39+ if (s < 28 * 86400) return `${Math.floor(s / (7 * 86400))}w`;40+ if (t.getFullYear() === new Date().getFullYear()) return day.format(t);41+ return t.toISOString().slice(0, 10);42+ };43+ const time = (secs: number): HTMLTimeElement => {44+ const t = new Date(secs * 1000);45+ const el = document.createElement('time');46+ el.dateTime = t.toISOString();47+ el.title = full.format(t);48+ el.textContent = compact(t);49+ return el;50+ };51+ const href = (c: Row): string | null => (c[6] ? `${commitBase}${c[0]}/` : null);52+53+ const failed = (btn: HTMLButtonElement): void => {54+ btn.disabled = true;55+ btn.title = 'blame.json could not be loaded';56+ };57+58+ let drawn = false;59+ function drawBlame(d: Data): void {60+ if (drawn) return;61+ drawn = true;62+ const lines = code!.querySelectorAll<HTMLElement>('.l');63+ d.ranges.forEach(([start, n, ci], band) => {64+ const c = d.commits[ci];65+ for (let k = 0; k < n; k++) {66+ const l = lines[start - 1 + k];67+ if (!l) continue;68+ const s = document.createElement('span');69+ s.className = `bl${band % 2 ? ' alt' : ''}${k === 0 ? ' first' : ''}`;70+ s.dataset.c = String(ci);71+ // The line is a link to itself; its name stays the line. History has the same facts as links.72+ s.setAttribute('aria-hidden', 'true');73+ s.title = `${c[0].slice(0, 7)} ${c[1]}, ${c[2]}${c[4] != null ? `, review #${c[4]}` : ''}`;74+ if (k === 0) {75+ s.append(`${c[0].slice(0, 7)} `, time(c[3]));76+ }77+ l.prepend(s);78+ }79+ });80+ code!.addEventListener('click', (e) => {81+ const s = (e.target as HTMLElement).closest<HTMLElement>('.bl');82+ if (!s || !code!.classList.contains('blamed')) return;83+ const c = d.commits[Number(s.dataset.c)];84+ const to = c && href(c);85+ if (!to) return;86+ e.preventDefault();87+ location.href = to;88+ });89+ }90+91+ function drawHistory(d: Data): void {92+ const body = panel!.querySelector('tbody')!;93+ if (body.childElementCount) return;94+ for (const ci of d.history) {95+ const c = d.commits[ci];96+ const tr = document.createElement('tr');97+ const when = document.createElement('td');98+ when.className = 'date';99+ when.appendChild(time(c[3]));100+ const subj = document.createElement('td');101+ subj.className = 'main';102+ const to = href(c);103+ const a = document.createElement(to ? 'a' : 'span');104+ if (to) (a as HTMLAnchorElement).href = to;105+ a.textContent = c[1];106+ subj.appendChild(a);107+ const who = document.createElement('td');108+ who.className = 'hide-sm';109+ who.textContent = c[2];110+ const rv = document.createElement('td');111+ rv.className = 'rv';112+ if (c[4] != null) {113+ const r = document.createElement('a');114+ r.href = `${reviewBase}${c[4]}/`;115+ r.textContent = `#${c[4]}`;116+ r.title = c[5] ?? '';117+ rv.appendChild(r);118+ }119+ const sha = document.createElement('td');120+ sha.className = 'sha hide-sm';121+ sha.textContent = c[0].slice(0, 7);122+ tr.append(when, subj, who, rv, sha);123+ body.appendChild(tr);124+ }125+ const grow = panel!.querySelector('.panel-head .grow');126+ if (grow) grow.textContent = `${d.history.length}${d.more ? '+' : ''} ${d.history.length === 1 ? 'commit' : 'commits'}, newest first${d.more ? '; older ones are in the log' : ''}`;127+ }128+129+ const KEY = 'forge.blame';130+ const setBlame = (on: boolean): void => {131+ blameBtn.setAttribute('aria-pressed', String(on));132+ if (!on) {133+ code.classList.remove('blamed');134+ localStorage.removeItem(KEY);135+ return;136+ }137+ localStorage.setItem(KEY, '1');138+ load().then((d) => {139+ drawBlame(d);140+ code.classList.add('blamed');141+ }, () => failed(blameBtn));142+ };143+ blameBtn.addEventListener('click', () => setBlame(blameBtn.getAttribute('aria-pressed') !== 'true'));144+ historyBtn.addEventListener('click', () => {145+ const on = historyBtn.getAttribute('aria-pressed') !== 'true';146+ historyBtn.setAttribute('aria-pressed', String(on));147+ if (!on) {148+ panel.hidden = true;149+ return;150+ }151+ load().then((d) => {152+ drawHistory(d);153+ panel.hidden = false;154+ }, () => failed(historyBtn));155+ });156+ // Blame stays on from page to page, like wrap lines.157+ if (localStorage.getItem(KEY) === '1') setBlame(true);158+})();@@ -5,18 +5,35 @@ pagination:
55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}77 {% import "ui.njk" as ui %}8+{% block scripts %}{% if p.blameHref and not p.markdown %}<script src="{{ assets.blame }}" defer></script>{% endif %}{% endblock %}89 {% block content %}910 {% css %}1011 .image-view { padding: var(--space-4); text-align: center; }1112 .image-view img { max-width: 100%; height: auto; }13+/* Blame (blame.ts): a column before the line numbers, a band per run of14+ lines from one commit; its first line names the commit and when. */15+.code:not(.blamed) .bl { display: none; }16+.code .bl { flex: none; width: 17ch; padding: 0 var(--space-2); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-xs); color: var(--muted); border-right: var(--hairline) solid var(--border); background: var(--raised); }17+.code .bl.alt { background: var(--tint); }18+.code .bl.first { border-top: var(--hairline) solid var(--border); }19+.code .bl a { color: var(--link); }20+.code .bl time { font-size: inherit; color: var(--faint); }21+@media (max-width: 480px) { .code .bl { width: 9ch; } .code .bl time { display: none; } }22+.history { margin-bottom: var(--space-4); }23+.history td.rv { white-space: nowrap; }1224 {% endcss %}1325 <div class="bar">{{ m.branches(p.repo, p.branch.name, 'tree') }}{{ m.crumbs(p.repo, p.branch.name, p.crumbs) }}</div>1426 {% set head %}1527 <span class="grow"><b class="mono">{{ p.path.split('/') | last }}</b> <span class="n">{{ p.size | size }}</span>{% if p.mode == '100755' %} executable{% endif %}</span>1628 <span class="end">{% if p.markdown %}<a href="{{ p.sourceHref }}">source</a>{% elif p.isSource %}<a href="{{ p.renderedHref }}">rendered</a>{% endif %}17-{% if not p.markdown and not p.image %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{{ ui.button('copy', false, 'data-copy-code') }}{% endif %}</span>29+{% if not p.markdown and not p.image %}{% if p.blameHref %}{{ ui.button('blame', false, 'data-blame aria-pressed="false" title="who last changed each line"') }}{{ ui.button('history', false, 'data-history aria-pressed="false" title="the commits that changed this file"') }}{% endif %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{{ ui.button('copy', false, 'data-copy-code') }}{% endif %}</span>1830 {% endset %}19-{% call ui.panel(head, 'viewer') %}31+{% set battrs %}{% if p.blameHref and not p.markdown %}data-blame-src="{{ p.blameHref }}" data-commit-base="{{ p.commitBase }}" data-review-base="{{ p.reviewBase }}"{% endif %}{% endset %}32+{% if p.blameHref and not p.markdown %}33+{# The commits that changed the file (blame.ts fills it from blame.json). #}34+<section class="panel history" id="history" hidden><header class="panel-head"><b>history</b><span class="grow"></span></header><table class="list nav"><tbody></tbody></table></section>35+{% endif %}36+{% call ui.panel(head, 'viewer', battrs) %}2037 {% if p.markdown %}<div class="markdown">{{ p | markdownHtml | safe }}</div>{% else %}{{ p | blobHtml | safe }}{% endif %}2138 {% endcall %}2239 {% endblock %}@@ -0,0 +1,66 @@
1+# Blame and history (lib/blame.ts): each file on the default branch gets a2+# blame.json beside its page, saying which commit last changed each line3+# and which commits changed the file, with the review each landed in. A4+# build recomputes only the files commits touched since.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+printf 'one\ntwo\nthree\n' >"$T/w/d/a.txt"10+printf '# hi\n' >"$T/w/d/README.md"11+at "$T/w/d" commit "first" b.txt12+c1=$(git -C "$T/w/d" rev-parse HEAD)13+ok push_as dami "$T/w/d" origin main14+# Line 2 changes in a review that lands.15+at "$T/w/d" git switch -q -c dami/two16+printf 'one\nTWO\nthree\n' >"$T/w/d/a.txt"17+at "$T/w/d" git commit -q -am "shout two"18+c2=$(git -C "$T/w/d" rev-parse HEAD)19+ok push_as dami "$T/w/d" origin dami/two -o "title=Shout"20+ok push_as dami "$T/w/d" origin dami/two:main21+build dami/greet22+j=$FORGE_WWW/dami/greet/blob/main/a.txt/blame.json23+exists "$j"24+node -e '25+ const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));26+ const sha = (i) => d.commits[i][0];27+ const [c1, c2] = process.argv.slice(2);28+ const by = d.ranges.map(([s, n, i]) => `${s}+${n}:${sha(i) === c1 ? "c1" : sha(i) === c2 ? "c2" : "?"}`).join(" ");29+ if (by !== "1+1:c1 2+1:c2 3+1:c1") throw new Error("ranges: " + by);30+ if (d.history.map(sha).join(" ") !== `${c2} ${c1}`) throw new Error("history: " + d.history.map(sha));31+ const two = d.commits.find((c) => c[0] === c2);32+ if (two[1] !== "shout two" || two[4] !== 1 || two[5] !== "Shout" || two[6] !== 1) throw new Error("commit row: " + JSON.stringify(two));33+' "$j" "$c1" "$c2" || fail "blame.json is wrong: $(cat "$j")"34+page=$FORGE_WWW/dami/greet/blob/main/a.txt/index.html35+has "$page" 'data-blame-src="/dami/greet/blob/main/a.txt/blame.json" data-commit-base="/dami/greet/commit/" data-review-base="/dami/greet/reviews/"'36+has "$page" '<button type="button" data-blame aria-pressed="false"'37+has "$page" '<script src="/assets/blame\.[A-Za-z0-9_-]{10}\.js" defer>'38+# Markdown: the rendered page has no blame; its source page does.39+lacks "$FORGE_WWW/dami/greet/blob/main/README.md/index.html" 'data-blame'40+has "$FORGE_WWW/dami/greet/blob/main/README.md/source/index.html" 'data-blame-src="/dami/greet/blob/main/README.md/blame.json"'41+# Other branches' file pages have none.42+at "$T/w/d" git switch -q -c dami/side43+at "$T/w/d" commit "side" c.txt44+ok push_as dami "$T/w/d" origin dami/side45+build dami/greet46+lacks "$FORGE_WWW/dami/greet/blob/dami/side/a.txt/index.html" 'data-blame'47+48+# Changed and changed back: same blob, but the lines are the revert's now.49+at "$T/w/d" git switch -q main50+at "$T/w/d" git reset -q --hard origin/main51+printf 'one\ntwo\nthree\n' >"$T/w/d/a.txt"52+at "$T/w/d" git commit -q -am "lower two"53+printf 'one\nTWO\nthree\n' >"$T/w/d/a.txt"54+at "$T/w/d" git commit -q -am "shout two again"55+c4=$(git -C "$T/w/d" rev-parse HEAD)56+ok push_as dami "$T/w/d" origin main57+build dami/greet58+node -e '59+ const d = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));60+ const i = d.ranges.find(([s]) => s === 2)[2];61+ if (d.commits[i][0] !== process.argv[2]) throw new Error("line 2 is " + d.commits[i][0]);62+ if (d.history.length !== 4) throw new Error("history " + d.history.length);63+' "$j" "$c4" || fail "a file changed and back keeps an old blame: $(cat "$j")"64+# A file no commit touched since keeps its cached blame.65+cache=$(ls "$FORGE_CACHE/blame/dami/greet/" | wc -l | tr -d ' ')66+[ "$cache" -ge 3 ] || fail "expected cached blames, got $cache"