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.30.0",
"version": "0.31.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), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and merge-rung raises binding from the team-tracked layer only), /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
28 changes: 28 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@
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.31.0]

### Changed

- **No babysit parser resolves a flag abbreviation any more, and the property is now the
directory's rather than two files' (`#1371`).** A permission grant states its condition as the
literal presence or absence of a flag in the command text — above all "no `--merge` means
check-only". Argparse's default prefix abbreviation lets `--mer` resolve to `--merge` while the
command text contains no such flag, so the written command and the resolved behavior diverge,
which is exactly what such a condition must be able to rule out. `#1354` closed this on
`babysit_merge.py` and `babysit_resolve_thread.py`; the remaining seven entry points —
`babysit_findings.py`, `manage_babysit_lease.py`, `manage_feedback_ledger.py`,
`pr_queue_snapshot.py`, `prune_babysit_worktrees.py`, `refresh_pr_branch.py`, and
`request_review.py` — still inherited the default. All nine now set `allow_abbrev=False`.

Hardening them one at a time is what let the gap persist, so the guard contract gains a gate over
the whole catalogue: every Python entry point is invoked with an unambiguous three-character
prefix of `--help` and must not exit 0. `--help` is registered on every parser, and it
short-circuits parsing — so an abbreviation that resolves exits 0 before required-argument
validation runs, while one that does not is a usage error. That makes the exit code a sufficient
discriminator without a per-CLI argument shape, and a companion test asserts the discrimination
against argparse itself rather than assuming it. Three characters because
`manage_babysit_lease.py` also registers `--heartbeat-interval-seconds`, so a shorter prefix is
ambiguous there and exits 2 regardless — the probe would have passed on that entry point while
proving nothing. A tenth entry point arriving with the default now fails CI instead of shipping.

Abbreviated invocations that previously worked are now usage errors, which is the point.

## [0.30.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def fetch_live_comments(repo: str, number: int) -> list[dict[str, Any]]:


def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(add_help=True)
parser = argparse.ArgumentParser(add_help=True, allow_abbrev=False)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--pr", type=int, help="live PR number to fetch and count")
source.add_argument("--comments-json", help="comments JSON file (no network)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def run(args: argparse.Namespace) -> dict:

def main() -> int:
configure_stdio()
parser = argparse.ArgumentParser(description=__doc__)
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("action", choices=("acquire", "heartbeat", "release", "reap"))
parser.add_argument(
"--scope",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def run_locked(

def main() -> int:
configure_stdio()
parser = argparse.ArgumentParser(description=__doc__)
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument(
"action",
choices=("dispose", "record-advisory-round", "record-worker-checkin"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def exit_code_for(snapshot: dict[str, Any]) -> int:
def main() -> int:
configure_stdio()
parser = argparse.ArgumentParser(
description="Read-only GitHub PR babysitting snapshot."
description="Read-only GitHub PR babysitting snapshot.", allow_abbrev=False
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ def active_worker_lease(

def main() -> int:
configure_stdio()
parser = argparse.ArgumentParser(description="Prune babysit-prs Git worktrees.")
parser = argparse.ArgumentParser(
description="Prune babysit-prs Git worktrees.", allow_abbrev=False
)
parser.add_argument(
"--root",
required=True,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def run_locked(

def main() -> int:
configure_stdio()
parser = argparse.ArgumentParser(description=__doc__)
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("--pr", required=True, help="PR URL or owner/repo#number")
parser.add_argument(
"--expected-head-sha",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ def run_locked(

def main() -> int:
configure_stdio()
parser = argparse.ArgumentParser(description=__doc__)
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("--pr", required=True, help="PR URL or owner/repo#number")
parser.add_argument(
"--expected-head-sha",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
consumers cite; `GeneratedDocIsCurrent` is what keeps it honest.
"""

import argparse
import contextlib
import io
import json
import os
import pathlib
Expand Down Expand Up @@ -359,6 +362,79 @@ def test_every_mechanism_row(self) -> None:
)


class NoParserResolvesAnAbbreviation(unittest.TestCase):
"""`allow_abbrev=False` on every catalogued Python entry point, not two of them.

A permission grant states its condition as the literal presence or absence of
a flag in the command text. Argparse's default prefix abbreviation lets
`--app` resolve to `--apply` while the text contains no such flag, so the
written command and the resolved behavior diverge -- which is precisely what
such a condition has to be able to rule out. The merge gate and the thread
resolver were hardened individually; the property belongs to the directory,
because the next entry point inherits the default unless something fails.

ABBREVIATION_PROBE is the universal one: argparse registers `--help` on every
parser here, so with abbreviation on it resolves and exits 0, and with
abbreviation off it is an unrecognized argument at exit 2. No per-CLI
argument shape is needed, which is what makes this a gate over the catalogue
rather than a hand-maintained list of cases.

Three characters, not one or two: `manage_babysit_lease.py` also registers
`--heartbeat-interval-seconds`, so a shorter prefix is AMBIGUOUS there and
exits 2 even with abbreviation on -- the probe would pass on that entry point
while proving nothing.
"""

ABBREVIATION_PROBE = "--hel" # spellchecker:disable-line

def test_every_python_entry_point_refuses_an_abbreviated_flag(self) -> None:
catalogued = [
entry.path for entry in contract.ENTRY_POINTS if entry.path.endswith(".py")
]
self.assertTrue(catalogued, "no Python entry points catalogued")
for path in catalogued:
with self.subTest(entry_point=path):
proc = subprocess.run(
[
sys.executable,
str(contract.plugin_path(path)),
self.ABBREVIATION_PROBE,
],
capture_output=True,
text=True,
cwd=tempfile.gettempdir(),
)
self.assertNotEqual(
proc.returncode,
0,
f"`{path}` resolved `{self.ABBREVIATION_PROBE}` to `--help` and"
" exited 0; its parser needs allow_abbrev=False",
)

def test_the_probe_separates_the_two_parsers(self) -> None:
# Guards the guard. The probe reads an exit code, and a CLI can exit 2
# for reasons of its own -- several here have a required mutually
# exclusive group that errors before any unrecognized argument is
# reported, so the message is not a reliable discriminator and the code
# is. What makes the code sufficient is that `--help` short-circuits
# parsing: an abbreviation that resolves exits 0 before required-argument
# validation ever runs. Asserted against argparse itself rather than
# assumed, because the whole gate rests on it.
strict = argparse.ArgumentParser(prog="probe", allow_abbrev=False)
strict.add_argument("--required-thing", required=True)
with self.assertRaises(SystemExit) as refused:
strict.parse_args([self.ABBREVIATION_PROBE])
self.assertEqual(refused.exception.code, 2)

lenient = argparse.ArgumentParser(prog="probe")
lenient.add_argument("--required-thing", required=True)
# The resolved `--help` prints to stdout; keep it out of the test log.
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(SystemExit) as resolved:
lenient.parse_args([self.ABBREVIATION_PROBE])
self.assertEqual(resolved.exception.code, 0)


class EntryPointCatalogueIsComplete(unittest.TestCase):
def test_every_executable_script_is_classified(self) -> None:
# A new entry point must arrive with its mutation classification, or a
Expand Down
Loading