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

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:
2026-09-14 22:04:38 -04:00
co-authored by Claude Opus 5
parent 9b2de3552f
commit 7f974d9749
17 changed files with 445 additions and 182 deletions
+26 -16
View File
@@ -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)
+16
View File
@@ -318,6 +318,9 @@ async def list_notes(
degraded but never empty. Superseded records are not demoted here: the
penalty reorders a top-k, and reordering a paginated, counted list would
make page boundaries lie. The search surfaces carry the demotion.
`sort` is a Note column name, or "touched": the later of updated_at and
the record's newest work-log.
"""
from scribe.services.access import notes_visibility_clause
@@ -442,6 +445,19 @@ async def list_notes(
if semantic_order is not None:
# A query is a relevance claim — see the docstring.
query = query.order_by(semantic_order)
elif sort == "touched":
# Last touched: the later of the task's own edit and its newest
# work-log. Logging doesn't bump updated_at, so a task worked
# through its logs would otherwise sort as untouched (#4045).
# GREATEST skips a NULL, so a task with no logs sorts by its edit.
from scribe.models.task_log import TaskLog
last_log = (
select(func.max(TaskLog.created_at))
.where(TaskLog.task_id == Note.id)
.scalar_subquery()
)
touched = func.greatest(Note.updated_at, last_log)
query = query.order_by(touched.asc() if order == "asc" else touched.desc())
else:
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
+3 -2
View File
@@ -2236,8 +2236,9 @@ async def build_session_context(
f"Values: `resolve_design_system({design['id']})` · "
f"stylesheet: `get_design_system_stylesheet({design['id']})` "
f"· the prose (aesthetic, voice, where the accent may "
f"appear): `enter_project` returns it, or "
f"`get_design_system({design['id']})`.",
f"appear), inherited house style included: "
f"`get_design_system({design['id']})`"
f"`resolved_guidance`.",
]
elif unbound_repo:
lines += [
+21 -1
View File
@@ -1306,7 +1306,9 @@ async def get_applicable_rules(
}
def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict:
def rules_payload(
applicable: dict, *, user_id: int | None, source: str, brief: bool = False,
) -> dict:
"""The caller-facing shape of a get_applicable_rules() result.
Every surface that hands rules to an agent (enter_project, get_project,
@@ -1333,7 +1335,25 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
(`plugin_context`) — computed a marker and showed nobody
anything, and counting those would put rules in the denominator that no
agent ever saw.
`brief` is the session handshake's form (#4045): the project's own rules
as id and title, and the subscribed rulebooks, nothing else. Rules reach a
session in full by retrieval, which ignores subscriptions, so the handshake
lists which constraints exist rather than restating them; get_rule reads
one. Only what is shown is recorded as surfaced.
"""
if brief:
project_rules = [
{"id": r["id"], "title": r["title"]}
for r in applicable.get("project_rules", [])
]
record_rule_surfaced(
user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source,
)
return {
"project_rules": project_rules,
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
}
record_rule_surfaced(
user_id=user_id,
rule_ids=(