# SPEC: parametrize-base-branch ## Problem `scripts/status.py::_gate_worktree_drift` hard-codes `main` as the integration branch: ```python res = subprocess.run( ["git", "diff", "--name-only", "main...HEAD"], cwd=worktree_path, capture_output=True, text=True, timeout=10, check=False, ) ``` Source: `tasks/add-status-brakes/BUG_REPORT.md` O3. > Hard-codes `main` as the integration branch. Projects on `master`/`trunk` would show every file as out-of-scope (no `main` to diff against → git errors → gate skips with warning). On a project whose integration branch is `master`, `trunk`, `develop`, or `release/x.y`, `git diff main...HEAD` fails with `fatal: bad revision main`. The runner's drift gate prints `WARNING: could not run git diff for drift check: ...` and returns `None` (skip with warning, NOT halt). The drift gate is effectively disabled for every non-`main` project — a silent false-negative on the **drift** loop-death mode. ## Goal Replace the hardcoded `"main"` with a per-loop `blast_radius.base_branch` configuration field. The drift gate uses this branch for the `git diff ...HEAD` call. ## Non-goals - Multi-base-branch (e.g. "diff against ANY of these branches"). One base branch per loop. - Auto-detecting the repo's default branch (`git symbolic-ref refs/remotes/origin/HEAD`). Out of scope; operator sets `base_branch` explicitly in `loop.json`. - Validating the branch exists in the repo at `--create-loop` time. Defer to runtime — the drift gate's "bad revision" path already skips with warning. - Backfilling `base_branch` into v1 loops via `--upgrade-loops` (separately tracked; v1.1 loops get the field via `--create-loop` template). ## Schema addition (`loop.json`) Add an optional `base_branch` field under `blast_radius`: ```json "blast_radius": { "worktree": true, "file_scope": ["src/", "tests/"], "base_branch": "main" } ``` - **`blast_radius.base_branch`** (str, optional, default **`"main"`**): the integration branch to diff the worktree HEAD against in `_gate_worktree_drift`. Any string accepted as a git ref (branch name, tag, commit SHA). - Empty string coerces to `"main"` with WARNING. Non-string types coerce via `str(...)` with WARNING. `None` (key missing) → default `"main"` (silent). ## Requirements ### R1 — Drift gate reads base_branch `_gate_worktree_drift` calls a new helper `_base_branch(cfg) -> str` to get the integration branch. Replaces the hardcoded `"main"` in the `git diff` argv. ### R2 — Helper `_base_branch(cfg)` returns: - `"main"` if `cfg` is None or `blast_radius` is missing or `base_branch` is missing/None. - `"main"` (with stderr WARNING) if `base_branch` is an empty string. - `str(base_branch)` if non-empty str. - `str(base_branch)` (with stderr WARNING) if non-str type (int, bool, etc.). ### R3 — Drift-gate bad-revision path stays warning-skip If `git diff ...HEAD` fails (non-zero returncode OR exception), the gate logs `WARNING: could not run git diff for drift check: {stderr}` and returns `None` (no halt). Same behavior as v1 — operators running against a non-existent branch see a warning and a skipped gate, not a halt. Belt-and-suspenders: a wrong `base_branch` is admin error, not a drift event. ### R4 — Template + create-loop plumbing - `templates/loops/self-improvement/loop.json` adds `"base_branch": "main"` to the `blast_radius` block. New loops created via `--create-loop` get the field by default. - Existing v1 loops WITHOUT `base_branch` continue to work — `_base_branch` returns `"main"`. Backwards-compatible. ### R5 — No new pip deps; no new files; stdlib only. ## Test plan Pure-function tests (no subprocess except where mocked git is needed). Tests in `tests/test_base_branch.py` (NEW): 1. **Helper default `main`**: `_base_branch({})` → `"main"`. `_base_branch({"blast_radius": {}})` → `"main"`. `_base_branch({"blast_radius": {"base_branch": None}})` → `"main"` (all silent). 2. **Helper explicit value**: `_base_branch({"blast_radius": {"base_branch": "trunk"}})` → `"trunk"`. 3. **Helper empty string**: `_base_branch({"blast_radius": {"base_branch": ""}})` → `"main"` + stderr WARNING captured. 4. **Helper non-string**: `_base_branch({"blast_radius": {"base_branch": 42}})` → `"42"` + WARNING. 5. **Drift gate uses base_branch in argv** (mocked subprocess): patch `subprocess.run`, call `_gate_worktree_drift(state={"worktree_path": "/tmp/wt"}, cfg={"blast_radius": {"file_scope": ["src/"], "base_branch": "trunk"}}, project=None)`, assert captured argv is `["git", "diff", "--name-only", "trunk...HEAD"]`. 6. **Drift gate falls back to `main` when base_branch missing** (mocked subprocess): assert argv uses `"main"` when `blast_radius` lacks `base_branch`. 7. **Drift gate handles bad revision** (mocked subprocess returncode=128, stderr="fatal: bad revision 'trunk'"): assert gate returns `None` and prints WARNING to stderr (captured via `capsys`). 8. **Drift gate still detects drift** (mocked subprocess with names mtime.txt and out-of-scope `extraneous.txt`): assert gate returns dict with `halt_reason="drift_detected"` and `out_of_scope_files=["extraneous.txt"]`. Uses `base_branch="main"`. 9. **Drift gate in-scope files don't halt** (mocked subprocess returning only in-scope names): assert returns `None`. 10. **No worktree → None**: `state={"worktree_path": None}` → `None`. (Existing path; ensures R1 doesn't break.) 11. **Empty file_scope → None**: `cfg={"blast_radius": {"file_scope": [], "base_branch": "main"}}` → `None`. (Existing path.) 12. **Missing worktree_path → None**: `state={"worktree_path": "/does/not/exist"}` → `None`. 13. **Template includes base_branch**: load `templates/loops/self-improvement/loop.json`, assert `blast_radius.base_branch == "main"`. ## Decisions - **D-B1**: One base branch per loop (NOT a list). Schema simplicity; covers 95% of projects. Multi-base projects can use a SHA or tag if they need a moving target. - **D-B2**: Default `"main"` (most common on GitHub since 2020; matches v1 behavior). Operators override in `loop.json`. - **D-B3**: Bad-revision path stays WARNING-skip (NOT halt). v1 behavior preserved. A halt would punish operator misconfiguration; the existing drift-detection still triggers when the revision exists. Future: add `--validate-loop` to catch misconfiguration at create/install time. Out of scope here. - **D-B4**: Empty string → `"main"` with WARNING (not silent). Distinguishes "operator forgot the field" (None → silent default) from "operator set empty string" (probably a typo — flag it). - **D-B5**: No backfill on existing v1 loops. They get `"main"` via the helper default; no `--upgrade-loops` step required. - **D-B6**: Drift gate test strategy = mock `subprocess.run`. Pure-function; no live git; no worktree creation. Existing `tests/test_status_brakes.py` uses the same pattern. - **D-B7**: Template edit is the public-facing default. New loops get `base_branch: main` written explicitly in their `loop.json` (operator-visible). ## Files touched - `scripts/status.py` — add `_base_branch(cfg)` helper; use in `_gate_worktree_drift` (line ~2191). - `templates/loops/self-improvement/loop.json` — add `"base_branch": "main"` to `blast_radius`. - `design/loops/technical.md` — note `blast_radius.base_branch` in the schema enum; mention in §7 worktree-drift gate description. - `design/loops/functional.md` — add `base_branch` row to blast_radius fields list. - `CHANGELOG.md` — new entry under `[unreleased]`. - `tests/test_base_branch.py` (NEW) — 13 tests per plan above. ## Out of scope - `--validate-loop` command (cross-references real branches in the repo). Filed to `BACKLOG.md`. - Auto-detect default branch via `git symbolic-ref`. Filed to `BACKLOG.md`. - Multi-base-branch (list of integration branches). Filed to `BACKLOG.md`. ## Pipeline plan research → research:awaiting_approval → research:approved → implement → code_review → code_review:awaiting_approval → code_review:approved → bug_find → adversarial_bug_find → doc_review → referee → complete.