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
13 changes: 12 additions & 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.16.2",
"version": "0.16.3",
"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 merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.",
"author": {
"name": "Melodic Software",
Expand Down Expand Up @@ -102,6 +102,12 @@
"title": "Babysit extra bot logins",
"description": "Additional logins to treat as bots when structural detection cannot identify them. Absent: structural detection only."
},
"babysit_extra_dependency_manager_logins": {
"type": "string",
"multiple": true,
"title": "Babysit extra dependency-manager logins",
"description": "Additional dependency-manager bot logins beyond the built-in dependabot/renovate set whose PRs the merge gate holds absent --allow-dependency, the same as the built-ins. Absent: built-in dependency-manager set only."
},
"babysit_approval_downgrade_logins": {
"type": "string",
"multiple": true,
Expand Down Expand Up @@ -156,6 +162,11 @@
"description": "Abort a CI-log ZIP fetch larger than this",
"default": 52428800,
"min": 1
},
"branch_issue_pattern": {
"type": "string",
"title": "Branch-to-issue grammar (ERE)",
"description": "POSIX ERE for extracting the numeric GitHub issue number from the current branch name; the LAST capture group holds it and must resolve to digits (Closes #N honors only a numeric issue). Set this for a non-default branch scheme that places the number differently, e.g. '^[^/]+/([0-9]+)-' for 'alice/1234-slug' or '-([0-9]+)$' for 'feat/add-widget-1234'. Absent: the built-in '<type>/<N>-<slug>' (and routine-issue-<N>) convention."
}
}
}
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.16.3]

### Changed

- **Dependency-manager hold-merge login set is now configurable (`#917` W1).** The merge gate held
only the built-in `dependabot`/`renovate` product bots (`DEPENDENCY_MANAGER_LOGINS`); a
non-dependabot/renovate dependency bot an operator runs slipped the cross-tier hold. The gate now
also holds any login in the new `babysit_extra_dependency_manager_logins` userConfig (threaded as
the `--extra-dependency-manager-logins` merge-wrapper flag, matching the existing arg-threading of
`--approver-bot-logins`); logins are normalized on both sides (casefold, strip `app/` and `[bot]`).
Ships empty, so an unconfigured install matches the built-in set alone.
- **Branch-to-issue grammar is now configurable (`#917` W2).** `parse-branch-issue.sh` hardcoded the
`<type>/<N>-<slug>` (and `routine-issue-<N>`) convention, so a repo that places the GitHub issue
number differently in its branch names silently failed to derive a `Closes #N` line. The script now
accepts an ERE `pattern` positional (last capture group = the numeric GitHub issue number, e.g.
`^[^/]+/([0-9]+)-` for `alice/1234-slug`), wired from the new `branch_issue_pattern` userConfig at
the `/pull-request create` call site. The placeholder is single-quoted there so an unset value
reaches the script as an inert literal (double-quoting a dotted `${…}` name is a Bash
`bad substitution`) and falls back to the built-in convention.

## [0.16.2]

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions plugins/source-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ repo's owner.
| `babysit_review_gate_context` | string | review gate treated as absent |
| `babysit_ci_gateway_context` | string | gateway check unused |
| `babysit_extra_bot_logins` | string (multiple) | structural bot detection only |
| `babysit_extra_dependency_manager_logins` | string (multiple) | built-in dependabot/renovate dependency-manager set only |
| `babysit_approval_downgrade_logins` | string (multiple) | an approval carrying blocking-looking prose is downgraded to ignored structurally (every bot); a named login instead surfaces its own as material. Real APPROVED-state reviews and plain clean approvals are ignored regardless. |
| `babysit_skip_downgrade_logins` | string (multiple) | downgrade heuristic dormant |
| `babysit_max_quiet_recheck_seconds` | number | 14400 |
Expand All @@ -172,6 +173,7 @@ repo's owner.
| `babysit_worktree_root` | directory | `worktrees/` under the plugin data dir |
| `worktree_stale_days` | number | 14 (staleness threshold for `/worktree status`) |
| `fetch_logs_max_bytes` | number | 52428800 (CI-log ZIP size cap for `fetch-logs`) |
| `branch_issue_pattern` | string | built-in `<type>/<N>-<slug>` (and `routine-issue-<N>`) branch-to-issue grammar; set an ERE (last capture group = the numeric GitHub issue number) for a scheme that places the number differently, e.g. `^[^/]+/([0-9]+)-` (`alice/1234-slug`) or `-([0-9]+)$` (`feat/add-widget-1234`) |

The commit-subject / PR-title convention is separate: run
**`/source-control:setup`** to interview your repo and write the
Expand Down
16 changes: 8 additions & 8 deletions plugins/source-control/skills/babysit-prs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,13 @@ home in [reference/safety.md](reference/safety.md). Both fail closed without `--
gate refuses the merge; re-snapshot and reassess the new head instead of using
`--allow-unpinned-head` — the wrapper rejects that flag outright, so no unattended unpinned
merge exists. The pin is carried to GitHub's server-side match-head-commit guard. It refuses
a dependency-manager-authored PR absent `--allow-dependency`, refuses merge on an unprotected
a dependency-manager-authored PR absent `--allow-dependency` (held set: built-in dependabot/renovate
plus any `babysit_extra_dependency_manager_logins`, which you MUST append via
`--extra-dependency-manager-logins "<value>"` when set — see safety.md's merge command forms — or
those extra bots are silently not held), refuses merge on an unprotected
repo (zero required reviews and zero required contexts) for a non-self author absent
`--allow-unprotected`, never uses `--admin`, and cannot resolve threads, reply, or
force-push. React to `blockers`; do not bypass the gate. A `ready:false` immediately
following a `ready:true` on the same expected head is often GitHub's own mergeability
recompute lag — re-run the read-only check once before treating it as a real block.
force-push. React to `blockers`; do not bypass the gate. A `ready:false` immediately following a `ready:true` on the same expected head is often GitHub's own mergeability recompute lag — re-run the read-only check once before treating it as a real block.

- **Once ready, stop.** When the gate proves a PR ready (safe mode) or its merge is deferred to
a human (Pinned-Command Degradation, [reference/safety.md](reference/safety.md)), report that
Expand All @@ -223,8 +224,7 @@ home in [reference/safety.md](reference/safety.md). Both fail closed without `--
([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
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
(`resolved` vs `skipped-*` / `refused-stale-pin` / `resolve-failed`) and the
Expand Down Expand Up @@ -283,8 +283,7 @@ branch, make only clear branch-owned fixes, re-check the head SHA, push, clean u
The values below substitute from this plugin's stored configuration when this skill loads.
A surviving literal `${user_config.…}` placeholder means that key is unset — apply its
documented unset behavior. Reference files use `<angle-bracket>` slots; fill every slot from
this block. Values reach scripts ONLY as explicit CLI flags (option environment variables never
reach skill-invoked scripts). Configuration selects targets and thresholds; it never widens tier authority.
this block. Values reach scripts ONLY as explicit CLI flags (option environment variables never reach skill-invoked scripts). Configuration selects targets and thresholds; it never widens tier authority.

| Key | Value | Flag delivery | Unset behavior |
| --- | --- | --- | --- |
Expand All @@ -302,6 +301,7 @@ reach skill-invoked scripts). Configuration selects targets and thresholds; it n
| `babysit_review_gate_context` | `${user_config.babysit_review_gate_context}` | `--review-gate-context` (snapshot) | gate treated as absent |
| `babysit_ci_gateway_context` | `${user_config.babysit_ci_gateway_context}` | `--ci-gateway-context` (snapshot) | gateway check unused |
| `babysit_extra_bot_logins` | `${user_config.babysit_extra_bot_logins}` | `--extra-bot-logins` (snapshot) | structural bot detection only |
| `babysit_extra_dependency_manager_logins` | `${user_config.babysit_extra_dependency_manager_logins}` | `--extra-dependency-manager-logins` (merge gate) | built-in dependabot/renovate dependency-manager set only |
Comment thread
kyle-sexton marked this conversation as resolved.
| `babysit_approval_downgrade_logins` | `${user_config.babysit_approval_downgrade_logins}` | `--approval-downgrade-logins` (snapshot) | an approval carrying blocking-looking prose is downgraded to ignored structurally (every bot); a named login instead surfaces its own as material. Real APPROVED-state reviews and plain clean approvals are ignored regardless. |
| `babysit_skip_downgrade_logins` | `${user_config.babysit_skip_downgrade_logins}` | `--skip-downgrade-logins` (snapshot) | downgrade heuristic dormant |
| `babysit_max_quiet_recheck_seconds` | `${user_config.babysit_max_quiet_recheck_seconds}` | `--max-quiet-recheck-seconds` (snapshot) | `14400` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ actually is, not by hardcoding a bot's login:
dependency version (a lockfile, a manifest version field, a pinned Action SHA/tag for a
third-party action). Accepting the new version is a human policy call. Dependency-manager PRs
are never merged autonomously in any tier — `SKILL.md` states the invariant, and the merge
wrapper enforces it mechanically (`safety.md`, Guarded Mutation Wrappers).
wrapper enforces it mechanically (`safety.md`, Guarded Mutation Wrappers). The wrapper matches a
built-in dependency-manager login set (dependabot/renovate); a non-dependabot/renovate dependency
bot an operator runs is added to that hold via `babysit_extra_dependency_manager_logins`.
- **Policy/content-sync bot PR**: the diff is this repository receiving content it does not own
back from an upstream repository, through a mechanism the repository itself declares (for
example a sync manifest naming which local paths are managed from where). This is the reviewed
Expand Down
16 changes: 11 additions & 5 deletions plugins/source-control/skills/babysit-prs/reference/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,11 @@ auto-mode safety classifier and blocks the call before the wrapper runs.
- The merge CLI refuses a dependency-manager-authored PR absent `--allow-dependency`, and refuses
to merge on an unprotected repository — zero required reviews AND zero required status contexts
— when the PR author is not one of `<self-logins>`, absent `--allow-unprotected`. Both
overrides are human decisions, never passed autonomously.
overrides are human decisions, never passed autonomously. The held dependency-manager set is the
built-in dependabot/renovate bots plus, when `babysit_extra_dependency_manager_logins` is
configured (non-empty, not a literal unexpanded token), the logins appended via
`--extra-dependency-manager-logins <extra-dependency-manager-logins>` — supply it on every merge
command below, exactly as `--method` is, or those extra bots are not held.
- The merge wrapper's `--autopilot-merge-tier` flag layers the #476 tier criteria (issue-linked,
lane-authored, no blocking label, a distinct-bot approval on the live head, no human blocking
comment) onto the base gate. It is **fail-closed**: the umbrella flag refuses (exit `3`) unless
Expand Down Expand Up @@ -234,13 +238,15 @@ it, and any later gate-off flip, is a separate announced operator step.
never the four-flagless base command, which would ignore every tier criterion:

```text
bash "${CLAUDE_PLUGIN_ROOT}/bin/source-control-babysit-merge" owner/repo#N --allowed-owners <watched-owners> --self-logins @me,<self-logins> --merge --expected-head <post-push-head-sha> --autopilot-merge-tier --lane-logins <lane-logins> --approver-bot-logins <approver-bot-logins> --block-labels <merge-block-labels>
bash "${CLAUDE_PLUGIN_ROOT}/bin/source-control-babysit-merge" owner/repo#N --allowed-owners <watched-owners> --self-logins @me,<self-logins> --merge --expected-head <post-push-head-sha> --autopilot-merge-tier --lane-logins <lane-logins> --approver-bot-logins <approver-bot-logins> --block-labels <merge-block-labels> --extra-dependency-manager-logins <extra-dependency-manager-logins>
```

The umbrella `--autopilot-merge-tier` is fail-closed: it refuses (exit `3`) unless
`--lane-logins`, `--approver-bot-logins`, and `--block-labels` are all supplied, and any of
those three without the umbrella is a usage error (exit `2`). Add `--method <merge-method>`
when configured, exactly as for the base merge readiness gate above.
those three without the umbrella is a usage error (exit `2`). Add `--method <merge-method>` and
`--extra-dependency-manager-logins <extra-dependency-manager-logins>` when configured, exactly as
for the base merge readiness gate above (omit each when its value is empty or a literal
unexpanded token).

- **Second-account approve mechanic.** The approving review the gate's distinct-bot criterion
requires is submitted out-of-band by the agent — the gate only verifies one exists on the live
Expand Down Expand Up @@ -325,7 +331,7 @@ narrow allow rule.
For a merge:

```text
bash "${CLAUDE_PLUGIN_ROOT}/bin/source-control-babysit-merge" owner/repo#42 --allowed-owners <watched-owners> --merge --expected-head <post-push-head-sha> --method <merge-method>
bash "${CLAUDE_PLUGIN_ROOT}/bin/source-control-babysit-merge" owner/repo#42 --allowed-owners <watched-owners> --merge --expected-head <post-push-head-sha> --method <merge-method> --extra-dependency-manager-logins <extra-dependency-manager-logins>
```

When the autopilot merge tier is enabled, this degraded handoff carries the tier flags too:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,36 @@ def normalized_bot_login(item: dict[str, Any]) -> str:
return author_login(item).casefold().removesuffix("[bot]")


def is_dependency_author(login: str) -> bool:
"""Pure dependency-manager author test feeding the cross-tier hold-merge rule."""
def normalize_dependency_login_set(logins: Any) -> frozenset[str]:
"""Normalize dependency-manager logins the way `is_dependency_author` matches:
casefold, strip a leading `app/` and a trailing `[bot]`.

Distinct from `normalize_login_set`, which does not strip the `app/` prefix a
GitHub App author carries -- the dependency test compares against that
stripped form, so a configured `app/foo` and a raw `foo` must collapse.
"""
return frozenset(
str(login).casefold().removeprefix("app/").removesuffix("[bot]")
for login in (logins or [])
if str(login).strip()
)


def is_dependency_author(login: str, extra: Any = frozenset()) -> bool:
"""Dependency-manager author test feeding the cross-tier hold-merge rule.

Matches the built-in `DEPENDENCY_MANAGER_LOGINS` product bots plus any
caller-supplied `extra` logins -- a non-dependabot/renovate dependency bot an
operator runs, which should keep the same hold-merge protection. `extra` ships
empty, so an unconfigured caller matches the built-in set alone. Both sides are
normalized identically (casefold, strip `app/` prefix and `[bot]` suffix).
"""
normalized = str(login or "").casefold().removeprefix("app/").removesuffix("[bot]")
return normalized in DEPENDENCY_MANAGER_LOGINS
if normalized in DEPENDENCY_MANAGER_LOGINS:
return True
if not extra:
return False
return normalized in normalize_dependency_login_set(extra)


def body_text(item: dict[str, Any]) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ def evaluate(
allow_dependency: bool,
allow_unprotected: bool,
tier: AutopilotMergeTierConfig | None = None,
extra_dependency_manager_logins: frozenset[str] = frozenset(),
) -> dict[str, Any]:
owner = split_owner(repo)
pr_data = gh_json(
Expand Down Expand Up @@ -712,7 +713,12 @@ def evaluate(
)
# A dependency-manager PR is held in every tier unless explicitly allowed:
# its update should be reviewed, not auto-merged on a green gate alone.
if is_dependency_author(str(author_login or "")) and not allow_dependency:
if (
is_dependency_author(
str(author_login or ""), extra_dependency_manager_logins
)
and not allow_dependency
):
blockers.append(
f"author {author_login!r} is a dependency manager "
"-- held (pass --allow-dependency to override)"
Expand Down Expand Up @@ -836,6 +842,15 @@ def main() -> int:
action="store_true",
help="permit merging a dependency-manager-authored PR (held by default)",
)
parser.add_argument(
"--extra-dependency-manager-logins",
default=None,
help=(
"comma-separated extra dependency-manager bot logins beyond the "
"built-in dependabot/renovate set; their PRs are held absent "
"--allow-dependency, same as the built-ins"
),
)
parser.add_argument(
"--allow-unprotected",
action="store_true",
Expand Down Expand Up @@ -1001,6 +1016,10 @@ def main() -> int:
if token.strip().casefold() != "@me"
)

extra_dependency_manager_logins = frozenset(
parse_csv_set(args.extra_dependency_manager_logins)
)

try:
result = evaluate(
repo,
Expand All @@ -1011,6 +1030,7 @@ def main() -> int:
args.allow_dependency,
args.allow_unprotected,
tier,
extra_dependency_manager_logins=extra_dependency_manager_logins,
)
except (RuntimeError, ValueError, json.JSONDecodeError) as exc:
# Surface any gh/parse failure as JSON rather than a traceback.
Expand Down
Loading
Loading