From 6a1d7ddac02eac4cecf5f4878f8643c21f09922c Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:48:20 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(labels):=20lib/ruling.sh=20=E2=80=94?= =?UTF-8?q?=20shared=20needs-ruling=20sweep=20decisions=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One implementation for both surfaces: the stale exemption, the bare-flag mechanical proxy (15-minute back-window against the newest labeled event), the marker-scoped idempotency, the 7-day nudge with no marker (the comment is the activity that resets its own window), and the one impure orchestrator both reconcilers will source. Co-Authored-By: Claude Fable 5 --- lib/ruling.sh | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 lib/ruling.sh diff --git a/lib/ruling.sh b/lib/ruling.sh new file mode 100644 index 0000000..a2a2882 --- /dev/null +++ b/lib/ruling.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# lib/ruling.sh — the `needs-ruling` sweep invariants (issue #52, epic #50). +# +# Both reconcilers source this file: the bare-flag check 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 idempotency marker for the bare-flag comment. The nudge deliberately +# has NO marker — see ruling_nudge_decision. +RULING_BARE_MARKER='' + +# --------------------------------------------------------------------------- +# 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. + local labeled="$1" marked="${2:-}" + if [ -n "$marked" ] && [ "$marked" -gt "$labeled" ]; then + echo SKIP + else + echo POST + fi +} + +ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP + # Real activity only — comments, reviews, commits, 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() { # "loginiso8601" 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_url() { # $1 setter, $2 labeled epoch; "login epoch url" lines on stdin + # → the url of the EARLIEST in-window comment by the setter, or nothing. + # Earliest, because the natural shape is escalation-then-flag: the first + # qualifying comment is the escalation itself, later ones are follow-ups. + local setter="$1" labeled="$2" login epoch url best_epoch="" best_url="" + while read -r login epoch url; do + [ -n "$login" ] || continue + [ "$login" = "$setter" ] || continue + ruling_accompanies "$epoch" "$labeled" || continue + if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then + best_epoch="$epoch" + best_url="$url" + fi + done + [ -z "$best_url" ] || printf '%s\n' "$best_url" +} + +# --------------------------------------------------------------------------- +# 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)" + + local comments + if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \ + --jq '.[] | [.user.login, .created_at, .html_url, + (if ((.body // "") | contains("")) + then "marked" else "plain" end)] | @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 + # (for idempotency), and the "login epoch url" rows the link lookup reads. + local login at url kind epoch authored="" rows="" marked="" + while IFS=$'\t' read -r login at url kind; do + [ -n "$login" ] || continue + epoch="$(date -d "$at" +%s)" + authored="$authored$login $epoch"$'\n' + rows="$rows$login $epoch $url"$'\n' + if [ "$kind" = marked ]; then + if [ -z "$marked" ] || [ "$epoch" -gt "$marked" ]; then marked="$epoch"; fi + fi + done <<<"$comments" + + # ---- the bare-flag check (#50 D4, mechanical proxy) ---- + if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ] \ + && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked")" = 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 + + # ---- 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 +} From a40e42544eed1656df39e52c4984db6fcc18c574 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:52:57 +0000 Subject: [PATCH 2/5] feat(labels): wire the ruling pass into both reconcilers + lib test suite Issue side: the claim-reclaim clock stops under a pending ruling (the decision still sees an unassigned claim), an already-applied stale heals off, and reconcile_ruling runs for any flagged issue on any queue state. PR side: reconcile_ruling rides the (#51) stale section's real-activity computation. test/ruling.test.sh pins the window boundaries, newest-event anchoring, per-event marker scoping, the markerless nudge reset, the unreadable-timeline rule, and that no scenario writes a label. Co-Authored-By: Claude Fable 5 --- .../issueflow-reconcile.sh | 32 ++- actions/labels-reconcile/labels-reconcile.sh | 18 +- test/ruling.test.sh | 196 ++++++++++++++++++ 3 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 test/ruling.test.sh diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 6956a55..d51d898 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -24,6 +24,10 @@ STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600)) QUEUE_LABELS=(ready claimed blocked) TRIAGE_ACTORS=() +# The needs-ruling invariants (#52) — one implementation for both surfaces. +# shellcheck source=lib/ruling.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" + log() { printf 'issueflow: %s\n' "$*"; } run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } @@ -225,7 +229,16 @@ reconcile_issue() { assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" - decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" + if [ "$(ruling_stale_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then + # Waiting on a human is legitimately quiet (#50 D10): the reclaim + # clock does not run under a pending ruling — the same treatment + # `blocked` gets by never reaching this branch at all. Only the clock + # stops: an unassigned claim is still a repair the decision must see, + # so it runs on a zero age rather than being skipped. + decision="$(claim_decision "$assignees" "$open_pr" 0)" + else + decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" + fi case "$decision" in FLAG_UNASSIGNED) ensure_comment "$n" claimed-unassigned \ @@ -272,6 +285,23 @@ reconcile_issue() { log "#$n: completed epic nudged" fi fi + + # ---- the ruling invariants (#52), on any queue state ---- + # The flag composes with the queue labels (#50 D8), so this runs after the + # queue branches rather than inside one of them. The FLAG_CONFLICT return + # above still short-circuits it on purpose: a board lying about its queue + # state is repaired by triage before anything else is derived from it. + if has_issue_label needs-ruling; then + # An already-applied stale comes off: waiting on a human is legitimately + # quiet (#50 D10), and nothing on the issue side ever puts stale back. + if has_issue_label stale; then + run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null + log "#$n: unstale (a ruling is pending)" + fi + [ -n "${age:-}" ] \ + || age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" + reconcile_ruling "$n" "$age" "$NOW" + fi } reconcile_opened_issue() { diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 53f6d01..67e8a0e 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -50,6 +50,10 @@ LABELS="" RETIRED=(state:needs-rebase) STALE_AFTER=$((48 * 3600)) +# The needs-ruling invariants (#52) — one implementation for both surfaces. +# shellcheck source=lib/ruling.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" + log() { printf 'labels: %s\n' "$*"; } run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing @@ -412,7 +416,7 @@ $(configured_label_rows "$LABELS_CONF")" has_label() { grep -qxF "$1" <<<"$LABELS"; } reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch - local n="$1" desired remove s args last_activity age + local n="$1" desired remove s args last_activity last_activity_epoch age desired="$(decide_state)" @@ -517,7 +521,8 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' } | sort | tail -n1 )" - age=$((NOW - $(date -d "$last_activity" +%s))) + last_activity_epoch="$(date -d "$last_activity" +%s)" + age=$((NOW - last_activity_epoch)) # needs-ruling joins blocked here: waiting on a human is legitimately quiet # (#50 D10). The 7-day nudge is #52's, once for both surfaces. if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then @@ -529,6 +534,15 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null log "#$n: stale ($((age / 3600))h quiet)" fi + + # ---- the ruling invariants (#52): the bare-flag check + the 7-day nudge -- + # The stale EXEMPTION above is #51's; these are the sweep halves that ride + # the same real-activity computation (lib/ruling.sh, shared with the issue + # side). Behind the flag check so flag-free PRs — all of them, almost + # always — cost no extra API reads. + if has_label needs-ruling; then + reconcile_ruling "$n" "$last_activity_epoch" "$NOW" + fi } main() { diff --git a/test/ruling.test.sh b/test/ruling.test.sh new file mode 100644 index 0000000..fa3a3ed --- /dev/null +++ b/test/ruling.test.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +source "$ROOT/test/harness.sh" +# shellcheck source=lib/ruling.sh +source "$ROOT/lib/ruling.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --------------------------------------------------------------------------- +# Pure decisions. Epochs are arbitrary fixed numbers — no wall clock anywhere. +# --------------------------------------------------------------------------- + +check "needs-ruling is stale-exempt" 0 "EXEMPT" ruling_stale_exempt <<< $'claimed\nneeds-ruling' +check "a flag-free issue sweeps normally" 0 "SWEEP" ruling_stale_exempt <<< $'claimed' + +L=100000 # the labeled event, in every window case below + +check "escalation 14 minutes before the flag accompanies it" 0 "ACCOMPANIED" \ + ruling_bare_decision setter "$L" <<<"setter $((L - 840))" +check "escalation exactly 15 minutes before still accompanies (no earlier than)" 0 "ACCOMPANIED" \ + ruling_bare_decision setter "$L" <<<"setter $((L - 900))" +check "escalation after the flag accompanies it" 0 "ACCOMPANIED" \ + ruling_bare_decision setter "$L" <<<"setter $((L + 60))" +check "escalation 16 minutes before is bare" 0 "BARE" \ + ruling_bare_decision setter "$L" <<<"setter $((L - 960))" +check "somebody else's comment does not satisfy the contract" 0 "BARE" \ + ruling_bare_decision setter "$L" <<<"bystander $((L - 60))" +check "no comment at all is bare" 0 "BARE" \ + ruling_bare_decision setter "$L" >"$TMP/posted-$n" + file="$TMP/repos_${REPO%%/*}_${REPO#*/}_issues_${n}_comments.json" + [ -f "$file" ] || printf '[]\n' >"$file" + jq --arg b "$body" --arg at "$(iso "$NOW")" \ + '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ + "$file" >"$file.tmp" && mv "$file.tmp" "$file" + elif [ "$1" = issue ] && [ "$2" = edit ]; then + printf '%s\n' "$*" >>"$TMP/edits" + fi +} + +posts() { [ -f "$TMP/posted-$1" ] && grep -c '^----$' "$TMP/posted-$1" || echo 0; } +timeline_file() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } +comments_file() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } + +# -- bare flag, swept twice: exactly one comment ----------------------------- +T=$((NOW - 3600)) +jq -n --arg at "$(iso "$T")" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$(timeline_file 9)" +printf '[]\n' >"$(comments_file 9)" +check "a bare flag is commented on" 0 "ruling flag is bare" reconcile_ruling 9 "$T" "$NOW" +check "...and the sweep never posted twice" 0 "" reconcile_ruling 9 "$T" "$NOW" +check "one bare comment across two sweeps" 0 "1" posts 9 +check "the bare comment carries its marker" 0 "" \ + grep -qF '' "$TMP/posted-9" +check "the bare comment names the missing contract" 0 "" \ + grep -q 'question' "$TMP/posted-9" + +# -- accompanied flag: silence ---------------------------------------------- +jq -n --arg at "$(iso "$T")" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$(timeline_file 10)" +jq -n --arg at "$(iso "$((T - 840))")" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc","body":"question, options, recommendation"}]' \ + >"$(comments_file 10)" +reconcile_ruling 10 "$T" "$NOW" >/dev/null +check "an accompanied flag posts nothing" 0 "0" posts 10 + +# -- re-flag: judged on its own escalation, marker scoped per event ---------- +T1=$((NOW - 86400)) T2=$((NOW - 7200)) +jq -n --arg t1 "$(iso "$T1")" --arg t2 "$(iso "$T2")" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t1}, + {"event":"unlabeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t2}, + {"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t2}]' \ + >"$(timeline_file 11)" +jq -n --arg esc "$(iso "$((T1 - 60))")" --arg marked "$(iso "$((T1 + 300))")" \ + '[{"user":{"login":"setter"},"created_at":$esc,"html_url":"https://x/esc1","body":"the first escalation"}, + {"user":{"login":"sweep-bot"},"created_at":$marked,"html_url":"https://x/bare1","body":"\nolder episode"}]' \ + >"$(comments_file 11)" +reconcile_ruling 11 "$T2" "$NOW" >/dev/null +check "a re-flag is re-checked against its own escalation" 0 "1" posts 11 + +# -- nudge: fires past 7 quiet days, links the escalation, resets itself ----- +T0=$((NOW - 8 * 86400)) +jq -n --arg at "$(iso "$T0")" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$(timeline_file 12)" +jq -n --arg at "$(iso "$((T0 - 60))")" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc12","body":"question, options, recommendation"}]' \ + >"$(comments_file 12)" +check "8 quiet days nudge" 0 "ruling nudge" reconcile_ruling 12 "$T0" "$NOW" +check "one nudge posted" 0 "1" posts 12 +check "the nudge links the escalation comment" 0 "" grep -qF 'https://x/esc12' "$TMP/posted-12" +check "the nudge addresses the decider" 0 "" grep -qF '@danmt' "$TMP/posted-12" +check "the nudge does not tag the flag-setter" 1 "" grep -qF '@setter' "$TMP/posted-12" +check "the nudge carries no marker — the comment itself resets the window" 1 "" \ + grep -qF "$RULING_BARE_MARKER" "$TMP/posted-12" +# The reset, through the surfaces' own activity computation: the nudge the +# stub appended is the newest comment, so the recomputed last-activity is NOW. +newest_at="$(jq -r 'map(.created_at) | max' "$(comments_file 12)")" +check "the posted nudge is now the newest activity" 0 "" \ + test "$(date -d "$newest_at" +%s)" = "$NOW" +reconcile_ruling 12 "$(date -d "$newest_at" +%s)" "$NOW" >/dev/null +check "a sweep right after the nudge holds its silence" 0 "1" posts 12 + +# -- 6 quiet days: silence --------------------------------------------------- +jq -n --arg at "$(iso "$((NOW - 6 * 86400))")" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$(timeline_file 13)" +jq -n --arg at "$(iso "$((NOW - 6 * 86400))")" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc13","body":"question, options, recommendation"}]' \ + >"$(comments_file 13)" +reconcile_ruling 13 $((NOW - 6 * 86400)) "$NOW" >/dev/null +check "6 quiet days do not nudge" 0 "0" posts 13 + +# -- unreadable timeline: nothing happens ------------------------------------ +check "an unreadable timeline invents no verdict" 0 "timeline unreadable" \ + reconcile_ruling 14 "$T" "$NOW" +check "...and posts nothing" 0 "0" posts 14 + +# -- across every scenario above: not one label write ------------------------ +check "the ruling sweep never wrote a label" 1 "" test -f "$TMP/edits" + +# Grep-level pin for #50 D9: no mutation call in the sweep code names the +# flag. The only writes reconcile_ruling makes are comments. +check "no add/remove-label mutation names the ruling flag" 1 "" \ + grep -rEn -- '(add|remove)-label[^"]*needs-ruling' "$ROOT/actions" "$ROOT/lib" + +summary From c4079ea37c5e7fe27243ed556b4ceee0750a090f Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:57:20 +0000 Subject: [PATCH 3/5] test(labels): surface-level ruling contracts on both reconcilers Issue side: invariant-1 composition, the reclaim clock stopping under a pending ruling (with a flag-free reclaim control), the stale heal, label churn invisible to the activity clock, the surface-level nudge reset, and no edit anywhere naming the flag. PR side: the wired nudge riding the stale sweep's activity computation, one nudge across two sweeps, #51's stale skip intact. Co-Authored-By: Claude Fable 5 --- test/issueflow-reconcile.test.sh | 129 +++++++++++++++++++++++++++++++ test/labels-reconcile.test.sh | 88 +++++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index f020f14..4f913a2 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -149,4 +149,133 @@ check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\n check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN' check "epic without parseable children is stable" 0 "KEEP" epic_decision "" "" +# Invariant 1 keeps ignoring the ruling flag (#50 D8): it composes with the +# queue labels and is not one of them. +check "claimed plus a pending ruling is a healthy issue" 0 "KEEP" \ + queue_decision <<< $'claimed\nneeds-ruling' +check "a ruling flag alone is still invariant 1's violation" 0 "ADD_NEEDS_TRIAGE" \ + queue_decision <<< $'needs-ruling' + +# --------------------------------------------------------------------------- +# The ruling pass on the issue surface (#52), against a recording stub: the +# reclaim clock stops under a pending ruling, an applied stale heals off, +# label churn is not activity, the nudge resets on its own comment, and no +# edit anywhere names the flag (#50 D9). The stub serves fixture JSON per +# endpoint with the caller's --jq applied by real jq, appends posted comments +# back into the fixture (a second sweep sees the first one's writes), and +# records every label edit. +# --------------------------------------------------------------------------- +INOW=2000000000 +iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } + +issue_stub_gh() { + if [ "$1" = api ]; then + shift + local jqexpr="" endpoint="" file + while [ $# -gt 0 ]; do + case "$1" in + --jq) jqexpr="$2"; shift ;; + -*) ;; + *) [ -n "$endpoint" ] || endpoint="$1" ;; + esac + shift + done + file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" + [ -f "$file" ] || { printf '[]\n'; return 0; } + if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi + elif [ "$1" = issue ] && [ "$2" = comment ]; then + local n="$3" body="" file + shift 3 + while [ $# -gt 0 ]; do + case "$1" in --body) body="$2"; shift ;; esac + shift + done + printf '%s\n----\n' "$body" >>"$TMP/posted-$n" + file="$TMP/repos_owner_repo_issues_${n}_comments.json" + [ -f "$file" ] || printf '[]\n' >"$file" + jq --arg b "$body" --arg at "$(iso_at "$INOW")" \ + '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ + "$file" >"$file.tmp" && mv "$file.tmp" "$file" + elif [ "$1" = issue ] && [ "$2" = edit ]; then + printf '%s\n' "$*" >>"$TMP/issue-edits" + fi +} + +issue_probe() { # $1 = issue number, $2 = labels → reconcile_issue's log lines + ( + REPO=owner/repo NOW="$INOW" + ISSUE_LABELS="$2" + ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '{created_at: $at, assignees: [{login: "owner-bot"}], body: ""}')" + OPEN_PR_ISSUES="" + run() { "$@"; } + gh() { issue_stub_gh "$@"; } + reconcile_issue "$1" 2>&1 + ) +} + +tfix() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } +cfix() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } + +# -- the reclaim clock stops under a pending ruling (48h quiet, no PR) ------- +jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}, + {"event":"assigned","created_at":$l}]' >"$(tfix 21)" +jq -n --arg at "$(iso_at $((INOW - 10 * 86400 - 60)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc21","body":"question, options, recommendation"}]' \ + >"$(cfix 21)" +exempt="$(issue_probe 21 $'claimed\nneeds-ruling')" +check "a 10-day-quiet claim under a ruling is not reclaimed" 1 "" \ + grep -q 'reclaimed' <<<"$exempt" +check "...the same silence still nudges the pending ruling" 0 "" \ + grep -q 'ruling nudge' <<<"$exempt" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "...and the nudge went to the decider with the escalation linked" 0 "" \ + bash -c 'grep -qF "@danmt" "$1" && grep -qF "https://x/esc21" "$1"' _ "$TMP/posted-21" +again="$(issue_probe 21 $'claimed\nneeds-ruling')" +check "the sweep right after the nudge holds its silence" 1 "" \ + grep -q 'ruling nudge' <<<"$again" +check "exactly one nudge across both sweeps" 0 "1" \ + grep -c -- '^----$' "$TMP/posted-21" + +# -- control: the same silence without the flag is reclaimed ----------------- +jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"assigned","created_at":$l}]' >"$(tfix 22)" +printf '[]\n' >"$(cfix 22)" +control="$(issue_probe 22 claimed)" +check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \ + grep -q 'stale claim reclaimed -> ready' <<<"$control" + +# -- an already-applied stale heals off, and no edit names the flag ---------- +jq -n --arg l "$(iso_at $((INOW - 3600)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >"$(tfix 23)" +jq -n --arg at "$(iso_at $((INOW - 3660)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc23","body":"question, options, recommendation"}]' \ + >"$(cfix 23)" +healed="$(issue_probe 23 $'claimed\nneeds-ruling\nstale')" +check "an applied stale comes off under a pending ruling" 0 "" \ + grep -q 'unstale (a ruling is pending)' <<<"$healed" +check "...via an edit that removes exactly stale" 0 "" \ + grep -q -- '--remove-label stale' "$TMP/issue-edits" +check "no issue edit across every probe names the ruling flag (#50 D9)" 1 "" \ + grep -q 'needs-ruling' "$TMP/issue-edits" + +# -- label churn is not activity: the nudge clock reads comments, not labels -- +jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \ + --arg churn "$(iso_at $((INOW - 2 * 86400)))" \ + --arg assigned "$(iso_at $((INOW - 9 * 86400)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$flag}, + {"event":"labeled","label":{"name":"priority"},"actor":{"login":"anyone"},"created_at":$churn}, + {"event":"assigned","created_at":$assigned}]' >"$(tfix 24)" +jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \ + >"$(cfix 24)" +churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } + last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )" +check "last activity ignores the 2-day-old label churn" 0 "" \ + test "$churn_last" = "$((INOW - 8 * 86400 - 60))" +churned="$(issue_probe 24 $'claimed\nneeds-ruling')" +check "8 real-quiet days nudge through a 2-day-old label churn" 0 "" \ + grep -q 'ruling nudge' <<<"$churned" + summary diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index a3cf17d..c112aec 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -489,5 +489,93 @@ unstale="$(ruling_probe "$(printf 'needs-ruling\nstale')")" expect "...and an already-applied stale comes off" \ yes "$(grep -q 'unstale' <<<"$unstale" && echo yes || echo no)" +# --------------------------------------------------------------------------- +# The ruling pass on the PR surface (#52): the bare-flag check and the 7-day +# nudge ride reconcile_pr behind the flag, on the same real-activity +# computation the stale sweep reads. A recording stub serves the API facts: +# fixture JSON per endpoint (with the caller's --jq applied by real jq), +# posted comments appended back into the fixture so a second sweep sees the +# first one's writes, and every label edit recorded. +# --------------------------------------------------------------------------- +RTMP="$(mktemp -d)" +trap 'rm -rf "$RTMP"' EXIT +iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +RNOW=2000000000 + +ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines + ( + REPO_LABELS="$(printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling')" + REPO=owner/repo NOW="$RNOW" + LABELS="$1" + DRAFT=false HEAD_SHA=head1 REQUESTED="" + # Approvals submitted 8 days ago — the newest real activity anywhere. + REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \ + "$(rev "$BOT2" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \ + "$(rev "$BOT3" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")")" + MERGEABLE=MERGEABLE CHECKS=SUCCESS + PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')" + run() { "$@"; } # mutations reach the stub and are recorded, not swallowed + gh() { + if [ "$1" = api ]; then + shift + local jqexpr="" endpoint="" file + while [ $# -gt 0 ]; do + case "$1" in + --jq) jqexpr="$2"; shift ;; + -*) ;; + *) [ -n "$endpoint" ] || endpoint="$1" ;; + esac + shift + done + file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" + [ -f "$file" ] || { printf '[]\n'; return 0; } + if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi + elif [ "$1" = issue ] && [ "$2" = comment ]; then + local n="$3" body="" file + shift 3 + while [ $# -gt 0 ]; do + case "$1" in --body) body="$2"; shift ;; esac + shift + done + printf '%s\n----\n' "$body" >>"$RTMP/posted-$n" + file="$RTMP/repos_owner_repo_issues_${n}_comments.json" + [ -f "$file" ] || printf '[]\n' >"$file" + jq --arg b "$body" --arg at "$(iso_at "$RNOW")" \ + '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ + "$file" >"$file.tmp" && mv "$file.tmp" "$file" + elif [ "$1" = issue ] && [ "$2" = edit ]; then + printf '%s\n' "$*" >>"$RTMP/edits" + fi + } + reconcile_pr 77 2>&1 + ) +} + +# The flag went up 8 days ago with its escalation posted seconds earlier. +jq -n --arg at "$(iso_at $((RNOW - 8 * 86400)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$RTMP/repos_owner_repo_issues_77_timeline.json" +jq -n --arg at "$(iso_at $((RNOW - 8 * 86400 - 60)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc77","body":"question, options, recommendation"}]' \ + >"$RTMP/repos_owner_repo_issues_77_comments.json" + +wired="$(ruling_sweep_probe "needs-ruling")" +expect "8 quiet days under a ruling nudges on the PR surface" \ + yes "$(grep -q 'ruling nudge' <<<"$wired" && echo yes || echo no)" +expect "...while the quiet stays stale-free (#51's skip intact)" \ + no "$(grep -q 'stale (' <<<"$wired" && echo yes || echo no)" +expect "...the accompanied flag is not called bare" \ + no "$(grep -q 'ruling flag is bare' <<<"$wired" && echo yes || echo no)" +expect "the nudge addressed the decider and linked the escalation" \ + yes "$(grep -qF '@danmt' "$RTMP/posted-77" && grep -qF 'https://x/esc77' "$RTMP/posted-77" && echo yes || echo no)" +again="$(ruling_sweep_probe "needs-ruling")" +expect "the sweep right after the nudge holds its silence — the comment reset the window" \ + no "$(grep -q 'ruling nudge' <<<"$again" && echo yes || echo no)" +expect "exactly one nudge across both sweeps" \ + 1 "$(grep -c '^----$' "$RTMP/posted-77")" +expect "no label edit across both sweeps names the ruling flag" \ + no "$(grep -q 'needs-ruling' "$RTMP/edits" 2>/dev/null && echo yes || echo no)" + printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] From 9ff049ae4582002f47f1208b43f6557d7eac8744 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:59:05 +0000 Subject: [PATCH 4/5] docs(labels): the ruling sweep behaviors in LABELS.md, CONSUMERS.md, CHANGELOG LABELS.md's needs-ruling paragraph gains the bare-flag check and the markerless 7-day nudge; CONSUMERS.md names them in the labels job and states the caller stub is unchanged since #18 (a pin bump is the whole upgrade); one CHANGELOG line under Unreleased (#52). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + LABELS.md | 10 +++++++++- docs/CONSUMERS.md | 11 ++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0871c..16523f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ so entries say what changed, cite the issue, and stop. - `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51). - Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of #N` replaces the `Closes #N` that cannot cross repos (#57). - `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58). +- The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52). ## 0.1.0 — 2026-07-22 diff --git a/LABELS.md b/LABELS.md index d617129..5d3def5 100644 --- a/LABELS.md +++ b/LABELS.md @@ -89,7 +89,15 @@ disagrees that agreement was reached, the label goes back on. The machine reads it and never writes it: the reconciler refuses `state:needs-human` while it stands (the PR falls to `state:addressing` — the ball on the PR is the builder's, who carries the ruling in), and the staleness sweep skips -it, because waiting on a human is legitimately quiet. +it, because waiting on a human is legitimately quiet. Quiet, but not +unwatched (#52, both surfaces): a flag set with no escalation comment from +its setter is called out by the sweep — comment-only, scoped to the labeled +event, the label never removed — and a ruling with no real activity for 7 +days draws a comment-only nudge addressed to the decider, linking the +escalation. The nudge carries no marker on purpose: the comment is itself +activity, so it resets its own window and never repeats within a quiet +week. Label churn is not activity — the clock reads comments, reviews and +commits, or the sweep would reset itself. ## Scope — which surface? (PRs and issues, any number) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 1f38c91..bea4151 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -226,9 +226,12 @@ build (#15) and incubator's GHCR image push (#16). ## Labels automation The reusable labels workflow owns two independent jobs: additive path-based -`scope:*` labels and reconciliation of PR state, blockers, handoff, and stale -status. The consumer keeps its path mapping in `.github/labeler.yml` and its -review panel plus scope taxonomy in `.github/labels.conf`. +`scope:*` labels and reconciliation of PR state, blockers, handoff, stale +status, and the `needs-ruling` invariants on both surfaces — the bare-flag +check and the 7-day comment-only nudge (#52; the sweep reads that flag and +never writes it). The consumer keeps its path mapping in +`.github/labeler.yml` and its review panel plus scope taxonomy in +`.github/labels.conf`. The complete caller is: @@ -253,6 +256,8 @@ jobs: `pull_request_target` is intentional: fork PRs need the base repository's token to write labels. The reusable workflow executes no PR code. It checks out only the consumer's base branch and the pinned ceremony implementation. +The #52 ruling invariants ride exactly these triggers — the caller above is +unchanged since #18, so adopting them is a pin bump, not a stub edit. `.github/labels.conf` has one mandatory panel setting, one mandatory `triage-actors` setting, and then zero or more scope rows: From 8203f081ead97a7d83fd4807b867f3710469ca54 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 12:22:43 +0000 Subject: [PATCH 5/5] test: missing-fixture gh stub applies the caller's --jq; pin LC_ALL=C Real 'gh api --jq .[].created_at' on an empty collection emits no lines; the stub printed a literal '[]', which under byte-wise collation sorts after ISO-8601 timestamps and poisoned the PR-surface probe's last_activity. Route the synthesized empty array through the same jq projection as a present fixture, and pin the test's collation so the verdict cannot flip with the runner's ambient locale. Co-Authored-By: Claude Fable 5 --- test/labels-reconcile.test.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index c112aec..fa89f25 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +# Byte-wise collation, matching CI: the probes sort ISO timestamps, and the +# verdict must not flip with the runner's ambient locale. +export LC_ALL=C # Fixture tests for the labels-reconcile state machine: a comment is a # non-verdict whatever its body says (the AUTHOR escalates by requesting the @@ -529,7 +532,9 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines shift done file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" - [ -f "$file" ] || { printf '[]\n'; return 0; } + # A missing fixture is an empty collection — projected through the + # caller's --jq exactly like real gh, so '.[].foo' yields no lines. + [ -f "$file" ] || { printf '[]\n' | jq -r "${jqexpr:-.}"; return 0; } if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then local n="$3" body="" file