← refacto-feature-scout
from __future__ import annotations
import os
from pathlib import Path
HOME = Path.home()
CLOUD = bool(os.environ.get("SCOUT_CLOUD")) # set in fly.toml: repos are disposable mirrors
def _data_dir() -> Path:
"""Where this computer keeps its ideas, logs and scratch copies.
New installs use ~/.feature-scout. The first machine this ever ran on used a macOS-shaped
path, so that one is kept if it is already there — nobody should lose their ideas to a
tidier default.
"""
if os.environ.get("SCOUT_DATA_DIR"):
return Path(os.environ["SCOUT_DATA_DIR"])
legacy = HOME / "Library" / "FeatureScout"
return legacy if legacy.exists() else HOME / ".feature-scout"
DATA_DIR = _data_dir()
DB_PATH = DATA_DIR / "scout.sqlite"
LOG_DIR = DATA_DIR / "logs"
WORKTREE_DIR = DATA_DIR / "worktrees"
LOCK_PATH = DATA_DIR / "scout.lock"
# Extra settings this computer keeps (an API key, which Claude to use). Nothing here ships with
# the package: a machine that has no such file simply has no extra settings.
ENV_FILE = Path(os.environ.get("SCOUT_ENV_FILE") or DATA_DIR / "settings.env")
IDEAS_PER_PLATFORM = int(os.environ.get("SCOUT_IDEAS_PER_PLATFORM", "4")) # ideas per platform per scan
SCAN_TIMEOUT_S = 25 * 60
BUILD_TIMEOUT_S = 150 * 60
# A nudge resumes a session that stopped early and only has to finish up and write the report.
# Four builds on 2026-09-07 each sat for the full 2.5 h inside a nudge, waiting on a background
# browser suite that could never finish, so a nudge gets far less rope than a first attempt.
NUDGE_TIMEOUT_S = 20 * 60
TEST_TIMEOUT_S = 20 * 60
# Ideas recorded before the merge used these keys; the store maps them onto the merged platform.
LEGACY_PLATFORMS: dict[str, tuple[str, str]] = {}
VENUES = ("polymarket", "kalshi", "both")
IDEA_STATUSES = (
"proposed", "approved", "rejected", "building", "built", "blocked", "failed", "shipped",
)
def load_env(path: Path | None = None) -> dict[str, str]:
"""Settings: the dotenv file (Mac) overlaid with real environment variables (Fly secrets)."""
path = path or ENV_FILE
out: dict[str, str] = {}
for raw in (path.read_text().splitlines() if path.exists() else []):
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):]
if "=" not in line:
continue
k, v = line.split("=", 1)
v = v.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1]
out[k.strip()] = v
for k, v in os.environ.items():
if k.startswith(("SCOUT_", "LIBRARYMODE_", "FLY_")):
out[k] = v
return out
def ensure_dirs() -> None:
for d in (DATA_DIR, LOG_DIR, WORKTREE_DIR):
d.mkdir(parents=True, exist_ok=True)
# ---- settings a person chooses at setup and keeps on this computer (settings.env) ----
DEFAULT_CATEGORIES_DOC = "feature, improvement, fix, data, report, automation"
MODEL_CHOICES = {
# what a person says -> what the claude command is asked for ('' = its own default)
"default": "", "best": "opus", "balanced": "sonnet", "fast": "haiku",
}
def save_setting(name: str, value: str, path: Path | None = None) -> Path:
"""Write one NAME=value line into this computer's settings file (replacing any old one).
An empty value removes the line. Never used for anything that ships with the package."""
path = path or ENV_FILE
path.parent.mkdir(parents=True, exist_ok=True)
lines = [ln for ln in (path.read_text().splitlines() if path.exists() else [])
if not ln.startswith(f"{name}=")]
if value:
lines.append(f"{name}={value}")
path.write_text("\n".join(lines) + ("\n" if lines else ""))
os.chmod(path, 0o600)
return path
def scan_model() -> str | None:
"""Which Claude does the research, or None for the person's own default."""
return load_env().get("SCOUT_SCAN_MODEL") or None
def build_model() -> str | None:
return load_env().get("SCOUT_BUILD_MODEL") or None
def scan_time() -> tuple[int, int]:
"""When the daily look for ideas happens (hour, minute), local time."""
env = load_env()
try:
return int(env.get("SCOUT_SCAN_HOUR", 8)), int(env.get("SCOUT_SCAN_MINUTE", 15))
except ValueError:
return 8, 15