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
+84 -83
View File
@@ -42,41 +42,22 @@ async def list_projects() -> dict:
return {"projects": [p.to_dict() for p in rows]}
# Done milestones a project read still lists: the recent ones say what just
# finished, and older ones are a list_milestones call away (#4045).
_DONE_MILESTONES_KEPT = 5
async def _milestone_block(uid: int, project_id: int) -> dict:
"""`milestone_summary` for a project read, brief and bounded (#4045).
Every open milestone plus the most recent done ones, without plan bodies.
`milestone_summary_omitted` is attached only when older done milestones
were left out, and names the calls that reach them (#2483).
"""
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
brief, omitted = milestones_svc.brief_milestone_summary(
rows, done_kept=_DONE_MILESTONES_KEPT,
)
out: dict = {"milestone_summary": brief}
if omitted:
out["milestone_summary_omitted"] = (
f"{omitted} older done milestone(s) not listed. "
f"list_milestones({project_id}) lists every milestone; "
"get_milestone(id) has one milestone's plan and steps."
)
return out
# How many of each kind the handshake lists. Enough to say what was worked on
# lately and what's open; the rest is a list_milestones / list_tasks call away.
# The handshake once carried every milestone's plan and came to ~222k
# characters, past what a client accepts as a tool result (#4045).
_HANDSHAKE_MILESTONES = 5
_HANDSHAKE_OPEN_TASKS = 10
async def enter_project(project_id: int) -> dict:
"""Session-start handshake: load full context for working on a project.
"""Session-start handshake: a primer on the project before you work in it.
Call this FIRST whenever you're about to do project-scoped work
(start_planning, create_task, update_*, anything that takes a project_id).
One round-trip returns the project, its applicable rules (both rulebook-
subscribed and project-scoped), milestone progress, open tasks, and
recently-updated notes — everything you need to know the lay of the land
before mutating.
One round-trip returns what the project is for, what was worked on
lately, what's open, and the vocabulary to record against. It is kept
small on purpose: each part names the call that has the rest.
No persistent server state: this is a read snapshot. Re-call if the
session goes idle long enough that the data feels stale.
@@ -84,15 +65,29 @@ async def enter_project(project_id: int) -> dict:
Args:
project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes, design_system, systems, pattern_coverage —
plus systems_bootstrap, present only when it applies (see below).
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, subscribed_rulebooks, pattern_coverage —
plus milestone_summary_omitted, inception and systems_bootstrap, each
present only when it applies (see below).
`milestone_summary` lists every open milestone and the most recently
finished done ones, each with its description and progress but NOT its
plan: get_milestone(id) reads a plan. `milestone_summary_omitted` appears
only when older done milestones were left out, and says how many.
`project` is id, title, status and the full goal. get_project has the
whole record.
`milestone_summary` is the 5 most recently touched milestones, any status,
most recent first. Touched counts a step changing, not only the milestone
itself. Each carries its description and progress but NOT its plan:
get_milestone(id) reads a plan and its steps. `milestone_summary_omitted`
says how many others exist; list_milestones lists them all.
`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
names its milestone. list_tasks has the rest.
`project_rules` lists the project's own rules by id and title, and
`subscribed_rulebooks` the rulebooks it draws on. A rule reaches you in
full when your work matches it; get_rule(id) reads one, and
search(content_type="rule") asks whether one covers what you are about
to do.
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
@@ -110,7 +105,7 @@ async def enter_project(project_id: int) -> dict:
updating a record, ask which of these areas it is about and pass their ids
as `system_ids`. If the area a record describes is missing from this list,
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records.
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
@@ -125,10 +120,12 @@ async def enter_project(project_id: int) -> dict:
lists. It stops appearing the moment the first System exists.
`design_system` is null unless the project points at one. When present it
carries the chain-merged guidance (the house style AND this project's
departures from it) plus a summary of the token set — treat it as binding
for any UI you write, and pull the values with resolve_design_system or
get_design_system_stylesheet before reaching for a literal.
is a summary (title, what it inherits, token count and groups) and
`guidance_call`. The guidance binds any UI you write the way a rule does:
before writing UI, read `resolved_guidance` from get_design_system (the
house style AND this project's departures from it), and pull values with
resolve_design_system or get_design_system_stylesheet before reaching for
a literal.
Entering a project also SCOPES the session: reference and offer work on
this project only, and pass its id to search / list_* so results stay
@@ -151,19 +148,22 @@ async def enter_project(project_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
milestones = await _milestone_block(uid, project_id)
milestone_rows = await milestones_svc.get_project_milestone_summary(
uid, project_id,
)
milestone_summary, omitted = milestones_svc.brief_milestone_summary(
milestone_rows, limit=_HANDSHAKE_MILESTONES,
)
milestone_titles = {m["id"]: m.get("title") for m in milestone_rows}
open_tasks, _ = await notes_svc.list_notes(
uid, is_task=True, project_id=project_id,
status=["todo", "in_progress"], sort="updated_at", limit=10,
)
recent_notes, _ = await notes_svc.list_notes(
uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5,
status=["todo", "in_progress"], sort="touched", limit=_HANDSHAKE_OPEN_TASKS,
)
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
# records to Systems" is only executable if the list is in front of the
# agent when it writes — which it never was, and tagging stopped within
# three days of the feature landing (#2546's audit).
# three days of the feature landing (#2546's audit). Untagged writes now
# also ask with the vocabulary listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
# The arrival-moment half of the bootstrap ask (#2683): session start is
@@ -185,24 +185,30 @@ async def enter_project(project_id: int) -> dict:
if project.user_id == uid and not inception_svc.is_decided(project):
inception_ask = await inception_svc.inception_ask(uid, project_id)
# Probably the largest surfacing by volume, and it emitted nothing — so
# the pulls it caused floated unattributed and the surfaced:pulled ratio
# ran against a denominator missing its biggest contributor (#2477). An
# AMBIENT source: these are top-N-by-recency, not a ranked choice, and the
# readout counts them apart so dead-weight detection isn't poisoned by
# An AMBIENT source (#2477): top-N-by-recency, not a ranked choice, and
# the readout counts it apart so dead-weight detection isn't poisoned by
# "recently updated in a project you opened".
record_surfaced(
user_id=uid,
note_ids=[int(t.id) for t in open_tasks] + [int(n.id) for n in recent_notes],
note_ids=[int(t.id) for t in open_tasks],
source="enter_project",
)
# A project need not have one, and most installs won't — null is ordinary
# here, not a missing prerequisite.
# here, not a missing prerequisite. Summary only: the guidance is ~9k of
# prose most sessions never use, so it's one call away (#4045).
design_system = None
if project.design_system_id:
design_system = await design_systems_svc.design_context(
design = await design_systems_svc.design_context(
uid, project.design_system_id,
)
if design:
design_system = {
k: design[k]
for k in ("id", "title", "inherits_from", "token_count", "token_groups")
}
design_system["guidance_call"] = (
f"get_design_system({design['id']}) → resolved_guidance"
)
# Cache read ONLY — computing coverage moves a repo tarball and never
# belongs in this request path. Null is the ordinary state (no forge, or
@@ -223,38 +229,33 @@ async def enter_project(project_id: int) -> dict:
)
out = {
"project": project.to_dict(),
"project": {
"id": project.id, "title": project.title,
"status": project.status, "goal": project.goal,
},
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
# Trimmed to what tagging needs. The full charter is get_system's job —
# this list rides along on every session start, so it stays lean.
"systems": [
{
"id": s.id, "name": s.name,
"description": (s.description or "").split("\n")[0][:200],
}
for s in systems
],
"systems": [{"id": s.id, "name": s.name} for s in systems],
"design_system": design_system,
**milestones,
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"),
"milestone_summary": milestone_summary,
**rulebooks_svc.rules_payload(
applicable, user_id=uid, source="enter_project", brief=True,
),
"open_tasks": [
{
"id": t.id, "title": t.title, "status": t.status,
"priority": t.priority, "task_kind": t.task_kind,
"milestone_id": t.milestone_id,
"milestone_title": milestone_titles.get(t.milestone_id),
}
for t in open_tasks
],
"recent_notes": [
{
"id": n.id, "title": n.title,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in recent_notes
],
}
# Attached only when it applies — a key that usually says null trains
# readers to skip it (#2483), and this one exists to be acted on.
if omitted:
out["milestone_summary_omitted"] = (
f"{omitted} other milestone(s) not listed. "
f"list_milestones({project_id}) lists every milestone."
)
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
if inception_ask:
@@ -265,18 +266,18 @@ async def enter_project(project_id: int) -> dict:
async def get_project(project_id: int) -> dict:
"""Fetch a Scribe project by ID.
Returns full project fields, a milestone_summary list (shaped as in
enter_project: open milestones and the recent done ones, no plan bodies,
with milestone_summary_omitted when older done ones were left out), and
the rulebook-applicable_rules / subscribed_rulebooks pair the assistant
should consult when working on this project.
Returns full project fields, a milestone_summary list (every milestone,
with description and progress but no plan body; get_milestone reads a
plan), and the rulebook-applicable_rules / subscribed_rulebooks pair the
assistant should consult when working on this project.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
data = project.to_dict()
data.update(await _milestone_block(uid, project_id))
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)