ceremony/lib/ruling.sh
cluade-reviewer-andresmgsl e035130f65
Some checks failed
CI / test (pull_request) Failing after 33s
CI / release-exercise (pull_request) Successful in 12s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Failing after 5s
labels / labels (pull_request) Successful in 43s
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.

The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.

The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.

test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.

Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.

The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.

actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.

test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.

Refs #198
2026-08-05 11:56:23 +00:00

432 lines
23 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.
# forge_timeline projects both forges into the GitHub event shape
# (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
# status is the last command's, so `forge_timeline | jq` would collapse
# an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0
fi
flags="$(jq -r '
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv
' <<<"$timeline")"
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="$(forge_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 forge_issue_comment "$n" "$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 forge_issue_comment "$n" "$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 forge_issue_comment "$n" "$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 forge_issue_comment "$n" "$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 forge_issue_comment "$n" "@$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
}