Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,12 @@ This project uses the **mfbt MCP server**, which exposes a virtual filesystem (V

- **Venv:** `.venv/bin/python`, `.venv/bin/pytest`
- **Run tests:** `.venv/bin/pytest tests/unit/ -v`
- **Editable install with uv:** `uv sync && uv pip install -e . && uv pip install -r requirements-dev.txt` — then `.venv/bin/mfbt ...` runs the in-development CLI with edits picked up live. Note: `uv sync` only installs runtime deps from `pyproject.toml` and will *uninstall* anything not declared there, so re-run the `requirements-dev.txt` step after a sync to restore pytest/mypy/ruff/etc.
- **Config functions no longer take `project_root`** — `load_config()`, `save_config()`, `load_auth()`, `save_auth()`, `init_mfbt_dir()`, `resolve_config()` all operate on `~/.mfbt/` via `get_mfbt_dir()`. `TokenManager.__init__` takes only `base_url`.
- **API response formats:** List endpoints (`/features`, `/implementations`) return **paginated** `{"items": [...], "total": N, ...}` — always extract via `body["items"]`. Some endpoints (`/brainstorming-phases`) return plain lists. See `src/mfbt/tui/data_provider.py` for the canonical parsing pattern.
- **TUI navigation:** Projects > Phases > Modules > Features (4 levels). `NavigationState` stack depth: 0=projects, 1=phases, 2=modules, 3=features. Phase list shows brainstorming phases + virtual "Orphan modules" entry.
- **Ralph in TUI:** Integrated into main TUI (`r` key), not a standalone app. Uses `RalphPanel` widget in `#main-content` with `PreflightModal` for agent checks. Standalone `tui_app.py` deleted.
- **Ralph in TUI:** Integrated into main TUI (`r` key), not a standalone app. Ralph runs *in place* inside the unified `FeatureListPanel` (`tui/screens/feature_list.py`) in `#main-content` — there is no separate Ralph panel — with `PreflightModal` for agent checks. Standalone `tui_app.py` and the old `ralph_panel.py` are deleted.
- **Ralph subcommand:** `src/mfbt/commands/ralph/` — orchestrator, display (console), tui_display (Textual adapter), ralph_widgets (TUI widgets), agent runner, progress (API), prompt builder, types.
- **Display duck typing:** `RalphOrchestrator.display` is typed as `Any` — both `RalphDisplay` and `RalphTUIDisplay` are structurally compatible (same 8 methods).
- **Key new files:** `auth_flow.py` (shared OAuth), `coding_agents.py` (agent pre-flight checks), `tui/screens/phase_list.py`, `tui/screens/preflight_modal.py`, `tui/screens/ralph_panel.py`, `commands/ralph/ralph_widgets.py`.
- **Display duck typing:** `RalphOrchestrator.display` is typed as `Any` — both `RalphDisplay` and `RalphTUIDisplay` are structurally compatible (same display protocol).
- **Key files:** `auth_flow.py` (shared OAuth), `coding_agents.py` (agent pre-flight checks), `tui/screens/phase_list.py`, `tui/screens/preflight_modal.py`, `tui/screens/feature_list.py` (unified browse + in-place Ralph panel), `commands/ralph/ralph_widgets.py`.
- **TUI shortcuts:** `r` = Ralph, `ctrl+r` = Refresh, `d` = Describe, `enter` = Open/Detail, `esc` = Back, `?` = Help, `q` = Quit.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,23 @@ pip install -r requirements-dev.txt

# Run tests
pytest tests/ -v
```

### Using uv

If you prefer [uv](https://docs.astral.sh/uv/), do an editable install into the project's `.venv`:

```bash
uv sync # create .venv and install runtime deps from uv.lock
uv pip install -e . # editable install of the package
uv pip install -r requirements-dev.txt # dev tools (pytest, mypy, ruff, etc.)

# Code quality
.venv/bin/mfbt --help # run the in-development CLI
```

### Code quality

```bash
black --check src/ tests/
ruff check src/ tests/
mypy src/
Expand Down
28 changes: 27 additions & 1 deletion src/mfbt/commands/ralph/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ def __init__(
self._client = client
self._display = display
self._logger = logger
self._agent = AgentRunner(config.coding_agent, config.max_turns, config.special_instructions)
self._agent = AgentRunner(
config.coding_agent, config.max_turns, config.special_instructions
)
self._results: list[FeatureResult] = []
self._session_start: float = 0.0
self._stop_event = threading.Event()
Expand Down Expand Up @@ -116,6 +118,30 @@ def run(self) -> SessionSummary:
all_features = build_global_feature_list(
self._client, self._config.project_id, list(phases), all_progress
)

# Restrict to specific modules when requested (single-module TUI
# runs). An empty tuple is treated as "no restriction" (see
# RalphConfig.restricts_modules). The "module_id" key is produced by
# build_global_feature_list (see progress.get_module_feature_list /
# get_all_feature_list); orphan features may carry module_id "".
if self._config.restricts_modules:
module_ids = set(self._config.module_ids or ())
before = len(all_features)
all_features = [
f for f in all_features if f.get("module_id") in module_ids
]
if before > 0 and not all_features:
logger.warning(
"module_ids filter %s matched none of %d features",
sorted(module_ids),
before,
)
self._display.warn(
"No features matched the selected module(s) — nothing "
"to run. The module may have no spec'd features or its "
"id changed."
)

self._display.populate_features(all_features)

# Count actionable features for display
Expand Down
106 changes: 66 additions & 40 deletions src/mfbt/commands/ralph/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,60 @@ def get_all_feature_list(
return result


def get_module_feature_list(
client: APIClient,
project_id: str,
module_id: str,
module_key: str = "",
) -> list[dict[str, str]]:
"""Fetch all features for a single module via a targeted API call.

Returns the same normalized dict format as get_all_feature_list(),
sorted by feature_key ascending. ``phase_id``/``phase_title`` are left
empty (the caller scopes by module, not phase).
"""
if not module_id:
return []

resp = client.get(
f"/api/v1/projects/{project_id}/features",
params={
"module_id": module_id,
"limit": 500,
},
)
body = resp.body
if isinstance(body, dict) and "items" in body:
features = body["items"]
elif isinstance(body, list):
features = body
else:
features = []

result = []
for f in features:
if f.get("feature_type") != "implementation":
continue
result.append(
{
"id": f.get("id", ""),
"feature_key": f.get("feature_key", ""),
"title": f.get("title", ""),
"module_key": module_key,
"module_id": module_id,
"priority": f.get("priority", ""),
"phase_id": "",
"phase_title": "",
"completion_status": f.get("completion_status", "pending"),
"has_spec": f.get("has_spec", False),
"has_prompt_plan": f.get("has_prompt_plan", False),
"has_notes": f.get("has_notes", False),
}
)
result.sort(key=lambda x: x["feature_key"])
return result


def get_orphan_module_features(
client: APIClient,
project_id: str,
Expand All @@ -170,47 +224,16 @@ def get_orphan_module_features(

Returns the same dict format as get_all_feature_list().
"""
result = []
result: list[dict[str, str]] = []
for m in orphan_modules:
mid = m.get("module_id", "")
mkey = m.get("module_key", "")
if not mid:
continue

resp = client.get(
f"/api/v1/projects/{project_id}/features",
params={
"module_id": mid,
"limit": 500,
},
)
body = resp.body
if isinstance(body, dict) and "items" in body:
features = body["items"]
elif isinstance(body, list):
features = body
else:
features = []

for f in features:
if f.get("feature_type") != "implementation":
continue
result.append(
{
"id": f.get("id", ""),
"feature_key": f.get("feature_key", ""),
"title": f.get("title", ""),
"module_key": mkey,
"module_id": mid,
"priority": f.get("priority", ""),
"phase_id": "",
"phase_title": "",
"completion_status": f.get("completion_status", "pending"),
"has_spec": f.get("has_spec", False),
"has_prompt_plan": f.get("has_prompt_plan", False),
"has_notes": f.get("has_notes", False),
}
result.extend(
get_module_feature_list(
client,
project_id,
m.get("module_id", ""),
m.get("module_key", ""),
)
)
result.sort(key=lambda x: x["feature_key"])
return result

Expand Down Expand Up @@ -253,7 +276,10 @@ def build_global_feature_list(
phase_module_ids.add(mid)

orphans = [
{"module_id": m.get("id", ""), "module_key": m.get("module_key", m.get("key", ""))}
{
"module_id": m.get("id", ""),
"module_key": m.get("module_key", m.get("key", "")),
}
for m in all_modules
if not m.get("brainstorming_phase_id")
and m.get("id", "") not in phase_module_ids
Expand Down
Loading
Loading