CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m14s
CI & Build / Build & push image (push) Skipped
The work log reached the web UI through routes/task_logs.py and nothing
else. get_task returned only the body — a claim written once, before the
work — with the record written during it invisible beside it. So a stale
body arrived with nothing to contradict it, and this session rebuilt work
that had already shipped, with the evidence sitting in the task's own logs.
Read side, scoped through the access layer (rule 78):
- logs_for_task / count_logs_for_task / log_counts_for_tasks in
services/task_logs.py. Scoped by who may read the TASK rather than by
who wrote the entry: list_logs filters TaskLog.user_id == user_id,
which hands a shared collaborator an empty list reading as "no work
has been done". The page query folds readable_notes_clause into the
same statement so the permission does not become an N+1.
- get_task returns work_log; list_tasks and get_milestone steps carry
log_count, zero-filled so "none" is a count and not a missing key.
Elision keeps both ends. The newest entry arrives whole to 4000 chars
because it answers "where does this stand"; older ones are shortened from
the MIDDLE, never the head. A head cut selects what a reader sees by
character position, which is uncorrelated with what matters — an entry
closing with "so this shipped in 04775c3" loses the one sentence that
answers the question, and a truncated flag says something went, never
whether it mattered. The gap states how many characters it covers.
conftest gains an autouse stub for the new read arm, same reasoning as
_no_rule_arm: three widely-called tools grew a database read, and the
existing call sites should not each have to learn about it.
Raised while reviewing this: search() has the same shape and worse —
body[:240] with no marker at all, while the chunk that actually matched
sits unused in the row that won. Filed as #4243, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
210 lines
8.4 KiB
Python
210 lines
8.4 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 task_logs as task_logs_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.
|
|
|
|
Each step also carries `log_count`. A step's STATUS is set by hand and a
|
|
plan is exactly where that goes stale; the count says which steps have a
|
|
work log that would say otherwise, so `get_task(id)` goes to the step
|
|
with a record rather than to each one in turn (#4241).
|
|
"""
|
|
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,
|
|
)
|
|
log_counts = await task_logs_svc.log_counts_for_tasks(
|
|
uid, [int(t.id) for t in steps]
|
|
)
|
|
out = milestone.to_dict()
|
|
out.update(progress)
|
|
return {
|
|
"milestone": out,
|
|
"steps": [
|
|
notes_svc.brief_row(
|
|
t, {milestone.id: milestone.title}, log_counts=log_counts
|
|
)
|
|
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)
|