arbor-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. arbor/__init__.py +7 -0
  2. arbor/_app.py +30 -0
  3. arbor/cli/__init__.py +1 -0
  4. arbor/cli/_autodetect.py +101 -0
  5. arbor/cli/_constants.py +81 -0
  6. arbor/cli/app.py +100 -0
  7. arbor/cli/branch_guard.py +128 -0
  8. arbor/cli/chart.py +243 -0
  9. arbor/cli/commands/__init__.py +1 -0
  10. arbor/cli/commands/config_cmd.py +230 -0
  11. arbor/cli/commands/doctor_cmd.py +134 -0
  12. arbor/cli/commands/report_cmd.py +41 -0
  13. arbor/cli/commands/run.py +921 -0
  14. arbor/cli/commands/setup_cmd.py +133 -0
  15. arbor/cli/companion.py +485 -0
  16. arbor/cli/i18n.py +76 -0
  17. arbor/cli/intake/__init__.py +16 -0
  18. arbor/cli/intake/display.py +206 -0
  19. arbor/cli/intake/launch_tool.py +190 -0
  20. arbor/cli/intake/repl.py +744 -0
  21. arbor/cli/intake/system_prompt.py +332 -0
  22. arbor/cli/post_run.py +331 -0
  23. arbor/cli/preflight.py +218 -0
  24. arbor/cli/resume_picker.py +232 -0
  25. arbor/cli/run_dashboard.py +2695 -0
  26. arbor/cli/run_state.py +898 -0
  27. arbor/cli/style.py +196 -0
  28. arbor/cli/user_config.py +50 -0
  29. arbor/coordinator/__init__.py +17 -0
  30. arbor/coordinator/checkpoint.py +277 -0
  31. arbor/coordinator/config.py +516 -0
  32. arbor/coordinator/context_prune.py +219 -0
  33. arbor/coordinator/convergence.py +362 -0
  34. arbor/coordinator/hitl.py +73 -0
  35. arbor/coordinator/idea_tree.py +583 -0
  36. arbor/coordinator/main.py +255 -0
  37. arbor/coordinator/orchestrator.py +1169 -0
  38. arbor/coordinator/prompts.py +781 -0
  39. arbor/coordinator/tools/__init__.py +140 -0
  40. arbor/coordinator/tools/ask_user.py +117 -0
  41. arbor/coordinator/tools/executor_run.py +1307 -0
  42. arbor/coordinator/tools/git_ops.py +576 -0
  43. arbor/coordinator/tools/search_ctx.py +586 -0
  44. arbor/coordinator/tools/tree_ops.py +635 -0
  45. arbor/core/__init__.py +111 -0
  46. arbor/core/agent.py +824 -0
  47. arbor/core/config.py +103 -0
  48. arbor/core/config_cli.py +161 -0
  49. arbor/core/config_resolve.py +309 -0
  50. arbor/core/config_schema.py +388 -0
  51. arbor/core/context.py +420 -0
  52. arbor/core/experiment.py +282 -0
  53. arbor/core/git_artifacts.py +63 -0
  54. arbor/core/llm/__init__.py +13 -0
  55. arbor/core/llm/base.py +203 -0
  56. arbor/core/llm/claude.py +391 -0
  57. arbor/core/llm/litellm_provider.py +182 -0
  58. arbor/core/llm/openai_compat.py +408 -0
  59. arbor/core/llm/openai_responses.py +398 -0
  60. arbor/core/logging_setup.py +39 -0
  61. arbor/core/skill_registry.py +144 -0
  62. arbor/core/tools/__init__.py +74 -0
  63. arbor/core/tools/base.py +106 -0
  64. arbor/core/tools/bash.py +411 -0
  65. arbor/core/tools/executor_tool.py +135 -0
  66. arbor/core/tools/file_edit.py +201 -0
  67. arbor/core/tools/file_read.py +178 -0
  68. arbor/core/tools/file_write.py +69 -0
  69. arbor/core/tools/glob_tool.py +91 -0
  70. arbor/core/tools/grep.py +226 -0
  71. arbor/core/tools/path_guard.py +36 -0
  72. arbor/core/tools/run_training.py +444 -0
  73. arbor/core/tools/skill.py +78 -0
  74. arbor/core/tools/web/__init__.py +11 -0
  75. arbor/core/tools/web/_coerce.py +72 -0
  76. arbor/core/tools/web/prompts.py +20 -0
  77. arbor/core/tools/web/search.py +404 -0
  78. arbor/core/tools/web/visit.py +237 -0
  79. arbor/dashboard.py +781 -0
  80. arbor/events/__init__.py +14 -0
  81. arbor/events/bus.py +126 -0
  82. arbor/events/mock.py +60 -0
  83. arbor/events/payloads.py +133 -0
  84. arbor/events/subscribers/__init__.py +1 -0
  85. arbor/events/subscribers/cli_logger.py +255 -0
  86. arbor/events/subscribers/file_logger.py +58 -0
  87. arbor/events/subscribers/stats_collector.py +111 -0
  88. arbor/events/types.py +64 -0
  89. arbor/executor/__init__.py +6 -0
  90. arbor/executor/main.py +183 -0
  91. arbor/executor/prompts.py +437 -0
  92. arbor/plugins/__init__.py +5 -0
  93. arbor/plugins/base.py +160 -0
  94. arbor/plugins/mle_kaggle.yaml +269 -0
  95. arbor/report/__init__.py +5 -0
  96. arbor/report/generator.py +250 -0
  97. arbor/review.py +325 -0
  98. arbor/run.py +733 -0
  99. arbor/search_agent/__init__.py +20 -0
  100. arbor/search_agent/agent.py +146 -0
  101. arbor/search_agent/main.py +118 -0
  102. arbor/search_agent/prompts.py +130 -0
  103. arbor/skills/first_principles_probe.md +34 -0
  104. arbor/skills/idea_drafting.md +244 -0
  105. arbor/webui/__init__.py +6 -0
  106. arbor/webui/index.html +1036 -0
  107. arbor/webui/launcher.py +50 -0
  108. arbor/webui/server.py +320 -0
  109. arbor/webui/snapshot.py +168 -0
  110. arbor_agent-0.1.0.dist-info/METADATA +458 -0
  111. arbor_agent-0.1.0.dist-info/RECORD +115 -0
  112. arbor_agent-0.1.0.dist-info/WHEEL +5 -0
  113. arbor_agent-0.1.0.dist-info/entry_points.txt +6 -0
  114. arbor_agent-0.1.0.dist-info/licenses/LICENSE +201 -0
  115. arbor_agent-0.1.0.dist-info/top_level.txt +1 -0
arbor/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """arbor — AI-powered autonomous research framework.
2
+
3
+ Sub-packages:
4
+ - core: Shared infrastructure (Agent, tools, LLM providers, context management)
5
+ - executor: Research executor that implements individual ideas
6
+ - coordinator: arbor-guided orchestrator that manages the Idea Tree
7
+ """
arbor/_app.py ADDED
@@ -0,0 +1,30 @@
1
+ """Single-source-of-truth for the application's brand name.
2
+
3
+ Future renames only need to change APP_NAME below; all derived strings
4
+ (CLI command, config dir, config file) update automatically. Do not write
5
+ the literal string "arbor" anywhere else in the codebase.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ APP_NAME = "arbor"
11
+
12
+ CLI_COMMAND = APP_NAME
13
+
14
+ # Product taglines, shown on the splash banner and in `--help`. Kept here as a
15
+ # single source of truth so the two surfaces never drift. TAGLINE is the punchy
16
+ # hero line; TAGLINE_SUB explains what the agent actually does (branch → prune →
17
+ # harvest), mirroring the tree/arbor brand.
18
+ TAGLINE = "Grow evidence, not logs."
19
+ TAGLINE_SUB = "Every hypothesis becomes a branch — pruned if it fails, harvested if it works."
20
+
21
+ CONFIG_DIR_NAME = f".{APP_NAME}"
22
+ CONFIG_FILE_NAME = f"{APP_NAME}.yaml"
23
+
24
+ GLOBAL_CONFIG_DIR = Path.home() / f".{APP_NAME}"
25
+ GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.yaml"
26
+
27
+ # Legacy paths kept for one release so users with a pre-rename config
28
+ # don't lose their settings. The user_config loader falls back to these.
29
+ LEGACY_GLOBAL_CONFIG_DIR = Path.home() / ".autoresearch"
30
+ LEGACY_GLOBAL_CONFIG_FILE = LEGACY_GLOBAL_CONFIG_DIR / "config.yaml"
arbor/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI entry point for the arbor tool."""
@@ -0,0 +1,101 @@
1
+ """Setup-time backend auto-detection for ``provider: auto``.
2
+
3
+ When the user picks ``auto`` we resolve it to a *concrete* backend **once**, at
4
+ ``arbor setup`` / ``arbor config init`` time, and freeze the result in the config
5
+ file. The runtime stays pure and fast (``resolve_backend`` never touches the
6
+ network); the only network probe happens here, during setup.
7
+
8
+ Resolution rules:
9
+
10
+ * ``claude*`` → ``anthropic`` (native Messages API: signed thinking blocks +
11
+ prompt caching), against the official endpoint or a custom ``base_url``.
12
+ * Anything else → **probe** ``{base_url}/responses``. If the endpoint serves the
13
+ OpenAI Responses API we pick ``openai-responses`` so the reasoning chain is
14
+ preserved across ReAct turns; otherwise we fall back to ``openai-chat`` (chat
15
+ completions), which every OpenAI-compatible endpoint supports.
16
+
17
+ The probe is best-effort and never raises — an inconclusive result (network
18
+ error, bad key, …) falls back to ``openai-chat``, which the user can always
19
+ override by setting ``provider`` explicitly.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ # How long to wait for the one-shot Responses probe before giving up and
29
+ # falling back to chat completions. Setup is interactive, so keep it snappy.
30
+ _PROBE_TIMEOUT = 10.0
31
+
32
+
33
+ def probe_responses_api(
34
+ *,
35
+ model: str,
36
+ base_url: str | None,
37
+ api_key: str | None,
38
+ timeout: float = _PROBE_TIMEOUT,
39
+ ) -> bool:
40
+ """Best-effort: ``True`` iff ``{base_url}/responses`` actually answers this
41
+ model with a usable response.
42
+
43
+ Never raises. Only a clean success counts as "supported" — this is
44
+ deliberately conservative. A false positive (picking the Responses API when
45
+ it won't work) breaks every run, which is exactly the failure we're trying
46
+ to prevent; a false negative merely costs the reasoning-chain upgrade and
47
+ falls back to chat completions, which still works. Note that a route can
48
+ *exist* yet reject the model (e.g. a proxy that answers ``/responses`` with
49
+ 400 "this model does not support the responses endpoint"), so "the route is
50
+ there" is not enough — we require an actual 2xx.
51
+ """
52
+ try:
53
+ from openai import OpenAI
54
+ except Exception: # pragma: no cover - openai always installed in practice
55
+ return False
56
+
57
+ try:
58
+ client = OpenAI(
59
+ api_key=api_key or "dummy",
60
+ base_url=base_url or None,
61
+ max_retries=0,
62
+ timeout=timeout,
63
+ )
64
+ # Minimal request: no reasoning, tiny output. We only care whether the
65
+ # /responses route returns a usable response for this model.
66
+ resp = client.responses.create(model=model, input="ping", max_output_tokens=16)
67
+ # A genuine Responses API returns an object with an id; anything else
68
+ # (a chat shim echoing JSON, an empty body, …) is not the real thing.
69
+ return getattr(resp, "id", None) is not None
70
+ except Exception as e:
71
+ # 404 (no route), 400 (route exists but model unsupported / bad request),
72
+ # auth, connection, timeout — all inconclusive or negative. Fall back to
73
+ # the universal chat path.
74
+ log.debug("responses probe failed for %s @ %s: %s", model, base_url, e)
75
+ return False
76
+
77
+
78
+ def resolve_auto_provider(
79
+ *,
80
+ model: str,
81
+ base_url: str | None,
82
+ api_key: str | None,
83
+ timeout: float = _PROBE_TIMEOUT,
84
+ ) -> tuple[str, str]:
85
+ """Resolve ``provider: auto`` to a concrete backend at setup time.
86
+
87
+ Returns ``(provider, reason)`` where ``provider`` is one of ``anthropic`` |
88
+ ``openai-responses`` | ``openai-chat`` (the user-facing menu values minus
89
+ ``auto``) and ``reason`` is a short human-readable note for the setup output.
90
+ """
91
+ bare = (model or "").rsplit("/", 1)[-1].lower()
92
+
93
+ if bare.startswith(("claude", "anthropic")):
94
+ return "anthropic", "Claude model → native Anthropic Messages API"
95
+
96
+ if probe_responses_api(model=model, base_url=base_url, api_key=api_key, timeout=timeout):
97
+ return (
98
+ "openai-responses",
99
+ "endpoint serves the Responses API → openai-responses (reasoning chain preserved)",
100
+ )
101
+ return "openai-chat", "no Responses API on this endpoint → openai-chat (chat completions)"
@@ -0,0 +1,81 @@
1
+ """Shared CLI constants and small provider helpers.
2
+
3
+ Single source of truth for values the ``run`` / ``config`` commands and the
4
+ intake REPL all need, so they cannot drift apart — e.g. a provider added in one
5
+ command but forgotten in another, or two copies of a "default model" helper that
6
+ quietly disagree.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # User-facing provider menu (setup wizard + `config init --provider`), in
12
+ # display order. Each is a single-axis value that maps 1:1 onto a backend, so
13
+ # the config file reads the same as the menu. `auto` resolves to one of the
14
+ # concrete three at setup time.
15
+ PROVIDER_CHOICES = ("auto", "openai-responses", "openai-chat", "anthropic")
16
+
17
+ # Concrete providers Arbor can store + serve after `auto` is resolved. `litellm`
18
+ # stays a valid backend for back-compat / advanced hand-edited configs, but is
19
+ # no longer advertised in the menu.
20
+ _BACKEND_PROVIDERS = {"anthropic", "openai-responses", "openai-chat", "litellm"}
21
+ VALID_OPENAI_APIS = {"chat", "responses"}
22
+
23
+ # Intake-agent LLM call budget — seeded into the agent config by ``run`` and
24
+ # applied directly by the REPL.
25
+ INTAKE_LLM_TIMEOUT = 20.0
26
+ INTAKE_LLM_PROVIDER_RETRIES = 0
27
+ INTAKE_LLM_RETRY_ATTEMPTS = 2
28
+ INTAKE_LLM_RETRY_BASE_DELAY = 1.0
29
+ INTAKE_LLM_RETRY_MAX_DELAY = 2.0
30
+
31
+ # Intake is a planning conversation (read the eval, propose a contract), not a
32
+ # deep-reasoning task — so it overrides the user's reasoning_effort (often
33
+ # "high") with a lighter setting to keep each turn snappy.
34
+ INTAKE_REASONING_EFFORT = "low"
35
+
36
+ DEFAULT_OPENAI_MODEL = "gpt-4o"
37
+ DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-20250514"
38
+
39
+ # Read-only WebUI: the browser monitor binds here by default for interactive
40
+ # runs (no flag needed). If the port is taken we walk the next few ports so a
41
+ # second concurrent run doesn't collide.
42
+ DEFAULT_WEBUI_PORT = 8765
43
+ WEBUI_PORT_SCAN = 10
44
+
45
+
46
+ def canonical_provider(provider: str | None, openai_api: str | None = None) -> str:
47
+ """Collapse any provider alias onto a single canonical, single-axis value.
48
+
49
+ Returns one of ``auto`` | ``anthropic`` | ``openai-responses`` |
50
+ ``openai-chat`` | ``litellm``. The legacy two-axis form (``openai`` plus
51
+ ``openai_api: chat|responses``) folds into the matching ``openai-*`` value,
52
+ so newly written configs only ever carry the single ``provider`` field.
53
+ """
54
+ p = (provider or "anthropic").strip().lower()
55
+ api = (openai_api or "").strip().lower()
56
+ if p == "auto":
57
+ return "auto"
58
+ if p in ("claude", "anthropic"):
59
+ return "anthropic"
60
+ if p == "litellm":
61
+ return "litellm"
62
+ if p in ("openai-chat", "chat", "openai_compat", "openai_chat"):
63
+ return "openai-chat"
64
+ if p in ("openai-responses", "responses", "openai_responses", "openai_response"):
65
+ return "openai-responses"
66
+ if p == "openai": # legacy bare provider: respect the openai_api axis
67
+ return "openai-chat" if api == "chat" else "openai-responses"
68
+ return p # unknown → passthrough; resolve_backend decides or errors later
69
+
70
+
71
+ def default_model_for_provider(provider: str | None) -> str | None:
72
+ """Default model for ``provider``, or ``None`` to defer to the provider.
73
+
74
+ ``anthropic``/``auto`` return ``None`` because Claude supplies its own
75
+ default; the OpenAI family and litellm need an explicit model here. Callers
76
+ that must persist a concrete string substitute :data:`DEFAULT_CLAUDE_MODEL`.
77
+ """
78
+ canon = canonical_provider(provider)
79
+ if canon.startswith("openai") or canon == "litellm":
80
+ return DEFAULT_OPENAI_MODEL
81
+ return None
arbor/cli/app.py ADDED
@@ -0,0 +1,100 @@
1
+ """Top-level Typer app for the arbor CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from difflib import get_close_matches
7
+
8
+ import typer
9
+
10
+ from .._app import APP_NAME, TAGLINE, TAGLINE_SUB
11
+ from .commands.run import run_command
12
+ from .commands.report_cmd import report_command
13
+ from .commands.config_cmd import config_app
14
+ from .commands.doctor_cmd import doctor_command
15
+ from .commands.setup_cmd import setup_command
16
+
17
+
18
+ # We don't use a Typer.callback() default because that would shadow flag
19
+ # handling for `arbor --help`. Instead, we detect the no-subcommand case
20
+ # in main() and rewrite argv to insert "run" before delegating.
21
+
22
+ app = typer.Typer(
23
+ name=APP_NAME,
24
+ help=(
25
+ f"{APP_NAME} — {TAGLINE}\n\n"
26
+ f"{TAGLINE_SUB}\n\n"
27
+ f"Tip: run `{APP_NAME}` (no subcommand) inside your project to start "
28
+ f"an interactive session — equivalent to `{APP_NAME} run`."
29
+ ),
30
+ no_args_is_help=False,
31
+ add_completion=False,
32
+ )
33
+
34
+ app.command("run")(run_command)
35
+ app.command("report")(report_command)
36
+ app.command("doctor")(doctor_command)
37
+ app.command("setup")(setup_command)
38
+ app.add_typer(config_app, name="config")
39
+
40
+
41
+ @app.command("version")
42
+ def version_command() -> None:
43
+ """Print the installed version."""
44
+ try:
45
+ from importlib.metadata import version as _v
46
+ ver = _v(APP_NAME)
47
+ except Exception:
48
+ ver = "unknown"
49
+ typer.echo(f"{APP_NAME} {ver}")
50
+
51
+
52
+ _KNOWN_COMMANDS = {"run", "report", "config", "version", "doctor", "setup"}
53
+ _ROOT_FLAGS = {"--help", "-h"}
54
+ _VERSION_FLAGS = {"--version", "-V"}
55
+
56
+
57
+ def main() -> None:
58
+ """Console-script entry point.
59
+
60
+ If invoked with no subcommand (e.g. `arbor` or `arbor --cwd .`),
61
+ default to `run`. The only flags that stay at root level are --help / -h.
62
+ """
63
+ # Some terminals (notably macOS Terminal.app with "Set locale env vars on
64
+ # startup" off) hand Python a non-UTF-8 stdout, and any glyph or CJK text
65
+ # we print then raises UnicodeEncodeError and crashes. Force UTF-8 with
66
+ # replacement so the worst case is a "?" rather than a dead process.
67
+ for _stream in (sys.stdout, sys.stderr):
68
+ try:
69
+ enc = (getattr(_stream, "encoding", None) or "").lower()
70
+ if hasattr(_stream, "reconfigure") and enc not in ("utf-8", "utf8"):
71
+ _stream.reconfigure(encoding="utf-8", errors="replace")
72
+ except Exception:
73
+ pass
74
+
75
+ argv = sys.argv[1:]
76
+ first = argv[0] if argv else None
77
+ if first in _VERSION_FLAGS:
78
+ sys.argv = [sys.argv[0], "version", *argv[1:]]
79
+ app()
80
+ return
81
+ needs_default = (
82
+ not argv
83
+ or (first not in _KNOWN_COMMANDS and first not in _ROOT_FLAGS)
84
+ )
85
+ if first and first not in _KNOWN_COMMANDS and first not in _ROOT_FLAGS and not first.startswith("-"):
86
+ match = get_close_matches(first, sorted(_KNOWN_COMMANDS), n=1, cutoff=0.74)
87
+ if match:
88
+ typer.secho(
89
+ f"error: unknown command {first!r}. Did you mean {match[0]!r}?",
90
+ fg=typer.colors.RED,
91
+ err=True,
92
+ )
93
+ sys.exit(2)
94
+ if needs_default:
95
+ sys.argv = [sys.argv[0], "run", *argv]
96
+ app()
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,128 @@
1
+ """Pre-launch git base-branch guard.
2
+
3
+ Every research run creates its trunk/experiment branches off the project's base
4
+ branch (``main``/``master``) and, when it finishes, leaves you checked out on the
5
+ working trunk (``coordinator/trunk``). The *next* run in that repo would then hit
6
+ the engine's "refusing to create a trunk from a non-base branch" guard and die
7
+ after the whole dashboard has spun up.
8
+
9
+ This module catches that situation up front and, in an interactive terminal,
10
+ offers to switch back to the base branch (the common case) / proceed anyway /
11
+ abort — so the user gets a one-keypress recovery instead of a raw error.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+
21
+ def _git(cwd: Path, *args: str) -> str | None:
22
+ try:
23
+ return subprocess.check_output(
24
+ ["git", *args], cwd=str(cwd), stderr=subprocess.DEVNULL, text=True,
25
+ ).strip()
26
+ except (subprocess.CalledProcessError, OSError):
27
+ return None
28
+
29
+
30
+ def current_branch(cwd: Path) -> str | None:
31
+ return _git(cwd, "branch", "--show-current") or None
32
+
33
+
34
+ def _branch_exists(cwd: Path, name: str) -> bool:
35
+ return _git(cwd, "rev-parse", "--verify", "--quiet", f"refs/heads/{name}") is not None
36
+
37
+
38
+ def resolve_base_branch(cwd: Path, configured: str | None) -> str | None:
39
+ """The repo's base branch: the configured one, else the first of main/master
40
+ that exists. None if we can't tell (not a git repo / detached / neither)."""
41
+ if configured:
42
+ return configured
43
+ for name in ("main", "master"):
44
+ if _branch_exists(cwd, name):
45
+ return name
46
+ return None
47
+
48
+
49
+ def on_base_branch(cwd: Path, configured_base: str | None) -> tuple[bool, str | None, str | None]:
50
+ """Return (is_on_base, current, base). ``is_on_base`` is True when we can't
51
+ determine current/base (detached HEAD, no base) — the engine's own guard
52
+ stays the backstop for those odd states."""
53
+ cur = current_branch(cwd)
54
+ base = resolve_base_branch(cwd, configured_base)
55
+ if cur is None or base is None:
56
+ return True, cur, base
57
+ return cur == base, cur, base
58
+
59
+
60
+ def resolve_start_branch(
61
+ cwd: Path,
62
+ config: Any,
63
+ *,
64
+ allow_non_base: bool,
65
+ interactive: bool,
66
+ console: Any,
67
+ ) -> Literal["proceed", "abort"]:
68
+ """Ensure the run starts from the base branch, or the user knowingly opts out.
69
+
70
+ Side effects: may ``git checkout`` the base branch, or set
71
+ ``config.require_base_branch = False`` when the user proceeds on a non-base
72
+ branch. Returns "proceed" or "abort".
73
+ """
74
+ on_base, cur, base = on_base_branch(cwd, config.base_branch)
75
+ if on_base:
76
+ return "proceed"
77
+
78
+ # Explicit opt-out (flag) — honor it without prompting.
79
+ if allow_non_base:
80
+ config.require_base_branch = False
81
+ return "proceed"
82
+
83
+ # Non-interactive (piped / --yes / CI): can't ask. Fail clean with the
84
+ # followable fix instead of crashing mid-run.
85
+ if not interactive:
86
+ from .style import render_error_panel
87
+ render_error_panel(
88
+ "not on the base branch",
89
+ f"Currently on '{cur}', but runs start from the base branch '{base}'.\n"
90
+ f"A previous run likely left you on '{cur}'. Either:\n"
91
+ f" • git checkout {base}\n"
92
+ f" • or re-run with --allow-non-base-branch to use this branch as-is.",
93
+ )
94
+ return "abort"
95
+
96
+ # Interactive: offer the one-keypress recovery.
97
+ import typer
98
+
99
+ console.print()
100
+ console.print(
101
+ f"[yellow]You're on branch [bold]{cur}[/], not the base branch "
102
+ f"[bold]{base}[/].[/]")
103
+ console.print(f"[dim]A previous run usually leaves you on '{cur}'.[/]\n")
104
+ console.print(f" [bold]m[/] checkout '{base}' and start fresh [dim](recommended)[/]")
105
+ console.print(f" [bold]p[/] proceed on '{cur}' as-is")
106
+ console.print(" [bold]a[/] abort\n")
107
+
108
+ while True:
109
+ choice = typer.prompt(f"Checkout {base}, proceed, or abort? [m/p/a]",
110
+ default="m").strip().lower()
111
+ if choice in ("m", "main", base):
112
+ if _git(cwd, "checkout", base) is None:
113
+ from .style import render_error_panel
114
+ render_error_panel(
115
+ "checkout failed",
116
+ f"Could not checkout '{base}' (uncommitted changes on '{cur}'?). "
117
+ f"Resolve it manually, then re-run.",
118
+ )
119
+ return "abort"
120
+ console.print(f"[green]✓[/] now on '{base}'")
121
+ return "proceed"
122
+ if choice in ("p", "proceed"):
123
+ config.require_base_branch = False
124
+ console.print(f"[dim]proceeding on '{cur}'[/]")
125
+ return "proceed"
126
+ if choice in ("a", "abort", "q"):
127
+ return "abort"
128
+ console.print("[yellow] enter m, p, or a[/]")