Skip to content

refactor(source-control): one shared babysit authorship/finding/approval classifier + golden fixtures (#534) - #634

Merged
kyle-sexton merged 6 commits into
mainfrom
fix/534-babysit-shared-classifier
Jul 20, 2026
Merged

refactor(source-control): one shared babysit authorship/finding/approval classifier + golden fixtures (#534)#634
kyle-sexton merged 6 commits into
mainfrom
fix/534-babysit-shared-classifier

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Authorship (self/bot/human), finding (severity + lifetime-vs-open state), and approval-verdict detection were hand-rolled independently across the babysit snapshot (babysit_delta/babysit_feedback), the merge gate (babysit_merge), the resolve-thread reporter (babysit_resolve_thread), and the readiness gate (babysit-readiness-gate.sh). The surfaces disagreed on identical input — the six-issue misclassification class this umbrella closes (the smoking gun: #499, where the gate returned findings=0 while the snapshot called the same review "1 blocking bot finding").

This extracts one shared classifier and migrates every surface to it, so the surfaces cannot diverge, and folds each member issue in as a golden fixture — regression-proof by construction. Implements the operator-ACCEPTED Option A from the decision brief verbatim.

Fix

  • New leaf module babysit_classify.py (depends only on babysit_util) owns the three concern areas: authorship (is_bot, actor_kind, self-login normalization/membership, dependency-author test), finding (blocking/severity heuristics + the readiness gate's finding/classification counting), and approval-verdict (approval/skip downgrades). The primitives moved out of babysit_feedback, which re-exports them so existing consumers are unchanged.
  • Migrations (import the shared classifier directly): babysit_delta and babysit_merge replace hand-rolled self_logins casefold-compare (delta ×3 + merge) with the shared normalize_self_logins/is_self_login; babysit_feedback orchestrates the shared primitives; babysit_resolve_thread shares the same is_bot. babysit_merge remains the merge-ready boolean owner — it just stops carrying its own private authorship copy.
  • Gate shells out (ratified, non-defaultable call): babysit-readiness-gate.sh shells out to babysit_findings.py for finding-counting (mirroring the existing source-control-babysit-merge bash→Python wrapper) instead of re-implementing the severity vocabulary in bash grep. The bash counting is retained only as the Python-free safe-tier degrade (reference/loop.md is that path and it runs this gate; hard-requiring Python would regress that documented tier). A convergence test pins the two counts together on thread-state-free input.
  • source-control:babysit-prs: babysit-readiness-gate.sh over-counts lifetime P-badges as 'findings', false READINESS_BLOCKED reason=under-decomposed on fully-classified PRs #465 (lifetime P-badges over-counted → false READINESS_BLOCKED): the shared counter discounts a severity marker carried in a review thread GitHub reports resolved or outdated, counting currently-open findings only. Scope: this is the mechanical resolved/outdated discount the brief specs ("severity occurrence + lifetime-vs-open state"). De-duplicating the same concern restated across re-review rounds within still-open threads is deliberately out of scope — there is no reliable mechanical "same concern" signal — so restatements still count.
  • source-control:babysit-prs: resolve-thread reports humanThreadsActed=1 for a Bot-authored thread (mislabeled counter) #512 (humanThreadsActed reported for a Bot-authored thread): count only threads whose opening author is human (via the shared is_bot), not botOnly (which mislabeled a bot-opened thread carrying a later human reply).
  • orchestration doc: there was no explicit "cross-check live thread state each cycle" workaround to delete — orchestration.md already mandates trusting the deterministic engine (the brief notes the re-derivation was "real but implicit"). Strengthened that trust instruction to name the classification as now one shared, fixture-locked classifier, so "don't re-derive by eye" has teeth.
  • Every surface stays a pure predicate with no writes (source-control: no read/consume-only posture — "safe" babysit tier and pull-request monitor write to GitHub autonomously #440 report-only default preserved).

Verification

All commands run in the worktree with Python 3.14.6, ruff 0.15.20, jq 1.8.2.

Engine suite (unittest + ruff + guarded wrappers) — 256 tests (was 236; +17 classifier + 3 resolve-thread golden fixtures):

== unittest suite ==
Ran 256 tests in 1.500s
OK
== ruff ==
All checks passed!
== guarded-wrapper behavior ==
PASS: merge wrapper rejects --allow-unpinned-head
PASS: merge wrapper reaches fail-closed CLI (no allowlist)
PASS: resolve wrapper reaches fail-closed CLI (no allowlist)

Readiness-gate bash suite — 53 cases, exit 0. The 47 pre-existing fixtures still pass (now through the Python counter). New: the #465 lifetime discount end-to-end, plus an explicit dual-path convergence test — BABYSIT_READINESS_BASH_ONLY=1 forces the degrade so both counts are observed in one run and asserted equal, so the severity vocabulary cannot drift between Python and bash silently:

PASS: [48] #465 lifetime discount -> findings=1 (only open)
PASS: [49] #465 lifetime discount -> READINESS_OK
PASS: [50] convergence [severity-words]: python == bash degrade (findings=3 classified=1)
PASS: [51] convergence [codex-badges]: python == bash degrade (findings=2 classified=1)
PASS: [52] convergence [plain-p-markers]: python == bash degrade (findings=2 classified=1)
PASS: [53] convergence [self-row-exclusion]: python == bash degrade (findings=1 classified=1)

#465 end-to-end (3 lifetime severity markers — 2 resolved/outdated, 1 open — fully classified):

$ python babysit_findings.py --comments-json lifetime.json --self me[bot]
findings=1 classified=1
$ bash babysit-readiness-gate.sh 377 --comments-json lifetime.json --self me[bot]
READINESS_OK findings=1 classified=1 checklist=n/a   (exit 0)

The bash degrade counts all 3 lifetime markers (old behavior, preserved for Python-free); the Python path discounts the 2 resolved/outdated → the false READINESS_BLOCKED is gone.

#512 regression (tests/test_babysit_resolve_thread.py): bot-opened thread with a human reply, acted under --include-humanhumanThreadsActed: 0; human-opened → 1; mixed → 1. Passing.

Lint: shellcheck --rcfile .shellcheckrc clean on both gate scripts; shfmt -d (editorconfig-driven) clean; plugin manifest valid JSON at 0.13.0.

Closes #534

Related

Member issues folded as golden fixtures / regression proofs: #512 (bot thread counted as human — fixed here), #499 (Approve-with-nits, already fixed by #567 — regression fixture), #465 (lifetime over-count — fixed here), #497 (single---pr self_logins empty, already fixed on main — covered by SinglePrScopeSelfLoginTests), #473 (CLOSED, self-reply — fixture-only). #455 is a different classifier (auto-mode retry semantics, not authorship/finding/approval) and is referenced only, per the brief.

Deferred out of this PR's scope (arrived as research/triage notes, not operator-ACCEPTED like Option A; none trivially covered by the classifier built here):

🤖 Generated with Claude Code

https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC

…pproval classifier (#534)

Authorship (self/bot/human), finding (severity + lifetime-vs-open), and
approval-verdict detection were hand-rolled independently across the babysit
snapshot, merge gate, resolve-thread reporter, and readiness gate, and the
surfaces disagreed on identical input -- the six-issue misclassification class.

Extract one shared babysit_classify module (leaf: depends only on babysit_util)
and migrate every surface to it: babysit_delta/feedback/merge import the
self-login membership test and authorship/finding/approval primitives directly,
babysit_resolve_thread shares the same is_bot test, and babysit-readiness-gate.sh
shells out to a Python finding counter (babysit_findings.py) instead of
re-implementing the severity vocabulary in bash grep. The bash counting is kept
only as the Python-free safe-tier degrade; a convergence test pins the two.

Fixes folded in as golden fixtures by construction:
- #465 finding lifetime-vs-open: the counter discounts markers in resolved or
  outdated threads (mechanical resolved/outdated discount only; dedup-by-concern
  out of scope). babysit_merge remains the merge-ready boolean owner.
- #512 humanThreadsActed: count only threads whose opening author is human.
Members #499/#497/#473 are already fixed on main -> regression fixtures; #455 is
a different (retry) classifier, referenced only.

Every surface stays a pure predicate with no writes (#440 report-only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
@kyle-sexton kyle-sexton added area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation. labels Jul 20, 2026
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 55s —— View job


PR Review: refactor(source-control): one shared babysit classifier

  • Gather context and understand request
  • Read core new module babysit_classify.py
  • Read babysit_findings.py (new gate script)
  • Review migrated surfaces (babysit_delta.py, babysit_merge.py, babysit_feedback.py, babysit_resolve_thread.py)
  • Review test files (test_babysit_classify.py, test_babysit_resolve_thread.py)
  • Review shell scripts (babysit-readiness-gate.sh, .test.sh)
  • Review supporting files (plugin.json, orchestration.md, CHANGELOG.md)
  • Synthesize findings and post review

Summary

The extraction is well-executed. babysit_classify.py is a genuinely acyclic leaf (depends only on babysit_util), the re-export in babysit_feedback.py via __all__ preserves backward compatibility for existing consumers, the shell gate's Python-first/bash-fallback degrade pattern is sound, and the three issue fixes (#465, #512, and the #499 regression fixture) are each correctly implemented. A few things are worth surfacing:


Finding 1 — count_classified does not filter by thread_is_open (medium)

babysit_classify.py line 404

count_findings skips comments in resolved/outdated threads (thread_is_open guard). count_classified has no equivalent filter — it counts classification rows from self in all threads, resolved or not.

The combination can produce a false gate pass:

Round 1: Bot posts CRITICAL in thread T1. Self classifies → VALID. T1 resolves.
Round 2: Bot posts CRITICAL in thread T2 (new, open). No classification yet.

count_findings  = 1  (T2 only; T1 discounted by thread_is_open)
count_classified = 1  (VALID row from T1 — not discounted)
Gate: 1 > 0 AND 1 < 1 → FALSE → READINESS_OK  ✗

The bash path doesn't have this because it never discounts either side. The convergence test pins the two on thread-state-free fixtures, so this divergence is invisible to the current test suite.

If this is an acknowledged edge case within the "dedup-by-concern is out of scope" boundary, the comment in count_classified should say so explicitly. If it's unintentional, adding if not thread_is_open(comment): continue mirrors what count_findings already does. Fix this →


Finding 2 — actor_kind uses .lower() while the rest of the module uses .casefold() (low)

babysit_classify.py line 224

login = author_login(item).lower()
return "bot" if is_bot(login, None, config.extra_bot_logins) else "human"

Inside is_bot, this same login then has .casefold() applied again:

return login.casefold().removesuffix("[bot]") in normalize_login_set(extra_bot_logins)

For ASCII logins .lower() and .casefold() are identical, but the inconsistency is a latent footgun and makes the code harder to audit. The rest of babysit_classify.py and normalize_self_logins/normalize_login_set consistently use .casefold(). Fix this →


Confirmed correct

#512 fix (babysit_resolve_thread.py line 473–479): humanThreadsActed now gates on is_bot(r["author"], r["authorType"]) — the opening author's type — instead of r["botOnly"] (all participants). A bot-opened thread with a human reply had botOnly=False, which mislabeled it as a human thread action. The fix is correct and the test in test_babysit_resolve_thread.py covers the exact scenario.

#465 fix (babysit_classify.py thread_is_open, count_findings): The resolved/outdated discount is correctly mechanical. thread_is_open returns True when neither isResolved nor isOutdated is truthy — a comment with neither field set (issue-level or bash-compatible fixture) correctly counts. The bash degrade intentionally retains the old behavior for the Python-free safe tier; the convergence test correctly scopes pinning to thread-state-free fixtures.

#499 regression fixture (test_babysit_classify.py line 118–128): Covers the Approve-with-nits body via approval_downgrade and count_findings returning 0. This is a regression-proof check.

Acyclicity: babysit_classify.py imports only from babysit_util. ✓

Backward compat: babysit_feedback.py re-exports every moved name in __all__; existing consumers import unchanged. ✓

normalize_login_set vs normalize_self_logins asymmetry (keeps [bot] in self set, strips it in the general set): documented and correct — the self set must match against the raw author login that carries the suffix.

babysit-readiness-gate.sh Python path: The if [[ "$py_out" =~ findings=([0-9]+) ... ]] regex guard means a Python failure (exit 2, empty output) silently falls back to the bash counts rather than failing the gate. That is the documented degrade contract. ✓

Bash script injection safety: SELF_JSON is constructed with jq -R . | jq -s ., which correctly escapes self login values before they reach jq --argjson. ✓


The PR is structurally sound and the two priority-1 member issues are fixed correctly. Finding 1 above is the most important to resolve before merge (or explicitly acknowledge as in-scope of the dedup-by-concern out-of-scope carve-out), since it can produce a real false gate pass that count_findings's own thread-state logic creates.

babysit_classify.py and babysit_findings.py carry shebangs; match the executable
git index mode of the sibling babysit-prs scripts (hygiene shebang-exec gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 19s —— View job


PR Review: refactor(source-control): one shared babysit authorship/finding/approval classifier

  • Gather context and understand request
  • Read core new module babysit_classify.py
  • Read babysit_findings.py (new gate script)
  • Review migrated surfaces (babysit_delta.py, babysit_merge.py, babysit_feedback.py, babysit_resolve_thread.py)
  • Review test files (test_babysit_classify.py, test_babysit_resolve_thread.py)
  • Review shell scripts (babysit-readiness-gate.sh, .test.sh)
  • Review supporting files (plugin.json, orchestration.md, CHANGELOG.md)
  • Synthesize findings and post review

Overall

Solid extraction. The dependency graph is correctly acyclic (babysit_classifybabysit_util only), all four surfaces now share one vocabulary, and each formerly-divergent member issue has a golden fixture locking it in. The bash-degrade path and the convergence test are well-conceived. The commits are clean and the CHANGELOG accurately describes the behavioral changes.

One medium-severity correctness gap, one minor inconsistency, and a few notes below.


Medium: count_classified is not thread-state-aware, enabling a false-READINESS_OK

File: babysit_classify.py L404–421

count_findings correctly discounts severity markers in resolved/outdated threads. count_classified does not apply the same filter — it counts every self-authored classification row regardless of whether the comment is in a resolved or open thread.

The gate blocks on findings > 0 AND classified < findings. When classifications from prior resolved rounds accumulate in the denominator while new open findings appear in the numerator, the gate can pass even though a new finding has no classification at all.

Concrete scenario:

Round Event count_findings count_classified Gate
1 3 findings posted, all classified, all threads resolved 0 3 OK
2 Bot posts 1 NEW finding in a fresh (open) thread; no classification yet 1 3 False OK3 >= 1

This is different from the "dedup-by-concern across re-review rounds" that the PR explicitly defers. That deferral concerns the same concern restated within still-open threads; this is an unclassified new finding absorbing historical classifications from unrelated resolved threads.

The fix would be to compute count_classified only over self comments in currently-open threads — mirroring count_findings's thread_is_open filter. Alternatively, document this as a known limitation if the operational cost of false-OKs is judged tolerable.

Fix this →


Low: is_bot in project_thread and humanThreadsActed silently drops extra_bot_logins

File: babysit_resolve_thread.py L117–122 and L476–478

The is_bot call in project_thread (for computing botOnly) and the humanThreadsActed counter both call is_bot(login, typename) with no extra_bot_logins argument. If an operator registers a non-structural bot in babysit_extra_bot_logins, that account's threads will be treated as human-opened threads by this CLI:

  • botOnly = False for a thread where all commenters are from extra_bot_logins only (no [bot] suffix, no Bot typename) → thread classified as skipped-human-thread instead of eligible
  • humanThreadsActed counts that account as a human if it opens a thread that gets acted on

In practice GitHub App bots always carry the [bot] suffix or Bot typename, so this is only triggered by accounts that require the extra_bot_logins escape hatch. Worth at minimum a code comment noting the omission; worth fixing if the operator UX of babysit_extra_bot_logins is meant to propagate everywhere.


Note: count_classified counts thread-state-free issue-comments / review summaries too

From babysit_findings.py, fetch_live_comments builds the corpus with:

  • issue comments → isResolved: False, isOutdated: False (not thread-scoped)
  • review summaries → same

These are never in review threads, so thread_is_open always returns True for them — they are always counted in count_findings. Similarly, a self-authored classification reply posted as a top-level issue comment will always be counted in count_classified. This is correct and consistent; just noting the architecture for future readers.


Regex correctness spot-check: NEGATED_SEVERITY_MARKER_RE

The scoped inline-flag design ((?i:...) on the negator and trailing noun, bare (?:CRITICAL|IMPORTANT) in the middle) is correct and matches the documented intent. "No CRITICAL or IMPORTANT findings" → redacted; "No critical findings" → not redacted (because critical is lowercase and the middle alternation is case-sensitive). ✓


Shell degrade convergence proof is correctly scoped

The 47 pre-existing bash gate fixtures running through the Python counter is an accurate convergence proof for thread-state-free input (bash cannot see thread state; the fixtures don't carry isResolved/isOutdated). The #465 case is correctly guarded by a Python 3.11+ version probe so it's skipped on the bash-only safe-tier path. ✓


babysit_resolve_thread.py fix for #512 is correct

humanThreadsActed now gates on not is_bot(r["author"], r["authorType"]) — the opening author's type — rather than not botOnly (which was False for any thread with a human reply, regardless of who opened it). The three new golden fixtures in test_babysit_resolve_thread.py (bot-opened/human-reply, human-opened, mixed) are sufficient regression coverage. ✓


babysit_findings.py error handling

On Python failure, stderr is suppressed by the gate's 2>/dev/null, stdout is empty, the findings=..., classified=... regex doesn't match, and the bash degrade counts stand. This fail-open-to-bash fallback is intentional and correctly implemented. ✓


Security

  • No shell injection vectors: COMMENTS_JSON is quoted everywhere; self_csv_joined is constructed via IFS=, printf '%s' "${SELF_LOGINS[*]}" (array expansion, not eval).
  • The Python helper path sourcing (PY_SCRIPTS derived from SCRIPT_DIR via ${BASH_SOURCE[0]}) is robust against CWD manipulation.
  • babysit_classify.py carries no writes and has no network access — pure predicate as specified.
  • No secrets or PII introduced.

The ported badge-vocabulary comment reused the literal `![PN Badge]` alt-text
token, which the typos hygiene gate flags (PN -> ON). Reword to describe the
alt text without the token; also tidy a garbled test fixture string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 38s —— View job


PR Review: refactor(source-control): one shared babysit authorship/finding/approval classifier

  • Gather context and understand request
  • Inspect latest commit (fba38f6) — what changed vs prior review passes
  • Check whether prior medium finding (count_classified thread-state asymmetry) was addressed
  • Check whether prior low finding (actor_kind .lower() vs .casefold()) was addressed
  • Review babysit_classify.py for any new changes
  • Synthesize and post review

Latest commit (fba38f6) — hygiene only, confirmed clean

The sync commit makes two cosmetic-only changes:

  1. Comment reword (babysit_classify.py L329): removes the literal ![PN Badge] alt-text token from the inline comment — the typos gate was flagging PN as a likely ON typo. The replacement prose conveys the same double-count rationale without the token. No behavioral change.

  2. Test fixture tidy (test_babysit_classify.py L130): the prior fixture body was "CRITICAL: un," + " null dereference will crash the handler." — a stray "un," artifact from string concatenation during porting. The corrected string "CRITICAL: a null dereference will crash the handler." tests the same predicate (a genuine finding is not downgraded) and is now grammatically coherent. The assertion logic is unchanged.

Both changes are correct. No new findings from this commit.


Prior findings — still open

Neither prior review finding was addressed in this commit. They remain standing:

Medium — count_classified is not thread-state-aware (babysit_classify.py L402–421)

count_findings skips comments in resolved/outdated threads via thread_is_open. count_classified (L413 loop) applies no such filter, so classification rows from prior resolved threads accumulate in the denominator. A new unclassified finding in a fresh open thread then causes the gate to pass (classified >= findings) even though the new finding has no classification.

Round 1: 2 findings posted, self classifies both, threads resolved.  count_findings=0, count_classified=2
Round 2: Bot posts 1 new finding in a fresh open thread, unclassified. count_findings=1, count_classified=2
Gate: 2 >= 1 → READINESS_OK  ✗ (unclassified open finding ignored)

This is distinct from the "dedup-by-concern across re-review rounds" deferral, which addresses the same concern restated within still-open threads. This is an unclassified new finding being silently absorbed by historical classifications from unrelated resolved threads.

Fix: apply the same thread_is_open guard in count_classified so classifications from resolved threads are discounted symmetrically. Or add a code comment explicitly acknowledging the asymmetry as an accepted limitation. Fix this →

Low — actor_kind uses .lower(), rest of module uses .casefold() (babysit_classify.py L224)

login = author_login(item).lower() before passing to is_bot. All other normalization in this module uses .casefold(). Harmless for ASCII logins, but the inconsistency makes the code harder to audit and is a latent footgun for non-ASCII login characters. Fix this →


Everything else confirmed unchanged and correct

The structural review from the prior passes holds: acyclic dependency graph, correct #465 lifetime discount, correct #512 bot-thread fix, backward-compatible re-exports, bash degrade contract, and shell injection safety. This commit doesn't touch any of those paths.

…eads

Two review findings on the shared classifier:

- count_classified applied no thread_is_open guard, unlike count_findings,
  so a classification row from an already-resolved thread kept inflating
  the denominator -- a fresh finding in a newly-open thread could pass the
  gate's classified >= findings check with zero real classification. Apply
  the same #465 lifetime-vs-open discount symmetrically.
- actor_kind used .lower() instead of .casefold() before is_bot, unlike
  every other login normalization in this module.

Adds a regression test proving a resolved-thread classification no longer
masks a fresh open-thread unclassified finding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 17s —— View job


PR Review: refactor(source-control): one shared babysit authorship/finding/approval classifier

  • Gather context — new commit 3406846 (fix(source-control): count_classified discounts resolved/outdated threads)
  • Read babysit_classify.py — verify medium finding (thread-state filter in count_classified) is fixed
  • Check low finding — actor_kind .lower() vs .casefold()
  • Synthesize and post review

Latest commit (3406846) — both prior findings addressed, correctly

This commit resolves both open findings from the prior review passes.

Medium fixed — count_classified now discounts resolved/outdated threads (babysit_classify.py L419)

# Before (asymmetric — classifications accumulated across all threads)
if not is_json_object(comment):

# After (symmetric with count_findings)
if not is_json_object(comment) or not thread_is_open(comment):

The fix is exactly right: count_classified now applies the identical thread_is_open guard that count_findings already used, so the denominator and numerator of the classified >= findings gate predicate discount resolved/outdated threads symmetrically. A classification row from a prior resolved thread can no longer absorb an unclassified finding in a fresh open thread.

The docstring was also updated to explicitly document why the filter exists — this is the right kind of comment (a non-obvious invariant, not a description of what the code does).

Regression test (test_babysit_classify.py L111–133) covers exactly the false-pass scenario: one resolved thread with a self classification row (count_classified=0), one open thread with an unclassified bot finding (count_findings=1), and an assertLess proving the gate now correctly blocks. This is precisely the right fixture for this invariant.

Low fixed — actor_kind now uses .casefold() (babysit_classify.py L224)

# Before
login = author_login(item).lower()

# After
login = author_login(item).casefold()

Consistent with normalized_bot_login at L228–229 and all other normalization sites in the module. ✓


No new findings from this commit

The two changes are purely additive (one filter condition, one method call), and they fix the exact gap each prior finding described. No regressions to the passing paths — thread_is_open returns True when neither isResolved nor isOutdated is set, so existing fixtures without thread-state fields continue to count in both count_findings and count_classified unchanged.


Full PR status — all findings resolved

Finding Severity Status
count_classified not thread-state-aware → false READINESS_OK Medium Fixed in 3406846
actor_kind uses .lower() vs .casefold() Low Fixed in 3406846
is_bot in project_thread/humanThreadsActed drops extra_bot_logins Low (informational) Accepted — structural bots always carry [bot] suffix or Bot typename; this only matters for the extra_bot_logins escape hatch, which is a documented operator action

All three member issue fixes (#465, #512, #499 regression) remain correctly implemented and covered by golden fixtures. The acyclic dependency graph, backward-compatible re-exports, bash degrade contract, and shell injection safety from the prior structural review are all unchanged. ✓

This PR is ready to merge.

… discount stale classifications

Add a real dual-path convergence test: BABYSIT_READINESS_BASH_ONLY=1 forces the
Python-free degrade so the gate test asserts the shared Python counter and the
bash grep counting agree on thread-state-free fixtures (the operator escape also
makes the degrade path exercisable). The severity vocabulary can no longer drift
between the two silently.

Make count_classified apply the same resolved/outdated discount as count_findings
(#465 symmetry): a classification row in an already-resolved thread is a lifetime
artifact, so without the discount a stale row would offset a fresh unclassified
finding and false-pass the gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 17s —— View job


PR Review: refactor(source-control): one shared babysit authorship/finding/approval classifier

  • Gather context — commits 3406846 (medium fix: count_classified thread-state filter) and 206dad5 (convergence test pinning)
  • Read babysit_classify.py — verify medium finding fixed correctly
  • Check low finding — actor_kind .lower() vs .casefold()
  • Read new/updated test files — verify convergence test and fixture coverage
  • Synthesize and post final review

Both prior findings resolved

Medium — count_classified thread-state asymmetry → Fixed in 3406846.

babysit_classify.py L419 now applies or not thread_is_open(comment) as the loop guard, exactly mirroring count_findings. The docstring was updated to explain the rationale. The exact false-gate-pass scenario (stale resolved-thread classification absorbing an unclassified open finding) is pinned by a new regression test:

test_babysit_classify.py L112–132test_resolved_thread_classification_is_discounted asserts count_classified == 0 for the resolved-thread row, count_findings == 1 for the fresh open-thread finding, and classified < findings (gate would correctly block). ✓

Low — actor_kind used .lower() instead of .casefold() → Fixed in 3406846.

babysit_classify.py L224 now reads login = author_login(item).casefold(), consistent with every other login normalization in the module. ✓


New commit 206dad5 — convergence test is correct and complete

BABYSIT_READINESS_BASH_ONLY=1 escape hatch in babysit-readiness-gate.sh: the guard "${BABYSIT_READINESS_BASH_ONLY:-}" != 1 is correctly written — the :- default means an unset variable behaves identically to an absent one; the escape only fires when explicitly set to 1. ✓

The four convergence fixtures in babysit-readiness-gate.test.sh L304–334 cover all three severity-vocabulary shapes and the self-row exclusion rule:

Fixture Vocabulary shape
severity-words CRITICAL/IMPORTANT/SUGGESTION
codex-badges /badge/P1-, /badge/P2- shields.io URLs
plain-p-markers [P1], [P2] bracketed labels
self-row-exclusion | CRITICAL: null deref | VALID | table row excluded from count

All fixtures are thread-state-free (no isResolved/isOutdated fields), which is the correct scope for pinning bash-vs-Python: bash cannot see thread state, so convergence can only be asserted on input both paths handle identically. The header comment in the test makes this explicit. ✓

mkjson is defined at line 27 of the test script and is correctly reused. ✓


Remaining note (low, from prior pass — still open)

is_bot in babysit_resolve_thread.py omits extra_bot_logins at two call sites (L117–120 for botOnly, L477 for humanThreadsActed). If an operator registers a non-structural bot via babysit_extra_bot_logins, that account's threads are miscategorized here. This is pre-existing relative to the PR's scope (the PR changes these lines only to use the shared is_bot — the extra_bot_logins omission was latent before). Worth a follow-up or a code comment acknowledging the omission, but not a blocker for this PR.


Overall

Both priority findings are fixed with correct implementations and regression fixtures. The convergence test is a genuine improvement: it transforms the "the bash and Python counters shouldn't drift" property from a prose claim into an executable assertion. The PR is structurally sound and ready for merge.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Review classification (work-2 lane, post-green pass)

# Finding Classification Disposition
1 count_classified not thread-state-aware — historical classifications from resolved threads could mask a fresh unclassified finding, producing a false READINESS_OK VALID (medium) Fixed — thread_is_open guard mirrored from count_findings; regression test test_resolved_thread_classification_is_discounted added
2 actor_kind used .lower() where the module convention is .casefold() VALID (low) Fixed — now uses .casefold()
3 babysit_resolve_thread.py's two is_bot call sites omit extra_bot_logins VALID, deferred Filed #637 — pre-existing gap this PR's migration surfaced but didn't introduce; low severity, out of scope for this PR's diff

Findings 1-2 fixed and verified by an independent re-review pass (commit 3406846 + convergence-test commit 206dad5), confirmed both correctly implemented with regression coverage. Finding 3 tracked in #637, cited in ## Related.

Also did a manual diff read (rule 10 due diligence, given this touches merge-readiness gating): the PR's one deviation from the ratified brief — retaining a bash-only Python-free degrade path via BABYSIT_READINESS_BASH_ONLY=1 rather than hard-requiring Python for the shell-out — is verified sound. reference/loop.md genuinely documents a pre-existing "Python-free degrade path" contract this preserves; shell-out remains the default/preferred path, the bash path is a fallback with a convergence test pinning the two counters together. Not a re-litigation of the ratified shell-out decision, just preserving an existing guarantee the brief's author didn't know about.

@kyle-sexton
kyle-sexton marked this pull request as ready for review July 20, 2026 07:16
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 39s —— View job


PR Review: refactor(source-control): one shared babysit authorship/finding/approval classifier

  • Gather context — HEAD SHA 206dad5, reviewing final state after all prior finding fixes
  • Read babysit_classify.py — verify both fixes from prior rounds
  • Read babysit_findings.py and test files
  • Verify convergence test additions (206dad5)
  • Check shell scripts and security posture
  • Synthesize final verdict

Summary

This is the final review pass on HEAD 206dad5 after four iterative review rounds. Both standing findings from prior passes were fixed in 3406846 and the convergence test was added in 206dad5. No new findings.


Prior findings — all resolved

Medium (fixed 3406846) — count_classified thread-state asymmetry

babysit_classify.py L419

The or not thread_is_open(comment) guard was added, mirroring count_findings's existing discount. The docstring now explicitly names the invariant: a stale classification from a resolved thread cannot inflate the denominator against a fresh, unclassified finding. The regression test test_resolved_thread_classification_is_discounted (L112–132) pins the exact false-gate-pass scenario — count_classified=0, count_findings=1, assertLess proves the gate blocks. ✓

Low (fixed 3406846) — actor_kind used .lower() instead of .casefold()

babysit_classify.py L224

author_login(item).casefold() — consistent with every other login normalization site in the module. ✓

Low/informational (accepted, #637 filed) — is_bot in babysit_resolve_thread.py omits extra_bot_logins

Pre-existing gap surfaced by the migration, not introduced by it. Structural bots always carry the [bot] suffix or Bot typename; the gap only matters for accounts requiring the extra_bot_logins escape hatch. Filed as #637, cited in ## Related. ✓


Convergence test (206dad5) — correctly scoped and implemented

The converge() helper in babysit-readiness-gate.test.sh L303–311 runs the gate normally (Python-preferred) and again with BABYSIT_READINESS_BASH_ONLY=1 (bash degrade forced), then asserts their findings=/classified= outputs match. The four fixture shapes cover all three severity vocabularies and the self-row exclusion rule. Fixtures are thread-state-free (correct scope: bash cannot see isResolved/isOutdated). The :- default operator on BABYSIT_READINESS_BASH_ONLY means the escape only fires when explicitly set to 1. ✓


Spot-checks confirmed correct

Area Check
Acyclicity babysit_classify.py imports only babysit_util. All consumer surfaces import from babysit_classify (or babysit_feedback which re-exports). ✓
Backward compat babysit_feedback.__all__ lists all 17 moved names; all have a corresponding from babysit_classify import .... Existing consumers unchanged. ✓
#465 lifetime discount thread_is_open returns True when neither isResolved nor isOutdated is truthy; comments without either field (issue-level, bash-fixture shape) correctly count. ✓
#512 fix humanThreadsActed at L477 gates on not is_bot(r["author"], r["authorType"]) — the opening author's type — not botOnly. Three golden fixtures cover bot-opened/human-reply, human-opened, and mixed. ✓
#499 regression test_approve_with_nits_downgrades_and_has_no_severity_finding covers the exact scenario from the issue. ✓
Python degrade contract On Python failure, stderr suppressed by 2>/dev/null, stdout empty, findings=/classified= regex doesn't match, bash counts stand. ✓
fetch_live_comments resolved thread handling include_resolved=True is intentional — resolved threads fetched so they can be discounted, not silently dropped. ✓
Shell injection SELF_JSON built via printf ... | jq -R . | jq -s .; self CSV joined with array expansion, not eval. ✓
All-bodies finding count Bash path uses all_bodies = non_self_bodies + self_source_bodies (self classification rows stripped). Python count_findings applies the same strip for self-authored comments. Symmetric. ✓
Pure predicate babysit_classify.py has no writes and no network access. babysit-readiness-gate.sh detection-only. ✓

Overall

Both priority findings are correctly fixed with regression coverage, the convergence test transforms a prose claim into an executable assertion, and the three member issues (#465, #512, #499) each have a golden fixture locking in the fix. The dependency graph is acyclic, the re-export surface is intact, and the bash-degrade contract is preserved.

This PR is ready to merge.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 206dad5764

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

… review thread

fetch_review_threads caps each thread's comment connection and flags an
oversized thread comments_truncated instead of raising, so one giant thread
cannot fail the whole snapshot for consumers that do not need every comment.
fetch_live_comments IS such an exact-count consumer but ignored the flag: past
the 100-comment cap a severity marker or classification row was silently
dropped, under-counting findings so the readiness gate could report
READINESS_OK while a later open finding sat unclassified.

Honor fetch_review_threads' documented contract and fail closed: raise on any
truncated thread. main maps this to exit 2, which emits no findings= line, so
the gate stays on its complete REST/bash degrade count (fetch-all-pr-comments.sh
surface 3 paginates every inline review comment PR-wide, with no per-thread
cap). Falling back over-counts at worst (safe direction), never false-passes.

Adds test_babysit_findings.py pinning the gate-observable contract: a truncated
thread makes main exit 2 with no findings= line on stdout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FM1RfM3jHkgenpdbMv4o64
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 50s —— View job


PR Review: refactor(source-control): one shared babysit authorship/finding/approval classifier

  • Gather context — new commit bb55a5d (fail babysit finding count closed on a truncated review thread)
  • Read babysit_findings.py — verify truncation handling
  • Read babysit-readiness-gate.sh — verify gate behavior on truncated Python output
  • Check test coverage for truncation scenario
  • Synthesize and post final review

Latest commit bb55a5d — Codex P2 finding addressed, correctly

This commit adds the truncation guard the Codex review requested and pins its contract with a dedicated test file.

The fix (babysit_findings.py L116–121):

if thread.get("comments_truncated"):
    raise RuntimeError(
        f"review thread {thread.get('id')} has "
        f"{thread.get('comments_total_count')} comments exceeding the "
        "per-thread fetch cap; failing closed to the bash finding count"
    )

RuntimeError is already in main's except clause (L149). On truncation: main returns exit 2 → stderr suppressed by the gate's 2>/dev/nullpy_out is empty → the gate's findings=([0-9]+) regex doesn't match → findings/classified stay as the bash-computed values. The bash degrade uses fetch-all-pr-comments.sh, which paginates PR-wide with no per-thread cap — it sees all comments. The fail direction is safe: bash over-counts at worst (all threads, regardless of resolution state), never under-counts and false-passes.

Scope of the fix is correct. Only fetch_live_comments (the --pr live-fetch path) touches fetch_review_threads and needs the guard. The --comments-json path uses a pre-built flat array with no comments_truncated metadata; it doesn't pass through this function.

Test coverage (test_babysit_findings.py):

Test What it pins
test_truncated_thread_raises fetch_live_comments raises RuntimeError on a truncated thread
test_untruncated_thread_collects_comments Normal operation unchanged
test_main_exits_2_with_no_count_line_on_truncation Gate-observable contract: exit 2, no findings= on stdout

The third test is the load-bearing one — it proves what the gate actually observes (empty stdout, no regex match → bash degrade stands). Patching on the bf namespace (imported-by-name) is the correct mock target for this pattern. ✓

Docstring update (L97–104): explains why this counter is an exact-count consumer (unlike snapshot consumers that tolerate partial thread data), and why the fail-closed contract is correct here. This is the right kind of comment. ✓


All findings resolved

Finding Severity Status
count_classified not thread-state-aware → false READINESS_OK Medium Fixed in 3406846
actor_kind uses .lower() vs .casefold() Low Fixed in 3406846
is_bot in babysit_resolve_thread.py omits extra_bot_logins at two call sites Low (informational) Accepted — structural bots always carry [bot] suffix or Bot typename; filed as #637
[Codex P2] Truncated review-thread page silently dropped → false READINESS_OK Medium Fixed in bb55a5d

Full structural review — unchanged and confirmed correct

Everything from prior passes holds: acyclic dependency graph, correct #465 lifetime discount, correct #512 bot-thread fix, #499 regression fixture, backward-compatible re-exports, bash degrade contract, shell injection safety. No regressions from the two changes in bb55a5d (new if guard in a loop, new test file).

This PR is ready to merge.

@kyle-sexton
kyle-sexton merged commit 3f42807 into main Jul 20, 2026
17 checks passed
@kyle-sexton
kyle-sexton deleted the fix/534-babysit-shared-classifier branch July 20, 2026 07:57
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…) (#643)

## Summary

`/work-items:work` selection had no rule excluding an issue that already
has an open linked PR from the pickable frontier. An issue keeps
`status: ready` for its entire window with an open PR (from open through
merge/auto-close), so a picker had to manually cross-check `gh pr list`
to avoid re-picking work already in flight and starting a duplicate
branch. This ships the ratified in-repo open-linked-PR selection filter
(operator decision, 2026-07-19): an issue with an open linked PR is not
pickable.

## Fix

- **Selection-time frontier filter (`skills/work/SKILL.md`, Step 1).**
After `list-frontier` derives the frontier, tiers 2–3 candidates are
additionally filtered to drop any number that has an open PR targeting
it for closure — so an in-flight item leaves the pickable set instead of
being re-picked. The **closing-keyword linkage** is authoritative (the
same `Closes #N` / native-closing-keyword signal `pr-issue-linkage`
enforces); an intentional `Refs #N` opt-out does not exclude its issue.
The filter **fails open** when the bound provider exposes no PR host
(offline `local-markdown` is never a coordination surface and touches no
network tool).
- **New GitHub adapter mechanic
(`tools/work-item-tracker/adapters/github/README.md`, "Open linked
PRs").** The provider mechanics stay in the adapter per this repo's
seam/adapter separation — the skill core inlines no `gh`. It uses `gh pr
list --state open --search "<N> in:body"` (truncation-safe per item, no
page-size race) and a closing-keyword `jq` test with an exact-number
boundary so `#463` does not match `#4630` / `#1463`.
- **Retires the interim heuristic.** The prior "Already-in-flight
(interim, retire on `#463`)" bullet in the execute-step staleness
pre-check is removed — the frontier itself now excludes in-flight items,
which is exactly the retirement that bullet anticipated. Scope-narrowed
to the closing-keyword signal (the interim branch-pattern-only match is
dropped): a standard-flow PR always carries the gate-enforced `Closes
#N`, so keyword-matching is faithful to the ratified "references it as a
closing target" scope.
- **No seam-contract change.** `CONTRACT.md` and `list-frontier` are
untouched; the addition is a README-documented adapter mechanic, not a
new seam verb. The durable seam-level in-review state is explicitly NOT
built here (see Related).

## Verification

The adapter mechanic was exercised against live repo data (read-only).
`<N>` substituted per row:

```
issue #435 open-closing-PR: true    (open, unassigned, status: ready; open PR #638 closes it)
issue #487 open-closing-PR: true    (open, unassigned, status: ready; open PR #629 closes it)
issue #534 open-closing-PR: true    (open, unassigned, status: ready; open PR #634 closes it)
issue #463 open-closing-PR: false   (no open PR closes it → stays pickable)
issue #4630 open-closing-PR: false  (exact-number boundary: does not false-match on #463's PR)
```

Each of #435 / #487 / #534 is a genuine frontier-eligible candidate
(open, unassigned, `status: ready`) that under the old logic stayed
pickable despite an open PR already in flight; the filter returns `true`
for exactly those and excludes them, while #463 (no open closing PR)
returns `false` and remains pickable. `list-frontier` itself was not run
end-to-end here because the `work-items` plugin repo binds no tracker
(`.work-item-tracker.json` is a consuming-repo artifact) — the
per-number filter, which is the added logic, is what is demonstrated
above.

Closes #463

## Related

- #463 — this issue (open-linked-PR selection filter).
- #416 — planning routes through the tracker seam; argues the durable
in-review state belongs at the seam, not a GitHub-only label. This PR
does NOT implement that durable state.
- #498 — seam read-verb coverage; where a durable in-review /
container-scoped read would live. This PR does NOT implement that
durable state.

Note: PR #641 concurrently bumps `work-items` to `0.14.0` (minor, for
#478) and also edits `plugin.json` + `CHANGELOG.md`. This PR is a patch
bump to `0.13.1` from the current `origin/main` base (`0.13.0`) — a
different version slot, not a race — but whichever merges second will
need a mechanical rebase on those two files (the same-plugin concurrency
#464 treats as an awareness note, not a block).

🤖 Generated with a Claude Code implementation subagent (issue #463)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…ce (#642) (#666)

## Summary

The babysit readiness gate blocks while source findings outnumber their
per-finding classification rows. The shared classifier counted a
self-authored
classification pipe-row in ANY comment, including PR-level
review-summary
comments that are never thread-resolved. A review thread's findings are
discounted when it resolves (the #465 lifetime-vs-open guard), but a
PR-level
comment can never be — so a stale classification posted outside a thread
kept
counting after its finding was discounted, inflating the classified
count past a
fresh, still-unclassified open-thread finding and emitting a fail-open
`READINESS_OK`.

This was the single live fail-open on `main` and the ratified backstop
for the
#476 gate-off flip: per the backstop clause it must be fixed before the
flip.

The dispatched fix shape ("non-thread PR-level comments never contribute
to
`classified`") turned out unsafe on verification: `review-discipline.md`
§D5
*mandates* that issue/review-level (PR-level) findings be answered with
a
detached PR-level classification comment. Thread-only counting would
count every
such classification as zero and permanently block any PR whose findings
come
from review summaries — and break the Python↔bash convergence. Corrected
in
agreement with the tower.

## Fix

- **Per-surface classification credit (`babysit_classify.py`).**
`count_effective_classified` buckets comments by surface and caps credit
within
each bucket via `min(classified, findings)`, then sums — a
classification can
only offset a finding on its own surface, so a stale PR-level row can no
longer
spill over to cover an open-thread finding. `count_classified` and
`count_findings` stay pure raw counters; the bucketing composes them. On
unsignalled input every comment lands in one bucket and this collapses
to
`min(classified, findings)`, preserving existing behavior and the bash
convergence property.
- **Surface discriminator (`comment_surface`).** Three surfaces —
review-thread,
PR-level, and an isolated bucket for comments bearing no surface signal
—
resolved from two signals in order: the explicit `in_review_thread`
stamp
(authoritative when present), then the `fetch-all-pr-comments.sh` `type`
tag on
the `--comments-json` reuse path (`inline` → thread; `general`/`review`
→
PR-level). A comment with neither signal is isolated so its rows cannot
offset —
and its findings cannot be offset by — a known surface (fail-closed for
unknown
provenance), preserving the "no signal = PR-level lifetime" model
`thread_is_open`
documents.
- **Surface stamping (`babysit_findings.py`).** `_comment()` records
`in_review_thread` (true only when fetched from a review thread);
issue-level and
review-summary comments are stamped PR-level. The entrypoint emits the
effective
count.
- **Bash degrade cap (`babysit-readiness-gate.sh`).** The thread-blind
safe-tier
degrade gains the thread-state-free analogue `classified =
min(classified,
findings)` so a row over-count can't mask a finding and the degrade
stays
convergent with the Python `min` on unsignalled input. Per-surface
bucketing is
inherently surface-aware and remains Python-only, exactly like the #465
discount.

## Behavior flip (documented, fail-closed)

A PR whose inline-thread findings are answered only by detached PR-level
classification replies now reports `READINESS_BLOCKED` where it
previously
passed — a PR-level row no longer offsets an inline-thread finding. This
mechanically enforces §D5's already-ratified reply routing (inline
findings MUST
reply threaded, "NEVER a detached `pr comment`"). Runs already following
§D5 are
unaffected; only runs relying on the previously-tolerated detached-reply
shape
change verdict.

## Residual (documented, not closable here)

An orphaned PR-level classification covering a fresh *PR-level* finding
is
irreducible: GitHub's flat issue comments carry no
finding↔classification
linkage, so a stale PR-level row is data-identical to a live one. It is
reachable
only via a §D5 routing violation (an inline finding answered with a
detached PR
comment) or a reviewer editing/deleting a finding; a §D5-compliant,
no-edit run
never hits it. The per-surface fix closes the entire linkable (thread)
side, on
both the live and `--comments-json` reuse paths.

## Verification

- `python -m unittest discover -s tests` — 269 tests OK, incl. new
`EffectiveClassifiedTests` (per-surface credit, reuse-path `type`
inference,
explicit-stamp precedence, isolated-unknown), `SurfaceStampingTests`,
and
`Main642FailOpenTests`.
- `babysit-readiness-gate.test.sh` — 60 pass, incl. the live-path #642
scenario,
the reuse-path inline-`type` scenario, the unsignalled-provenance
isolation
scenario (all Python-gated like #465), and all five convergence cases
including
the over-classified cap.
- `engine.test.sh` — pass (unittest + `ruff check`); shellcheck + shfmt
clean;
`markdownlint-cli2` + `validate-plugins.sh` clean.

## Versioning

- `plugins/source-control/.claude-plugin/plugin.json` → **0.13.3**;
CHANGELOG
entry under `[0.13.3]` documenting the fail-open closure and the §D5
PASS→BLOCK
behavior flip. `origin/main` merged in (0.13.2 base from #651).

## Related

Closes #642
- #534 / #634 — shared babysit classifier this hardens (found during the
#634 digest).
- #465 — the lifetime-vs-open discount this mirrors for classifications.
- #476 — gate-off flip this fail-open blocks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01FM1RfM3jHkgenpdbMv4o64

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…s, zero orphans (#708)

## Summary

Fix-direction (a) from the issue, per the #634 graded-fixture idiom:
every fixture under
`plugins/autonomy/skills/setup/evals/fixtures/security-binding/` is now
graded.

- **One table-driven runner**
(`check-security-binding.fixtures.test.mjs`) + **one co-located
expectations manifest**: each entry pins the checker invocation
(`--probe-evidence-root` at the fixtures dir; per-fixture
`--egress-hosts`/`--evidence` where needed) and the expected outcome —
exit code plus defect-naming stderr substrings. 109 fixtures: 14
pass-expected (12 valid bindings + 2 evidence-input pairings), 95
reject-expected. **Zero quarantined — no name-vs-behavior mismatches
surfaced.**
- **Self-policing both directions**: a new top-level fixture without a
manifest (or quarantine) entry fails; a manifest ref whose file vanished
fails; the 67 `probe-transcripts/` suite inputs are enumerated and
reconciled against disk both ways.
- **Baseline drained**: all 178 security-binding lines leave
`scripts/orphaned-fixtures-baseline.txt`; the orphaned-fixture gate
(#681) passes with the set consumed, exactly as its stale-guard demands.
- Thin `.test.sh` wrapper joins `plugins/**` CI test discovery. Suite:
**394/394 checks pass.** `validate-plugin-contracts.mjs`,
orphaned-fixtures `--check`, changelog-parity `--check`/`--check-bump`
all green. Autonomy plugin bumped 0.7.3 with CHANGELOG entry.

Note for reviewers: the convention-level decision about the
golden-fixture idiom repo-wide stays with #664 (needs-human); this PR
instantiates the already-precedented #634 shape for the one suite #662
names, which the issue's own fix-direction (a) authorizes.

## Related

- #681 (the orphaned-fixture gate whose baseline this drains)
- #664 (repo-wide golden-fixture convention decision — untouched)

Closes #662

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
… disabled by default (#665)

## Summary

Implements the #476 autopilot merge tier for `babysit-prs`: at day-scale
throughput, human
approve-and-merge is the pipeline bottleneck. The tier lets the fleet
**satisfy** the branch
ruleset instead of bypassing it — a second bot account (author ≠
approver) runs a genuine review
pass and submits an approving review **only when clean**, after which
the pinned merge gate merges
**only when every criterion holds**. The ruleset itself is never
touched; the bot review is what
makes this a real gate rather than a rubber stamp.

**HELD FOR OPERATOR: do-not-merge until the operator personally reviews
the safety-contract
change.** This PR moves through the normal pipeline as a proposal only.
The babysit safety contract
(`reference/safety.md`) changes *if and only if* the operator merges
this PR. It carries the
`do-not-merge` label at creation; `do-not-merge.yml` will hold the
required check red by design —
that is the intended hold, not a CI failure to fix.

## Criteria (verbatim from the 2026-07-19 15:49 maintainer decision)

Enforced deterministically in `babysit_merge.py` behind the fail-closed
`--autopilot-merge-tier`
umbrella flag. Base-gate criteria already existed; the tier layers the
rest.

| Decision criterion | Where enforced |
| --- | --- |
| required checks green incl. review workflow | base gate
(`mergeStateStatus` CLEAN + required-context reconciliation) |
| issue-linked | tier — `closingIssuesReferences` non-empty |
| authored by a pipeline lane | tier — `--lane-logins` membership |
| no human CHANGES_REQUESTED | base gate (`reviewDecision`) |
| no human blocking comment | tier — shared `has_blocking_text` /
`has_blocking_severity` over human comments |
| no unresolved thread | base gate (unresolved review threads) |
| no do-not-merge label | tier — `--block-labels` |
| no unratified decision-default marker on the linked issue | tier —
scans `closingIssuesReferences` comments for a `Decision defaulted`
marker; ratified only by a human `OWNER`/`MEMBER` comment after it |
| head SHA unchanged since review | tier — distinct-bot approval pinned
to the live head; `--expected-head` as always |
| author ≠ approver via bot identity | tier — distinct-bot approving
review (`--approver-bot-logins`, shared `is_bot`) |

**No deviations from the decision comment.** Every criterion predicate
is reused from the shared
`babysit_classify` module (#634), not re-implemented. Any criterion
failing falls back to today's
behavior: the PR is reported on the human merge-ready list — the tier
never routes around the gate.

## Flip preconditions (tier stays disabled until these land)

Both are enforcement-in-the-gate placements recorded on #476; the
pre-tier world (a human reads the
comments at GATE-ON) stays safe meanwhile.

- **Decision-default veto** — added in this PR as a merge criterion
(table above). Reactions are
deliberately not consulted for ratification: the reactions API carries
no `authorAssociation`, so a
reaction cannot be attributed to a maintainer, and attributing it via
the operator's self-logins
would let pipeline automation clear its own veto (the #450
attribution-drift hazard). Ratification
is therefore a maintainer comment, or a manual merge. Note: #476's own
thread quotes the marker
phrase, so once the tier is active #665 would self-hold under this
criterion — harmless, since it
  is `do-not-merge`-held and merged by the operator by hand regardless.
- **#642** — the `count_classified` fail-open (stale pipe-rows in
non-thread comments) has **landed**
(source-control 0.13.3 via #666) and is merged into this branch, so this
flip-precondition is now
cleared. It was never worked around locally — the tier consumes the
shared classifier directly. The
remaining holds are the operator's safety-contract review and the
disabled-by-default flip.

## Disabled by default

The tier exists only while the new `babysit_autopilot_merge_tier`
userConfig (boolean, default
**off**) is enabled; the skill wires the `--autopilot-merge-tier` flags
only then. Enabling the
flag, and any later gate-off flip, is a separate, announced operator
step. Absent the flag the merge
gate is byte-for-byte its prior self, so `worker`/`autopilot`'s existing
gate-proven merges are
unchanged. The umbrella flag is fail-closed: it refuses (exit 3) unless
`--lane-logins`,
`--approver-bot-logins`, and `--block-labels` are all supplied.

## safety.md rationale

`reference/safety.md`'s "Never do automatically → merge" contract is
updated deliberately to codify
the tier and its criteria as an explicit, config-gated carve-out: the
fleet may generate its own
approving review and merge **only** under the enumerated criteria,
fail-closed, with a genuine bot
review and an untouched ruleset. This is the safety-posture change the
operator signs off by
merging.

## Test plan

- `python -m unittest discover -s tests` — 287 tests pass, including new
`test_babysit_merge.py`
(each criterion with a passing **and** a fall-back fixture, the
decision-default veto's
pass / unratified / fetch-error cases, plus the tier-absent no-network
invariant) and guard /
  skill-contract additions for the fail-closed CLI and tier prose.
- `engine.test.sh` — unittest suite + ruff clean + guarded-wrapper
checks (including the new
  wrapper-level tier fail-closed) all pass.
- `babysit-readiness-gate.test.sh` — unchanged, green (Python-free
degrade + convergence intact).
- markdownlint, typos, and plugin-manifest JSON-schema validation all
clean locally.

## Related

Closes #476

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…1337)

Closes #637

*This was generated by AI during work-loop execution.*

## Summary

`babysit_resolve_thread.py` called the shared `is_bot` classifier at two
sites without passing
the caller's `extra_bot_logins` config — `project_thread`'s `botOnly`
computation (L117-120) and
the `humanThreadsActed` reporting counter (L477) — unlike every other
classifier call site (e.g.
`actor_kind` in `babysit_classify.py`). An operator who registered a
non-structural bot account
via `babysit_extra_bot_logins` (no `[bot]` login suffix, API
`__typename` reports `User`) had
that account's threads miscategorized at both sites. Pre-existing
relative to #534/#634 (that PR
migrated these call sites to the shared `is_bot` but did not introduce
the omission).

This PR adds an `--extra-bot-logins` CLI flag to
`babysit_resolve_thread.py` (same
comma-separated shape as the snapshot wrapper's flag), threads it
through `project_thread` and
`fetch_threads` via a closure, and passes it to both `is_bot` call
sites. `SKILL.md`'s
`babysit_extra_bot_logins` flag-delivery table now lists
`resolve-thread` alongside `snapshot`.
Patch-bumps `source-control` to 0.26.3 with a matching CHANGELOG entry.

## Test plan

- Added `ProjectThreadExtraBotLogins` (site 1) and
`HumanThreadsActedExtraBotLogins` (site 2) to
`tests/test_babysit_resolve_thread.py` — each asserts a configured
non-structural login is
correctly classified as bot, and that an unconfigured one still falls
back to structural
  detection alone (regression coverage for both directions).
- `python -m pytest tests/ -q` in
`plugins/source-control/skills/babysit-prs/scripts` — 352
  passed, 58 subtests passed (full existing suite, no regressions).
- `python -c "import ast; ast.parse(...)"` — syntax check on the
modified script.

## Related

N/A

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: security Security-relevant: vulnerability, hardening, or disclosure follow-up. automated Opened by automation.

Projects

None yet

1 participant