Scoped to the fixtures the new rule actually binds: a fragment whose
predicate complaint is already its name, a smuggled heading, a dangling
heading or the 300-character bound is left alone, so the diagnosis it
tests is still the one it draws. The section-predicate fixtures are
untouched (D4).
The computed-length fixtures keep their measured lengths: the cite is
seven characters, so an entry that must measure exactly 300 builds 293
of the run and lets the cite carry the rest. 33.md's cite lands on the
last continuation line, which is the wrapped-citation case.
Refs #262
'- Fixed entry.' is shared between the dangling-heading fragment fixture
and the section-predicate fixture, so the global replace crossed D4's
line. Next commit filters to fragments that actually red on the cite rule.
Refs #262
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
release-exercise stubs `gh` to answer the one API fact the ceremony path
consults. Since #191 facts.sh selects a backend, and on this Forgejo runner
it selected the forgejo one — which speaks curl and walked straight past the
stub to the live instance, read the fixture's SHA against the real
repository, found no merged release-labeled PR behind it and refused.
The exercise rehearses the WIRING — facts → decide → notes through the real
$GITHUB_OUTPUT plumbing. Which backend answers is lib/forge.sh's own
contract and is covered in test/forge*.test.sh. So the facts step now pins
CEREMONY_FORGE=github, the backend its stub is shaped for, and the stub
returns the array shape the new label read expects.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures on !193's first run, both real and both caught by the guards
that exist for them.
release-exercise: `lib/forge-forgejo.sh: line 550: REPO: unbound variable`.
The forgejo backend addresses the repository through REPO, which each
reconciler sets for itself; the github backend reads GITHUB_REPOSITORY
directly. facts.sh set neither, so every forgejo read refused — correctly,
and with the new #191 diagnostic, which is how it was legible at all. The
github-path suites could not have caught this: they never touch that
backend.
self-guards: changelog-armed measured a 404-character entry against the
300-character bound (#167). Split into three shorter entries in the same
fragment, which is what the rule asks for.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 0.4.1 drill measured both doors dead on Forgejo. lib/facts.sh gathered
`released` with `gh release view` and `labeled` with `gh api .../pulls`, and
release.yml tagged and published with `gh` — none of which exist on the
runner image. The merge door therefore read labeled=no for a correctly
labeled, correctly merged ceremony PR and refused it as "a bare push";
the tag door cleared every gate and died at `gh release create`.
Both are ported onto lib/forge.sh. Two asymmetries were measured against
the live instance and its swagger rather than assumed:
* GitHub serves an ARRAY of PRs at /commits/{sha}/pulls; Forgejo serves a
single OBJECT at /commits/{sha}/pull and 404s on the plural. Both verbs
emit the array shape, so facts.sh carries one jq expression.
* GitHub creates a tag by POSTing to /git/refs; Forgejo serves that path
GET-only and creates tags at /tags. A 1:1 port of the gh call would
have 404'd forever.
The behaviour change is the second half of the bug. Any failure used to
become a definite `no`, which is safe for row 4 and catastrophic for row 5:
it is how a missing binary became "this was not a release ceremony". Now a
completed read that finds nothing is still `no` and still fail-closed, and a
read that did not complete refuses and emits no fact at all.
Four new cases in test/facts.test.sh cover exactly that, and a mutation back
to the old fail-closed-on-error behaviour kills all four and nothing else.
1014 assertions, 22 suites, shellcheck and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 1 on #286: codex, claude and kimi each blocked on the same thing —
the amended paragraph carried incident narrative that CONTRIBUTING.md's
doctrine conventions keep in the cited record.
Drop #275's head sha and both start stamps, the cancel-in-progress
episode sentence, and the four clauses of 2026-07-27 gloss provenance.
Carry checks_state's carve-out as one clause with a bare (#139, #276).
The rule is unchanged and still decides #275's head unaided: two entries
for one check, newest by start time wins, and the cancelled one is not
the check's word while a non-cancelled sibling stands.
Step 1 ruled what a rollup entry means and never said which entry to
read. A job in a cancel-in-progress group displaces itself, so a head
routinely carries a CANCELLED node beside the SUCCESS that replaced it,
and read by class alone that head is not green while checks_state calls
it SUCCESS.
State the collapse ahead of the classes it feeds: newest entry by start
time, and a CANCELLED entry is not the check's word while a non-cancelled
sibling stands at the same head. All-cancelled and pending are untouched.
Refs #276
The drill ran and FAILED, and the record says so. Merge door: lib/facts.sh
reads the release label with `gh api`, the runner image has no gh, the fact
comes back "no", and decide fail-closes on a wrong fact — reproduced twice.
Tag door: clears every gate the merge door fails, then dies at
`gh release create`.
Release count in the scratch repo at the end: 0. Every refusal created
nothing, which is the property the drill exists to check.
drill-recorded wants a record, not a passing result — this is the honest
one, and it says 0.4.1 cannot publish from this instance until facts.sh and
the publish call sites are ported off gh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/VENDORED.txt and actions/docs-sync/docs-sync.sh entered the tree in
the same commit and are byte-identical at every tag — blobs 10c20a3c and
ba426479 at 0.1.0 through 0.5.0 — and 0.1.0's copy of the tool is already
manifest-driven (MANIFEST="docs/VENDORED.txt", L75). Citing 0.5.0 told the
0.1.0-0.4.1 tail, which is exactly the population this section is written
for, that the manifest was unavailable at its pin, so it would keep the
hardcoded list: #251's failure mode reproduced by the document that exists
to abolish it. The same file already said 0.1.0 at L131-L133.
Also make the guard's tracked-ness skip announce itself. It degrades to
"not asserted" wherever the tree is not a git work tree root, and doing
that in silence is the shape this script's own header argues against, so
the skip now prints on both output paths, green and red, with a test row
each way.
Round 1: claude blocking point, and claude nit 3.
Re-vendor tooling and docs-sync equivalents read the pin docs/VENDORED.txt
(available at 0.5.0 and later) instead of naming the doc set themselves, so
a new doctrine file reaches every consumer at its next ordinary pin bump
with zero list edits. A hardcoded list propagates nothing and its
staleness is silent: docs-sync --check asserts byte-identity for the files
the list names and says nothing about one it omits.
What makes reading the manifest sufficient rather than merely better is
the self-guard this PR adds, tagged unreleased until the first tag carries
it, per the RELEASES.md paragraph above it.
Refs #251
One CI step beside the self-ref pin, and test/vendored.test.sh covering
both directions: the manifest -> tree scan (missing, symlink, directory,
empty, ../ escape, absolute, untracked) and the closed-world root rule
(neither list, vendored, exempted, prose is not an input, no recursion
below the root), plus the real tree unmodified and the RELEASES.md
regression both ways.
The one-off `grep -Fx RELEASES.md` row at test/docs-sync.test.sh is
deleted (#251 D4): two spellings of "the manifest is right" is the drift
the manifest exists to prevent. Its intent is now a guard case, which the
next doctrine file inherits for free.
Refs #251
docs/VENDORED.txt is already machine-authoritative on the consumer side —
actions/docs-sync reads it as the sole declaration and enforces
"manifest union .ceremony/, nothing else". Nothing enforced the other end:
a doctrine file landing at ceremony every root with nobody adding it to
the manifest is a SILENT miss, because docs-sync only asserts
byte-identity for the files the manifest names.
Two directions, two mechanisms (#251 D2): manifest -> tree is a scan
(regular, non-empty, tracked, no symlink, no directory, no .. escape);
tree -> manifest is a closed-world rule over root *.md with a short
in-script exemption list, because nothing in the tree answers "which
files are vendorable".
Refs #251
The three stamps, in one commit as the ceremony requires: VERSION goes
bare, the self-ref pin follows it, and the changelog section is the
assembler's output rather than a hand edit.
0.4.1 is the forge release. Everything in the section comes from #188's
single fragment: ceremony stops being gh-only. `lib/forge.sh` selects a
backend from the runner's own environment, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` implement one call surface twice, and the
reconcilers preflight before they sweep — so a GitHub-shaped client
pointed at a Forgejo instance is a named refusal instead of a sweep that
reads nothing and reports success.
Verified on this instance before stamping: the first post-merge `labels`
run on main (7fc9afe, task 467) came back SUCCESS — the reconciler's
first green run on Forgejo, and the evidence the section's claims are
not merely asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ruled-term paragraph now names its field — a check carrying a
terminal conclusion is green or not-green by that conclusion whatever
its status reports — and rules the head with no checks configured:
nothing to wait for, request straight away, no argued exception owed.
The draft-round restatement comes out so the file states the rule once.
Closes#260, closes#272 via the PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"no activity for N days" was true of the old clock and is now imprecise:
an assignment no longer counts, so an item assigned yesterday would read
a nudge claiming nine days of nothing. It says "no comment" instead —
the fact the sweep actually read.
Refs #254
"a comment, a review or a commit" is the ruling nudge's house phrasing and
false on the issue surface twice over: there is no review or commit fact in
what the sweep reads, and an assignment is no longer counted here. Say
what is read, and say what does not buy another 7 days of silence.
Refs #254
The nudge rode `last_issue_activity`, which counts `assigned` timeline
events because assignment is the claim the 48-hour reclaim protects.
`post-merge` has no claim: an assignee there is the invalid composition
the flag beside it reports, so counting the assignment let a broken board
buy the item another 7 days of silence — this issue's failure direction
taken backwards.
One computation, two clocks over it: `issue_activity_at` is the body,
`last_issue_activity` keeps the reclaim and ruling clocks byte-identical,
and `last_issue_comment_activity` is the evidence clock. Both clocks are
read before this branch posts anything, the ruling one included — read
after, it would date the issue by the evidence nudge's own comment and
silence the ruling nudge, which is the self-silencing the branch already
guarded against in the other direction.
Refs #254
The two 'must fail loudly' cases from the plan: an unparseable body still
nudges and the nudge quotes none of it, and a grep-level pin that no second
7-day constant appears in the sweep.
Refs #254
LABELS.md said the sweep never reclaims post-merge and stopped there, which
now reads as 'the machine says nothing' — after this change it says one
thing, once per 7 quiet days. Plus the fragment.
Refs #254
Covers the must-nudge pair (8 quiet days; post-merge + needs-ruling both
speak), the must-not set (6 days, fresh comment, every other queue state),
self-rate-limiting proven by sweeping again a day later rather than by
asserting a marker's absence, and zero writes across every probe.
Two existing probes move: #36's 'no comment' assertion described the
starvation this issue ends, and #67 gets recent activity so its precedence
count stays the assertion doing the work.
Refs #254
WIP checkpoint: the nudge itself, tests still owed. Reuses
ruling_nudge_decision so the 7-day rule keeps one spelling, addresses the
triage actor (post-merge is triage's completion queue), and carries no
idempotency marker — the comment is itself activity, so it self-rate-limits.
Refs #254
Both occurrences said crew's engine already converts a PR back to draft at
round close; crew#139 is OPEN, blocked and unassigned, so the passage was
describing an unshipped mechanism as live (codex, kimi). The same sentence
also read as if the conversion caused the CI spend — it is the status quo
the conversion would end, so the counterfactual is now explicit. Rewrapped
the paragraph at 80 columns; 7948b99 had left one line at 133.