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

- 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:
2026-10-06 13:27:39 -04:00
co-authored by Claude Opus 5.5
parent 5f41dbd283
commit 07e21c7ff9
19 changed files with 637 additions and 19 deletions
+99
View File
@@ -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")
+9
View File
@@ -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
+170
View File
@@ -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)) == []
+41
View File
@@ -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
+23 -2
View File
@@ -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
+2
View File
@@ -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"),
]