diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a4c92a82..7b7af610 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "human-fsm", "source": "./plugins/human-fsm", "description": "Slash commands for the human's side of the issueβ†’PR pipeline FSM: read the next ai:ready PR and check the vetter's verdict against the diff and the issue it closes, read the next ai:close-candidate flag and check the producer's reason against the issue and the code it claims about, then rule on a close-candidate, reject, raise a design question, or keep an issue open. Every input comes from pr-review-report; none writes GitHub state itself.", - "version": "0.10.0", + "version": "0.11.0", "author": { "name": "Rain Open Source Software Ltd" }, "keywords": [ "rainlanguage", diff --git a/CLAUDE.md b/CLAUDE.md index 14735a02..3fc679cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,9 +116,9 @@ transition functions: | `sol-toolchain-audit ` | the CHECK on the rule above (#203): every Solidity check the run ran, against the toolchain `sol-toolchain` named for that checkout β€” `matched` / `skew` / `unasked` / `unmatchable`, plus the audit's own two blind spots (an invocation naming no checkout, one entering the working directory's flake without naming it). Read out of the run's OWN trace, where the answer and the `nix develop` that followed are both recorded, so it costs no network read and cannot stop a run. Exit 3 on skew or unasked; `campaign-run.sh` appends it to the run log | | `unvetted_close_candidates` (MCP) | the vetter's second state-load: which producer close-candidate flags need judging this run | | `record_close_candidate_verdict` (MCP) | the vetter's issue write: uphold (queued for the human) or reject (strips the flag β†’ producer's queue) | -| `human-rule ""` | the HUMAN's PR ruling: label + a head-sha-pinned `πŸ‘€ human` comment (supersedes any prior human ruling). Park-or-delegate is chosen HERE (#111) and belongs to two verbs only: `reject` REQUIRES `--rework ""` / `--rework-file `, which emits the trusted `Rework note` work order in the same call; `design` takes exactly one of `--rework` / `--park` (the explicit pure park); a bare reject/design refuses, and `close-candidate` refuses both flags β€” the verb is its own disposition, and its refusal names `human-close` | +| `human-rule ""` | the HUMAN's PR ruling: label + a head-sha-pinned `πŸ‘€ human` comment (supersedes any prior human ruling). Park-or-delegate is chosen HERE (#111) and belongs to two verbs only: `reject` REQUIRES `--rework ""` / `--rework-file `, which emits the trusted `Rework note` work order in the same call; `design` takes exactly one of `--rework` / `--park` (the explicit pure park); a bare reject/design refuses; there is no close verb β€” deciding a close IS executing one, which is `human-close` (#213) | | `human-rule-issue ""` | the HUMAN's issue ruling: adds `keep-open`; pinned to the live close-candidate flag, or to the issue as filed. `reject` / `design` take the same disposition flags on the same terms; `close-candidate` / `keep-open` refuse them | -| `human-close ""` | the HUMAN's TERMINAL edge on either subject: rule `close-candidate`, retire the pending `ai:close-candidate`, close β€” ONE transition (#94) | +| `human-close ""` | the HUMAN's TERMINAL edge on either subject: record the `close-candidate` ruling comment, close, retire the pending `ai:close-candidate` β€” ONE transition, in that tear-safe order (#94, #213) | | `record-close-candidate-verdict …` | the vetter's flag verdict, also as a subcommand β€” `human-rule-issue`'s stranded-flag refusal names it, and a terminal has no MCP | | `require-qa-block` | the QA-GUIDE Β§8 gate on PR-open: refuses a `gh pr create` whose body lacks the evidence block. Wired as a PreToolUse `Bash` hook, so it binds every session β€” including the ones with no MCP surface, which is the only population still reaching for `gh pr create` now `open_pr` exists | | `open_pr` (MCP) | the PRODUCER'S OUTPUT EDGE: open the PR for a pushed branch, assigned, with a typed `closes` linkage and a body `carries_qa_block` has already accepted β€” and a RESULT carrying the PR number, so the trace holds `{agent, repo, issue, PR}` as typed data | @@ -307,16 +307,15 @@ it finds is a different PR's code. - **Human decisions protect AUTHORSHIP, and a ruling is an INPUT (#111).** No AI actor ever writes a `human:*` label, and none removes one as an OVERRIDE of the human: a native `APPROVED`/`CHANGES_REQUESTED` review, a `πŸ‘€ human` ruling - pinned to the CURRENT head, an absolutely-parking label - (`human:close-candidate`, retired `human:reject`), or an un-executed - `human:design` is never overwritten by the vetter β€” `--record-verdict` refuses - (exit 3), closing the TOCTOU race. But absolute parking was the ruled-out - overreaction: a ruling is an input the machine EXECUTES. A delegated - `human:design` (its trusted `Rework note` pinned at head) is the producer's - work order; once executed (the push moves the head past the pin) the PR is - un-vetted and the verdict that re-judges it clears the spent label β€” - clearing-by-execution, the completion of what the human asked, not an - override. Pure parking survives only as the explicit `--park` spelling. + pinned to the CURRENT head, an absolutely-parking label (the retired + `human:reject`), or an un-executed `human:design` is never overwritten by the + vetter β€” `--record-verdict` refuses (exit 3), closing the TOCTOU race. But + absolute parking was the ruled-out overreaction: a ruling is an input the + machine EXECUTES. A delegated `human:design` (its trusted `Rework note` pinned + at head) is the producer's work order; once executed (the push moves the head + past the pin) the PR is un-vetted and the verdict that re-judges it clears the + spent label β€” clearing-by-execution, the completion of what the human asked, + not an override. Pure parking survives only as the explicit `--park` spelling. - **A reject is ONE state, and the ruler rides on the comment (#133).** `ai:reject` and `human:reject` demanded the same move from the same actor, so they are one state: `ai:reject`, whoever ruled. The label says what the work @@ -356,23 +355,29 @@ it finds is a different PR's code. checked against a diff that is not there is not checked, and a guard that silently stops firing is this very failure one level up, inside the thing built to prevent it. -- **The human's TERMINAL edge is a transition too.** `gh issue close` knows - nothing about the FSM, so a hand-close left `ai:close-candidate` attached: 74 - closed subjects org-wide (55 issues, 19 PRs) carried it when #94 was filed, a - state no modeled transition produces. `human-close` is that edge as ONE - transition β€” comment, `human:close-candidate`, retire the pending flag, close, - in that order, with the close LAST because a closed subject reads as moot to - every ruling plan. It retires `ai:close-candidate` and no other `ai:*` label: - that one means "a human still has to ACT", and closing is the act. Chaining a - ruling tool with a Bash `gh close` would put the order and the flag clear in a - prompt, which is exactly the half that was wrong every time. -- **A ruling names its POPULATION.** `ai:close-candidate` covers two separately - sized sets β€” `closeCandidateIssues` (a producer claim on an issue) and - `lanes.vetter-verdicts.ai:close-candidate` (the vetter's own verdict on a PR). - Every human transition takes a full `owner/repo#n`; where it can act on either - it READS the subject's `url` rather than trusting which command was typed, and - where it cannot it refuses by naming what was referenced and printing the - command that fits β€” in both directions. +- **The human's TERMINAL edge is a transition too β€” decide+do, no state between + (#213).** `gh issue close` knows nothing about the FSM, so a hand-close left + `ai:close-candidate` attached: 74 closed subjects org-wide (55 issues, 19 PRs) + carried it when #94 was filed, a state no modeled transition produces. + `human-close` is that edge as ONE transition β€” the pinned `πŸ‘€ human` ruling + comment, the close, then the flag retirement, in that order, writing NO label: + the comment alone is the durable intent, and a tear between it and the close + is the torn-close signature the vetter's state-load completes. It retires + `ai:close-candidate` and no other `ai:*` label: that one means "a human still + has to ACT", and closing is the act. Chaining a ruling tool with a Bash + `gh close` would put the order and the flag clear in a prompt, which is + exactly the half that was wrong every time. +- **A ruling names its POPULATION.** `ai:close-candidate` is ONE machine over + both subject types (#211/#212), but on a PR the label has two distinct + origins: a PRODUCER FLAG (a claim the vetter judges, whose reject returns the + PR to the vet queue) or the VETTER'S OWN `close` verdict (already judged β€” the + human's, via `human-close`; `record-close-candidate-verdict` refuses it + because no producer claim exists there to judge). The inventories are the + mixed `closeCandidateUnvetted` / `closeCandidateUpheld` arrays β€” no lane holds + a flagged PR. Every human transition takes a full `owner/repo#n`; where it can + act on either subject it READS the subject's `url` rather than trusting which + command was typed, and where it cannot it refuses by naming what was + referenced and printing the command that fits β€” in both directions. - **A human ruling is a transition too, and it pins to what it ruled on.** `human-rule` / `human-rule-issue` are the only sanctioned way to write a `human:*` label: raw `gh issue edit --add-label` binds to nothing and, on an diff --git a/README.md b/README.md index 546eae7d..d1e9fee6 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,15 @@ is interactive (a human merges on their explicit per-PR word). See stateDiagram-v2 direction LR state "open issue" as issue - state "ai:close-candidate (issue)" as icand - state "close-candidate Β· upheld" as iupheld + state "ai:close-candidate Β· un-vetted flag (issue or PR)" as icand + state "close-candidate Β· upheld (issue or PR)" as iupheld state "un-vetted PR" as unvetted state "ai:ready" as ready state "ai:reject β€” needs rework" as reject state "ai:design" as design - state "ai:close-candidate (PR)" as close state "ai:blocked-on" as bon state "run ended Β· infra down" as infradown state "human:design" as hdesign - state "human:close-candidate" as hclose state "human:keep-open (issue)" as ikeep state "presentable Β· in queue" as queue state "approved Β· human review" as approved @@ -34,22 +32,29 @@ stateDiagram-v2 [*] --> issue issue --> unvetted : producer opens PR - %% issue close-candidate lifecycle β€” the vetter's SECOND subject. A flag is a CLAIM, and it is - %% vetted before a human is asked to act on it (a bad flag asks a human to destroy work). + %% close-candidate lifecycle β€” the vetter's SECOND subject, over BOTH subject types + %% (#211/#212): a flag is a CLAIM whatever carries it, and it is vetted before a human is + %% asked to act on it (a bad flag asks a human to destroy work). The flag PARKS its subject β€” + %% a flagged issue leaves the producer backlog, a flagged PR leaves the PR lanes β€” and the + %% upheld state is ONE mixed human inbox, reached by an upheld flag on either subject type or + %% by the vetter's own PR `close` verdict. issue --> icand : producer flag-close-candidate + unvetted --> icand : producer flag-close-candidate (a PR) icand --> iupheld : vetter uphold Β· evidence holds - icand --> issue : vetter reject Β· strips the flag β†’ back to uncovered + icand --> issue : vetter reject (issue) Β· strips the flag β†’ back to uncovered + icand --> unvetted : vetter reject (PR) Β· strips the flag β†’ back to the vet queue icand --> icand : producer re-flags (new evidence) β†’ un-vetted again - iupheld --> [*] : human-close Β· rules, retires the flag, closes - icand --> hclose : human-rule-issue close-candidate (sacred) + iupheld --> [*] : human-close Β· records the ruling, closes, retires the flag (issue or PR, by lookup) + icand --> [*] : human-close Β· the same decide+do edge, taken early icand --> ikeep : human-rule-issue keep-open (sacred Β· clears the flag) ikeep --> [*] : stays open, never re-flagged - %% vet lifecycle β€” the vetter is the sole verdict transition fn + %% vet lifecycle β€” the vetter is the sole verdict transition fn. The `close` verdict lands in + %% the SAME upheld inbox an upheld flag does (#212): one judgement, two routes, one state. unvetted --> ready : vetter record-verdict unvetted --> reject : vetter record-verdict unvetted --> design : vetter record-verdict - unvetted --> close : vetter record-verdict + unvetted --> iupheld : vetter record-verdict close ready --> unvetted : head moves (producer fix) Β· verdict no longer current %% ready β†’ the human merge queue @@ -61,7 +66,7 @@ stateDiagram-v2 %% ruled (#133) and whatever the ground: a code rework, a linkage repair (#135 retired %% ai:relink β€” a linkage error is a reject whose note names the reference), or a close. reject --> unvetted : producer reworks β†’ head moves - reject --> close : producer judges it not worth doing + reject --> icand : producer flag-close-candidate Β· judged not worth doing reject --> unvetted : linkage reject Β· producer weaken-closes Closesβ†’Refs %% blocked hand-off. blocked-on sits with the VETTER (#161): the flag carries typed @@ -88,13 +93,11 @@ stateDiagram-v2 ready --> reject : human-rule reject --rework Β· ruling + work order, one call ready --> hdesign : human-rule design --rework Β· delegated work order ready --> hdesign : human-rule design --park Β· explicit park - ready --> hclose : human-rule close-candidate + ready --> [*] : human-close Β· decide+do, one transition (#213) hdesign --> unvetted : producer executes the order β†’ push Β· the re-vet clears the spent label hdesign --> [*] : parked Β· exit is the human superseding their own ruling - hclose --> [*] : human-close Β· the human's terminal edge, retires the flag too design --> [*] : human design ruling - close --> [*] : human-close (a PR) Β· retires the flag too merged --> [*] ``` @@ -106,17 +109,17 @@ this an actual finite state machine rather than a picture of one. ### The human's transitions Every actor's hand-off is a labelled transition β€” including the human's. That -was not true until #86: `human:reject`, `human:design`, `human:close-candidate` -and `human:keep-open` appeared in the binary only as strings it **read and -refused on**, so the one actor whose decisions everything else treats as sacred -was also the only one improvising raw `gh issue edit --add-label`. - -| Transition | The move it makes | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `human-rule ""` | PR ruling β€” `reject` / `design` / `close-candidate`, pinned to the **head sha**; park-or-delegate is chosen HERE (#111), and the flags that choose it are `reject`'s and `design`'s alone: `reject` REQUIRES `--rework ""`, `design` takes exactly one of `--rework` / `--park` | -| `human-rule-issue "<…>"` | issue ruling β€” those three plus `keep-open`, pinned to the **live flag** or to the **issue as filed**; `reject` / `design` carry the same disposition flags, `close-candidate` / `keep-open` refuse them | -| `human-close ""` | the **terminal** edge, on either subject: rule, retire the pending flag, close β€” one transition | -| `record-close-candidate-verdict ` | the vetter's flag verdict, now reachable from a terminal too (the refusal above names it) | +was not true until #86: the `human:*` ruling labels appeared in the binary only +as strings it **read and refused on**, so the one actor whose decisions +everything else treats as sacred was also the only one improvising raw +`gh issue edit --add-label`. + +| Transition | The move it makes | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `human-rule ""` | PR ruling β€” `reject` / `design`, pinned to the **head sha**; park-or-delegate is chosen HERE (#111): `reject` REQUIRES `--rework ""`, `design` takes exactly one of `--rework` / `--park` | +| `human-rule-issue "<…>"` | issue ruling β€” those two plus `keep-open`, pinned to the **live flag** or to the **issue as filed**; `reject` / `design` carry the same disposition flags, `keep-open` refuses them (the verb is its own disposition) | +| `human-close ""` | the **terminal** edge, on either subject: record the ruling, close, retire the pending flag β€” one transition | +| `record-close-candidate-verdict ` | the vetter's flag verdict, on either subject type, now reachable from a terminal too (the refusal above names it) | **A ruling can delegate, not only park (#111).** The ruling and the work order are two records with their own shapes β€” the ruling is provenance (_a human @@ -128,20 +131,20 @@ the ruling, in the **exact prefix form** the producer's same anchor as the ruling so the two go stale together. The tool owns the prefix, so the failure the issue measured β€” a hand-typed `Rework Note` silently parking a PR meant to be delegated β€” is unconstructible. `reject` **requires** -the order (a reject IS a send-back; one not worth reworking is a -close-candidate, not a park); `design` takes exactly one of `--rework` / -`--park`; and a bare call to either **refuses** rather than parking by accident -β€” pure parking is an explicit spelling, never the default meaning of a ruling. -The other two verbs take neither flag and say so: `close-candidate` is a decided -close whose refusal names `human-close` as its consumer, and `keep-open` is a -standing constraint β€” the verb is its own disposition, so a second spelling of -it would only be a way to mean something else by accident. What the human -namespace protects is **authorship only**: no AI actor writes a `human:*` label, -and none removes one as an override β€” but a ruling is an input the machine -executes, so the producer's state-load picks a delegated `human:design` up as a -work order (`worklist` routes it `rework-ruling`), the push moves the head, and -the vetter's next verdict clears the spent label as the **completion** of the -ruling through the ordinary rework β†’ un-vetted β†’ re-vet flow. +the order (a reject IS a send-back; one not worth reworking is a `human-close`, +not a park); `design` takes exactly one of `--rework` / `--park`; and a bare +call to either **refuses** rather than parking by accident β€” pure parking is an +explicit spelling, never the default meaning of a ruling. `keep-open` takes +neither flag and says so: it is a standing constraint β€” the verb is its own +disposition, so a second spelling of it would only be a way to mean something +else by accident. A close is not a ruling verb at all: deciding one IS executing +one (`human-close`, #213). What the human namespace protects is **authorship +only**: no AI actor writes a `human:*` label, and none removes one as an +override β€” but a ruling is an input the machine executes, so the producer's +state-load picks a delegated `human:design` up as a work order (`worklist` +routes it `rework-ruling`), the push moves the head, and the vetter's next +verdict clears the spent label as the **completion** of the ruling through the +ordinary rework β†’ un-vetted β†’ re-vet flow. The vocabularies are not a second list: they **are** `HUMAN_DECISION_LABELS` (PRs) and `HUMAN_RULING_LABELS` (issues), the same constants every AI transition @@ -223,7 +226,7 @@ carries no human decision at all and every AI actor is free to move it). #### The terminal edge β€” `human-close` -`iupheld --> [*]` and `hclose --> [*]` are edges of the diagram above, and until +`iupheld --> [*]` and `icand --> [*]` are edges of the diagram above, and until #94 they had no transition function, so the only way to take them was raw `gh issue close` β€” which knows nothing about the machine. The cost was measured: when #94 was filed, **74** terminal subjects across the org (55 issues, 19 PRs) @@ -233,52 +236,48 @@ tool β€” a flag `reject` on `rain.dia#42` β€” came out clean. That asymmetry is argument: the transition with a tool was consistent, the one without it was wrong every time. -`human-close ""` is that edge as **one** transition: the -`πŸ‘€ human` comment, `human:close-candidate`, the retirement of the pending flag, -then the close. +`human-close ""` is that edge as **one** transition β€” +decide+do, no state between (#213): the pinned `πŸ‘€ human` ruling comment, the +close, then the retirement of the pending flag. It writes no label; the comment +alone is the durable intent. - **It is one transition, not a command that chains two.** A slash command that - called `human-rule-issue` and then `gh issue close` would put the ORDER and - the flag clear in a prompt β€” unenforced, untested, free to drift. Here the - order is a tested property, and the close is **last** because a closed subject - reads as moot to every ruling plan: closing first would make the labels - permanently unreachable and a retry would report "nothing to do" over a - half-written transition. + called a ruling tool and then `gh issue close` would put the ORDER and the + flag clear in a prompt β€” unenforced, untested, free to drift. Here the order + is a tested property, chosen so no tear is invisible: a tear after the comment + leaves the torn-close signature (ruling on the record, subject open) that the + vetter's state-load recognises and completes itself, and a tear after the + close leaves a closed subject whose stale flag the same state-load's + closed-side sweep removes. - **It retires `ai:close-candidate` and no other `ai:*` label.** That one label means "a human still has to ACT on this subject" β€” on an issue the producer's pending claim, on a PR the vetter's pending `close` verdict β€” and closing IS that act. Every other `ai:*` label is a judgement about the code and survives, exactly as the rulings leave it. -- **The ruling alone still leaves the flag standing.** While the subject is open - the flag is the live pending claim and `closeCandidateUpheld` still counts it; - only the terminal act retires it. The two are deliberately different, and that - difference is the edge. - **It resolves PR-or-issue by lookup**, from the subject's own `url`, so one - `owner/repo#n` reference cannot act on the wrong one of the two populations - that share the label name. + `owner/repo#n` reference cannot act on the wrong population. - **On an already-closed subject it clears a stale flag and writes no ruling.** The state had no exit and now has one β€” the machine has no dead ends β€” but the human's close is already on the record as GitHub's own close event, and a `πŸ‘€ human` reason written today would date the decision to today. Manufactured provenance is worse than none. -#### One label, two populations - -`ai:close-candidate` names two separately-sized sets: `closeCandidateIssues` -counts **issues** (a producer CLAIM awaiting judgement) and -`lanes.vetter-verdicts.ai:close-candidate` counts **PRs** (the vetter's own -`close` verdict). Every human transition therefore takes a full `owner/repo#n`, -and where it can act on either subject it READS which one it has rather than -trusting which command was typed. Where it cannot, it refuses by naming what was -actually referenced and handing over the command that fits β€” in **both** -directions, because a refusal a caller cannot act on sends them straight back to -raw `gh`: - -- `record-close-candidate-verdict` pointed at a PR used to answer "no trusted - producer close-candidate flag β€” nothing to judge", which reads as _this PR has - no human path at all_ and was recorded as exactly that misreading. It now says - the subject is a pull request, why a flag verdict cannot apply to one, and - names the three moves that do. +#### One label, one machine, two subject types + +`ai:close-candidate` covers issues AND pull requests (#211/#212): a producer +flag on either subject is the vetter's to judge (`closeCandidateUnvetted`), and +an upheld flag or the vetter's own PR `close` verdict is the human's to dispose +of, in the ONE mixed `closeCandidateUpheld` inbox. Every human transition takes +a full `owner/repo#n`, and where it can act on either subject it READS which one +it has rather than trusting which command was typed. Where it cannot, it refuses +by naming what was actually referenced and handing over the command that fits β€” +in **both** directions, because a refusal a caller cannot act on sends them +straight back to raw `gh`: + +- `record-close-candidate-verdict` pointed at a PR whose label is the vetter's + OWN `close` verdict refuses β€” there is no producer claim for a second AI + judgement to judge β€” and names the three moves that do apply. A producer FLAG + on a PR is judged exactly as one on an issue. - `human-rule` pointed at an issue used to answer "`gh pr view` failed β€” not writing on incomplete data", which reads as an API outage. It now names the subject and prints the `human-rule-issue` line, carrying the same ruling @@ -377,9 +376,9 @@ server is the vetter's **only** tool surface. | `pr_checkout` | local read-only clone of the PR head, so the `audit` skill has source β€” returns the `dir` AND the `head` sha it produced, or errors having left nothing behind | | `record_verdict` | the PR write: `ai:` label + `πŸ€– ai:vetter` comment bound to the head sha, stamped with the vet protocol, carrying the cost β€” refused unless `covered` accounts for every changed file | | `clone_release` | dispose of a checkout it is finished with (guarded β€” see below) | -| `unvetted_close_candidates` | state-load: ONE PAGE of the producer close-candidate flags to judge, each with its `flagAt` + stated evidence | +| `unvetted_close_candidates` | state-load: ONE PAGE of the producer close-candidate flags to judge β€” issues AND PRs (#211) β€” each with its `flagAt` + stated evidence | | `close_candidate_context` | read one flag: the issue's title/body/`createdAt`/labels, the full flag body, any prior verdicts, and `citationEvidence` β€” the machine's read of the cited change's own diff | -| `record_close_candidate_verdict` | the issue write: `uphold` (flag stands, queued for the human) or `reject` (strips `ai:close-candidate`) | +| `record_close_candidate_verdict` | the flag write, either subject type: `uphold` (flag stands, queued for the human) or `reject` (strips `ai:close-candidate`) | There is a **third profile**, and it is the answer to "CLI subcommand or MCP tool?" for the human: `pr-review-report mcp --profile human` (wired by @@ -1336,49 +1335,54 @@ grouped into four lanes so the dashboard can show where PRs pile up: next mover β€” the state-load clears the flag the run after every typed dep merges/closes and the PR re-enters vetting fresh ("clear when deps merge" is vetter action, not human polling). -- **vetter-verdicts** β€” `ai:ready`, `ai:reject`, `ai:design`, - `ai:close-candidate`, plus the RETIRED `ai:relink` for as long as any PR still - carries it (#135). +- **vetter-verdicts** β€” `ai:ready`, `ai:reject`, `ai:design`, plus the RETIRED + `ai:relink` for as long as any PR still carries it (#135). + `ai:close-candidate` is NOT a lane state (#211/#212): the flag hands the PR to + the close-candidate machinery, which inventories it in the mixed + `closeCandidateUnvetted` / `closeCandidateUpheld` arrays β€” the PR-side mirror + of a flagged issue leaving `uncoveredIssues`. - **producer-blocked** β€” the RETIRED `ai:blocked-deploy` (#162) and `ai:blocked-infra` (#108), each for as long as any PR still carries it. The blocked-deploy residue is deliberately **not** vet-lifecycle: the #164 clearance reads exactly `ai:blocked-on` typed refs, which this residue does not have β€” its exit is an eyes-on human pass that re-flags each PR blocked-on its repo's migration, or unblocks it outright. -- **human-decisions** β€” `human:design`, `human:close-candidate`, plus the - RETIRED `human:reject` for as long as any PR still carries it (#133). That - last count is the migration's progress meter: `migrate-reject` moves those PRs - to `ai:reject` and it only ever shrinks. +- **human-decisions** β€” `human:design`, plus the RETIRED `human:reject` for as + long as any PR still carries it (#133). That last count is the migration's + progress meter: `migrate-reject` moves those PRs to `ai:reject` and it only + ever shrinks. Each PR is bucketed **once**, by FSM precedence (a human decision dominates a stale `ai:*` label). The legacy `states` / `leaks` / `counts` keys are preserved unchanged; `lanes` and the additive `counts` keys (`reject`, `relink` (retired, -counting down to zero), `closeCandidatePrs`, `humanReject`, `humanDesign`, -`humanCloseCandidate`, `unvetted`) are the full-machine view the dashboard -renders. +counting down to zero), `humanReject`, `humanDesign`, `unvetted`) are the +full-machine view the dashboard renders. -The ISSUE close-candidate lifecycle carries two further additive counts, which -split the existing `closeCandidateIssues` (unchanged: every issue carrying the -label) by vet state: +The close-candidate lifecycle carries two further additive counts over BOTH +subject types (#211/#212) β€” the legacy `closeCandidateIssues` keeps its +issues-only meaning, and these are the machine's own split: - **`closeCandidateUnvetted`** β€” flagged, no `human:*` ruling, and no vetter verdict against the CURRENT flag. This is the vetter's inbox, and it is the same set `unvetted-close-candidates` returns, so the dashboard and the vetter cannot disagree about its size. -- **`closeCandidateUpheld`** β€” the vetter judged the evidence sound, so the flag - genuinely awaits human triage. +- **`closeCandidateUpheld`** β€” the vetter judged this should close: an upheld + flag on either subject type, or the vetter's own PR `close` verdict β€” the ONE + mixed inbox the human disposes of. A **rejected** flag needs no count: the vetter strips `ai:close-candidate`, so -the issue leaves this set entirely and reappears under `uncoveredIssues` β€” the -producer's queue β€” which is exactly the behaviour a rejection should have. +the subject leaves this set entirely β€” an issue reappears under +`uncoveredIssues`, a PR under its lane β€” which is exactly the behaviour a +rejection should have. Both are emitted exactly as `closeCandidateIssues` and `uncoveredIssues` are: the key appears **twice** β€” at the top level as the ITEM ARRAY and under `counts` as its length. The dashboard's state boxes are click-through, so a count without its array renders a number that then lists nothing. Arrays and counts are derived from a single document, so `counts.X == X.len()` holds by -construction. These are ISSUE states, so β€” like `closeCandidateIssues` β€” they -are **not** in `lanes`, which groups PRs. +construction. Both arrays MIX subject types and each item carries its resolved +`url`, so they are **not** in `lanes` β€” and a flagged PR is in no lane either: +one subject, one state. ### How OLD the work is: `ages` @@ -1668,7 +1672,7 @@ to carry: every one of those arrays is built from a `gh search` / `gh issue view` payload that already returns the url, so no extra call is made for it. The human-readable `human-queue` prints the same carried url, for the same reason β€” it used to rebuild `…/pull/`, and printed that for -close-candidate **issues**. +close-candidate flags on either subject type. That is enforced by a single type (`SubjectRef`) with a single serialiser, not by several structs agreeing: adding or removing a field is a compile error at diff --git a/campaign-prompt.txt b/campaign-prompt.txt index 461f5d64..09e17639 100644 --- a/campaign-prompt.txt +++ b/campaign-prompt.txt @@ -79,7 +79,7 @@ Each run: 6. Conventions: one issue per PR; minimal diff; one-contract-per-.sol where relevant; comments describe CURRENT behavior only (no audit/history narration); account for pre-existing CI reds (env *_RPC_URL fork tests, the cachix-action infra flake, deploy-dependent constant tests, the rainix-sol-static one-contract check, solver e2e-fork) and NEVER claim a red fixed without reading its log. If a fix changes deployed bytecode (interpreter words, parser rules, pinned deploy constants), open the PR with regenerated artifacts and NO deploy flag of any kind: on a repo already on the split release lifecycle that is the whole job β€” no merge waits on a deploy (#162). If the repo still has the legacy premerge deploy shape (a prod-pin check that goes red until an out-of-band deploy), that is step 3b's MIGRATION case (iv) β€” the PR is flagged blocked-on the repo's lifecycle migration; never write a redeploy marker and never deploy. EPHEMERAL, CLEAN CLONES: a work clone must be left CLEAN and then RELEASED. (a) Keep temp files OUT of the clone β€” every PR body, cached tool output and other throwaway file goes in THIS RUN'S SCRATCH DIR, `{{SCRATCH_DIR}}` (e.g. `{{SCRATCH_DIR}}/pr-body-.md`), never inside the checkout and never loose in `{{WORK_DIR}}` or the install dir β€” the loose ones are nobody's to delete and six weeks of them piled up (`covered.json`, `covered-set.json`, `covered_set.json`, `covered-keys.json`, … seven filenames for one notion), so invent no path of your own. It already exists and is deleted when the run ends, so nothing you leave there needs cleaning up. WRITE THE PATH OUT IN FULL: a redirection whose target contains a `$` is refused by the harness as unevaluable shell expansion, so `... > {{SCRATCH_DIR}}/wl.json` works while the identical redirect written through the `$SCRATCH_DIR` variable does not β€” that variable is exported for TOOLS you invoke to read, never for a redirect target. Redirection into `{{WORK_DIR}}` and the install dir is otherwise permitted β€” including INTO a work clone, which is how a render harness gets the stub components it mounts the real one against β€” and refused OUTSIDE both, by a message that unhelpfully lists a directory you did not name as allowed; that message is wrong about the reason and the answer is this dir, never a hunt for another writable path. Nothing refuses a throwaway file dropped loose in a clone or the install dir, so (a) is a rule you keep, not one the harness keeps for you. (b) Before a clone's work counts as submitted, `git -C status --porcelain` MUST be empty: commit + push every REAL change, and if the dirt is a build/tooling artifact (`.pre-commit-config.yaml`, lockfile churn, `out/`/`target/`/generated output) gitignore it as part of your PR (or `.git/info/exclude` locally) β€” a clone that can't be made clean is a hygiene bug to FIX, not leave. (c) The MOMENT the work is safely on GitHub (PR opened, 3b/3d fix or conflict-resolution pushed, 3b(iv) migration flag posted), RELEASE the clone with the `mcp__fsm__clone_release` TOOL β€” `{"clone": ""}`. That tool IS the deletion; never `rm -rf` a clone (`rm -rf` is denied by a prefix-matched rule that also matches every work-clone path, which is why clones accumulated to 195 GB and filled the disk). It refuses a clone holding UNPUSHED commits outright β€” push them, that refusal is protecting real work. If it refuses for UNCOMMITTED changes, read the dirt it prints: commit + push anything real, and only once you have confirmed the rest is build/tooling output, re-call with `discard_uncommitted: true`. Never leave clones behind β€” accumulated local clones are the box's disk-fill hazard, and the work is always recoverable from GitHub (a later run re-clones fresh; step 3b(ii) already handles a missing dir). 7. NEVER merge and NEVER close/reopen/edit ANY issue; a human reviews/approves/merges/closes interactively. You take NO production action at all: you deploy NOTHING in any form (#162 β€” deploys are a human's separate manual workflow dispatch, decoupled from merges; the old producer redeploy path is retired). `gh pr merge` is FORBIDDEN in every form β€” landing happens interactively on the human's explicit word; your step-2z duty ends at GREEN-READY. The other org-mutating actions this routine takes are `mcp__fsm__open_pr` (PR-open is a TOOL now, never `gh pr create`), `gh pr comment`, and `mcp__fsm__push` (PUSHING IS A TOOL now, never a bare `git push`; it is NON-FORCE by construction and its result is the typed record of the work) of fix commits, `--allow-empty` CI re-triggers, OR step-3d `git merge origin/` conflict-resolution commits to YOUR OWN open PR branches (steps 3b/3d), OR step-3e `gh api graphql` CodeRabbit review-thread replies + resolutions on YOUR OWN PRs (`addPullRequestReviewThreadReply` / `resolveReviewThread` β€” never silent resolves). Never `gh pr close` / `gh pr edit`, never `gh issue close`/`gh issue edit`/`gh issue reopen` (permission-denied β€” do not route around them; the ONLY sanctioned writes to a PR BODY are the two producer body repairs, `mcp__fsm__repair_qa_block` (appends QA-GUIDE Β§8's evidence block) and `mcp__fsm__weaken_closes` (weakens `Closes #N` to `Refs #N`), each of which leaves every other byte alone β€” step 3's BODY-ONLY REJECT and LINKAGE REJECT clauses); the ONLY issue mutations you make are (a) `pr-review-report flag-close-candidate` (step 7a β€” applies the `ai:close-candidate` label + a reason comment, the sanctioned close-signal path), (b) a plain `gh issue comment` for the step-4c re-verification notes, and (c) `gh issue create` ONLY for a repo's missing lifecycle-migration issue (step 3b (iv)) β€” no other issue creation; never invoke `gh workflow run` yourself and no other CI/artifact trigger β€” there is NO deploy you may run in any form (#162; the old `pr-review-report deploy` route is a human's manual tool, never yours) β€” never force-push in ANY form or spelling (`--force`, `-f`, `--force-with-lease`, `--force-if-includes`, a leading-`+` refspec, `HEAD:branch --force`) β€” INCLUDING routed through `git -C push …` β€” and never `git reset --hard` in ANY form (including `git -C reset --hard`); a plain fast-forward `mcp__fsm__push` of a new commit (a code fix, an `--allow-empty` flake re-trigger, or a step-3d `git merge origin/` conflict-resolution commit) is the only push allowed to a PR branch. CRITICAL: do NOT route a forbidden gh command through `nix shell … --command gh` or `nix run nixpkgs#gh` to evade the deny-list β€” gh is on PATH, use it BARE; the wrapper trick is itself forbidden (it once let a `gh workflow run` slip past the deny-list). If you believe an issue should be closed (already fixed, invalid, duplicate, won't-fix), do NOT run `gh issue close` β€” it is hard-blocked and every attempt is a wasted, denied command. The instant you conclude "this should close", your ONE action is to flag it via `pr-review-report flag-close-candidate` (step 7a) β€” that flag IS your close-signal. Flag EVERY such issue (the human relies on the `ai:close-candidate` queue being complete), then move on; never `gh issue close`. 7a. CLOSE-CANDIDATE RECORD (the only channel for "this issue should be closed"): the record IS a GitHub comment on the issue β€” there is no local file. ONLY record an issue when its resolution has GENUINELY LANDED β€” the fix is already on `main` (a MERGED commit/PR that no `Closes #N` keyword will auto-close), or the issue is invalid / a duplicate / won't-fix. An issue merely COVERED BY AN OPEN PR is NOT a close-candidate (an open PR has not landed and self-closes its issues when it merges); an OPEN PR or open dependency is never sufficient evidence. Before recording, verify the issue is still OPEN (`gh issue view -R rainlanguage/ --json state`) and that its comments do not ALREADY carry a close-candidate note (`gh issue view --json comments`) β€” never post a duplicate. Then comment exactly one line block: - `pr-review-report flag-close-candidate ": "` β€” the SOLE sanctioned close-candidate path. It applies the `ai:close-candidate` LABEL + a trusted `πŸ€– ai:producer` reason comment, REFUSES (exit 3) if the issue carries `human:keep-open`/`human:close-candidate` (the human's decision is sacred), and is idempotent (re-flag = no-op). Never use raw `gh issue edit`/`gh issue comment` for this β€” the subcommand is the one path (it labels the issue, which you otherwise cannot). RECENCY IS ENFORCED (already-fixed-on-main only): the tool REFUSES (exit 4) a reason that cites no MERGED commit sha / PR number, and refuses again if that commit/PR date does NOT post-date the issue's `createdAt`. "This code is on main today" is NOT the claim β€” the claim is "a change landed that resolved this AFTER it was reported"; code that predates the report cannot be the fix. So before flagging, identify the change that actually fixed it (`git log -S'' -- ` / the merged PR) and cite THAT; if you cannot find one, the issue is probably still live β€” or the honest category is `invalid`/`duplicate`/`wont-fix`, which are judgements and are not date-gated. CITATION EVIDENCE IS APPENDED TO YOUR FLAG, AND YOU READ IT BACK: the tool fetches the cited PR/commit's OWN diff and writes onto the comment how many files it touches, its `+a/-d` on every path your reason names, and which symbols your reason names that its changed lines do not contain. It refuses on exactly ONE reading of that line, and only this one (exit 5): the reason named path(s) or symbol(s), and the cited change contains NOT ONE of them β€” no named path in its file list, no named symbol in its changed lines. A partial miss NEVER refuses β€” a sound reason routinely names current-main symbols the cited change never touched, and a fix by deletion leaves its evidence on the removed side β€” but it is printed where you will see it, and a citation that CANNOT be what you claimed is now visible the moment you write it. `rain.dia#22` is the case: the flag said merged PR #48 "landed testRoundTripEmpty (line 27) and testRoundTrip31Bytes (line 32)" when #48's touch on that file is `+2/-2` (an import rewrite) and PR #33 is what added those tests. If the line says the paths you named are NOT TOUCHED BY IT, or names the very symbols your claim turns on as absent, you have cited the wrong change β€” find the right one and re-flag with it rather than leaving a provenance trail that sends the next reader to a PR that did not fix this. DO NOT CITE THE SHA YOU READ MAIN AT. `already-fixed-on-main: …Foo.svelte:40 at abc1234` looks like it carries a commit anchor and does not: `abc1234` dates the TREE YOU LOOKED AT, not the change that fixed anything, and because every recent main sha post-dates the issue the recency check passes without meaning a thing. That is a bare `file:line` in disguise, which is the claim shape this gate refuses outright. It is the shape of every commit-anchored flag on record β€” `raindex#588`/`#574`/`#573`/`#570` all cite `bb83031`, a build-script rename touching `foundry.toml` and `script/Build.sol`, as the fix for four unrelated Svelte UI bugs; `raindex#928` cites `7ba0fa8` in the words "tauri-app/ existed AT 7ba0fa8", naming the state BEFORE the deletion it credits. Cite the change that landed the fix (`git log -S'' -- `), and if its path or name has since been renamed, cite a path or symbol THAT CHANGE ITSELF contains. REACHABILITY (all already-fixed-on-main evidence, not only GUI): the cited code must be shown to RUN β€” a caller chain, a test that exercises it, or the screenshot below. A definition with no construction site, a symbol nothing outside its own file and tests references, or anything inside a commented-out block is NOT evidence that behavior changed (a `successMessage` constant can exist and never reach the DOM; a chart component can be 100% commented out). GUI-CLAIM PROOF (mandatory): when the `already-fixed-on-main` evidence is about RENDERED behavior a USER SEES (a `.svelte` component, a visible element, user-visible styling in packages/webapp|packages/ui-components, a non-`.svelte` service whose output is displayed, or a `site/` dashboard section), a `file:line`/grep is NOT sufficient β€” a code path can exist without producing the claimed rendered result (an author can wire `readableMsg` and still render a blank toast). RENDER the current-main behavior via step 5's harness and prove it: push `shots/-issue-.png` to the raindex `pr-screenshots` branch and EMBED the raw URL in the flag reason (`already-fixed-on-main: ; screenshot `), READING your own PNG to confirm it shows the claimed result before you flag. If the flow genuinely cannot be rendered in the harness (e.g. a wallet-gated path it can't reach), state that explicitly in the reason instead (`already-fixed-on-main: ; screenshot-not-possible: `) β€” where the reason is THE ATTEMPT THAT FAILED (what you rendered, where it stopped), never a judgement of what the render would have shown. "Pixel-identical", "no visible effect", "cosmetic only" are the CONCLUSIONS the render exists to support or refute, so they waive the evidence with the claim it was supposed to produce; the vetter rejects a why-not of that shape here exactly as it does on a PR (2026-07-29: cyclo.site#431's "rendered output is pixel-identical" waiver, human-rejected). An unattempted render is not "not possible", it is not done. A bare code claim about GUI behavior with NEITHER a screenshot NOR a FAILED-ATTEMPT why-not is not a valid close-candidate β€” do not flag it; render it, or name the attempt that could not. + `pr-review-report flag-close-candidate ": "` β€” the SOLE sanctioned close-candidate path. It applies the `ai:close-candidate` LABEL + a trusted `πŸ€– ai:producer` reason comment, REFUSES (exit 3) if the subject carries any `human:*` ruling label (the human's decision is sacred), and is idempotent (re-flag = no-op). Never use raw `gh issue edit`/`gh issue comment` for this β€” the subcommand is the one path (it labels the issue, which you otherwise cannot). RECENCY IS ENFORCED (already-fixed-on-main only): the tool REFUSES (exit 4) a reason that cites no MERGED commit sha / PR number, and refuses again if that commit/PR date does NOT post-date the issue's `createdAt`. "This code is on main today" is NOT the claim β€” the claim is "a change landed that resolved this AFTER it was reported"; code that predates the report cannot be the fix. So before flagging, identify the change that actually fixed it (`git log -S'' -- ` / the merged PR) and cite THAT; if you cannot find one, the issue is probably still live β€” or the honest category is `invalid`/`duplicate`/`wont-fix`, which are judgements and are not date-gated. CITATION EVIDENCE IS APPENDED TO YOUR FLAG, AND YOU READ IT BACK: the tool fetches the cited PR/commit's OWN diff and writes onto the comment how many files it touches, its `+a/-d` on every path your reason names, and which symbols your reason names that its changed lines do not contain. It refuses on exactly ONE reading of that line, and only this one (exit 5): the reason named path(s) or symbol(s), and the cited change contains NOT ONE of them β€” no named path in its file list, no named symbol in its changed lines. A partial miss NEVER refuses β€” a sound reason routinely names current-main symbols the cited change never touched, and a fix by deletion leaves its evidence on the removed side β€” but it is printed where you will see it, and a citation that CANNOT be what you claimed is now visible the moment you write it. `rain.dia#22` is the case: the flag said merged PR #48 "landed testRoundTripEmpty (line 27) and testRoundTrip31Bytes (line 32)" when #48's touch on that file is `+2/-2` (an import rewrite) and PR #33 is what added those tests. If the line says the paths you named are NOT TOUCHED BY IT, or names the very symbols your claim turns on as absent, you have cited the wrong change β€” find the right one and re-flag with it rather than leaving a provenance trail that sends the next reader to a PR that did not fix this. DO NOT CITE THE SHA YOU READ MAIN AT. `already-fixed-on-main: …Foo.svelte:40 at abc1234` looks like it carries a commit anchor and does not: `abc1234` dates the TREE YOU LOOKED AT, not the change that fixed anything, and because every recent main sha post-dates the issue the recency check passes without meaning a thing. That is a bare `file:line` in disguise, which is the claim shape this gate refuses outright. It is the shape of every commit-anchored flag on record β€” `raindex#588`/`#574`/`#573`/`#570` all cite `bb83031`, a build-script rename touching `foundry.toml` and `script/Build.sol`, as the fix for four unrelated Svelte UI bugs; `raindex#928` cites `7ba0fa8` in the words "tauri-app/ existed AT 7ba0fa8", naming the state BEFORE the deletion it credits. Cite the change that landed the fix (`git log -S'' -- `), and if its path or name has since been renamed, cite a path or symbol THAT CHANGE ITSELF contains. REACHABILITY (all already-fixed-on-main evidence, not only GUI): the cited code must be shown to RUN β€” a caller chain, a test that exercises it, or the screenshot below. A definition with no construction site, a symbol nothing outside its own file and tests references, or anything inside a commented-out block is NOT evidence that behavior changed (a `successMessage` constant can exist and never reach the DOM; a chart component can be 100% commented out). GUI-CLAIM PROOF (mandatory): when the `already-fixed-on-main` evidence is about RENDERED behavior a USER SEES (a `.svelte` component, a visible element, user-visible styling in packages/webapp|packages/ui-components, a non-`.svelte` service whose output is displayed, or a `site/` dashboard section), a `file:line`/grep is NOT sufficient β€” a code path can exist without producing the claimed rendered result (an author can wire `readableMsg` and still render a blank toast). RENDER the current-main behavior via step 5's harness and prove it: push `shots/-issue-.png` to the raindex `pr-screenshots` branch and EMBED the raw URL in the flag reason (`already-fixed-on-main: ; screenshot `), READING your own PNG to confirm it shows the claimed result before you flag. If the flow genuinely cannot be rendered in the harness (e.g. a wallet-gated path it can't reach), state that explicitly in the reason instead (`already-fixed-on-main: ; screenshot-not-possible: `) β€” where the reason is THE ATTEMPT THAT FAILED (what you rendered, where it stopped), never a judgement of what the render would have shown. "Pixel-identical", "no visible effect", "cosmetic only" are the CONCLUSIONS the render exists to support or refute, so they waive the evidence with the claim it was supposed to produce; the vetter rejects a why-not of that shape here exactly as it does on a PR (2026-07-29: cyclo.site#431's "rendered output is pixel-identical" waiver, human-rejected). An unattempted render is not "not possible", it is not done. A bare code claim about GUI behavior with NEITHER a screenshot NOR a FAILED-ATTEMPT why-not is not a valid close-candidate β€” do not flag it; render it, or name the attempt that could not. The `ai:close-candidate` LABEL is the queue key β€” the human's triage view is `gh search issues {{OWNER_FLAGS}} --state open --label ai:close-candidate` (minus `human:keep-open`), which is self-cleaning: a closed/fixed issue drops out automatically and a `human:keep-open` ruling excludes it for good. You still NEVER close; flagging is the entire action. 8. Skip issues that are too large / need design / aren't reproducible / are research spikes / are pure umbrella trackers (brief note). If a fix won't converge after a focused attempt, skip it; don't grind for an hour on one. If a coverage target is already mutation-saturated, report it and open NO PR (never a no-test or scan-record PR). 9. Take tractable, genuinely-uncovered issues UP TO THE RUN'S REMAINING BUDGET (3 items total per run, shared with rework/conflict/migration work β€” if greenness work already used the budget, this step does nothing this run). Quality over quantity: every PR goes to a human reviewer, and a run that opens one PR a human merges beats one that opens five they have to triage. Because earlier runs/sessions have already PR'd most tractable issues, expect each run to mostly find things already covered (dedup) and do little β€” that's correct; focus on newly-filed issues and ones a prior run skipped. diff --git a/plugins/human-fsm/.claude-plugin/plugin.json b/plugins/human-fsm/.claude-plugin/plugin.json index 8e90ea4a..4e4b2ba8 100644 --- a/plugins/human-fsm/.claude-plugin/plugin.json +++ b/plugins/human-fsm/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "human-fsm", "displayName": "Human FSM transitions", - "version": "0.10.0", + "version": "0.11.0", "description": "Slash commands for the human's side of the rainlanguage issueβ†’PR pipeline finite state machine: the reads that precede a ruling and the transitions that make it. Every input arrives from pr-review-report β€” each ruling as one subcommand, /nr and /ncc as typed MCP reads β€” and the guards (vocabulary, mandatory note, provenance anchor, stranded-flag refusal, subject type, terminal-is-moot, idempotence) live in that binary, not here.", "author": { "name": "Rain Open Source Software Ltd", diff --git a/plugins/human-fsm/commands/close-candidate.md b/plugins/human-fsm/commands/close-candidate.md index 0cc9c48f..b2391d86 100644 --- a/plugins/human-fsm/commands/close-candidate.md +++ b/plugins/human-fsm/commands/close-candidate.md @@ -26,11 +26,13 @@ Run **exactly one** command, and nothing else. pr-review-report human-close ``` -One transition, in a fail-safe order the binary owns: post the `πŸ‘€ human` ruling -comment pinned to the head sha (a PR) or to the live producer flag (an issue), -apply `human:close-candidate`, retire the pending `ai:close-candidate`, then -close. It resolves PR-or-issue **by lookup**, so the reference alone decides -which population it acts on and neither you nor this command ever guesses. +One transition β€” decide+do, no state between: post the `πŸ‘€ human` ruling comment +pinned to the head sha (a PR) or to the live producer flag (an issue), close, +then retire the pending `ai:close-candidate`. The comment alone is the durable +intent; a tear between it and the close is completed by the vetter's own +state-load. It resolves PR-or-issue **by lookup**, so the reference alone +decides which population it acts on and neither you nor this command ever +guesses. On a subject that is already closed it clears a stale `ai:close-candidate` and writes no ruling β€” the close is already on the record, and a reason dated today @@ -42,10 +44,13 @@ would not be one. pr-review-report record-close-candidate-verdict reject ``` -This drops `ai:close-candidate` and returns the issue to the producer's -uncovered queue, which may re-flag it on better evidence. It is **issue-only** β€” -a producer flag is a claim on an issue; on a PR the same label is the vetter's -own `close` verdict, and the tool will say so and name the moves that do apply. +This drops `ai:close-candidate` and returns the subject to its own queue β€” an +issue to the producer's uncovered backlog, a PR to the vet queue β€” either free +to be re-flagged on better evidence. It judges a producer FLAG on either subject +type; the one PR it refuses is the one whose label is the vetter's own `close` +verdict (no producer claim exists there to judge β€” that PR is yours to `uphold` +above, or to send back with `/reject`), and the refusal says so and names the +moves that do apply. If you mean "this must **never** be flagged again" rather than "not on this evidence", that is the sacred ruling `/keep-open`, not this. diff --git a/pr-review-report-rs/src/main.rs b/pr-review-report-rs/src/main.rs index b785895e..67e1740d 100644 --- a/pr-review-report-rs/src/main.rs +++ b/pr-review-report-rs/src/main.rs @@ -1261,27 +1261,24 @@ const RETIRED_HUMAN_REJECT_LABEL: &str = "human:reject"; /// This constant is the WRITE-protected set, and it dominates the label-based views (the queue's /// presentability filter, [`classify_lane`]'s human-decisions lane). Which of them still PARK a PR /// against AI actors, and on what condition, is [`pr_human_sacred`]'s question, not this set's: -/// `human:design` parks only while un-executed, the two in [`PR_PARKED_HUMAN_LABELS`] park +/// `human:design` parks only while un-executed, the one in [`PR_PARKED_HUMAN_LABELS`] parks /// absolutely. /// -/// `human:reject` appears only as the RETIRED name β€” see [`RETIRED_HUMAN_REJECT_LABEL`]. -const PR_SACRED_LABELS: [&str; 3] = [ - "human:design", - "human:close-candidate", - RETIRED_HUMAN_REJECT_LABEL, -]; +/// `human:reject` appears only as the RETIRED name β€” see [`RETIRED_HUMAN_REJECT_LABEL`]. A close +/// ruling carries NO label at all (#213): deciding a close and executing it are one transition, +/// `human-close`, whose durable record is the pinned `πŸ‘€ human` comment β€” a state between the two +/// would split one actor's decide/do on a terminal act that is a single API call. +const PR_SACRED_LABELS: [&str; 2] = ["human:design", RETIRED_HUMAN_REJECT_LABEL]; /// The `human:*` labels that park a PR ABSOLUTELY β€” no AI actor acts on the PR while one is -/// present, whatever the comments say. Each is absolute because its consuming transition is the -/// human's own, so there is no execution for an AI actor to complete: -/// -/// - `human:close-candidate` is a DECIDED close awaiting the human's terminal edge (`human-close`) -/// β€” closing is the human's act by standing ruling, so the machine has nothing to execute; -/// - the retired `human:reject` is exited by `migrate-reject` and nothing else. +/// present, whatever the comments say. One entry: the retired `human:reject`, exited by +/// `migrate-reject` and nothing else. /// /// `human:design` is deliberately NOT here: it is authorship-protected like the rest, but an -/// executed delegation ([`Delegation::Executed`]) is consumable β€” see [`pr_human_sacred`]. -const PR_PARKED_HUMAN_LABELS: [&str; 2] = ["human:close-candidate", RETIRED_HUMAN_REJECT_LABEL]; +/// executed delegation ([`Delegation::Executed`]) is consumable β€” see [`pr_human_sacred`]. There +/// is no absolutely-parked live state at all: "human authority" means the machine executes what +/// the human RECORDED (#213), so every `human:*` state has a consuming transition. +const PR_PARKED_HUMAN_LABELS: [&str; 1] = [RETIRED_HUMAN_REJECT_LABEL]; /// A `gh search` result carries a human override label (which beats an `ai:ready` label) when any of /// its labels is in [`PR_SACRED_LABELS`]. Derived from the constant so the sacred set and the @@ -10432,7 +10429,6 @@ fn label_meta(label: &str) -> (&'static str, &'static str) { // a description get silently rewritten org-wide by whatever writes it next. "human:reject" => ("b60205", "Human reviewer: needs rework"), "human:design" => ("3d1a78", "Human maintainer: design question (sacred)"), - "human:close-candidate" => ("555555", "Human maintainer: close candidate (sacred)"), "human:keep-open" => ( "0e8a16", "Human decision: keep open (excluded from the close-candidate queue)", @@ -12825,12 +12821,10 @@ enum CloseFlagPlan { /// /// It is also the ISSUE-side ruling vocabulary: `human-rule-issue` derives its verbs from this array /// (see [`human_rulings`]), so a state added here gains its transition rather than needing one. -const HUMAN_RULING_LABELS: [&str; 4] = [ - "human:reject", - "human:design", - "human:close-candidate", - "human:keep-open", -]; +/// A CLOSE ruling has no entry (#213): deciding a close and executing it are one transition, +/// `human-close`, recorded by the pinned `πŸ‘€ human` comment alone β€” a label between the decision +/// and the act would be a state splitting one actor's decide/do. +const HUMAN_RULING_LABELS: [&str; 3] = ["human:reject", "human:design", "human:keep-open"]; /// The PR RULING vocabulary β€” the labels `human-rule` may write on a pull request, and the twin of /// [`HUMAN_RULING_LABELS`] on the other subject. @@ -12841,17 +12835,19 @@ const HUMAN_RULING_LABELS: [&str; 4] = [ /// describes. The attribute did not go away: it moved to the sha-pinned `πŸ‘€ human` comment the same /// transition posts, where [`human_ruled_at_head`] reads it and where the vetter cannot write it. /// -/// The two `human:*` entries stay in that namespace because they are not one half of a split: no -/// `ai:design` / `ai:close-candidate` on a PR asks the same thing of the same actor as its `human:` -/// twin β€” the AI's raises a question for the human; the human's records the ANSWER, which either -/// delegates the execution (`design --rework`, the producer's work order) or explicitly parks -/// (`design --park`), and `human:close-candidate` awaits the human's own terminal edge (#111). +/// The one `human:*` entry stays in that namespace because it is not one half of a split: +/// `ai:design` on a PR does not ask the same thing of the same actor as `human:design` β€” the AI's +/// raises a question for the human; the human's records the ANSWER, which either delegates the +/// execution (`design --rework`, the producer's work order) or explicitly parks (`design --park`). +/// A CLOSE is not here at all (#213): it is `human-close`'s decide+do, never a ruling verb, for +/// the reason #133 gives β€” a state that models an attribute (or a phase) is a machine bigger than +/// the thing it describes. /// /// This is deliberately NOT the same array as [`HUMAN_DECISION_LABELS`] any more, and the invariant /// that mattered is kept as a property instead of an identity: every label a ruling can write must be /// one [`classify_lane`] buckets into a real lane, else the ruling is a leak. That is asserted /// directly, over both arrays. -const HUMAN_PR_RULING_LABELS: [&str; 3] = ["ai:reject", "human:design", "human:close-candidate"]; +const HUMAN_PR_RULING_LABELS: [&str; 2] = ["ai:reject", "human:design"]; /// Does any human ruling sit on this issue? Takes label NAMES, which callers already have. fn has_human_ruling(labels: &[String]) -> bool { @@ -12926,13 +12922,89 @@ fn last_close_candidate_flag(issue: &Value) -> Option<(String, String)> { .next_back() } -/// The most-recent trusted `πŸ€– ai:vetter` comment on an ISSUE recording a close-candidate verdict. +/// The most-recent trusted `πŸ€– ai:vetter` comment recording a close-candidate verdict. Works on +/// either subject type β€” the flag lifecycle is one machine whatever carries the label (#211), and +/// the comment shape (`Reviewed close-candidate @: …`) is the same on both. fn last_cc_vetter_comment(issue: &Value) -> Option { trusted_comments(issue, Some("πŸ€– ai:vetter")) .into_iter() .rfind(|b| b.contains("Reviewed close-candidate @")) } +/// Is this subject snapshot actually a PULL REQUEST? `gh issue view ` and `gh search issues` +/// both happily answer for one, and the subject's own `url` is the discriminator every +/// mixed-population surface here reads (#94) β€” never the number, which says nothing. +fn subject_is_pr(detail: &Value) -> bool { + detail + .get("url") + .and_then(|u| u.as_str()) + .is_some_and(|u| u.contains("/pull/")) +} + +/// PURE: does one trusted vetter comment body record the PR-side `close` VERDICT β€” a +/// `Reviewed : close …` line? The cc-flag twin (`Reviewed close-candidate @…`) is excluded by +/// its own token, so a flag verdict never reads as a PR close verdict or vice versa. +/// +/// Deliberately NOT head-currency-checked: [`classify_lane`] keeps every non-`ready` verdict label +/// in force whatever the head did, and this reader exists to explain the same label, so the two +/// must answer alike or one surface calls "vetter verdict" what the other calls "stranded". +fn pr_close_verdict_line(body: &str) -> bool { + body.lines().any(|l| { + let Some(rest) = l.trim().strip_prefix("Reviewed ") else { + return false; + }; + if rest.starts_with("close-candidate @") { + return false; + } + matches!( + rest.split_once(": "), + Some((_, tail)) if tail.split_whitespace().next() == Some("close") + ) + }) +} + +/// The most-recent trusted `πŸ€– ai:vetter` PR-side `close` verdict on this subject, as +/// `(createdAt, body)` β€” the shape [`last_close_candidate_flag`] uses, and for the same consumer: +/// the timestamp is the FIFO queue key where a flag's own timestamp does not exist, because the +/// vetter's verdict IS what put this subject in the human's close queue. +fn last_pr_close_verdict(detail: &Value) -> Option<(String, String)> { + detail + .get("comments") + .and_then(|c| c.as_array()) + .into_iter() + .flatten() + .filter(|c| author_login(c) == Some(TRUSTED_AUTHOR)) + .filter_map(|c| { + let body = c.get("body").and_then(|b| b.as_str())?; + if !body.starts_with("πŸ€– ai:vetter") || !pr_close_verdict_line(body) { + return None; + } + let at = c.get("createdAt").and_then(|t| t.as_str()).unwrap_or(""); + Some((at.to_string(), body.to_string())) + }) + .next_back() +} + +/// PURE: has a human recorded the CLOSE ruling on this subject? A trusted `πŸ‘€ human` comment with +/// a `Ruled …: close-candidate` line β€” [`human_rule_comment`]'s own shape for the ruling word +/// `human-close` records. +/// +/// On a subject that is still OPEN this is the torn-close signature (#213): `human-close` writes +/// the comment first and closes second, so a tear between the two leaves exactly this β€” the +/// decision on the record, the act not landed. Execution needs no further judgement (the recorded +/// ruling is the authority), which is why [`cc_gate`] routes it to completion instead of +/// presenting the subject to anyone. +fn human_close_ruled(detail: &Value) -> bool { + trusted_comments(detail, Some(HUMAN_MARKER)) + .iter() + .any(|b| { + b.lines().any(|l| { + let t = l.trim(); + t.starts_with("Ruled ") && t.contains(": close-candidate") + }) + }) +} + /// The issue-side analogue of [`vetted_at_head`]: a flag is vetted only when the vetter's own /// comment pins the CURRENT flag's timestamp. The `ai:close-candidate` label alone is not a verdict /// β€” it is the producer's claim. A RE-flag (new comment, new timestamp) un-vets, exactly as a moved @@ -12971,12 +13043,13 @@ fn cc_verdict_comment(flag_at: &str, verdict: &str, note: &str, citation: Option /// guard-before-write shape as [`VerdictPlan`]. #[derive(Debug, PartialEq)] enum CcVerdictPlan { - /// The verdict was pointed at a PULL REQUEST. `gh issue view ` answers for one, so without - /// this the ISSUE-side authority silently reaches a PR β€” where `ai:close-candidate` is not a - /// producer CLAIM awaiting judgement but the vetter's own `record_verdict … close`. The refusal - /// that fired here instead was `NoFlag`'s "nothing to judge", which reads as "this PR has no - /// human path at all" and was recorded as exactly that misreading on #94. - NotAnIssue, + /// The verdict was pointed at a PULL REQUEST whose label is the vetter's OWN `record_verdict … + /// close` β€” no trusted producer flag states a claim, so there is nothing here for a second AI + /// judgement to judge (#211 narrowed this from "any PR": a producer CLAIM on a PR is vetted + /// exactly as one on an issue is). The refusal that fired here before the variant existed was + /// `NoFlag`'s "nothing to judge", which reads as "this PR has no human path at all" and was + /// recorded as exactly that misreading on #94. + VetterVerdictPr, /// A human already ruled: never overwritten. RefuseHuman, /// The issue is closed β€” the flag is moot. @@ -12997,15 +13070,14 @@ enum CcVerdictPlan { /// stops firing. const CC_VERDICT_FIELDS: &str = "state,labels,comments,url"; -/// PURE: may the vetter record `verdict` on this flagged issue, and what does it change? +/// PURE: may the vetter record `verdict` on this flagged subject, and what does it change? fn cc_verdict_plan(issue_json: &Value, verdict: &str) -> CcVerdictPlan { - // Subject type FIRST, so a closed PR is told what it is rather than that its flag is moot. - if issue_json - .get("url") - .and_then(|u| u.as_str()) - .is_some_and(|u| u.contains("/pull/")) - { - return CcVerdictPlan::NotAnIssue; + // Subject type FIRST, so a closed verdict-close PR is told what it is rather than that its + // label is moot. A PR is refused only when NO trusted producer flag states a claim β€” then the + // label is the vetter's own verdict (#211); a flagged PR takes the same lifecycle as a + // flagged issue and falls through to it. + if subject_is_pr(issue_json) && last_close_candidate_flag(issue_json).is_none() { + return CcVerdictPlan::VetterVerdictPr; } let state = issue_json .get("state") @@ -13632,11 +13704,12 @@ fn citation_unsupported_refusal(slug: &str, issue: &str, ev: &CitationEvidence) } /// `--flag-close-candidate "" [--dry-run]`: the SOLE sanctioned way the -/// producer flags a closeable ISSUE β€” applies the `ai:close-candidate` label + a trusted +/// producer flags a closeable SUBJECT β€” applies the `ai:close-candidate` label + a trusted /// `πŸ€– ai:producer` reason comment, replacing the old local close-candidates.jsonl. GitHub state is -/// the source of truth: a closed/fixed issue drops out of the `--state open` query automatically, -/// re-flagging is idempotent, and a human `human:keep-open` / `human:close-candidate` ruling is -/// sacred (the tool refuses, exit 3). The producer NEVER closes the issue β€” a human does that. +/// the source of truth: a closed/fixed subject drops out of the `--state open` query automatically, +/// re-flagging is idempotent, and a human ruling (`human:keep-open`, or any other `human:*` label) +/// is sacred (the tool refuses, exit 3). The producer NEVER closes the subject β€” that is the +/// human's authority, exercised through `human-close`. /// /// An `already-fixed-on-main` reason additionally must carry a commit sha or merged PR number whose /// date POST-DATES the issue (exit 4). "This code is on main today" is not the same claim as "this @@ -14680,9 +14753,10 @@ fn ai_state_label(labels: &[String]) -> Option { const HUMAN_MARKER: &str = "πŸ‘€ human"; /// The human rulings that DISPOSE of a live producer close-candidate flag β€” the two that answer the -/// flag's own question. `close-candidate` agrees with it (the issue is the human's to close); -/// `keep-open` contradicts it outright. Any OTHER ruling written onto a live flag strands it (see -/// [`HumanRulePlan::StrandsFlag`]). +/// flag's own question. `close-candidate` agrees with it and is `human-close`'s OWN plan word, not +/// a vocabulary verb (#213 β€” the close is decide+do, so no `human-rule*` surface offers it); +/// `keep-open` contradicts the flag outright. Any OTHER ruling written onto a live flag strands it +/// (see [`HumanRulePlan::StrandsFlag`]). const FLAG_DISPOSING_RULINGS: [&str; 2] = ["close-candidate", "keep-open"]; /// PURE: the VERB a state label offers a ruling β€” its name after the namespace prefix. Split on the @@ -15004,6 +15078,16 @@ fn human_pr_rule_plan(pr_json: &Value, ruling: &str, target: &str) -> HumanRuleP return HumanRulePlan::NoAnchor; } let labels = label_names(pr_json); + // The PR-side twin of the issue plan's strand guard (#211): a producer flag lives on PRs now, + // and `record_close_candidate_verdict` refuses once a human has ruled, so a ruling that + // neither disposes the flag nor clears the label would park it un-judgeable for ever β€” + // rain.erc4626.words#93's shape, on the other subject type. An `ai:*` target disposes + // structurally: [`labels_to_remove`] strips every other `ai:*`, the flag label with it. + if let Some(flag_at) = live_close_candidate_flag(pr_json, &labels) { + if !FLAG_DISPOSING_RULINGS.contains(&ruling) && !target.starts_with("ai:") { + return HumanRulePlan::StrandsFlag { flag_at }; + } + } HumanRulePlan::Record { supersedes: labels .iter() @@ -15346,14 +15430,12 @@ fn strands_flag_error(slug: &str, issue: &str, target: &str, flag_at: &str) -> ( and `{target}` would strand it β€” every AI transition refuses once a human has ruled, \ so `record_close_candidate_verdict` could never judge this flag again.\n\ The moves that ARE available here, one command each:\n \ - human-close {slug} {issue} \"…\" β€” the flag is right AND \ - you are acting on it now: rules close-candidate, clears the flag, closes the issue\n \ - human-rule-issue {slug} {issue} close-candidate \"…\" β€” the flag is right but \ - the issue stays open for now (sacred; the flag stands as the pending claim)\n \ + human-close {slug} {issue} \"…\" β€” the flag is right: \ + record the ruling, close, retire the flag β€” decide+do, one transition\n \ human-rule-issue {slug} {issue} keep-open \"…\" β€” the flag is wrong AND \ this must never be re-flagged (sacred; clears ai:close-candidate)\n \ record-close-candidate-verdict {slug} {issue} reject \"…\" β€” the flag is wrong on \ - THIS evidence; drop it and return the issue to the producer, which may re-flag on better \ + THIS evidence; drop it and return the subject to its queue, which may re-flag on better \ evidence" ), ) @@ -15657,8 +15739,8 @@ fn human_rule_issue_apply( // ───────────────────────────────────────────────────────────────────────────── // the TERMINAL human edge: rule, retire the pending flag, close. // -// `iupheld --> [*] : human closes` and `hclose --> [*] : human closes` are two edges of the state -// diagram that had no transition function, so the only way to take them was raw `gh issue close` β€” +// The terminal close edges of the state diagram had no transition function, so the only way to +// take them was raw `gh issue close` β€” // which knows nothing about the FSM. #94 measured what that cost: at the time it was filed, **74** // terminal subjects across the org (55 issues, 19 PRs) were CLOSED and still carrying // `ai:close-candidate`, a state no modeled transition produces. The one ruling in the same sitting @@ -15669,8 +15751,14 @@ fn human_rule_issue_apply( // It is deliberately ONE transition rather than a command that chains two. A slash command that // called `human-rule-issue` and then `gh issue close` would put the ORDER β€” and the flag clear β€” // in a prompt, which is exactly the "loose transition" CLAUDE.md forbids: unenforced, untested, and -// free to drift. Here the order is [`human_close_steps`], a tested property, and the close is last -// because a closed subject reads as `Moot` to every ruling plan. +// free to drift. Here the order is [`human_close_steps`], a tested property. +// +// It writes NO label (#213): a label between the decision and the act would split one actor's +// decide/do on a terminal act that is a single API call β€” the pinned `πŸ‘€ human` comment alone is +// the durable intent. A tear between that comment and the close leaves the torn-close signature +// (ruling on the record, subject still open), which the close-candidate machinery recognises and +// COMPLETES ([`CcGate::TornHumanClose`]) wherever the subject is scan-visible, and which a re-run +// of this same command resumes everywhere else (the comment dedups; the close executes). // ───────────────────────────────────────────────────────────────────────────── /// The one `ai:*` label that means "a human still has to ACT on this subject", rather than "an AI @@ -15678,9 +15766,6 @@ fn human_rule_issue_apply( /// the vetter's pending `close` verdict. Both name a queue, and closing the subject is the act that /// empties it β€” which is why the terminal edge retires it and no other `ai:*` label. const PENDING_CLOSE_FLAG: &str = "ai:close-candidate"; -/// The label the terminal edge writes. Not a parameter: `human-close` IS the `close-candidate` -/// ruling plus its terminal act, so a second verb here would be a second spelling of one state. -const HUMAN_CLOSE_TARGET: &str = "human:close-candidate"; /// The terminal-close decision. Delegates every guard to the ruling plan the subject's own type /// selects β€” so vocabulary, note, anchor, stranding and idempotence are the SAME code, tested once β€” @@ -15710,10 +15795,15 @@ enum HumanClosePlan { /// one. fn human_close_plan(subject: &Value, is_pr: bool) -> HumanClosePlan { let flagged = label_names(subject).iter().any(|l| l == PENDING_CLOSE_FLAG); + // The TARGET is empty (#213): this transition writes no label, and an empty target excepts + // nothing from `supersedes` β€” a close supersedes every standing `human:*` ruling, because the + // subject it parked no longer exists to be parked. The ruling WORD stays `close-candidate`: + // it is the vocabulary every recorded close ruling already carries, and the torn-close + // detector ([`human_close_ruled`]) reads exactly that word off the record. let plan = if is_pr { - human_pr_rule_plan(subject, "close-candidate", HUMAN_CLOSE_TARGET) + human_pr_rule_plan(subject, "close-candidate", "") } else { - human_issue_rule_plan(subject, "close-candidate", HUMAN_CLOSE_TARGET) + human_issue_rule_plan(subject, "close-candidate", "") }; match plan { HumanRulePlan::Moot if flagged => HumanClosePlan::StaleFlag, @@ -15743,22 +15833,48 @@ fn human_close_plan(subject: &Value, is_pr: bool) -> HumanClosePlan { } } -/// PURE: the terminal edge's write order β€” the ruling's own sequence with the close appended. -/// Reusing [`human_rule_steps`] rather than restating it keeps ONE fail-safe order: a second list -/// could be re-ordered without the property test that guards the first one noticing. +/// PURE: the terminal edge's write order. NOT [`human_rule_steps`] + a close any more (#213): +/// that order existed to protect label writes, and this transition makes none. The invariant this +/// order satisfies β€” pinned by tests, one per tear point β€” is that **no tear leaves a subject +/// invisible to existing machinery**: +/// +/// - `Comment` FIRST. The pinned ruling is the durable intent; a tear after it leaves the +/// torn-close signature β€” close ruling on the record, subject still open β€” which the flag scan +/// sees and COMPLETES while the subject carries `ai:close-candidate` +/// ([`CcGate::TornHumanClose`]), and which a re-run of this command resumes when it does not +/// (the comment dedups, the close executes). +/// - `Close` SECOND, ahead of every label removal β€” the REVERSE of the ruling order's +/// "close last". That rule kept label writes reachable on a subject whose plans read closed as +/// `Moot`; with no label written, ordering the removals first would instead open the one +/// indefensible tear: an OPEN subject with no flag, visible to no scan. A tear after the close +/// leaves a CLOSED subject β€” the act is done β€” with at worst a stale flag, a state MACHINERY +/// enumerates ([`sweep_stale_closed_flags`], the closed-side search on the same state-load) and +/// the already-closed re-run path ([`HumanClosePlan::StaleFlag`], #94) also consumes. +/// - Removals LAST: the pending flag first (it is the machine-visible queue state, and the sweep +/// above is what heals a tear here), then superseded `human:*` rulings β€” the latter at worst +/// cosmetic residue on a closed subject, with the re-run as its exit. fn human_close_steps( supersedes: &[String], clears: &[String], - has_target: bool, skip_comment: bool, ) -> Vec { - let mut steps = human_rule_steps(supersedes, clears, has_target, skip_comment); + let mut steps = Vec::new(); + if !skip_comment { + steps.push(RuleStep::Comment); + } steps.push(RuleStep::Close); + steps.extend( + clears + .iter() + .chain(supersedes) + .map(|l| RuleStep::RemoveLabel(l.clone())), + ); steps } -/// PURE: the report the terminal edge prints β€” the ruling's report plus the act that made it -/// terminal, so one line says everything that moved. +/// PURE: the report the terminal edge prints β€” what was recorded, what was retired, and the act, +/// in one line. No label is named because none is written (#213): the ruling lives in the pinned +/// comment. fn human_close_report( slug: &str, n: &str, @@ -15768,17 +15884,22 @@ fn human_close_report( skip_comment: bool, ) -> String { format!( - "{} [closed]", - human_rule_report( - slug, - n, - HUMAN_CLOSE_TARGET, - anchor, - supersedes, - clears, - skip_comment, - "" - ) + "ruled close-candidate on {slug}#{n} @ {anchor}{}{}{} [closed]", + if supersedes.is_empty() { + String::new() + } else { + format!(" [superseded {}]", supersedes.join(",")) + }, + if clears.is_empty() { + String::new() + } else { + format!(" [cleared {}]", clears.join(",")) + }, + if skip_comment { + " [comment deduped]" + } else { + " [comment posted]" + } ) } @@ -15818,7 +15939,7 @@ fn human_close_apply( } else { ("issue", seen) }; - let (anchor, supersedes, clears, has_target, skip) = match human_close_plan(&subject, is_pr) { + let (anchor, supersedes, clears, _has_target, skip) = match human_close_plan(&subject, is_pr) { HumanClosePlan::Settled => { return Ok(format!( "{slug}#{n} is already closed and carries no {PENDING_CLOSE_FLAG} β€” nothing written" @@ -15868,7 +15989,7 @@ fn human_close_apply( )); } HumanClosePlan::Rule(HumanRulePlan::StrandsFlag { flag_at }) => { - return Err(strands_flag_error(slug, n, HUMAN_CLOSE_TARGET, &flag_at)); + return Err(strands_flag_error(slug, n, "human-close", &flag_at)); } HumanClosePlan::Rule(HumanRulePlan::Moot) => { return Ok(format!("{slug}#{n} is not open β€” nothing written")); @@ -15893,13 +16014,15 @@ fn human_close_apply( } )); } + // The target is empty: this transition writes no label (#213), so no step here can reach the + // EnsureLabel/AddLabel argv that would need one. human_rule_write( noun, slug, n, - HUMAN_CLOSE_TARGET, + "", &comment, - &human_close_steps(&supersedes, &clears, has_target, skip), + &human_close_steps(&supersedes, &clears, skip), )?; Ok(human_close_report( slug, @@ -16417,15 +16540,19 @@ fn print_transition_result(result: Result) -> i32 { // lane bucketing β€” the FSM's full inventory, grouped by lane for the dashboard. // // `human-queue --json` emits EVERY modeled state's inventory, not just the human-action ones, so the -// dashboard can show where PRs pile up. Each producer PR lands in exactly ONE lane bucket by FSM -// precedence (a human decision dominates a stale ai:* label; a blocked hand-off next β€” filed under -// the lane of whoever moves it: the vetter for `ai:blocked-on` (#161), the producer for the -// RETIRED blocked residue; then an ai:ready PR whose verdict is not current at its head falls back -// to un-vetted; then the other vetter verdicts; a label-less PR is a leak if the producer -// commented, else un-vetted). +// dashboard can show where PRs pile up. Each producer PR lands in exactly ONE bucket by FSM +// precedence (a human decision dominates a stale ai:* label; an `ai:close-candidate` PR is handed +// to the close-candidate machinery, which inventories it in the mixed unvetted/upheld arrays +// instead of any lane (#211/#212); a blocked hand-off next β€” filed under the lane of whoever moves +// it: the vetter for `ai:blocked-on` (#161), the producer for the RETIRED blocked residue; then an +// ai:ready PR whose verdict is not current at its head falls back to un-vetted; then the other +// vetter verdicts; a label-less PR is a leak if the producer commented, else un-vetted). // ───────────────────────────────────────────────────────────────────────────── -/// The four FSM lanes, plus the `Leak` anti-lane (escaped the machine β€” not a modeled state). +/// The four FSM lanes, plus the `Leak` anti-lane (escaped the machine β€” not a modeled state) and +/// the `CloseCandidate` hand-off (#211/#212): a PR the flag machinery owns, inventoried by the +/// close-candidate state-load (`closeCandidateUnvetted` / `closeCandidateUpheld`) rather than by +/// any lane β€” the PR-side mirror of a flagged issue leaving `uncoveredIssues`. #[derive(Debug, PartialEq, Eq)] enum Lane { VetLifecycle, @@ -16433,6 +16560,7 @@ enum Lane { ProducerBlocked, HumanDecisions, Leak, + CloseCandidate, } impl Lane { @@ -16443,6 +16571,7 @@ impl Lane { Lane::ProducerBlocked => "producer-blocked", Lane::HumanDecisions => "human-decisions", Lane::Leak => "leak", + Lane::CloseCandidate => "close-candidate", } } } @@ -16457,8 +16586,9 @@ impl Lane { /// state would misreport what the PR carries), so a delegated design PR shows here until the re-vet /// clears it; re-homing the dashboard slot that reads this lane is the #148-shaped follow-up gated /// on this machinery. The ruling vocabulary is [`HUMAN_PR_RULING_LABELS`] β€” no longer the same -/// array, and no longer required to be. -const HUMAN_DECISION_LABELS: [&str; 2] = ["human:design", "human:close-candidate"]; +/// array, and no longer required to be. A close ruling buckets nowhere by design (#213): it is +/// `human-close`'s decide+do with no label, so there is no close state for this lane to hold. +const HUMAN_DECISION_LABELS: [&str; 1] = ["human:design"]; /// The vetter's non-`ready` verdict labels (the `ready` split is handled separately by head drift). /// /// `ai:relink` is RETIRED (#135) β€” [`VETTER_VERDICTS`] no longer accepts the word, so no transition @@ -16467,8 +16597,12 @@ const HUMAN_DECISION_LABELS: [&str; 2] = ["human:design", "human:close-candidate /// reclassify that PR as `un-vetted` and hide the very thing that needs unpicking. It leaves the /// moment the human re-records the verdict as a `reject` naming the linkage β€” `labels_to_remove` /// strips every other `ai:*` β€” after which this entry is dead and can go. -const VETTER_VERDICT_LABELS: [&str; 4] = - ["ai:reject", "ai:relink", "ai:design", "ai:close-candidate"]; +/// +/// `ai:close-candidate` is NOT here any more (#211/#212): the label β€” the vetter's `close` verdict +/// and the producer's flag alike β€” hands the PR to the close-candidate machinery, which is its own +/// arm in [`classify_lane`] rather than a verdict-lane state, so the ONE mixed upheld inbox and +/// the ONE mixed unvetted inbox own every subject that carries it. +const VETTER_VERDICT_LABELS: [&str; 3] = ["ai:reject", "ai:relink", "ai:design"]; /// PURE: the single (lane, state) a producer PR belongs to, by FSM precedence. /// - `ready_vetted_at_head`: for an `ai:ready` PR, `Some(false)` when no `ai:vetter` verdict is @@ -16498,6 +16632,16 @@ fn classify_lane( if has(RETIRED_HUMAN_REJECT_LABEL) { return (Lane::HumanDecisions, RETIRED_HUMAN_REJECT_LABEL.to_string()); } + // The close-candidate hand-off (#211/#212), ahead of every remaining `ai:*` state: the label + // parks the PR in the flag machinery β€” the org-wide label search is what enumerates it, into + // `closeCandidateUnvetted` (a producer flag awaiting the vetter) or `closeCandidateUpheld` + // (an upheld flag or the vetter's own `close` verdict, the human's ONE mixed inbox) β€” so + // bucketing it into a lane as well would count one subject in two states at once. Below the + // human arms because a human ruling dominates the flag in `cc_gate` too: the two classifiers + // must hand each subject to the same owner or the inboxes disagree. + if has("ai:close-candidate") { + return (Lane::CloseCandidate, "ai:close-candidate".to_string()); + } // RETIRED (#162) but still bucketed, at its old precedence: a blocked state dominates a stale // `ai:ready` label. Deliberately producer-blocked and NOT vet-lifecycle β€” the #164 clearance // reads exactly `ai:blocked-on` (typed refs the vetter can resolve), and this residue has @@ -16596,12 +16740,13 @@ fn leak_reason(trusted_bodies: &[String]) -> Option { /// those cannot build a link at all β€” the field is simply not there. /// /// Nor can it derive one: `{repo, number}` does not say whether the number is an issue or a PR, and -/// the arrays split BOTH ways (`states`/`leaks` are PRs, the rest are issues). That split is an -/// implementation fact β€” `gh search issues` scopes to issues, `gh search prs` to PRs β€” not -/// something the payload states, so a consumer has to hard-code a per-key rule it cannot verify and -/// that changes silently if a key's source query ever changes. GitHub does redirect -/// `/pull/` ↔ `/issues/`, so a guessed link survives a browser click today; it does not -/// survive a non-following API client, a link checker, or a subject transferred to another repo. +/// no per-key rule says either β€” `states`/`leaks` are PRs, `uncoveredIssues` is issues, and the +/// close-candidate arrays MIX the two (#211/#212: the flag lifecycle covers both subject types). +/// A consumer hard-coding a per-key rule could not verify it and would break silently when a key's +/// source query changed β€” which is exactly what #211 did to the old "close-candidate β‡’ issue" +/// rule. GitHub does redirect `/pull/` ↔ `/issues/`, so a guessed link survives a browser +/// click today; it does not survive a non-following API client, a link checker, or a subject +/// transferred to another repo. /// The resolved `url` is already in hand at every one of those sites (each array is built from a /// `gh search` / `gh issue view` payload that returns it), so carrying it costs nothing. /// @@ -16708,6 +16853,13 @@ fn lanes_doc(prs: &[QueuePr]) -> Value { > = std::collections::BTreeMap::new(); for p in prs { let (lane, state) = classify_lane(&p.labels, p.ready_vetted_at_head, p.producer_commented); + // NOT a lane (#211/#212): the flag machinery inventories this PR β€” the same run's + // `closeCandidateUnvetted`/`closeCandidateUpheld` arrays, fed by the org-wide label + // search, which reaches every PR this author-scoped scan does and more. Emitting it here + // too would put one subject in two states, which is the split #212 retired. + if lane == Lane::CloseCandidate { + continue; + } lanes .entry(lane.key()) .or_default() @@ -17031,12 +17183,14 @@ fn human_queue_doc( "blockedInfra": buckets.get("ai:blocked-infra").map(|v| v.len()).unwrap_or(0), "blockedOn": buckets.get("ai:blocked-on").map(|v| v.len()).unwrap_or(0), "closeCandidateIssues": close_issues.len(), - // Close-candidate VET lifecycle (#72/#73). `closeCandidateIssues` above keeps its - // meaning β€” every issue carrying the label β€” and these split it by vet state: + // Close-candidate VET lifecycle (#72/#73), over BOTH subject types (#211/#212). + // `closeCandidateIssues` above keeps its legacy meaning β€” every ISSUE carrying the + // label β€” and these are the machine's own split, mixing issues and PRs: // unvetted = the vetter's inbox (flagged, no human ruling, no verdict at THIS flag) - // upheld = the vetter judged the evidence sound; genuinely queued for the human - // A REJECTED flag needs no key: the vetter strips `ai:close-candidate`, so the issue - // leaves this set entirely and reappears under `uncoveredIssues`. + // upheld = the vetter judged this should close β€” an upheld flag on either subject + // type, or the vetter's own PR `close` verdict β€” queued for the human + // A REJECTED flag needs no key: the vetter strips `ai:close-candidate`, so the subject + // leaves this set entirely β€” an issue back to `uncoveredIssues`, a PR to its lane. "closeCandidateUnvetted": cc_unvetted_n, "closeCandidateUpheld": cc_upheld_n, "leaks": leaks.len(), @@ -17053,11 +17207,12 @@ fn human_queue_doc( // stays there. The key is kept while it can be non-zero β€” a dashboard that stopped // rendering the state would hide the PR that still needs re-recording as a `reject`. "relink": lane_state_count(lanes, "vetter-verdicts", "ai:relink"), - "closeCandidatePrs": lane_state_count(lanes, "vetter-verdicts", "ai:close-candidate"), + // No key counts a close-candidate PR lane state or a decided-close state: flagged + // PRs are inventoried by the mixed close-candidate arrays above (#211/#212), and a + // close ruling is `human-close`'s decide+do with no state between (#213). // RETIRED (#133). Kept β€” the dashboard reads it β€” and it now means "still to migrate". "humanReject": lane_state_count(lanes, "human-decisions", RETIRED_HUMAN_REJECT_LABEL), "humanDesign": lane_state_count(lanes, "human-decisions", "human:design"), - "humanCloseCandidate": lane_state_count(lanes, "human-decisions", "human:close-candidate"), } }); if let Some(open) = open { @@ -17252,7 +17407,9 @@ fn human_queue_mode(json_out: bool) -> i32 { .collect(); let lanes = lanes_doc(&queue_prs); - // The open close-candidate ISSUES (close-candidate is an issue-level flag). + // The open close-candidate ISSUES β€” the LEGACY `closeCandidateIssues` count, which has always + // meant "issues carrying the label" and keeps that meaning; the machine's own mixed-subject + // populations are the `closeCandidateUnvetted`/`closeCandidateUpheld` pair below (#211/#212). let mut iargs: Vec = vec!["search".into(), "issues".into()]; iargs.extend(org_owner_args()); iargs.extend( @@ -17283,15 +17440,17 @@ fn human_queue_mode(json_out: bool) -> i32 { .0, ); - // Close-candidate vet state, as `(unvetted, upheld)`. Computed from the same state-load the - // vetter reads, so the dashboard and the vetter can never disagree about the size of the inbox. - // Additive and FAILURE-TOLERANT: this costs one `gh issue view` per flagged issue, so a transient - // API failure yields (0, 0) rather than aborting the queue render or corrupting the legacy - // `closeCandidateIssues` count, which keeps its own cheap search above. + // Close-candidate vet state, as `(unvetted, upheld)` β€” BOTH arrays mixing issues and PRs + // (#211/#212). Computed from the same state-load the vetter reads, so the dashboard and the + // vetter can never disagree about the size of the inbox. + // Additive and FAILURE-TOLERANT: this costs one `gh issue view` per flagged subject, so a + // transient API failure yields (0, 0) rather than aborting the queue render or corrupting the + // legacy `closeCandidateIssues` count, which keeps its own cheap search above. // - // Upheld is derived, not stored: a REJECTED flag has its label stripped, so it cannot appear in - // this search at all β€” an issue that still carries the label AND is vetted at its current flag - // was necessarily upheld. + // Upheld is derived, not stored: a REJECTED flag has its label stripped, so it cannot appear + // in this search at all β€” a subject that still carries the label AND is vetted at its current + // flag was necessarily upheld, and a PR whose label is the vetter's own `close` verdict is the + // same judgement by its other route. // `include_skipped` is required: the upheld set lives in the skipped rows. Arrays and counts // both come from this ONE document, so `counts.X == X.len()` holds by construction β€” the // dashboard's boxes are click-through, and a count that disagreed with its list would render a @@ -17421,11 +17580,16 @@ fn human_queue_mode(json_out: bool) -> i32 { if let Some(v) = buckets.get("ai:design") { show("RULE β€” ai:design", v); } - show_lane( - "CLOSE β€” ai:close-candidate (PRs)", - "vetter-verdicts", - "ai:close-candidate", - ); + // Print a mixed-subject close-candidate array (issues AND PRs, #211/#212) straight from the + // same document the JSON emits, so the daily review and the dashboard read one population. + let show_cc = |title: &str, items: &Value| { + let empty = Vec::new(); + let items = items.as_array().unwrap_or(&empty); + println!("\nβ–“β–“ {title} ({})", items.len()); + for it in items { + println!("{}", review_subject_block(&SubjectRef::from_row(it), 66)); + } + }; // producer-blocked (`ai:blocked-on` prints with the vetter's group above β€” #161) // RETIRED (#162) β€” no transition writes this label any more. Shown while any PR still carries // it, so each residue PR stays visible until its eyes-on triage: re-flag it @@ -17449,12 +17613,16 @@ fn human_queue_mode(json_out: bool) -> i32 { RETIRED_HUMAN_REJECT_LABEL, ); show_lane("HUMAN-DESIGN", "human-decisions", "human:design"); - show_lane( - "HUMAN-CLOSE-CANDIDATE", - "human-decisions", - "human:close-candidate", + // The two mixed-subject inboxes (#211/#212): the vetter's, then the human's. The legacy + // issues-only view is gone from the review β€” one population, printed once, per owner. + show_cc( + "VET THE FLAG β€” ai:close-candidate (unvetted, issues + PRs)", + &cc_unvetted, + ); + show_cc( + "CLOSE β€” ai:close-candidate (upheld, issues + PRs)", + &cc_upheld, ); - show("CLOSE β€” ai:close-candidate (issues)", &close_issues); println!( "\n⚠⚠ NOT IN ANY MODELED STATE (FSM leak β€” should trend to 0) ({})", leaks.len() @@ -19513,9 +19681,14 @@ fn cc_stranded_cleared_comment(gate: CcGate, flag_at: &str) -> Option { ), // NEVER `RepoArchived`: the clearance is a `--remove-label` plus a comment, and an // archived repo refuses both β€” a clearance attempted there fails on every run and reports - // `clearanceFailed` for ever. Total over the enum on purpose, so a new state is - // non-clearable until somebody decides otherwise. + // `clearanceFailed` for ever. NEVER `VetterClose` either: that label is a verdict in + // force, and clearing it would erase the vetter's own judgement (#211). NEVER + // `TornHumanClose`: its consuming transition is the COMPLETION (#213), not a label + // clearance. Total over the enum on purpose, so a new state is non-clearable until + // somebody decides otherwise. CcGate::Presentable + | CcGate::VetterClose + | CcGate::TornHumanClose | CcGate::HumanRuled | CcGate::Unvetted | CcGate::RepoArchived => None, @@ -19553,6 +19726,10 @@ fn cc_row( human, &flag_at, last_cc_vetter_comment(detail).and_then(|b| cc_verdict_parts(&b)), + // Computed off the SAME detail the gate classifies, and only where it can mean anything: + // the `Reviewed : close` shape is a PR verdict, so an issue never reads as one. + subject_is_pr(detail) && last_pr_close_verdict(detail).is_some(), + human_close_ruled(detail), ); // Precedence is `cc_gate`'s, which is the PR side's: writability first, then a human ruling, // then "nothing was claimed", then what the vetter did with the claim. @@ -19562,6 +19739,13 @@ fn cc_row( CcGate::NoFlag => CC_CLEAR_NO_FLAG, CcGate::RejectedStillFlagged => CC_CLEAR_REJECTED, CcGate::Presentable => "skip-vetted-at-flag", + // The PR-side close verdict: already judged, so it skips the vetter exactly as an upheld + // flag does, into the same ONE human inbox (#212) β€” under its own name, because "vetted at + // flag" would claim a flag that does not exist. + CcGate::VetterClose => "skip-vetter-close", + // The torn `human-close` (#213): the recorded ruling is executed by the state-load, so + // the action names the completion rather than any queue. + CcGate::TornHumanClose => "complete-human-close", CcGate::Unvetted => "vet", }; ( @@ -19591,18 +19775,20 @@ fn cc_row( /// /// The `url` comes from the row, which got it from the `gh issue view` that built the row β€” this /// projection used to drop it, which is the drift #114 is about. The projection carries whatever -/// url the row holds rather than rebuilding one from `repo` + `number`: `ai:close-candidate` is a -/// label the producer also puts on PRs (see `lanes.vetter-verdicts.ai:close-candidate`), so -/// "close-candidate β‡’ `/issues/`" is a rule about which query fed this array, not about the -/// concept, and it is not the projection's to encode. +/// url the row holds rather than rebuilding one from `repo` + `number`: both arrays MIX issues and +/// PRs (#211/#212 β€” the flag lifecycle is one machine whatever carries the label), so +/// "close-candidate β‡’ `/issues/`" was never the concept's rule, and it is not the projection's to +/// encode. /// /// Both the arrays and their counts are derived from THIS one document, which is what makes an /// array/count mismatch unrepresentable: a box that renders "5" and then lists three issues when /// clicked is the drift this shape rules out. /// -/// `upheld` is the skipped rows whose action is `skip-vetted-at-flag`, and that action now means -/// UPHELD specifically, because [`cc_row`] reads the verdict WORD off [`cc_gate`] rather than -/// inferring it from the flag being vetted at all. +/// `upheld` is the skipped rows whose action is `skip-vetted-at-flag` β€” which means UPHELD +/// specifically, because [`cc_row`] reads the verdict WORD off [`cc_gate`] rather than inferring +/// it from the flag being vetted at all β€” plus the `skip-vetter-close` rows, the PRs whose label +/// is the vetter's own `close` verdict (#212): a different route to the same judgement, and the +/// human's ONE disposal inbox holds both. /// /// It used to be inferred, on the argument that a rejected flag has its label stripped so it cannot /// appear in this search β€” and `rain.erc4626.words#93` disproved it: rejected 2026-07-28, label @@ -19621,7 +19807,15 @@ fn cc_item_arrays(doc: &Value) -> (Vec, Vec) { .and_then(|v| v.as_array()) .map(|a| { a.iter() - .filter(|r| r.get("action").and_then(|v| v.as_str()) == Some("skip-vetted-at-flag")) + // TWO actions, ONE inbox (#212): an upheld flag and a PR-side close verdict both + // mean "the vetter judged this should close; a human disposes of it", so they + // project into the same upheld array rather than splitting by subject type. + .filter(|r| { + matches!( + r.get("action").and_then(|v| v.as_str()), + Some("skip-vetted-at-flag") | Some("skip-vetter-close") + ) + }) .map(item) .collect() }) @@ -19700,9 +19894,105 @@ fn cc_clear_stranded_flag( Ok(()) } +/// The torn-close COMPLETION (#213): execute the close a recorded `πŸ‘€ human` ruling already +/// ordered. Close FIRST, then retire the flag β€” a failure between the two leaves a CLOSED subject +/// with a stale flag, exactly the state `human-close`'s already-closed re-run path consumes +/// (#94); the reverse order would tear into an open, unflagged subject no scan enumerates. +/// +/// NO comment is written: the ruling comment is the decision's record and GitHub's own close +/// event records the act β€” a machine comment between them would be provenance nobody authored. +/// +/// `Err` is the reason, for the row: a failed completion is REPORTED and retried next state-load, +/// never swallowed. The caller must not count it as completed β€” the subject is still open. +fn cc_complete_torn_close(slug: &str, num: u64, detail: &Value) -> Result<(), String> { + let n = num.to_string(); + let noun = if subject_is_pr(detail) { "pr" } else { "issue" }; + if !gh_run(&[noun, "close", &n, "-R", slug]) { + return Err( + "closing FAILED β€” the ruling stands recorded and the subject is still open; the next \ + state-load retries" + .to_string(), + ); + } + if !gh_run(&[ + "issue", + "edit", + &n, + "-R", + slug, + "--remove-label", + "ai:close-candidate", + ]) { + return Err( + "closed, but removing ai:close-candidate FAILED β€” a stale flag on a closed subject; \ + the next state-load's closed-side sweep clears it" + .to_string(), + ); + } + Ok(()) +} + +/// The CLOSED-side stale-flag sweep: every closed subject still carrying `ai:close-candidate` gets +/// the label removed, org-wide, every state-load. This is the tear between `human-close`'s close +/// and its flag retirement (and [`cc_complete_torn_close`]'s identical pair) made VISIBLE to +/// machinery: every open-side search excludes closed subjects, so without this enumeration that +/// tear would sit for ever β€” the exact #94 population, which measured 74 when it was filed. +/// +/// No comment is written, the already-closed posture `human-close` itself takes: the close is on +/// the record as GitHub's own event, and the label is stale decoration on a terminal subject. +/// Archived repos are withheld and counted β€” the removal write would be refused for ever (#206). +/// +/// Returns `(cleared, failures, archived)` counts plus the failure rows; failures retry next +/// state-load by construction, because the subject stays in this search's population. +fn sweep_stale_closed_flags(archived_repos: &ArchivedRepos) -> (usize, usize, usize, Vec) { + let (mut n_cleared, mut n_failed, mut n_archived) = (0usize, 0usize, 0usize); + let mut failures: Vec = Vec::new(); + for noun in FLAG_SEARCH_NOUNS { + let args = flagged_subjects_args(noun, "closed"); + let argref: Vec<&str> = args.iter().map(String::as_str).collect(); + // A failed search is skipped, not fatal: this sweep is a repair pass riding a state-load + // whose own population must still be served, and the stale flags it would have found are + // still there for the next run. + let Some(found) = gh_json(&argref).and_then(|v| v.as_array().cloned()) else { + continue; + }; + for hit in &found { + let Some((slug, num)) = search_issue_ref(hit) else { + continue; + }; + if archived_repos.contains(&slug) { + n_archived += 1; + continue; + } + if gh_run(&[ + "issue", + "edit", + &num.to_string(), + "-R", + &slug, + "--remove-label", + "ai:close-candidate", + ]) { + n_cleared += 1; + } else { + n_failed += 1; + failures.push(serde_json::json!({ + "issue": format!("{slug}#{num}"), + "url": hit.get("url").and_then(|u| u.as_str()).unwrap_or(""), + "error": "removing ai:close-candidate from a closed subject FAILED β€” retried \ + next state-load", + })); + } + } + } + (n_cleared, n_failed, n_archived, failures) +} + /// Live `unvetted-close-candidates` state-load: ONE org-wide search for open `ai:close-candidate` -/// issues + one `gh issue view` each. Errors rather than returning a falsely-empty set, for the same -/// reason the PR side does β€” an empty queue must never be an API failure in disguise. +/// subjects β€” issues AND pull requests (#211) β€” + one `gh issue view` each (`gh issue view` +/// answers for a PR too, and the row's own `url` says which it was). Errors rather than returning +/// a falsely-empty set, for the same reason the PR side does β€” an empty queue must never be an API +/// failure in disguise. /// /// `limit` pages the `issues` list for the same reason [`unvetted_doc`] pages `prs` (#78): the MCP /// caller has a token budget, so it always supplies one. The DASHBOARD passes `None` β€” it derives @@ -19713,12 +20003,13 @@ fn unvetted_close_candidates_fetch( limit: Option, ) -> Result { // The SAME search the human's `next_close_candidate` gate runs, shared rather than written - // twice: two spellings of "which issues are flagged" is how the vetter's inbox and the human's - // would come to answer differently. - let FlaggedIssues { + // twice: two spellings of "what is flagged" is how the vetter's inbox and the human's + // would come to answer differently. Both subject types (#211): a flag parks whatever + // carries it, so the vetter's inbox holds flagged PRs beside flagged issues. + let FlaggedSubjects { hits: found, archived: archived_repos, - } = flagged_open_issues()?; + } = flagged_open_subjects()?; // The dashboard's `closeCandidateUnvetted` reads this queue, so a per-issue failure must be // reported (see `fetchErrors` below), never dropped. // A flag in an ARCHIVED repo is not vettable either (#206): `record_close_candidate_verdict` @@ -19734,9 +20025,14 @@ fn unvetted_close_candidates_fetch( // a skip is a state something else owns, and these were owned by nobody. let mut cleared: Vec = Vec::new(); let mut clear_failures: Vec = Vec::new(); - let (mut n_human, mut n_vetted) = (0usize, 0usize); + // The torn `human-close` completions (#213), same posture as the clearances: executed here, + // split by outcome, never silently absorbed into a skip. + let mut completed_closes: Vec = Vec::new(); + let mut completion_failures: Vec = Vec::new(); + let (mut n_human, mut n_vetted, mut n_vetter_close) = (0usize, 0usize, 0usize); let (mut n_cleared_no_flag, mut n_cleared_rejected, mut n_clear_failed) = (0usize, 0usize, 0usize); + let (mut n_completed_close, mut n_completion_failed) = (0usize, 0usize); for i in &found { let url = i.get("url").and_then(|u| u.as_str()).unwrap_or(""); let title = i.get("title").and_then(|t| t.as_str()).unwrap_or(""); @@ -19772,6 +20068,27 @@ fn unvetted_close_candidates_fetch( archived_flags.push(row); continue; } + // The torn-close COMPLETION (#213), run every state-load exactly as the clearances are: + // the pinned `πŸ‘€ human` ruling already ordered this close, so the machine executes it β€” + // no queue, no second judgement. Split by outcome; a failure keeps the subject here and + // the next state-load retries. + if gate == CcGate::TornHumanClose { + let obj = row.as_object_mut().expect("row is an object"); + match cc_complete_torn_close(&slug, num, &detail) { + Ok(()) => { + n_completed_close += 1; + obj.insert("completed".into(), Value::from(true)); + completed_closes.push(row); + } + Err(why) => { + n_completion_failed += 1; + obj.insert("completed".into(), Value::from(false)); + obj.insert("completionError".into(), Value::from(why)); + completion_failures.push(row); + } + } + continue; + } // The CLEARANCE, run every state-load exactly as the `ai:blocked-on` one is (#161): a // stranded label is machine-detectable from typed data, so it is repaired by machinery // rather than found by a human building a tool. Attempted BEFORE the row is filed, so the @@ -19793,6 +20110,8 @@ fn unvetted_close_candidates_fetch( CcGate::NoFlag => n_cleared_no_flag += 1, CcGate::RejectedStillFlagged => n_cleared_rejected += 1, CcGate::Presentable + | CcGate::VetterClose + | CcGate::TornHumanClose | CcGate::HumanRuled | CcGate::Unvetted | CcGate::RepoArchived => { @@ -19821,22 +20140,37 @@ fn unvetted_close_candidates_fetch( } else { match action { "skip-human-decided" => n_human += 1, + // Its own count, not folded into skipVettedAtFlag: a PR-side close verdict is the + // human's for a different reason than an upheld flag, and folding a new state into + // an old count is the silent accumulation #179 names. + "skip-vetter-close" => n_vetter_close += 1, _ => n_vetted += 1, } skipped.push(row); } } + // The closed-side sweep runs on the SAME state-load as the other repairs, and against the + // SAME archived set, so all four self-heals (#179 clearances, #213 completions, this) report + // from one read of the world. + let (n_stale_cleared, n_stale_failed, n_stale_archived, stale_failures) = + sweep_stale_closed_flags(&archived_repos); let n_vet = rows.len(); let (issues, more) = page(rows, limit); let (cleared_page, more_cleared) = page(cleared, limit); let (clear_failures_page, more_clear_failures) = page(clear_failures, limit); + let (completed_page, more_completed) = page(completed_closes, limit); + let (completion_failures_page, more_completion_failures) = page(completion_failures, limit); + let (stale_failures_page, more_stale_failures) = page(stale_failures, limit); let mut doc = serde_json::json!({ "counts": { "flagged": found.len() + archived_flags.len(), "vet": n_vet, "skipHumanDecided": n_human, "skipVettedAtFlag": n_vetted, + // The PR-side close verdicts (#211/#212): already judged, human-owned, listed in the + // upheld projection beside the upheld flags. + "skipVetterClose": n_vetter_close, "skipArchivedRepo": archived_flags.len(), // THE NUMBERS THAT GROW (#179). A stranded flag used to be visible only when a human // typed `/ncc` and read `strandedFlags` β€” detection in a tool nobody runs on a @@ -19849,14 +20183,34 @@ fn unvetted_close_candidates_fetch( // A clearance that could not be written. This is the one that must never be quietly // zero: the label is still live, so the issue is still parked. "clearanceFailed": n_clear_failed, - // flagged == vet + skip* + cleared* + clearanceFailed + fetchErrors, always. A non-zero - // value here is the ONLY reason the parts may not sum to the whole. + // The torn `human-close` completions (#213): closes this state-load EXECUTED because + // a pinned `πŸ‘€ human` ruling ordered them and the subject was still open. The failed + // count is the one that must never be quietly non-zero-and-ignored β€” the decision + // stands recorded while the subject stays open. + "completedHumanClose": n_completed_close, + "humanCloseCompletionFailed": n_completion_failed, + // The CLOSED-side sweep: stale flags on closed subjects (the tear between a close and + // its flag retirement, and the legacy hand-close residue, #94). Its OWN population β€” + // a closed subject is in no open-side count above, so these are additive, not parts + // of `flagged`. + "staleClosedFlagsCleared": n_stale_cleared, + "staleClosedFlagClearFailed": n_stale_failed, + "staleClosedFlagsArchived": n_stale_archived, + // flagged == vet + skip* + cleared* + clearanceFailed + completedHumanClose + + // humanCloseCompletionFailed + fetchErrors, always. A non-zero fetchErrors is the + // ONLY reason the parts may not sum to the whole. "fetchErrors": errors.len(), }, "cleared": cleared_page, "moreCleared": more_cleared, "clearanceFailures": clear_failures_page, "moreClearanceFailures": more_clear_failures, + "completedHumanCloses": completed_page, + "moreCompletedHumanCloses": more_completed, + "humanCloseCompletionFailures": completion_failures_page, + "moreHumanCloseCompletionFailures": more_completion_failures, + "staleClosedFlagClearFailures": stale_failures_page, + "moreStaleClosedFlagClearFailures": more_stale_failures, "issues": issues, "more": more, // UNBOUNDED like `cleared` / `clearanceFailures`, and for the strongest version of their @@ -19932,10 +20286,12 @@ fn close_candidate_context_fetch(slug: &str, num: u64) -> Result )) } -/// The close-candidate verdict write β€” the issue-side twin of [`record_verdict_apply`]. `uphold` -/// leaves the flag queued for the human; `reject` REMOVES `ai:close-candidate`, which returns the -/// issue to the producer's uncovered-issues queue. Either way a trusted, flag-pinned `πŸ€– ai:vetter` -/// comment records the judgement. +/// The close-candidate verdict write β€” the flag-side twin of [`record_verdict_apply`], for BOTH +/// subject types (#211). `uphold` leaves the flag queued for the human; `reject` REMOVES +/// `ai:close-candidate`, which returns the subject to its ordinary state β€” an issue to the +/// producer's uncovered-issues queue, a PR to whatever its remaining labels say (un-vetted when +/// they say nothing). Either way a trusted, flag-pinned `πŸ€– ai:vetter` comment records the +/// judgement. /// /// The vetter never writes a `human:*` label: those are the human's namespace, and this routine is /// AI. Its whole authority is the `ai:*` flag it may drop and the comment it may post. @@ -19979,14 +20335,15 @@ fn record_cc_verdict_apply( )); }; let (flag_at, remove_label, skip) = match cc_verdict_plan(&j, verdict) { - CcVerdictPlan::NotAnIssue => { + CcVerdictPlan::VetterVerdictPr => { return Err(( 2, format!( - "refusing: {slug}#{issue} is a PULL REQUEST, not an issue β€” `gh issue view` \ - answers for either. A close-candidate FLAG is a producer claim on an ISSUE; on \ - a PR `ai:close-candidate` is the VETTER's own verdict, which no second AI \ - judgement applies to. This is not a PR without a path β€” it has three:\n \ + "refusing: {slug}#{issue} is a PULL REQUEST carrying `ai:close-candidate` with \ + NO trusted producer flag β€” the label is the VETTER's own `record_verdict … \ + close`, which no second AI judgement applies to. (A producer FLAG on a PR is \ + judgeable here exactly as on an issue β€” this subject just does not carry \ + one.) This is not a PR without a path β€” it has three:\n \ human-close {slug} {issue} \"…\" β€” the verdict is right: rule, \ clear the flag and close it, one command\n \ human-rule {slug} {issue} reject \"…\" β€” the verdict is wrong; it needs \ @@ -20077,10 +20434,12 @@ fn record_cc_verdict_apply( } Ok(format!( "recorded close-candidate {verdict} on {slug}#{issue} @ {flag_at}{}{}", - if remove_label { - " [ai:close-candidate removed -> back to the producer queue]" - } else { - " [flag stands -> queued for the human]" + match (remove_label, subject_is_pr(&j)) { + // The reject fallback is per-subject: stripping the flag returns each to the state it + // was parked out of β€” the issue to the producer's backlog, the PR to the vet queue. + (true, false) => " [ai:close-candidate removed -> back to the producer queue]", + (true, true) => " [ai:close-candidate removed -> back to the vet queue]", + (false, _) => " [flag stands -> queued for the human]", }, if skip { " [comment deduped]" } else { "" } )) @@ -22051,6 +22410,18 @@ mod next_ready_tests { enum CcGate { /// The vetter UPHELD the current flag and no human has ruled: the human's to rule on. Presentable, + /// A PULL REQUEST whose `ai:close-candidate` is the vetter's own `record_verdict … close` β€” + /// no producer flag exists, so the flag lifecycle's verdict states cannot describe it, and it + /// is ALREADY the vetter's judgement that this subject should close (#211/#212). The human's + /// to dispose of, alongside `Presentable`, in the ONE mixed upheld inbox; never stranded and + /// never cleared β€” clearing it would erase a verdict in force. + VetterClose, + /// A torn `human-close` (#213): a trusted `πŸ‘€ human` close ruling is on the record and the + /// subject is still OPEN β€” the comment landed, the close did not. Not an inbox: the decision + /// is made and needs no second judgement, so the vetter's state-load EXECUTES the recorded + /// close (the same self-heal posture as the stranded-flag clearances, #179) and the human's + /// queue never sees the subject. + TornHumanClose, /// The REPO is archived (#206). Read-only: no label moves, no comment posts, no issue closes, /// so not one transition this FSM owns can be applied β€” including the stranded-flag clearance /// below, which is why this arm is FIRST in [`cc_gate`] and dominates every other state. @@ -22091,6 +22462,8 @@ impl CcGate { fn as_str(self) -> &'static str { match self { CcGate::Presentable => "presentable", + CcGate::VetterClose => "vetter-close-verdict", + CcGate::TornHumanClose => "torn-human-close", CcGate::RepoArchived => "repo-archived", CcGate::HumanRuled => "human-ruled", CcGate::NoFlag => "no-producer-flag", @@ -22150,6 +22523,8 @@ fn cc_gate( human_ruled: bool, flag_at: &str, verdict: Option<(String, String)>, + vetter_close: bool, + close_ruled: bool, ) -> CcGate { // FIRST, ahead of every other arm including the sacred one (#206). Every state below names // something a transition would DO β€” present it to a human, clear the label, ask the vetter for @@ -22162,7 +22537,21 @@ fn cc_gate( if human_ruled { return CcGate::HumanRuled; } + // A recorded close ruling on a still-open subject is a torn `human-close` (#213), and it + // dominates the whole flag lifecycle below: the human has already judged the very question + // every remaining state exists to route β€” spending a vetter verdict or a human presentation + // on it would re-ask a decided question. The completion consumes it. + if close_ruled { + return CcGate::TornHumanClose; + } if flag_at.is_empty() { + // Between "no claim" and "stranded": a PR-side `close` verdict explains the label without + // any producer flag existing (#211) β€” the vetter already judged, so the subject is the + // human's to dispose of. Checked only when no flag is live, because a LIVE flag is a newer + // claim than any stored verdict and takes the ordinary lifecycle below. + if vetter_close { + return CcGate::VetterClose; + } return CcGate::NoFlag; } match verdict { @@ -22189,6 +22578,12 @@ enum PrCoverage { /// The query failed or came back malformed. NEVER read as `Uncovered`: an unseen covering PR is /// work in flight nobody was told about, while a falsely-reported one only costs a second look. Unreadable, + /// The subject is a PULL REQUEST (#211), and "which open PRs claim to close this" is a question + /// GitHub only answers for issues β€” `closedByPullRequestsReferences` lives on the Issue type. + /// Its own state (open, and what closes via merging it) is on the row already, so there is no + /// unread signal here to fail safe about; a separate variant rather than `Unreadable` because + /// "cannot apply" and "could not read" ask a human for different things. + NotApplicable, } impl PrCoverage { @@ -22197,12 +22592,14 @@ impl PrCoverage { PrCoverage::Covered => "covered-by-open-pr", PrCoverage::Uncovered => "no-open-pr", PrCoverage::Unreadable => "unreadable", + PrCoverage::NotApplicable => "not-applicable-subject-is-a-pr", } } /// Is something in flight against this issue, as far as the read can tell? `Unreadable` counts, - /// which is what makes an unanswered query fail SAFE rather than merely fail. + /// which is what makes an unanswered query fail SAFE rather than merely fail β€” `NotApplicable` + /// does not, because it is not an unanswered read: the question has no referent on a PR. fn in_flight(self) -> bool { - !matches!(self, PrCoverage::Uncovered) + !matches!(self, PrCoverage::Uncovered | PrCoverage::NotApplicable) } } @@ -22288,6 +22685,10 @@ fn coverage_blocks_close(coverage: PrCoverage, grounds: FlagGrounds) -> bool { /// are no use to a reader who has to remember which way round the hazard runs. fn coverage_meaning(coverage: PrCoverage, grounds: FlagGrounds) -> &'static str { match (coverage, grounds) { + // Ahead of the grounds arms: on a PR subject neither grounds reading changes what the + // absent signal means, and falling through to a landing/coverage sentence would describe + // a read that never ran. + (PrCoverage::NotApplicable, _) => "coverage-does-not-apply-the-subject-is-a-pr", (PrCoverage::Uncovered, _) => "no-open-pr-claims-this-issue", (_, FlagGrounds::CitedLanding) => "reported-not-blocking-the-flag-cites-a-landing", (PrCoverage::Covered, FlagGrounds::NoLanding) => { @@ -22352,7 +22753,7 @@ fn covering_open_prs(resp: Option<&Value>) -> (PrCoverage, Vec) { /// genuinely types `Int!` and it keeps `-F`, because `-f n=7` sends `"7"` and fails the same query /// from the other side; blanket-converting the call site would trade one break for the other. /// -/// Separated from the fetch for the reason [`flagged_open_issues_args`] is: the flag/variable +/// Separated from the fetch for the reason [`flagged_open_subjects_args`] is: the flag/variable /// pairing is the thing that decides whether the read can answer at all, and inside a network call /// nothing asserts it. fn covering_prs_args(owner: &str, repo: &str, num: u64) -> Vec { @@ -22562,8 +22963,15 @@ fn next_close_candidate_row(f: &NextCcFacts) -> Value { .collect(); let reason_full = flag_reason(f.flag_body); let grounds = flag_grounds(&reason_full, f.slug, f.covering); - let note_full = last_cc_vetter_comment(f.detail).unwrap_or_default(); - let parts = cc_verdict_parts(¬e_full); + // The verdict on the row: the flag-pinned cc comment where one exists, else the PR-side + // `close` verdict that queued this row (#212) β€” whose own timestamp stands in for the pin, + // the same value the queue ordered by, because there is no flag to pin. + let note_full = last_cc_vetter_comment(f.detail) + .or_else(|| last_pr_close_verdict(f.detail).map(|(_, b)| b)) + .unwrap_or_default(); + let parts = cc_verdict_parts(¬e_full).or_else(|| { + pr_close_verdict_line(¬e_full).then(|| (f.flag_at.to_string(), "close".to_string())) + }); let prs: Vec = f .covering .iter() @@ -22620,10 +23028,10 @@ fn next_close_candidate_row(f: &NextCcFacts) -> Value { }) } -/// The whole-queue breakdown. Every flag the search returned lands in exactly one of these, plus the -/// fetch errors β€” so `flagged == presentable + unvetted + noProducerFlag + humanRuled + -/// vetterRejectedStillFlagged + archivedRepo + fetchErrors`, always, and a reader can see there is -/// no further bucket quietly absorbing rows. +/// The whole-queue breakdown. Every flagged subject the search returned lands in exactly one of +/// these, plus the fetch errors β€” so `flagged == presentable + vetterCloseVerdict + unvetted + +/// noProducerFlag + humanRuled + vetterRejectedStillFlagged + archivedRepo + fetchErrors`, always, +/// and a reader can see there is no further bucket quietly absorbing rows. /// /// `archived_repo` is counted BEFORE the gate rather than as a gate state: a frozen flag is not /// classified at all, because classifying it would cost a `gh issue view` to answer a question no @@ -22632,6 +23040,13 @@ fn next_close_candidate_row(f: &NextCcFacts) -> Value { struct FlagQueueCounts { flagged: usize, presentable: usize, + /// The PR-side `close` verdicts (#211/#212) β€” human-owned like `presentable`, counted apart + /// so folding two states into one number never hides which route filled the queue. + vetter_close: usize, + /// Torn `human-close` transitions (#213) β€” a recorded close ruling on a still-open subject. + /// Never presented: the vetter's state-load executes these, so a non-zero count here is a + /// completion that has not run yet or could not write. + torn_human_close: usize, unvetted: usize, no_flag: usize, human_ruled: usize, @@ -22675,7 +23090,10 @@ struct FlagQueueWithheld { /// nothing to explain the gap, which is the defect that let them sit in the queue unnoticed. fn next_close_candidate_doc(rows: Vec, w: &FlagQueueWithheld) -> Value { let returned = rows.len(); - let presentable = w.counts.presentable; + // The queue holds BOTH human-owned states (#212): upheld flags and PR-side close verdicts are + // one inbox, so `queue.presentable` is their sum while `counts` keeps them apart. Saturating + // for the reason the byte-budget test feeds usize::MAX: a count display must never panic. + let presentable = w.counts.presentable.saturating_add(w.counts.vetter_close); serde_json::json!({ "queue": { "presentable": presentable, @@ -22684,7 +23102,9 @@ fn next_close_candidate_doc(rows: Vec, w: &FlagQueueWithheld) -> Value { }, "counts": { "flagged": w.counts.flagged, - "presentable": presentable, + "presentable": w.counts.presentable, + "vetterCloseVerdict": w.counts.vetter_close, + "tornHumanClose": w.counts.torn_human_close, "unvetted": w.counts.unvetted, "noProducerFlag": w.counts.no_flag, "humanRuled": w.counts.human_ruled, @@ -22725,21 +23145,40 @@ fn search_issue_ref(hit: &Value) -> Option<(String, u64)> { Some((slug, num)) } -/// PURE: the argv of the one search behind both close-candidate inboxes. Every qualifier is -/// load-bearing and each is wrong in its own direction: without `--state open` a closed issue's -/// leftover flag joins the queue, without the label the queue is the whole backlog, and the `--json` -/// set is exactly what [`search_issue_ref`] needs to ADDRESS a hit β€” a field dropped here makes -/// every row unaddressable at once. +/// PURE: the argv of ONE HALF of the search behind both close-candidate inboxes β€” `noun` is +/// `"issues"` or `"prs"`, because `gh search issues` scopes to `type:issue` and `gh search prs` to +/// `type:pr`, so covering the whole flagged population (#211: a flag can sit on either subject +/// type) takes the same query spelled once per noun. Every qualifier is load-bearing and each is +/// wrong in its own direction: without `--state open` a closed subject's leftover flag joins the +/// queue, without the label the queue is the whole backlog, and the `--json` set is exactly what +/// [`search_issue_ref`] needs to ADDRESS a hit β€” a field dropped here makes every row +/// unaddressable at once. /// /// Separated from the fetch so the one thing deciding which population both inboxes see is a value a /// test can read; the network call around it is asserted by nothing. -fn flagged_open_issues_args() -> Vec { - let mut args: Vec = vec!["search".into(), "issues".into()]; +fn flagged_open_subjects_args(noun: &str) -> Vec { + flagged_subjects_args(noun, "open") +} + +/// The two `gh search` nouns that together cover the whole flagged population (#211). ONE +/// spelling, used by every enumerator β€” the open-side fetch and the closed-side sweep β€” so a +/// subject type cannot silently drop out of one of them: `gh search issues` scopes to +/// `type:issue` and `gh search prs` to `type:pr`, and the defect #211 names is exactly the `prs` +/// half missing. +const FLAG_SEARCH_NOUNS: [&str; 2] = ["issues", "prs"]; + +/// PURE: the argv shared by the open-side inboxes and the CLOSED-side stale-flag sweep β€” the one +/// place the label query is spelled, parameterised on the ONE qualifier that differs. The closed +/// spelling exists because `human-close`'s own order (comment β†’ close β†’ retire flag) can tear +/// between its last two writes, and a closed subject still flagged is invisible to every +/// `--state open` search β€” the #94 population, reachable again unless something enumerates it. +fn flagged_subjects_args(noun: &str, state: &str) -> Vec { + let mut args: Vec = vec!["search".into(), noun.into()]; args.extend(org_owner_args()); args.extend( [ "--state", - "open", + state, "--label", "ai:close-candidate", "--limit", @@ -22759,31 +23198,38 @@ fn flagged_open_issues_args() -> Vec { /// reading archived state separately is how the vetter's queue and the human's would come to /// disagree about which flags are actionable. The CLASSIFICATION is [`cc_gate`]'s, not this /// struct's β€” [`CcGate::RepoArchived`] is a state both inboxes get from the one classifier. -struct FlaggedIssues { +struct FlaggedSubjects { hits: Vec, archived: ArchivedRepos, } -/// The ONE org-wide search behind BOTH close-candidate inboxes: every open issue carrying -/// `ai:close-candidate`. Shared rather than written twice, so the vetter's queue and the human's are -/// populations of the same query β€” two spellings of "which issues are flagged" is how they would -/// come to answer differently. -/// -/// Errors rather than returning a falsely-empty set, for the reason the PR side does β€” including -/// when the ARCHIVED set is unreadable, since a flag whose repo's state is unknown cannot be -/// asserted to be actionable. -fn flagged_open_issues() -> Result { - let args = flagged_open_issues_args(); - let argref: Vec<&str> = args.iter().map(String::as_str).collect(); - let found = gh_json(&argref) - .and_then(|v| v.as_array().cloned()) - .ok_or_else(|| { - "error: `gh search issues --label ai:close-candidate` failed (transient API/auth?) β€” \ - aborting rather than report a falsely-empty close-candidate queue" - .to_string() - })?; +/// The ONE org-wide search behind BOTH close-candidate inboxes: every open subject β€” issue OR pull +/// request (#211) β€” carrying `ai:close-candidate`. Two `gh search` calls because that is how the +/// CLI spells one label query over both subject types, but shared rather than written twice, so +/// the vetter's queue and the human's are populations of the same query β€” two spellings of "what +/// is flagged" is how they would come to answer differently. Issues first, then PRs: a stable +/// concatenation order, so two runs over the same org emit the same document. +/// +/// Errors when EITHER half fails β€” including when the ARCHIVED set is unreadable β€” for the reason +/// the PR side does: a queue missing half its population is a falsely-empty queue wearing a +/// partial one's clothes, and an unseen flag is a subject parked with nobody owning it. +fn flagged_open_subjects() -> Result { + let mut found: Vec = Vec::new(); + for noun in FLAG_SEARCH_NOUNS { + let args = flagged_open_subjects_args(noun); + let argref: Vec<&str> = args.iter().map(String::as_str).collect(); + let half = gh_json(&argref) + .and_then(|v| v.as_array().cloned()) + .ok_or_else(|| { + format!( + "error: `gh search {noun} --label ai:close-candidate` failed (transient \ + API/auth?) β€” aborting rather than report a falsely-empty close-candidate queue" + ) + })?; + found.extend(half); + } let archived = archived_repos().map_err(archived_read_error)?; - Ok(FlaggedIssues { + Ok(FlaggedSubjects { hits: found, archived, }) @@ -22792,10 +23238,10 @@ fn flagged_open_issues() -> Result { /// Live `next_close_candidate`: classify the whole flagged set once, rank the human's half of it, /// then pay for the covering-PR read only on the rows actually returned. fn next_close_candidate_fetch(limit: usize) -> Result { - let FlaggedIssues { + let FlaggedSubjects { hits: found, archived: archived_repos, - } = flagged_open_issues()?; + } = flagged_open_subjects()?; let mut flags: Vec = Vec::new(); let mut counts = FlagQueueCounts { flagged: found.len(), @@ -22834,11 +23280,20 @@ fn next_close_candidate_fetch(limit: usize) -> Result { }; let flag = last_close_candidate_flag(&detail); let flag_at = flag.as_ref().map(|(a, _)| a.clone()).unwrap_or_default(); + // The PR-side close verdict, read ONCE: the gate's input, and β€” where it is the gate's + // whole answer β€” the presentable row's queue key and body (#211/#212). + let close_verdict = if subject_is_pr(&detail) { + last_pr_close_verdict(&detail) + } else { + None + }; let gate = cc_gate( archived_repos.contains(&slug), has_human_ruling(&label_names(&detail)), &flag_at, last_cc_vetter_comment(&detail).and_then(|b| cc_verdict_parts(&b)), + close_verdict.is_some(), + human_close_ruled(&detail), ); // EXHAUSTIVE on purpose: a new gate state must be given its own count rather than folding // silently into an existing one, which is what makes `flagged` provably the sum of its @@ -22846,6 +23301,10 @@ fn next_close_candidate_fetch(limit: usize) -> Result { // variants here that could drift from it. match gate { CcGate::Presentable => counts.presentable += 1, + CcGate::VetterClose => counts.vetter_close += 1, + // Counted, never presented: the vetter's state-load executes these (#213), and one + // showing here means that completion has not run yet or could not write. + CcGate::TornHumanClose => counts.torn_human_close += 1, CcGate::HumanRuled => counts.human_ruled += 1, CcGate::Unvetted => counts.unvetted += 1, CcGate::NoFlag => counts.no_flag += 1, @@ -22858,21 +23317,43 @@ fn next_close_candidate_fetch(limit: usize) -> Result { if gate == CcGate::RepoArchived { archived.push(withheld_entry(&format!("{slug}#{num}"), ARCHIVED_REPO_WHY)); } - if gate == CcGate::Presentable { - flags.push(PresentableFlag { + // BOTH human-owned states join the one queue (#212): an upheld flag keyed by the flag it + // judged, a PR close verdict keyed by its own timestamp β€” each the moment the subject + // became the human's, so FIFO measures the same limbo across subject types. + match gate { + CcGate::Presentable => flags.push(PresentableFlag { slug, num, flag_at, flag_body: flag.map(|(_, b)| b).unwrap_or_default(), detail, - }); + }), + CcGate::VetterClose => { + let (at, body) = + close_verdict.expect("VetterClose is only reachable when the verdict exists"); + flags.push(PresentableFlag { + slug, + num, + flag_at: at, + flag_body: body, + detail, + }); + } + _ => {} } } rank_flags(&mut flags); let rows: Vec = next_close_candidate_page(&flags, limit) .into_iter() .map(|f| { - let (coverage, covering) = covering_open_prs_fetch(&f.slug, f.num); + // The covering-PR read is an ISSUE question (GitHub answers it on the Issue type + // only), so a PR row states `not-applicable` rather than paying for a read that + // cannot answer and reporting its failure as an unread signal. + let (coverage, covering) = if subject_is_pr(&f.detail) { + (PrCoverage::NotApplicable, Vec::new()) + } else { + covering_open_prs_fetch(&f.slug, f.num) + }; next_close_candidate_row(&NextCcFacts { slug: &f.slug, num: f.num, @@ -23001,23 +23482,32 @@ mod next_close_candidate_tests { let at = "2026-07-20T09:00:00Z"; let parts = |w: &str| Some((at.to_string(), w.to_string())); assert_eq!( - cc_gate(false, false, at, parts("uphold")), + cc_gate(false, false, at, parts("uphold"), false, false), CcGate::Presentable ); assert_eq!( - cc_gate(false, false, at, parts("reject")), + cc_gate(false, false, at, parts("reject"), false, false), CcGate::RejectedStillFlagged ); // A word this machine does not know is not a verdict in force β€” the vetter still owes one, // exactly as an unstamped vet-protocol is never current. for unknown in ["close", "ready", "UPHOLD"] { assert_eq!( - cc_gate(false, false, at, parts(unknown)), + cc_gate(false, false, at, parts(unknown), false, false), CcGate::Unvetted, "{unknown}" ); } - assert_eq!(cc_gate(false, false, at, None), CcGate::Unvetted); + assert_eq!( + cc_gate(false, false, at, None, false, false), + CcGate::Unvetted + ); + // A LIVE flag outranks a stored PR close verdict: the flag is the newer claim, so the + // subject re-enters the vet lifecycle rather than sitting on the old judgement (#211). + assert_eq!( + cc_gate(false, false, at, None, true, false), + CcGate::Unvetted + ); assert!(CcGate::RejectedStillFlagged.is_stranded()); assert!(CcGate::NoFlag.is_stranded()); for live in [CcGate::Presentable, CcGate::Unvetted, CcGate::HumanRuled] { @@ -23036,7 +23526,9 @@ mod next_close_candidate_tests { false, false, second, - Some((first.to_string(), "uphold".into())) + Some((first.to_string(), "uphold".into())), + false, + false ), CcGate::Unvetted ); @@ -23045,27 +23537,111 @@ mod next_close_candidate_tests { false, false, first, - Some((first.to_string(), "uphold".into())) + Some((first.to_string(), "uphold".into())), + false, + false ), CcGate::Presentable ); } + // The PR-side close verdict (#211/#212): with no producer flag, the label is explained by the + // vetter's own judgement β€” human-owned like an upheld flag, never stranded, never cleared. + #[test] + fn a_pr_close_verdict_is_human_owned_not_stranded_and_not_cleared() { + assert_eq!( + cc_gate(false, false, "", None, true, false), + CcGate::VetterClose + ); + // A human ruling still dominates it, exactly as it dominates an upheld flag. + assert_eq!( + cc_gate(false, true, "", None, true, false), + CcGate::HumanRuled + ); + // …and an archived repo dominates everything. + assert_eq!( + cc_gate(true, false, "", None, true, false), + CcGate::RepoArchived + ); + assert!(!CcGate::VetterClose.is_stranded()); + // NOT clearable: the label is a verdict in force, and the #179 clearance erasing it would + // erase the vetter's own judgement. + assert_eq!(cc_stranded_cleared_comment(CcGate::VetterClose, ""), None); + assert_eq!(CcGate::VetterClose.as_str(), "vetter-close-verdict"); + } + + // The torn `human-close` (#213): a recorded close ruling on a still-open subject dominates + // the flag lifecycle ENTIRELY β€” flagless, unvetted, upheld, rejected or verdict-close alike β€” + // because the human already answered the question every one of those states routes. Only a + // live `human:*` label ruling and an archived repo rank above it. + #[test] + fn a_recorded_close_ruling_dominates_the_flag_lifecycle() { + let at = "2026-07-20T09:00:00Z"; + for (flag_at, verdict, vetter_close) in [ + ("", None, false), + ("", None, true), + (at, None, false), + (at, Some((at.to_string(), "uphold".to_string())), false), + (at, Some((at.to_string(), "reject".to_string())), false), + ] { + assert_eq!( + cc_gate(false, false, flag_at, verdict.clone(), vetter_close, true), + CcGate::TornHumanClose, + "({flag_at:?}, {verdict:?}, {vetter_close})" + ); + assert_eq!( + cc_gate(false, true, flag_at, verdict.clone(), vetter_close, true), + CcGate::HumanRuled + ); + assert_eq!( + cc_gate(true, false, flag_at, verdict, vetter_close, true), + CcGate::RepoArchived + ); + } + // Consumed by the COMPLETION, so it is neither stranded nor label-clearable. + assert!(!CcGate::TornHumanClose.is_stranded()); + assert_eq!( + cc_stranded_cleared_comment(CcGate::TornHumanClose, at), + None + ); + assert_eq!(CcGate::TornHumanClose.as_str(), "torn-human-close"); + } + // The precedence, in the one order that matters: a human ruling is sacred and already made, so // it dominates a flag, a verdict, and the absence of either. #[test] fn a_human_ruling_dominates_and_a_flagless_label_is_stranded() { let at = "2026-07-20T09:00:00Z"; assert_eq!( - cc_gate(false, true, at, Some((at.to_string(), "uphold".into()))), + cc_gate( + false, + true, + at, + Some((at.to_string(), "uphold".into())), + false, + false + ), + CcGate::HumanRuled + ); + assert_eq!( + cc_gate(false, true, "", None, false, false), CcGate::HumanRuled ); - assert_eq!(cc_gate(false, true, "", None), CcGate::HumanRuled); // Labelled, with no trusted producer comment behind it: nothing was CLAIMED, so there is // nothing to rule on evidence β€” and the vetter skips it too, which is why it is named. - assert_eq!(cc_gate(false, false, "", None), CcGate::NoFlag); assert_eq!( - cc_gate(false, false, "", Some((at.to_string(), "uphold".into()))), + cc_gate(false, false, "", None, false, false), + CcGate::NoFlag + ); + assert_eq!( + cc_gate( + false, + false, + "", + Some((at.to_string(), "uphold".into())), + false, + false + ), CcGate::NoFlag ); } @@ -23083,20 +23659,36 @@ mod next_close_candidate_tests { let at = "2026-07-20T09:00:00Z"; // Each of these is a DIFFERENT state when the repo is live. Archived, all of them are the // same one β€” which is what "dominates" means and what a reordering would break. - for (human, flag_at, verdict) in [ - (false, at, Some((at.to_string(), "uphold".to_string()))), - (false, at, Some((at.to_string(), "reject".to_string()))), - (false, at, None), - (false, "", None), - (true, at, Some((at.to_string(), "uphold".to_string()))), + for (human, flag_at, verdict, vetter_close) in [ + ( + false, + at, + Some((at.to_string(), "uphold".to_string())), + false, + ), + ( + false, + at, + Some((at.to_string(), "reject".to_string())), + false, + ), + (false, at, None, false), + (false, "", None, false), + (false, "", None, true), + ( + true, + at, + Some((at.to_string(), "uphold".to_string())), + false, + ), ] { assert_ne!( - cc_gate(false, human, flag_at, verdict.clone()), + cc_gate(false, human, flag_at, verdict.clone(), vetter_close, false), CcGate::RepoArchived, "a LIVE repo never produces RepoArchived" ); assert_eq!( - cc_gate(true, human, flag_at, verdict), + cc_gate(true, human, flag_at, verdict, vetter_close, false), CcGate::RepoArchived, "archived must dominate (human_ruled={human}, flag_at={flag_at:?})" ); @@ -23741,8 +24333,10 @@ mod next_close_candidate_tests { #[test] fn the_envelope_states_what_the_page_left_behind() { let w = withheld(FlagQueueCounts { - flagged: 10, + flagged: 11, presentable: 5, + vetter_close: 1, + torn_human_close: 0, unvetted: 2, no_flag: 1, human_ruled: 1, @@ -23751,15 +24345,18 @@ mod next_close_candidate_tests { fetch_errors: 0, }); let doc = next_close_candidate_doc(vec![json!({"issue": "o/r#7"})], &w); - assert_eq!(doc["queue"]["presentable"], json!(5)); + // `queue.presentable` is the ONE mixed inbox (#212): upheld flags + PR close verdicts. + assert_eq!(doc["queue"]["presentable"], json!(6)); assert_eq!(doc["queue"]["returned"], json!(1)); - assert_eq!(doc["queue"]["more"], json!(4)); + assert_eq!(doc["queue"]["more"], json!(5)); // The counts partition the search: nothing is in two buckets and nothing is in none. // `archivedRepo` is one of the parts (#206) β€” a flag withheld for a reason that is not in // this list is a flag the sum cannot account for, which is how a silent drop would look. let c = &doc["counts"]; let parts: u64 = [ "presentable", + "vetterCloseVerdict", + "tornHumanClose", "unvetted", "noProducerFlag", "humanRuled", @@ -23920,6 +24517,8 @@ mod next_close_candidate_tests { counts: FlagQueueCounts { flagged: usize::MAX, presentable: usize::MAX, + vetter_close: usize::MAX, + torn_human_close: usize::MAX, unvetted: usize::MAX, no_flag: usize::MAX, human_ruled: usize::MAX, @@ -23970,12 +24569,12 @@ mod next_close_candidate_tests { {NCC_WITHHELD_FIXED_BYTES} allowed" ); - // Fifteen numeric fields in the envelope: eight counts, three queue figures, three - // overflows. + // Seventeen numeric fields in the envelope: ten counts (#212 added vetterCloseVerdict, + // #213 tornHumanClose), three queue figures, three overflows. let env_len = next_close_candidate_doc(vec![], &withheld(FlagQueueCounts::default())) .to_string() .len() - + 15 * NCC_MAX_DIGITS; + + 17 * NCC_MAX_DIGITS; assert!( env_len <= NCC_ENVELOPE_BYTES, "the envelope's fixed cost is {env_len} bytes, over the {NCC_ENVELOPE_BYTES} allowed" @@ -23983,29 +24582,37 @@ mod next_close_candidate_tests { } // Both close-candidate inboxes must enumerate ONE population, or the vetter and the human come - // to disagree about which issues are flagged. The search is shared; this pins the QUERY they - // share, which is otherwise inside a network call nothing asserts. + // to disagree about what is flagged. The search is shared; this pins the QUERY they share, + // which is otherwise inside a network call nothing asserts β€” for BOTH halves, because `gh + // search issues` scopes to `type:issue` and a PR-shaped flag is only reachable through the + // `prs` spelling (#211: the defect was exactly this half missing). #[test] fn the_two_close_candidate_inboxes_search_one_population() { - let args = flagged_open_issues_args(); - let pairs: Vec<(&str, &str)> = args - .windows(2) - .map(|w| (w[0].as_str(), w[1].as_str())) - .collect(); - for want in [ - // Open only: a closed issue's leftover flag is not the human's inbox. - ("--state", "open"), - // Flagged only: without it this is the whole backlog. - ("--label", "ai:close-candidate"), - // Exactly what `search_issue_ref` addresses a hit with, plus the title. - ("--json", "url,number,repository,title"), - ] { - assert!(pairs.contains(&want), "{want:?} missing from {args:?}"); - } - assert_eq!(&args[..2], &["search".to_string(), "issues".to_string()]); - // The org scope comes from the ONE source every other search reads, never a literal here. - for owner in org_owner_args() { - assert!(args.contains(&owner), "{owner} missing from {args:?}"); + // Iterating the CONST the live fetchers iterate β€” with the noun pinned per pass β€” is what + // makes "the prs half went missing" a test failure instead of a silent scope shrink. + assert_eq!(FLAG_SEARCH_NOUNS, ["issues", "prs"]); + for noun in FLAG_SEARCH_NOUNS { + let args = flagged_open_subjects_args(noun); + let pairs: Vec<(&str, &str)> = args + .windows(2) + .map(|w| (w[0].as_str(), w[1].as_str())) + .collect(); + for want in [ + // Open only: a closed subject's leftover flag is not the human's inbox. + ("--state", "open"), + // Flagged only: without it this is the whole backlog. + ("--label", "ai:close-candidate"), + // Exactly what `search_issue_ref` addresses a hit with, plus the title. + ("--json", "url,number,repository,title"), + ] { + assert!(pairs.contains(&want), "{want:?} missing from {args:?}"); + } + assert_eq!(&args[..2], &["search".to_string(), noun.to_string()]); + // The org scope comes from the ONE source every other search reads, never a literal + // here β€” in BOTH halves, or the two subject types would enumerate different orgs. + for owner in org_owner_args() { + assert!(args.contains(&owner), "{owner} missing from {args:?}"); + } } } @@ -24228,7 +24835,7 @@ fn mcp_all_tools() -> Value { { "name": "next_close_candidate", "narrows": "limit", - "description": "The next ai:close-candidate flag to rule on, OLDEST FLAG FIRST (the flag parks the issue β€” it is neither the producer's work nor closed β€” so the wait is the cost, and evidence about a moving main decays). Per flag: the issue's title/state/labels/createdAt, the producer's stated reason (the CLAIM being checked, never a fact) with `flag.grounds` saying whether it cites a landing, the vetter's verdict pinned to that flag (`atFlag` false means it judged a superseded claim), and whether an OPEN PR claims to close the issue. Coverage is always reported; `openPr.blocksClose` pairs it with the grounds β€” a flag citing no landing is the `merely COVERED BY AN OPEN PR` case and blocks (an unreadable answer blocks too), while a flag citing a merged commit/PR does not, since a redundant PR in flight does not un-land what landed. `counts.unvetted` is where a flag the vetter has not judged went; `strandedFlags` are labels parking an issue with nothing consuming them β€” the vetter's state-load clears both kinds, so one listed here is a clearance that has not run yet or could not write.", + "description": "The next close-candidate to rule on β€” an upheld flag on an ISSUE OR A PR, or a PR the vetter verdicted `close` (one mixed queue, #211/#212) β€” OLDEST FIRST (the flag parks the subject β€” it is neither the producer's work nor closed β€” so the wait is the cost, and evidence about a moving main decays). Per row: the subject's title/state/labels/createdAt (`url` says which subject type), the producer's stated reason (the CLAIM being checked, never a fact) with `flag.grounds` saying whether it cites a landing, and the vetter's judgement pinned to what it judged (`atFlag` false means a superseded claim; a PR close verdict reports as `close` at its own timestamp). Coverage is reported for issues; on a PR row it is `not-applicable-subject-is-a-pr` and never blocks. For issues `openPr.blocksClose` pairs coverage with the grounds β€” a flag citing no landing is the `merely COVERED BY AN OPEN PR` case and blocks (an unreadable answer blocks too), while a flag citing a merged commit/PR does not, since a redundant PR in flight does not un-land what landed. `counts.unvetted` is where a flag the vetter has not judged went; `counts.vetterCloseVerdict` is the PR-close-verdict share of the queue; `strandedFlags` are labels parking a subject with nothing consuming them β€” the vetter's state-load clears both kinds, so one listed here is a clearance that has not run yet or could not write.", "inputSchema": { "type": "object", "properties": { @@ -24289,7 +24896,7 @@ fn mcp_all_tools() -> Value { { "name": "unvetted_close_candidates", "narrows": "limit", - "description": "State-load: ONE PAGE of the producer close-candidate flags on open issues to vet. Per issue: flagAt, flagReason (the producer's stated evidence), labels, humanSacred, vettedAtFlag. `counts` is whole-queue; `more` is how many this page left behind β€” the NEXT run's work: a run spends at most 3 ITEMS in total, shared with the PRs from unvetted, so never re-call for a second page. Human-ruled and already-vetted-at-flag issues are excluded.", + "description": "State-load: ONE PAGE of the producer close-candidate flags on open SUBJECTS β€” issues AND pull requests (#211; the row's url says which) β€” to vet. Per subject: flagAt, flagReason (the producer's stated evidence), labels, humanSacred, vettedAtFlag. `counts` is whole-queue; `more` is how many this page left behind β€” the NEXT run's work: a run spends at most 3 ITEMS in total, shared with the PRs from unvetted, so never re-call for a second page. Human-ruled and already-vetted-at-flag subjects are excluded, as are PRs whose label is the vetter's own `close` verdict (counts.skipVetterClose β€” the human's queue, not a claim to judge).", "inputSchema": { "type": "object", "properties": { @@ -24300,7 +24907,7 @@ fn mcp_all_tools() -> Value { }, { "name": "close_candidate_context", - "description": "Everything needed to judge one close-candidate flag: the issue's title, body, labels, createdAt, state, and the trusted ai:producer flag(s) + any prior ai:vetter verdicts.", + "description": "Everything needed to judge one close-candidate flag, on an issue OR a PR (#211): the subject's title, body, labels, createdAt, state, url, and the trusted ai:producer flag(s) + any prior ai:vetter verdicts.", "inputSchema": { "type": "object", "properties": { @@ -24311,7 +24918,7 @@ fn mcp_all_tools() -> Value { }, { "name": "record_close_candidate_verdict", - "description": "The vetter's write on a flag: uphold (leave it queued for the human) or reject (drop ai:close-candidate, returning the issue to the producer). Posts a flag-pinned ai:vetter comment. Refuses if a human has ruled.", + "description": "The vetter's write on a flag, on an issue OR a PR (#211): uphold (leave it queued for the human) or reject (drop ai:close-candidate β€” an issue returns to the producer's backlog, a PR to its vet lane). Posts a flag-pinned ai:vetter comment. Refuses if a human has ruled, and refuses a PR whose label is the vetter's own `close` verdict (no producer claim to judge β€” that PR is the human's).", "inputSchema": { "type": "object", "properties": { @@ -24329,7 +24936,7 @@ fn mcp_all_tools() -> Value { "type": "object", "properties": { "pr": {"type": "string", "description": "owner/repo#number"}, - "ruling": {"type": "string", "enum": ["reject", "design", "close-candidate"]}, + "ruling": {"type": "string", "enum": ["reject", "design"]}, "note": {"type": "string", "description": "One line: what you ruled and the evidence it rests on."}, "rework": {"type": "string", "description": "The work order the producer executes β€” emitted as a trusted 'Rework note @: …' comment, the exact form the producer's trusted-comments marker read accepts. Required for reject; one of rework/park for design."}, "park": {"type": "boolean", "description": "EXPLICIT pure park (design only): the ruling stands, the human keeps the next move. Never the default."} @@ -24344,7 +24951,7 @@ fn mcp_all_tools() -> Value { "type": "object", "properties": { "issue": {"type": "string", "description": "owner/repo#number"}, - "ruling": {"type": "string", "enum": ["reject", "design", "close-candidate", "keep-open"]}, + "ruling": {"type": "string", "enum": ["reject", "design", "keep-open"]}, "note": {"type": "string", "description": "One line: what you ruled and the evidence it rests on."}, "rework": {"type": "string", "description": "The work order the producer executes β€” emitted as a trusted 'Rework note @: …' comment. Required for reject; one of rework/park for design."}, "park": {"type": "boolean", "description": "EXPLICIT pure park (design only). Never the default."} @@ -24354,7 +24961,7 @@ fn mcp_all_tools() -> Value { }, { "name": "human_close", - "description": "The human's TERMINAL transition on an issue OR a PR, in one call: apply human:close-candidate + a pinned πŸ‘€ human comment, retire the pending ai:close-candidate flag, then close. The subject type is resolved by lookup, so one reference cannot act on the wrong one of the two populations that share the label name. On an already-closed subject it only clears a stale flag, and invents no ruling.", + "description": "The human's TERMINAL transition on an issue OR a PR, in one call β€” decide+do, no state between (#213): post the pinned πŸ‘€ human ruling comment, CLOSE the subject, then retire the pending ai:close-candidate flag. The comment alone is the durable intent; a tear between it and the close is completed by the vetter's own state-load. The subject type is resolved by lookup, so one reference cannot act on the wrong population. On an already-closed subject it only clears a stale flag, and invents no ruling.", "inputSchema": { "type": "object", "properties": { @@ -29676,9 +30283,10 @@ enum Cmd { #[arg(long)] dry_run: bool, }, - /// Human TERMINAL transition on an issue OR a PR: rule human:close-candidate, retire the - /// pending ai:close-candidate flag, and close β€” one transition. The subject type is resolved by - /// lookup, so one owner/repo #n reference can never act on the wrong one of the two populations. + /// Human TERMINAL transition on an issue OR a PR β€” decide+do, one transition, no label + /// (#213): post the pinned πŸ‘€ human ruling comment, close, retire the pending + /// ai:close-candidate flag. The subject type is resolved by lookup, so one owner/repo #n + /// reference can never act on the wrong one of the two populations. HumanClose { /// owner/repo slug: String, @@ -29846,11 +30454,11 @@ struct PrSignals { deploy_done_at_head: bool, parked: bool, ui_missing_screenshot: bool, - /// The PR carries a human decision that PARKS it: an absolutely-parking label - /// (`human:close-candidate` / retired `human:reject`), or a `human:design` with no work order - /// ([`Delegation::Parked`] β€” the explicit park). Blocks routine producer action, even over a - /// stale `ai:*` label. An EXECUTED `human:design` delegation sets neither flag: the PR is back - /// in the ordinary flow (awaiting re-vet), so it classifies from CI like any reworked PR. + /// The PR carries a human decision that PARKS it: an absolutely-parking label (the retired + /// `human:reject`), or a `human:design` with no work order ([`Delegation::Parked`] β€” the + /// explicit park). Blocks routine producer action, even over a stale `ai:*` label. An + /// EXECUTED `human:design` delegation sets neither flag: the PR is back in the ordinary flow + /// (awaiting re-vet), so it classifies from CI like any reworked PR. human_parked: bool, /// The PR carries a `human:design` ruling whose trusted work order is pinned to the CURRENT /// head ([`Delegation::Ordered`]): the ruling is the producer's WORK ORDER (#111) β€” execute it @@ -31184,9 +31792,10 @@ fn coverage_uncovered() -> Option { } /// True when an uncovered issue belongs to the PRODUCER's untouched backlog: NOT already flagged -/// `ai:close-candidate` (that is the human's close queue, surfaced separately) and carrying NO -/// `human:*` label (a human ruling is the human's inbox, not the producer's). The raw -/// `uncovered-issues` set does NOT apply these exclusions β€” the backlog is deliberately narrower. +/// `ai:close-candidate` (the flag parks the subject in the close-candidate machinery β€” the +/// vetter's inbox un-vetted, the human's upheld) and carrying NO `human:*` label (a human ruling +/// is the human's inbox, not the producer's). The raw `uncovered-issues` set does NOT apply these +/// exclusions β€” the backlog is deliberately narrower. fn is_producer_backlog(meta: &Value) -> bool { !meta .get("labels") @@ -33948,13 +34557,14 @@ mod queue_tests { }) } - // A human ruling on an ISSUE was invisible to the old check, which looked only for - // `human:keep-open` (a label the org does not use) and `human:close-candidate`. So an issue a - // human had already parked with `human:reject` / `human:design` could still be flagged. + // A human ruling on an ISSUE must be sacred under EVERY label in the ruling vocabulary β€” the + // old check hard-coded a subset, so an issue a human had already parked with `human:reject` / + // `human:design` could still be flagged. Iterating the vocabulary array itself is the fix + // that cannot re-drift: a label added there gains this protection with it. #[test] fn a_human_ruling_on_an_issue_is_sacred_whichever_label_it_wears() { let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); - for l in ["human:reject", "human:design", "human:close-candidate"] { + for l in HUMAN_RULING_LABELS { assert_eq!( close_candidate_plan("OPEN", &s(&[l]), false, false), CloseFlagPlan::RefuseHuman, @@ -34036,6 +34646,49 @@ mod queue_tests { assert_eq!(cc_stranded_cleared_comment(gate, at), None); } + /// #211/#212: PR subjects flow through the SAME row classifier. A producer flag on a PR is the + /// vetter's to vet; a PR whose label is the vetter's own `close` verdict is the human's, named + /// apart from an upheld flag; and that verdict-close PR is never cleared as flagless, because + /// the clearance would strip a verdict in force. + #[test] + fn cc_row_classifies_pr_subjects_through_the_same_gate() { + let at = "2026-07-17T21:23:11Z"; + let mut flagged = flagged_issue(&["ai:close-candidate"], at, "superseded-by: #48", vec![]); + flagged["url"] = json!("https://github.com/o/r/pull/63"); + let (gate, action, row) = cc_row("o/r", 63, "t", &flagged, false); + assert_eq!(gate, CcGate::Unvetted, "a flagged PR is the vetter's inbox"); + assert_eq!(action, "vet"); + assert_eq!(row["url"], json!("https://github.com/o/r/pull/63")); + let vc = json!({ + "state": "OPEN", + "labels": [{"name": "ai:close-candidate"}], + "url": "https://github.com/o/r/pull/63", + "comments": [{ + "author": {"login": TRUSTED_AUTHOR}, + "createdAt": "2026-07-26T10:00:00Z", + "body": "πŸ€– ai:vetter\nvet-protocol 1\nReviewed f0107db: close β€” superseded by #48", + }], + }); + let (gate, action, _) = cc_row("o/r", 63, "t", &vc, false); + assert_eq!(gate, CcGate::VetterClose); + assert_eq!(action, "skip-vetter-close"); + assert_eq!(cc_stranded_cleared_comment(gate, ""), None); + // An untrusted "verdict" does not explain the label β€” provenance first, as everywhere: + // the row falls to NoFlag and the clearance handles it as the stranded label it is. + let spoofed = json!({ + "state": "OPEN", + "labels": [{"name": "ai:close-candidate"}], + "url": "https://github.com/o/r/pull/63", + "comments": [{ + "author": {"login": "someone-else"}, + "createdAt": "2026-07-26T10:00:00Z", + "body": "πŸ€– ai:vetter\nReviewed f0107db: close β€” spoofed", + }], + }); + let (gate, _, _) = cc_row("o/r", 63, "t", &spoofed, false); + assert_eq!(gate, CcGate::NoFlag); + } + /// A human ruling dominates a stranded label. `human:*` is sacred with no carve-out, and /// "the label looks stuck" is not one β€” the clearance is an AI write, so it must not fire on an /// issue a human has already parked, whatever the flag underneath still says. @@ -34284,6 +34937,10 @@ mod queue_tests { ], "skipped": [ row("rainlanguage/raindex", 523, "nothing visual happens", "skip-vetted-at-flag", "issues"), + // UPHELD too (#212): a PR whose label is the vetter's own `close` verdict is the + // same judgement by its other route, and the human's ONE disposal inbox holds it + // beside the upheld flags. + row("rainlanguage/rain.dia", 63, "superseded test PR", "skip-vetter-close", "pull"), // None of these is UPHELD: one is a human ruling, one has no flag to judge, and one // is `rain.erc4626.words#93`'s state β€” a flag the vetter REJECTED, still labelled. // The last used to land in `upheld`, because "vetted at its flag" was read as @@ -34299,7 +34956,7 @@ mod queue_tests { // Populated, and each item carries EXACTLY the generic subject-item shape β€” `url` included. assert_eq!(unvetted.len(), 2); - assert_eq!(upheld.len(), 1); + assert_eq!(upheld.len(), 2); assert_eq!( unvetted[0], json!({"repo": "rainlanguage/raindex", "number": 512, @@ -34318,8 +34975,13 @@ mod queue_tests { "url": "https://github.com/rainlanguage/raindex/issues/523", "title": "nothing visual happens"}) ); - // Only `skip-vetted-at-flag` is upheld β€” a human ruling, a missing flag, and a REJECTED - // flag still wearing its label are none of them. + // The vetter-close PR sits beside it, PR url intact β€” one inbox, mixed subjects (#212). + assert_eq!( + upheld[1]["url"], + json!("https://github.com/rainlanguage/rain.dia/pull/63") + ); + // Only `skip-vetted-at-flag` and `skip-vetter-close` are upheld β€” a human ruling, a + // missing flag, and a REJECTED flag still wearing its label are none of them. for r in &upheld { assert_ne!(r["number"], json!(184)); assert_ne!(r["number"], json!(999)); @@ -34377,7 +35039,7 @@ mod queue_tests { CloseFlagPlan::RefuseHuman ); assert_eq!( - close_candidate_plan("OPEN", &s(&["human:close-candidate"]), false, false), + close_candidate_plan("OPEN", &s(&["human:design"]), false, false), CloseFlagPlan::RefuseHuman ); assert_eq!( @@ -35374,7 +36036,7 @@ diff --git a/a.c b/a.c #[test] fn human_override_labels_detected() { - for l in ["human:reject", "human:design", "human:close-candidate"] { + for l in PR_SACRED_LABELS { let p = json!({"labels": [{"name": "ai:ready"}, {"name": l}]}); assert!(has_human_override(&p), "must override on {l}"); } @@ -37815,9 +38477,10 @@ mod repo_root_tests { let exempt = [ // The accessor itself. "org_owner_args", - // PURE argv builders. Their live callers β€” `flagged_open_issues` for the first β€” do - // the withholding, which is why the filter is not visible in the builder. - "flagged_open_issues_args", + // PURE argv builders. Their live callers β€” `flagged_open_subjects` and + // `sweep_stale_closed_flags` for the first β€” do the withholding, which is why the + // filter is not visible in the builder. + "flagged_subjects_args", // RETIRED one-shot sweeps (#108 item 4, #133). Nothing writes either label any more, // both populations are empty in scope, and neither OFFERS work: a per-PR edit that // fails is already reported as a failed edit rather than queued as a task. @@ -37853,9 +38516,9 @@ mod repo_root_tests { // is only sound if the set actually reaches the classifier, so the read is pinned here. let reads = items_whose_code_contains(&format!("archived_re{}", "pos()")); assert!( - reads.contains(&"flagged_open_issues".to_string()), - "`flagged_open_issues` is the ONE search behind both close-candidate inboxes; if it \ - stops reading the archived set, `cc_gate` is handed `false` for every issue and the \ + reads.contains(&"flagged_open_subjects".to_string()), + "`flagged_open_subjects` is the ONE search behind both close-candidate inboxes; if it \ + stops reading the archived set, `cc_gate` is handed `false` for every subject and the \ `RepoArchived` state becomes unreachable. Reading items: {reads:?}" ); // …and the classifier is where the decision is made, in both of them. @@ -44845,7 +45508,7 @@ mod worklist_tests { // Any human:* ruling β†’ the human's inbox, not the producer's. assert!(!is_producer_backlog(&mk(&["human:keep-open"]))); assert!(!is_producer_backlog(&mk(&["human:design"]))); - assert!(!is_producer_backlog(&mk(&["bug", "human:close-candidate"]))); + assert!(!is_producer_backlog(&mk(&["bug", "human:reject"]))); // Missing labels field β†’ conservatively counted in, never silently dropped. assert!(is_producer_backlog(&json!({}))); } @@ -45892,9 +46555,23 @@ mod fsm_completeness_tests { classify_lane(&s(&["ai:design"]), None, false), (Lane::VetterVerdicts, "ai:design".to_string()) ); + // The close-candidate hand-off (#211/#212): NOT a verdict-lane state any more β€” the flag + // machinery inventories the PR (mixed unvetted/upheld arrays), so the classifier hands it + // over wholesale… assert_eq!( classify_lane(&s(&["ai:close-candidate"]), None, false), - (Lane::VetterVerdicts, "ai:close-candidate".to_string()) + (Lane::CloseCandidate, "ai:close-candidate".to_string()) + ); + // …ahead of every remaining ai:* state (the flag PARKS the subject, exactly as it parks a + // flagged issue out of the backlog), while a human decision still dominates it β€” the same + // precedence `cc_gate` gives `HumanRuled`, so the two classifiers name one owner. + assert_eq!( + classify_lane(&s(&["ai:ready", "ai:close-candidate"]), Some(true), false), + (Lane::CloseCandidate, "ai:close-candidate".to_string()) + ); + assert_eq!( + classify_lane(&s(&["ai:close-candidate", "human:design"]), None, false), + (Lane::HumanDecisions, "human:design".to_string()) ); // label-less: leak if the producer commented, else un-vetted. assert_eq!( @@ -45929,19 +46606,19 @@ mod fsm_completeness_tests { #[test] fn lanes_doc_emits_every_state_with_the_right_members() { let prs = vec![ - qpr(1, &[], None, false), // un-vetted - qpr(2, &["ai:ready"], Some(false), false), // un-vetted (verdict not current) - qpr(3, &["ai:ready"], Some(true), false), // ai:ready - qpr(4, &["ai:reject"], None, false), // ai:reject - qpr(5, &["ai:relink"], None, false), // ai:relink - qpr(6, &["ai:design"], None, false), // ai:design - qpr(7, &["ai:close-candidate"], None, false), // ai:close-candidate (PR) - qpr(8, &["ai:blocked-deploy"], None, false), // producer-blocked (RETIRED residue #162) + qpr(1, &[], None, false), // un-vetted + qpr(2, &["ai:ready"], Some(false), false), // un-vetted (verdict not current) + qpr(3, &["ai:ready"], Some(true), false), // ai:ready + qpr(4, &["ai:reject"], None, false), // ai:reject + qpr(5, &["ai:relink"], None, false), // ai:relink + qpr(6, &["ai:design"], None, false), // ai:design + // NOT in any lane (#211/#212): the close-candidate machinery inventories it. + qpr(7, &["ai:close-candidate"], None, false), + qpr(8, &["ai:blocked-deploy"], None, false), // producer-blocked (RETIRED residue #162) qpr(9, &["ai:blocked-infra"], None, false), qpr(10, &["ai:blocked-on"], None, false), // vet-lifecycle: the vetter clears it (#161) qpr(11, &["human:reject"], None, false), // human decisions qpr(12, &["human:design"], None, false), - qpr(13, &["human:close-candidate"], None, false), qpr(14, &[], None, true), // leak qpr(15, &["ai:reject"], None, false), // a second ai:reject member ]; @@ -45956,7 +46633,11 @@ mod fsm_completeness_tests { assert_eq!(count("vetter-verdicts", "ai:reject"), 2); assert_eq!(count("vetter-verdicts", "ai:relink"), 1); assert_eq!(count("vetter-verdicts", "ai:design"), 1); - assert_eq!(count("vetter-verdicts", "ai:close-candidate"), 1); + // The retired PR lane state (#212): a flagged PR appears in NO lane β€” the close-candidate + // state-load inventories it β€” and the retired cell is absent rather than zero, so an old + // dashboard renders a dimmed box and a new one draws nothing. + assert_eq!(count("vetter-verdicts", "ai:close-candidate"), 0); + assert!(doc.pointer("/close-candidate").is_none(), "not a lane"); assert_eq!(count("producer-blocked", "ai:blocked-deploy"), 1); assert_eq!(count("producer-blocked", "ai:blocked-infra"), 1); // #161: blocked-on emits under the VETTER's lane β€” the dash files it as vetter action @@ -45965,7 +46646,6 @@ mod fsm_completeness_tests { assert_eq!(count("producer-blocked", "ai:blocked-on"), 0); assert_eq!(count("human-decisions", "human:reject"), 1); assert_eq!(count("human-decisions", "human:design"), 1); - assert_eq!(count("human-decisions", "human:close-candidate"), 1); assert_eq!(count("leak", "leak"), 1); // the PR list carries {repo, number, url, title}. #1 (never labelled) and #2 (ai:ready with @@ -45986,14 +46666,16 @@ mod fsm_completeness_tests { ); assert!(arv.get("title").is_some()); - // total across lanes == number of PRs (each bucketed exactly once). + // total across lanes == number of PRs MINUS the close-candidate hand-off (#211/#212): + // #7 is inventoried by the flag machinery, and counting it in a lane too would be the + // one-subject-two-states split #212 retired. let mut total = 0usize; for (_, states) in doc.as_object().unwrap() { for (_, b) in states.as_object().unwrap() { total += b.get("count").and_then(|v| v.as_u64()).unwrap() as usize; } } - assert_eq!(total, prs.len()); + assert_eq!(total, prs.len() - 1); } } @@ -46004,8 +46686,8 @@ mod fsm_completeness_tests { // subject arrays shipped `{repo, number, title}`. Nothing failed. One consumer (rain-org-health's // pipeline panel) simply could not render a link, because the field was not there β€” and could not // derive one, because `{repo, number}` alone does not say whether the number is an issue or a PR. -// The arrays split BOTH ways (`states`/`leaks` PRs, the rest issues), and which way is a fact about -// each key's source query, not something the payload states. +// No per-key rule says either β€” the close-candidate arrays MIX the two (#211/#212) β€” and which +// subjects a key holds is a fact about its source query, not something the payload states. // // The oracle is the LANE item, which was always right: every top-level subject item must carry the // same key set. That comparison, not a hard-coded key list, is what makes the next divergence a @@ -46940,11 +47622,11 @@ mod human_rule_tests { // silently dropped the verb and left the human unable to reject a PR through the machine. assert_eq!( human_rulings(&HUMAN_PR_RULING_LABELS), - vec!["reject", "design", "close-candidate"] + vec!["reject", "design"] ); assert_eq!( human_rulings(&HUMAN_RULING_LABELS), - vec!["reject", "design", "close-candidate", "keep-open"] + vec!["reject", "design", "keep-open"] ); assert_eq!( human_ruling_label(&HUMAN_PR_RULING_LABELS, "reject"), @@ -46971,6 +47653,9 @@ mod human_rule_tests { "ai:reject", "approve", "close", + // Decide+do (#213): a close is `human-close`'s own transition, resolvable in NO + // ruling vocabulary β€” offering it as a verb would re-split the act it fuses. + "close-candidate", " ", ] { assert_eq!(human_ruling_label(&HUMAN_RULING_LABELS, bad), None, "{bad}"); @@ -47021,8 +47706,12 @@ mod human_rule_tests { fn a_ruling_outside_the_vocabulary_is_refused_with_the_vocabulary() { let (code, msg) = human_ruling_vocab_error(&HUMAN_PR_RULING_LABELS, "keep-open", "PR"); assert_eq!(code, 2); - assert!(msg.contains("reject, design, close-candidate"), "{msg}"); + assert!(msg.contains("reject, design"), "{msg}"); assert!(!msg.contains("keep-open,"), "{msg}"); + assert!( + !msg.contains("close-candidate"), + "a close is decide+do (`human-close`), never a ruling verb to offer: {msg}" + ); let (code, msg) = human_ruling_vocab_error(&HUMAN_RULING_LABELS, "nope", "issue"); assert_eq!(code, 2); assert!(msg.contains("keep-open"), "{msg}"); @@ -47056,7 +47745,7 @@ mod human_rule_tests { human_rule_pr_apply("o/r", "1", "no-such-verb", "note", &RulingWork::Bare, true) .unwrap_err(); assert_eq!(code, 2); - assert!(msg.contains("reject, design, close-candidate"), "{msg}"); + assert!(msg.contains("reject, design"), "{msg}"); // … and `keep-open` is ISSUE-only here too, checked before the missing note masks it. let (_, msg) = human_rule_pr_apply("o/r", "1", "keep-open", "", &RulingWork::Bare, true).unwrap_err(); @@ -47220,11 +47909,15 @@ mod human_rule_tests { "{stranding} must not strand the flag" ); } - // The two that ANSWER the flag are legal β€” the human is never left without a move. - for disposing in FLAG_DISPOSING_RULINGS { - let label = human_ruling_label(&HUMAN_RULING_LABELS, disposing).unwrap(); - record(human_issue_rule_plan(&flagged, disposing, label)); - } + // The two that ANSWER the flag are legal β€” the human is never left without a move: + // `keep-open` through the ruling vocabulary, the close through the target-less plan + // `human-close` drives (#213 β€” it writes no label, so it has no vocabulary entry). + record(human_issue_rule_plan( + &flagged, + "keep-open", + "human:keep-open", + )); + record(human_issue_rule_plan(&flagged, "close-candidate", "")); // The guard is about a LIVE flag, not about the label alone: a bare `ai:close-candidate` // with no trusted producer comment is nothing `record_close_candidate_verdict` could judge // anyway (it returns NoFlag), so refusing there would block a human for no benefit. @@ -47242,6 +47935,42 @@ mod human_rule_tests { ); } + // The PR-side twin (#211): a producer flag lives on PRs now, and the same one-way door exists + // β€” a ruling that neither disposes the flag nor clears its label parks it un-judgeable, #93's + // shape on the other subject type. `reject` stays legal because its target is `ai:reject`, + // which clears every other `ai:*` structurally (the flag label with it); `close-candidate` + // stays legal because the terminal edge retires the flag itself. + #[test] + fn a_pr_ruling_that_would_strand_a_live_flag_is_refused() { + let flag = json!({ + "author": {"login": TRUSTED_AUTHOR}, + "createdAt": "2026-07-17T21:23:11Z", + "body": "πŸ€– ai:producer\nClose-candidate: already-fixed-on-main: PR #181", + }); + let flagged = pr("abc123", &["ai:close-candidate"], vec![flag]); + assert_eq!( + human_pr_rule_plan(&flagged, "design", "human:design"), + HumanRulePlan::StrandsFlag { + flag_at: "2026-07-17T21:23:11Z".to_string() + } + ); + let (_, _, clears, ..) = record(human_pr_rule_plan(&flagged, "reject", "ai:reject")); + assert!( + clears.contains(&"ai:close-candidate".to_string()), + "an ai:reject ruling disposes the flag by clearing its label: {clears:?}" + ); + record(human_pr_rule_plan(&flagged, "close-candidate", "")); + // The guard protects a producer CLAIM, never the vetter's own `close` verdict β€” that PR + // has no flag for `record_close_candidate_verdict` to be locked out of, so a design + // ruling on it stays free. + let verdict = json!({ + "author": {"login": TRUSTED_AUTHOR}, + "body": "πŸ€– ai:vetter\nReviewed abc123: close β€” superseded by o/r#48", + }); + let vc = pr("abc123", &["ai:close-candidate"], vec![verdict]); + record(human_pr_rule_plan(&vc, "design", "human:design")); + } + // The refusal must name every legal move, or it is an obstruction rather than a redirection β€” // a tool harder to use than raw `gh` gets bypassed, which is the failure to avoid above all. #[test] @@ -47271,7 +48000,6 @@ mod human_rule_tests { // TERMINAL one, which is the move a human upholding a flag actually wants (#94). for named in [ "human-close o/r 93", - "human-rule-issue o/r 93 close-candidate", "human-rule-issue o/r 93 keep-open", "record-close-candidate-verdict o/r 93 reject", ] { @@ -47281,14 +48009,6 @@ mod human_rule_tests { // exactly the "reach for a tool that does not exist" that produced #93. for argv in [ vec!["prr", "human-close", "o/r", "93", "x"], - vec![ - "prr", - "human-rule-issue", - "o/r", - "93", - "close-candidate", - "x", - ], vec!["prr", "human-rule-issue", "o/r", "93", "keep-open", "x"], vec![ "prr", @@ -47325,13 +48045,10 @@ mod human_rule_tests { "human:keep-open", )); assert_eq!(clears, s(&["ai:close-candidate"])); - // `close-candidate` AGREES with the flag: leaving it is consistent, and the flag is the - // record of what the ruling ruled on. - let (_, _, clears, ..) = record(human_issue_rule_plan( - &flagged, - "close-candidate", - "human:close-candidate", - )); + // The CLOSE plan (target-less β€” `human-close` writes no label, #213) leaves the flag out + // of the RULING's own clears: the flag AGREES with the close, and the terminal transition + // is what retires it, ordered after the act (see `human_close_plan`/`human_close_steps`). + let (_, _, clears, ..) = record(human_issue_rule_plan(&flagged, "close-candidate", "")); assert!(clears.is_empty()); // Nothing else is touched: `audit` is not the machine's label to remove. let (_, supersedes, clears, ..) = record(human_issue_rule_plan( @@ -47771,19 +48488,6 @@ mod human_rule_tests { assert!(!has_target); assert!(!skip); - // The RULING alone still leaves it standing β€” while the subject is open the flag is the - // live pending claim, and `closeCandidateUpheld` still counts it. The terminal act retires - // it. These two must not be made to agree: that difference is the transition. - let (_, _, ruling_clears, ..) = record(human_issue_rule_plan( - &flagged, - "close-candidate", - "human:close-candidate", - )); - assert!( - ruling_clears.is_empty(), - "the open-state ruling must leave the pending claim standing" - ); - // A PR carries the same label for a different reason (the vetter's own `close` verdict) and // is retired the same way β€” one reference, both populations. let (anchor, _, clears, ..) = close_record(human_close_plan( @@ -47854,54 +48558,122 @@ mod human_rule_tests { )); } - // The close is TERMINAL, so it is last. Closing before the labels would make them permanently - // unreachable: every ruling plan reads a closed subject as `Moot`, so the retry that should - // finish the transition would report "nothing to do" over a half-written one. + // The write order IS the tear-safety argument (#213), so it is pinned exactly: the recorded + // ruling first, the act second, the label retirement last. No label is ever ADDED β€” the + // comment alone is the durable intent. #[test] - fn the_close_is_the_last_write_and_never_precedes_the_record() { - let steps = human_close_steps( - &s(&["human:design"]), - &s(&["ai:close-candidate"]), - false, - false, - ); + fn the_close_follows_the_record_and_labels_are_only_ever_removed() { + let steps = human_close_steps(&s(&["human:design"]), &s(&["ai:close-candidate"]), false); assert_eq!( steps, vec![ RuleStep::Comment, - RuleStep::EnsureLabel, - RuleStep::AddLabel, - RuleStep::RemoveLabel("human:design".to_string()), - RuleStep::RemoveLabel("ai:close-candidate".to_string()), RuleStep::Close, + RuleStep::RemoveLabel("ai:close-candidate".to_string()), + RuleStep::RemoveLabel("human:design".to_string()), ] ); - assert_eq!(steps.last(), Some(&RuleStep::Close)); - // Stated as the property too, so a re-order that keeps the same members still fails. + // Stated as properties too, so a re-order that keeps the same members still fails: + // nothing precedes the comment, nothing but the comment precedes the close, and no step + // ever writes a label. let close_at = steps.iter().position(|x| x == &RuleStep::Close).unwrap(); - for (i, step) in steps.iter().enumerate() { + assert_eq!(close_at, 1, "only the recorded ruling precedes the act"); + for step in &steps { assert!( - i == close_at || step != &RuleStep::Close, - "the close must happen exactly once" + !matches!(step, RuleStep::EnsureLabel | RuleStep::AddLabel), + "the terminal edge writes no label (#213): {step:?}" ); - assert!(i <= close_at, "no write may follow the close"); } - // It is the ruling's own order plus one step β€” not a second list that could drift. - let mut ruling = human_rule_steps( - &s(&["human:design"]), - &s(&["ai:close-candidate"]), - false, - false, + // A re-run of a close already recorded goes straight to the act: dedup drops the comment, + // and the close is what remains to do. + assert_eq!(human_close_steps(&[], &[], true), vec![RuleStep::Close]); + } + + // One test per tear point (#213): every prefix of the close sequence must land the subject + // where existing machinery sees and finishes it β€” nothing may tear into an invisible state. + #[test] + fn every_tear_point_lands_where_machinery_can_finish_it() { + let ruled = |at: &str| { + json!({ + "author": {"login": TRUSTED_AUTHOR}, + "body": human_rule_comment(&format!("close-candidate @{at}"), "close-candidate", "evidence"), + }) + }; + let at = "2026-07-17T21:23:11Z"; + // Tear after Comment, subject FLAGGED: the flag scan enumerates it, and the gate routes it + // to the completion rather than to any inbox. + let torn = issue( + &["ai:close-candidate"], + "2026-01-01T00:00:00Z", + Some(at), + vec![ruled(at)], + ); + assert!(human_close_ruled(&torn)); + let (gate, action, _) = cc_row("o/r", 93, "t", &torn, false); + assert_eq!(gate, CcGate::TornHumanClose); + assert_eq!(action, "complete-human-close"); + // Tear after Comment, subject UNFLAGGED: no scan enumerates it, so the exit is the re-run + // of the same command β€” the comment dedups and the plan's remaining step is the act. + let unflagged = issue( + &["bug"], + "2026-01-01T00:00:00Z", + None, + vec![json!({ + "author": {"login": TRUSTED_AUTHOR}, + "body": human_rule_comment("issue @2026-01-01T00:00:00Z", "close-candidate", "evidence"), + })], ); - assert_eq!(&steps[..steps.len() - 1], &ruling[..]); - ruling.push(RuleStep::Close); - assert_eq!(steps, ruling); - // A re-run of a ruling already recorded still closes: dedup drops the comment, the present - // label drops the add, and the terminal act is what remains to do. + let (_, supersedes, clears, _, skip) = close_record(human_close_plan(&unflagged, false)); + assert!(skip, "the re-run must dedup the recorded ruling"); assert_eq!( - human_close_steps(&[], &[], true, true), - vec![RuleStep::EnsureLabel, RuleStep::Close] + human_close_steps(&supersedes, &clears, skip), + vec![RuleStep::Close], + "the resumed transition is exactly the act that tore off" ); + // Tear after Close: a CLOSED subject still flagged. TWO consumers, because every + // open-side search excludes it: the state-load's closed-side sweep enumerates exactly + // this population (the argv pinned below β€” the machinery path, no human required), and + // the already-closed re-run path (#94) finishes it interactively. + let mut closed = torn.clone(); + closed["state"] = json!("CLOSED"); + assert_eq!(human_close_plan(&closed, false), HumanClosePlan::StaleFlag); + for noun in FLAG_SEARCH_NOUNS { + let args = flagged_subjects_args(noun, "closed"); + let pairs: Vec<(&str, &str)> = args + .windows(2) + .map(|w| (w[0].as_str(), w[1].as_str())) + .collect(); + assert!( + pairs.contains(&("--state", "closed")), + "the sweep must search CLOSED subjects: {args:?}" + ); + assert!( + pairs.contains(&("--label", "ai:close-candidate")), + "the sweep must search the flag: {args:?}" + ); + } + // All steps landed: settled, and the re-run writes nothing. + let mut done = issue(&[], "2026-01-01T00:00:00Z", None, vec![ruled(at)]); + done["state"] = json!("CLOSED"); + assert_eq!(human_close_plan(&done, false), HumanClosePlan::Settled); + // The detector reads the RULING WORD, not the marker: any other recorded ruling β€” here a + // keep-open, whose anchor also spells "close-candidate @" β€” must not read as a torn + // close, or the completion would close subjects a human ruled to keep open. + let kept = issue( + &["ai:close-candidate"], + "2026-01-01T00:00:00Z", + Some(at), + vec![json!({ + "author": {"login": TRUSTED_AUTHOR}, + "body": human_rule_comment(&format!("close-candidate @{at}"), "keep-open", "still live"), + })], + ); + assert!( + !human_close_ruled(&kept), + "a keep-open ruling is not a close" + ); + let (gate, ..) = cc_row("o/r", 93, "t", &kept, false); + assert_ne!(gate, CcGate::TornHumanClose); } // WHICH GitHub operation each step performs. A `Close` spelled as an `edit`, or an @@ -47911,7 +48683,7 @@ mod human_rule_tests { #[test] fn each_step_invokes_the_gh_operation_it_names() { let argv = |step: &RuleStep, noun: &str| { - rule_step_argv(step, noun, "o/r", "93", "human:close-candidate", "body").join(" ") + rule_step_argv(step, noun, "o/r", "93", "human:design", "body").join(" ") }; assert_eq!( argv(&RuleStep::Close, "issue"), @@ -47925,7 +48697,7 @@ mod human_rule_tests { ); assert_eq!( argv(&RuleStep::AddLabel, "issue"), - "issue edit 93 -R o/r --add-label human:close-candidate" + "issue edit 93 -R o/r --add-label human:design" ); assert_eq!( argv( @@ -47937,7 +48709,7 @@ mod human_rule_tests { // The label step is repo-scoped, never subject-scoped, so it carries no noun at all. let ensure = argv(&RuleStep::EnsureLabel, "issue"); assert!( - ensure.starts_with("label create human:close-candidate -R o/r"), + ensure.starts_with("label create human:design -R o/r"), "{ensure}" ); assert!(ensure.ends_with("--force"), "{ensure}"); @@ -47946,7 +48718,7 @@ mod human_rule_tests { // Every failure names the half-state it leaves, so the caller knows what to re-run β€” except // EnsureLabel, which is best-effort and must NOT abort a ruling. assert_eq!( - rule_step_failure(&RuleStep::EnsureLabel, "o/r", "93", "human:close-candidate"), + rule_step_failure(&RuleStep::EnsureLabel, "o/r", "93", "human:design"), None ); for step in [ @@ -47955,21 +48727,19 @@ mod human_rule_tests { RuleStep::RemoveLabel("x".to_string()), RuleStep::Close, ] { - let (code, msg) = - rule_step_failure(&step, "o/r", "93", "human:close-candidate").expect("named"); + let (code, msg) = rule_step_failure(&step, "o/r", "93", "human:design").expect("named"); assert_eq!(code, 1, "{step:?}"); assert!(msg.contains("o/r#93"), "{step:?}: {msg}"); } // The close's failure says the subject is ruled but still OPEN, and that a re-run finishes // it β€” the one half-state a caller can and must complete. - let (_, msg) = - rule_step_failure(&RuleStep::Close, "o/r", "93", "human:close-candidate").unwrap(); + let (_, msg) = rule_step_failure(&RuleStep::Close, "o/r", "93", "human:design").unwrap(); assert!(msg.contains("still open"), "{msg}"); assert!(msg.contains("Re-run"), "{msg}"); } - // The report says the subject was closed, on top of everything the ruling's report already - // says β€” a transition whose terminal half is invisible reads as a ruling that did not land. + // The report says the subject was closed, on top of what was recorded and retired β€” a + // transition whose terminal half is invisible reads as a ruling that did not land. #[test] fn the_close_report_names_the_close_as_well_as_the_ruling() { let r = human_close_report( @@ -47980,37 +48750,88 @@ mod human_rule_tests { &s(&["ai:close-candidate"]), false, ); - assert!(r.contains("ruled human:close-candidate on o/r#93"), "{r}"); + assert!(r.contains("ruled close-candidate on o/r#93"), "{r}"); assert!(r.contains("cleared ai:close-candidate"), "{r}"); assert!(r.contains("[closed]"), "{r}"); - // The label it writes is the sacred one, not a fifth invented state. - assert!(HUMAN_RULING_LABELS.contains(&HUMAN_CLOSE_TARGET)); - assert!(HUMAN_DECISION_LABELS.contains(&HUMAN_CLOSE_TARGET)); + // It writes NO label (#213): no ruling vocabulary, on either subject, offers a close β€” + // deciding one IS executing one, so a close state is unrepresentable in the machine. + for set in [&HUMAN_RULING_LABELS[..], &HUMAN_PR_RULING_LABELS[..]] { + assert!( + !set.iter() + .any(|l| ruling_verb(l) == Some("close-candidate")), + "a close must not be writable as a standalone ruling: {set:?}" + ); + } // And the flag it retires is the one both populations share. assert_eq!(PENDING_CLOSE_FLAG, "ai:close-candidate"); } - // The flag verdict is ISSUE-side authority. Pointed at a PR it used to answer "nothing to - // judge", which reads as "this PR has no human path" β€” and was recorded as exactly that - // misreading. It now says what the subject is and names the three moves that DO apply. + // The flag verdict covers BOTH subject types (#211): a producer flag on a PR is judged exactly + // as one on an issue. The refusal is narrowed to the PR whose label is the vetter's OWN + // `close` verdict β€” no claim exists for a second AI judgement β€” and it still names the three + // moves that DO apply (the pre-#211 refusal fired on every PR, flagged or not). #[test] - fn the_flag_verdict_refuses_a_pull_request_by_naming_its_moves() { + fn the_flag_verdict_judges_a_flagged_pr_and_refuses_the_vetters_own_verdict() { + // Labelled, NO producer flag: the vetter's own verdict. Refused. let mut p = issue( &["ai:close-candidate"], "2026-01-01T00:00:00Z", - Some("2026-07-17T21:23:11Z"), + None, vec![], ); p["url"] = json!("https://github.com/o/r/pull/28"); - assert_eq!(cc_verdict_plan(&p, "uphold"), CcVerdictPlan::NotAnIssue); - // Type before state: a CLOSED PR must be told what it is, not that its flag is moot. + assert_eq!( + cc_verdict_plan(&p, "uphold"), + CcVerdictPlan::VetterVerdictPr + ); + // Type before state: a CLOSED verdict-close PR must be told what it is, not that a flag + // it never carried is moot. p["state"] = json!("CLOSED"); - assert_eq!(cc_verdict_plan(&p, "reject"), CcVerdictPlan::NotAnIssue); + assert_eq!( + cc_verdict_plan(&p, "reject"), + CcVerdictPlan::VetterVerdictPr + ); + // A PRODUCER flag on a PR is judgeable (#211) β€” and a reject strips the label, returning + // the PR to its ordinary lane. + let mut flagged = issue( + &["ai:close-candidate"], + "2026-01-01T00:00:00Z", + Some("2026-07-17T21:23:11Z"), + vec![], + ); + flagged["url"] = json!("https://github.com/o/r/pull/28"); + match cc_verdict_plan(&flagged, "reject") { + CcVerdictPlan::Record { + flag_at, + remove_label, + skip_comment, + } => { + assert_eq!(flag_at, "2026-07-17T21:23:11Z"); + assert!(remove_label, "reject drops the flag on a PR as on an issue"); + assert!(!skip_comment); + } + other => panic!("a flagged PR is judgeable, got {other:?}"), + } + // …and a CLOSED flagged PR is moot, exactly as a closed flagged issue is. + flagged["state"] = json!("CLOSED"); + assert_eq!( + cc_verdict_plan(&flagged, "uphold"), + CcVerdictPlan::AlreadyClosed + ); // An issue URL is not a PR, and a subject whose URL was not fetched is still judged. p["url"] = json!("https://github.com/o/r/issues/28"); p["state"] = json!("OPEN"); + // (No flag on `p`, so the issue path answers NoFlag β€” the claim-less refusal.) + assert_eq!(cc_verdict_plan(&p, "uphold"), CcVerdictPlan::NoFlag); + let mut fi = issue( + &["ai:close-candidate"], + "2026-01-01T00:00:00Z", + Some("2026-07-17T21:23:11Z"), + vec![], + ); + fi["url"] = json!("https://github.com/o/r/issues/28"); assert!(matches!( - cc_verdict_plan(&p, "uphold"), + cc_verdict_plan(&fi, "uphold"), CcVerdictPlan::Record { .. } )); // The guard can only fire on a field the fetch asks for. @@ -53829,11 +54650,12 @@ mod infra_down_tests { &["ai:ready".to_string(), "ai:design".to_string()], false )); - // Everything that returns ahead of the `ai:ready` branch makes the call dead weight. + // Everything that returns ahead of the `ai:ready` branch makes the call dead weight β€” + // including the close-candidate hand-off (#211), whose subjects the flag machinery owns. for dominating in [ "human:design", - "human:close-candidate", RETIRED_HUMAN_REJECT_LABEL, + "ai:close-candidate", RETIRED_BLOCKED_DEPLOY_LABEL, "ai:blocked-on", RETIRED_STATE_LABEL, diff --git a/review-prompt.txt b/review-prompt.txt index b282595b..98845abb 100644 --- a/review-prompt.txt +++ b/review-prompt.txt @@ -1,6 +1,6 @@ -You are an autonomous VETTING routine for the {{ORGS}} GitHub orgs, running on a persistent LOCAL box via cron with NO prior conversational context. You have TWO jobs, and both are the same job: judge what the producer emitted before a human is asked to act on it. (1) AI-review the open PRs not yet vetted and record ONE verdict for each. (2) Judge the producer's `ai:close-candidate` FLAGS on issues β€” a PR asks a human to merge code, but a bad flag asks a human to DESTROY WORK, so the flags are the higher-stakes half. You are the "AI review" stage of the pipeline (AI review β†’ human approval β†’ merge); you do NOT approve, merge, or close anything. +You are an autonomous VETTING routine for the {{ORGS}} GitHub orgs, running on a persistent LOCAL box via cron with NO prior conversational context. You have TWO jobs, and both are the same job: judge what the producer emitted before a human is asked to act on it. (1) AI-review the open PRs not yet vetted and record ONE verdict for each. (2) Judge the producer's `ai:close-candidate` FLAGS β€” on issues AND on PRs alike (#211; the row's url says which subject you are judging) β€” a PR asks a human to merge code, but a bad flag asks a human to DESTROY WORK, so the flags are the higher-stakes half. You are the "AI review" stage of the pipeline (AI review β†’ human approval β†’ merge); you do NOT approve, merge, or close anything. -YOUR TOOL SURFACE IS THE STATE MACHINE. You have NO Bash, no `gh`, no `git`. Eight MCP tools ARE the vetter's transitions, across the two subjects. PRs: `unvetted` (the state-load), `pr_context` (read one PR), `pr_checkout` (local source for the audit lens), `record_verdict` (your PR write), `clone_release` (dispose of a checkout you are finished with). CLOSE-CANDIDATE FLAGS: `unvetted_close_candidates` (the state-load), `close_candidate_context` (read one flag and the issue it judges), `record_close_candidate_verdict` (your issue write). Anything not expressible in them is not a move of this machine; do not look for a way around them. The guards live in the tool: it refuses a verdict outside `ready|reject|design|close` (or `uphold|reject` on a flag), a missing/out-of-range cost, a `covered` set that does not account for every file the PR changes, and any PR or issue a human has decided. A tool ERROR is an instruction, not an obstacle: when a tool refuses because its answer would not fit one result, it names the argument to narrow β€” re-call it NARROWER; and when it says it could NOT produce something, it did not produce it, so you never go looking for what it failed to make. Never substitute a different call that happens to return less; a state-load you improvised around is one you cannot tell what is missing from. You never write a `human:*` label and you never write a `πŸ‘€ human` comment β€” that namespace and that marker are the human's, they are what makes a human's ruling unforgeable, and your tools cannot produce either. On a flag your whole authority is the `ai:close-candidate` label you may DROP and the comment you post. +YOUR TOOL SURFACE IS THE STATE MACHINE. You have NO Bash, no `gh`, no `git`. Eight MCP tools ARE the vetter's transitions, across the two subjects. PRs: `unvetted` (the state-load), `pr_context` (read one PR), `pr_checkout` (local source for the audit lens), `record_verdict` (your PR write), `clone_release` (dispose of a checkout you are finished with). CLOSE-CANDIDATE FLAGS: `unvetted_close_candidates` (the state-load β€” issues AND PRs), `close_candidate_context` (read one flag and the subject it judges), `record_close_candidate_verdict` (your flag write, either subject type; it refuses a PR whose label is your own `close` verdict β€” that one is the human's). Anything not expressible in them is not a move of this machine; do not look for a way around them. The guards live in the tool: it refuses a verdict outside `ready|reject|design|close` (or `uphold|reject` on a flag), a missing/out-of-range cost, a `covered` set that does not account for every file the PR changes, and any PR or issue a human has decided. A tool ERROR is an instruction, not an obstacle: when a tool refuses because its answer would not fit one result, it names the argument to narrow β€” re-call it NARROWER; and when it says it could NOT produce something, it did not produce it, so you never go looking for what it failed to make. Never substitute a different call that happens to return less; a state-load you improvised around is one you cannot tell what is missing from. You never write a `human:*` label and you never write a `πŸ‘€ human` comment β€” that namespace and that marker are the human's, they are what makes a human's ruling unforgeable, and your tools cannot produce either. On a flag your whole authority is the `ai:close-candidate` label you may DROP and the comment you post β€” a reject returns an issue to the producer's backlog and a PR to the vet queue. Each run: 1. Call `unvetted`. It returns ONE PAGE of the PRs to vet β€” the first 3 in closest-to-merge order (`limit`, max 3). RUN BUDGET: 3 ITEMS PER RUN IN TOTAL, where an item is a PR you vet OR a close-candidate flag you rule on (step 5) β€” ONE shared allowance, spent in whatever mix the queues hand you: 3 PRs and no flags, or 2 flags and at most 1 PR. It is 3 because this machine is not yet reliable or efficient, and every item a run attempts is an item that can go WRONG β€” a wrong verdict a human then acts on, a sound flag stripped, tokens burnt for nothing β€” so the cap bounds how much damage ONE run can do while that is still true. It is a RISK CONTROL: deliberately conservative, explicitly temporary, and raised only on evidence that runs have become reliable and efficient β€” never because a run finished early with budget to spare. Spend the budget, write your run summary and stop; do not re-call a state-load for a second page of work. A verdict is a claim a human acts on, and three audited properly beats ten skimmed β€” each with `headRefOid`, `labels`, `reviewDecision`, `humanSacred`, `vettedAtHead`, `ci`, `mergeable`. PRs a human still HOLDS (an absolutely-parking `human:*` label, a native APPROVED/CHANGES_REQUESTED review, a ruling pinned to the current head, or a `human:design` whose work order is not yet executed), drafts, PRs already carrying a CURRENT verdict at their current head, and PRs carrying UNRESOLVED review threads are ALREADY EXCLUDED β€” you do not re-derive any of that, and you never re-open one. A `human:design`-labelled PR that DOES appear here is an EXECUTED delegation (#111): the human's ruling was a work order, the producer pushed it, and your verdict re-judges the result with the ruling in `humanComments` β€” the tool clears the spent label as part of recording, which is the ruling's completion, not your write into the human's namespace. The tool also runs the `ai:blocked-on` CLEARANCE inside this same call (#161): a blocked PR whose typed deps are ALL merged/closed has its flag cleared in-place and simply appears in the page as un-vetted (vet it fresh, exactly like any other β€” the dep landing may have changed what correct means); a blocked PR with a dep still open is listed under `blockedOn` (held β€” NOT yours to vet this run); one under `blockedOnManualReview` cannot be judged by the machine (no typed refs / unresolvable ref β€” a human migrates or rules on it). You never clear, vet, or comment on a held or manual-review blocked PR. A PR you have judged before comes back in this list whenever its verdict stopped being current β€” its head moved, or the vet protocol was bumped past the one that verdict was written under β€” and everything in this list is UN-VETTED, one state with one handling: vet it exactly as if you had never seen it.