feat(mcp): enter_project names active milestones with no steps as open work (#4076)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / integration (push) Canceled after 42s
CI & Build / TypeScript typecheck (push) Canceled after 42s
CI & Build / Python tests (push) Canceled after 47s

A plan written as a milestone with a description and no steps was invisible to
the session handshake: it lists the 5 most recently touched milestones (#4045),
and touching is a step changing, so a step-less milestone can never qualify.
FabledLibrarian's roadmap (nine such milestones) sat unseen while later plans
were opened as new milestones beside the ones that already described them.

enter_project adds `unplanned_milestones`: active milestones with no steps, in
roadmap order, id/title/description, up to 10 with an omitted count, none
repeated from the recent list, and absent when there are none. The docstring
says what they are for: check them before starting a new milestone, and add
steps to a match with create_records(milestone_id=...).

Milestone 415 step 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 13:28:05 -04:00
co-authored by Claude Opus 5
parent ac01eee040
commit 184a3e026d
3 changed files with 105 additions and 1 deletions
+55
View File
@@ -14,6 +14,7 @@ import pytest
from scribe.mcp.tools.milestones import list_milestones
from scribe.mcp.tools.projects import enter_project, get_project
from scribe.services.milestones import brief_milestone_summary
from scribe.services.milestones import unplanned_milestones as brief_unplanned
from tests.helpers import fake_project
@@ -218,3 +219,57 @@ def test_a_planning_read_lists_rules_without_restating_them():
assert out["applicable_rules_truncated"] is True
assert len(surfaced.call_args.kwargs["rule_ids"]) == 81
assert len(json.dumps(out)) < 6_000, len(json.dumps(out))
# ── Milestones with no steps are open work (milestone 415) ─────────────────
def _planless(mid: int, status: str = "active") -> dict:
"""A roadmap milestone: a description and no steps, never touched since."""
row = _milestone(mid, status, touched_day=1)
row.update(total=0, completed=0, pct=0.0,
status_counts={"todo": 0, "in_progress": 0, "done": 0, "cancelled": 0})
return row
def test_unplanned_lists_active_milestones_with_no_steps_in_roadmap_order():
rows = [_planless(3), _milestone(4, "active", 9), _planless(5, "done"), _planless(6)]
kept, omitted = brief_unplanned(rows)
assert [r["id"] for r in kept] == [3, 6] # not the one with steps, not the done one
assert kept[0] == {"id": 3, "title": "M3", "description": "what M3 is for"}
assert omitted == 0
def test_unplanned_respects_exclusions_and_the_cap():
rows = [_planless(i) for i in range(15)]
kept, omitted = brief_unplanned(rows, exclude_ids={0, 1}, limit=10)
assert [r["id"] for r in kept] == list(range(2, 12))
assert omitted == 3
@pytest.mark.asyncio
async def test_enter_project_names_a_roadmap_the_recent_list_cannot_reach():
"""The FabledLibrarian shape: plans written as step-less milestones sat
beside newer milestones that did their work, and the handshake — five most
recently touched — could never show them."""
rows = _history(8) + [_planless(100), _planless(101), _planless(102, "done")]
out, _ = await _enter(*_enter_stubs(fake_project(id=5), rows, []))
assert [m["id"] for m in out["unplanned_milestones"]] == [100, 101]
listed = {m["id"] for m in out["milestone_summary"]}
assert not listed & {m["id"] for m in out["unplanned_milestones"]}
assert "unplanned_milestones_omitted" not in out
@pytest.mark.asyncio
async def test_no_unplanned_key_when_every_milestone_has_steps():
out, _ = await _enter(*_enter_stubs(fake_project(id=5), _history(4), []))
assert "unplanned_milestones" not in out
@pytest.mark.asyncio
async def test_a_long_roadmap_does_not_rebuild_the_payload():
rows = _history(5) + [_planless(1000 + i) for i in range(40)]
out, _ = await _enter(*_enter_stubs(fake_project(id=5), rows, []))
assert len(out["unplanned_milestones"]) == 10
assert out["unplanned_milestones_omitted"].startswith("30 more")