feat(mcp): enter_project becomes a small primer: goal, recent work, open work, vocabulary (#4045)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 46s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 23s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 46s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 23s
The handshake carried the whole project record, every milestone's plan, full rule text, the notes most recently edited and ~9k of design guidance. For project 2 that was ~222k characters, past what an MCP client accepts as a tool result. Each category was walked through with the operator and sized to what a session needs on arrival; each names the call that has the rest. - project: id, title, status and the full goal (session start's "full goal" pointer still lands here). get_project keeps the whole record. - milestone_summary: the 5 most recently touched milestones, any status, most recent first, without plans. Summaries gain last_touched_at: the later of the milestone's own edit and its newest step update, from the query that already counts steps. milestone_summary_omitted counts the rest and points to list_milestones. get_project and list_milestones list every milestone, also without plans. - open_tasks: the 10 most recently touched open tasks, with or without a milestone, each naming its milestone. list_notes gains sort="touched" (the later of updated_at and the newest work-log), because a log doesn't bump updated_at. - recent_notes: dropped. Retrieval surfaces notes by relevance, and get_recent covers recency. - systems: id and name. - design_system: summary plus guidance_call. get_design_system gains resolved_guidance, the chain-merged prose; its own guidance field is only the departures, so session start's old pointer to it led to a fragment. The session start pointer and using-scribe's "Building UI" section now name resolved_guidance. - rules: rules_payload(brief=True) gives project_rules as id and title plus subscribed_rulebooks, and records only what it shows. Retrieval delivers rules in full and ignores subscriptions (#4052). Other callers unchanged. - pattern_coverage, inception and systems_bootstrap: unchanged. Clients: the plugin's using-scribe skill, the compaction notice and session start are updated here; the REST project summary only gains last_touched_at. Plugin version minted. Tests: a size ceiling on the handshake for a large project; milestone and task selection and naming; brief rules; resolved_guidance; the session start pointer; and a real-Postgres test that a work-log touches its task and a step update touches its milestone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -213,9 +213,13 @@ async def get_project_milestone_summaries(
|
||||
)).scalars().all())
|
||||
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
step_touched: dict[int, datetime] = {}
|
||||
if milestones:
|
||||
rows = await session.execute(
|
||||
select(Note.milestone_id, Note.status, func.count(Note.id))
|
||||
select(
|
||||
Note.milestone_id, Note.status, func.count(Note.id),
|
||||
func.max(Note.updated_at),
|
||||
)
|
||||
.where(
|
||||
Note.milestone_id.in_([m.id for m in milestones]),
|
||||
Note.status.isnot(None),
|
||||
@@ -223,13 +227,21 @@ async def get_project_milestone_summaries(
|
||||
)
|
||||
.group_by(Note.milestone_id, Note.status)
|
||||
)
|
||||
for milestone_id, status, count in rows.fetchall():
|
||||
for milestone_id, status, count, latest in rows.fetchall():
|
||||
counts.setdefault(milestone_id, {})[status] = count
|
||||
if latest and (milestone_id not in step_touched
|
||||
or latest > step_touched[milestone_id]):
|
||||
step_touched[milestone_id] = latest
|
||||
|
||||
out: dict[int, list[dict]] = {pid: [] for pid in project_ids}
|
||||
for m in milestones:
|
||||
entry = m.to_dict()
|
||||
entry.update(_progress_from_counts(counts.get(m.id, {})))
|
||||
# A milestone's own updated_at doesn't move when its steps do, so a
|
||||
# plan whose steps closed today would read as untouched since it was
|
||||
# written (#4045). Touched is the later of the two.
|
||||
touched = [t for t in (m.updated_at, step_touched.get(m.id)) if t]
|
||||
entry["last_touched_at"] = max(touched).isoformat() if touched else None
|
||||
out.setdefault(m.project_id, []).append(entry)
|
||||
return out
|
||||
|
||||
@@ -254,22 +266,20 @@ _BRIEF_FIELDS = (
|
||||
|
||||
|
||||
def brief_milestone_summary(
|
||||
rows: list[dict], *, done_kept: int | None = None,
|
||||
rows: list[dict], *, limit: int | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Trim summary rows to the listing fields, optionally capping done ones.
|
||||
"""Trim summary rows to the listing fields, optionally keeping only the
|
||||
most recently touched.
|
||||
|
||||
`done_kept` keeps only the N most recently updated done milestones (every
|
||||
open one stays), in the original order_index order. A done plan is
|
||||
history, and a handshake that grows with a project's whole history grows
|
||||
past what a client accepts. None keeps every row. Returns (rows, omitted),
|
||||
where `omitted` counts the done milestones left out.
|
||||
With `limit`, keeps the N rows with the latest `last_touched_at`, whatever
|
||||
their status, most recent first: a handshake says what was worked on
|
||||
lately, and "active" alone doesn't (a plan can sit active for months).
|
||||
Without it, every row stays in its original order. Returns (rows, omitted).
|
||||
"""
|
||||
if done_kept is None:
|
||||
kept = rows
|
||||
else:
|
||||
done = [r for r in rows if r.get("status") == "done"]
|
||||
recent = sorted(done, key=lambda r: r.get("updated_at") or "", reverse=True)
|
||||
keep_ids = {r.get("id") for r in recent[:done_kept]}
|
||||
kept = [r for r in rows if r.get("status") != "done" or r.get("id") in keep_ids]
|
||||
kept = rows
|
||||
if limit is not None:
|
||||
kept = sorted(
|
||||
rows, key=lambda r: r.get("last_touched_at") or "", reverse=True,
|
||||
)[:limit]
|
||||
brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept]
|
||||
return brief, len(rows) - len(kept)
|
||||
|
||||
Reference in New Issue
Block a user