Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
Title: `area: what changes`, 72 characters at most, in words a visitor understands. It becomes the commit on main.
Title: `area: what changes`, 72 characters at most, in words a visitor understands (never a branch slug or "work-N"). It becomes the commit on main.
This page is public: write the plain sections for someone who has never read the code (docs/AGENT_WORK.md, "The PR page").
`scripts/check-pr-body <PR number>` checks the shape. How we work: CONTRIBUTING.md.
-->
Expand All @@ -10,21 +10,25 @@ Part of #<!-- the plan's issue, if there is one -->

<!-- Two to four sentences: what changes for a person using fizzy, and why it matters. No function, file or flag names. -->

## What changes

<!-- Bullets, in plain terms. At least one fenced code snippet taken from the diff: a before/after or the key function, 15 lines at most. -->

## What you will notice

<!-- What a person sees or feels. Or: "Nothing visible: internal", and what it makes possible. -->

## How we know it works

<!-- In plain terms: the tests that fail without the change, the check on a real device, the measurement. -->

## How this serves fizzy

<!-- One sentence: the goal or principle this moves, and the result for people, linked to its anchor:
[Phones and tablets](https://github.com/fizzyedit/fizzy/blob/main/docs/VISION.md#mobile)
Anchors: speed, quality, demos, mobile, platform, agents, collaboration, materials; principles: zig-quality, lean,
measured, respect, every-platform, delight, craft, app-owns-look, evidence. -->

## How we know it works

<!-- In plain terms: the tests that fail without the change, the check on a real device, the measurement. -->

<details><summary>For agents: technical summary</summary>

## Goal
Expand Down Expand Up @@ -54,3 +58,5 @@ measured, respect, every-platform, delight, craft, app-owns-look, evidence. -->
<!-- What was deliberately left out, with an issue if it outlives this PR. -->

</details>

<!-- agent-notes {"issue":0,"worker":"","slot":"","status":"draft","area":"","open_questions":[],"next":"review"} -->
63 changes: 45 additions & 18 deletions docs/AGENT_WORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,49 +104,74 @@ bottom. The body, in order:

1. `## In plain words`: two to four sentences. What changes for a person using fizzy, and why it
matters. No function, file or flag names.
2. `## What you will notice`: what a person sees or feels, or `Nothing visible: internal` and
2. `## What changes`: bullets, in plain terms, with at least one fenced code snippet taken from the
diff (a before/after or the key function, 15 lines at most), so a reader sees the change itself.
3. `## What you will notice`: what a person sees or feels, or `Nothing visible: internal` and
what it makes possible.
3. `## How this serves fizzy`: one sentence naming the goal or principle the change moves and the
result for people, linked to its anchor in [`VISION.md`](VISION.md), for example
`[Phones and tablets](https://github.com/fizzyedit/fizzy/blob/main/docs/VISION.md#mobile)`.
4. `## How we know it works`: in plain terms: the tests that fail without the change, the check
on a real device, the measurement.
5. A collapsed `<details><summary>For agents: technical summary</summary>` block holding `## Goal`,
5. `## How this serves fizzy`: one sentence naming the goal or principle the change moves and the
result for people, linked to its anchor in [`VISION.md`](VISION.md), for example
`[Phones and tablets](https://github.com/fizzyedit/fizzy/blob/main/docs/VISION.md#mobile)`.
6. A collapsed `<details><summary>For agents: technical summary</summary>` block holding `## Goal`,
`## What changed and why`, `## How it was verified` and `## Not done / follow-ups`.

The title follows the same rule: `area: what changes`, in words a visitor understands. The stage
is not in the body: the PM's status comment, kept current, opens with it in one plain sentence.
7. The **agent notes**, the last line of the body: a hidden comment
`<!-- agent-notes {"issue":N,"worker":"codex","slot":"linux-codex-3","status":"draft|ready","area":"core/widgets","open_questions":[],"next":"review|fixes|merge"} -->`.
It is the one known place for agents to leave each other notes; agents read and write only this
block (and the PR thread for findings), never free text in the body. `agent-finish` writes it
(worker and slot from `FIZZY_WORKER` and `FIZZY_SLOT`, `status` `draft` at the claim and `ready`
after the gates) and on a rerun keeps the `open_questions` and `next` the last agent set.

The title follows the same rule: `area: what changes`, 72 characters at most, in words a visitor
understands. It is never a branch slug: `windows: work-723` and `linux: touch-slop` are refused.
The claim's draft PR takes the work issue's title; the finished title is the first line
`# area: what changes` of `PR_SUMMARY.md`, which `agent-finish` sets. The stage is not in the body:
the PM's status comment, kept current, opens with it in one plain sentence.

A worker writes the plain sections first, in `PR_SUMMARY.md` (step 5 below), and
`scripts/agent-finish` puts the technical half in the collapsed block. `scripts/check-pr-body
<file|PR number>` checks a body: the four sections are there and not empty, the vision link names
an anchor that exists, and the block is there. `agent-finish` runs it before the gates and
refuses to publish a body that fails; the PM runs it on a person's PR at triage.
[--title <title>] <file|PR number>` checks a page: the title, the five sections there, in order and
not empty or still the template's text, the snippet, the vision link naming an anchor that exists,
the block, and the agent notes (required on a PR number, checked when a file has them: valid JSON
with every key above). A page whose notes say `"status":"draft"` may still have empty sections;
a `ready` one may not. `agent-finish` runs it before the gates and refuses to publish a page that
fails; the PM runs it on a person's PR at triage.

An example, PR #978 rewritten:

```markdown
````markdown
## In plain words

On a phone, tapping a text box opened the keyboard and closed it again straight away, so you could
not type in fizzy's web app at all. Typing on a phone is how many people talk to fizzy, so this
had to work.

## What changes

- A touch on the screen no longer also counts as a mouse click, and that click was what took the
focus back from the text box.

```js
// web/fizzy.js, pointerInput
canvas.addEventListener("touchstart", (e) => e.preventDefault(), { capture: true, passive: false });
canvas.addEventListener("touchend", (e) => e.preventDefault(), { capture: true, passive: false });
```

## What you will notice

Tap a text box on a phone or tablet and the keyboard opens and stays open. Nothing changes with a
mouse or a keyboard.

## How this serves fizzy

[Phones and tablets](https://github.com/fizzyedit/fizzy/blob/main/docs/VISION.md#mobile): typing
on a phone works in the web app.

## How we know it works

A new test replays the browser's events for a tap: without the fix the text box loses focus, with
it the text box keeps it. The check on a real phone is still to do and is listed below.

## How this serves fizzy

[Phones and tablets](https://github.com/fizzyedit/fizzy/blob/main/docs/VISION.md#mobile): typing
on a phone works in the web app.

<details><summary>For agents: technical summary</summary>

## Goal
Expand All @@ -169,7 +194,9 @@ which the hidden input keeps focus. Fails without the change.
The 10-of-10 phone check needs a real phone (the QA loop).

</details>
```

<!-- agent-notes {"issue":978,"worker":"claude","slot":"web","status":"ready","area":"web","open_questions":[],"next":"review"} -->
````

## Trust boundary

Expand Down
31 changes: 26 additions & 5 deletions scripts/agent-finish
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ fi
$claim || [[ -s $tmp/stat ]] || fail 'no changes relative to main'
marker='<!-- agent-finish: generated below -->'
# The plain sections a visitor reads, then the technical ones (docs/AGENT_WORK.md, "The PR page").
headings=$'## In plain words\n\n%s\n\n## What you will notice\n\n%s\n\n## How this serves fizzy\n\n%s\n\n## How we know it works\n\n%s\n\n## Goal\n\n%s\n\n## What changed and why\n\n%s\n\n## How it was verified\n\n%s\n\n## Not done / follow-ups\n\n%s'
headings=$'## In plain words\n\n%s\n\n## What changes\n\n%s\n\n## What you will notice\n\n%s\n\n## How we know it works\n\n%s\n\n## How this serves fizzy\n\n%s\n\n## Goal\n\n%s\n\n## What changed and why\n\n%s\n\n## How it was verified\n\n%s\n\n## Not done / follow-ups\n\n%s'
details='<details><summary>For agents: technical summary</summary>'
if $use_jj && ! git rev-parse --git-dir >/dev/null 2>&1; then
summary_file="$(jj root)/.jj/PR_SUMMARY.md"
Expand All @@ -71,11 +71,15 @@ if [[ -f $summary_file ]]; then
elif grep -qxF "$marker" <<<"$pr_body"; then
awk -v m="$marker" '$0 == m { exit } !started && (/^Part of / || /^Rework: / || /^$/) { next } { started = 1; print }' <<<"$pr_body" >"$tmp/summary"
elif $claim; then
printf "$headings\n" 'Draft: work in progress' '' '' '' '' '' '' 'Draft: work in progress' >"$tmp/summary"
printf "$headings\n" 'Draft: work in progress' '' '' '' '' '' '' '' 'Draft: work in progress' >"$tmp/summary"
else
printf "$headings\n" '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' >"$tmp/summary"
printf "$headings\n" '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' '(to fill)' >"$tmp/summary"
fi
retitle=()
if $claim && [[ -z $pr ]]; then
# The claim's draft is titled by the work issue until the real summary names the change.
title=$(gh issue view "$issue" --repo fizzyedit/work --json title --jq .title) || fail 'could not read the work issue title'
fi
# A first line `# <title>` is the PR title (the commit on main), in plain words.
if [[ $(head -n 1 "$tmp/summary") == '# '* ]]; then
title=$(head -n 1 "$tmp/summary" | cut -c3-)
Expand All @@ -91,7 +95,7 @@ fi
# Checked before the gates run, so a missing plain section costs no build.
if ! $claim; then
reason='the summary misses part of the PR page (docs/AGENT_WORK.md, "The PR page")'
scripts/check-pr-body "$tmp/summary"
scripts/check-pr-body --title "$title" "$tmp/summary"
fi
changes=$(tail -n 1 "$tmp/stat" | awk '
/files? changed/ { n = $1; a = 0; d = 0 }
Expand Down Expand Up @@ -138,6 +142,23 @@ else
done
gates_label="$gates_passed of $gates_total passed"
fi
# The agent-notes block is the one place agents leave each other notes; a rerun keeps the open
# questions and next step the last agent wrote there.
status=ready
if $claim; then status=draft; fi
notes=$(perl -MJSON::PP -e '
my ($issue, $worker, $slot, $status, $area, $old) = @ARGV;
my %n = (issue => $issue + 0, worker => $worker, slot => $slot, status => $status, area => $area,
open_questions => [], next => "review");
if ($old =~ /^<!-- agent-notes (\{.*\}) -->$/m) {
my $o = eval { decode_json($1) };
if (ref $o eq "HASH") {
$n{open_questions} = $o->{open_questions} if ref $o->{open_questions} eq "ARRAY";
$n{next} = $o->{next} if ($o->{next} // "") =~ /^(review|fixes|merge)$/;
}
}
print "<!-- agent-notes ", JSON::PP->new->canonical->encode(\%n), " -->";
' "$issue" "${FIZZY_WORKER:-unknown}" "${FIZZY_SLOT:-unknown}" "$status" "${branch%%/*}" "$pr_body")
# A rework run on an existing PR keeps the PR's own work issue: its running: lock and
# close-merged find the PR by that line.
part_of=$(grep -m1 -E '^Part of fizzyedit/work#[1-9][0-9]*$' <<<"$pr_body" || true)
Expand All @@ -152,7 +173,7 @@ part_of=$(grep -m1 -E '^Part of fizzyedit/work#[1-9][0-9]*$' <<<"$pr_body" || tr
head -n 100 "$tmp/stat"
printf '```\n\n</details>\n\n<details><summary>Gates: %s</summary>\n\n' "$gates_label"
cat "$tmp/gates"
printf '</details>\n'
printf '</details>\n\n%s\n' "$notes"
} >"$tmp/body"
reason='could not publish branch'
if $use_jj; then
Expand Down
101 changes: 89 additions & 12 deletions scripts/check-pr-body
Original file line number Diff line number Diff line change
@@ -1,17 +1,69 @@
#!/usr/bin/env bash
# Check a PR body against docs/AGENT_WORK.md "The PR page": the plain sections a visitor reads, the
# vision anchor, and the collapsed technical summary. `check-pr-body <file|PR number>`.
# Check a PR page against docs/AGENT_WORK.md "The PR page": a real title, the plain sections a visitor
# reads in order (with a code snippet under "What changes"), the vision anchor, the collapsed technical
# summary and the agent-notes block. `check-pr-body [--title <title>] <file|PR number>`.
# A PR number reads the live title and requires the agent-notes block; a file checks the notes only
# when it holds them, and takes its title from a first line `# <title>` or `--title`.
set -euo pipefail
fail() { echo "check-pr-body: $*" >&2; exit 2; }
[[ $# == 1 ]] || fail 'usage: check-pr-body <file|PR number>'
title=
have_title=false
if [[ ${1:-} == --title ]]; then
[[ $# -ge 2 ]] || fail 'usage: check-pr-body [--title <title>] <file|PR number>'
title=$2; have_title=true; shift 2
fi
[[ $# == 1 ]] || fail 'usage: check-pr-body [--title <title>] <file|PR number>'
cd "$(dirname "$0")/.."
require_notes=false
if [[ $1 =~ ^[1-9][0-9]*$ ]]; then
title=$(gh pr view "$1" --repo fizzyedit/fizzy --json title --jq .title) || fail "cannot read PR #$1"
have_title=true; require_notes=true
body=$(gh pr view "$1" --repo fizzyedit/fizzy --json body --jq .body) || fail "cannot read PR #$1"
else
body=$(<"$1") || fail "cannot read $1"
if ! $have_title && [[ $body == '# '* ]]; then
title=$(head -n 1 <<<"$body" | tr -d '\r' | cut -c3-); have_title=true
fi
fi
body=$(tr -d '\r' <<<"$body")
problems=()
if $have_title; then
if [[ ${#title} -gt 72 ]]; then problems+=("title is over 72 characters: \"$title\""); fi
if [[ ! $title =~ ^[a-z0-9_-]+:\ .+ ]]; then
problems+=("title \"$title\" is not \"area: what changes\"")
elif [[ $title =~ ^[A-Za-z0-9_]+:\ work-[0-9]+ || $title =~ ^[A-Za-z0-9_]+:\ [a-z-]+$ ]]; then
problems+=("title \"$title\" is a slug; say what changes, in words a person understands")
fi
fi
# Agent notes: the one place agents talk to each other, a hidden comment at the end of the body.
notes=$(grep -E '^<!-- agent-notes \{.*\} -->$' <<<"$body" || true)
if [[ -n $notes && $(grep -c . <<<"$notes") != 1 ]]; then
problems+=('more than one agent-notes block')
elif [[ -z $notes ]]; then
if grep -q '^<!-- *agent-notes' <<<"$body"; then
problems+=('the agent-notes block is malformed: expected one line <!-- agent-notes {...} -->')
elif $require_notes; then problems+=('no "<!-- agent-notes {...} -->" block'); fi
else
last=$(grep -v '^[[:space:]]*$' <<<"$body" | tail -n 1)
[[ $last == "$notes" ]] || problems+=('the agent-notes block is not the last thing in the body')
json=${notes#'<!-- agent-notes '}; json=${json%' -->'}
if ! why=$(perl -MJSON::PP -e '
my $j = eval { decode_json($ARGV[0]) } or die "is not valid JSON\n";
ref $j eq "HASH" or die "is not a JSON object\n";
for (qw(issue worker slot status area open_questions next)) { exists $j->{$_} or die "lacks \"$_\"\n" }
ref $j->{open_questions} eq "ARRAY" or die "\"open_questions\" is not a list\n";
$j->{status} =~ /^(draft|ready)$/ or die "\"status\" is not draft or ready\n";
$j->{next} =~ /^(review|fixes|merge)$/ or die "\"next\" is not review, fixes or merge\n";
' "$json" 2>&1); then
problems+=("agent-notes ${why:-is invalid}")
fi
# A claim draft may still be empty; a ready page may not.
draft=false
draft_re='"status": *"draft"'; if [[ $json =~ $draft_re ]]; then draft=true; fi
fi
draft=${draft:-false}
# Template hints are HTML comments, often several lines long: they are not content.
body=$(tr -d '\r' <<<"$body" | awk '
body=$(awk '
{
line = $0; out = ""
while (line != "") {
Expand All @@ -26,22 +78,47 @@ body=$(tr -d '\r' <<<"$body" | awk '
}
}
print out
}')
problems=()
# The text under a `## <heading>`, up to the next heading or the technical summary.
}' <<<"$body")
# The text under a `## <heading>`, up to the next heading or the technical summary; a `#` line
# inside a code fence is code.
section() {
awk -v h="## $1" '
$0 == h { on = 1; next }
on && (/^##? / || /^<details>/) { exit }
/^```/ && on { fence = !fence }
$0 == h && !fence { on = 1; next }
on && !fence && (/^##? / || /^<details>/) { exit }
on && !/^[[:space:]]*$/ { print }
' <<<"$body"
}
for heading in 'In plain words' 'What you will notice' 'How this serves fizzy' 'How we know it works'; do
empty=()
order=
for heading in 'In plain words' 'What changes' 'What you will notice' 'How we know it works' 'How this serves fizzy'; do
text=$(section "$heading")
if [[ -z $text || $text == '(to fill)' ]]; then problems+=("\"## $heading\" is missing or empty"); fi
if [[ -z $text || $text == '(to fill)' || $text == 'Draft: work in progress' ]]; then
empty+=("\"## $heading\" is missing or empty")
fi
order+="$(grep -n -m1 -xF "## $heading" <<<"$body" | cut -d: -f1 || true) "
done
if [[ $(tr ' ' '\n' <<<"$order" | grep . | sort -n | tr '\n' ' ') != "$order" ||
$(tr ' ' '\n' <<<"$order" | grep -c .) != 5 ]]; then
problems+=('the plain sections are not all there in order: In plain words, What changes, What you will notice, How we know it works, How this serves fizzy')
fi
changes=$(section 'What changes')
if [[ -n $changes && $changes != 'Draft: work in progress' ]]; then
# Each fenced snippet is at most 15 lines; at least one is there.
sizes=$(awk '/^```/ { if (inside) { print n; inside = 0 } else { inside = 1; n = 0 } next } inside { n++ }' <<<"$changes")
if [[ -z $sizes ]]; then
empty+=('"## What changes" has no fenced code snippet (a before/after or the key function)')
elif [[ $(sort -n <<<"$sizes" | tail -n 1) -gt 15 ]]; then
problems+=('a code snippet under "## What changes" is over 15 lines')
fi
fi
if $draft; then
for e in ${empty[@]+"${empty[@]}"}; do echo "check-pr-body: $1: draft, still to write: $e" >&2; done
else
problems+=(${empty[@]+"${empty[@]}"})
fi
anchors=$(grep -o 'docs/VISION\.md#[a-z0-9-]*' <<<"$(section 'How this serves fizzy')" | sed 's/.*#//' | sort -u || true)
if [[ -z $anchors ]]; then
if [[ -z $anchors ]] && ! $draft; then
problems+=('"## How this serves fizzy" links no docs/VISION.md#<anchor>')
fi
for anchor in $anchors; do
Expand Down
Loading
Loading