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
+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>`