From 07e21c7ff9f7b4b5d027c1bef62c2035cd82685f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 13:27:39 -0400 Subject: [PATCH] feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992) - 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 --- plugin/.claude-plugin/plugin.json | 4 +- plugin/hooks/scribe_static_context.md | 2 +- plugin/skills/family-canon/SKILL.md | 93 ++++++++++++++ plugin/skills/reporting-back/SKILL.md | 2 + src/scribe/mcp/server.py | 15 +-- src/scribe/mcp/tools/notes.py | 5 +- src/scribe/mcp/tools/projects.py | 26 +++- src/scribe/mcp/tools/shapes.py | 2 +- src/scribe/mcp/tools/snippets.py | 4 +- src/scribe/mcp/tools/tasks.py | 9 +- src/scribe/services/embeddings.py | 16 +++ src/scribe/services/family_adoption.py | 131 +++++++++++++++++++ src/scribe/services/moment_actions.py | 1 + tests/test_family_delivery.py | 99 ++++++++++++++ tests/test_guidance_ownership.py | 9 ++ tests/test_integration_family_reach.py | 170 +++++++++++++++++++++++++ tests/test_mcp_tool_projects.py | 41 ++++++ tests/test_mcp_tool_report_back_cue.py | 25 +++- tests/test_milestone_summary_brief.py | 2 + 19 files changed, 637 insertions(+), 19 deletions(-) create mode 100644 plugin/skills/family-canon/SKILL.md create mode 100644 tests/test_family_delivery.py create mode 100644 tests/test_integration_family_reach.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index bc96d731..665e6325 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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" }, diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 64550e14..d602efa0 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -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: diff --git a/plugin/skills/family-canon/SKILL.md b/plugin/skills/family-canon/SKILL.md new file mode 100644 index 00000000..ff883481 --- /dev/null +++ b/plugin/skills/family-canon/SKILL.md @@ -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. diff --git a/plugin/skills/reporting-back/SKILL.md b/plugin/skills/reporting-back/SKILL.md index a8766c8e..7dbe9eb2 100644 --- a/plugin/skills/reporting-back/SKILL.md +++ b/plugin/skills/reporting-back/SKILL.md @@ -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. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index f405d829..43d3637f 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -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. diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index 94334c66..45e57d15 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -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) diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index 087feecf..a06e72de 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -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 diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index f2bd4c77..4c8ea4f2 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -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, diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py index 27015be1..a19f6b4c 100644 --- a/src/scribe/mcp/tools/snippets.py +++ b/src/scribe/mcp/tools/snippets.py @@ -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, diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 247b93a1..3b4851e6 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -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, ) diff --git a/src/scribe/services/embeddings.py b/src/scribe/services/embeddings.py index 5be290cb..3b7e4af7 100644 --- a/src/scribe/services/embeddings.py +++ b/src/scribe/services/embeddings.py @@ -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 diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py index 6d3f9103..e4313fb3 100644 --- a/src/scribe/services/family_adoption.py +++ b/src/scribe/services/family_adoption.py @@ -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) diff --git a/src/scribe/services/moment_actions.py b/src/scribe/services/moment_actions.py index 179a450c..58d6e728 100644 --- a/src/scribe/services/moment_actions.py +++ b/src/scribe/services/moment_actions.py @@ -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-` diff --git a/tests/test_family_delivery.py b/tests/test_family_delivery.py new file mode 100644 index 00000000..91faf078 --- /dev/null +++ b/tests/test_family_delivery.py @@ -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") diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py index 897ddf19..7efe543d 100644 --- a/tests/test_guidance_ownership.py +++ b/tests/test_guidance_ownership.py @@ -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 diff --git a/tests/test_integration_family_reach.py b/tests/test_integration_family_reach.py new file mode 100644 index 00000000..b7f0416d --- /dev/null +++ b/tests/test_integration_family_reach.py @@ -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)) == [] diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index e40dd61e..c3bd8634 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -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 diff --git a/tests/test_mcp_tool_report_back_cue.py b/tests/test_mcp_tool_report_back_cue.py index 7d2129c5..f06338e9 100644 --- a/tests/test_mcp_tool_report_back_cue.py +++ b/tests/test_mcp_tool_report_back_cue.py @@ -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 diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index eabdb007..fdf8bffc 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -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"), ]