.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.
- commit
- a88eb729a2600eb60b38bf93007dc04680c4d996
- parent
- 242833e9b6329682e1969af0591105b196d08a39
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 124s, signed by cat
- kind
- feat risk score 2.6 of 3, Jev's estimate
- review
- #33 CI metrics, approval rules, agent reviews, blame
ci attestation
forge-attestation 1 type ci commit a88eb729a2600eb60b38bf93007dc04680c4d996 tree a72d12e416e939ade90700249e723bccf1796112 script .forge/ci a98260e2aa136262a5db7566a0895a6dfd4d79fd result pass exit 0 started 2026-11-04T05:06:55Z duration 124s runner (not shown here; refs/notes/ci has it) system Darwin 27.2.0 arm64 env host log sha256:b29874524be54e95f50aea797a912b1aae972261ec19d7986056f5c2cd3f2c6f 341 lines | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |3b25da7 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |3fa0c29 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |3fa0c29 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 4c1f66e -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |48df1dc running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |48df1dc pass in 0s, signed |pushed main |d6b97a1 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |d6b97a1 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |27a5223 running .forge/ci: approval rules, and a reviewer for forge critique | | sh: /bin/sh | | ok greet |27a5223 pass in 0s, signed |(node:58291) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4At7Cb/wt/demo/root/.forge/www |#7 d6b97a1 reviewing everything since main: Regional notes |#7: 2 finding(s), 2 posted; 1 blocking open; signed (demo-rules) |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 4158d2b -> /sites/dami/forge/guide/ |(node:60290) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4At7Cb/wt/demo/root/.forge/www |(node:60470) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4At7Cb/wt/demo/root/.forge/www |(node:60656) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4At7Cb/wt/demo/root/.forge/www |(node:60835) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4At7Cb/wt/demo/root/.forge/www |(node:61243) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.4At7Cb/wt/demo/root/.forge/www |240 of 240 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| 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 v1