From 90a35008a1118503157d0584b32139511e68c398 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:06:03 +0000 Subject: [PATCH 1/7] wip: the collision and window board flags, decisions and gather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both checks ride the existing sweep walk and write nothing but comments (#293 D1). The board read now answers the whole payload, because both decisions are over the WHOLE board — every open issue's labels and title, and every open release issue's body — and a second pagination for the same rows would be a second board free to disagree with this one mid-sweep. Fixtures still owed. --- .../issueflow-reconcile.sh | 283 +++++++++++++++++- 1 file changed, 274 insertions(+), 9 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 9081297..94d2e64 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -346,10 +346,18 @@ blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker # pair and leave the class: `-`, `_` and `.` are legal in a qualifier token # and all collapse the same way. The slug stays in front so a human reading # the raw comment can still see which set it belongs to; it decides nothing. + state_marker blockers-parsed "$1" +} + +state_marker() { # $1 = marker family, $2 = the state's rendered value + # The one spelling of a value-keyed marker. Three flags now key on a state + # that changes rather than on "have I ever said this" — the blocked-parse + # echo (#252) and the two board flags (#293) — and a second implementation + # of the slug-plus-digest rule is the drift a shared helper prevents. local slug digest - slug="$(printf '%s' "$1" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" - digest="$(printf '%s' "$1" | sha256sum | cut -c1-12)" - printf 'blockers-parsed-%s-%s\n' "${slug:-none}" "$digest" + slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" + digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)" + printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest" } blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet @@ -367,13 +375,20 @@ blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 q # Comparing markers rather than re-rendering the last set keeps the digest as # the only identity: two sets are the same here iff blocked_parse_marker says # so, the same rule the marker itself is built on. + state_echo_needed "$1" blockers-parsed "$2" +} + +state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo, 1 quiet + # The value-keyed dedup itself, family-scoped so each flag compares against + # its OWN last word and never against another flag's (#293 D4 asks for the + # declaration echo's mechanism exactly, and three families now share it). local bodies last guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" # The read fails closed above (#247 D1): an unreadable history skips the # issue rather than answering "nothing echoed yet" and re-posting. - last="$(grep -o '' <<<"$bodies" | tail -n 1)" - [ "$last" != "" ] + last="$(grep -o "" <<<"$bodies" | tail -n 1)" + [ "$last" != "" ] } blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs @@ -402,6 +417,138 @@ epic_decision() { # $1 refs, $2 states fi } +# ---- the two board flags (#293): collision (#288) and window (#292) -------- +# +# Both are ADVISORY and comment-only (#293 D1). The sweep never guesses +# intent, so neither writes a label, changes a state, or invents one: it +# states the board fact and triage resolves it. Both are also the first +# checks here whose input is the WHOLE board rather than one issue, so the +# facts are gathered once in main() and every decision below is pure over +# those records — one record per open issue, `numberlabelstitle`. +# +# Why they exist as guards at all: #288 and #292 are triage prose, and both +# failed silently on the same morning (2026-08-04) — #284 minted `ready` +# into a claimed file's function, and six `ready` non-members raced an +# emptying gate. #262 measured the pattern: the same class of rule, once in +# a guard, produced zero misses. + +DELIVERABLE_PATH_PREFIXES=(actions/ lib/ bin/ .github/) + +deliverable_key() { # $1 = one title segment -> its normalized key, or nothing + # Normalized, because the 2026-08-04 miss spelled one deliverable two ways + # — `actions/issueflow-reconcile` against plain `issueflow-reconcile` — so + # exact-prefix matching would have missed the pair it was written for + # (#293 D2). One leading path segment comes off, then every extension: + # `issueflow-reconcile.test.sh` and `issueflow-reconcile.sh` are the same + # spelling habit one more time. + local key="$1" prefix + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + for prefix in "${DELIVERABLE_PATH_PREFIXES[@]}"; do + [ "${key#"$prefix"}" = "$key" ] || { key="${key#"$prefix"}"; break; } + done + while [[ "$key" =~ \.[[:alnum:]]+$ ]]; do key="${key%.*}"; done + # Case folds because the key is a spelling, not an identifier, and folding + # only ever widens the match — the same direction of error the blocked + # parse takes, and the cheap one: a false pair costs a comment a human + # dismisses, a missed pair costs two builders one deliverable. + printf '%s\n' "$key" | tr '[:upper:]' '[:lower:]' +} + +deliverable_keys() { # title on stdin -> its deliverable keys, one per line + local title prefix segment key + IFS= read -r title + # The issue contract forces every title to name its deliverable before the + # em dash, so the key exists on every well-formed title by construction + # (#288 D5). A title without one names no deliverable, and inventing a key + # out of prose is the guessing this sweep never does — the malformed title + # is triage's own contract to enforce, not this flag's to infer around. + prefix="${title%%—*}" + [ "$prefix" != "$title" ] || return 0 + # A multi-file deliverable joins its files with `+` and collides on any + # segment: `TRIAGE.md + RELEASES.md` carries both keys. + local segments=() + IFS='+' read -r -a segments <<<"$prefix" + for segment in "${segments[@]}"; do + key="$(deliverable_key "$segment")" + [ -z "$key" ] || printf '%s\n' "$key" + done +} + +collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set + # `blocked` is out: a chained issue is the GOAL state of #288's rule, and + # flagging it would report the fix as the defect. `epic` and `post-merge` + # are out by #288 D6 — neither is picked by a builder — and they carry no + # queue label to admit them here anyway. + case ",$1," in *,blocked,*) return 1 ;; esac + case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac + return 1 +} + +window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule + # `epic` and `post-merge` are outside the claimable set and exempt by name + # (#292 D1); `blocked` is already placed behind something and is what the + # non-member leg of the mint-time call writes. + case ",$1," in *,blocked,*|*,epic,*|*,post-merge,*) return 1 ;; esac + return 0 +} + +collision_key_index() { # board records on stdin -> "keynumber" in scope + local n labels title key + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + collision_in_scope "$labels" || continue + while IFS= read -r key; do + [ -z "$key" ] || printf '%s\t%s\n' "$key" "$n" + done < <(deliverable_keys <<<"$title") + done +} + +collision_flags() { # key index on stdin -> "numberkey=carrier[,key=carrier]" + # A CHAIN, not a fan (#288 D3): within one key, each issue names the newest + # open carrier below it, so the declaration the flag asks for releases + # exactly one successor per close. Three issues on one deliverable draw two + # comments — #257 naming #253, #284 naming #257 — never three pairs, which + # is the fan the rule exists to forbid. + # + # One line per issue, its keys folded into one state: an issue carrying two + # colliding deliverables has ONE offending state and owes one comment (D4), + # the same shape the blocked-parse echo takes with its set. + sort -t $'\t' -k1,1 -k2,2n \ + | awk -F '\t' ' + $1 == key { print $2 "\t" $1 "=" carrier } + { key = $1; carrier = $2 } + ' \ + | sort -t $'\t' -k1,1n -k2,2 \ + | awk -F '\t' ' + $1 != n { if (n != "") print n "\t" state; n = $1; state = $2; next } + { state = state "," $2 } + END { if (n != "") print n "\t" state } + ' +} + +window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers + local n labels title gate="$1" carriers="$2" + [ -n "$carriers" ] || return 0 + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + window_in_scope "$labels" || continue + grep -qxF "$n" <<<"$gate" && continue + # The release issue is the graph's SINK, never one of its own members + # (#292 D2), so it can never be its own non-member. + grep -qxF "$n" <<<"$carriers" && continue + printf '%s\n' "$n" + done +} + +window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250" + awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1" +} + +flag_for_issue() { # $1 = issue, $2 = flag records "numberstate" + awk -F '\t' -v n="$1" '$1 == n { print $2 }' <<<"$2" +} + offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N jq -r ' .[] @@ -544,6 +691,80 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on issue_activity_at "$1" "$2" comments-only } +reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) + # Dedup is the declaration echo's, per family (#293 D4): the marker is + # keyed to the offending state's VALUE and compared against this family's + # last word on the thread, so a state that changes speaks and a state that + # stands is silent. What that buys over ensure_comment's any-occurrence + # grep is the A -> B -> A case — an issue that collides with #257, is + # re-declared against #284, and collides with #257 again is saying + # something new each time, and an any-occurrence marker would go quiet on + # the third. What it does not buy is the state that resolves and returns + # unchanged: nothing is posted at the resolution, so the thread's last word + # is still the state itself and the return is silent. That is the echo's + # own boundary, and it is the right one here — the flag speaks about a + # board fact that is true right now, and a board where the fact never + # changed has nothing new to say. + local n="$1" state marker rendered + state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker collision "$state")" + if state_echo_needed "$n" collision "$marker"; then + rendered="$(tr ',' '\n' <<<"$state" \ + | awk -F= '{ print "- `" $1 "` — also carried by #" $2 }')" + run gh issue comment "$n" -R "$REPO" --body " +This issue and the issue named beside each key below are both open and +unblocked, and their titles name the same deliverable: + +$rendered + +That owes a **collision edge**, and #288 makes it unconditional: a deliverable +already carried by an open \`ready\`, \`claimed\` or \`blocked\` issue owes +\`Blocked by #N\` on the newer issue, naming the newest open carrier, so each +close releases exactly one successor. Disjoint regions do not waive it — +\`ready\` must mean claimable concurrently with every other \`ready\` issue, +and an undeclared collision sends two builders at one deliverable. + +The key is the title's em-dash prefix, normalized: one leading \`actions/\`, +\`lib/\`, \`bin/\` or \`.github/\` segment comes off, then every extension, and +a \`+\`-joined title matches on any segment. That is what the machine read, +never a judgment about what the deliverable is — if two spellings normalized +to one deliverable that is really two, say so and no edge is owed. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the collision itself, so an unchanged one never re-posts.*" >/dev/null + log "#$n: collision flag — $state" + fi + fi + + state="$(flag_for_issue "$n" "${WINDOW_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker window-nonmember "$state")" + if state_echo_needed "$n" window-nonmember "$marker"; then + run gh issue comment "$n" -R "$REPO" --body " +A release window is standing ($state) and this issue is neither one of its +gate members nor an \`epic\` or \`post-merge\` issue. + +#292's invariant: during a standing window — an open \`release\`-labeled issue +with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and +\`post-merge\` exempt. Every mint during a window is a membership call, binary, +made at mint time: **behind the gate**, this issue's own Dependencies declare +the release issue as a blocker and the sweep releases it when the release +closes; or **into the graph**, three writes in one tick — this issue declares +its immediate predecessors, every member whose immediate predecessor it +becomes re-points to it, and the release issue gains \`Blocked by #N\`, which +records membership and nothing else. Silence is not a state. + +The gate is read from the release issue's own \`Blocked by\` declarations — the +same parse every \`blocked\` issue is gated on, echoed on that issue. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null + log "#$n: window flag — a ready non-member under $state" + fi + fi +} + reconcile_issue() { local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" @@ -794,6 +1015,13 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step reconcile_attention "$n" issue "$assignees" "$attention_suppression" fi + # ---- the two board flags (#293), on any queue state ---- + # After the queue branches for the same reason the ruling block is: both + # compose with every queue state, and FLAG_CONFLICT's early return still + # short-circuits them, because a board lying about its queue state is + # repaired before anything is derived from it. + reconcile_board_flags "$n" + # ---- 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 @@ -935,19 +1163,56 @@ main() { done < <(refs_references <<<"$body") done)" - local n tail_line issue_numbers + local n tail_line issue_numbers board_json release_bodies rn rbody gate + local window_rendered="" SKIPPED_COUNT=0 SKIPPED_ISSUES="" # A command substitution in a for list suppresses errexit. Capture and # check the board read before entering the loop, or a 504 (including one # after partial pagination) reports a full pass over a truncated board # (#257). - if ! guarded_read issue_numbers gh api --paginate \ - "repos/$REPO/issues?state=open&per_page=100" \ - --jq '.[] | select(has("pull_request") | not) | .number'; then + # + # The read answers the whole payload rather than a projection of it because + # the two board flags (#293) are decided over the WHOLE board — every open + # issue's labels and title, and every open `release` issue's body. One read + # supplies all of it; a second pagination for the same rows would be a + # second board, free to disagree with this one mid-sweep. + if ! guarded_read board_json gh api --paginate \ + "repos/$REPO/issues?state=open&per_page=100"; then log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")" return 1 fi + BOARD_RECORDS="$(jq -r '.[] | select(has("pull_request") | not) + | [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")] + | @tsv' \ + <<<"$board_json")" + issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")" + # A standing window is an open `release`-labeled issue whose gate still + # holds an OPEN member (#292 D1). The board read IS the open set, so + # membership decides openness with no extra call — and an all-closed gate + # is exactly the emptied gate the release's own `blocked` -> `ready` + # promotion answers, which is why a `ready` release leaves the flag + # dormant rather than flagging the whole board. + release_bodies="$(jq -r '.[] | select(has("pull_request") | not) + | select((.labels // []) | map(.name) | index("release")) + | [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ + <<<"$board_json")" + WINDOW_CARRIERS="" + WINDOW_GATE="" + if [ -n "$issue_numbers" ]; then + while IFS=$'\t' read -r rn rbody; do + [ -n "$rn" ] || continue + gate="$(blocked_references <<<"$rbody")" + [ -n "$gate" ] || continue + grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue + WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n' + WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n' + done <<<"$release_bodies" + fi + [ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")" + COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)" + WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ + | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" if [ -z "$issue_numbers" ]; then log "no open issues." else From bdcc211a0b5f795a2a023e2eef3d356f69eb3d6f Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:15:00 +0000 Subject: [PATCH 2/7] the sweep flags what the window and collision rules forbid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two advisory flags on the issue-flow sweep, the mechanical backstop for #288's collision rule and #292's window rule. Both are prose today, and both failed silently on the same morning: #284 was minted `ready` into a file another issue held claimed with a PR in flight, and six `ready` non-members raced an emptying gate. #262 measured the pattern — the same class of rule, once in a guard, produced zero misses. Comments only (D1): no label write, no state change, no new label. The sweep never guesses intent; it states the board fact and triage resolves. The collision key is the title's em-dash prefix NORMALIZED, because the 2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile` against bare `issueflow-reconcile` — so exact-prefix matching would have missed the pair it was written for. One leading path segment comes off, then every extension; a `+`-joined title matches on any segment. The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue names the newest open carrier below it, so the declaration it asks for releases exactly one successor per close. A standing window is a release issue whose gate still holds an OPEN member. The board read IS the open set, so membership decides openness with no extra call, and an all-closed gate is the emptied gate the release's own blocked -> ready promotion answers — which is why a `ready` release leaves the flag dormant instead of flagging the whole board. Dedup is the declaration echo's, extracted into state_marker / state_echo_needed and scoped per family (D4): the marker is keyed to the offending state's value and compared against that family's last word on the thread, so a state that changes always speaks. Fixtures replay the 2026-08-04 morning board whole and the post-ruling board beside it: the first draws exactly four collision flags and six window flags and writes not one label; the second draws none. Closes #293. --- changelog.d/293.md | 13 ++ test/issueflow-reconcile.test.sh | 291 +++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 changelog.d/293.md diff --git a/changelog.d/293.md b/changelog.d/293.md new file mode 100644 index 0000000..2ef2ebb --- /dev/null +++ b/changelog.d/293.md @@ -0,0 +1,13 @@ +### Added + +- The issue-flow sweep now flags a collision the board never declared: two + open, unblocked issues whose titles name one deliverable draw a comment + naming the newer's owed `Blocked by` edge. Keys normalize, so + `actions/x` and `x` are one deliverable (#288). +- The sweep now flags a `ready` non-member during a standing release window, + naming the window's invariant. The gate is read from the release issue's + own `Blocked by` declarations, and an emptied gate leaves it dormant + (#292). +- Both flags are advisory: comments only, no label write and no state + change, deduped against each family's last word on the thread so a + standing state re-sweeps silently (#293). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 37f3682..9ea6878 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2044,6 +2044,297 @@ order_board '[{"number":83}]' order_run >/dev/null check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 +# -- the two board flags (#293): the deliverable key, normalized ------------ +# The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix +# matching is specified away (D2). These pin the normalization itself. +check "the em-dash prefix is the key" 0 "issueflow-reconcile" \ + deliverable_keys <<<"issueflow-reconcile — the ruling clock counts assigned" +check "a leading actions/ segment comes off" 0 "issueflow-reconcile" \ + deliverable_keys <<<"actions/issueflow-reconcile — a failed board read" +check "...and so does .github/" 0 "labeler" \ + deliverable_keys <<<".github/labeler.yml — one wrong answer left by D4" +check "...and lib/" 0 "attention" deliverable_keys <<<"lib/attention.sh — the target" +check "...and bin/" 0 "decide" deliverable_keys <<<"bin/decide.sh — the door" +check "every extension comes off, not just the last" 0 "issueflow-reconcile" \ + deliverable_keys <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" +check "the key folds case" 0 "triage" deliverable_keys <<<"TRIAGE.md — the bullet" +check "a + title carries both segments" 0 $'triage\nreleases' \ + deliverable_keys <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" +# A path segment the rule does not name stays part of the key: the strip list +# is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own +# deliverable and not the action it exercises. +check "an unlisted path segment stays in the key" 0 "test/issueflow-reconcile" \ + deliverable_keys <<<"test/issueflow-reconcile.test.sh — the pre-read" +# No em dash, no key. Inventing one out of prose is the guessing this sweep +# never does; the malformed title is triage's own contract to enforce. +check "a title with no em dash names no deliverable" 0 "" \ + deliverable_keys <<<"a title that names nothing" + +# -- the collision decision: a chain, never a fan (#288 D3) ------------------ +# Sourced helpers, not `bash -c`: a subshell started with -c has none of these +# functions, and a pipeline ending in grep would then answer "no match" from a +# command-not-found and pass a negative case for the wrong reason. +collision_chain() { collision_key_index | collision_flags; } +collision_flags_issue() { collision_chain | grep -q "^$1"; } +window_flags_issue() { # $1 issue, $2 gate, $3 carriers; records on stdin + window_flags "$2" "$3" | grep -qx "$1" +} +collision_board=$'253\tclaimed\tissueflow-reconcile — release-init\n257\tclaimed\tactions/issueflow-reconcile — a failed board read\n284\tready\tissueflow-reconcile — the ruling clock' +check "three issues on one deliverable chain, each naming the newest below it" 0 \ + $'257\tissueflow-reconcile=253\n284\tissueflow-reconcile=257' \ + collision_chain <<<"$collision_board" +check "...so the oldest carrier is never itself flagged" 1 "" \ + collision_flags_issue 253 <<<"$collision_board" +check "a lone carrier draws nothing" 0 "" \ + collision_chain <<<$'284\tready\tissueflow-reconcile — alone' +# `blocked` is the GOAL state of #288's rule; flagging it reports the fix as +# the defect. Both legs of the test plan, on one board. +check "two blocked twins are the declared chain, not a collision" 0 "" \ + collision_chain \ + <<<$'264\tblocked\tTRIAGE.md — one\n266\tblocked\tTRIAGE.md — two' +check "a blocked twin does not carry a ready one's edge either" 0 "" \ + collision_chain \ + <<<$'264\tblocked\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +check "an epic carrying the key is outside the claimable set (#288 D6)" 0 "" \ + collision_chain \ + <<<$'264\tepic\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +check "a post-merge carrier is outside it too" 0 "" \ + collision_chain \ + <<<$'264\tpost-merge\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +# The #284 shape, stated as its own case (test plan): a `claimed` issue whose +# PR is already in flight is the STRONGEST collision on the board, not a +# weaker one, and the flag reads the queue label rather than the PR link. +check "a claimed carrier with a PR in flight still carries the collision" 0 \ + $'284\tissueflow-reconcile=253' \ + collision_chain \ + <<<$'253\tclaimed,scope:labels\tissueflow-reconcile — release-init\n284\tready\tissueflow-reconcile — the ruling clock' +# One issue, two colliding deliverables: ONE offending state, one comment (D4). +check "a multi-file title folds its collisions into one state" 0 \ + $'295\treleases=292,triage=264' \ + collision_chain \ + <<<$'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three' + +# -- the window decision (#292 D1) ------------------------------------------ +window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' +check "a ready non-member is flagged during a standing window" 0 "264" \ + window_flags "253" "249" <<<"$window_board" +check "...and a gate member is not" 1 "" \ + window_flags_issue 264 $'253\n264' 249 <<<"$window_board" +check "...nor an epic (#292 D1 exempts it by name)" 1 "" \ + window_flags_issue 270 253 249 <<<"$window_board" +check "...nor a post-merge issue" 1 "" \ + window_flags_issue 271 253 249 <<<"$window_board" +check "...nor a blocked issue, which is already placed behind something" 1 "" \ + window_flags_issue 272 253 249 <<<"$window_board" +# The release issue is the graph's SINK (#292 D2), so it can never be its own +# non-member — even when its own labels would otherwise admit it. +check "the window carrier is never flagged as its own non-member" 1 "" \ + window_flags_issue 249 253 249 \ + <<<$'249\tready,release\tRelease 0.6.0 — the board empties' +check "no standing window means no flag at all" 0 "" \ + window_flags "" "" <<<"$window_board" +check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n' + +# -- the 2026-08-04 board, replayed whole (D5) ------------------------------ +# The corpus the operator ruled on. Both flags are decided over the WHOLE +# board, so a sourced decision probe cannot exercise the gather — these run +# the script as a subprocess behind the PATH-stubbed gh, #91's lesson applied +# to a board-wide check. +BOARD="$TMP/board" +mkdir -p "$BOARD" +cp "$ARRIVAL/fixtures/graphql-open.json" "$BOARD/graphql-open.json" +cp "$ARRIVAL/fixtures/graphql-merged.json" "$BOARD/graphql-merged.json" + +board_issue() { # $1 number, $2 labels(csv), $3 title, $4 body, $5 assignee count + local labels_json + labels_json="$(printf '%s' "$2" | tr ',' '\n' \ + | jq -R . | jq -sc 'map(select(. != "") | {name: .})')" + jq -n --argjson n "$1" --argjson labels "$labels_json" --arg t "$3" \ + --arg b "${4:-}" --argjson a "${5:-0}" --arg at "$(iso_at "$INOW")" \ + '{number: $n, state: "open", title: $t, body: $b, labels: $labels, + created_at: $at, user: {login: "triage-one"}, + assignees: (if $a > 0 then [{login: "builder-bot"}] else [] end)}' \ + >"$BOARD/repos_owner_repo_issues_$1.json" +} + +board_assemble() { # numbers… -> the open-issue list, with fresh comment threads + local n + for n in "$@"; do printf '[]\n' >"$BOARD/repos_owner_repo_issues_${n}_comments.json"; done + # shellcheck disable=SC2016 # the filename expansion belongs to the loop below + for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ + | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open_per_page_100.json" +} + +flag_count() { # $1 = collision|window, $2 = a sweep's output + grep -c ": $1 flag — " <<<"$2" +} + +board_run() { + : >"$BOARD/edits" + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$BOARD" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} + +# The morning shape: ten open issues, six of them `ready` non-members, a +# standing gate, and one deliverable carried three times in two spellings. +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257.' +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +board_issue 257 claimed 'actions/issueflow-reconcile — a failed board read sweeps an empty board' '' 1 +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' +board_issue 276 ready 'REVIEWER.md — the green-check precondition' +board_issue 281 ready 'LABELS.md — the attention row' +board_issue 282 ready 'TRIAGE.md — the two comment links come out' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' +board_assemble 249 253 257 264 266 276 281 282 284 +morning_out="$(board_run)" +morning_rc=$? + +check "the morning board replays green" 0 "" test "$morning_rc" -eq 0 +# D5's named pair, and the whole reason the key is normalized: #257 spells the +# deliverable `actions/issueflow-reconcile`, #284 spells it bare. +check "#284 draws the collision flag, naming #257 across the spelling variance" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$morning_out" +check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \ + 'issueflow: #257: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$morning_out" +check "...while #253, the oldest carrier, is asked for nothing" 1 "" \ + grep -qF 'issueflow: #253: collision flag' <<<"$morning_out" +check "the TRIAGE.md triple chains the same way" 0 \ + 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$morning_out" +check "...through its tail" 0 'issueflow: #282: collision flag — triage=266' \ + printf '%s\n' "$morning_out" +check "the morning board draws exactly four collision flags" 0 "4" \ + flag_count collision "$morning_out" +# D3's corpus: the six `ready` non-members that raced the emptying gate. +for nonmember in 264 266 276 281 282 284; do + check "#$nonmember is flagged as a ready non-member under #249" 0 \ + "issueflow: #$nonmember: window flag — a ready non-member under #249" \ + printf '%s\n' "$morning_out" +done +check "the morning board draws exactly six window flags" 0 "6" \ + flag_count window "$morning_out" +check "...and never flags a gate member" 1 "" \ + grep -qE 'issueflow: #(253|257): window flag' <<<"$morning_out" +check "...nor the release issue that carries the window" 1 "" \ + grep -qF 'issueflow: #249: window flag' <<<"$morning_out" +# D1: comments only. Not "no unexpected edit" — no edit at all. +check "the whole replay writes no label and no state (D1)" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +check "...and no new label is ever proposed" 1 "" \ + grep -qE 'add-label (collision|window)' "$BOARD/edits" +check "the collision comment cites the rule it is asking for" 0 "" \ + grep -qF 'collision edge' "$BOARD/edits" +check "...and names #288 as its authority" 0 "" grep -qF '#288 makes it unconditional' "$BOARD/edits" +check "the window comment names #292's invariant" 0 "" \ + grep -qF "#292's invariant" "$BOARD/edits" +# shellcheck disable=SC2016 # backticks are the comment body's own Markdown +check "...and states the subset rule with its exemptions" 0 "" \ + grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits" +check "both comments carry idempotency markers (D4)" 0 "" \ + grep -qF ' +said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +jq -n --arg b " +said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_276_comments.json" +resweep_out="$(board_run)" +check "a standing collision is silent on the next sweep (D4)" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$resweep_out" +check "a standing window non-membership is silent too" 1 "" \ + grep -qF 'issueflow: #276: window flag' <<<"$resweep_out" +check "...while every other flag on the board still speaks" 0 "3" \ + flag_count collision "$resweep_out" +check "...and the window flags with it" 0 "5" \ + flag_count window "$resweep_out" +# The value-keyed marker's whole point: a state that CHANGED speaks, even +# though this family has already had its say on the thread (#252's A -> B -> A). +jq -n --arg b " +an older, different state" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +changed_out="$(board_run)" +check "a changed collision state speaks over this family's last word" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$changed_out" +# And a family only ever silences itself: the blocked-parse echo's marker +# lives on many of these threads and must not read as either flag's. +jq -n --arg b " +a different family entirely" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +foreign_out="$(board_run)" +check "another family's marker never silences the collision flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$foreign_out" + +# -- the post-ruling board draws nothing (D5's must-not-flag leg) ----------- +# The same issues after triage placed them: the TRIAGE.md triple chained +# oldest-first, the reconciler chain chained, and every one of them a gate +# member. Every flag above must go quiet, or the flag is reporting the fix. +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.' +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \ + 'Blocked by #253.' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 blocked 'TRIAGE.md — the epic task-list heading is literally `## Task list`' \ + 'Blocked by #264.' +board_issue 276 ready 'REVIEWER.md — the green-check precondition' +board_issue 281 ready 'LABELS.md — the attention row' +board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 blocked 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' \ + 'Blocked by #257.' +board_assemble 249 253 257 264 266 276 281 282 284 +ruled_out="$(board_run)" +check "the post-ruling board replays green" 0 "" test $? -eq 0 +check "...and draws no collision flag at all" 1 "" \ + grep -qF ': collision flag' <<<"$ruled_out" +check "...and no window flag either" 1 "" grep -qF ': window flag' <<<"$ruled_out" +check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ + printf '%s\n' "$ruled_out" + +# -- an emptied gate leaves the window flag dormant (test plan) ------------- +# The release stands `ready` because every declared member closed, so no +# member is on the open board. `Blocked by` is still in the body: a +# declaration is not a gate, an OPEN member is. +board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257.' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' +board_assemble 249 264 266 +empty_gate_out="$(board_run)" +check "an emptied gate leaves D3 dormant" 1 "" grep -qF ': window flag' <<<"$empty_gate_out" +check "...while the collision flag beside it is unaffected" 0 \ + 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$empty_gate_out" + +# -- the #284 shape end to end: a claimed carrier with its PR in flight ----- +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/graphql-open.json" +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' +board_assemble 253 284 +in_flight_out="$(board_run)" +check "a claimed carrier with an open PR still draws the newer issue's flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$in_flight_out" +check "...and the live claim is left exactly as it was" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" + # -- the invariant is enforced at the source, not remembered ---------------- # Staging only holds while every mutation goes through run(). A future call # site reaching gh directly would reopen this hole silently, so it is pinned From 5d13573c53605f567cce77b150ae2954f6fa334a Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:21:56 +0000 Subject: [PATCH 3/7] fixtures take the corpus correction and the three ruled cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage ruled all three questions (18:11Z) and corrected the corpus: at the 10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed` with NO open PR — #285 was not created until 10:49:16Z. The morning board takes that shape, which also makes the six `ready` non-members come out as six, and puts the blocked twin on the board rather than only in a decision probe. D2's parenthetical is struck: unblocked = open and not blocked, both flags, one definition. D3b is corrected in the same direction — a non-member `claimed` WITH an open PR is flagged too, because a non-member holding a builder and a review round is #292's competition realized, not a mild case of it. Nothing in the implementation moves: neither flag ever consulted PR liveness. Three cases added, so the fixtures pin the rulings and not the prose: - both carriers `claimed` with their own PRs open -> still flags. The ninety-three minutes from #285's creation to its merge are exactly when the struck parenthetical went silent on a live collision, which made the flag's firing a property of someone's workflow rather than of the board. - a fifteen-member declaration with every member closed -> D3 dormant, and the release issue never flagged as its own non-member. A gate declaration never empties; the precondition is its OPEN members, read off the board. - flagged -> resolved -> recreated unchanged -> silent, asserted as D4's stated boundary rather than left accidental. Plus today's board — the `blocked` sink, a `claimed` gate member, two `blocked` issues — which draws nothing. Verified live as well as in fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real board draws zero flags of either kind. --- test/issueflow-reconcile.test.sh | 118 +++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 23 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 9ea6878..032e02d 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2176,18 +2176,23 @@ board_run() { bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } -# The morning shape: ten open issues, six of them `ready` non-members, a -# standing gate, and one deliverable carried three times in two spellings. +# The morning shape, as the board actually stood at the 10:28:54Z mint: +# #253 `claimed` with no open PR (#285 was not created until 10:49:16Z), +# #257 `ready` since the evening before, #284 minted `ready` into both of +# them — six `ready` non-members against a standing gate, and one deliverable +# carried three times in two spellings. board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ - 'Blocked by #253, #257.' + 'Blocked by #253.' board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 -board_issue 257 claimed 'actions/issueflow-reconcile — a failed board read sweeps an empty board' '' 1 +board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_issue 276 ready 'REVIEWER.md — the green-check precondition' board_issue 281 ready 'LABELS.md — the attention row' -board_issue 282 ready 'TRIAGE.md — the two comment links come out' +# The blocked twin on the same key, on the board rather than in a decision +# probe: it is neither a collision flag nor one of the six. +board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' board_assemble 249 253 257 264 266 276 281 282 284 @@ -2205,22 +2210,24 @@ check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \ printf '%s\n' "$morning_out" check "...while #253, the oldest carrier, is asked for nothing" 1 "" \ grep -qF 'issueflow: #253: collision flag' <<<"$morning_out" -check "the TRIAGE.md triple chains the same way" 0 \ +check "the TRIAGE.md pair chains the same way" 0 \ 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$morning_out" -check "...through its tail" 0 'issueflow: #282: collision flag — triage=266' \ - printf '%s\n' "$morning_out" -check "the morning board draws exactly four collision flags" 0 "4" \ +check "...while the blocked twin beside them is the goal state, not a flag" 1 "" \ + grep -qF 'issueflow: #282: collision flag' <<<"$morning_out" +check "the morning board draws exactly three collision flags" 0 "3" \ flag_count collision "$morning_out" # D3's corpus: the six `ready` non-members that raced the emptying gate. -for nonmember in 264 266 276 281 282 284; do +for nonmember in 257 264 266 276 281 284; do check "#$nonmember is flagged as a ready non-member under #249" 0 \ "issueflow: #$nonmember: window flag — a ready non-member under #249" \ printf '%s\n' "$morning_out" done check "the morning board draws exactly six window flags" 0 "6" \ flag_count window "$morning_out" -check "...and never flags a gate member" 1 "" \ - grep -qE 'issueflow: #(253|257): window flag' <<<"$morning_out" +check "...and never flags the gate member holding the window open" 1 "" \ + grep -qF 'issueflow: #253: window flag' <<<"$morning_out" +check "...nor the blocked issue already placed behind something" 1 "" \ + grep -qF 'issueflow: #282: window flag' <<<"$morning_out" check "...nor the release issue that carries the window" 1 "" \ grep -qF 'issueflow: #249: window flag' <<<"$morning_out" # D1: comments only. Not "no unexpected edit" — no edit at all. @@ -2254,7 +2261,7 @@ check "a standing collision is silent on the next sweep (D4)" 1 "" \ grep -qF 'issueflow: #284: collision flag' <<<"$resweep_out" check "a standing window non-membership is silent too" 1 "" \ grep -qF 'issueflow: #276: window flag' <<<"$resweep_out" -check "...while every other flag on the board still speaks" 0 "3" \ +check "...while every other flag on the board still speaks" 0 "2" \ flag_count collision "$resweep_out" check "...and the window flags with it" 0 "5" \ flag_count window "$resweep_out" @@ -2306,34 +2313,99 @@ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ printf '%s\n' "$ruled_out" # -- an emptied gate leaves the window flag dormant (test plan) ------------- -# The release stands `ready` because every declared member closed, so no -# member is on the open board. `Blocked by` is still in the body: a -# declaration is not a gate, an OPEN member is. +# A gate DECLARATION never empties: #249 names fifteen members and still names +# fifteen after all fifteen close. So the precondition is the gate's OPEN +# members, not its parse — read straight off the board, which already is the +# open set. Under the declaration reading the release issue, now `ready`, is +# itself an open unblocked non-`epic` non-member, and D3 would flag the sink +# at the exact moment the window ends. board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ - 'Blocked by #253, #257.' + 'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_assemble 249 264 266 empty_gate_out="$(board_run)" -check "an emptied gate leaves D3 dormant" 1 "" grep -qF ': window flag' <<<"$empty_gate_out" +check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \ + grep -qF ': window flag' <<<"$empty_gate_out" +check "...and the release issue is never flagged as its own non-member" 1 "" \ + grep -qF 'issueflow: #249' <<<"$empty_gate_out" check "...while the collision flag beside it is unaffected" 0 \ 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$empty_gate_out" -# -- the #284 shape end to end: a claimed carrier with its PR in flight ----- +# -- both carriers claimed, both with their own PRs open (test plan) -------- +# The ninety-three minutes from #285's creation to its merge: under D2's +# struck parenthetical the live collision went silent for all of them, so +# whether the flag ever fired depended on where the sweep tick fell relative +# to a builder opening a PR. It fires on the board, and only on the board. printf '%s\n' \ - '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}},{"number":286,"body":"","closingIssuesReferences":{"nodes":[{"number":284}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$BOARD/graphql-open.json" board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 claimed 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' '' 1 +board_assemble 253 284 +both_claimed_out="$(board_run)" +check "two claimed carriers, both with PRs in flight, still draw the newer's flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$both_claimed_out" +check "...and both live claims are left exactly as they were" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +# The same board with the newer side `ready`, so the queue label is visibly +# the only input the flag has. +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' board_assemble 253 284 in_flight_out="$(board_run)" -check "a claimed carrier with an open PR still draws the newer issue's flag" 0 \ +check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$in_flight_out" -check "...and the live claim is left exactly as it was" 1 "" \ - grep -qF 'issue edit' "$BOARD/edits" + +# -- flagged, resolved, recreated unchanged: silent, and specified ---------- +# D4's boundary, asserted rather than left accidental. Nothing is posted at +# the resolution — D1 admits no comment there — so the thread's last word is +# still the state itself and an identical return says nothing new. #292 D2b +# owns the recurrence: a board state violating the window invariants is +# triage's to repair in the tick it is seen, and triage has already been told +# about this one. +jq -n --arg b " +flagged once" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +board_assemble_keep() { # board_assemble without wiping the seeded threads + local n + for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ + | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open_per_page_100.json" +} +board_assemble_keep 284 +resolved_out="$(board_run)" +check "the collision resolves when its carrier leaves the board" 1 "" \ + grep -qF ': collision flag' <<<"$resolved_out" +check "...and the resolution itself writes nothing at all" 1 "" test -s "$BOARD/edits" +board_assemble_keep 253 284 +recreated_out="$(board_run)" +check "an unchanged state recreated is silent — D4's stated boundary" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$recreated_out" + +# -- today's board draws nothing (the post-ruling shape, live) -------------- +# #249 the `blocked` sink, this issue `claimed` with no open PR and a gate +# member, #307 and #311 `blocked`. The `claimed` member is the case D3b would +# flag if membership were read wrong, which is why it is here. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/graphql-open.json" +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #293, #307.' +board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1 +board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \ + 'Blocked by #293.' +board_issue 311 blocked 'docs/CONSUMERS.md — a deliberate non-member' 'Blocked by #249.' +board_assemble 249 293 307 311 +today_out="$(board_run)" +check "today's board draws no collision flag" 1 "" grep -qF ': collision flag' <<<"$today_out" +check "...and no window flag: the claimed member is a member" 1 "" \ + grep -qF ': window flag' <<<"$today_out" +check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ + printf '%s\n' "$today_out" # -- the invariant is enforced at the source, not remembered ---------------- # Staging only holds while every mutation goes through run(). A future call From 919135db12c8964c061e605291702396b9bc2e19 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:54:52 +0000 Subject: [PATCH 4/7] the two flags mean one thing by unblocked, and a key set is a set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: deliverable_keys answered a multiset, so a `+` title whose segments normalize to one key made collision_flags find the issue adjacent to itself and chain it to its own number — the comment asked an issue to declare `Blocked by` itself, and two such carriers corrupted the chain between them. The keys are deduped where the set property belongs. B2: window_in_scope excluded only blocked/epic/post-merge, admitting `needs-triage` and label-less issues, so the sweep could add `needs-triage` to an issue and then tell it about a mint-time membership call in the same pass. Both flags now call one unblocked_claimable predicate — #293 D2 corrected gives one gloss on `unblocked` and D3b says D3 uses it. The window log line says "unblocked", not "ready": D3b corrected exactly that wording, and the flag fires on `claimed` too. --- .../issueflow-reconcile.sh | 60 ++++++++++++++----- test/issueflow-reconcile.test.sh | 4 +- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 94d2e64..41532c2 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -469,28 +469,56 @@ deliverable_keys() { # title on stdin -> its deliverable keys, one per line # segment: `TRIAGE.md + RELEASES.md` carries both keys. local segments=() IFS='+' read -r -a segments <<<"$prefix" - for segment in "${segments[@]}"; do - key="$(deliverable_key "$segment")" - [ -z "$key" ] || printf '%s\n' "$key" - done + # An issue answers a SET of keys, never a multiset. Normalization is + # many-to-one by design — `issueflow-reconcile.sh + issueflow-reconcile.test.sh` + # is one deliverable spelled twice, which is exactly the `+` shape D2 wrote + # the segment rule for — and a repeated key makes `collision_flags`' scan + # find the issue adjacent to itself, chaining it to its own number: the + # comment would ask #402 to declare `Blocked by #402`. It corrupts the chain + # between two such issues too, since each contributes two rows to one key. + # Deduping here rather than in the index keeps the set property with the + # function whose contract it is. + { for segment in "${segments[@]}"; do + key="$(deliverable_key "$segment")" + [ -z "$key" ] || printf '%s\n' "$key" + done + } | awk '!seen[$0]++' } -collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set +unblocked_claimable() { # $1 = comma-joined labels -> 0 when the issue is unblocked + # THE one definition of `unblocked`, because #293 gives both flags one word + # and one gloss on it: D2 as corrected reads "`unblocked` means open and not + # `blocked` — carrying `ready` or `claimed`, with or without an open PR", + # and D3b's first line says D3 uses D2's corrected `unblocked` and names the + # domain as the claimable set. Two spellings of one spec word is how the + # flags came to disagree about `needs-triage`, so there is one predicate and + # both flags call it. + # # `blocked` is out: a chained issue is the GOAL state of #288's rule, and - # flagging it would report the fix as the defect. `epic` and `post-merge` - # are out by #288 D6 — neither is picked by a builder — and they carry no - # queue label to admit them here anyway. + # flagging it would report the fix as the defect. Anything else without + # `ready` or `claimed` is out because it is not claimable — `needs-triage` + # and a label-less issue are not states a builder can pick up, and an + # unlabeled one is getting `needs-triage` from this very pass. `epic` and + # `post-merge` are out by #288 D6 and #292 D1 alike — neither is picked by a + # builder — and they carry no queue label to admit them here anyway. case ",$1," in *,blocked,*) return 1 ;; esac case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac return 1 } +collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set + unblocked_claimable "$1" +} + window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule - # `epic` and `post-merge` are outside the claimable set and exempt by name - # (#292 D1); `blocked` is already placed behind something and is what the - # non-member leg of the mint-time call writes. - case ",$1," in *,blocked,*|*,epic,*|*,post-merge,*) return 1 ;; esac - return 0 + # The same `unblocked`, not a second reading of it. Excluding only + # `blocked`/`epic`/`post-merge` here admitted `needs-triage` and a + # label-less issue, which left the sweep adding `needs-triage` to an + # unlabeled issue and then, in the same pass, telling it about a membership + # call made at mint time. Neither is claimable; #292's invariant is stated + # over the claimable set (D3b), and its exemptions say why — `epic` and + # `post-merge` are exempt *because neither is claimable*. + unblocked_claimable "$1" } collision_key_index() { # board records on stdin -> "keynumber" in scope @@ -760,7 +788,11 @@ same parse every \`blocked\` issue is gated on, echoed on that issue. *Comment only: nothing on this path writes a label or changes a state. The marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null - log "#$n: window flag — a ready non-member under $state" + # "unblocked", not "ready": the flag fires on `claimed` too, PR in + # flight or not, which is the one wording #293 D3b went out of its way + # to correct. The log line is read by a human deciding whether the + # sweep understood the board, so it says what the predicate says. + log "#$n: window flag — an unblocked non-member under $state" fi fi } diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 032e02d..be5b6de 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2218,8 +2218,8 @@ check "the morning board draws exactly three collision flags" 0 "3" \ flag_count collision "$morning_out" # D3's corpus: the six `ready` non-members that raced the emptying gate. for nonmember in 257 264 266 276 281 284; do - check "#$nonmember is flagged as a ready non-member under #249" 0 \ - "issueflow: #$nonmember: window flag — a ready non-member under #249" \ + check "#$nonmember is flagged as an unblocked non-member under #249" 0 \ + "issueflow: #$nonmember: window flag — an unblocked non-member under #249" \ printf '%s\n' "$morning_out" done check "the morning board draws exactly six window flags" 0 "6" \ From 4f852ee152f13f7763b973a7adfa013816b73fa1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:58:21 +0000 Subject: [PATCH 5/7] the normalization fixtures fail for the reasons they are named for check() matches its expectation as a substring, so a bare `issueflow-reconcile` row was satisfied by `issueflow-reconcile.test` too: the multi-extension rule stayed green under a normalization that strips only the last extension. keys_of brackets each key, and the no-em-dash row asserts its emptiness through grep rather than through an expectation check() cannot make. Beside them, the cases the two fixes are named for: a self-folding + title answers its key once and never chains an issue to its own number, and `needs-triage` and a label-less issue are outside both flags. --- test/issueflow-reconcile.test.sh | 94 ++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index be5b6de..19c415e 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2047,28 +2047,50 @@ check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 # -- the two board flags (#293): the deliverable key, normalized ------------ # The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix # matching is specified away (D2). These pin the normalization itself. -check "the em-dash prefix is the key" 0 "issueflow-reconcile" \ - deliverable_keys <<<"issueflow-reconcile — the ruling clock counts assigned" -check "a leading actions/ segment comes off" 0 "issueflow-reconcile" \ - deliverable_keys <<<"actions/issueflow-reconcile — a failed board read" -check "...and so does .github/" 0 "labeler" \ - deliverable_keys <<<".github/labeler.yml — one wrong answer left by D4" -check "...and lib/" 0 "attention" deliverable_keys <<<"lib/attention.sh — the target" -check "...and bin/" 0 "decide" deliverable_keys <<<"bin/decide.sh — the door" -check "every extension comes off, not just the last" 0 "issueflow-reconcile" \ - deliverable_keys <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" -check "the key folds case" 0 "triage" deliverable_keys <<<"TRIAGE.md — the bullet" -check "a + title carries both segments" 0 $'triage\nreleases' \ - deliverable_keys <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" +count_lines() { deliverable_keys | grep -c .; } +keys_of() { # title on stdin -> its keys, each bracketed so `check` matches exactly + # ANCHORED, because `check` compares its expectation as a substring: a bare + # `issueflow-reconcile` expectation is satisfied by `issueflow-reconcile.test` + # too, so the multi-extension row below stayed green under a normalization + # stripping only the last extension — it asserted nothing it was named for. + # Bracketing each key makes every row here fail for its own reason. + deliverable_keys | sed 's/.*/[&]/' +} +check "the em-dash prefix is the key" 0 "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile — the ruling clock counts assigned" +check "a leading actions/ segment comes off" 0 "[issueflow-reconcile]" \ + keys_of <<<"actions/issueflow-reconcile — a failed board read" +check "...and so does .github/" 0 "[labeler]" \ + keys_of <<<".github/labeler.yml — one wrong answer left by D4" +check "...and lib/" 0 "[attention]" keys_of <<<"lib/attention.sh — the target" +check "...and bin/" 0 "[decide]" keys_of <<<"bin/decide.sh — the door" +check "every extension comes off, not just the last" 0 "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" +check "the key folds case" 0 "[triage]" keys_of <<<"TRIAGE.md — the bullet" +check "a + title carries both segments" 0 $'[triage]\n[releases]' \ + keys_of <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" # A path segment the rule does not name stays part of the key: the strip list # is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own # deliverable and not the action it exercises. -check "an unlisted path segment stays in the key" 0 "test/issueflow-reconcile" \ - deliverable_keys <<<"test/issueflow-reconcile.test.sh — the pre-read" +check "an unlisted path segment stays in the key" 0 "[test/issueflow-reconcile]" \ + keys_of <<<"test/issueflow-reconcile.test.sh — the pre-read" +# One issue answers a SET. Normalization is many-to-one by design, so a `+` +# title can spell one deliverable twice — a deliverable and its test named +# together is the ordinary shape here, not an exotic one — and a repeated key +# makes the chain scan find the issue adjacent to ITSELF. +check "a + title whose segments normalize to one key answers that key once" 0 \ + "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" +check "...and answers it exactly once, not twice" 0 "1" \ + count_lines <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" +check "...and the path prefix folds onto the bare spelling the same way" 0 "1" \ + count_lines <<<"actions/issueflow-reconcile + issueflow-reconcile.sh — still one" # No em dash, no key. Inventing one out of prose is the guessing this sweep -# never does; the malformed title is triage's own contract to enforce. -check "a title with no em dash names no deliverable" 0 "" \ - deliverable_keys <<<"a title that names nothing" +# never does; the malformed title is triage's own contract to enforce. The +# emptiness is asserted through grep's exit, since `check` cannot assert an +# empty expectation. +check "a title with no em dash names no deliverable" 1 "" \ + grep -q . < <(deliverable_keys <<<"a title that names nothing") # -- the collision decision: a chain, never a fan (#288 D3) ------------------ # Sourced helpers, not `bash -c`: a subshell started with -c has none of these @@ -2113,6 +2135,20 @@ check "a multi-file title folds its collisions into one state" 0 \ $'295\treleases=292,triage=264' \ collision_chain \ <<<$'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three' +# ...and an issue can never be its own carrier. A `+` title whose segments +# normalize to one key contributed that key twice, and the chain scan, which +# reads adjacent rows within a key, then found the issue beside itself: the +# comment asked #402 to declare `Blocked by #402`. +check "a self-folding + title never chains an issue to its own number" 0 "" \ + collision_chain \ + <<<$'402\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable' +check "...and two such carriers chain once, to each other" 0 \ + $'284\tissueflow-reconcile=257' \ + collision_chain \ + <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' +check "...with the older carrier still asked for nothing" 1 "" \ + collision_flags_issue 257 \ + <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' # -- the window decision (#292 D1) ------------------------------------------ window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' @@ -2134,6 +2170,28 @@ check "the window carrier is never flagged as its own non-member" 1 "" \ check "no standing window means no flag at all" 0 "" \ window_flags "" "" <<<"$window_board" check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n' +# ONE reading of `unblocked` across both flags. D2 as corrected glosses the +# word as "carrying `ready` or `claimed`" and D3b says D3 uses that gloss and +# names the domain as the claimable set, so an issue that is `needs-triage` or +# carries no queue label at all is outside BOTH flags. Excluding only +# `blocked`/`epic`/`post-merge` admitted them, and the second case is the one +# that showed: the same pass adds `needs-triage` to an unlabeled issue and +# then tells it about a membership call made at mint time. +scope_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n400\tneeds-triage\tTRIAGE.md — not through the door yet\n401\t\tTRIAGE.md — no queue label at all\n402\tclaimed\tREVIEWER.md — claimable, and a non-member' +check "a needs-triage issue is not in the window flag's domain" 1 "" \ + window_flags_issue 400 253 249 <<<"$scope_board" +check "...nor is an issue carrying no queue label at all" 1 "" \ + window_flags_issue 401 253 249 <<<"$scope_board" +check "...while the claimable non-member beside them still flags" 0 "402" \ + window_flags "253" "249" <<<"$scope_board" +# The same word, asserted through the other flag, so the two can never drift +# apart again without a red. +check "the collision flag reads that word identically" 0 "" \ + collision_chain <<<$'400\tneeds-triage\tTRIAGE.md — one\n401\t\tTRIAGE.md — two' +check "...and both flags answer one shared predicate" 1 "" \ + unblocked_claimable "needs-triage" +check "...which admits ready and claimed, and nothing else" 0 "" \ + unblocked_claimable "claimed,scope:labels" # -- the 2026-08-04 board, replayed whole (D5) ------------------------------ # The corpus the operator ruled on. Both flags are decided over the WHOLE From 13605cf4b684c08fb7a2e8d8448d1d7728d3353e Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:00:48 +0000 Subject: [PATCH 6/7] pin family scoping in the direction that carries it, and D3b on the window side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family-scoping fixture asserted that a foreign marker does not SILENCE the flag, which a family-blind grep satisfies too. The property that is load-bearing is the other one: a foreign family late on the thread must not make the flag re-post. Made family-blind, the sweep now reds. D3b says a claimed non-member is flagged whether or not it has an open PR, and the fixtures covered that for the collision flag only — the window side, which is the flag the 18:11Z correction was about, had no case at all. --- test/issueflow-reconcile.test.sh | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 19c415e..1f39de6 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2341,6 +2341,24 @@ foreign_out="$(board_run)" check "another family's marker never silences the collision flag" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=257' \ printf '%s\n' "$foreign_out" +# The direction that is actually load-bearing, and that the case above cannot +# reach: a foreign family's marker landing AFTER this flag's own must not make +# the flag speak again. Family-blind, "the last marker on the thread" is the +# blocked-parse echo's, which is not this state's marker, and the flag +# re-posts a comment that already stands — the noise D4's dedup exists to +# stop, on the one thread where three families all have something to say. +jq -n --arg b " +this flag's own last word" \ + --arg c " +a different family, later on the thread" \ + '[{"user": {"login": "sweep-bot"}, "body": $b}, + {"user": {"login": "sweep-bot"}, "body": $c}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +later_foreign_out="$(board_run)" +check "a foreign family's LATER marker never makes the flag re-post" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$later_foreign_out" +check "...while every other collision on the board still speaks" 0 "2" \ + flag_count collision "$later_foreign_out" # -- the post-ruling board draws nothing (D5's must-not-flag leg) ----------- # The same issues after triage placed them: the TRIAGE.md triple chained @@ -2419,6 +2437,33 @@ check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$in_flight_out" +# -- D3b's headline case, on the WINDOW side (acceptance criterion) ---------- +# The criterion says a `claimed` non-member is flagged whether or not it has +# an open PR, and it is the line the 18:11Z ruling turned on — triage had +# excluded the open-PR case at 18:06Z and corrected it five minutes later. +# The collision fixtures above cover the PR-liveness question for their flag; +# this covers it for the other one. #292's charge against the third state is +# that a non-member competes with gate members for builders, and a non-member +# holding a builder AND a review round is that competition realized. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/graphql-open.json" +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253.' +board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1 +board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1 +board_assemble 249 253 402 +nonmember_pr_out="$(board_run)" +check "a claimed non-member with an open PR still draws the window flag" 0 \ + 'issueflow: #402: window flag — an unblocked non-member under #249' \ + printf '%s\n' "$nonmember_pr_out" +check "...and the gate member beside it, also claimed with a PR, is not" 1 "" \ + grep -qF 'issueflow: #253: window flag' <<<"$nonmember_pr_out" +check "...and the live claim is left exactly as it was" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +check "...one window flag on the board, and only one" 0 "1" \ + flag_count window "$nonmember_pr_out" + # -- flagged, resolved, recreated unchanged: silent, and specified ---------- # D4's boundary, asserted rather than left accidental. Nothing is posted at # the resolution — D1 admits no comment there — so the thread's last word is From 20e35b15da672ebd55f4c6c53630fe0a79c6d064 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:03:24 +0000 Subject: [PATCH 7/7] the release note says unblocked, not ready D3b corrected the window flag to fire on `claimed` too, PR in flight or not; the fragment still described it as a `ready` non-member, which is the reading the ruling struck. --- changelog.d/293.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/293.md b/changelog.d/293.md index 2ef2ebb..40a6d63 100644 --- a/changelog.d/293.md +++ b/changelog.d/293.md @@ -4,10 +4,10 @@ open, unblocked issues whose titles name one deliverable draw a comment naming the newer's owed `Blocked by` edge. Keys normalize, so `actions/x` and `x` are one deliverable (#288). -- The sweep now flags a `ready` non-member during a standing release window, - naming the window's invariant. The gate is read from the release issue's - own `Blocked by` declarations, and an emptied gate leaves it dormant - (#292). +- The sweep now flags an unblocked non-member during a standing release + window, naming the window's invariant. `claimed` counts, PR in flight or + not. The gate is read from the release issue's own `Blocked by` + declarations, and an emptied gate leaves it dormant (#292). - Both flags are advisory: comments only, no label write and no state change, deduped against each family's last word on the thread so a standing state re-sweeps silently (#293).