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
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.05.2219",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.06.1726",
"author": {
"name": "Bryan Van Deusen"
},
+1 -1
View File
@@ -6,7 +6,7 @@ once, in the **`using-scribe`** skill: reach for it at the start of the session
and whenever you are unsure what Scribe expects. Each tool's contract is in its
description, and the process skills (writing-plans, reporting-back,
reusing-code, systematic-debugging, verification, brainstorming,
shape-accounting) carry their arcs.
shape-accounting, family-canon) carry their arcs.
What only Claude Code needs said:
+93
View File
@@ -0,0 +1,93 @@
---
name: family-canon
description: Use when family canon reaches the session — a `family_hint` on a create (a record may be a pattern every project on a platform will need), a `family` key from enter_project (ideas this project has not answered, owes, or must recheck), a `family_owed` list when a task closes, or the operator asks whether a pattern is shared between projects. Triggers on "family", "shared between projects", "promote", "canon idea", "owed", "adopt", "every Android app", "every project on".
metadata:
moments: work.record
---
# Family canon — good ideas spread by criteria, not by approval
Some patterns are not one app's: every project on a platform meets the same
problem (an Android app that ships its own APK, a Go service behind a
reverse proxy). Family canon is how such an idea is recorded once, reaches
every project on that platform, and gets an answer from each. Nobody
approves anything. **You decide, against written criteria, and the decision
log keeps you consistent with the last decision like it.** The tools quote
the criteria, the outcomes and the conflict order in full; this skill is when
to reach for them and what a good answer looks like.
The shape is the point, not identical code: the same idea in Kotlin and in
Go is one idea with two reference implementations.
## When an idea is evaluated for promotion
A `family_hint` on a create_note / create_snippet response means a trigger
opened an evaluation: the record cites another project's record as its
source, or it repeats a record in a project on a shared platform. The source
is now a `candidate`. Evaluate it in the same turn, while you know why you
wrote what you wrote:
1. `get_family_idea(id)` — the candidate, its decisions, and the precedents
(decisions on the ideas nearest it).
2. Judge the three criteria from `promote_family_idea`'s description. Each
needs support you can name; **one criterion with no support vetoes it**.
3. Promote it, or leave it a candidate with the reason. A held candidate is
precedent too, so the reason is the record — never skip writing it.
A milestone closing on a platform project hints without recording anything:
ask whether what it built is something every project on the platform will
face, and if so write the note that carries the idea.
## Answering an idea in a project
enter_project's `family` key counts what this project owes the family:
`unassessed`, `owed`, `needs_recheck`, each with the call that lists them.
Answer an idea **when your work reaches its area**, not as a chore list at
session start — the count is there so it is never invisible, not so it
preempts the operator's task.
- `get_family_adoption(project_id, idea_id)` first: the idea, the project's
current answer, the precedents, and the reference implementations with the
project's languages.
- Judge the outcomes **in the order `assess_family_adoption` lists them**,
stopping at the first that holds. Every outcome needs a reason; `adopted`
needs evidence naming where.
- **When the code is in the shape ledger, answer there instead:**
`classify_shapes` the shape with `idea_id=` as an `instance` or a
`variant`, and the adoption answer moves with it. An answer the shapes
contradict is refused — fix the shapes, not the answer.
- `owed` files a task in THAT project and stops. Nothing here edits another
repository; the project's own session picks the task up.
### What counts as a reason
A reason names a **fact about this project**: "the app is installed by a
managed store, never sideloaded", "the service has no user accounts". A
preference, a taste, or "we already did it another way" is not a reason —
that is `owed`, or, if this project's way is better rather than different, a
conflict. Write the reason so a session in another project can tell whether
the same fact holds there; that is how it becomes precedent.
### The precedent reflex
Before answering, read what was answered before: the same idea in other
projects, and this project's answers to the nearest ideas. Answer
consistently unless this project differs in a way you can name — and then
the difference IS the reason. The engine records the precedents it found
whether or not you cite them, so an inconsistent answer is visible later.
## When two projects disagree
Two projects solving one idea differently, each sure of its way, is a
conflict — settle it with `resolve_family_conflict`, which states the order.
The first ground that applies wins, and **every ground above it must be said
not to apply** — you cannot skip to the one you like. The losing side's
reasoning is folded into the idea's note (as a trap, an alternative, or the
branch for its condition) and never dropped; the version moves, so every
other project's answer reads as needing a recheck.
## Reporting it
A `family_owed` list on a closing task is work you filed into other projects
while it was open: name each one in the report, by project and idea — it is
waiting there now.
+2
View File
@@ -207,6 +207,8 @@ Notes on each section:
the task alone is enough.
- **What now works** — outcomes the operator would notice: "You can now…",
"X no longer…". The files and steps behind them belong in the task's log.
A `family_owed` list on the closing response is work you filed into other
projects: name each by project and idea (the family-canon skill).
- **How / why** — only the decisions worth knowing, plus **how it was
verified**. If something could not be verified, say what and why here rather
than letting it read as passed.
+7 -8
View File
@@ -43,13 +43,12 @@ from quart import Quart
# decision #4027 and the notes it supersedes.
_INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting,
record as you go, keep one copy here, not in local memory files. Each
practice is stated in full in the using-scribe skill and each tool's
description.
record as you go, keep one copy here, not in local memory. Each
practice is stated in full in a skill or a tool's description.
- Start with enter_project(id): the project, open work, Systems and design
system. An `inception` key: ask what it inherits, then
decide_project_inception.
- Start with enter_project(id): the project, open work, Systems, design
system. `inception`: ask what it inherits, then decide_project_inception.
`family`: shared ideas to answer (family-canon skill).
- Rules are not preloaded; one arrives when your work matches it or reaches
a moment it is mounted on (list_moments; mount rules about WHEN). Before a
consequential act, what_might_apply("what you are about to do");
@@ -65,8 +64,8 @@ description.
(search(content_type="milestone")) before start_planning.
- IDs exist only once a create returns them; records citing each other go
through create_records, writing {{ref:N}} for the Nth.
- In UI work the project's design system binds: resolve_design_system before
hand-writing a value.
- In UI work the design system binds: resolve_design_system before
writing a value.
Creates are duplicate-gated: a near-match returns the existing id to update.
shared:true records are another user's suggestion, not settled practice.
+4 -1
View File
@@ -204,7 +204,10 @@ async def create_note(
"existing_id": ..., "message": ...} and nothing is created. A tagged
record shows its `systems`; created untagged in a project, the response
carries the `systems_hint` question instead — answer it: tag the record,
create the missing System, or deliberately leave it untagged.
create the missing System, or deliberately leave it untagged. A
`family_hint` means the note may carry an idea every project on a shared
platform will need, and an evaluation was opened — the family-canon skill
says how to judge it.
"""
uid = current_user_id()
await refuse_guessed_ids(title, body)
+24 -2
View File
@@ -16,10 +16,13 @@ keeps working.
"""
from __future__ import annotations
import logging
from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import coverage as coverage_svc
from scribe.services import design_systems as design_systems_svc
from scribe.services import family_adoption as family_adoption_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
@@ -31,6 +34,8 @@ from scribe.services import trash as trash_svc
from scribe.services.background import spawn
from scribe.services.note_usage import record_surfaced
logger = logging.getLogger(__name__)
async def list_projects() -> dict:
"""List all Scribe projects for the current user.
@@ -70,8 +75,8 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, pattern_coverage —
plus unplanned_milestones, milestone_summary_omitted,
unplanned_milestones_omitted, inception and systems_bootstrap, each
present only when it applies (see below).
unplanned_milestones_omitted, family, inception and systems_bootstrap,
each present only when it applies (see below).
`project` is id, title, status and the full goal. get_project has the
whole record.
@@ -132,6 +137,14 @@ async def enter_project(project_id: int) -> dict:
family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms.
`family` (milestone 463) appears ONLY when this project has family canon
to answer: how many canon ideas on its platforms are `unassessed`, how
many it `owed`s, and how many answers were given against an older canon
version (`needs_recheck`) — each with the list_family_adoptions call that
lists them. Answer an unassessed idea when your work reaches its area
(get_family_adoption, then assess_family_adoption, or classify the code
against it); the family-canon skill has the order.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(design system, Systems, platforms), what to ask the
@@ -299,6 +312,15 @@ async def enter_project(project_id: int) -> dict:
)
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
# Family canon (milestone 463 step 6): counts only, attached when one is
# non-zero. A readout must never fail the handshake it rides on.
try:
family = await family_adoption_svc.family_readout(uid, project_id)
except Exception:
logger.warning("family readout failed for project %s", project_id, exc_info=True)
family = None
if family:
out["family"] = family
if inception_ask:
out["inception"] = inception_ask
return out
+1 -1
View File
@@ -71,7 +71,7 @@ async def classify_shapes(
withdrawing the shapes that gave an answer returns it to `unassessed`.
The adoption ledger is moved for you — `family` in the result lists the
answers that moved — and assess_family_adoption refuses an answer the
shapes contradict.
shapes contradict. The family-canon skill has when to answer an idea.
All-or-nothing: a structural error, a missing snippet target, an unbound
repo, or no write access applies NOTHING. Returns {"classified": N,
+3 -1
View File
@@ -178,7 +178,9 @@ async def create_snippet(
rather than forcing a second copy with force=true. A tagged record shows
its `systems`; created untagged in a project, the response carries the
`systems_hint` question instead — answer it: tag, create the missing
System, or deliberately skip.
System, or deliberately skip. A `family_hint` means the shape may be one
every project on a shared platform will need, and an evaluation was
opened — the family-canon skill says how to judge it.
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
same repo · path · symbol, or holding byte-identical code. Those are certain,
+8 -1
View File
@@ -29,6 +29,7 @@ from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import family as family_svc
from scribe.services import family_adoption as family_adoption_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
# Imported by NAME, not reached through notes_svc: minted_kind is pure
@@ -422,7 +423,10 @@ async def update_task(
by their `when_to_apply`, so a preference whose trigger is writing the
report after finishing a task is the one that arrives here. Where one
differs from the default shape, the preference is what the operator
asked for.
asked for. When family adoptions were answered `owed` while the task was
open, they come back as `family_owed` ({idea_id, idea_title, project_id,
project_title, owed_task_id}): name each in the report — it is work
filed into that project.
"""
uid = current_user_id()
fields: dict = {}
@@ -473,6 +477,9 @@ async def update_task(
if prefs:
data["reply_preferences"] = prefs
data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE
# Owed family adoptions filed while the task was open (milestone 463
# step 6) — work now waiting in some project, which the report names.
await family_adoption_svc.attach_owed_adoptions(uid, data, note)
return await moment_delivery.attach_moment_rules(
uid, "update_task", {"status": status, "project_id": project_id}, data,
)
+16
View File
@@ -1047,6 +1047,22 @@ async def semantic_search_notes(
in_project = or_(
in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES)
)
# Family canon (milestone 463 step 6) crosses the project
# line the same way a lesson does, but only to a project
# on one of the idea's platforms: the canon ideas reaching
# it, and their references in its languages. Membership
# is decided here, readability by the visibility clause
# above. Read on its own session, so a failure narrows
# the search and cannot abort this one's transaction.
try:
from scribe.services.family_adoption import family_reach_ids
reach = await family_reach_ids(project_id)
except Exception:
logger.debug("family reach unavailable", exc_info=True)
reach = set()
if reach:
in_project = or_(in_project, Note.id.in_(sorted(reach)))
stmt = stmt.where(in_project)
# Narrow to records tagged to one System (subsystem/area). An
# association filter, not a ranking signal — membership in the
+131
View File
@@ -1214,3 +1214,134 @@ async def _shapes_follow(user_id: int, project_id: int, idea_id: int, outcome: s
for r in rows
], via="agent")
return int(result.get("classified") or 0)
# --- delivery (milestone 463 step 6) -------------------------------------------------
#
# The ledger reaches a session three ways: a count on entering a project, the
# ideas themselves by retrieval, and the owed answers filed while a task was
# open, handed back when it closes.
def family_line(project_id: int, cells: list[dict]) -> dict | None:
"""The `family` line enter_project carries: what this project has not
answered, owes, or answered against an older canon version. None when
all three are zero — the key is attached only when it asks something.
Shown on EVERY entry (the step settled this), not only when a session
touches a platform: entering is when the agent chooses what to work on,
an owed task already appears in open_tasks, and an unanswered idea has
nowhere else to be seen. Counts only, each with the call that lists it."""
unassessed = sum(1 for c in cells if c["in_scope"] and c["status"] == "unassessed")
owed = sum(1 for c in cells if c["status"] == "owed")
recheck = sum(1 for c in cells if c["needs_recheck"])
if not (unassessed or owed or recheck):
return None
parts, calls = [], {}
if unassessed:
parts.append(f"{unassessed} unassessed")
calls["unassessed"] = f'list_family_adoptions(project_id={project_id}, status="unassessed")'
if owed:
parts.append(f"{owed} owed")
calls["owed"] = f'list_family_adoptions(project_id={project_id}, status="owed")'
if recheck:
parts.append(f"{recheck} to recheck")
calls["needs_recheck"] = f"list_family_adoptions(project_id={project_id}, needs_recheck=true)"
return {
"line": "family canon on this project's platforms: " + " · ".join(parts),
"unassessed": unassessed, "owed": owed, "needs_recheck": recheck,
"calls": calls,
"answer_with": ("get_family_adoption(project_id, idea_id) reads one with its "
"precedents; assess_family_adoption answers it — or classify_shapes "
"the code against it (idea_id=…). The family-canon skill has the order."),
}
async def family_readout(user_id: int, project_id: int) -> dict | None:
matrix = await adoption_matrix(user_id, project_id=project_id)
return family_line(project_id, matrix["cells"])
async def family_reach_ids(project_id: int, session=None) -> set[int]:
"""The family records a project's retrieval reaches beyond its own: every
canon idea on a platform the project is a member of, and each idea's
reference implementations in the project's languages — all of them when
none is in those languages, so a cross-language idea still brings its
reference. Readability is the search's own scope, not decided here."""
if session is None:
async with async_session() as own:
return await family_reach_ids(project_id, own)
ideas = set((await session.execute(
select(FamilyIdea.note_id)
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.note_id == FamilyIdea.note_id)
.join(ProjectPlatform, ProjectPlatform.platform_id == FamilyIdeaPlatform.platform_id)
.where(
FamilyIdea.status == "canon",
ProjectPlatform.project_id == project_id,
ProjectPlatform.state.in_(family_svc.MEMBER_STATES),
)
)).scalars().all())
if not ideas:
return set()
langs = set(await _project_languages(session, project_id))
refs: dict[int, list[tuple[int, str]]] = {}
for iid, nid, lang in (await session.execute(
select(FamilyIdeaReference.idea_id, Note.id, func.lower(Note.data["language"].astext))
.join(Note, Note.id == FamilyIdeaReference.snippet_id)
.where(FamilyIdeaReference.idea_id.in_(ideas), Note.deleted_at.is_(None))
)).all():
refs.setdefault(iid, []).append((nid, lang or ""))
reach = set(ideas)
for found in refs.values():
local = [nid for nid, lang in found if lang in langs]
reach.update(local or [nid for nid, _ in found])
return reach
async def owed_since(user_id: int, since) -> list[dict]:
"""The owed answers this user recorded since ``since`` that are still
owed — work filed into a project (often another one) that the report
closing this task should name."""
if since is None:
return []
async with async_session() as session:
rows = (await session.execute(
select(FamilyAdoption, Note.title, Project.title)
.join(FamilyDecision, (FamilyDecision.idea_id == FamilyAdoption.idea_id)
& (FamilyDecision.project_id == FamilyAdoption.project_id))
.join(Note, Note.id == FamilyAdoption.idea_id)
.join(Project, Project.id == FamilyAdoption.project_id)
.where(
FamilyDecision.user_id == user_id,
FamilyDecision.created_at >= since,
FamilyDecision.after["status"].astext == "owed",
FamilyAdoption.status == "owed",
)
.distinct()
.order_by(Project.title, Note.title)
)).all()
return [
{"idea_id": row.idea_id, "idea_title": idea_title,
"project_id": row.project_id, "project_title": project_title,
"owed_task_id": row.owed_task_id}
for row, idea_title, project_title in rows
if await access.can_read_project(user_id, row.project_id)
]
OWED_CUE = ("Owed family adoptions were filed while this task was open (`family_owed`) — "
"name each in the report: it is work now waiting in that project.")
async def attach_owed_adoptions(user_id: int, data: dict, note) -> None:
"""Ride the owed answers filed since the task started on its closing
response — fail-open: a decoration never breaks the close it rides on."""
try:
owed = await owed_since(user_id, getattr(note, "started_at", None)
or getattr(note, "created_at", None))
if owed:
data["family_owed"] = owed
if "report_back" in data:
data["report_back"] = f"{data['report_back']} {OWED_CUE}"
except Exception:
logger.warning("owed adoptions unreadable for task %s", getattr(note, "id", None),
exc_info=True)
+1
View File
@@ -80,6 +80,7 @@ BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
"verification": ("work.verify",),
"shape-accounting": ("work.record",),
"reporting-back": ("reply.report",),
"family-canon": ("work.record",),
}
# A stored process arrives as the skill `scribe-proc-<slug>`
+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"),
]