feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 1m53s
CI & Build / Python tests (push) Successful in 2m39s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 1m53s
CI & Build / Python tests (push) Successful in 2m39s
CI & Build / Build & push image (push) Skipped
- enter_project carries a `family` key, but only when the project has something to answer: counts of unassessed, owed and to-recheck answers, each with the list_family_adoptions call that lists it. It shows on every entry, never by platform touch: entry is when work is chosen, and an unanswered idea is otherwise invisible. - Retrieval: a widened project search (include_global_kinds) now also reaches the canon ideas on the project's platforms. It also reaches their references in the project's languages, or all of them when none matches. An off-platform project gets none, and the plain project filter (the duplicate gate) is unchanged. - Closing a task returns `family_owed`, the owed answers filed while it was open, and the report cue asks for them to be named. - New plugin skill family-canon (moment work.record) covers when to evaluate a promotion, answering in order, what counts as a reason, the precedent reflex and the conflict order. _INSTRUCTIONS, create_note, create_snippet, classify_shapes and reporting-back point at it. The plugin version is minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""How family canon reaches a session (milestone 463 step 6), without a
|
||||
database: the line enter_project carries, the skill and its triggers, and the
|
||||
pointers every other surface gives to it. Retrieval reach, the readout's
|
||||
counts and the owed answers against Postgres are in
|
||||
tests/test_integration_family_reach.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from scribe.services import moment_actions
|
||||
from scribe.services.family_adoption import family_line
|
||||
from tests.helpers import skill_text, tool_doc
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _cell(status="unassessed", *, in_scope=True, needs_recheck=False):
|
||||
return {"status": status, "in_scope": in_scope, "needs_recheck": needs_recheck}
|
||||
|
||||
|
||||
# --- the enter_project line ----------------------------------------------------------
|
||||
|
||||
def test_nothing_to_answer_is_no_line():
|
||||
assert family_line(5, []) is None
|
||||
assert family_line(5, [_cell("adopted"), _cell("exempt")]) is None
|
||||
|
||||
|
||||
def test_the_line_counts_each_ask_and_names_the_call_that_lists_it():
|
||||
line = family_line(5, [
|
||||
_cell(), _cell(), _cell("owed"), _cell("adopted", needs_recheck=True),
|
||||
])
|
||||
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (2, 1, 1)
|
||||
assert line["line"].endswith("2 unassessed · 1 owed · 1 to recheck")
|
||||
assert line["calls"] == {
|
||||
"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")',
|
||||
"owed": 'list_family_adoptions(project_id=5, status="owed")',
|
||||
"needs_recheck": "list_family_adoptions(project_id=5, needs_recheck=true)",
|
||||
}
|
||||
assert "family-canon" in line["answer_with"]
|
||||
|
||||
|
||||
def test_a_count_of_zero_names_no_call():
|
||||
line = family_line(5, [_cell("owed")])
|
||||
assert set(line["calls"]) == {"owed"} and "unassessed" not in line["line"]
|
||||
|
||||
|
||||
def test_an_answer_kept_as_history_off_the_platform_is_not_asked_again():
|
||||
"""A row whose project left the idea's platforms stays as history; an
|
||||
unassessed one there is not something this project is asked for."""
|
||||
assert family_line(5, [_cell(in_scope=False)]) is None
|
||||
|
||||
|
||||
# --- the skill ----------------------------------------------------------------------
|
||||
|
||||
def _frontmatter(name: str) -> str:
|
||||
text = (ROOT / "plugin" / "skills" / name / "SKILL.md").read_text()
|
||||
return re.match(r"\A---\n(.*?)\n---\n", text, re.S).group(1)
|
||||
|
||||
|
||||
def test_the_family_canon_skill_ships_and_triggers_on_what_the_server_sends():
|
||||
"""Its description is what makes a session load it, so it names every
|
||||
key the server hands back about family canon."""
|
||||
front = _frontmatter("family-canon")
|
||||
assert "name: family-canon" in front
|
||||
for trigger in ("family_hint", "`family`", "family_owed", "enter_project"):
|
||||
assert trigger in front, trigger
|
||||
assert moment_actions.BUNDLED_SKILL_MOMENTS["family-canon"] == ("work.record",)
|
||||
|
||||
|
||||
def test_the_skill_carries_the_reflexes_and_defers_the_lists_to_the_tools():
|
||||
text = " ".join(skill_text("family-canon").split())
|
||||
for phrase in ("one criterion with no support vetoes it",
|
||||
"in the order `assess_family_adoption` lists them",
|
||||
"every ground above it must be said not to apply",
|
||||
"What counts as a reason", "The precedent reflex"):
|
||||
assert phrase in text, phrase
|
||||
|
||||
|
||||
def test_the_plugin_names_the_skill_it_ships():
|
||||
manifest = (ROOT / "plugin" / ".claude-plugin" / "plugin.json").read_text()
|
||||
static = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
||||
assert "family-canon" in manifest and "family-canon" in " ".join(static.split())
|
||||
|
||||
|
||||
# --- the pointers ---------------------------------------------------------------------
|
||||
|
||||
def test_every_surface_that_meets_family_canon_points_at_the_skill():
|
||||
server = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
|
||||
index = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S).group(1)
|
||||
assert "family-canon" in index and "`family`" in index
|
||||
for module, tool in (("notes", "create_note"), ("snippets", "create_snippet"),
|
||||
("shapes", "classify_shapes")):
|
||||
assert "family-canon" in tool_doc(f"scribe.mcp.tools.{module}", tool), tool
|
||||
assert "family_hint" in tool_doc("scribe.mcp.tools.notes", "create_note")
|
||||
assert "`family`" in tool_doc("scribe.mcp.tools.projects", "enter_project")
|
||||
assert "family_owed" in tool_doc("scribe.mcp.tools.tasks", "update_task")
|
||||
assert "family_owed" in skill_text("reporting-back")
|
||||
@@ -270,6 +270,15 @@ TOPICS: tuple[Topic, ...] = (
|
||||
Topic("a record you only cite still gets read",
|
||||
"skill:reporting-back", ("next_step", "only mention"),
|
||||
"a record you only mention is a record to read"),
|
||||
# Milestone 463 step 6: family canon — when an idea is evaluated, how a
|
||||
# project answers one, what a reason is, and the conflict order's reflex.
|
||||
# The criteria, outcomes and grounds themselves are product text on the
|
||||
# tools that enforce them; the skill owns when and how.
|
||||
Topic("family canon: evaluate on a hint, answer in order, a reason is a fact",
|
||||
"skill:family-canon",
|
||||
("family_hint", "get_family_adoption", "resolve_family_conflict", "precedent"),
|
||||
"write the reason so a session in another project can tell whether the same fact holds there",
|
||||
index=("family-canon",)),
|
||||
# ── per-tool contracts and in-band behaviour — owned by the server ──
|
||||
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
|
||||
# The agent is the judge (#4208). Two topics, not one, because they fire
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""A family idea reaches every project on its platform, and no other
|
||||
(milestone 463 step 6).
|
||||
|
||||
WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning (#3730) holds
|
||||
here too: the reach is one `OR` inside the search's project filter, and only
|
||||
the ROWS that come back prove it. Every note embeds identically to the query,
|
||||
so scoping is the only thing that can separate them. The embedder is stubbed;
|
||||
no similarity is asserted, only membership.
|
||||
|
||||
The corpus: an idea written in android one, canon for the Android platform,
|
||||
with a Kotlin and a Python reference. Android two writes Python, so it should
|
||||
reach the idea and the Python reference only. The Go service is on no shared
|
||||
platform and should reach none of it.
|
||||
"""
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
|
||||
from scribe.models.family import (
|
||||
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
|
||||
Platform, ProjectPlatform,
|
||||
)
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import family_adoption as adoption_svc
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1)
|
||||
|
||||
|
||||
async def _platform(s, slug: str) -> int:
|
||||
return await s.scalar(select(Platform.id).where(
|
||||
Platform.slug == slug, Platform.deleted_at.is_(None)))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def corpus():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, f"family_reach_owner_{tag}")
|
||||
await s.flush()
|
||||
android, go = await _platform(s, "android-app"), await _platform(s, "go")
|
||||
a = Project(user_id=owner.id, title="android one")
|
||||
b = Project(user_id=owner.id, title="android two")
|
||||
c = Project(user_id=owner.id, title="go service")
|
||||
s.add_all([a, b, c])
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
|
||||
ProjectPlatform(project_id=b.id, platform_id=android, state="declared"),
|
||||
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
||||
])
|
||||
|
||||
def snippet(project, title, language):
|
||||
return Note(user_id=owner.id, project_id=project.id, note_type="snippet",
|
||||
title=title, body=title, data={"language": language})
|
||||
|
||||
rows = {
|
||||
"idea": Note(user_id=owner.id, project_id=a.id, note_type="note",
|
||||
title="Signed APK lane", body="one keystore, in-place update"),
|
||||
"kotlin_ref": snippet(a, "installUpdate (kotlin)", "kotlin"),
|
||||
"python_ref": snippet(a, "install_update (python)", "python"),
|
||||
"note_on_a": Note(user_id=owner.id, project_id=a.id, note_type="note",
|
||||
title="An ordinary note", body="ordinary"),
|
||||
"b_own": snippet(b, "b's own python helper", "python"),
|
||||
}
|
||||
s.add_all(rows.values())
|
||||
await s.flush()
|
||||
s.add(FamilyIdea(note_id=rows["idea"].id, status="canon",
|
||||
applies_when="any Android app that ships its own APK"))
|
||||
await s.flush()
|
||||
s.add_all([
|
||||
FamilyIdeaPlatform(note_id=rows["idea"].id, platform_id=android),
|
||||
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["kotlin_ref"].id),
|
||||
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["python_ref"].id),
|
||||
])
|
||||
for note in rows.values():
|
||||
s.add(NoteEmbedding(
|
||||
note_id=note.id, chunk_index=0, user_id=owner.id,
|
||||
embedding=QUERY_VEC, chunk_text=note.title,
|
||||
chunker_version=CHUNKER_VERSION, embedding_model=EMBEDDING_MODEL,
|
||||
))
|
||||
ids = {k: n.id for k, n in rows.items()}
|
||||
ids.update(owner=owner.id, a=a.id, b=b.id, c=c.id)
|
||||
await s.commit()
|
||||
return ids
|
||||
|
||||
|
||||
async def _search(uid, **kw):
|
||||
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)):
|
||||
hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw)
|
||||
return {note.id for _score, note in hits}
|
||||
|
||||
|
||||
async def test_an_idea_reaches_a_project_on_its_platform_with_the_reference_in_its_language(corpus):
|
||||
found = await _search(corpus["owner"], project_id=corpus["b"], include_global_kinds=True)
|
||||
assert {corpus["idea"], corpus["python_ref"], corpus["b_own"]} <= found
|
||||
# Android two writes Python: the Kotlin reference stays with the idea.
|
||||
assert corpus["kotlin_ref"] not in found
|
||||
assert corpus["note_on_a"] not in found
|
||||
|
||||
|
||||
async def test_an_idea_does_not_reach_a_project_on_no_shared_platform(corpus):
|
||||
found = await _search(corpus["owner"], project_id=corpus["c"], include_global_kinds=True)
|
||||
assert not found & {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
|
||||
|
||||
|
||||
async def test_the_reach_is_off_unless_the_search_widens_the_project(corpus):
|
||||
"""The near-duplicate gate searches a project without widening it; a
|
||||
family idea there would block a create on a project it was never
|
||||
written in."""
|
||||
found = await _search(corpus["owner"], project_id=corpus["b"])
|
||||
assert found == {corpus["b_own"]}
|
||||
|
||||
|
||||
async def test_with_no_reference_in_the_projects_language_every_reference_comes(corpus):
|
||||
async with async_session() as s:
|
||||
b_own = await s.get(Note, corpus["b_own"])
|
||||
b_own.data = {"language": "dart"}
|
||||
await s.commit()
|
||||
reach = await adoption_svc.family_reach_ids(corpus["b"])
|
||||
assert reach == {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
|
||||
|
||||
|
||||
async def test_a_retired_idea_reaches_nobody(corpus):
|
||||
async with async_session() as s:
|
||||
(await s.get(FamilyIdea, corpus["idea"])).status = "retired"
|
||||
await s.commit()
|
||||
assert await adoption_svc.family_reach_ids(corpus["b"]) == set()
|
||||
|
||||
|
||||
# --- the entry readout and the owed answers a close hands back -------------------
|
||||
|
||||
async def test_the_entry_readout_counts_what_the_project_has_to_answer(corpus):
|
||||
line = await adoption_svc.family_readout(corpus["owner"], corpus["b"])
|
||||
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (1, 0, 0)
|
||||
assert line["calls"]["unassessed"] == (
|
||||
f'list_family_adoptions(project_id={corpus["b"]}, status="unassessed")')
|
||||
# Nothing reaches the Go service, so it carries no line at all.
|
||||
assert await adoption_svc.family_readout(corpus["owner"], corpus["c"]) is None
|
||||
|
||||
|
||||
async def test_owed_since_lists_what_is_still_owed_and_only_since_then(corpus):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
async with async_session() as s:
|
||||
s.add(FamilyAdoption(project_id=corpus["b"], idea_id=corpus["idea"], status="owed",
|
||||
reason="no in-place update yet", canon_version=1,
|
||||
decided_via="agent"))
|
||||
s.add(FamilyDecision(idea_id=corpus["idea"], project_id=corpus["b"], action="assess",
|
||||
reason="no in-place update yet",
|
||||
before={"status": "unassessed", "reason": "", "canon_version": None},
|
||||
after={"status": "owed", "reason": "no in-place update yet",
|
||||
"canon_version": 1},
|
||||
evidence={}, precedent_ids=[], decided_via="agent",
|
||||
user_id=corpus["owner"]))
|
||||
await s.commit()
|
||||
now = datetime.now(timezone.utc)
|
||||
[owed] = await adoption_svc.owed_since(corpus["owner"], now - timedelta(minutes=5))
|
||||
assert (owed["idea_id"], owed["project_id"], owed["project_title"]) == (
|
||||
corpus["idea"], corpus["b"], "android two")
|
||||
assert await adoption_svc.owed_since(corpus["owner"], now + timedelta(minutes=5)) == []
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for fable_*_project tools."""
|
||||
import contextlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -48,6 +49,16 @@ def _no_coverage():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_family():
|
||||
"""enter_project reads the family-canon readout (milestone 463 step 6) —
|
||||
no database here, so the common case is stubbed: nothing to answer. The
|
||||
populated key is asserted in its own test below."""
|
||||
with patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
|
||||
AsyncMock(return_value=None)) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_background_seed():
|
||||
"""enter_project now fire-and-forgets a coverage self-seed (#2802). These
|
||||
@@ -483,3 +494,33 @@ def test_inception_routes_and_tool_are_registered():
|
||||
# Rules left inception with subscriptions (milestone 414).
|
||||
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_carries_the_family_line_only_when_it_asks_something(_no_family):
|
||||
"""The `family` key is attached when the project has family canon to
|
||||
answer, and absent otherwise — a key that usually says null trains
|
||||
readers to skip it (#2483)."""
|
||||
line = {"line": "family canon on this project's platforms: 2 unassessed",
|
||||
"unassessed": 2, "owed": 0, "needs_recheck": 0,
|
||||
"calls": {"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")'}}
|
||||
|
||||
async def enter():
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _enter_project_stubs(fake_project(id=5)):
|
||||
stack.enter_context(cm)
|
||||
return await enter_project(project_id=5)
|
||||
|
||||
assert "family" not in await enter()
|
||||
_no_family.return_value = line
|
||||
assert (await enter())["family"] == line
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_family_readout_never_fails_the_handshake(_no_family):
|
||||
_no_family.side_effect = RuntimeError("database down")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _enter_project_stubs(fake_project(id=5)):
|
||||
stack.enter_context(cm)
|
||||
out = await enter_project(project_id=5)
|
||||
assert "family" not in out and out["project"]["id"] == 5
|
||||
|
||||
@@ -16,15 +16,17 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
|
||||
_PREF = {"id": 41, "title": "Say how it was checked", "statement": "…", "kind": "preference"}
|
||||
|
||||
|
||||
async def _update(prefs=None, **kwargs):
|
||||
async def _update(prefs=None, owed=None, **kwargs):
|
||||
from scribe.mcp.tools.tasks import update_task
|
||||
|
||||
note = MagicMock(id=5, user_id=7, project_id=3)
|
||||
note = MagicMock(id=5, user_id=7, project_id=3, started_at="2026-10-06T09:00:00+00:00")
|
||||
note.to_dict.return_value = {"id": 5}
|
||||
lookup = AsyncMock(return_value=list(prefs or []))
|
||||
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \
|
||||
patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \
|
||||
patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \
|
||||
patch("scribe.mcp.tools.tasks.family_adoption_svc.owed_since",
|
||||
AsyncMock(return_value=list(owed or []))), \
|
||||
patch("scribe.mcp.tools.tasks.reply_prefs_svc.completion_preferences", lookup):
|
||||
return await update_task(task_id=5, **kwargs), lookup
|
||||
|
||||
@@ -56,3 +58,22 @@ async def test_no_preferences_means_no_key_and_the_plain_cue():
|
||||
out, _ = await _update(prefs=[], status="done")
|
||||
assert "reply_preferences" not in out
|
||||
assert out["report_back"] == REPORT_BACK_CUE
|
||||
|
||||
|
||||
_OWED = {"idea_id": 9, "idea_title": "Signed APK lane", "project_id": 4,
|
||||
"project_title": "android two", "owed_task_id": 77}
|
||||
|
||||
|
||||
async def test_closing_names_the_owed_adoptions_filed_while_the_task_was_open():
|
||||
"""The finishing moment of milestone 463 step 6: owed family work filed
|
||||
into a project during this task comes back for the report to name."""
|
||||
from scribe.services.family_adoption import OWED_CUE
|
||||
|
||||
out, _ = await _update(owed=[_OWED], status="done")
|
||||
assert out["family_owed"] == [_OWED]
|
||||
assert out["report_back"].endswith(OWED_CUE) and "family_owed" in out["report_back"]
|
||||
|
||||
|
||||
async def test_no_owed_adoptions_means_no_key():
|
||||
out, _ = await _update(owed=[], status="done")
|
||||
assert "family_owed" not in out
|
||||
|
||||
@@ -117,6 +117,8 @@ def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, sy
|
||||
AsyncMock(return_value=design)),
|
||||
patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage",
|
||||
AsyncMock(return_value=None)),
|
||||
patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
|
||||
AsyncMock(return_value=None)),
|
||||
patch("scribe.mcp.tools.projects.spawn"),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user