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.
- arbor/__init__.py +7 -0
- arbor/_app.py +30 -0
- arbor/cli/__init__.py +1 -0
- arbor/cli/_autodetect.py +101 -0
- arbor/cli/_constants.py +81 -0
- arbor/cli/app.py +100 -0
- arbor/cli/branch_guard.py +128 -0
- arbor/cli/chart.py +243 -0
- arbor/cli/commands/__init__.py +1 -0
- arbor/cli/commands/config_cmd.py +230 -0
- arbor/cli/commands/doctor_cmd.py +134 -0
- arbor/cli/commands/report_cmd.py +41 -0
- arbor/cli/commands/run.py +921 -0
- arbor/cli/commands/setup_cmd.py +133 -0
- arbor/cli/companion.py +485 -0
- arbor/cli/i18n.py +76 -0
- arbor/cli/intake/__init__.py +16 -0
- arbor/cli/intake/display.py +206 -0
- arbor/cli/intake/launch_tool.py +190 -0
- arbor/cli/intake/repl.py +744 -0
- arbor/cli/intake/system_prompt.py +332 -0
- arbor/cli/post_run.py +331 -0
- arbor/cli/preflight.py +218 -0
- arbor/cli/resume_picker.py +232 -0
- arbor/cli/run_dashboard.py +2695 -0
- arbor/cli/run_state.py +898 -0
- arbor/cli/style.py +196 -0
- arbor/cli/user_config.py +50 -0
- arbor/coordinator/__init__.py +17 -0
- arbor/coordinator/checkpoint.py +277 -0
- arbor/coordinator/config.py +516 -0
- arbor/coordinator/context_prune.py +219 -0
- arbor/coordinator/convergence.py +362 -0
- arbor/coordinator/hitl.py +73 -0
- arbor/coordinator/idea_tree.py +583 -0
- arbor/coordinator/main.py +255 -0
- arbor/coordinator/orchestrator.py +1169 -0
- arbor/coordinator/prompts.py +781 -0
- arbor/coordinator/tools/__init__.py +140 -0
- arbor/coordinator/tools/ask_user.py +117 -0
- arbor/coordinator/tools/executor_run.py +1307 -0
- arbor/coordinator/tools/git_ops.py +576 -0
- arbor/coordinator/tools/search_ctx.py +586 -0
- arbor/coordinator/tools/tree_ops.py +635 -0
- arbor/core/__init__.py +111 -0
- arbor/core/agent.py +824 -0
- arbor/core/config.py +103 -0
- arbor/core/config_cli.py +161 -0
- arbor/core/config_resolve.py +309 -0
- arbor/core/config_schema.py +388 -0
- arbor/core/context.py +420 -0
- arbor/core/experiment.py +282 -0
- arbor/core/git_artifacts.py +63 -0
- arbor/core/llm/__init__.py +13 -0
- arbor/core/llm/base.py +203 -0
- arbor/core/llm/claude.py +391 -0
- arbor/core/llm/litellm_provider.py +182 -0
- arbor/core/llm/openai_compat.py +408 -0
- arbor/core/llm/openai_responses.py +398 -0
- arbor/core/logging_setup.py +39 -0
- arbor/core/skill_registry.py +144 -0
- arbor/core/tools/__init__.py +74 -0
- arbor/core/tools/base.py +106 -0
- arbor/core/tools/bash.py +411 -0
- arbor/core/tools/executor_tool.py +135 -0
- arbor/core/tools/file_edit.py +201 -0
- arbor/core/tools/file_read.py +178 -0
- arbor/core/tools/file_write.py +69 -0
- arbor/core/tools/glob_tool.py +91 -0
- arbor/core/tools/grep.py +226 -0
- arbor/core/tools/path_guard.py +36 -0
- arbor/core/tools/run_training.py +444 -0
- arbor/core/tools/skill.py +78 -0
- arbor/core/tools/web/__init__.py +11 -0
- arbor/core/tools/web/_coerce.py +72 -0
- arbor/core/tools/web/prompts.py +20 -0
- arbor/core/tools/web/search.py +404 -0
- arbor/core/tools/web/visit.py +237 -0
- arbor/dashboard.py +781 -0
- arbor/events/__init__.py +14 -0
- arbor/events/bus.py +126 -0
- arbor/events/mock.py +60 -0
- arbor/events/payloads.py +133 -0
- arbor/events/subscribers/__init__.py +1 -0
- arbor/events/subscribers/cli_logger.py +255 -0
- arbor/events/subscribers/file_logger.py +58 -0
- arbor/events/subscribers/stats_collector.py +111 -0
- arbor/events/types.py +64 -0
- arbor/executor/__init__.py +6 -0
- arbor/executor/main.py +183 -0
- arbor/executor/prompts.py +437 -0
- arbor/plugins/__init__.py +5 -0
- arbor/plugins/base.py +160 -0
- arbor/plugins/mle_kaggle.yaml +269 -0
- arbor/report/__init__.py +5 -0
- arbor/report/generator.py +250 -0
- arbor/review.py +325 -0
- arbor/run.py +733 -0
- arbor/search_agent/__init__.py +20 -0
- arbor/search_agent/agent.py +146 -0
- arbor/search_agent/main.py +118 -0
- arbor/search_agent/prompts.py +130 -0
- arbor/skills/first_principles_probe.md +34 -0
- arbor/skills/idea_drafting.md +244 -0
- arbor/webui/__init__.py +6 -0
- arbor/webui/index.html +1036 -0
- arbor/webui/launcher.py +50 -0
- arbor/webui/server.py +320 -0
- arbor/webui/snapshot.py +168 -0
- arbor_agent-0.1.0.dist-info/METADATA +458 -0
- arbor_agent-0.1.0.dist-info/RECORD +115 -0
- arbor_agent-0.1.0.dist-info/WHEEL +5 -0
- arbor_agent-0.1.0.dist-info/entry_points.txt +6 -0
- arbor_agent-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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."""
|
arbor/cli/_autodetect.py
ADDED
|
@@ -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)"
|
arbor/cli/_constants.py
ADDED
|
@@ -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[/]")
|