Files
FabledScribe/src/scribe/mcp/tools/milestones.py
T
bvandeusenandClaude Opus 5 5c6175ad97
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
feat(placement): a record you only cite carries its status (#4154)
Step 1 made placement cheap for a task whose status CHANGES: create_task
and update_task return where it sits, and the report is written from
that. It did nothing for a task a reply merely cites.

This milestone's own step-6 review reported "#4014 is the open step of
milestone 409". #4014 had been done for four days; the open step was
#4015. The id did not come from a read — it came from a retrieval hint,
which carries an id, a kind and a title and says nothing about status,
while list_milestones said "8 of 9" and would not say which one. The
gap was there to be filled and the nearest-looking id filled it.

Two surfaces, one principle: the status arrives with the id.

1. get_project_milestone_summaries gains next_step — the earliest open
   step, {id, title, status} or None — carried through _BRIEF_FIELDS to
   enter_project, get_project and list_milestones. One extra flat query
   for the whole batch, so #2384's fan-out does not come back.

   OPEN_STEP_STATUSES moves to services/milestones.py and placement.py
   imports it; both surfaces now answer "what is next" and must not
   drift on what counts as open. Both step queries take the same
   readable_notes_clause (rule 78), so a row cannot name a step its own
   progress numbers exclude.

2. _record_kind renders a task's status: [task (done)], [issue (todo)].
   A finished step and an open one read identically before, which is
   exactly the line the misreport was taken from. Only tasks — is_task
   IS status-is-not-None on the model, so there is no fallback branch.

reporting-back gains the practice, owned and registered in the guidance
ownership table: a record you only mention is a record to read.

The guards are structural and each fails on the regression it names:
the query count is asserted rather than the payload shape, and the two
surfaces' agreement is pinned on the rendered ORDER BY, since a mocked
session hands back whatever order the test chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 12:08:00 -04:00

196 lines
7.9 KiB
Python

"""Milestone CRUD MCP tools — thin wrappers over services/milestones.py.
Mirrors existing fable-mcp milestone tool contracts: list/create/update. The
existing surface has no fable_get_milestone or fable_delete_milestone — kept
that way for parity.
Sentinels:
- title="" / description="" / status="" → "leave unchanged" on update
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
- status="active" default on create
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
from scribe.services.record_refs import refuse_guessed_ids
async def list_milestones(project_id: int) -> dict:
"""List milestones for a Scribe project, ordered by order_index.
Returns every milestone, done ones included: id, title, description,
status (active/done), order_index, progress (total, completed, pct,
status_counts) and `next_step`. The plan itself is not listed:
get_milestone(id) returns a milestone's body and its steps.
`next_step` is the earliest step still open — {id, title, status} — or
null when the plan has none left. Use it as it came back. "7 of 9 done"
invites naming the open one from memory, and a remembered id reads exactly
like a read one while being a step that closed days ago.
"""
uid = current_user_id()
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
brief, _ = milestones_svc.brief_milestone_summary(rows)
return {"milestones": brief}
async def get_milestone(milestone_id: int) -> dict:
"""Fetch a milestone (the plan container) with its step-tasks and rules.
A milestone IS a plan: its `body` holds the design/intent, and its steps
are the child tasks listed here. Use this to read a plan top-to-bottom —
the body for the design, `steps` for the trackable units of work. Mirrors
the planning context that start_planning returns (applicable rules), so the
rules surface again on recall.
Returns: milestone (incl. body), progress, steps (its tasks ordered by
status then update), and applicable_rules / project_rules. Each step says
what it is and where it stands, not its whole body — read a step in full
with get_task(id). A plan with forty long steps is otherwise too big to
arrive inline, and the design is in the milestone body.
"""
uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
progress = await milestones_svc.get_milestone_progress(milestone_id)
steps, _ = await notes_svc.list_notes(
uid, is_task=True, milestone_id=milestone_id, sort="status", limit=200,
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=milestone.project_id, user_id=uid,
)
out = milestone.to_dict()
out.update(progress)
return {
"milestone": out,
"steps": [notes_svc.brief_row(t, {milestone.id: milestone.title}) for t in steps],
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_milestone"),
}
async def create_milestone(
project_id: int,
title: str,
description: str = "",
body: str = "",
status: str = "active",
force: bool = False,
) -> dict:
"""Create a milestone within a Scribe project.
A milestone can serve as a plan container — put the design/intent in `body`
and track each step as a child task (create_task(milestone_id=...)). For a
fresh plan, prefer start_planning, which seeds the body template + surfaces
the project's rules.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional one-line summary of what this milestone covers.
body: Optional plan/design (markdown) — the milestone's full plan text.
status: active (default) or done.
force: Bypass the plan gate. By default an active milestone that
already has this title, or reads as the same plan, is returned
(`existing_milestone`) and nothing is created.
"""
uid = current_user_id()
await refuse_guessed_ids(title, description, body)
# A done milestone is a record of past work, not a competing plan, so
# only an active create is gated — the same line find_matching_plan draws.
if status == "active" and not force:
match = await dedup_svc.plan_gate(
uid, project_id, title,
dedup_svc.plan_candidate_text(description=description, body=body),
)
if match is not None:
return match
milestone = await milestones_svc.create_milestone(
uid,
project_id=project_id,
title=title,
description=description or None,
body=body or None,
status=status,
)
return milestone.to_dict()
async def update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
body: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Scribe milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to (preserved for API parity;
ownership scoping is enforced by user_id at the service layer).
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New one-line summary, or omit to leave unchanged.
body: New plan/design (markdown), or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if body:
fields["body"] = body
if status:
fields["status"] = status
if order_index >= 0:
fields["order_index"] = order_index
await refuse_guessed_ids(title, description, body)
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
return milestone.to_dict()
async def delete_milestone(milestone_id: int) -> dict:
"""Move a milestone to the trash (recoverable). Its tasks go with it as one batch.
Restore via restore(batch_id)."""
uid = current_user_id()
# Read the title BEFORE the delete: afterwards the row is trashed and the
# confirmation could only echo the number back. A deletion the operator
# cannot recognise is one they cannot tell was the wrong one.
# Fail-open: the title is a COURTESY on top of the delete, so a lookup
# that errors must not stop the delete happening. Same posture the
# staleness marker takes — a decoration may never break its payload.
try:
doomed = await milestones_svc.get_milestone(uid, milestone_id)
title = getattr(doomed, "title", "") if doomed else ""
except Exception:
title = ""
batch = await trash_svc.delete(uid, "milestone", milestone_id)
if batch is None:
raise ValueError(f"milestone {milestone_id} not found")
return {"deleted": milestone_id, "title": title, "deleted_batch_id": batch,
"message": f'Milestone {milestone_id} ("{title}") and its tasks '
f"moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
for fn in (
list_milestones,
get_milestone,
create_milestone,
update_milestone,
delete_milestone,
):
mcp.tool(name=fn.__name__)(fn)