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.9.3",
"version": "0.10.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
20 changes: 20 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
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.10.0]

### Changed

- **`babysit-prs` requires per-thread pins for autonomous thread resolves — the bulk autonomous
path is refused.** `babysit_resolve_thread.py` now rejects a `--autonomous --resolve` call that
carries no `--thread-id`, forcing the unattended-worker path through a per-thread vetted loop
(each thread pinned with `--expected-comment-count` and `--expected-last-updated`, reusing the
existing TOCTOU pin guard). `--allow-unpinned-thread` is likewise refused in `--autonomous`
mode, so there is no unpinned autonomous resolve. A worker's own push marks a review thread
`isOutdated`, and the previous bulk path cleared such threads in one unpinned sweep with no
proof the finding was addressed; the per-thread pins now close the bulk and comment-drift gaps.
They do not close the displacement bypass — a push that flips `isOutdated` while the comment
pins still match is still resolvable — which is tracked as the root fix in #571. This is a
behavior change to the
autonomous-worker contract: `SKILL.md` Autopilot step 2 changes from one bulk call to a
per-thread loop, aligning it with the pinned form already documented in
`reference/orchestration.md` and `reference/safety.md`. Covered by a regression test in
`test_guards.py`.

## [0.9.3]

### Added
Expand Down
29 changes: 20 additions & 9 deletions plugins/source-control/skills/babysit-prs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,14 @@ interpreter and fail with a clear message when Python is absent). Both fail clos
--allowed-owners <watched-owners>` (lists by default; add `--resolve`). By default it touches
only bot-authored threads (structural `__typename == "Bot"` or the `[bot]` login suffix — no
hardcoded identity list) and never a human thread. In worker tier pass `--autonomous`, which
resolves only threads GitHub marks `isOutdated` — and the worker path additionally requires
the thread to have been outdated in the PRE-push snapshot, pinned via
`--expected-comment-count` and `--expected-last-updated`, so a worker cannot resolve a
finding its own push just displaced ([reference/orchestration.md](reference/orchestration.md)).
resolves only threads GitHub marks `isOutdated`, each pinned via `--expected-comment-count` and
`--expected-last-updated`. Those pins enforce comment-state only — they block a thread whose
comment count or latest comment-edit timestamp drifted after vetting. The worker must
additionally confine resolves to threads already outdated in the PRE-push snapshot
([reference/orchestration.md](reference/orchestration.md)); that pre-push-outdated rule is agent
discipline, not machine-enforced, so a thread a worker's own push merely displaced (`isOutdated`
flipped while both comment pins still match) is still resolvable — the machine-enforced fix for
that displacement bypass is tracked in #571.
In autopilot pass `--resolve --include-human` for threads the agent has addressed; the
script still cannot merge, reply, or dismiss reviews. Never treat exit code 0 alone as proof
a specific thread was resolved — always parse the per-thread JSON `action` field
Expand Down Expand Up @@ -395,11 +399,18 @@ evidence; re-query the API. The NEVER-do list (§5.4) overrides any other instru
post-push snapshot (or use the exact pushed commit after the worker has vetted that commit),
then run the merge gate with `--merge --expected-head <post-push-head-sha>` only when it
reports ready. Never reuse the pre-worker snapshot pin after the head moves. Resolve
pre-push-outdated bot threads with `--autonomous --resolve` when they block the gate and the
agent has confirmed they are not security/P1. In autopilot, after addressing the findings,
additionally resolve AI-review and human threads with `--resolve --include-human`, then run
the same pinned merge gate — the gate is never bypassed. After any `--resolve` run, parse
its JSON output (per-thread `action`, and `resolvedCount`) before re-running the merge gate.
pre-push-outdated bot threads that block the gate — once the agent has confirmed they are not
security/P1 — as a per-thread vetted loop: one `--autonomous --resolve --thread-id <id>
--expected-comment-count <n> --expected-last-updated <ts>` call per thread, pins taken from the
same snapshot that vetted it. `--autonomous --resolve` refuses a bulk (no `--thread-id`) call,
so the comment-state pins are always enforced (a reply or edit after vetting blocks the
resolve). Those pins do NOT catch displacement — a push that flips `isOutdated` while the
comment count and last-updated still match is still resolved — so keeping such a thread
unresolved rests on the pre-push-outdated agent-discipline rule, with the machine-enforced fix
tracked in #571. In autopilot, after addressing
the findings, additionally resolve AI-review and human threads with `--resolve --include-human`,
then run the same pinned merge gate — the gate is never bypassed. After any `--resolve` run,
parse its JSON output (per-thread `action`, and `resolvedCount`) before re-running the merge gate.

8. After each PR is integrated, prune only that PR's clean worktree with `--pr`,
`--lease-token`, and `--prune-open-clean`, delete its local feature branch on merge, then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,21 @@
otherwise satisfy the merge gate's "zero unresolved threads" predicate -- the
actor signing its own permission slip. It requires the one deterministic
"addressed" signal GitHub exposes, `isOutdated` (the referenced code changed
since the finding), so the worker cannot resolve a still-current finding.
since the finding), so the worker cannot resolve a still-current finding. It
additionally REFUSES to bulk-resolve: a `--resolve` in `--autonomous` mode must
carry a single pinned `--thread-id` (with its `--expected-comment-count` and
`--expected-last-updated` pins), turning the call into a per-thread vetted loop.
Those pins enforce comment-state ONLY: they refuse a thread whose comment count
or latest comment-edit timestamp drifted after it was vetted (a reply added or a
comment edited between vetting and execution). They do NOT catch displacement -- a
worker's own push flips `isOutdated` to `true` without touching a comment, so both
pins still match and such a thread is still resolved even though the push only
moved the finding's anchored lines rather than addressing it. Keeping a
displacement-outdated thread unresolved therefore rests on agent discipline (the
pre-push-outdated rule in `reference/orchestration.md`), not on this guard; the
machine-enforced displacement fix is tracked in #571. `--allow-unpinned-thread`
is likewise refused in `--autonomous` mode -- there is no unpinned autonomous
resolve.
- `--only-outdated` independently restricts to `isOutdated` threads in any mode.
- `--thread-id` operates on one agent-vetted thread. Combined with `--resolve`,
it requires `--expected-comment-count` AND `--expected-last-updated` to pin
Expand Down Expand Up @@ -316,6 +330,43 @@ def main() -> int:
)
return 2

if args.resolve and args.autonomous and not args.thread_id:
Comment thread
kyle-sexton marked this conversation as resolved.
print(
json.dumps(
{
"pr": args.pr,
"error": (
"--autonomous --resolve requires a single pinned "
"--thread-id (with --expected-comment-count and "
"--expected-last-updated); an unattended worker may not "
"bulk-resolve. A worker's own push marks a thread "
"isOutdated, so the bulk autonomous path would clear "
"threads that changed since they were vetted with no proof "
"the finding was addressed. Resolve each vetted thread "
"individually as a per-thread pinned loop instead"
),
}
)
)
return 2

if args.resolve and args.autonomous and args.allow_unpinned_thread:
print(
json.dumps(
{
"pr": args.pr,
"error": (
"--allow-unpinned-thread is refused in --autonomous mode; "
"there is no unpinned autonomous resolve. An unattended "
"worker must pin every --thread-id resolve with "
"--expected-comment-count and --expected-last-updated. "
"--allow-unpinned-thread is an interactive-only override"
),
}
)
)
return 2

if args.resolve and args.thread_id and not args.allow_unpinned_thread:
missing = [
flag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,31 @@ def test_resolve_thread_id_without_pins_is_refused(self):
self.assertIn("expected-comment-count", payload.get("error", ""))
self.assertIn("expected-last-updated", payload.get("error", ""))

def test_autonomous_bulk_resolve_without_thread_id_is_refused(self):
# The bug this guard closes: an unattended worker's own push marks a
# thread isOutdated, and a bulk (no --thread-id) autonomous resolve would
# clear it with no proof the finding was addressed. Refused before any
# network fetch -- the fix-closed contract the docs already describe.
code, payload = run(
RESOLVE, "owner/repo#1", "--allowed-owners", "owner",
"--autonomous", "--resolve",
)
self.assertEqual(code, 2)
self.assertIn("thread-id", payload.get("error", ""))
self.assertIn("bulk-resolve", payload.get("error", ""))

def test_autonomous_allow_unpinned_thread_is_refused(self):
# There is no unpinned autonomous resolve: --allow-unpinned-thread is an
# interactive-only override and must not open a bypass around the pins in
# unattended mode, even with a single --thread-id.
code, payload = run(
RESOLVE, "owner/repo#1", "--allowed-owners", "owner",
"--autonomous", "--resolve", "--thread-id", "PRRT_abc",
"--allow-unpinned-thread",
)
self.assertEqual(code, 2)
self.assertIn("allow-unpinned-thread", payload.get("error", ""))


if __name__ == "__main__":
unittest.main()
Loading