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
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:
@@ -48,6 +48,7 @@ async def list_projects() -> dict:
|
|||||||
# characters, past what a client accepts as a tool result (#4045).
|
# characters, past what a client accepts as a tool result (#4045).
|
||||||
_HANDSHAKE_MILESTONES = 5
|
_HANDSHAKE_MILESTONES = 5
|
||||||
_HANDSHAKE_OPEN_TASKS = 10
|
_HANDSHAKE_OPEN_TASKS = 10
|
||||||
|
_HANDSHAKE_UNPLANNED = 10
|
||||||
|
|
||||||
|
|
||||||
async def enter_project(project_id: int) -> dict:
|
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,
|
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
|
||||||
design_system, project_rules, pattern_coverage —
|
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).
|
present only when it applies (see below).
|
||||||
|
|
||||||
`project` is id, title, status and the full goal. get_project has the
|
`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`
|
get_milestone(id) reads a plan and its steps. `milestone_summary_omitted`
|
||||||
says how many others exist; list_milestones lists them all.
|
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,
|
`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
|
with or without a milestone. A work-log counts as touching its task. Each
|
||||||
names its milestone. list_tasks has the rest.
|
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_rows, limit=_HANDSHAKE_MILESTONES,
|
||||||
)
|
)
|
||||||
milestone_titles = {m["id"]: m.get("title") for m in milestone_rows}
|
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(
|
open_tasks, _ = await notes_svc.list_notes(
|
||||||
uid, is_task=True, project_id=project_id,
|
uid, is_task=True, project_id=project_id,
|
||||||
status=["todo", "in_progress"], sort="touched", limit=_HANDSHAKE_OPEN_TASKS,
|
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"{omitted} other milestone(s) not listed. "
|
||||||
f"list_milestones({project_id}) lists every milestone."
|
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:
|
if systems_bootstrap:
|
||||||
out["systems_bootstrap"] = systems_bootstrap
|
out["systems_bootstrap"] = systems_bootstrap
|
||||||
if inception_ask:
|
if inception_ask:
|
||||||
|
|||||||
@@ -283,3 +283,29 @@ def brief_milestone_summary(
|
|||||||
)[:limit]
|
)[:limit]
|
||||||
brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept]
|
brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept]
|
||||||
return brief, len(rows) - len(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)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import pytest
|
|||||||
from scribe.mcp.tools.milestones import list_milestones
|
from scribe.mcp.tools.milestones import list_milestones
|
||||||
from scribe.mcp.tools.projects import enter_project, get_project
|
from scribe.mcp.tools.projects import enter_project, get_project
|
||||||
from scribe.services.milestones import brief_milestone_summary
|
from scribe.services.milestones import brief_milestone_summary
|
||||||
|
from scribe.services.milestones import unplanned_milestones as brief_unplanned
|
||||||
from tests.helpers import fake_project
|
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 out["applicable_rules_truncated"] is True
|
||||||
assert len(surfaced.call_args.kwargs["rule_ids"]) == 81
|
assert len(surfaced.call_args.kwargs["rule_ids"]) == 81
|
||||||
assert len(json.dumps(out)) < 6_000, len(json.dumps(out))
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user