ceremony/lib/ruling.sh
dan-claude-bot 48547d5eb1 fix: the issue-side ruling clock reads comments only (#284)
Claiming a needs-ruling issue dated it through the assigned timeline
event, silencing the 7-day escalation nudge at exactly the moment a
builder started working through it. The ruling block now reads
last_issue_comment_activity (D1); the reclaim clock keeps the assignment
(D2) because there the assignment IS the claim; post-merge hands its
evidence read to the ruling block instead of reading again (D6, D7); the
claimed branch reads both clocks at its top, before anything it posts.
LABELS.md and lib/ruling.sh now say what each surface's clock reads
(D4, D5). The #257-era order compositions move to the comments read —
the timeline is no longer an input the issue clocks take, and its
unreadability no longer holds unrelated writes hostage; that narrowing
is pinned rather than implied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:16:19 +00:00

425 lines
22 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# lib/ruling.sh — the `needs-ruling` sweep invariants (issues #52 and #73,
# epic #50).
#
# Both reconcilers source this file: the bare-flag check, the escalation
# shape check, the ladder's rung comments and the 7-day nudge are ONE
# implementation serving both surfaces — two copies of a 7-day rule is how
# the family got here in the first place (#50). Pure decisions sit above
# the divider (facts in, verdict out); the one impure orchestrator below
# talks to gh and posts through the sourcing script's run()/log().
#
# Standing rules this file lives under:
# - The machine never sets or clears `needs-ruling` (#50 D9). Nothing here
# mutates a label; the only writes are comments. Pinned by a sweep-probe
# test and a grep-level check over the mutation calls.
# - The machine never judges prose (#50 D4). "Did the escalation contract
# accompany the flag" is decided by a mechanical proxy: the actor who
# applied the label has a comment timestamped no earlier than 15 minutes
# before the `labeled` event. The back-window exists because the natural
# ordering is post-the-escalation-then-set-the-label, seconds apart; a
# strictly-after rule would flag every correctly-formed escalation.
# - The failure direction is always *flag*, never *act*: a bare flag is
# commented on, never removed (removing would delete somebody's
# escalation on the strength of a timestamp heuristic), and an
# unreadable timeline does nothing at all — an unreadable fact must
# never invent a verdict (the reconciler's standing rule).
# One constant, both surfaces — the whole reason this file exists. 7 days
# (#50 D10): long enough that an active back-and-forth is never nudged,
# short enough that a forgotten ruling surfaces within the week.
RULING_NUDGE_AFTER=$((7 * 24 * 3600))
# The escalation back-window: a comment this many seconds before the
# `labeled` event still accompanies it.
RULING_BARE_WINDOW=$((15 * 60))
# The ladder's rungs (#50 D13, via #72's doctrine): moments past the current
# episode's `labeled` event. Two timers only — the 24h rung's comment names
# triage's past-24h authority too, so there is no third timer to keep honest.
RULING_RUNG12_AT=$((12 * 3600))
RULING_RUNG24_AT=$((24 * 3600))
# The escalation contract's four field labels (#50 D12). Literal strings —
# the template in BUILDER.md ("the ruling ask") fixes them so this machinery
# can check for them; presence is all that is ever checked (#50 D4).
RULING_SHAPE_FIELDS=(Options: Recommend: Blocked: Default:)
# The idempotency markers, one per comment kind, each scoped to the current
# `labeled` episode by ruling_bare_comment_needed. The nudge deliberately
# has NO marker — see ruling_nudge_decision.
RULING_BARE_MARKER='<!-- ceremony:needs-ruling-bare -->'
RULING_SHAPE_MARKER='<!-- ceremony:needs-ruling-shape -->'
RULING_RUNG12_MARKER='<!-- ceremony:needs-ruling-rung12 -->'
RULING_RUNG24_MARKER='<!-- ceremony:needs-ruling-rung24 -->'
# ---------------------------------------------------------------------------
# Pure decisions. Facts in (args/stdin), verdict out. No gh, no clock.
# ---------------------------------------------------------------------------
ruling_stale_exempt() { # labels on stdin → EXEMPT | SWEEP
# Waiting on a human is legitimately quiet (#50 D10): under a pending
# ruling the staleness clock does not run, the same treatment `blocked`
# gets. The PR-side skip is #51's and lives in labels-reconcile.sh; this
# verdict drives the ISSUE side (the claim-reclaim clock), so the rule has
# one spelling even though the two surfaces consult it in different places.
if grep -qxF needs-ruling; then echo EXEMPT; else echo SWEEP; fi
}
ruling_accompanies() { # $1 comment epoch, $2 labeled epoch → 0 iff in-window
# One spelling of the window rule, shared by the bare verdict and the
# escalation-comment lookup the nudge links — two comparisons drifting
# apart would let the nudge link a comment the bare check rejected.
[ "$1" -ge "$(($2 - RULING_BARE_WINDOW))" ]
}
ruling_bare_decision() { # $1 setter, $2 labeled epoch; "login epoch" lines on stdin
# → ACCOMPANIED | BARE. Only the flag-setter's own comments count: the
# escalation contract (question, options, recommendation — #50 D4) is the
# flag-setter's to post, and somebody else's chatter must not satisfy it.
local setter="$1" labeled="$2" login epoch
while read -r login epoch; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
if ruling_accompanies "$epoch" "$labeled"; then
echo ACCOMPANIED
return 0
fi
done
echo BARE
}
ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoch ("" = none)
# → POST | SKIP. Scoped to the CURRENT labeled event: a marked comment
# older than the event belongs to an earlier flag episode, so a genuine
# re-flag is re-checked while a 15-minute cron never repeats itself.
# Marker-agnostic — the caller tracks a newest epoch PER marker (#73), so
# this one comparison scopes every marked write (bare, shape, both rungs).
local labeled="$1" marked="${2:-}"
if [ -n "$marked" ] && [ "$marked" -gt "$labeled" ]; then
echo SKIP
else
echo POST
fi
}
ruling_shape_field_present() { # $1 field label; escalation body on stdin → 0 iff present
# THE one spelling of the field-presence test — the escalation selector
# scores by it and the shape check grades by it, on purpose in one place:
# a selector scoring by one grep while the check grades by another is how
# the crew#293 misgrade would come back from the other side (#226).
# Line-anchored, allowing leading whitespace and Markdown bold
# (`**Options:**` is how the live escalations write them): the labels
# appearing only mid-sentence is not the template.
local field="$1"
grep -Eq "^[[:space:]]*(\*\*)?$field"
}
ruling_shape_score() { # escalation body on stdin → 04, one point per field present
# The selection rule's metric (#226). An empty body scores 0 through the
# same loop — no special case, and never an error.
local body field score=0
body="$(cat)"
for field in "${RULING_SHAPE_FIELDS[@]}"; do
if ruling_shape_field_present "$field" <<<"$body"; then score=$((score + 1)); fi
done
echo "$score"
}
ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED <missing labels>
# Presence only (#50 D4): that `Recommend:` exists is checkable, that the
# recommendation is any good is not — no counting options, no parsing the
# prose. The per-field test is ruling_shape_field_present, shared with the
# selector (#226). The `🧭 needs-ruling` header line is deliberately
# unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a
# portability trap for zero enforcement value.
local body field missing=""
body="$(cat)"
for field in "${RULING_SHAPE_FIELDS[@]}"; do
ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field"
done
if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi
}
ruling_deadline_decision() { # $1 now, $2 the current episode's labeled epoch → RUNG0 | RUNG12 | RUNG24
# The ladder anchors to the `labeled` event, never to activity (#50 D14:
# an active back-and-forth still climbs it; only the separate 7-day nudge
# resets). "At 12h" means AT: the boundary starts the rung — the rung is a
# moment whose duty exists the moment it strikes, unlike the strictly-past
# nudge horizon. RUNG24 covers "past 24h" too: the 24h comment names both
# the builder's rung and triage's past-24h authority, so there is no
# fourth timer to keep honest.
local age=$(($1 - $2))
if [ "$age" -ge "$RULING_RUNG24_AT" ]; then
echo RUNG24
elif [ "$age" -ge "$RULING_RUNG12_AT" ]; then
echo RUNG12
else
echo RUNG0
fi
}
ruling_default_decision() { # escalation body on stdin → DEADLINE <ts> | HARDBLOCK | UNPARSEABLE
# Parsed only to *describe* the item in the rung comments, never to gate a
# rung (#50 D14: the rungs apply whatever `Default:` says). Mechanical or
# nothing: an ISO-8601 UTC timestamp anywhere on the `Default:` line is
# the deadline, the literal word `none` is a hard block, anything else is
# reported as unparseable rather than guessed at. Only the `Default:` line
# is read — a timestamp elsewhere in the body is somebody's prose.
local line ts
line="$(grep -E '^[[:space:]]*(\*\*)?Default:' | head -n1)"
ts="$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2})?Z' <<<"$line" | head -n1)"
if [ -n "$ts" ]; then
echo "DEADLINE $ts"
elif grep -qE '(^|[^[:alnum:]])none([^[:alnum:]]|$)' <<<"$line"; then
echo HARDBLOCK
else
echo UNPARSEABLE
fi
}
ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
# Real activity only, as the caller's surface defines it: the PR sweep
# supplies comments, reviews and commits; the issue sweeps supply comments
# alone — an `assigned` event is the claim clock's fact, and counting it
# let a claim silence a pending ruling (#284). Never label churn, or the
# sweep would reset its own clock. The nudge needs NO marker: the
# nudge comment is itself activity, so posting it resets this window and
# the rule self-rate-limits to at most one nudge per 7 quiet days. That is
# deliberate — a later refactor that "fixes" it by adding a marker breaks
# exactly the property that makes it safe on a 15-minute cron.
if [ "$(($1 - $2))" -gt "$RULING_NUDGE_AFTER" ]; then echo NUDGE; else echo KEEP; fi
}
ruling_newest_flag() { # "login<TAB>iso8601" lines on stdin → the newest line
# A re-flag after a removal is judged on its own escalation, never on the
# last one's — so every fact anchors to the MOST RECENT labeled event.
# ISO-8601 UTC sorts lexically, so no date parsing is needed here.
sort -t $'\t' -k2 | tail -n1
}
ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin
# → "url b64" of the BEST-SHAPED in-window comment by the setter, or
# nothing: highest ruling_shape_score wins, equal scores break to the
# earliest epoch. Earliest-wins outright was the rule until crew#293
# (2026-08-02): a builder answered its round whole and escalated 33
# seconds later — both in one window, the reply earlier — and the sweep
# graded the round reply, told a correct escalation it was malformed, and
# the setter re-posted a shape it had already met. Score resolves both
# orderings; the earliest tiebreak keeps escalation-then-follow-ups
# wherever the scores cannot tell candidates apart, including all-zero.
# An undecodable or absent body scores 0 and stays a legal candidate —
# an unreadable fact never invents a verdict, and never errors the sweep.
# The window and the setter gate candidacy before any score is taken.
local setter="$1" labeled="$2" login epoch url b64 body score
local best_score=-1 best_epoch="" best=""
while read -r login epoch url b64; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
ruling_accompanies "$epoch" "$labeled" || continue
if body="$(base64 -d <<<"${b64:-}" 2>/dev/null)"; then
score="$(ruling_shape_score <<<"$body")"
else
score=0
fi
if [ "$score" -gt "$best_score" ] \
|| { [ "$score" -eq "$best_score" ] && [ "$epoch" -lt "$best_epoch" ]; }; then
best_score="$score"
best_epoch="$epoch"
best="$url ${b64:-}"
fi
done
[ -z "$best" ] || printf '%s\n' "$best"
}
ruling_escalation_url() { # same contract, url column only — the nudge's link
local row
row="$(ruling_escalation_row "$@")"
[ -z "$row" ] || printf '%s\n' "${row%% *}"
}
# ---------------------------------------------------------------------------
# The impure orchestrator: fetch the facts, call the decisions, post through
# the caller's run(). Called by both reconcilers for every open item that
# carries the flag. Needs REPO; uses the caller's run() and log().
# ---------------------------------------------------------------------------
reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
local n="$1" last_activity="$2" now="$3"
: "${REPO:?reconcile_ruling: REPO is required}"
# The newest `labeled` event for the flag: actor + timestamp. A failed read
# skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids.
local flags newest setter labeled_at labeled_epoch
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0
fi
if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing.
log "#$n: ruling flag has no visible labeled event — no verdict invented this pass"
return 0
fi
newest="$(ruling_newest_flag <<<"$flags")"
setter="${newest%%$'\t'*}"
labeled_at="${newest##*$'\t'}"
labeled_epoch="$(date -d "$labeled_at" +%s)"
# The body travels as jq's @base64 — bodies carry newlines and tabs, and
# the whole file is line-oriented, so the row format stays TSV and the
# body is decoded at its points of use (#73). Do not switch rows to JSON.
local comments
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[] | [.user.login, .created_at, .html_url,
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
log "#$n: ruling comments unreadable — no verdict invented this pass"
return 0
fi
# One pass over the comments builds every fact the decisions consume: who
# commented when (for the bare verdict), the newest marked comment PER
# MARKER (each write is idempotent per episode — one pass, not one pass
# per marker), and the "login epoch url b64" rows the escalation lookup
# reads. A body that fails to decode counts as unmarked — for idempotency
# that risks a repeat, never an invented verdict.
local login at url b64 body epoch authored="" rows=""
local marked_bare="" marked_shape="" marked_rung12="" marked_rung24=""
while IFS=$'\t' read -r login at url b64; do
[ -n "$login" ] || continue
epoch="$(date -d "$at" +%s)"
authored="$authored$login $epoch"$'\n'
rows="$rows$login $epoch $url ${b64:-}"$'\n'
body="$(base64 -d <<<"${b64:-}" 2>/dev/null)" || body=""
case "$body" in *"$RULING_BARE_MARKER"*)
if [ -z "$marked_bare" ] || [ "$epoch" -gt "$marked_bare" ]; then marked_bare="$epoch"; fi ;;
esac
case "$body" in *"$RULING_SHAPE_MARKER"*)
if [ -z "$marked_shape" ] || [ "$epoch" -gt "$marked_shape" ]; then marked_shape="$epoch"; fi ;;
esac
case "$body" in *"$RULING_RUNG12_MARKER"*)
if [ -z "$marked_rung12" ] || [ "$epoch" -gt "$marked_rung12" ]; then marked_rung12="$epoch"; fi ;;
esac
case "$body" in *"$RULING_RUNG24_MARKER"*)
if [ -z "$marked_rung24" ] || [ "$epoch" -gt "$marked_rung24" ]; then marked_rung24="$epoch"; fi ;;
esac
done <<<"$comments"
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER
The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the
flag-setter no more than 15 minutes before applying the label, or any time
after ([LABELS.md](https://github.com/heavy-duty/ceremony/blob/main/LABELS.md)
carries the flag-setter's obligations; heavy-duty/ceremony#50 D4). The label stays — this machine never removes an
escalation on the strength of a timestamp heuristic — but the contract is
still owed." >/dev/null
log "#$n: ruling flag is bare — commented (the label is never removed)"
fi
# Bare stops here (#73): there is no shape to check when there is no
# escalation comment, and a rung comment beside the bare comment would be
# two comments about the same omission — noise. The 7-day nudge below is
# deliberately untouched by this exclusion; it predates the ladder and
# already words the bare case itself.
else
# ---- the shape check (#50 D12): the contract's four field labels ----
local esc_row esc_url esc_body shape
esc_row="$(ruling_escalation_row "$setter" "$labeled_epoch" <<<"$rows")"
esc_url="${esc_row%% *}"
if ! esc_body="$(base64 -d <<<"${esc_row#* }" 2>/dev/null)"; then
# An undecodable body must not become "malformed" — an unreadable fact
# never invents a verdict. The rungs read the same body, so they wait
# for a readable pass too.
log "#$n: escalation body unreadable — no verdict invented this pass"
else
shape="$(ruling_shape_decision <<<"$esc_body")"
if [ "$shape" != SHAPED ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
local missing="${shape#MALFORMED }"
run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
is missing required field labels: **$missing**. The contract's shape is
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
four line-anchored field labels — \`Options:\`, \`Recommend:\`, \`Blocked:\`,
\`Default:\` — per the canonical template in
[BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask).
Presence is all that is checked; the machine never judges the prose
(heavy-duty/ceremony#50 D4). The label stays — the shape is owed, not
enforced." >/dev/null
log "#$n: escalation malformed (missing:$missing) — commented (the shape is owed, not enforced)"
fi
# ---- the ladder's rungs (#50 D13D14), observed never decided ----
# Each rung's comment fires once per episode, AT its moment: a rung
# whose moment passed unobserved (the sweep was down through 12h24h)
# is not paged after the fact — the later rung's comment carries the
# whole remaining duty.
local rung state described
rung="$(ruling_deadline_decision "$now" "$labeled_epoch")"
state="$(ruling_default_decision <<<"$esc_body")"
case "$state" in
DEADLINE\ *) described="a stated default deadline of \`${state#DEADLINE }\`" ;;
HARDBLOCK) described="\`Default: none\` — a hard block; no default ever fires" ;;
*) described="a missing or unparseable \`Default:\` line — reported as-is, never guessed at (and the contract's own rule is that unsure is a hard block)" ;;
esac
if [ "$rung" = RUNG12 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
$described.
The rung's duty is the flag-setter's: re-read the \`Default:\` against
everything that has landed since the flag went up — does it still hold, and
has reasonable doubt appeared? A stale default does not fire, and new doubt
makes it a hard block. The rungs run on the \`labeled\` clock and do not
reset on activity; this comment fires once per flag episode." >/dev/null
log "#$n: ruling at the 12h rung — commented (the setter re-reads the default)"
fi
if [ "$rung" = RUNG24 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
$described.
At 24h the builder proceeds regardless, **as a PR**: pick an option and
state in the PR body which way you went and what doubt remains. Nothing
merges by this — the human still gates the merge. Past 24h the choice is
triage's to make: triage picks the option, records it as a decision, and
remains accountable; the operator may overturn it at merge. The rungs run on
the \`labeled\` clock and do not reset on activity; this comment fires once
per flag episode and covers everything past 24h — there is no further
timer." >/dev/null
log "#$n: ruling at the 24h rung — commented (the builder proceeds as a PR; past 24h is triage's)"
fi
fi
fi
# ---- the 7-day nudge (#50 D10) ----
if [ "$(ruling_nudge_decision "$now" "$last_activity")" = NUDGE ]; then
# The decider is the repo's human reviewer — the same knob the PR
# reconciler trusts for the merge gate, defaulted the same way. The
# flag-setter is named but deliberately not tagged: address the decider,
# never the whole thread's cast (#50 D10).
local decider="${HUMAN_REVIEWER:-danmt}" days esc_url esc_line
days=$(((now - last_activity) / 86400))
esc_url="$(ruling_escalation_url "$setter" "$labeled_epoch" <<<"$rows")"
if [ -n "$esc_url" ]; then
esc_line="The escalation is here: $esc_url"
else
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits. Do not add a marker.*" >/dev/null
log "#$n: ruling nudge (${days}d quiet — the decider owes an answer)"
fi
}