"""``muse bisect`` — binary search through commit history to find regressions. ``muse bisect`` is Muse's power-tool for regression hunting. Given a known-bad commit and a known-good commit it performs a binary search through the history between them, asking at each midpoint: *"does the bug exist here?"* until the first bad commit is isolated. It is fully agent-safe: ``muse bisect run `` automates the search by running an arbitrary command at each step and interpreting the exit code: 0 → good (bug not present) 125 → skip (commit untestable) else → bad (bug present) Symbol-scoped bisect -------------------- Pass ``--symbol addr`` to ``muse bisect start`` to restrict the candidate commit list to only commits whose structured_delta touched that symbol. This can reduce a 9-step bisect over 300 commits to a 3-step bisect over the 8 commits that actually changed the symbol you care about. muse bisect start --bad HEAD --good v1.0.0 \\ --symbol billing.py::Invoice.compute_total At each step, Muse shows which ops the midpoint commit applied to the symbol so you know exactly what changed before you run your tests. JSON output ----------- Pass ``--json`` to any subcommand for machine-readable NDJSON output. The ``run`` subcommand emits one JSON object per bisect step (NDJSON), plus a final summary line. All other subcommands emit a single JSON object on stdout. Subcommands:: muse bisect start [--bad ] [--good ] [--symbol ] [--json] muse bisect bad [] [--json] muse bisect good [] [--json] muse bisect skip [] [--json] muse bisect run [--json] muse bisect log [--json] muse bisect reset [--json] Exit codes:: 0 — success 1 — user error (no session, bad ref, bad args) 2 — internal error (lost state) """ import argparse import json import logging import pathlib import sys from typing import TypedDict from muse.core.bisect import ( BisectResult, BisectStatusDict, _commits_touching_symbol, _symbol_ops_in_commit, get_bisect_log, get_bisect_next, get_bisect_status, is_bisect_active, mark_bad, mark_good, reset_bisect, run_bisect_command, skip_commit, start_bisect, ) from muse.core.envelope import EnvelopeJson, make_envelope from muse.core.timing import start_timer from muse.core.errors import ExitCode from muse.core.repo import require_repo from muse.core.refs import ( get_head_commit_id, read_current_branch, ) from muse.core.commits import resolve_commit_ref from muse.core.validation import sanitize_display logger = logging.getLogger(__name__) _MAX_SYMBOL_ADDR_LEN = 500 # --------------------------------------------------------------------------- # Typed JSON schemas # --------------------------------------------------------------------------- class _BisectStepPayload(TypedDict): """Domain fields built by ``_result_to_json``.""" done: bool first_bad: str | None next_to_test: str | None remaining_count: int steps_remaining: int verdict: str symbol_changes: list[str] class _BisectStepJson(_BisectStepPayload, EnvelopeJson): """Full wire shape for start / bad / good / skip subcommands.""" class _BisectLogEntryJson(TypedDict): """One structured entry in the bisect log.""" commit_id: str verdict: str timestamp: str class _BisectStatusJson(EnvelopeJson, total=False): """JSON output for ``muse bisect status --json``. ``active`` is always present. All other keys are only present when ``active`` is ``True``. """ active: bool bad_id: str good_ids: list[str] symbol_filter: str remaining_count: int steps_remaining: int skipped_count: int branch: str class _BisectRunStepJson(EnvelopeJson): """One NDJSON line emitted by ``muse bisect run --json`` per step.""" step: int testing: str verdict: str remaining_count: int done: bool symbol_changes: list[str] class _BisectRunDoneJson(TypedDict): """Final NDJSON line emitted by ``muse bisect run --json`` when complete.""" done: bool first_bad: str | None steps_taken: int class _BisectLogJson(EnvelopeJson): """JSON output for ``muse bisect log --json``.""" active: bool entries: list[_BisectLogEntryJson] class _BisectResetJson(EnvelopeJson): """JSON output for ``muse bisect reset --json``.""" reset: bool # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _resolve_ref(root: pathlib.Path, ref: str | None) -> str: """Resolve *ref* to a full commit ID; fall back to HEAD when *ref* is None.""" branch = read_current_branch(root) if ref is None: commit_id = get_head_commit_id(root, branch) if not commit_id: print("❌ No commits on current branch.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) return commit_id commit = resolve_commit_ref(root, branch, ref) if commit is None: print(f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) return commit.commit_id def _print_result(result: BisectResult) -> None: """Render a BisectResult as human-readable text to stdout.""" if result.done: print(f"\n✅ First bad commit found: {sanitize_display(result.first_bad or '')}") print(" Run 'muse bisect reset' to end the session.") else: print( f"Next to test: {sanitize_display(result.next_to_test or '')} " f"({result.remaining_count} remaining, ~{result.steps_remaining} step(s) left)" ) if result.symbol_changes: print(" Symbol changes in this commit:") for line in result.symbol_changes: print(f" {sanitize_display(line)}") def _result_to_json(result: BisectResult) -> _BisectStepPayload: """Convert a BisectResult to a typed JSON-serialisable dict.""" return _BisectStepPayload( done=result.done, first_bad=result.first_bad, next_to_test=result.next_to_test, remaining_count=result.remaining_count, steps_remaining=result.steps_remaining, verdict=result.verdict, symbol_changes=[sanitize_display(s) for s in result.symbol_changes], ) def _parse_log_entry(raw: str) -> _BisectLogEntryJson: """Parse ``" "`` into a typed dict. The on-disk log format written by :func:`muse.core.bisect._apply_verdict` and :func:`muse.core.bisect.start_bisect` is a space-separated triple. Any missing or malformed parts default to empty strings. All fields are sanitized before output. """ parts = raw.split(" ", 2) return _BisectLogEntryJson( commit_id=sanitize_display(parts[0]) if len(parts) > 0 else "", verdict=sanitize_display(parts[1]) if len(parts) > 1 else "", timestamp=sanitize_display(parts[2]) if len(parts) > 2 else "", ) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def run_bisect_start(args: argparse.Namespace) -> None: """Start a bisect session between a known-bad and known-good commit. Immediately suggests the midpoint commit to test. Use ``--symbol`` to restrict the search to commits that touched a specific symbol — this can reduce a 9-step bisect to 3 steps when the regressing symbol is known. Agent quickstart ---------------- :: muse bisect start --bad HEAD --good v1.0.0 --json muse bisect start --bad HEAD --good v1.0.0 --symbol billing.py::Invoice.compute_total --json JSON fields ----------- done ``true`` when the first bad commit has been isolated. first_bad Full commit ID of the first bad commit; ``null`` while in progress. next_to_test Full commit ID of the next midpoint to test; ``null`` when done. remaining_count Number of commits still in the candidate set. steps_remaining Estimated binary-search steps left. verdict ``"started"`` for this subcommand. symbol_changes List of symbol-op descriptions for the midpoint commit. Exit codes ---------- 0 Session started (or immediately resolved when bad/good are adjacent). 1 Session already active, invalid ``--symbol``, no ``--good``, or ref not found. 2 Not inside a Muse repository. """ elapsed = start_timer() bad: str | None = args.bad good: list[str] | None = args.good symbol: str | None = args.symbol json_out: bool = args.json_out root = require_repo() if is_bisect_active(root): print( "⚠️ A bisect session is already active. Run 'muse bisect reset' first.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if symbol is not None: if "::" not in symbol: print( f"❌ --symbol must be a qualified symbol address " f"(e.g. billing.py::func), got: {sanitize_display(symbol)!r}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if len(symbol) > _MAX_SYMBOL_ADDR_LEN: print( f"❌ --symbol address too long (max {_MAX_SYMBOL_ADDR_LEN} chars).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) bad_id = _resolve_ref(root, bad) good_ids = [_resolve_ref(root, g) for g in (good or [])] if not good_ids: print( "❌ Provide at least one --good commit: " "muse bisect start --bad HEAD --good ", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) branch = read_current_branch(root) result = start_bisect(root, bad_id, good_ids, branch=branch, symbol_filter=symbol or "") if json_out: print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result)))) return symbol_msg = f" symbol={sanitize_display(symbol)}" if symbol else "" print( f"Bisect session started. bad={bad_id} " f"good=[{', '.join(good_ids)}]{symbol_msg}" ) if symbol and result.remaining_count == 0 and not result.done: print( f"⚠️ No commits between bad and good touched symbol " f"{sanitize_display(symbol)!r}." ) print(" Try bisecting without --symbol, or widen the bad/good range.") _print_result(result) def run_bisect_bad(args: argparse.Namespace) -> None: """Mark a commit as bad (the bug is present in this commit). Narrows the bisect search range by recording that the given commit (default: HEAD) exhibits the regression. Muse updates the remaining set and suggests the next midpoint to test. Agent quickstart ---------------- :: muse bisect bad --json muse bisect bad a1b2c3 --json JSON fields ----------- done ``true`` when the first bad commit has been isolated. first_bad Full commit ID of the first bad commit; ``null`` while in progress. next_to_test Full commit ID of the next midpoint to test; ``null`` when done. remaining_count Commits still in the candidate set. steps_remaining Estimated binary-search steps left. verdict ``"bad"`` for this subcommand. symbol_changes List of symbol-op descriptions for the midpoint commit. Exit codes ---------- 0 Verdict recorded; search advanced (or ``done=true`` if isolated). 1 No active bisect session, or ref not found. 2 Not inside a Muse repository. """ elapsed = start_timer() ref: str | None = args.ref json_out: bool = args.json_out root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) commit_id = _resolve_ref(root, ref) result = mark_bad(root, commit_id) if json_out: print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result)))) return print(f"Marked {commit_id} as bad.") _print_result(result) def run_bisect_good(args: argparse.Namespace) -> None: """Mark a commit as good (the bug is absent in this commit). Narrows the bisect search range — Muse updates the candidate set and suggests the next midpoint to test. Agent quickstart ---------------- :: muse bisect good --json muse bisect good a1b2c3 --json JSON fields ----------- done ``true`` when the first bad commit has been isolated. first_bad Full commit ID of the first bad commit; ``null`` while in progress. next_to_test Full commit ID of the next midpoint to test; ``null`` when done. remaining_count Commits still in the candidate set. steps_remaining Estimated binary-search steps left. verdict ``"good"`` for this subcommand. symbol_changes List of symbol-op descriptions for the midpoint commit. Exit codes ---------- 0 Verdict recorded; search advanced (or ``done=true`` if isolated). 1 No active bisect session, or ref not found. 2 Not inside a Muse repository. """ elapsed = start_timer() ref: str | None = args.ref json_out: bool = args.json_out root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) commit_id = _resolve_ref(root, ref) result = mark_good(root, commit_id) if json_out: print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result)))) return print(f"Marked {commit_id} as good.") _print_result(result) def run_bisect_skip(args: argparse.Namespace) -> None: """Skip a commit that cannot be tested (e.g. fails to build). Muse excludes it from the remaining set and suggests the next midpoint. In ``muse bisect run`` mode, exit code 125 from the test script triggers this automatically. Agent quickstart ---------------- :: muse bisect skip --json muse bisect skip a1b2c3 --json JSON fields ----------- done ``true`` when the first bad commit has been isolated. first_bad Full commit ID of the first bad commit; ``null`` while in progress. next_to_test Full commit ID of the next midpoint to test; ``null`` when done. remaining_count Commits still in the candidate set. steps_remaining Estimated binary-search steps left. verdict ``"skip"`` for this subcommand. symbol_changes List of symbol-op descriptions for the midpoint commit. Exit codes ---------- 0 Commit skipped; search advanced (or ``done=true`` if isolated). 1 No active bisect session, or ref not found. 2 Not inside a Muse repository. """ elapsed = start_timer() ref: str | None = args.ref json_out: bool = args.json_out root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) commit_id = _resolve_ref(root, ref) result = skip_commit(root, commit_id) if json_out: print(json.dumps(_BisectStepJson(**make_envelope(elapsed), **_result_to_json(result)))) return print(f"Skipped {commit_id}.") _print_result(result) def run_bisect_run(args: argparse.Namespace) -> None: """Automatically bisect by running a command at each step. The command exit code determines the verdict:: 0 → good 125 → skip else → bad The command is run in the repository root. Exit code ``0`` → good, ``125`` → skip, anything else → bad. Agent quickstart ---------------- :: muse bisect run "pytest tests/test_regression.py -x -q" --json muse bisect run "./check.sh" --json JSON fields (NDJSON — one object per step, then a final summary) ---------------------------------------------------------------- Per-step fields: step Step number (1-based). testing Full commit ID being tested. verdict ``"good"``, ``"bad"``, or ``"skip"``. remaining_count Candidates remaining after this step. done ``true`` on the final step. symbol_changes Symbol-op descriptions for the commit tested. Final summary fields: done Always ``true``. first_bad Full commit ID of the first bad commit; ``null`` if none isolated. steps_taken Total number of steps executed. Exit codes ---------- 0 Bisect run completed; first bad commit identified or session exhausted. 1 No active bisect session. 2 Not inside a Muse repository. """ elapsed = start_timer() command: str = args.command json_out: bool = args.json_out timeout: int | None = args.timeout root = require_repo() if not is_bisect_active(root): print("❌ No bisect session in progress. Run 'muse bisect start' first.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) step = 0 while True: current, symbol_filter = get_bisect_next(root) if current is None: if json_out: # NDJSON — keep compact (one line per record) print(json.dumps(_BisectRunDoneJson(done=True, first_bad=None, steps_taken=step))) else: print("✅ Bisect complete. Run 'muse bisect reset' to end.") return # Collect symbol changes before running the command (they describe the # commit we are about to test, not the result of the test). changes: list[str] = [] if symbol_filter: changes = _symbol_ops_in_commit(root, current, symbol_filter) if not json_out: print(f" → Testing {current} …") if changes: print(" Symbol changes:") for line in changes: print(f" {sanitize_display(line)}") result = run_bisect_command(root, command, current, timeout=timeout) step += 1 if json_out: # NDJSON — keep compact (one line per step record) print(json.dumps(_BisectRunStepJson( **make_envelope(elapsed), step=step, testing=current, verdict=result.verdict, remaining_count=result.remaining_count, done=result.done, symbol_changes=[sanitize_display(s) for s in changes], ))) else: print(f" verdict: {result.verdict}") if result.done: if json_out: # NDJSON — keep compact print(json.dumps( _BisectRunDoneJson( done=True, first_bad=result.first_bad, steps_taken=step, ) )) else: print(f"\n✅ First bad commit: {sanitize_display(result.first_bad or '')}") return def run_bisect_log(args: argparse.Namespace) -> None: """Show the full bisect session log. Displays every verdict applied so far (oldest first). Works whether or not a session is currently active — returns an empty list when no session has been started. Agent quickstart ---------------- :: muse bisect log --json JSON fields ----------- active ``true`` when a bisect session is in progress. entries List of log entry objects (oldest first). Each entry: commit_id Commit ID string. verdict ``"bad"``, ``"good"``, or ``"skip"``. timestamp ISO-8601 timestamp of the verdict. Exit codes ---------- 0 Always (empty entries when no session exists). 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = args.json_out root = require_repo() entries = get_bisect_log(root) active = is_bisect_active(root) if json_out: print(json.dumps(_BisectLogJson(**make_envelope(elapsed), active=active, entries=[_parse_log_entry(e) for e in entries]))) return if not entries: print("No bisect log. Start a session with 'muse bisect start'.") return print("Bisect log:") for entry in entries: print(f" {sanitize_display(entry)}") def run_bisect_reset(args: argparse.Namespace) -> None: """End the bisect session and remove all bisect state. Idempotent — safe to call whether or not a session is active. Agent quickstart ---------------- :: muse bisect reset --json JSON fields ----------- reset Always ``true``. Exit codes ---------- 0 Always (state removed if it existed, no-op otherwise). 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = args.json_out root = require_repo() reset_bisect(root) if json_out: print(json.dumps(_BisectResetJson(**make_envelope(elapsed), reset=True))) return print("Bisect session reset.") def run_bisect_status(args: argparse.Namespace) -> None: """Report the current bisect session state without modifying anything. Read-only snapshot of the active session. Call this at the start of any bisect workflow to discover whether a session is already running. Agent quickstart ---------------- :: muse bisect status --json JSON fields ----------- active ``true`` when a bisect session is in progress; ``false`` otherwise. When ``active`` is ``true``: bad_id Full commit ID of the known-bad commit. good_ids List of known-good commit IDs. symbol_filter Symbol address filter; empty string if none. remaining_count Commits still in the candidate set. steps_remaining Estimated binary-search steps left. skipped_count Number of commits skipped so far. branch Branch the session was started on. Exit codes ---------- 0 Always (``active=false`` when no session exists). 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = args.json_out root = require_repo() status = get_bisect_status(root) if json_out: print(json.dumps(_BisectStatusJson(**make_envelope(elapsed), **dict(status)))) return if not status.get("active"): print("No bisect session active. Start one with 'muse bisect start'.") return print("Bisect session active.") bad = status.get("bad_id", "") print(f" bad: {sanitize_display(bad)}") good_ids = status.get("good_ids", []) good_display = ", ".join(sanitize_display(g) for g in good_ids) print(f" good: {good_display}") sym = status.get("symbol_filter", "") if sym: print(f" symbol: {sanitize_display(sym)}") remaining = status.get("remaining_count", 0) steps = status.get("steps_remaining", 0) print(f" remaining: {remaining} commit(s) (~{steps} step(s))") print(f" skipped: {status.get('skipped_count', 0)}") branch = status.get("branch", "") if branch: print(f" branch: {sanitize_display(branch)}") # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``bisect`` subcommand.""" parser = subparsers.add_parser( "bisect", help="Binary search through commit history to find regressions.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") subs.required = True # ── bad ─────────────────────────────────────────────────────────────────── bad_p = subs.add_parser( "bad", help="Mark a commit as bad (bug present).", description=( "Record that the given commit (default: HEAD) exhibits the\n" "regression. Muse narrows the search range and suggests the\n" "next midpoint to test.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect bad --json\n" " muse bisect bad --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "bad", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — verdict recorded (done=true when first bad commit isolated)\n" " 1 — no active bisect session, or ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) bad_p.add_argument( "ref", nargs="?", default=None, metavar="REF", help="Commit to mark bad (default: HEAD).", ) bad_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) bad_p.set_defaults(func=run_bisect_bad) # ── good ────────────────────────────────────────────────────────────────── good_p = subs.add_parser( "good", help="Mark a commit as good (bug absent).", description=( "Record that the given commit (default: HEAD) does not exhibit\n" "the regression. Muse narrows the search range and suggests the\n" "next midpoint to test.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect good --json\n" " muse bisect good --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "good", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — verdict recorded (done=true when first bad commit isolated)\n" " 1 — no active bisect session, or ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) good_p.add_argument( "ref", nargs="?", default=None, metavar="REF", help="Commit to mark good (default: HEAD).", ) good_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) good_p.set_defaults(func=run_bisect_good) # ── log ─────────────────────────────────────────────────────────────────── log_p = subs.add_parser( "log", help="Show the bisect session log.", description=( "Display every verdict applied in the current (or most recent)\n" "bisect session, oldest first. Each entry is a space-separated\n" "triple: .\n\n" "Agent quickstart\n" "----------------\n" " muse bisect log --json\n\n" "JSON output schema\n" "------------------\n" ' {"active": true|false,\n' ' "entries": [{"commit_id": "", "verdict": "bad|good|skip",\n' ' "timestamp": ""}, ...]}\n\n' "Exit codes\n" "----------\n" " 0 — always (empty entries list when no session exists)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) log_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) log_p.set_defaults(func=run_bisect_log) # ── reset ───────────────────────────────────────────────────────────────── reset_p = subs.add_parser( "reset", help="End the bisect session and clean up state.", description=( "Remove all bisect state. Idempotent — safe to call whether or\n" "not a session is active. After reset, bad/good/skip commands\n" "will refuse until a new session is started with 'muse bisect\n" "start'.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect reset --json\n\n" "JSON output schema\n" "------------------\n" ' {"reset": true}\n\n' "Exit codes\n" "----------\n" " 0 — always (no-op when no session is active)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) reset_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) reset_p.set_defaults(func=run_bisect_reset) # ── run ─────────────────────────────────────────────────────────────────── run_p = subs.add_parser( "run", help="Automatically bisect by running a command.", description=( "Run COMMAND at each bisect step; Muse interprets the exit code\n" "and applies the verdict automatically until the first bad commit\n" "is isolated. Exit codes: 0=good, 125=skip, anything else=bad.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect run 'pytest tests/test_regression.py -x -q' --json\n" " muse bisect run './check.sh' --json\n\n" "NDJSON output — one step line per commit, then a summary line\n" "-----------------------------------------------------------\n" ' step line: {"step":, "testing":"", "verdict":"good|bad|skip",\n' ' "remaining_count":, "done":false,\n' ' "symbol_changes":[...]}\n' ' done line: {"done":true, "first_bad":"|null", "steps_taken":}\n\n' "Exit codes\n" "----------\n" " 0 — run complete (first bad isolated or session exhausted)\n" " 1 — no active bisect session\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) run_p.add_argument( "command", metavar="COMMAND", help="Shell command to run at each step (exit 0=good, 125=skip, else=bad).", ) run_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable NDJSON (one line per step, then a summary line).", ) run_p.add_argument( "--timeout", "-t", type=int, default=None, metavar="SECONDS", help=( "Kill the test command after SECONDS and treat the commit as " "untestable (skip, same as exit 125). Prevents a hanging command " "from stalling an automated bisect." ), ) run_p.set_defaults(func=run_bisect_run) # ── skip ────────────────────────────────────────────────────────────────── skip_p = subs.add_parser( "skip", help="Skip an untestable commit.", description=( "Record that the given commit (default: HEAD) cannot be tested —\n" "e.g. it fails to build. Muse excludes it from the remaining set\n" "and suggests the next midpoint. In 'muse bisect run' mode, exit\n" "code 125 from the test script triggers this automatically.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect skip --json\n" " muse bisect skip --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "skip", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — commit skipped (done=true when first bad commit isolated)\n" " 1 — no active bisect session, or ref not found\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) skip_p.add_argument( "ref", nargs="?", default=None, metavar="REF", help="Commit to skip (default: HEAD).", ) skip_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) skip_p.set_defaults(func=run_bisect_skip) # ── start ───────────────────────────────────────────────────────────────── start_p = subs.add_parser( "start", help="Begin a bisect session.", description=( "Mark the known-bad and known-good commits, then let Muse binary-\n" "search the commits between them. Muse immediately suggests the\n" "midpoint commit to test. Use --symbol to restrict the search to\n" "commits that touched a specific symbol.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect start --bad HEAD --good v1.0.0 --json\n" " muse bisect start --bad HEAD --good v1.0.0 \\\n" " --symbol billing.py::Invoice.compute_total --json\n\n" "JSON output schema\n" "------------------\n" ' {"done": false, "first_bad": null, "next_to_test": "",\n' ' "remaining_count": , "steps_remaining": ,\n' ' "verdict": "started", "symbol_changes": [...]}\n\n' "Exit codes\n" "----------\n" " 0 — session started (or immediately resolved when bad/good adjacent)\n" " 1 — session already active, bad --symbol, missing --good, or bad ref\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) start_p.add_argument( "--bad", default=None, metavar="REF", help="Known-bad commit (default: HEAD).", ) start_p.add_argument( "--good", nargs="*", default=None, metavar="REF", help="Known-good commit(s). Repeat for multiple: --good v1.0 --good v0.9.", ) start_p.add_argument( "--symbol", "-s", default=None, metavar="ADDR", help=( "Restrict search to commits that touched this symbol " "(e.g. billing.py::Invoice.compute_total). Dramatically reduces " "the number of steps when you already know which symbol regressed." ), ) start_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) start_p.set_defaults(func=run_bisect_start) # ── status ──────────────────────────────────────────────────────────────── status_p = subs.add_parser( "status", help="Report the current bisect session state (read-only).", description=( "Return a structured read-only snapshot of the active bisect\n" "session. Safe to call at any time — never modifies state.\n" "Agents should use this instead of parsing 'muse bisect log'\n" "to discover whether a session is running.\n\n" "Agent quickstart\n" "----------------\n" " muse bisect status --json\n\n" "JSON output schema (active)\n" "---------------------------\n" ' {"active": true, "bad_id": "", "good_ids": ["", ...],\n' ' "symbol_filter": "", "remaining_count": ,\n' ' "steps_remaining": , "skipped_count": ,\n' ' "branch": ""}\n\n' "JSON output schema (no session)\n" "-------------------------------\n" ' {"active": false}\n\n' "Exit codes\n" "----------\n" " 0 — always (active=false when no session exists)\n" " 2 — not inside a Muse repository\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) status_p.add_argument( "--json", "-j", action="store_true", dest="json_out", default=False, help="Emit machine-readable JSON to stdout.", ) status_p.set_defaults(func=run_bisect_status)