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
@@ -0,0 +1,81 @@
"""Real-Postgres tests for "recently touched" in the enter_project handshake (#4045).
What a mock cannot show: that a work-log moves its task up the list even
though logging never bumps the task's updated_at, and that a milestone whose
steps changed today reads as touched today even though its own row is old.
"""
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.task_log import TaskLog
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
NOW = datetime.now(timezone.utc)
@pytest_asyncio.fixture
async def world():
"""Two open tasks and two milestones with deliberately staged timestamps."""
async with async_session() as s:
owner = await ensure_user(s, "handshake_owner")
project = Project(user_id=owner.id, title="Handshake target")
s.add(project)
await s.flush()
# Milestone "old plan": its own row is 30 days old, one step changed now.
old_plan = Milestone(user_id=owner.id, project_id=project.id, title="old plan",
updated_at=NOW - timedelta(days=30))
# Milestone "recent edit": its own row changed 2 days ago, no steps.
recent_edit = Milestone(user_id=owner.id, project_id=project.id, title="recent edit",
updated_at=NOW - timedelta(days=2))
s.add_all([old_plan, recent_edit])
await s.flush()
# "logged": last edited 10 days ago, but worked through a log just now.
logged = Note(user_id=owner.id, project_id=project.id, title="logged",
status="todo", task_kind="work",
updated_at=NOW - timedelta(days=10))
# "edited": last edited 1 day ago, no logs.
edited = Note(user_id=owner.id, project_id=project.id, title="edited",
status="todo", task_kind="work",
updated_at=NOW - timedelta(days=1))
step = Note(user_id=owner.id, project_id=project.id, milestone_id=old_plan.id,
title="step", status="done", task_kind="work", updated_at=NOW)
s.add_all([logged, edited, step])
await s.flush()
s.add(TaskLog(task_id=logged.id, user_id=owner.id, content="worked on it",
created_at=NOW))
ids = {"owner": owner.id, "pid": project.id, "logged": logged.id,
"edited": edited.id, "old_plan": old_plan.id, "recent_edit": recent_edit.id}
await s.commit()
return ids
async def test_a_work_log_counts_as_touching_its_task(world):
by_edit, _ = await notes_svc.list_notes(
world["owner"], is_task=True, project_id=world["pid"],
status=["todo", "in_progress"], sort="updated_at",
)
by_touch, _ = await notes_svc.list_notes(
world["owner"], is_task=True, project_id=world["pid"],
status=["todo", "in_progress"], sort="touched",
)
assert [t.id for t in by_edit] == [world["edited"], world["logged"]]
assert [t.id for t in by_touch] == [world["logged"], world["edited"]]
async def test_a_step_changing_counts_as_touching_its_milestone(world):
rows = await milestones_svc.get_project_milestone_summary(world["owner"], world["pid"])
brief, omitted = milestones_svc.brief_milestone_summary(rows, limit=1)
assert omitted == 1
assert [m["id"] for m in brief] == [world["old_plan"]]