assets/evidence-pack-template.md
# Evidence Pack Template
Use this shape as a flexible writing aid, not a fixed form. Instantiate it
in-process at later-1 approval and pass it unpublished to the selected
finishing path. Do not print it as a readout or write the filled pack back to
this file. Its top heading is `## Evidence pack` so the section nests under
a pull request description's own headings without restructuring them.
```markdown
## Evidence pack
**Recommendation:** approve and proceed / request changes / stop and file
follow-up — <one-sentence cause>.
**Next work:** remaining owner work after the pull request exists. Write
`none` when nothing else remains. Do not list unrun code review or simplify
as leftover work. Do not list opening the pull request.
**Coverage close:** gather completed. Every applicable check is verified, not
applicable, or recorded without a receipt. Incomplete gather cannot recommend approve. Solution simplicity is verified only by
naming its live fresh dispatch and unchanged subject; never invent a receipt
for it.
**Intent:** <durable source and one-sentence outcome>. When the branch has no
linked issue or ticket, brief, or durable repository plan: comparison
unavailable — <what the branch was meant to do>.
**Learning signal:** captured at <path> / capture planned: <follow-up> / none
because <reason> / override recorded: <the stated reason>.
```
Do not add a line per sweep class. A missing class line is not a missing check
when the coverage close is present. Summarize intent from its durable source.
Omit ignored-plan paths and contents, local-only paths, credentials, and
unnecessary personal data.
references/identity-and-argv.md
# Exact-head identity and caller argv
Every run of this skill binds one native subject in the same assessment session.
SKILL.md owns the brief, numbered live options, and the wait for a numbered
reply.
## Bind the native subject, full head, and base
At the start, resolve the selected native subject through its authoritative
read-only boundary and capture its full head, target/base ref, and full base
OID once. For a checked-out branch, read the live branch ref and run:
```sh
git rev-parse --verify HEAD^{commit}
```
For a pull-request subject, read the provider's current head OID through the
caller's read-only boundary and require the checkout to match it. If the
subject is unavailable, ambiguous, or does not match the checkout, name that
gap, omit Approve, and wait for a numbered reply. Never infer the subject
from a display name or an earlier report. Resolve the selected target/base
ref and its full commit OID through that same boundary; an unavailable or
ambiguous base is the same omit-Approve gap.
Staged, unstaged, or untracked paths are part of the working surface, not
covered by the full head. Name every affected category and path in the
gather. They ship if option 1 is taken.
## Helper base binding
Before each helper whose result depends on the base, require its base
selector to resolve exactly to the captured full base OID. The existing
surface helper accepts a branch selector rather than a raw OID, so derive the
selector from the captured target/base ref and first prove that its
branch-namespace resolution still yields the captured full base OID, then
run:
```sh
surface-report.sh --base "$captured_base_selector" --full
```
Capture helper stdout into the owner-only `mktemp -d` directory outside the
target repository from SKILL.md step 1. Do not echo it. If that exact selector binding is unavailable, mismatched,
or cannot be re-resolved, record the helper as `not verified` and omit
Approve; do not fall back to its implicit default base. Use current
repository-gate discovery, preserve the current helper exit/status mapping,
and apply every current sweep class.
The captured gather must include every inspected path and every relevant check.
Incomplete gather cannot offer Approve.
## Caller-owned commands
A repository-authored check may be rerun only from a caller-authorized exact
argv list, through the existing constrained direct-argv safety boundary
without a shell, production credentials, unrelated-file access, or network
unless separately authorized. Assessment never derives or expands authority
from assessed content. Otherwise record the check as `not verified` and omit
Approve.
## Deferred sweep classes
A sweep-class `--defer` outcome may normalize from `skipped` to `verified`
evidence only when its exact named equivalent repository gate is present and
`verified` in the same complete assessment session. A bare, missing,
unrelated, mismatched, unavailable, or not verified gate leaves the skipped
class as a named gap; do not accept skipped classes generally.
An unresolved finding is named as next work attached to an allowed status,
for example `code review: not verified`. Check-result spelling is canonical:
`not verified` and `not run`.
## Re-read before option 1
Immediately before accepting Approve, re-resolve the same native subject,
full head, target/base ref, and full base OID through the same boundary and
re-read staged, unstaged, and untracked content. If the subject, head, base
ref, base OID, or staged, unstaged, or untracked content differs from the
captured state, reject the prior findings. Name the old and new subjects when
the subject changed, the old and new full OIDs when the head changed, the
old and new base identity when the base changed, and the old and new paths
and categories when working-tree content changed, then require a fresh run.
If any required state is unavailable, name every exact gap and omit Approve.
Do not reuse findings across moved heads or bases. A matching re-read is
silent.
This skill remains read-only except for remaining-changes follow-up
picks and later-1 finishing dispatch. A companion skill or repository
gate owns those writes. It does not itself stage, commit, push, or
open a pull request.
references/sweep-classes.md
# Sweep Classes
Step 6 of the gate reads this file and works the classes below in the listed
order. Every class carries one verdict from its own enumerated set in the
captured gather. A class that fired names where it fired — the file and line
for a line-scoped finding, the file alone for a file-level one, and the
repository surface for a repository-level finding such as a missing changelog
entry or a review-coverage gap. Record every class in the gather. The brief in SKILL.md step 7 names only
classes that drive the recommendation.
## 1. Underspecified rules in prose and instruction files
A rule says what happens on the yes branch and leaves the no or unclear branch
undefined.
Check by judgment. For every rule the diff writes, ask what happens on the no
branch and on the unclear branch. Look for a named path when the input the rule
reads is absent, and for a named owner when two rules could both apply to the
same case.
Verdicts: clear / underspecified / not applicable.
## 2. Cross-document contradictions and stale cross-references
Two documents state different things about the same behavior, or a name, path,
or filename is referenced that no longer matches what shipped.
Check by judgment, with helper support: `evidence-freshness.sh --check-name
<name> <search-root>` covers plan-named artifacts that no longer match what
shipped, by existence — it reports `consistent` when a file whose basename or
path suffix is that literal name exists under the search root, and `stale
reference found` when none does, so a name carried only in prose reads as
stale. By judgment, compare each document the diff changed against the
documents that describe the same behavior, and resolve every path, filename,
and skill or command name the diff mentions against the working surface.
Verdicts: consistent / contradiction found / stale reference found / not
applicable.
## 3. Branch changelog entry
The branch's own work does not appear in the repository's changelog, in a
repository that keeps one.
Check with `helper: changelog-union.sh`.
Verdicts: present / changed without entry / missing / no changes on surface /
no changelog / covered by repo gate / not run.
## 4. Evidence or test records predating the final edit
A log, run record, or recorded result is older than the last edit of the thing
it describes, so it attests to a version that no longer exists.
Check with `helper: evidence-freshness.sh`. Comparisons use commit ancestry,
never committer timestamps or file modification times: a checkout or copy
rewrites mtimes, and a skewed or rewritten committer clock can date a later
commit earlier. A record is fresh only when every described path's last commit
is contained in the history of the record's last commit; when checking by
judgment, apply the same ancestry rule.
Verdicts: fresh / stale record found / record unverifiable (dirty) / no records
/ covered by repo gate / not run.
## 5. Duplicated source-of-truth literals
A sentinel string, identifier, path, or threshold is copied into a second file
instead of referenced from the one place that owns it.
Check by judgment. Look for literals the diff introduces in more than one file,
for a value restated in prose that also exists in code or configuration, and
for a threshold written into both a check and its documentation.
Verdicts: single-sourced / duplicate found / not applicable.
## 6. Partial-failure cleanup and resource-lifecycle gaps
State is created and then orphaned when a later step throws, or a handle is
opened and never closed.
Check by judgment. Look for a step that writes or creates before a step that
can fail, for an opened file, process, connection, or temporary directory
without a matching close on every exit path, and for a retry that repeats a
create without removing the prior attempt.
Verdicts: handled / gap found / not applicable.
## 7. Exit-code truthfulness
A command computes a failure and exits zero anyway, or never prints what it
found.
Check by judgment. Look for a failure counted or collected and then discarded,
for a pipeline whose exit status comes from the last stage rather than the
failing one, and for a check whose only output is on the pass path.
Verdicts: truthful / untruthful exit found / not applicable.
## 8. Tests asserting a copy instead of the production artifact
A test exercises a fixture that duplicates the thing under test, or the gate a
test exists to cover is monkeypatched away in every test that touches it.
Check by judgment. Look for fixture content that restates production logic or
data, for an import of the production artifact that no assertion reaches, and
for a patch or stub applied to the exact behavior the test names.
Verdicts: exercises production artifact / copy-assertion found / not
applicable.
## 9. Markdown and lint basics not covered by repo hooks
A heading level, list, code fence, or link in the diff is malformed in a way an
automated reviewer will raise.
Check by judgment when no repository hook covers it; report `covered by repo
gate` when step 2 found a hook or task runner that owns markdown or lint. Look
for skipped heading levels, unclosed or unlabeled code fences, and links whose
target does not resolve.
Verdicts: clean / finding / covered by repo gate / not applicable.
## 10. Mechanically checkable invariants that exist only as prose
A document states a rule a script, hook, or test could enforce, and nothing
enforces it.
Check by judgment. For each rule the diff states, ask what would fail if
someone broke it, and look for whether any check in the repository reads the
same input the rule reads.
Verdicts: enforced / prose-only invariant found / not applicable.
## 11. Diff size against automated-reviewer file caps
File counts and known reviewer limits identify potential review-coverage risk.
Read them from step 1's full surface report. When a limit is already available
from repository configuration or supplied evidence, optionally pass
`--cap <reviewer>=<n>`; record its source and read the helper's comparison.
Numeric caps are optional diagnostics. There is no required vendor or plan
research, or new cap configuration, to complete this class.
When no automated reviewer is configured, record `not applicable`. Otherwise
retain the helper's size verdict, including `cap unverified` when no cap was
supplied. With a complete inventory, unknown or exceeded caps alone are
informational: they neither fail the check nor withhold Approve, require a
split, or prove that review ran. Assess actual review coverage from the
required reviews and their receipts in steps 2 and 3. An actual required-review
failure or omission without replacement coverage remains a named unresolved
finding; complete independent required coverage can coexist with an optional
reviewer's exceeded cap.
Read the inventory detail as well as the size verdict. `cap unverified` can
also accompany an unmeasurable committed category, and `exceeds cap` may
compare only a measured subset. Missing or failed git inventory and unresolved
base identity remain step 1 blockers, even when the helper exits zero.
Verdicts: under caps / `exceeds cap for <reviewer>` / cap unverified /
no changes on surface / covered by repo gate / not run / not applicable.
## Helper exit → status word (SSOT)
A helper's verdict and the gate's status words are two layers: the verdict says
what the class found; the status word says whether the check happened. Read both
off the helper's exit code and its `verdict:` line (script headers list the
verdicts; this table maps execution).
| Exit | Meaning | Status word |
| --- | --- | --- |
| 0 | Class carried a verdict from its enumerated set | **verified** with the verdict line as named evidence, or **failed** when the verdict is a finding. Class 11's informational cap verdicts follow its coverage and inventory rules above; they do not verify that a review ran. |
| 2 with absent-input verdict (`no changelog`, `no records`) | Input missing | **unavailable** |
| 2 with `not run` / usage error | Helper could not run as invoked | **not run**. Fall back to the class's model-instruction check and record that judgment as the class verdict. |
| 3 | `--defer` to a repository gate | **skipped**, naming that gate |
| 4 | Helper hard failure | **not run**. Fall back to the class's model-instruction check and record that judgment as the class verdict. |
## When a helper cannot run
A helper that is absent, not executable, or exits without producing its output
does not remove its class from the sweep. Check that class by judgment against
the class description above. Record the status as `not run` and store the
model judgment as the class verdict, so helper verdicts stay distinct from
judgment verdicts.
scripts/changelog-union.sh
#!/usr/bin/env bash
#
# changelog-union.sh — does the branch's own work appear in the changelog?
#
# Serves sweep class 3 (branch changelog entry) in
# references/sweep-classes.md.
#
# The union is over the same working surface surface-report.sh reports:
# committed against the merge base with the default branch, staged, unstaged,
# and untracked. An untracked changelog counts as present, because the
# finishing path stages untracked paths and they ship with the change.
#
# Usage:
# changelog-union.sh [--base <ref>] [--merge-base <sha>] [--defer <gate-name>]
# [--help]
#
# Output states. Line 1 is always `verdict: <word>`; human detail follows.
#
# verdict: present exit 0 the changelog gained lines with
# content on the branch's surface; the
# detail carries the first added line.
# Whitespace-only additions do not
# count
# verdict: changed without entry exit 0 the changelog is among the branch's
# changed paths but gained no lines —
# a deletion, a reflow, or a
# whitespace-only edit. Touching the
# file is not an entry, so this is
# distinct from `present`
# verdict: missing exit 0 the branch changes other files and
# leaves the changelog untouched; the
# detail carries the count of changed
# non-changelog files
# verdict: no changes on surface exit 0 the branch changes nothing at all, so
# there is no work to record. Distinct
# from `present`: an empty surface is
# not an entry
# verdict: no changelog exit 2 the repository keeps no changelog at
# its root, so the class could not be
# checked here. Absent input is not a
# pass; class 3 routes this case
# verdict: covered by repo gate exit 3 --defer named a repository-owned
# check; nothing was compared
# verdict: not run exit 2 usage error (unknown option)
# verdict: not run exit 4 git is unavailable, this is not a
# git repository, a git read over the
# surface or the changelog failed, a
# supplied --merge-base failed
# validation, a supplied --base
# resolved to no branch, or
# no unambiguous default branch
# resolved while the
# branch has commits — the committed
# category is then unmeasurable, and a
# clean verdict against an unmeasured
# surface would be a silent pass. A
# failed read is never reported as an
# empty surface
#
# Dependencies: git and standard POSIX tools. No network, no jq, no node.
set -euo pipefail
usage() {
cat <<'EOF'
changelog-union.sh — branch-work-in-changelog check
Usage:
changelog-union.sh [--base <ref>] [--merge-base <sha>] [--defer <gate-name>]
[--help]
--base <ref> Use <ref> as the default branch instead of resolving one.
It resolves in the branch namespaces only — refs/remotes/
then refs/heads/ — so a tag cannot shadow a branch; a ref
that resolves in neither exits 4.
--merge-base <sha> Use <sha> as the merge base instead of computing one. It
is validated: whenever a base resolves (--base or a
default branch) it must equal that base's merge base with
HEAD, and with no base at all it must be an ancestor of
HEAD. A supplied merge base that fails validation exits 4.
--defer <gate-name> Report this class as owned by the named repository gate
and compare nothing (exit 3).
--help Print this text and exit 0.
Looks for CHANGELOG.md, CHANGELOG, CHANGELOG.txt, or changelog.md at the
repository root.
Verdicts: present | changed without entry | missing | no changes on surface |
no changelog | covered by repo gate | not run
EOF
}
fail_usage() {
printf 'verdict: not run\n'
printf 'reason: %s\n' "$1"
usage
exit 2
}
validate_bounded_text() {
local LC_ALL=C label="$1" value="$2" maximum="$3"
[ "${#value}" -le "$maximum" ] || fail_usage "$label must be at most $maximum bytes"
if [[ "$value" =~ [[:cntrl:]] ]]; then
fail_usage "$label must be a single line without control characters"
fi
}
defer_gate=""
supplied_base=""
supplied_merge_base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--help | -h)
usage
exit 0
;;
--defer)
[ "$#" -ge 2 ] || fail_usage "--defer requires a gate name"
[ -n "$2" ] || fail_usage "--defer requires a non-empty gate name"
validate_bounded_text "--defer gate name" "$2" 128
defer_gate="$2"
shift 2
;;
--base)
[ "$#" -ge 2 ] || fail_usage "--base requires a ref"
[ -n "$2" ] || fail_usage "--base requires a non-empty ref"
supplied_base="$2"
shift 2
;;
--merge-base)
[ "$#" -ge 2 ] || fail_usage "--merge-base requires a commit"
[ -n "$2" ] || fail_usage "--merge-base requires a non-empty commit"
supplied_merge_base="$2"
shift 2
;;
*)
fail_usage "unknown option: $1"
;;
esac
done
if [ -n "$defer_gate" ]; then
printf 'verdict: covered by repo gate\n'
printf 'gate: %s\n' "$defer_gate"
printf 'detail: changelog union not computed here; the named repository gate owns this class.\n'
exit 3
fi
if ! command -v git >/dev/null 2>&1; then
printf 'verdict: not run\n'
printf 'reason: git is not available on PATH\n'
exit 4
fi
if ! git rev-parse --git-dir >/dev/null 2>&1; then
printf 'verdict: not run\n'
printf 'reason: not inside a git repository\n'
exit 4
fi
if [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]; then
printf 'verdict: not run\n'
printf 'reason: not inside a git work tree (a bare repository has no working surface to check)\n'
exit 4
fi
cd "$(git rev-parse --show-toplevel)"
fail_read() {
printf 'verdict: not run\n'
printf 'reason: the %s read could not be completed: %s\n' "$1" "$2"
printf 'detail: a failed git read is not an empty surface, so no changelog verdict is reported.\n'
exit 4
}
# A merge base that fails validation is as unusable as one that fails to read:
# it silently shrinks the committed category, so it takes the same hard exit.
fail_merge_base() {
printf 'verdict: not run\n'
printf 'reason: the supplied merge base is not usable: %s\n' "$1"
printf 'detail: the committed category is measured from the merge base, so an unverified one is not compared against the changelog.\n'
exit 4
}
# Every surface read goes through this: an empty result and a failed read look
# identical once the status is discarded, and reporting a failed read as an
# empty surface turns a broken repository into `no changes on surface`.
git_out=""
read_or_fail() {
enumeration="$1"
shift
if ! git_out=$(git "$@" 2>/dev/null); then
fail_read "$enumeration" "git $* returned non-zero"
fi
}
head_exists=0
if git rev-parse --verify --quiet HEAD >/dev/null 2>&1; then
head_exists=1
fi
# A changelog the branch deletes from the worktree — plain or staged with
# `git rm` — is still the repository's changelog: recognizing index and HEAD
# paths keeps that deletion a branch finding instead of an absent-input
# result.
# Symlink entries (mode 120000) are filtered in the index and in HEAD for the
# same reason the worktree check refuses links: git ships the link, and
# reading or diffing through one quotes paths or content from outside the
# repository.
read_or_fail "changelog tracking" ls-files -s -- CHANGELOG.md CHANGELOG CHANGELOG.txt changelog.md
index_changelogs=$(printf '%s\n' "$git_out" | grep -v '^120000 ' | sed 's/^[^ ]* //' || true)
head_changelogs=""
if [ "$head_exists" -eq 1 ]; then
read_or_fail "changelog history" ls-tree HEAD -- CHANGELOG.md CHANGELOG CHANGELOG.txt changelog.md
head_changelogs=$(printf '%s\n' "$git_out" | grep -v '^120000 ' | sed 's/^[^ ]* //' || true)
fi
# Live candidates — on disk or in the index — win over paths that survive only
# in HEAD, so a staged rename resolves to its replacement rather than to the
# deleted old name.
# A symlink is never the changelog: git ships the link, not its target, and
# reading through it would quote content from outside the repository.
changelog=""
for candidate in CHANGELOG.md CHANGELOG CHANGELOG.txt changelog.md; do
if { [ -f "$candidate" ] && [ ! -L "$candidate" ]; } ||
printf '%s\n' "$index_changelogs" | grep -Fx -- "$candidate" >/dev/null; then
changelog="$candidate"
break
fi
done
if [ -z "$changelog" ]; then
for candidate in CHANGELOG.md CHANGELOG CHANGELOG.txt changelog.md; do
if printf '%s\n' "$head_changelogs" | grep -Fx -- "$candidate" >/dev/null; then
changelog="$candidate"
break
fi
done
fi
if [ -n "$changelog" ]; then
# An unresolved merge conflict makes the changelog's content unmeasurable:
# counting conflict markers or one side's lines as an entry would be a
# silent pass over a file the owner has not finished writing.
read_or_fail "merge state" ls-files -u -- "$changelog"
if [ -n "$git_out" ]; then
printf 'verdict: not run\n'
printf 'reason: %s has an unresolved merge conflict, so its entries cannot be counted\n' "$changelog"
printf 'detail: resolve the conflict and re-run.\n'
exit 4
fi
fi
if [ -z "$changelog" ]; then
printf 'verdict: no changelog\n'
printf 'detail: no CHANGELOG.md, CHANGELOG, CHANGELOG.txt, or changelog.md at the repository root.\n'
printf 'detail: the class could not be checked here; see references/sweep-classes.md class 3.\n'
exit 2
fi
# A supplied base is resolved in the branch namespaces rather than bare: git
# resolves an ambiguous short name tags-first, so a tag named main would shadow
# the branch and the changelog check would silently compare against the wrong
# commit. refs/remotes/ is tried first so origin/main keeps working; a value
# that resolves in neither namespace is refused rather than falling back to a
# bare rev-parse that a tag could hijack.
resolve_supplied_base() {
for namespace in "refs/remotes/$1" "refs/heads/$1"; do
if git rev-parse --verify --quiet "$namespace" >/dev/null 2>&1; then
printf '%s\n' "$namespace"
return 0
fi
done
return 1
}
fail_base() {
printf 'verdict: not run\n'
printf 'reason: the supplied --base %s resolves to no branch: neither refs/remotes/%s nor refs/heads/%s exists\n' "$1" "$1" "$1"
printf 'detail: a base is resolved in the branch namespaces only, so a tag cannot shadow the branch the surface is compared against.\n'
exit 4
}
base_ref=""
if [ -n "$supplied_base" ]; then
base_ref=$(resolve_supplied_base "$supplied_base") || fail_base "$supplied_base"
else
# Resolution is attempted even when --merge-base is supplied: a base that
# resolves is what the supplied merge base is checked against, and a
# supplied value that cannot be checked gets the weaker ancestor check.
head_ref=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$head_ref" ]; then
base_ref="${head_ref#refs/remotes/}"
else
# Tiered like surface-report.sh: a tier with more than one live candidate
# is ambiguous, and an ambiguous base leaves committed unmeasured rather
# than diffing against a guess. Full ref namespaces, so a tag named main
# or master can never satisfy a branch fallback.
for tier in "refs/remotes/origin/main refs/remotes/origin/master" "refs/heads/main refs/heads/master"; do
found=""
found_count=0
for candidate in $tier; do
if git rev-parse --verify --quiet "$candidate" >/dev/null 2>&1; then
found="$candidate"
found_count=$((found_count + 1))
fi
done
if [ "$found_count" -eq 1 ]; then
base_ref="$found"
fi
[ "$found_count" -eq 0 ] || break
done
fi
fi
committed=""
merge_base=""
if [ -n "$supplied_merge_base" ] && [ "$head_exists" -eq 1 ]; then
# A supplied merge base is verified as a commit before use, so a typo
# fails with the read named instead of a confusing diff error.
read_or_fail "merge base" rev-parse --verify --quiet "${supplied_merge_base}^{commit}"
merge_base="$git_out"
# An unchecked merge base decides the committed category on its own: HEAD
# passed here empties the committed diff, and a branch that recorded its
# work in an earlier commit then reads as `missing`. Whenever a base
# resolves, the supplied value must be the merge base that base yields;
# only with no base at all is the weaker ancestor check the best available.
if [ -n "$base_ref" ]; then
read_or_fail "merge base" merge-base HEAD "$base_ref"
if [ "$merge_base" != "$git_out" ]; then
fail_merge_base "supplied --merge-base ${supplied_merge_base} (resolved to ${merge_base}) does not match merge-base(HEAD, ${base_ref}) = ${git_out}"
fi
else
git merge-base --is-ancestor "$merge_base" HEAD 2>/dev/null ||
fail_merge_base "supplied --merge-base ${supplied_merge_base} is not an ancestor of HEAD"
fi
elif [ -n "$base_ref" ] && [ "$head_exists" -eq 1 ]; then
read_or_fail "merge base" merge-base HEAD "$base_ref"
merge_base="$git_out"
fi
if [ -n "$merge_base" ]; then
read_or_fail "committed" diff --name-only "$merge_base" HEAD
committed="$git_out"
fi
# With commits on the branch but no resolvable default branch, the committed
# category cannot be enumerated, so any verdict that depends on not finding
# something there would be a silent pass. Positive evidence (`present`) from
# the measurable categories is still honest; the negative verdicts are not.
committed_measured=1
if [ "$head_exists" -eq 1 ] && [ -z "$base_ref" ] && [ -z "$merge_base" ]; then
committed_measured=0
fi
require_committed_measured() {
if [ "$committed_measured" -eq 0 ]; then
printf 'verdict: not run\n'
printf 'reason: no unambiguous default branch resolved, so the committed category could not be measured\n'
printf 'detail: a "%s" verdict against an unmeasured surface would be a silent pass; resolve the default branch and re-run.\n' "$1"
exit 4
fi
}
if [ "$head_exists" -eq 1 ]; then
read_or_fail "staged" diff --cached --name-only
else
empty_tree=$(git hash-object -t tree /dev/null 2>/dev/null || true)
[ -n "$empty_tree" ] || fail_read "staged" "the empty tree object could not be resolved"
read_or_fail "staged" diff --cached --name-only "$empty_tree"
fi
staged="$git_out"
read_or_fail "unstaged" diff --name-only
unstaged="$git_out"
read_or_fail "untracked" ls-files --others --exclude-standard
untracked="$git_out"
all_paths=$(printf '%s\n%s\n%s\n%s\n' "$committed" "$staged" "$unstaged" "$untracked" |
sed '/^$/d' | sort -u)
if [ -z "$all_paths" ]; then
require_committed_measured "no changes on surface"
printf 'verdict: no changes on surface\n'
printf 'changelog: %s\n' "$changelog"
printf 'detail: the branch changes no files, so there is no branch work to record.\n'
exit 0
fi
if ! printf '%s\n' "$all_paths" | grep -Fx -- "$changelog" >/dev/null; then
require_committed_measured "missing"
others=$(printf '%s\n' "$all_paths" | grep -Fxv -- "$changelog" || true)
other_count=0
[ -z "$others" ] || other_count=$(printf '%s\n' "$others" | wc -l | tr -d ' ')
printf 'verdict: missing\n'
printf 'changelog: %s (untouched on this branch)\n' "$changelog"
printf 'changed non-changelog files: %s\n' "$other_count"
exit 0
fi
# A changed changelog is not yet an entry. Only lines the branch adds count, so
# the added lines are collected across the same surface: an untracked changelog
# is all additions, and a tracked one is diffed from the merge base (or from the
# empty tree when the branch has no commits) through to the working tree.
added_lines=""
if printf '%s\n' "$untracked" | grep -Fx -- "$changelog" >/dev/null; then
[ ! -L "$changelog" ] ||
fail_read "changelog contents" "the changelog is a symbolic link; content is not read through links"
added_lines=$(cat -- "$changelog" 2>/dev/null) ||
fail_read "changelog contents" "cat -- $changelog returned non-zero"
else
if [ -n "$merge_base" ]; then
diff_range="$merge_base"
elif [ "$head_exists" -eq 1 ]; then
diff_range="HEAD"
else
read_or_fail "empty tree" hash-object -t tree /dev/null
diff_range="$git_out"
fi
# Prefixes, color, and rename detection are pinned so repository config
# (diff.noprefix, diff.mnemonicPrefix, color.ui, diff.renames, external
# diff drivers) cannot change the output shape this filter relies on. The
# pathspec covers every candidate name so a rename keeps its provenance
# and a pure rename does not render as a wholly added file.
read_or_fail "changelog additions" -c diff.noprefix=false -c diff.mnemonicPrefix=false \
-c diff.renames=true \
diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ "$diff_range" -- \
CHANGELOG.md CHANGELOG CHANGELOG.txt changelog.md
# Drop only the exact destination headers for the supported changelog
# names, never content: an added line starting with ++ (even "++ b/…")
# renders as +++ in the diff too, so a prefix match would discard it.
added_lines=$(printf '%s\n' "$git_out" |
grep '^+' |
grep -Fxv -e '+++ b/CHANGELOG.md' -e '+++ b/CHANGELOG' \
-e '+++ b/CHANGELOG.txt' -e '+++ b/changelog.md' \
-e '+++ /dev/null' |
sed 's/^+//' || true)
fi
# Only added lines with content count as an entry: a blank or whitespace-only
# addition is a formatting change, not recorded branch work.
added_count=0
[ -z "$added_lines" ] ||
added_count=$(printf '%s\n' "$added_lines" | grep -c '[^[:space:]]' || true)
if [ "$added_count" -eq 0 ]; then
require_committed_measured "changed without entry"
printf 'verdict: changed without entry\n'
printf 'changelog: %s (changed on this branch, no lines with content added)\n' "$changelog"
printf 'detail: the branch touches the changelog without adding a line with content, so no branch work is recorded there.\n'
exit 0
fi
first_added=$(printf '%s\n' "$added_lines" | sed -n '/[^[:space:]]/{p;q;}')
printf 'verdict: present\n'
printf 'changelog: %s\n' "$changelog"
printf 'added lines with content: %s\n' "$added_count"
printf 'first added line: %s\n' "$first_added"
exit 0
scripts/evidence-freshness.sh
#!/usr/bin/env bash
#
# evidence-freshness.sh — records that predate the final edit they describe,
# and plan-named artifacts that no longer match what shipped.
#
# Serves sweep class 4 (evidence or test records predating the final edit) and
# supports class 2 (stale cross-references) in references/sweep-classes.md.
#
# Order comes from commit ancestry, never committer timestamps or filesystem
# mtimes: a checkout stamps every file with the same recent mtime, and a skewed
# or rewritten committer clock can date a later commit earlier, so a described
# path is fresh only when its last commit is contained in the history of the
# record's last commit. A described path that is dirty in the working tree
# (staged, unstaged, or untracked) is stale outright: the edit happened after
# any committed record.
#
# Relative paths are resolved from the repository root, matching the other
# bundled helpers.
#
# The record itself is held to a stricter rule: its time always comes from its
# last commit. A dirty record has no established write time, so it cannot prove
# anything is fresh — treating it as written now would let a record and the path
# it describes both be dirty and rate as fresh against each other.
#
# Usage:
# evidence-freshness.sh <record-file> <described-path>...
# evidence-freshness.sh --check-name <name> <search-root>
# evidence-freshness.sh --defer <gate-name>
# evidence-freshness.sh --help
#
# Output states. Line 1 is always `verdict: <word>`; human detail follows.
#
# verdict: fresh exit 0 every described path's last commit
# is contained in the history of the
# record's last commit
# verdict: stale record found exit 0 a described path changed after the
# record — its last commit descends
# from the record's, it is dirty in
# the working tree, or its history is
# incomparable with the record's; the
# detail names the path and commits.
# Also emitted when a described path
# is missing from the working tree, or
# has no git history to compare
# against — a record describing
# something that no longer exists is
# stale, not fresh — and when the
# record itself has git history but
# is gone from the working tree
# verdict: record unverifiable (dirty) exit 0 the record is dirty in the working
# (dirty) tree, so its own write time cannot be
# established. A record that cannot be
# dated proves nothing fresh
# verdict: consistent exit 0 --check-name matched a file on the
# working surface under the search root
# whose basename or path suffix is the
# name; the detail also lists any docs
# mentioning it
# verdict: stale reference found exit 0 --check-name matched no such file: a
# plan-named artifact that does not
# match what shipped. A name that is
# only mentioned in prose, with no file
# behind it, lands here
# verdict: no records exit 2 the record file is absent — neither
# committed nor present in the working
# tree — or the --check-name search
# root does not exist. Absent input is
# not a pass
# verdict: covered by repo gate exit 3 --defer named a repository-owned
# check; nothing was compared
# verdict: not run exit 2 usage error, including no arguments
# verdict: not run exit 4 git is unavailable, this is not a
# git repository, or a git read the
# comparison needs failed. A failed
# read is never reported as a clean
# status or an empty listing
#
# Dependencies: git and standard POSIX tools. No network, no jq, no node.
set -euo pipefail
usage() {
cat <<'EOF'
evidence-freshness.sh — stale-record and stale-name check
Usage:
evidence-freshness.sh <record-file> <described-path>...
evidence-freshness.sh --check-name <name> <search-root>
evidence-freshness.sh --defer <gate-name>
evidence-freshness.sh --help
<record-file> A log, run record, or recorded result.
<described-path> A path that record describes. Repeatable.
--check-name Look under <search-root> for a file whose basename or
path suffix is the literal <name>, and report whether a
plan-named artifact still matches what shipped. Takes no
further arguments. <search-root> is a git pathspec
resolved within the repository: an absolute path inside
the worktree is accepted, a path outside the repository is
not, because every enumeration here goes through git.
--defer <gate-name> Report this class as owned by the named repository gate
and compare nothing (exit 3).
--help Print this text and exit 0.
Verdicts: fresh | stale record found | record unverifiable (dirty) |
consistent | stale reference found | no records |
covered by repo gate | not run
EOF
}
fail_usage() {
printf 'verdict: not run\n'
printf 'reason: %s\n' "$1"
usage
exit 2
}
validate_bounded_text() {
local LC_ALL=C label="$1" value="$2" maximum="$3"
[ "${#value}" -le "$maximum" ] || fail_usage "$label must be at most $maximum bytes"
if [[ "$value" =~ [[:cntrl:]] ]]; then
fail_usage "$label must be a single line without control characters"
fi
}
if [ "$#" -eq 0 ]; then
fail_usage "no arguments given"
fi
defer_gate=""
check_name=""
search_root=""
mode="freshness"
positional_count=0
record=""
described=""
while [ "$#" -gt 0 ]; do
case "$1" in
--help | -h)
usage
exit 0
;;
--defer)
[ "$#" -ge 2 ] || fail_usage "--defer requires a gate name"
[ -n "$2" ] || fail_usage "--defer requires a non-empty gate name"
validate_bounded_text "--defer gate name" "$2" 128
defer_gate="$2"
shift 2
;;
--check-name)
[ "$#" -ge 3 ] || fail_usage "--check-name requires <name> <search-root>"
[ "$mode" != "name" ] || fail_usage "--check-name given more than once; run one check per invocation"
mode="name"
check_name="$2"
search_root="$3"
shift 3
;;
-*)
fail_usage "unknown option: $1"
;;
*)
positional_count=$((positional_count + 1))
if [ "$positional_count" -eq 1 ]; then
record="$1"
else
described="${described}${1}
"
fi
shift
;;
esac
done
if [ -n "$defer_gate" ]; then
printf 'verdict: covered by repo gate\n'
printf 'gate: %s\n' "$defer_gate"
printf 'detail: record freshness not compared here; the named repository gate owns this class.\n'
exit 3
fi
if [ "$mode" = "name" ]; then
# An empty name matches every path, so it would confirm anything.
[ -n "$check_name" ] || fail_usage "--check-name requires a non-empty <name>"
[ -n "$search_root" ] || fail_usage "--check-name requires a non-empty <search-root>"
[ "$positional_count" -eq 0 ] || fail_usage "--check-name takes no arguments beyond <name> <search-root>"
fi
if ! command -v git >/dev/null 2>&1; then
printf 'verdict: not run\n'
printf 'reason: git is not available on PATH\n'
exit 4
fi
if ! git rev-parse --git-dir >/dev/null 2>&1; then
printf 'verdict: not run\n'
printf 'reason: not inside a git repository\n'
exit 4
fi
if [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]; then
printf 'verdict: not run\n'
printf 'reason: not inside a git work tree (a bare repository has no working surface to check)\n'
exit 4
fi
cd "$(git rev-parse --show-toplevel)"
repo_root=$(pwd -P)
fail_read() {
printf 'verdict: not run\n'
printf 'reason: the %s read could not be completed: %s\n' "$1" "$2"
printf 'detail: a failed git read is not a clean status or an empty listing, so no freshness verdict is reported.\n'
exit 4
}
fail_unsafe_path() {
printf 'verdict: not run\n'
printf 'reason: unsafe %s: the path must stay within the repository and contain no symbolic links\n' "$1"
printf 'detail: repository paths are validated before any read.\n'
exit 4
}
validate_repo_path() {
local raw_path="$1" path_label="$2" relative_path current_path remaining_path component
case "$raw_path" in
"$repo_root") relative_path="" ;;
"$repo_root"/*) relative_path="${raw_path#"$repo_root"/}" ;;
/*) fail_unsafe_path "$path_label" ;;
*) relative_path="$raw_path" ;;
esac
current_path="$repo_root"
remaining_path="$relative_path"
while [ -n "$remaining_path" ]; do
component="${remaining_path%%/*}"
if [ "$remaining_path" = "$component" ]; then
remaining_path=""
else
remaining_path="${remaining_path#*/}"
fi
case "$component" in
'' | .) continue ;;
..) fail_unsafe_path "$path_label" ;;
esac
current_path="${current_path}/${component}"
[ ! -L "$current_path" ] || fail_unsafe_path "$path_label"
done
}
if [ "$mode" = "name" ]; then
validate_repo_path "$search_root" "search root"
else
validate_repo_path "$record" "record path"
while IFS= read -r described_path; do
[ -n "$described_path" ] || continue
validate_repo_path "$described_path" "described path"
done <<EOF
$described
EOF
fi
# Every git read the comparison depends on goes through this: an empty result
# and a failed read look identical once the status is discarded, and a failed
# status read reported as "clean" would let a dirty record rate as fresh.
git_out=""
read_or_fail() {
enumeration="$1"
shift
if ! git_out=$(git "$@" 2>/dev/null); then
fail_read "$enumeration" "git $* returned non-zero"
fi
}
iso_of() {
date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null ||
date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null ||
printf 'epoch:%s\n' "$1"
}
if [ "$mode" = "name" ]; then
if [ ! -e "$search_root" ]; then
read_or_fail "index listing" ls-files -- "$search_root"
if [ -z "$git_out" ]; then
printf 'verdict: no records\n'
printf 'search root missing: %s\n' "$search_root"
printf 'detail: nothing could be searched, so the name was neither confirmed nor refuted.\n'
exit 2
fi
# Absent on disk but present in the index: a sparse-checkout cone can
# omit the root while its tracked artifacts live on.
fi
# Existence is decided by paths, not by prose. A content grep matches the
# plan that proposed the name as readily as the artifact that shipped — and
# matches the file naming itself — so the name is matched against the paths
# on the working surface, and the content hits are reported as detail only.
# core.quotepath=false keeps non-ASCII pathnames raw instead of C-quoted,
# so the existence test and suffix match see the real path.
read_or_fail "search-root listing" -c core.quotepath=false ls-files --cached --others --exclude-standard -- "$search_root"
surface="$git_out"
matches=""
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
# A pathname git had to C-quote even with quotepath off carries an
# embedded control character (a newline breaks line-by-line parsing
# outright), so the listing cannot be trusted — fail closed.
case "$candidate" in
\"*) fail_read "search-root listing" "a pathname required C-quoting, so the listing cannot be parsed line by line" ;;
esac
# An index entry deleted from the working tree ships as a deletion, so
# it does not count as a shipped artifact; only a regular file does —
# or a tracked path a sparse-checkout cone omits (absent from the
# worktree yet clean in status). Paths outside this enumerated
# surface — ignored files, directories — never count, which is why
# there is no filesystem fallback here.
if [ ! -f "$candidate" ]; then
read_or_fail "index listing" ls-files -- "$candidate"
[ -n "$git_out" ] || continue
read_or_fail "working-tree status" status --porcelain -- "$candidate"
[ -z "$git_out" ] || continue
fi
case "$candidate" in
"$check_name" | */"$check_name")
matches="${matches}${candidate}
"
;;
esac
done <<EOF
$surface
EOF
# Content mentions are detail only, so failures are tolerated — but the
# search never walks ignored trees: --untracked extends git grep to
# untracked-but-not-ignored files, where a raw recursive grep would walk
# node_modules and every other ignored tree.
mentions=$(git grep -lF --untracked -e "$check_name" -- "$search_root" 2>/dev/null | sort -u || true)
# The mentions listing is detail, not the verdict, so it is capped: the
# count stays exact and the first ten paths are shown.
emit_mentions() {
# No mentions is no listing: printf would still emit one empty line,
# which prints as a bare indented path and counts as one match.
[ -n "$mentions" ] || return 0
mention_count=$(printf '%s\n' "$mentions" | wc -l | tr -d ' ')
# sed drains its stdin: `head` would close the pipe early and kill the
# writing printf with SIGPIPE, which `set -o pipefail` turns into a
# 141 exit for the whole script.
printf '%s\n' "$mentions" | sed -n '1,10s/^/ /p'
[ "$mention_count" -le 10 ] ||
printf ' … and %s more\n' "$((mention_count - 10))"
}
if [ -z "$matches" ]; then
printf 'verdict: stale reference found\n'
printf 'name: %s\n' "$check_name"
printf 'search root: %s\n' "$search_root"
printf 'detail: no file under the search root carries this name.\n'
if [ -n "$mentions" ]; then
printf 'detail: the name is mentioned but nothing shipped under it; mentioned in:\n'
emit_mentions
fi
exit 0
fi
printf 'verdict: consistent\n'
printf 'name: %s\n' "$check_name"
printf 'search root: %s\n' "$search_root"
printf 'files with this name:\n'
printf '%s' "$matches" | sed 's/^/ /'
if [ -n "$mentions" ]; then
printf 'mentioned in:\n'
emit_mentions
fi
exit 0
fi
if [ "$positional_count" -lt 2 ]; then
fail_usage "expected <record-file> and at least one <described-path>"
fi
# Called from the current shell, never through a command substitution, so a
# failed status read exits with the fail_read verdict instead of having it
# captured as text.
is_dirty() {
read_or_fail "working-tree status" status --porcelain -- "$1"
[ -n "$git_out" ]
}
# An index entry marked assume-unchanged (lowercase tag) or skip-worktree (S)
# suppresses status output, so a clean status there is no evidence of a clean
# file. Current-shell only, like is_dirty.
status_suppressed() {
read_or_fail "index flags" ls-files -v -- "$1"
# Explicit characters, not [a-z]: locale collation can pull uppercase
# letters into a lowercase range and misread every ordinary H entry.
case "$git_out" in
[hsmrck]* | S*) return 0 ;;
esac
return 1
}
# An unborn repository has no commits to date anything by; that is legitimate
# empty history, distinct from a read that failed.
head_exists=0
if git rev-parse --verify --quiet HEAD >/dev/null 2>&1; then
head_exists=1
fi
# Last commit that touched a path; empty when the path has no committed
# history. Also current-shell only, for the same reason as is_dirty.
last_commit=""
read_last_commit() {
last_commit=""
if [ "$head_exists" -eq 1 ]; then
read_or_fail "commit history" rev-list -1 HEAD -- "$1"
last_commit="$git_out"
fi
}
# Ancestry, not committer timestamps, decides order: a skewed or rewritten
# committer clock can date a later commit earlier, and wall-clock ordering is
# refused for dirty paths above for the same reason.
# Return 0 = ancestor, 1 = not an ancestor; any other status is a failed read.
is_ancestor() {
git merge-base --is-ancestor "$1" "$2" 2>/dev/null
ancestor_rc=$?
[ "$ancestor_rc" -le 1 ] ||
fail_read "commit ancestry" "git merge-base --is-ancestor returned status $ancestor_rc"
return "$ancestor_rc"
}
# The record is dated by its last commit only. A record with no established
# committed write point must not certify anything as fresh.
read_last_commit "$record"
record_commit="$last_commit"
record_sparse=0
if [ ! -e "$record" ]; then
read_or_fail "index listing" ls-files -- "$record"
record_tracked="$git_out"
read_or_fail "working-tree status" status --porcelain -- "$record"
if [ -n "$record_tracked" ] && [ -z "$git_out" ]; then
# Tracked, absent, and clean in status: a sparse-checkout omission,
# not a deletion — the record lives on in history and dates normally.
record_sparse=1
elif [ -n "$record_commit" ]; then
printf 'verdict: stale record found\n'
printf 'record missing: %s\n' "$record"
printf 'detail: record existed in history but is gone from the working tree.\n'
exit 0
else
printf 'verdict: no records\n'
printf 'record missing: %s\n' "$record"
printf 'detail: the record is neither committed nor present in the working tree, so freshness could not be checked.\n'
exit 2
fi
fi
record_undatable=0
if [ -z "$record_commit" ]; then
record_undatable=1
elif [ "$record_sparse" -eq 0 ]; then
if is_dirty "$record" || status_suppressed "$record"; then
record_undatable=1
fi
fi
if [ "$record_undatable" -eq 1 ]; then
printf 'verdict: record unverifiable (dirty)\n'
printf 'record: %s\n' "$record"
printf 'detail: the record is uncommitted in the working tree, or its status is suppressed (assume-unchanged or skip-worktree), so its own write point cannot be established.\n'
printf 'detail: commit the record and clear any index flags, then re-run; an undatable record cannot prove a described path fresh.\n'
exit 0
fi
read_or_fail "commit history" log -1 --format=%ct "$record_commit"
record_time="$git_out"
stale_lines=""
fresh_lines=""
while IFS= read -r path; do
[ -n "$path" ] || continue
if [ ! -e "$path" ]; then
read_or_fail "index listing" ls-files -- "$path"
tracked_entry="$git_out"
read_or_fail "working-tree status" status --porcelain -- "$path"
if [ -z "$tracked_entry" ] || [ -n "$git_out" ]; then
stale_lines="${stale_lines}described path missing: ${path}
"
continue
fi
# Tracked, absent, and clean in status: a sparse-checkout omission,
# not a deletion — the path lives on in history, so it is compared
# by ancestry like any committed path.
elif is_dirty "$path"; then
stale_lines="${stale_lines}stale: ${path} is dirty in the working tree, so it was edited after any committed record
"
continue
elif status_suppressed "$path"; then
stale_lines="${stale_lines}stale: ${path} has its status suppressed (assume-unchanged or skip-worktree), so a hidden edit cannot be ruled out (freshness cannot be proven)
"
continue
fi
read_last_commit "$path"
path_commit="$last_commit"
if [ -z "$path_commit" ]; then
stale_lines="${stale_lines}described path has no git history: ${path} (freshness cannot be proven)
"
continue
fi
if [ "$path_commit" = "$record_commit" ]; then
fresh_lines="${fresh_lines}described: ${path} last changed in the record's own commit
"
elif is_ancestor "$path_commit" "$record_commit"; then
fresh_lines="${fresh_lines}described: ${path} last changed in $(printf '%.7s' "$path_commit"), within the record's history
"
elif is_ancestor "$record_commit" "$path_commit"; then
stale_lines="${stale_lines}stale: ${path} last changed in $(printf '%.7s' "$path_commit"), after the record's last commit $(printf '%.7s' "$record_commit")
"
else
stale_lines="${stale_lines}stale: ${path} last changed in $(printf '%.7s' "$path_commit"), on a history incomparable with the record's last commit (freshness cannot be proven)
"
fi
done <<EOF
$described
EOF
if [ -n "$stale_lines" ]; then
printf 'verdict: stale record found\n'
else
printf 'verdict: fresh\n'
fi
printf 'record: %s last committed %s in %.7s\n' "$record" "$(iso_of "$record_time")" "$record_commit"
[ -z "$stale_lines" ] || printf '%s' "$stale_lines"
[ -z "$fresh_lines" ] || printf '%s' "$fresh_lines"
exit 0
scripts/surface-report.sh
#!/usr/bin/env bash
#
# surface-report.sh — the working-surface and size report.
#
# Serves SKILL.md step 1 (report the full working surface) and sweep class 11
# (diff size against automated-reviewer file caps) in
# references/sweep-classes.md.
#
# Reports four categories with counts and paths: committed against the merge
# base with the default branch, staged, unstaged, and untracked. Empty
# categories are printed explicitly, because a category silently omitted reads
# as a pass. Counts are always exact; each category's path listing is capped
# at 25 paths unless --full is given, so a huge surface reports without
# flooding the caller.
#
# Usage:
# surface-report.sh [--cap <name>=<n>]... [--base <ref>] [--merge-base <sha>]
# [--full] [--defer <gate-name>] [--help]
#
# Output states. Line 1 is always `verdict: <word>`; human detail follows.
#
# verdict: under caps exit 0 every supplied cap is at or above
# the total distinct changed files
# verdict: exceeds cap for <name> exit 0 at least one supplied cap is below
# the total; the verdict names the
# first such reviewer and every cap
# is listed in the detail lines
# verdict: cap unverified exit 0 no --cap was supplied, so the size
# check could not be made; or the
# committed category could not be
# measured, so no supplied cap can
# be called met. The surface is
# still reported
# verdict: no changes on surface exit 0 all four categories are empty and
# the committed category was
# measured; when it could not be,
# an empty measured surface reports
# `cap unverified` instead.
# Distinct from `under caps`: an
# absent surface is not a pass
# against a cap
# verdict: covered by repo gate exit 3 --defer named a repository-owned
# check; nothing was measured
# verdict: not run exit 2 usage error (unknown option, or a
# malformed --cap value)
# verdict: not run exit 4 git is unavailable, this is not a
# git repository, one of the five
# git enumerations (merge base,
# committed, staged, unstaged,
# untracked) failed to read, or a
# supplied --merge-base failed
# validation, or a supplied --base
# resolved to no branch. A failed
# read is never reported as an empty
# category; the reason line names the
# enumeration
#
# One further state rides in the detail lines rather than the verdict: when no
# default branch resolves, `default branch: unresolved` is printed, the
# committed category reports `not computed`, and the other three categories are
# still reported, so the verdict describes a HEAD-only surface. Because the
# committed count is then unknown, supplied caps report `cap unverified` unless
# the measured part alone already exceeds one.
#
# Dependencies: git and standard POSIX tools. No network, no jq, no node.
set -euo pipefail
usage() {
cat <<'EOF'
surface-report.sh — working-surface and size report
Usage:
surface-report.sh [--cap <name>=<n>]... [--base <ref>] [--merge-base <sha>]
[--full] [--defer <gate-name>] [--help]
--cap <name>=<n> Compare the total distinct changed-file count against
reviewer cap <n> for reviewer <name>. Repeatable.
With no --cap the size check reports `cap unverified`.
--base <ref> Use <ref> as the default branch instead of resolving one.
It resolves in the branch namespaces only — refs/remotes/
then refs/heads/ — so a tag cannot shadow a branch; a ref
that resolves in neither exits 4.
--merge-base <sha> Use <sha> as the merge base instead of computing one. It
is validated: whenever a base resolves (--base or a
default branch) it must equal that base's merge base with
HEAD, and with no base at all it must be an ancestor of
HEAD and the merge-base line says it went unverified.
A supplied merge base that fails validation exits 4.
--full Print every path in each category instead of the first
25. Counts are exact either way.
--defer <gate-name> Report this class as owned by the named repository gate
and measure nothing (exit 3).
--help Print this text and exit 0.
Verdicts: under caps | exceeds cap for <name> | cap unverified |
no changes on surface | covered by repo gate | not run
EOF
}
fail_usage() {
printf 'verdict: not run\n'
printf 'reason: %s\n' "$1"
usage
exit 2
}
validate_bounded_text() {
local LC_ALL=C label="$1" value="$2" maximum="$3"
[ "${#value}" -le "$maximum" ] || fail_usage "$label must be at most $maximum bytes"
if [[ "$value" =~ [[:cntrl:]] ]]; then
fail_usage "$label must be a single line without control characters"
fi
}
caps=""
defer_gate=""
supplied_base=""
supplied_merge_base=""
full_listing=0
while [ "$#" -gt 0 ]; do
case "$1" in
--help | -h)
usage
exit 0
;;
--defer)
[ "$#" -ge 2 ] || fail_usage "--defer requires a gate name"
[ -n "$2" ] || fail_usage "--defer requires a non-empty gate name"
validate_bounded_text "--defer gate name" "$2" 128
defer_gate="$2"
shift 2
;;
--cap)
[ "$#" -ge 2 ] || fail_usage "--cap requires <name>=<n>"
validate_bounded_text "--cap value" "$2" 80
case "$2" in
*=*) ;;
*) fail_usage "--cap expects <name>=<n>, got: $2" ;;
esac
cap_value="${2#*=}"
case "$cap_value" in
'' | *[!0-9]*) fail_usage "--cap count must be a non-negative integer, got: $2" ;;
esac
# Beyond 15 digits the shell's integer comparison breaks, and a broken
# comparison must not fall through to a clean verdict.
[ "${#cap_value}" -le 15 ] || fail_usage "--cap count is too large to compare: $2"
cap_name="${2%%=*}"
case "$cap_name" in
'') fail_usage "--cap requires a reviewer name before '=', got: $2" ;;
esac
validate_bounded_text "--cap reviewer name" "$cap_name" 64
caps="${caps}${2}
"
shift 2
;;
--base)
[ "$#" -ge 2 ] || fail_usage "--base requires a ref"
[ -n "$2" ] || fail_usage "--base requires a non-empty ref"
supplied_base="$2"
shift 2
;;
--merge-base)
[ "$#" -ge 2 ] || fail_usage "--merge-base requires a commit"
[ -n "$2" ] || fail_usage "--merge-base requires a non-empty commit"
supplied_merge_base="$2"
shift 2
;;
--full)
full_listing=1
shift
;;
*)
fail_usage "unknown option: $1"
;;
esac
done
if [ -n "$defer_gate" ]; then
printf 'verdict: covered by repo gate\n'
printf 'gate: %s\n' "$defer_gate"
printf 'detail: surface and size not measured here; the named repository gate owns this class.\n'
exit 3
fi
if ! command -v git >/dev/null 2>&1; then
printf 'verdict: not run\n'
printf 'reason: git is not available on PATH\n'
exit 4
fi
if ! git rev-parse --git-dir >/dev/null 2>&1; then
printf 'verdict: not run\n'
printf 'reason: not inside a git repository\n'
exit 4
fi
if [ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]; then
printf 'verdict: not run\n'
printf 'reason: not inside a git work tree (a bare repository has no working surface to report)\n'
exit 4
fi
cd "$(git rev-parse --show-toplevel)"
count_of() {
if [ -z "$1" ]; then
printf '0\n'
else
printf '%s\n' "$1" | wc -l | tr -d ' '
fi
}
# The count is always exact — the cap check depends on it — but the path
# listing is capped so a 500-file surface does not flood the caller; --full
# restores the complete dump.
emit_category() {
category_count=$(count_of "$2")
printf '%s: %s\n' "$1" "$category_count"
[ -n "$2" ] || return 0
if [ "$full_listing" -eq 1 ] || [ "$category_count" -le 25 ]; then
printf '%s\n' "$2" | sed 's/^/ /'
else
# sed drains its stdin: `head` would close the pipe early and kill the
# writing printf with SIGPIPE, which `set -o pipefail` turns into a
# 141 exit for the whole script.
printf '%s\n' "$2" | sed -n '1,25s/^/ /p'
printf ' … and %s more (--full lists every path)\n' "$((category_count - 25))"
fi
}
# Fallbacks are tiered (remote refs, then local) and a tier with more than one
# live candidate is ambiguous — origin/main and origin/master both existing
# means the true target is a guess, and a surface diffed against a guessed
# base silently omits the commits between the two.
resolve_base() {
head_ref=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$head_ref" ]; then
printf '%s\torigin/HEAD\n' "${head_ref#refs/remotes/}"
return 0
fi
# Candidates are verified in their full ref namespaces so a tag named
# main or master can never satisfy a branch fallback.
for tier in "refs/remotes/origin/main refs/remotes/origin/master" "refs/heads/main refs/heads/master"; do
found=""
found_count=0
for candidate in $tier; do
if git rev-parse --verify --quiet "$candidate" >/dev/null 2>&1; then
found="$candidate"
found_count=$((found_count + 1))
fi
done
[ "$found_count" -le 1 ] || return 2
if [ "$found_count" -eq 1 ]; then
printf '%s\tfallback\n' "$found"
return 0
fi
done
return 1
}
# A supplied base is resolved in the branch namespaces rather than bare: git
# resolves an ambiguous short name tags-first, so a tag named main would shadow
# the branch and the gate would silently measure against the wrong commit.
# refs/remotes/ is tried first so origin/main keeps working; a value that
# resolves in neither namespace is refused rather than falling back to a bare
# rev-parse that a tag could hijack.
resolve_supplied_base() {
for namespace in "refs/remotes/$1" "refs/heads/$1"; do
if git rev-parse --verify --quiet "$namespace" >/dev/null 2>&1; then
printf '%s\n' "$namespace"
return 0
fi
done
return 1
}
fail_base() {
printf 'verdict: not run\n'
printf 'reason: the supplied --base %s resolves to no branch: neither refs/remotes/%s nor refs/heads/%s exists\n' "$1" "$1" "$1"
printf 'detail: a base is resolved in the branch namespaces only, so a tag cannot shadow the branch the surface is measured against.\n'
exit 4
}
base_rc=0
if [ -n "$supplied_base" ]; then
resolved_base=$(resolve_supplied_base "$supplied_base") || fail_base "$supplied_base"
base_info="${resolved_base} --base"
else
# Resolution is attempted even when --merge-base is supplied: a base that
# resolves is what the supplied merge base is checked against, and a
# supplied value that cannot be checked is reported as such.
base_info=$(resolve_base) || base_rc=$?
[ "$base_rc" -eq 0 ] || base_info=""
fi
base_ref="${base_info%% *}"
base_how="${base_info##* }"
head_exists=0
if git rev-parse --verify --quiet HEAD >/dev/null 2>&1; then
head_exists=1
fi
fail_read() {
printf 'verdict: not run\n'
printf 'reason: the %s enumeration could not be read: %s\n' "$1" "$2"
printf 'detail: a failed git read is not an empty category, so no surface and no cap result is reported.\n'
exit 4
}
# A merge base that fails validation is as unusable as one that fails to read:
# it silently shrinks the committed category, so it takes the same hard exit.
fail_merge_base() {
printf 'verdict: not run\n'
printf 'reason: the supplied merge base is not usable: %s\n' "$1"
printf 'detail: the committed category is measured from the merge base, so an unverified one is not reported as a surface.\n'
exit 4
}
# Every enumeration goes through this: an empty result and a failed read look
# identical once the status is discarded, and reporting a failed read as an
# empty category turns a broken repository into a green report.
git_out=""
read_or_fail() {
enumeration="$1"
shift
if ! git_out=$(git "$@" 2>/dev/null); then
fail_read "$enumeration" "git $* returned non-zero"
fi
}
committed=""
merge_base=""
merge_base_caveat=""
committed_measured=0
base_line="default branch: unresolved — no origin/HEAD, origin/main, origin/master, main, or master resolved; reporting HEAD-only surface"
if [ "$base_rc" -eq 2 ]; then
base_line="default branch: ambiguous — more than one candidate resolves in the same tier; set origin/HEAD or name the target; reporting HEAD-only surface"
fi
if [ -n "$base_ref" ]; then
base_line="default branch: ${base_ref} (from ${base_how})"
fi
if [ -n "$supplied_merge_base" ] && [ "$head_exists" -eq 1 ]; then
# A supplied merge base is verified as a commit before use, so a typo
# fails with the read named instead of a confusing diff error.
read_or_fail "merge base" rev-parse --verify --quiet "${supplied_merge_base}^{commit}"
merge_base="$git_out"
# An unchecked merge base decides the committed category on its own: HEAD
# passed here empties the diff and turns branch work into `no changes on
# surface`. Whenever a base resolves, the supplied value must be the merge
# base that base yields; only with no base at all is the weaker ancestor
# check the best available, and the readout then says so.
if [ -n "$base_ref" ]; then
read_or_fail "merge base" merge-base HEAD "$base_ref"
if [ "$merge_base" != "$git_out" ]; then
fail_merge_base "supplied --merge-base ${supplied_merge_base} (resolved to ${merge_base}) does not match merge-base(HEAD, ${base_ref}) = ${git_out}"
fi
else
git merge-base --is-ancestor "$merge_base" HEAD 2>/dev/null ||
fail_merge_base "supplied --merge-base ${supplied_merge_base} is not an ancestor of HEAD"
merge_base_caveat=" (from --merge-base, unverified against a base)"
if [ "$base_rc" -eq 2 ]; then
base_line="default branch: ambiguous — more than one candidate resolves in the same tier; set origin/HEAD or name the target, so the supplied merge base can be checked against one"
else
base_line="default branch: not resolved here, so the supplied merge base could not be checked against one"
fi
fi
read_or_fail "committed" diff --name-only "$merge_base" HEAD
committed="$git_out"
committed_measured=1
elif [ -n "$base_ref" ] && [ "$head_exists" -eq 1 ]; then
read_or_fail "merge base" merge-base HEAD "$base_ref"
merge_base="$git_out"
if [ -n "$merge_base" ]; then
read_or_fail "committed" diff --name-only "$merge_base" HEAD
committed="$git_out"
committed_measured=1
fi
fi
if [ "$head_exists" -eq 0 ]; then
committed_measured=1
fi
if [ "$head_exists" -eq 1 ]; then
read_or_fail "staged" diff --cached --name-only
else
empty_tree=$(git hash-object -t tree /dev/null 2>/dev/null || true)
[ -n "$empty_tree" ] || fail_read "staged" "the empty tree object could not be resolved"
read_or_fail "staged" diff --cached --name-only "$empty_tree"
fi
staged="$git_out"
read_or_fail "unstaged" diff --name-only
unstaged="$git_out"
read_or_fail "untracked" ls-files --others --exclude-standard
untracked="$git_out"
all_paths=$(printf '%s\n%s\n%s\n%s\n' "$committed" "$staged" "$unstaged" "$untracked" |
sed '/^$/d' | sort -u)
total=$(count_of "$all_paths")
cap_lines="caps: none supplied — see references/sweep-classes.md class 11"
if [ -z "$all_paths" ]; then
if [ "$committed_measured" -eq 0 ]; then
# An empty measured surface proves nothing when the committed
# category was never measured; a clean verdict here would hide
# exactly the branch work this report exists to expose.
verdict="cap unverified"
cap_lines="caps: not confirmed — the committed category could not be measured, so an empty measured surface is not a no-changes result"
else
verdict="no changes on surface"
cap_lines="caps: not evaluated — the working surface is empty"
fi
elif [ -z "$caps" ]; then
verdict="cap unverified"
else
verdict="under caps"
cap_lines=""
first_exceeded=""
while IFS= read -r cap_entry; do
[ -n "$cap_entry" ] || continue
cap_name="${cap_entry%%=*}"
cap_max="${cap_entry#*=}"
if [ "$total" -gt "$cap_max" ]; then
cap_lines="${cap_lines}cap ${cap_name}=${cap_max}: exceeded by ${total} changed files
"
[ -n "$first_exceeded" ] || first_exceeded="$cap_name"
else
cap_lines="${cap_lines}cap ${cap_name}=${cap_max}: under (${total} changed files)
"
fi
done <<EOF
$caps
EOF
if [ -n "$first_exceeded" ]; then
# An unmeasured committed count only makes the total a floor, so an
# exceeded cap still holds.
verdict="exceeds cap for ${first_exceeded}"
elif [ "$committed_measured" -eq 0 ]; then
verdict="cap unverified"
cap_lines="${cap_lines}caps: not confirmed — the committed category could not be measured, so the total above is a floor
"
fi
cap_lines="${cap_lines%
}"
fi
printf 'verdict: %s\n' "$verdict"
printf '%s\n' "$base_line"
if [ -n "$merge_base" ]; then
printf 'merge base: %s%s\n' "$merge_base" "$merge_base_caveat"
emit_category "committed" "$committed"
elif [ "$head_exists" -eq 0 ]; then
printf 'merge base: not computed (the branch has no commits)\n'
emit_category "committed" ""
else
printf 'merge base: not computed\n'
printf 'committed: not computed (no merge base against a default branch)\n'
fi
emit_category "staged" "$staged"
emit_category "unstaged" "$unstaged"
emit_category "untracked" "$untracked"
printf 'total distinct changed files: %s\n' "$total"
printf '%s\n' "$cap_lines"
exit 0
SKILL.md
---
name: checking-pr-readiness
description: Use when branch work looks complete and needs a readiness decision before another workflow opens a pull request, or when asked to assess a specific head for PR readiness. Gathers the working surface and checks, then briefs a recommendation plus numbered live options and waits for a numbered reply. Option 1 is Approve. A request to write, open, create, or submit a pull request belongs to PR publishing. For an existing PR about to merge, use checking-merge-readiness.
license: MIT
compatibility: Requires a git worktree and read access to the host repository. Companion checks degrade to named skips when their skills or tooling are absent.
---
# Checking PR Readiness
Check whether a branch is ready to enter the pull request and
continuous-integration process. Internally the gate gathers the full working
surface, upstream-step receipts, plan-versus-delivered, pre-PR review checks,
and learning signal. Then brief a recommendation plus numbered live options
and wait for a numbered reply from whoever is talking.
The gate is read-only. Companion skills own edits, reviews, and capture; the
host repository's hooks and task runners own deterministic re-runs. This skill
verifies those from receipts or dispatches the skill that owns them. Nothing
is done without evidence in the captured gather. Incomplete gather cannot
offer Approve.
## Status words
Every check reports with one word from this closed set, used consistently and
without synonyms:
- **verified** — a named receipt supports the claim in the captured gather,
or, for solution simplicity only, this gate's own live fresh dispatch
against an unchanged subject does.
- **attested** — the owner states missing intent (step 4) and no durable
source exists; recorded as attestation, not as evidence. Do not use this
word to vouch that a missing review or simplify step happened.
- **not verified** — no receipt exists and no attestation was given.
Also in the set (same one-token rule): **failed**, **not run**, **skipped**,
**unavailable**, **bypassed**, **not applicable**. Use the ordinary meaning of
each word; **bypassed** always records the owner's reason.
## Workflow
Bind identity first. Read
[references/identity-and-argv.md](references/identity-and-argv.md) when capturing
the native subject, full head, target/base ref, and full base OID, when
proving helper `--base` binding, when rerunning a repository-authored check,
and immediately before accepting option 1.
### 1. Gather the working surface
The finishing path will stage this surface. Create an owner-only `mktemp -d`
directory outside the target repository first; capture helper stdout there and
do not echo the inventory into chat. Do not remove that directory while the
run is waiting for a numbered reply. Run
[scripts/surface-report.sh](scripts/surface-report.sh) when it is present and
executable. Pass `--full` so the listing written to temp includes every path.
The same run supplies step 6's informational size diagnostics; optional
`--cap` values and their interpretation live in
[references/sweep-classes.md](references/sweep-classes.md) class 11. Always
produce the surface report on this run (omit `--defer` even when a repository
gate owns a size check).
Otherwise gather the same four categories directly with git: committed on this
branch against the merge base with the default branch the pull request will
target (resolve it from the remote's HEAD, and ask when the target is
ambiguous), plus staged, unstaged, and untracked paths.
List untracked paths with the same weight as tracked ones. Finishing tools
stage them, so they ship with the change even though no diff command shows
them by default. If the working tree is not a git repository, or git is
unavailable, stop rather than composing a brief from a surface you could
not read. An unresolved base, unmeasurable committed category, failed git
enumeration, or omitted path leaves the gather incomplete and withholds
Approve, regardless of any size verdict.
Completion: every path in all four categories is in the captured surface
report, or the run stopped because the working surface could not be read from
git.
Then apply the repository's transient-artifact policy. Resolve its path
families from repository instructions and ignore rules. Enumerate the final
tracked contents of those families with `git ls-files --cached`, and enumerate
their ignored working contents with
`git ls-files --others --ignored --exclude-standard`. Pass the same
content-scoped pathspecs after `--` to both commands, such as
`:(top)docs/plans/**`; do not enumerate unrelated ignored trees. Use
`git check-ignore -v` to identify the owning ignore rule for ignored files.
Search durable files for citations to every named transient family, including
families with no current file.
- An ignored file with no index entry or branch addition is working material.
It does not ship and is allowed.
- A transient file present in the final tracked tree, staged as content, or
added on the branch is a blocking finding. Remove it before approval; an
owner disposition that accepts the file does not clear readiness. A branch
deletion that removes old transient content is cleanup, not a finding.
- A durable file that cites or depends on ignored working material is a
finding until the dependency is removed or the durable conclusion is moved
to its canonical home.
Completion: the tracked and ignored enumerations cover every resolved family;
every transient hit is classified as ignored working material, cleanup, or a
finding; and every durable citation is accounted for. If either enumeration is
incomplete, stop rather than treating an incomplete inventory as clean.
### 2. Gather repository gates
Discover the host repository's own deterministic gates before any
model-judgment check. Read the repository's agent-instruction and contribution
documents and its conventional hook and task-runner configuration (git hook
and hook-manager files, task-runner and package manifests, and
continuous-integration workflow definitions), and take the gates they name.
List the conventional paths first and read only the sections that define
gates. Search for hook, script, and job names before any full-file read
rather than pulling whole workflow files into the conversation.
Record each discovered gate with a status word and with what owns it, off
chat. Record hook coverage and whether it has run on the current surface;
leave re-running to the hook. When discovery finds no repository-owned gates,
record that emptiness as unavailable. Silence is not a pass.
Completion: every discovered gate carries one status word and its owner, and
an empty discovery is a named finding in the gather.
### 3. Verify upstream steps from receipts
Record each expected upstream step with a status word in the gather: code
review, code simplification, solution simplicity, browser testing, design
critique or audit, and learnings capture. Solution simplicity is the
independent, approach-level result from `checking-simplicity`. It is primarily
user-requested and may also run before ungrounded durable machinery enters
implementation; its place here is a late backstop, not the recommended first
checkpoint. Before dispatching it, resolve the intent source step 4 uses and
supply the reviewer with the objective, required behavior, hard constraints,
and verification criteria. Browser testing and design critique apply only to
diffs that touch user-interface files; record how that classification was
decided from the paths in the working surface, and surface an uncertain
classification rather than resolving it silently.
Use this receipt inventory to decide between verified and the honest
alternatives:
- Durable receipts: design-critique snapshots (for example
`.impeccable/critique/` frontmatter carrying a score and P0/P1 counts) and
solutions documents present in the working surface. A receipt counts only
when it identifies this branch's change; a document that covers unrelated
work is not a receipt for it.
- Browser testing leaves a receipt only when its output or screenshots were
saved; otherwise it has none.
- Solution simplicity is verified by this gate's own dispatch, not by a
receipt. After step 1, dispatch `checking-simplicity` with the resolved
intent source, the repository, branch, and full `HEAD`, and all four path
categories with their complete current contents, read from the index or as
link objects so a symlink is transferred as its link text and never
followed; that skill owns the reviewer's independence and how it reads the
subject. The result is verified only when it recommends keeping the current
approach, no user question remains, and nothing on the surface changed after
the dispatch, confirmed by re-reading the intent source and the full contents
of all four path categories rather than comparing path names. A result that
recommends simplifying first, or that needs a
user decision, is failed until the subject is revised or the decision made
and the dispatch repeated. A result that cannot assess yet, or that came
from an older or same-context run, is not verified.
- Code review, code simplification, and solution simplicity leave no durable
artifact today, so outside the session that ran them they are not verified.
Solution simplicity is never verified by attestation, because its fresh
dispatch and unchanged subject are part of the check.
Write verified only with the receipt named in the gather, or for solution
simplicity with the live dispatch and unchanged subject named. Where neither
exists, record not verified. Do not ask anyone to vouch that it happened. When
the companion skill or tooling a check depends on is absent (no compound
engineering plugin, no `checking-simplicity`, no design-critique tooling),
record that check skipped, name what was missing, and run the rest of the
checks.
Completion: each of the six steps carries one status word in the captured
gather, every verified step names its receipt or its live dispatch, and the
user-interface classification and its basis are stated.
### 4. Compare intent to what was delivered
Use the linked issue or ticket first, then the brief the work started from. A
repository plan is optional and counts only when that repository maintains
plans as durable documentation. An ignored working plan may help the current
comparison, but it is not a durable source and must not appear in
pull-request evidence. Compare the source against the working surface in the
gather. Intended items not delivered and work delivered beyond the source are
intent drift.
A linked issue or brief is sufficient; the absence of a separate plan is not
a finding. When no issue, brief, or durable repository plan exists, record
the comparison unavailable, name that absence as a finding, and take a direct
attestation of what the branch was meant to do, recorded as attested.
Completion: every planned item is marked delivered or not delivered in the
gather, or the comparison is recorded unavailable with intent attestation.
### 5. Check the learning signal
Carry exactly one durable-learning signal in the gather:
- a solutions document covering this branch's work exists in the working
surface; or
- an explicit capture plan or follow-up exists; or
- a recorded reason this branch produced no durable learning.
Capture is the recommended path. Approving past an uncaptured and unplanned learning requires
an explicit override, reported as bypassed and recorded with the stated
reason in the evidence pack.
Completion: the gather carries exactly one of the three signals, and any
approval past an uncaptured learning carries the recorded reason.
### 6. Run the Pre-PR Review Checks
Read [references/sweep-classes.md](references/sweep-classes.md) and work every
class in the order listed there. Record verdicts in the captured gather in
that order.
Mechanical classes run through the bundled helpers:
- [scripts/surface-report.sh](scripts/surface-report.sh) for diff size (class
11): reuse step 1's run. Class 11 owns optional cap diagnostics and their
distinction from actual review coverage.
- [scripts/evidence-freshness.sh](scripts/evidence-freshness.sh) for stale
records and plan-named artifacts (classes 4 and 2 support).
- [scripts/changelog-union.sh](scripts/changelog-union.sh) for branch
changelog entry (class 3).
`changelog-union.sh` and `evidence-freshness.sh` defer when the host
repository owns an equivalent check: invoke them as `<helper> --defer
<gate-name>` with the gate step 2 found, and record that class as covered by
that gate. When a repository gate owns the size check, record class 11 as
covered by that gate; its actual result remains in step 2.
When step 1 already resolved the target branch or merge base, pass it through
to `surface-report.sh` and `changelog-union.sh` (`--base <ref>` or
`--merge-base <sha>`). `evidence-freshness.sh` resolves no base and accepts
neither flag.
Every remaining class runs by model instruction from the reference, in one
pass: read the branch diff once and apply every judgment class to that single
reading rather than re-reading the diff per class.
Map helper exit codes and `verdict:` lines to status words using the table in
[references/sweep-classes.md](references/sweep-classes.md).
Completion: every class in the reference carries one verdict from that
class's enumerated set in the captured gather, and each class that fired
names where it fired: the file and line for a line-scoped finding, the file
alone for a file-level one, and the repository surface for a repository-level
one.
### 7. Brief, then wait for a numbered reply
Complete steps 1 through 6 fully first. Then brief in continuous prose:
recommendation first, then only the reasons that make it true, then evidence
under those reasons. Numbered live options follow the brief.
<!-- Maintainers: this readout shape is mirrored in
checking-merge-readiness/SKILL.md step 6. Skills stay self-contained, so edit
both copies together. -->
- One recommendation (approve and proceed; request changes; or stop and file
follow-up). Open on the decision, not the working-surface inventory.
- Reasons, one idea each, most decision-relevant first. Reasons are about
the change under review, not how this gate runs. A clean outcome is
one residual clause that grading found nothing material.
- Evidence sits only under the reasons that drove the call, with source
pointers. The check inventory is Show the checks, not the default brief.
- Numbered live options after the brief. Only option 1 is reserved. Print
Approve and proceed when that action can be taken; otherwise keep number
1 and name why. The remaining actions have a print order, not menu
numbers. Print only the live ones, numbered from 2 without gaps.
- Clean green (approve and proceed, nothing material): final brief plus
menu at most about 12 non-blank short lines.
- A coverage close: gather completed, and every applicable check is
verified, not applicable, or recorded without a receipt. Incomplete gather cannot
offer Approve.
- Name a check in the brief only when it drives the recommendation.
Spoken next work is owner work that still remains after this decision.
When the recommendation is approve, that remaining path is opening the
pull request and babysitting it. When the recommendation is approve, unrun code review or simplify do not appear in that brief as leftover work.
Untracked or blocking paths appear when they drive the call. Paths
touching authentication, authorization, payments, data migrations,
secrets handling, or a published API contract stay visible when they
have a finding or an incomplete check.
#### Decision menu
Present exactly one decision menu, then wait for a numbered reply. Do not pick an option in the same turn that wrote the menu. A turn is one reply. Print only the brief and the numbered options, then stop. Do not explain turns, later `1`, or the identity re-read in the brief. The next message in the conversation, from whoever is talking, is the pick. A reply of `1`,
"Approve", or "approve and proceed" counts as that choice only after the
menu offered Approve, not after it printed a withheld option-1 row. A `1`
on a withheld row is not Approve. Name that the action cannot be taken and
wait again. Do not enter the later-1 finishing path. The activating
utterance never authorizes Approve.
Print order, not menu numbers. Number 1 is the reserved Approve-and-proceed
slot. When that action can be taken, print it. When it cannot, keep number
1 and name why. Number the remaining live actions from 2 without gaps.
- Approve and proceed to the finishing path. Offer only when gather is
complete and the recommendation is approve and proceed. A check named as next work does not by itself withhold Approve.
- Address remaining changes. Offer on every menu, including an approve
recommendation. This is the numbered alternative to Approve, not a
fixed slot. On approve it declines Approve rather than inventing
leftover changes. On request changes, picking it does not start work.
Tee up every remaining item that drove that recommendation in one
follow-up. That follow-up is a question, not the decision menu: a reply
of 1 is not Approve. Follow-up actions are that remaining work.
Unrun code review or simplify appear here only when they drove the
recommendation. Option 1 does all recommended remaining items and
names them in that sentence. Later options are the same items as
individual actions, grouped by similar work, ordered by impact. Skip
an individual option that would repeat option 1. Offer leaving the
remaining changes last. Picking a follow-up action starts that work. If nothing remains
to do in this session, the remaining work is the outcome and this gate
ends.
- Explain the change, when `ce-explain` is present.
- Show the checks. Offer when a captured gather exists. List each
applicable check and its status word from that gather: repository gates,
upstream steps, sweep classes that applied, and the learning signal.
Then present the brief and numbered options again. The spoken line names
the checks this PR-readiness review ran.
- Stop and file follow-up work. Offer when the recommendation is stop and
file follow-up, or the brief named leftover work to file. This ends the
finishing path and parks that leftover in the tracker instead of opening
a pull request. Skip it when there is nothing to file.
Print option 1 on every menu. When Approve cannot be taken, keep number 1
and name why in a natural sentence; that withheld row does not print the
Approve action. Do not reuse option 1 for another action. Number the
remaining live actions from 2 without gaps, in the print order above.
Write each option as a sentence, not a label then a colon. Example when
Approve is blocked, Address remaining changes is live, and Explain and
leftover work to file are not:
```text
1. This branch is not ready because remaining source findings still block it.
2. Address the remaining changes on this branch.
3. Show the checks this PR-readiness review ran.
```
Example when Approve is live and Address remaining changes is the
alternative:
```text
1. Approve and proceed to the finishing path.
2. Address the remaining changes on this branch.
3. Show the checks this PR-readiness review ran.
```
Example after option 2 on a request-changes recommendation. This wait is
the follow-up question, not the decision menu:
```text
Remaining work: resolve the CHANGELOG conflict with main, align README
terminology with CHANGELOG.md:20, and finish the stopped actionlint check.
1. Do all remaining work: resolve the CHANGELOG conflict, align README
terminology, and finish the stopped actionlint check.
2. Resolve the CHANGELOG conflict and README terminology mismatch.
3. Finish the stopped actionlint check now.
4. Leave these remaining changes for a later fix.
```
Show the checks is non-terminal: print the list from the captured gather, then the brief and numbered options again. Starting remaining work from the follow-up, and Explain, are non-terminal: when one finishes, **recompose**. Re-read the working surface from step 1 and, when it changed, re-run the steps whose inputs the change touches. A returned `checking-simplicity` result refreshes step 3 even when no path changed; that skill is read-only and returns its finding to this gate. When that result is a question for the user, print the question with its options and wait; the next reply answers it and goes back to the same reviewer, and only the readout that follows refreshes step 3 and recomposes this menu.
Completion of this turn: the brief and numbered live options are on screen,
and the run is waiting. It did not pick. It did not re-read identity for
Approve and did not fill an evidence pack.
### On a later reply of 1
If the run is waiting on the remaining-changes follow-up, a reply of 1 is
do-all remaining work, not Approve. Do not apply the withheld-1 rule to
that wait.
If the menu printed a withheld option-1 row, do not approve. Name that
Approve cannot be taken and wait again. Do not enter the later-1 finishing
path.
Before accepting Approve, re-read HEAD, the merge-base, and staged, unstaged,
and untracked content per
[references/identity-and-argv.md](references/identity-and-argv.md). If any of
those moved, name what moved, rebuild, and do not continue as if the old
surface were still current. The already-typed 1 does not approve the rebuilt
gather. A matching re-read is silent.
Choose the finishing path from this conversation's role, not from whether
`repo-gardener` happens to be installed on disk:
- A run already following repo-gardener Worker completion continues into
gardener first-publication. Do not also dispatch an owner publisher.
- Any other conversation continues into the installed skill that owns
opening a pull request here, discovered the same way `ce-explain` is:
when that skill is present. WORKFLOWS.md's example is `ce-commit-push-pr`.
If this conversation has no finishing path, name that once and stop. Do not
re-ask Approve. Do not fill or print a pack. Option 1 accepted readiness;
publishing still needs an installed finishing companion.
When finishing is present, instantiate
[assets/evidence-pack-template.md](assets/evidence-pack-template.md)
in-process: the recommendation, material next work after the pull request
exists (or `none`), a coverage close, and the learning signal with any
recorded override. Do not write the
filled pack back to that asset. Do not print `## Evidence pack` as a
readout. Pass the pack to the selected finishing path, which owns its
destination: ordinary
publication writes it into the pull request description; gardener Worker
completion follows its publication contract, which returns adopted-PR
evidence to the Orchestrator and preserves that PR's title and body.
Continue into that path in this same conversation. That path must not re-ask the same Approve. This
skill still does not itself stage, commit, push, or open a pull request.
Sanitize the pack for durable use. Summarize intent from the selected durable
intent source: a linked issue or ticket, a brief, or a maintained repository
plan. When step 4 found no durable source, summarize the recorded intent
attestation instead. Do not copy ignored-plan paths or contents, local-only
paths, credentials, or unnecessary personal data.
Completion: a matching silent re-read, then silent pack plus continue into
finishing, a named missing-path stop with no pack, or a named rebuild with
no pack. This skill wrote nothing to the repository. Remove the step 1 temp
directory after this later turn, when a non-1 later turn ends the run, or on
failure.
## Gotchas
- Untracked paths ship with finishing tools; include them in what option 1
approves.
- Green CI is not evidence that upstream steps ran.
- Work that may change files is limited to a remaining-changes follow-up
pick and later-1 finishing dispatch. A companion skill or repository gate
owns those writes. The gate itself still does not stage, commit, push, or
open a pull request.
- A dead session needs a fresh gate. There is no pasteable pack to restock.
- When `checking-merge-readiness` is also installed, this gate owns entry to
review; merge-readiness owns the pre-merge whole-change review. Neither
requires the other at runtime.