Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/source-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "source-control",
"version": "0.12.0",
"version": "0.13.0",
"description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention and babysit-prs config, or apply — interview the repo and write the tracked convention config), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable per repo via a tracked .claude/source-control.md config written by a re-runnable setup skill; Conventional Commits is the default when no convention is declared.",
"author": {
"name": "Melodic Software",
Expand Down
36 changes: 36 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@
All notable changes to the `source-control` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.13.0]

### Changed

- **`babysit-prs` authorship / finding / approval classification is now one shared module.** The
self/bot/human authorship test, the finding severity + lifetime-vs-open counting, and the
approval-verdict heuristics were hand-rolled independently across the snapshot classifier, the
merge gate, the resolve-thread reporter, and the readiness gate, and the surfaces disagreed on
identical input — the six-issue misclassification class this refactor closes. They now consume
one classifier: `babysit_delta`, `babysit_feedback`, and `babysit_merge` import the self-login
membership test and authorship/finding/approval primitives directly instead of re-deriving them,
`babysit_resolve_thread` shares the same `is_bot` test, and `babysit-readiness-gate.sh` shells
out to the shared finding counter (mirroring the existing merge-gate wrapper) rather than
re-implementing the severity vocabulary in bash grep. Every surface stays a pure predicate with
no writes. Each formerly-divergent member issue is now a golden fixture, regression-proof by
construction.

### Fixed

- **`babysit-readiness-gate.sh` no longer over-counts lifetime findings as unaddressed.** The gate
counted every severity marker ever posted across a PR's lifetime — including markers in review
threads GitHub already reports resolved or outdated — so a fully-classified PR with re-review
history reported `READINESS_BLOCKED reason=under-decomposed` permanently even when every open
item was addressed. The shared finding counter discounts a marker carried in a resolved or
outdated thread, counting currently-open findings only. (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.) The bash counting is
retained only as the Python-free safe-tier degrade, which cannot see thread state; a convergence
test pins the two counts together on thread-state-free input.
- **`source-control-babysit-resolve-thread` no longer reports `humanThreadsActed` for a
Bot-authored thread.** The counter incremented for any acted thread whose comments were not
*all* bots (`botOnly` false), so a bot-opened thread carrying a later human reply was reported as
a human-thread action that never happened, undermining the human-thread safety rail's own
telemetry. It now counts only threads whose opening author is human, via the shared authorship
classifier — the same author check the `--include-human` eligibility decision already uses.

## [0.12.0]

### Fixed
Expand Down
36 changes: 36 additions & 0 deletions plugins/source-control/scripts/babysit-readiness-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,42 @@ classified=${classified//[^0-9]/}
findings=$((${sev_words:-0} + ${sev_badges:-0} + ${sev_plain:-0}))
classified=${classified:-0}

# --- Prefer the shared Python classifier when available -----------------------

# The bash counts above are the Python-free safe-tier degrade (reference/loop.md
# is that path, and it runs this gate). When a Python 3.11+ interpreter is
# present, re-count via the shared babysit_classify module instead: it owns the
# severity vocabulary as ONE source of truth rather than a second bash copy that
# can drift from the snapshot classifier (the divergence class #534 exists to
# close), and it discounts a severity marker carried in a resolved or outdated
# thread, so a lifetime badge no longer inflates the count into a false
# READINESS_BLOCKED (#465). A convergence test pins the two counts together on
# thread-state-free input; if the Python counter cannot run (no interpreter, or a
# transient live fetch failure) the bash degrade counts above stand.
# BABYSIT_READINESS_BASH_ONLY=1 forces the degrade even when Python is present --
# the operator escape that exercises (and, in the gate's own tests, pins) the
# Python-free path deterministically.
PY_SCRIPTS="$SCRIPT_DIR/../skills/babysit-prs/scripts"
if [[ "${BABYSIT_READINESS_BASH_ONLY:-}" != 1 && -f "$PY_SCRIPTS/babysit-python.sh" ]]; then
# shellcheck source=../skills/babysit-prs/scripts/babysit-python.sh
. "$PY_SCRIPTS/babysit-python.sh"
self_csv_joined="$(
IFS=,
printf '%s' "${SELF_LOGINS[*]}"
)"
if [[ -n "$COMMENTS_JSON" ]]; then
py_out="$(babysit_python "$PY_SCRIPTS/babysit_findings.py" \
--comments-json "$COMMENTS_JSON" --self "$self_csv_joined" 2>/dev/null)"
else
py_out="$(babysit_python "$PY_SCRIPTS/babysit_findings.py" \
--pr "$PR_NUMBER" --self "$self_csv_joined" 2>/dev/null)"
fi
if [[ "$py_out" =~ findings=([0-9]+)[[:space:]]+classified=([0-9]+) ]]; then
findings="${BASH_REMATCH[1]}"
classified="${BASH_REMATCH[2]}"
fi
fi

# --- R6: checklist completeness ----------------------------------------------

unticked=0
Expand Down
69 changes: 69 additions & 0 deletions plugins/source-control/scripts/babysit-readiness-gate.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,73 @@ r=$(run_gate "$F")
assert_contains "out-of-range [P-num] -> findings=0" "$r" "findings=0"
assert_contains "out-of-range [P-num] -> OK" "$r" "READINESS_OK"

# --- Case: #465 lifetime findings in resolved/outdated threads are discounted -
# A severity marker carried in a thread GitHub reports resolved or outdated is a
# lifetime artifact of an already-addressed round, not a live finding. The shared
# Python classifier (babysit_findings.py) discounts it (open-state aware) so a
# fully-classified PR with re-review history no longer false-BLOCKs. The bash
# degrade cannot see thread state and counts lifetime markers, so this enriched
# behavior is asserted only when a Python 3.11+ interpreter is present -- the same
# path the gate itself prefers. Three lifetime markers, only one open: findings=1.
probe_py() {
"$@" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' \
>/dev/null 2>&1
}
if probe_py py -3 || probe_py python3 || probe_py python; then
F=$(mkjson lifetime-open '[
{author:"codex[bot]", body:"[CRITICAL] resolved earlier", isResolved:true},
{author:"codex[bot]", body:"[CRITICAL] outdated round", isOutdated:true},
{author:"codex[bot]", body:"[P1] still open null deref"},
{author:"me[bot]", body:"| 1 | null deref | VALID | fixed abc123 |"}
]')
r=$(run_gate "$F")
assert_contains "#465 lifetime discount -> findings=1 (only open)" "$r" "findings=1"
assert_contains "#465 lifetime discount -> READINESS_OK" "$r" "READINESS_OK"
else
pass "#465 lifetime discount skipped (no Python 3.11+; bash degrade counts lifetime)"
fi

# --- Convergence: Python counter and bash degrade agree on thread-state-free input
# The gate prefers the shared Python counter but keeps the bash grep counting as
# the Python-free safe-tier degrade. The two must not drift: a severity marker is
# a finding under both, or the safe tier and the engine-backed tier disagree on
# readiness. BABYSIT_READINESS_BASH_ONLY=1 forces the degrade so both counts are
# observable in one run; every representative fixture must yield identical
# `findings=/classified=`. (On a host without Python both runs already take the
# bash path and agree trivially; the assertion still holds.)
gate_counts() { # gate_counts <fixture> -> "findings=N classified=N"
bash "$GATE" 123 --comments-json "$1" --self 'me[bot]' 2>/dev/null |
grep -oE 'findings=[0-9]+ classified=[0-9]+'
}
converge() { # converge <name> <fixture>
local py bash_only
py="$(gate_counts "$2")"
bash_only="$(BABYSIT_READINESS_BASH_ONLY=1 gate_counts "$2")"
if [[ -n "$py" && "$py" == "$bash_only" ]]; then
pass "convergence [$1]: python == bash degrade ($py)"
else
fail "convergence [$1]: python == bash degrade" "$py" "$bash_only"
fi
}
F=$(mkjson conv-words '[
{author:"claude[bot]", body:"CRITICAL a and IMPORTANT b on one line\nSUGGESTION c"},
{author:"me[bot]", body:"| 1 | a | VALID | x |"}
]')
converge "severity-words" "$F"
F=$(mkjson conv-badge '[
{author:"chatgpt-codex-connector[bot]", body:"![P1 Badge](https://img.shields.io/badge/P1-red?style=flat) and ![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)"},
{author:"me[bot]", body:"| 1 | x | VALID | y |"}
]')
converge "codex-badges" "$F"
F=$(mkjson conv-plain '[
{author:"some-reviewer[bot]", body:"[P1] null deref\n[P2] missing timeout"},
{author:"me[bot]", body:"| 1 | null deref | VALID | fixed |"}
]')
converge "plain-p-markers" "$F"
F=$(mkjson conv-selfrow '[
{author:"claude[bot]", body:"CRITICAL null deref in handler"},
{author:"me[bot]", body:"| 1 | CRITICAL: null deref | VALID | fixed abc123 |"}
]')
converge "self-row-exclusion" "$F"

[[ $FAILED -eq 0 ]] || exit 1
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ subdirectory of the plugin data directory.

Spawn a fresh 1:1 worker for a PR **only when the snapshot's `needs_worker` field for that PR is
`true`**. This is a deterministic engine output, not something to re-derive by eyeballing
`material_findings` text or the raw `classification`. Read it straight from the per-PR output of
the snapshot engine (see `needs_worker_reasons` for why):
`material_findings` text or the raw `classification`. The authorship, finding, and approval
classification behind those fields is one shared classifier locked by golden fixtures — the same
classifier the readiness gate and merge gate consume — so eyeballing it is strictly less reliable
than the field it would second-guess, not a safety check on top of it. Read it straight from the
per-PR output of the snapshot engine (see `needs_worker_reasons` for why):

```text
python "${CLAUDE_PLUGIN_ROOT}/skills/babysit-prs/scripts/pr_queue_snapshot.py" --queue --author @me --owners <watched-owners> --state-dir <state-dir> --write-state
Expand Down
Loading
Loading