Detect PRs that received no CI at all (#345) - #346
Conversation
PR #344 produced ZERO pull_request workflow runs -- not failures, not skips. `gh pr checks` printed "no checks reported", mergeStateStatus stayed CLEAN, and the PR looked mergeable with nothing having evaluated it. FIRST, THE DIAGNOSIS IN #345 WAS WRONG AND IS CORRECTED THERE. I filed it blaming GitHub's 300-file limit on evaluating `paths:` filters. But pr-sanity.yaml and vendored-sync.yaml carry NO paths filter at all -- deliberately, per #184/#200 -- and neither fired either. Querying by SHA rather than branch confirms it: every run on that head is event=workflow_dispatch, i.e. only the ones dispatched by hand. A limit on filter evaluation cannot explain a workflow with no filter to evaluate. PR #343, two files, opened minutes earlier from the same clone, got five pull_request runs. The file count correlates; I have not shown it causes. So this detects the SILENCE rather than guarding one hypothesised cause, and stays meaningful however the runs go missing. The workflow triggers on push to main, NOT on pull_request, and that is the substance of the design rather than a detail: a workflow that runs on pull_request cannot detect a PR where pull_request events are not arriving. Pushes to main demonstrably do fire -- every merge here produces runs -- so it reports from a vantage point that works. No schedule, per the kill-switch reasoning pr-shepherd.yml sets out. Runs triggered by workflow_dispatch deliberately do NOT count as evidence. Dispatching by hand is what you do AFTER noticing the problem, so counting it would make the check green on exactly the PRs it exists to find. A test pins that specifically. Fetching is kept out of the rule so it is testable without network or a token; six tests cover the rule and main() shells out to gh. Verified against the live repo: it flags #344 and nothing else. Not in `qc` -- it needs network and gh auth, which qc deliberately does not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @realmarcin's task in 3m 46s —— View job ReviewThe reasoning is sound and the correction to #345's diagnosis is the right call — detecting the silence rather than a hypothesised cause is what makes this durable. Triggering on push to main is correct and well justified. One real defect, plus a ceiling on what green means here that I think belongs in the docstring. No trait data, CURIEs, evidence snippets, or causal graphs touched, so the usual grounding/evidence checks don't apply. 🟡
|
There was a problem hiding this comment.
🟡 One real defect: scripts/audit_pr_checks_present.py:61 calls gh pr list with no --limit, so it silently caps at gh's default of 30 open PRs — the 31st onward are never fetched, never checked, and the audit exits 0 reporting clean on a set it never looked at. That is #345's failure shape reproduced inside the detector. Line 66's runs query likewise takes the API's default per_page=30. Both are one-flag fixes.
Also worth a docstring line: claude-code-review.yml fires on pull_request with no paths filter, so the reviewer bot alone satisfies the rule on essentially every PR. The check therefore detects total event silence only — which is #344's shape and matches the title, but a partial silence (the paths-filter hypothesis you left open) passes through green.
Smaller notes in the full review comment: denylist vs allowlist for NON_EVIDENCE_EVENTS, the permanent red X on a main commit for another PR's condition, and a concurrency key that queues rather than supersedes.
The correction to #345's diagnosis and the choice to trigger on push-to-main are both right, and the dispatch-doesn't-count test pins exactly the trap that matters.
The 🟡 is the one that mattered and it is squarely fair: `gh pr list` defaults to 30, so past 30 open PRs the rest were never fetched and the audit printed a clean bill of health for a set it never looked at -- the exact failure #345 is about, reproduced inside the detector. --limit 500 now, stated as explicit rather than inherited. The runs query took the API's per_page=30 the same way; a PR with many hand-dispatches (precisely the #344 remediation path) could push a genuine pull_request run off page one, so &per_page=100 too. That direction produced a false positive rather than a false negative, but it is one flag either way. Five 🔵s, all taken: - Evidence is now an ALLOWLIST -- {pull_request, pull_request_target} -- not "anything but dispatch". The denylist counted push, repository_dispatch and merge_group as evidence; nothing produces those against an open PR head today, but a trigger added later would have silently started counting with no test failing. Two tests pin the new direction, including that a push run alone is not evidence. - Documented what this CANNOT catch. claude-code-review.yml fires on pull_request with no paths filter and records a run even when its if: gates skip the job, so nearly every PR gets one run from the reviewer bot. This therefore detects TOTAL silence only; a partial silence with the gating workflows mute passes. The docstring now says so plainly, and says the stronger property needs a named required-workflow set rather than a tweak to this rule. - PRs younger than 10 minutes are skipped. A PR opened seconds before a merge has no runs yet through no fault of its own, and would have been reported as an offender on every merge. - Concurrency key is now github.run_id. Nothing here supersedes anything, so a shared key did not cancel (it must not) but did queue, making a manual dispatch wait behind an in-flight push run for nothing. - --repo defaults from GITHUB_REPOSITORY, falling back to the literal. Verified live: it still flags #344, no longer flags #346 now that its checks arrived, and runs clean against CommunityMech via --repo -- which is the fleet-wide use the last fix enables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All six taken. The 🟡 is the one that mattered, and it's a fair hit — the detector had the bug it detects. 🟡
|
|
Claude finished @realmarcin's task in 3m 58s —— View job ReviewAll six taken, and the 🟡 is properly fixed — No trait data, CURIEs, evidence snippets, or causal graphs touched. Verified against the previous round
I also checked the thing a new no- 🔵 The age gate measures the wrong clock
Narrow in practice: checkout + 🔵 PRs skipped for youth vanish from the report
🔵 100 is still a cap — server-side filtering removes the question
🔵
|
There was a problem hiding this comment.
All six taken; the 30-PR cap fix is correct and the --limit comment states it as explicit rather than inherited, which is what stops it recurring. Allowlist has the right two tests, the WHAT THIS DOES NOT CATCH section is the honest version, and the concurrency key now matches WORKFLOW_CONVENTIONS.md. I also checked that this new no-paths: workflow does not inflate the #200 floor — NO_UNFILTERED_CI counts a workflow only if it triggers on pull_request, and this one triggers on push-to-main, so it correctly does not count.
Four 🔵s in the full comment, none blocking: age_minutes is derived from createdAt while evidence is keyed on headRefOid, so a long-open PR pushed seconds ago gets no grace period (the head commit's committedDate is the clock you want); PRs skipped by the age gate are invisible in the output, so it reads as full coverage; per_page=100 is a bigger ceiling rather than none, and &event=pull_request would remove the question server-side; and --limit 500 traded a silent undercount for a possible 10-minute timeout, which is the right trade but worth knowing.
No trait data, CURIEs, evidence snippets, or causal graphs touched. Bash was restricted here so I read rather than ran, but pytest/qc/pr-sanity/vendored-sync are green on this head.
Approved, but three of these were cheap and one of them was the same
failure shape this tool exists to catch, which is not one to leave in
this tool of all tools.
- Skipped PRs no longer vanish. offenders() `continue`d past a PR
excluded for youth, and main() printed only open/unchecked counts -- so
the output read as full coverage of a set it had not fully checked.
partition() now returns (offenders, skipped) and main() NAMES the
skipped ones.
- The age gate measures the HEAD COMMIT, not createdAt. A PR opened last
week and pushed twenty seconds ago has a brand-new SHA with no runs
against it, and age_minutes in the thousands, so the grace period did
not apply -- and push is far more frequent than open, so the gate
covered the rarer case. Falls back to createdAt if the lookup fails.
- The runs query filters SERVER-SIDE (&event=...&per_page=1) instead of
paginating. per_page=100 was a bigger ceiling, not the absence of one;
an event filter cannot be crowded out at any count.
The fourth (--limit 500 could outlast timeout-minutes: 10 at fleet scale)
is accepted as the right trade, and is now cheaper anyway: evidence
short-circuits on the first matching event, and the commit-date lookup
happens ONLY for a PR that would otherwise be reported. A healthy PR
costs one request, not three.
Worth recording how the JSON bug in this commit was found: `gh api -q`
unwraps to a bare scalar, and json.loads("2026-08-06T21:47:27Z") raises.
The unit tests did not catch it because they feed offenders() fixtures
and never exercise collect() -- the same separation that makes the rule
testable leaves the fetching untested. Running it against the live repo
caught it. Added _gh_text() for raw scalars.
Verified live after the fix: still flags #344 alone, and runs clean
against CommunityMech via --repo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Took three of the four. Approved already, but one of them was the same failure shape this tool exists to catch — not one to leave in this tool of all tools. 🔵 Skipped PRs vanishing — fixedYou're right that it's the shape I'd just fixed one level up. 🔵 The age gate's clock — fixedCorrect, and the frequency argument is the decisive part: push is far more common than open, so 🔵 Server-side filtering — fixed
🔵
|
|
Claude finished @realmarcin's task in 3m 5s —— View job ReviewRound 3 takes three of the four 🔵s and each is correctly done. One new defect came in with the fix, in the part the author already flagged as the untested seam. No trait data, CURIEs, evidence snippets, or causal graphs touched. 🟡 The documented
|
There was a problem hiding this comment.
🟡 One defect in f6ec10a7: the "Falls back to createdAt if the commit lookup fails" comment at scripts/audit_pr_checks_present.py:137 describes behaviour the code does not have. _gh_text() raises SystemExit(2) on a non-zero gh exit (line 93), so a failed commit lookup aborts the whole audit rather than falling back — and it aborts before main() prints the offenders collect() had already found. One transient 5xx or rate-limit on PR #7 discards the report about #344. The or pr['createdAt'] guard only catches empty stdout; gh api -q on a missing field prints the literal null, which then reaches fromisoformat and tracebacks, so the fallback is unreachable by either route. Either implement it (catch the failure and return "") or delete the sentence.
The three taken 🔵s are all correctly done: partition() returns and main() names the youth-skipped PRs, the clock is the head commit, and &event=...&per_page=1 removes the ceiling rather than raising it. The _gh_text/_gh_json split for -q-unwrapped scalars is the right shape and the comment on it will stop the next person from re-introducing it.
Two 🔵s in the full comment: collect() is now ~40 lines of untested shell-out that has already harboured one live-only bug, and the new test_a_long_open_pr_with_a_freshly_pushed_head_is_skipped exercises the age gate rather than the clock source, so it would not have caught the change it is named for; and partition() checks youth before evidence, which is only correct because collect() sets age solely for evidence-less PRs.
No trait data, CURIEs, evidence snippets, or causal graphs touched. Bash is restricted here so I read rather than ran.
The 🟡 is exact and it is my comment that was wrong twice over. The line
said "falls back to createdAt if the commit lookup fails", but _gh_text
raises SystemExit(2) on non-zero exit, so a failed lookup never reached
the `or` -- it terminated the process and discarded the report on every
OTHER PR, which is the run's entire job. And `gh api -q` prints the
literal string "null" for a missing field, which is truthy, so that path
reached datetime.fromisoformat("null") and raised ValueError.
Added _gh_text_opt(), kept distinct from _gh_text on purpose: this is the
one call site where a transient failure must DEGRADE rather than abort.
It returns None on non-zero exit and filters the literal "null" rather
than trusting an `or`. Parsing moved into _age_minutes(), which returns
None on an unparseable value; partition() treats unknown age as "do not
skip", the safe direction since skipping is what suppresses a report.
partition() now checks evidence BEFORE youth. A young PR that already has
runs is simply fine, and calling it "skipped (too new)" understated the
coverage the run actually achieved. Youth only excuses an ABSENCE.
collect() is no longer the untested half. Both real defects in this file
lived there -- json.loads on a bare scalar, then this fallback -- and
both were found by running it, not by the suite, because the tests fed
offenders() fixtures and never crossed the gh boundary. Five tests now
stub that boundary. Canaried: reverting to _gh_text fails two of them.
456 tests, qc green, live run unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The 🟡 is exact, and it's my comment that was wrong — twice over, in two different ways I hadn't distinguished. 🟡 The fallback that couldn't fire
Added 🔵 Evidence before youthRight. A young PR that already has runs is simply fine, and calling it "skipped (too new)" understated the coverage the run achieved. Youth now only excuses an absence. 🔵
|
|
Claude finished @realmarcin's task in 2m 15s —— View job ReviewRound 4 fixes the 🟡 properly — the fallback is now reachable by both routes it was unreachable by, and No trait data, CURIEs, evidence snippets, or causal graphs touched. Verified against round 3
🔵 The clock-source test does not discriminate the clock source
This is the same shape flagged last round on 🔵 The
|
There was a problem hiding this comment.
Round 4 fixes the 🟡 correctly. _gh_text_opt returns None on a non-zero gh exit instead of SystemExit(2), and filters the literal null that gh api -q prints for a missing field, so both routes that made the documented createdAt fallback unreachable are closed. _age_minutes returns None on an unparseable value and partition() treats unknown age as 'do not skip', which is the safe direction since skipping is what suppresses a report. Keeping _gh_text_opt as a separate function rather than a default= flag on _gh_text is the better shape — it makes 'this call site must degrade' a property of the call site. Evidence-before-youth is done, and the fallback test is a real canary: reverting to _gh_text makes the monkeypatch stop applying and the test exits 2.
Three 🔵s in the full comment, none blocking. Two are about the seam you named yourself. test_collect_uses_the_head_commit_date_when_available asserts only age_minutes > 0, which holds for both the 2020-06 commit date and the 2020-01 createdAt, so it would stay green if collect() were reverted to pr['createdAt'] — the coverage arrived without the discriminating assertion. And test_collect_survives_an_unparseable_timestamp monkeypatches _gh_text_opt, the function that does the null filtering, so what it exercises is _age_minutes's ValueError path; lines 118 and 120 have no coverage, and the test's inline comment describes behaviour it doesn't reach. Outcome is safe either way. Third: _gh_text is now dead code, referenced only from _gh_text_opt's docstring.
No trait data, CURIEs, evidence snippets, or causal graphs touched. Bash is restricted to git here so I read rather than ran; CI on this head is the independent check on the test claims.
This PR was opened at 19:24Z on 2026-08-06, during a critical GitHub Actions incident (stspg.io/rcz3fcm83sff) in which webhook triggers were throttled to ~15% and many push and pull_request events never created workflow runs. It received none, so it has sat CLEAN-looking and entirely unverified -- which is the state #346's detector now reports on every merge to main, and it flagged this PR within a minute of landing. The incident is resolved. Empty commit to fire a synchronize event now that they are being delivered again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (#344) * Normalise the corpus so the round-trip claim becomes enforceable (#322) write_validated_trait's comment claimed a byte-identical round trip. #343 corrected the claim to match reality -- it held for 127 of 477 records. This makes the claim TRUE instead, which is the half #322 left open. 350 records are rewritten through the helper's own emission path. No data changes; only formatting. safe_dump re-wraps long strings at its own width and drops hand-written quoting, so the diff is entirely those two things. Verified three independent ways rather than trusted: - The normaliser is TWO-PASS (#324's lesson): it computes and semantically checks every file before writing any, and aborts on the first mismatch rather than leaving the corpus half-normalised. 0 problems across 477. - Every rewritten file was re-parsed and compared to its pre-write document; a formatting pass that changed data would have failed there. - Independently, every file's parsed document was compared against `git show HEAD:` afterwards. 477 compared, 0 semantic differences. The strongest evidence is what did NOT change: pages/, reports/ and conf/ are byte-identical, because every generator and audit parses the YAML. Identical data in, identical artifacts out. The tests flip from documenting the gap to enforcing its absence. What asserted a 127/350 split now asserts that NO record fails to round trip, and the end-to-end test asserts equality rather than difference. Canaried by re-quoting a single scalar by hand: the suite fails. The payoff is that bulk scripts can use this helper again. #323, #328 and #341 all hand-rolled raw-line editors specifically to avoid the reflow churn, and that workaround is no longer necessary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Re-trigger CI The initial push produced no workflow runs at all despite the diff matching several paths: filters (data/traits/**, src/traitmech/**.py, tests/**.py). Close/reopen did not trigger them either. Empty commit to fire a synchronize event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Re-trigger CI after the Actions outage This PR was opened at 19:24Z on 2026-08-06, during a critical GitHub Actions incident (stspg.io/rcz3fcm83sff) in which webhook triggers were throttled to ~15% and many push and pull_request events never created workflow runs. It received none, so it has sat CLEAN-looking and entirely unverified -- which is the state #346's detector now reports on every merge to main, and it flagged this PR within a minute of landing. The incident is resolved. Empty commit to fire a synchronize event now that they are being delivered again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pin the corpus total, and fix prose the flip contradicted (PR #344 review) Both 🟡 are fair and the first is a regression I introduced when flipping the test. _split() skips anything that will not parse. The old assertion was `(len(same), len(changed)) == (127, 350)`, which pinned the total at 477, so a skipped record failed it. `assert not changed` does not -- a record edited into invalid YAML would drop silently out of the guard and the test would pass. Now asserts len(same) == len(TRAITS) as well. Canaried by appending invalid YAML to a record: it fails, and names the count. The second is the stale-prose-beside-updated-code mistake I have now made several times in this session. The module docstring still opened with the claim being "false for most of the corpus" three lines above saying it is now true, and test_the_helpers_own_output_round_trips still described the helper as "unsafe for a bulk rewrite" -- the exact opposite of what this PR establishes and of write_validated.py's own comment. Rewritten: the docstring is now past-tense about the pre-#322 state, and that test is described as what it actually asserts, the emitter's idempotence, which is a different property from the corpus being in that form. The 🔵 about a `str` representer emitting block scalars for the ~50 curation-history prose blocks is a good idea and is filed as #347 rather than taken here: it changes the emitted format, so it would mean re-normalising all 477 records inside a PR already under review, and the round-trip test would then enforce whichever form is chosen -- a one-way decision worth making deliberately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Check that every workflow which SHOULD have run, ran (#348) audit-pr-checks can only see TOTAL silence, and the reason is structural: claude-code-review.yml fires on pull_request with no paths: filter and records a run even when its if: gates skip the job, so nearly every PR here has at least one qualifying event. qc, pytest and validate-strict could all be mute and it would stay green. This is the stronger property #348 asked for -- each PR-triggered workflow checked by name. THE REQUIRED SET IS DERIVED, NOT DECLARED: every workflow in .github/workflows with a pull_request: trigger, read from the files. #252 rejected a hand-maintained list for audit-qc-paths because a declaration drifts the moment someone adds a workflow, and the argument applies here unchanged. A test pins that adding a workflow file grows the set with no second edit. A paths: FILTER IS NOT A MISSING RUN. Five of the eight PR-triggered workflows are filtered, so the filters are evaluated against the PR's own changed files and only an unfiltered-or-matching workflow is expected. That evaluation is the hard part, and also the payoff: a paths: regression -- the class #184, #200, #250 and #252 all belong to -- surfaces here as "expected, did not run". Two things I got wrong while writing it, both pinned by tests: PyYAML resolves the unquoted key `on:` to the BOOLEAN True under YAML 1.1, so doc["on"] is absent in every workflow in this repo. Reading only "on" would have found zero required workflows and exited 0 -- a check that passes because it looked at nothing, the exact vacuous green it exists to catch. Invisible without a test, so there is one asserting the trap itself. The matcher anchored only the end, so `data/traits/**` would have matched `vendor/data/traits/x.yaml`. Now fullmatch. GitHub's `*` also does not cross a slash where fnmatch's does, which is why this is hand-rolled rather than fnmatch. Filter syntax the matcher does not implement ([], !, +, ?) is reported as UNSUPPORTED and never guessed at, since the value of the check rests on "expected, did not run" meaning something. PRs past GitHub's 300-file path-filter evaluation limit are skipped AND NAMED, per #346's rule that a PR vanishing from both counts makes the output read as coverage it did not have. Wired into pr-checks-present.yaml with if: always(), so a total silence does not suppress the partial-silence report -- they answer different questions. CANARY (live, against this repo, before wiring it into CI). 8 workflows parsed; PR #353's 4 changed files predicted 5 expected workflows, all 5 matched against real API data, and curation-history, label-correspondence and validate-strict were correctly filtered out. Negative control: deleting qc's run from the fetched data reports exactly ['.github/workflows/qc.yaml'], so the green was not vacuous. Not exercised: a PR with >300 files, and unsupported filter syntax, both of which exist only in tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fetch at the PR head, survive pagination, decline branch filters (#354 review) All three correct, and the first two were reachable today. PAGINATION. `gh api --paginate -q` applies the jq filter to EACH PAGE and concatenates, so past GitHub's default per_page=30 json.loads raises "Extra data" -- taking down collect() for every OTHER open PR, and making the >300-file skip branch unreachable because the fetch died at 31. Reproduced live against PR #311 (346 changed files): JSONDecodeError: Extra data: line 2 column 1 (char 7050) gh 2.97 refuses --slurp together with --jq, so the fix is --slurp plus extraction in Python. Same PR now returns 346 filenames. HEAD vs MAIN. GitHub dispatches pull_request events from the workflow files AT THE PR HEAD; this process has whatever it was checked out at, which for pr-checks-present.yaml is main. They disagree exactly when a PR touches .github/workflows -- and every filtered workflow here lists its own file in its paths:, so a PR DELETING one matches the filter and main's copy expects a run GitHub correctly never made. A false "expected, did not run" is what makes this check stop meaning anything, so collect() now fetches the head's copy per PR. A fetch failure is a REFUSAL: the PR is skipped and named rather than judged against the wrong ref, because falling back to main would silently reintroduce the bug. Writing that test found a bug the review did not: `pr.get("workflows") or workflows` falls back when the head list is EMPTY, so a PR deleting all the workflows would have every one of them reported missing. Now keyed on `in`, not truthiness. BRANCH FILTERS. `branches:`/`branches-ignore:` restrict which base a PR must target and this audit never reads the base, so unmodelled they would make every PR against another base a false offender. Routed through the existing UNSUPPORTED escape hatch. `types:` gets a sharper rule rather than a blanket refusal: every PR head arrives via `opened` (the first) or `synchronize` (any later push), so a types: containing both is predictable and one that does not is declined -- which keeps claude-code-review.yml's `types: [opened, synchronize]` in the set. Also: an unparseable workflow is now NAMED rather than silently dropped (it would shrink the required set invisibly), and _glob_to_regex is lru_cached. CANARY, live, after the rewrite. 2 open PRs, 8 workflows fetched AT EACH HEAD, 5 expected and 5 matched on both. Pagination exercised for real on PR #311's 346 files. Negative control: deleting pytest's run from the fetched data reports exactly ['.github/workflows/pytest.yaml']. Still not exercised live: the >300-file skip and unsupported syntax, both tests-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PR #344 produced zero
pull_requestworkflow runs — not failures, not skips.gh pr checksprinted "no checks reported",mergeStateStatusstayedCLEAN, and the PR lookedmergeable with nothing having evaluated it.
First: the diagnosis in #345 was wrong, and is corrected there
I filed it blaming GitHub's 300-file limit on evaluating
paths:filters. Butpr-sanity.yamlandvendored-sync.yamlcarry nopaths:filter at all — deliberately,per #184/#200 — and neither fired either. Querying by SHA rather than by branch confirms it:
A limit on filter evaluation cannot explain a workflow with no filter to evaluate. PR #343
(2 files), opened minutes earlier from the same clone, got 5
pull_requestruns. The filecount correlates; I have not shown it causes.
So this detects the silence, not a hypothesised cause
The rule: does an open PR have at least one check triggered by the pull request itself?
That stays meaningful however the runs go missing.
The workflow triggers on push to main, not on
pull_request— and that's the substance,not a detail. A workflow that runs on
pull_requestcannot detect a PR wherepull_requestevents aren't arriving. Pushes to main demonstrably do fire, so it reportsfrom a vantage point that works. No
schedule:, per the kill-switch reasoningpr-shepherd.ymlsets out.workflow_dispatchruns deliberately don't count as evidence. Dispatching by hand iswhat you do after noticing — counting it would make the check green on exactly the PRs it
exists to find. A test pins that specifically.
What was checked
the rule,
main()shells out togh.just qcgreen ·just pr-sanityclean · 443 tests pass ·audit-qc-pathsacceptsthe new workflow ·
audit-justfile-pathscaught the untracked script before CI did.Not in
qc: it needs network and gh auth, whichqcdeliberately does not.Partially addresses #345 — the detector lands, the root cause stays open.
🤖 Generated with Claude Code