Skip to content
Draft
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
59 changes: 59 additions & 0 deletions .github/check-agent-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Fail when a tree holds agent or job notes.
#
# Usage: .github/check-agent-notes.sh [COMMIT]
#
# With no argument it lists the index, which a fresh checkout fills from the
# commit it checks out. With a commit, it lists that commit's tree through a
# temporary index, without checking it out: CI fetches a pull request's head
# commit and checks it, whatever the pull request's base.
#
# Subfleet build jobs keep their state in a .job/ folder at the root of
# their worktree: HANDOFF.md, PR_BODY.md, env, a git bundle and sometimes a
# whole repo.git. #704 and #699 committed .job/HANDOFF.md and .job/PR_BODY.md
# on 23 October 2026. Agents also write hand-off notes and reports at the
# root of a worktree: policyengine-uk#2376 put REPORT.md and PROGRESS.md on
# its main on 12 October 2026. Their content belongs on the pull request.
#
# The patterns are git pathspecs. "**/" matches any depth, the root
# included, and icase ignores capitalisation. The folders are rejected at any
# depth, the note names only at the root, since a note of the same name
# deeper in the tree can be real documentation.
set -euo pipefail

if [ $# -gt 0 ]; then
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
export GIT_INDEX_FILE="$scratch/index"
git read-tree "$1^{tree}"
fi

notes=$(git -c core.quotePath=false ls-files -- \
':(glob,icase)**/.job' ':(glob,icase)**/.job/**' \
':(glob,icase)**/.job-git' ':(glob,icase)**/.job-git/**' \
':(glob,icase)**/.diag' ':(glob,icase)**/.diag/**' \
':(top,icase)HANDOFF.md' ':(top,icase)handoffs.jsonl' \
':(top,icase)PR_BODY.md' ':(top,icase)PR-BODY.md' \
':(top,icase)REPORT.md' ':(top,icase)PROGRESS.md')

if [ -n "$notes" ]; then
# The paths come from the pull request, so the runner must not read them
# as workflow commands: "::" at the start of a line, or the older "##["
# anywhere in it. stop-commands with a random token turns both off until
# the token comes back. Git quotes a name that holds a newline, so each
# path is one line.
token=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
echo "::stop-commands::${token}"
count=0
while IFS= read -r path; do
printf 'Agent or job note: %s\n' "$path"
count=$((count + 1))
done <<<"$notes"
echo "::${token}::"
# Annotation data decodes "%", so encode it.
first=${notes%%$'\n'*}
first=${first//%/%25}
echo "::error::${count} agent or job note path(s) in the tree, starting with ${first} (all listed in the log) - post their content on the pull request and remove them."
exit 1
fi
echo "No agent or job notes in the tree."
85 changes: 85 additions & 0 deletions .github/workflows/pr_agent_notes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Workflow that fails a pull request whose head holds agent or job notes.
#
# Subfleet build jobs keep their state in .job/ at the root of their
# worktree, and #704 and #699 committed .job/HANDOFF.md and .job/PR_BODY.md
# on 23 October 2026. .github/check-agent-notes.sh lists the paths it
# rejects.
#
# pr_code_changes.yaml has a path filter, so a pull request that adds only
# such files runs none of its jobs. This workflow has no path or branch
# filter. It checks the pull request's head commit, fetched as data, not the
# merge with the base, so the base cannot change the result. Main has none
# of these paths, so a pull request based on main fails exactly when it adds
# one. "edited" covers a retarget: a stacked pull request whose old base had
# no guard first gets one when it is retargeted to main. Every edit runs the
# full check, so a title edit cannot replace a failure with a skipped run.
#
# A skip instruction in a pushed commit's message skips pull_request and
# push runs, and so can a merge that carries one to main. No commit message
# can skip the daily schedule, so a note that reaches main that way is found
# by the next scheduled run, if it is still there; GitHub can delay or drop
# a scheduled run.
#
# It uses pull_request, not pull_request_target, like the other pull request
# workflows here (docs/release-bundles.md): GitHub blocks pull_request_target
# in public repositories without an event policy from 2 December 2026.

name: Agent notes
on:
pull_request:
types: [opened, synchronize, reopened, edited]
push:
branches:
- main
schedule:
- cron: "17 6 * * *"
workflow_dispatch:

# A newer run for the same pull request, after a push or an edit, supersedes
# the one in progress, so cancel that one: the newer run checks the current
# head in full. Runs on main are never cancelled.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
AgentNotes:
name: No agent or job notes in the tree
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Fetch the pull request's head and the default branch
if: github.event_name == 'pull_request'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: >-
git fetch --no-tags --depth=1 origin "$HEAD_SHA"
"+refs/heads/$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH"
- name: No agent or job notes in the tree
# An explicit bash shell adds pipefail, so a git failure fails the
# step instead of passing it. The script sets it too.
shell: bash
env:
EVENT: ${{ github.event_name }}
COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
# On a pull request, run the default branch's guard, so a pull
# request cannot pass by editing the script. Only while the default
# branch has none, on the pull request that adds it, does the
# checked-out copy run. Other events check out the commit they check.
run: |
guard=.github/check-agent-notes.sh
if [ "$EVENT" = pull_request ]; then
main="refs/remotes/origin/$DEFAULT_BRANCH"
git rev-parse --verify --quiet "$main^{commit}" >/dev/null
if git cat-file -e "$main:$guard" 2>/dev/null; then
git show "$main:$guard" >"$RUNNER_TEMP/check-agent-notes.sh"
guard="$RUNNER_TEMP/check-agent-notes.sh"
fi
fi
bash "$guard" "$COMMIT"
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ _build/
**/.DS_Store
build/
.hypothesis/
# Agent job state and diagnostics: .github/check-agent-notes.sh fails a pull
# request that adds them.
.job/
.job-git/
.diag/
1 change: 1 addition & 0 deletions changelog.d/712.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CI now fails a pull request whose head commit holds agent or job notes: a `.job/`, `.job-git/` or `.diag/` folder at any depth, or `HANDOFF.md`, `handoffs.jsonl`, `PR_BODY.md`, `PR-BODY.md`, `REPORT.md` or `PROGRESS.md` at the repository root. The new Agent notes workflow runs the default branch's `.github/check-agent-notes.sh` on every pull request, which the code workflow's path filter would skip, including when one is retargeted. It also checks main on every push and once a day, since a skip instruction in a commit message can skip the pull request and push runs but not the daily one. `.gitignore` now ignores the three folders.
20 changes: 20 additions & 0 deletions docs/engineering/skills/github-prs.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,23 @@ same-repository draft PR.

Do not add `[codex]`, `[claude]`, `[copilot]`, or other agent labels to PR
titles. Use a plain descriptive title.

## No Agent Notes In The Tree

Keep job state and working notes out of commits. The Agent notes workflow
(`.github/workflows/pr_agent_notes.yaml`) runs `.github/check-agent-notes.sh`
on every PR and fails when the PR's head commit contains:

- a `.job/`, `.job-git/` or `.diag/` folder at any depth. Subfleet build jobs
keep their state, hand-off and PR body in `.job/`.
- `HANDOFF.md`, `handoffs.jsonl`, `PR_BODY.md`, `PR-BODY.md`, `REPORT.md` or
`PROGRESS.md` at the repository root, in any capitalisation.

Put their content in the PR description or a PR comment instead. `.gitignore`
ignores the three folders, so `git add -A` leaves them out.

The workflow checks the PR's head commit with the default branch's copy of the
script, so neither the PR's base nor an edit to the script changes the
result. A skip instruction in a commit message skips the PR and push runs, so
it also checks main once a day, which no commit message can skip. To check a
commit yourself, run `.github/check-agent-notes.sh <commit>`.
Loading
Loading