diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index 02d4384..5477670 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -48,6 +48,7 @@ async def list_projects() -> dict: # characters, past what a client accepts as a tool result (#4045). _HANDSHAKE_MILESTONES = 5 _HANDSHAKE_OPEN_TASKS = 10 +_HANDSHAKE_UNPLANNED = 10 async def enter_project(project_id: int) -> dict: @@ -67,7 +68,8 @@ async def enter_project(project_id: int) -> dict: Returns a dict with keys: project, milestone_summary, open_tasks, systems, design_system, project_rules, pattern_coverage — - plus milestone_summary_omitted, inception and systems_bootstrap, each + plus unplanned_milestones, milestone_summary_omitted, + unplanned_milestones_omitted, inception and systems_bootstrap, each present only when it applies (see below). `project` is id, title, status and the full goal. get_project has the @@ -79,6 +81,15 @@ async def enter_project(project_id: int) -> dict: get_milestone(id) reads a plan and its steps. `milestone_summary_omitted` says how many others exist; list_milestones lists them all. + `unplanned_milestones` is the active milestones that have NO steps yet, + in roadmap order (up to 10; `unplanned_milestones_omitted` counts the + rest) — id, title and description. They are open work: a plan somebody + wrote down and nobody has broken into steps. A milestone with no steps is + never touched, so the recent list above can never show one. Before + starting a new milestone for work, check whether one of these already + describes it; if so, add steps to it with create_records(milestone_id=…) + rather than opening a second plan for the same thing. + `open_tasks` is the 10 most recently touched todo / in-progress tasks, with or without a milestone. A work-log counts as touching its task. Each names its milestone. list_tasks has the rest. @@ -156,6 +167,11 @@ async def enter_project(project_id: int) -> dict: milestone_rows, limit=_HANDSHAKE_MILESTONES, ) milestone_titles = {m["id"]: m.get("title") for m in milestone_rows} + unplanned, unplanned_omitted = milestones_svc.unplanned_milestones( + milestone_rows, + exclude_ids={m["id"] for m in milestone_summary}, + limit=_HANDSHAKE_UNPLANNED, + ) open_tasks, _ = await notes_svc.list_notes( uid, is_task=True, project_id=project_id, status=["todo", "in_progress"], sort="touched", limit=_HANDSHAKE_OPEN_TASKS, @@ -256,6 +272,13 @@ async def enter_project(project_id: int) -> dict: f"{omitted} other milestone(s) not listed. " f"list_milestones({project_id}) lists every milestone." ) + if unplanned: + out["unplanned_milestones"] = unplanned + if unplanned_omitted: + out["unplanned_milestones_omitted"] = ( + f"{unplanned_omitted} more active milestone(s) with no steps. " + f"list_milestones({project_id}) lists every milestone." + ) if systems_bootstrap: out["systems_bootstrap"] = systems_bootstrap if inception_ask: diff --git a/src/scribe/services/milestones.py b/src/scribe/services/milestones.py index c9229b2..06ba93d 100644 --- a/src/scribe/services/milestones.py +++ b/src/scribe/services/milestones.py @@ -283,3 +283,29 @@ def brief_milestone_summary( )[:limit] brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept] return brief, len(rows) - len(kept) + + +def unplanned_milestones( + rows: list[dict], *, exclude_ids: set[int] = frozenset(), limit: int | None = None, +) -> tuple[list[dict], int]: + """Active milestones with no steps yet, as (rows, omitted). + + A plan written as a milestone with a description and no steps is open work + that nothing else names. It is never "touched" — touching is a step + changing — so the recency list that brief_milestone_summary(limit=) builds + can never reach it, and progress reads 0% either way. A project whose + roadmap was written that way ended up with every later plan opened as a + new milestone beside the one that already described it (milestone 415). + + `exclude_ids` drops milestones a caller already listed. Kept in roadmap + order (order_index, then creation), the order they were written in. + Rows are id, title and description: what a reader needs to recognise the + plan, and not its body, which get_milestone reads. + """ + found = [ + {"id": r["id"], "title": r.get("title"), "description": r.get("description")} + for r in rows + if r.get("status") == "active" and not r.get("total") and r["id"] not in exclude_ids + ] + kept = found if limit is None else found[:limit] + return kept, len(found) - len(kept) diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index 63309bf..50fc698 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -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")