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
+17 -12
View File
@@ -165,8 +165,8 @@ async def test_update_project_raises_when_not_found():
@pytest.mark.asyncio
async def test_enter_project_composes_full_context():
"""enter_project pulls project + rules + milestone summary + open tasks +
recent notes in one composed call."""
"""enter_project pulls project + rule titles + milestone summary + open
tasks in one composed call, each in its brief handshake form (#4045)."""
p = fake_project(id=5, title="P")
applicable_payload = {
"rules": [{"id": 1, "title": "r1", "statement": "s",
@@ -199,14 +199,19 @@ async def test_enter_project_composes_full_context():
):
out = await enter_project(project_id=5)
assert out["project"]["id"] == 5
assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""}
assert out["milestone_summary"] == milestone_summary
assert out["applicable_rules"][0]["title"] == "r1"
assert out["project_rules"][0]["id"] == 99
# Rules arrive in full by retrieval; the handshake lists the project's own
# by id and title and drops the subscription bookkeeping (#4045).
assert out["project_rules"] == [{"id": 99, "title": "pr1"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
assert out["open_tasks"][0]["id"] == 100
assert out["open_tasks"][0]["status"] == "in_progress"
assert out["recent_notes"][0]["id"] == 200
for gone in ("applicable_rules", "applicable_rules_truncated",
"suppressed_rules", "suppressed_topics", "recent_notes"):
assert gone not in out, gone
assert out["open_tasks"] == [{
"id": 100, "title": "T1", "status": "in_progress",
"milestone_id": 10, "milestone_title": "MS",
}]
# No design system on this project -> the key is present and null, not
# absent. A caller that has to distinguish "no key" from "no system" will
# eventually get it wrong.
@@ -252,10 +257,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
):
out = await enter_project(project_id=5)
assert out["systems"] == [
{"id": 3, "name": "retrieval",
"description": "Embeddings, ranking, auto-inject."}
]
assert out["systems"] == [{"id": 3, "name": "retrieval"}]
def _enter_project_stubs(p):
@@ -371,6 +373,9 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
assert out["design_system"]["token_count"] == 95
assert out["design_system"]["inherits_from"] == ["House"]
# Summary only: the guidance is one call away (#4045).
assert "guidance" not in out["design_system"]
assert out["design_system"]["guidance_call"] == "get_design_system(9) → resolved_guidance"
assert ctx.await_args.args == (7, 9) # caller's id, the project's system