# 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.