feat(family): the promotion engine - triggers, the three criteria, the decision log and undo (milestone 463 step 3, #4989)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped

The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces.

- Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent.
- Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names.
- Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held.
- Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion.
- Triggers open evaluations but never promote:
  - a cross-project lineage citation ("matching #N") on a note or task write;
  - a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65);
  - a milestone closing on a platform.
  Each fails open and rides the response as family_hint.
- Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire.
- utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:33:30 -04:00
co-authored by Claude Opus 5.5
parent 1774ee3696
commit 8aacc1824c
21 changed files with 2151 additions and 12 deletions
+6
View File
@@ -163,6 +163,9 @@ _READ_ONLY_TOOLS = frozenset({
# The platform catalog and a project's answers (milestone 463). A pure
# read; set_project_platforms is the write.
"list_platforms",
# Family canon (milestone 463): ideas, one idea with its precedents, and
# the decision log. Reads of records the caller can read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
@@ -187,6 +190,9 @@ _WRITE_TOOLS = frozenset({
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
"set_project_platforms",
# family canon — the promotion engine
"propose_family_idea", "promote_family_idea", "retire_family_idea",
"undo_family_decision",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+2 -1
View File
@@ -5,7 +5,7 @@ to an MCPServer instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
design_systems, family, lessons, milestones, notes, processes, projects, recent, repos,
moments, platforms, retrieval_review, retrieval_tuning,
wide_net,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
@@ -25,6 +25,7 @@ def register_all(mcp) -> None:
milestones.register(mcp)
systems.register(mcp)
platforms.register(mcp)
family.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
+183
View File
@@ -0,0 +1,183 @@
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
Thin wrappers over services/family.py. The agent is the decider here: no
person approves a promotion, so the criteria are stated in these docstrings
and enforced by the service, and every decision is logged with its reason.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import family as family_svc
async def list_family_ideas(
status: str = "", platform: str = "", limit: int = 50, offset: int = 0,
) -> dict:
"""Family ideas — records that every project on a platform shares — with
their state: `candidate` (waiting to be evaluated, or held back by a
criterion), `canon` (promoted; every project on its platforms answers it)
or `retired`.
Args:
status: candidate | canon | retired. Empty = all.
platform: a platform slug (list_platforms). Empty = all.
limit / offset: page through the list.
"""
ideas = await family_svc.list_ideas(
current_user_id(), status=status or None, platform=platform or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"ideas": ideas, "limit": limit, "offset": offset}
async def get_family_idea(note_id: int) -> dict:
"""One family idea with what an evaluation needs: its state, platforms,
full decision history, ledger counts, THE THREE CRITERIA, and the
`precedents` — the decisions on the ideas nearest this one by meaning.
Read the precedents before deciding, and decide consistently with them
unless this idea differs in a way you can name.
Args:
note_id: the idea's record id.
"""
uid = current_user_id()
idea = await family_svc.get_idea(uid, note_id)
if idea is None:
raise ValueError(f"#{note_id} is not a family idea you can read")
idea["precedents"] = await family_svc.precedents(uid, note_id)
return idea
async def propose_family_idea(note_id: int, reason: str, applies_when: str = "") -> dict:
"""Record a note, snippet or lesson as a family-idea CANDIDATE: something
you believe every project on some platform will face. It reaches no
project's ledger until it is promoted.
Proposing is cheap and does not promote. When the record already meets
the three criteria, call promote_family_idea directly instead.
Args:
note_id: the record that carries the idea.
reason: why this looks like a family idea.
applies_when: optional draft of when it applies, in platform terms.
"""
idea, created = await family_svc.propose(
current_user_id(), note_id, reason=reason, applies_when=applies_when or None,
)
return {"idea": idea, "created": created}
async def promote_family_idea(
note_id: int,
applies_when: str,
platforms: list[str],
platform_terms: str,
platform_problem: str,
proven: str,
evidence: list[str],
reason: str,
precedent_ids: list[int] | None = None,
) -> dict:
"""Evaluate a record against the three criteria and, if all hold, promote
it to family canon. YOU decide; nobody approves. The record of why is what
makes the decision reviewable and the next one consistent.
An idea is promoted only when ALL THREE hold, and each is answered with
your reasoning:
1. platform_terms — its 'when it applies' is stated in terms of a
platform (what a project is built on or ships as), not one app's
domain. Any project on the platform could read it and know whether it
applies.
2. platform_problem — it answers a problem the platform itself causes, or
a stance the operator holds across projects. One app's preference does
not qualify.
3. proven — it has worked for real at least once (CI green, verified on a
device, shipped). Name that in `evidence`.
Any criterion left blank — or `applies_when`, `platforms` or `evidence`
left empty — VETOES the promotion on its own. A veto is not an error: the
idea stays a candidate and the veto is logged, so the next evaluation of
something like it sees why this one was held. That is also how you record
"evaluated, and it does not qualify": leave the failing criterion blank
and say why in `reason`.
Before deciding, read get_family_idea's `precedents`. The engine also
finds the nearest earlier decisions itself and stores them as consulted.
On promotion every project that is on one of `platforms` and that you can
write gets an `unassessed` row in its adoption ledger. If the idea was
canon before, its version moves, so earlier answers read as needing a
recheck.
Args:
note_id: the record that carries the idea.
applies_when: when it applies, in platform terms.
platforms: platform slugs it is for (list_platforms).
platform_terms: why criterion 1 holds (blank = it does not).
platform_problem: why criterion 2 holds (blank = it does not).
proven: why criterion 3 holds (blank = it does not).
evidence: what proved it — a CI run, a task, a commit, a device check.
reason: the decision in one or two sentences.
precedent_ids: earlier family decisions you followed, if any.
"""
return await family_svc.promote(
current_user_id(), note_id,
applies_when=applies_when, platforms=platforms or [],
criteria={
"platform_terms": platform_terms,
"platform_problem": platform_problem,
"proven": proven,
},
evidence=evidence or [], reason=reason, precedent_ids=precedent_ids or [],
)
async def retire_family_idea(note_id: int, reason: str) -> dict:
"""Demote a family idea — it no longer applies, or a better one replaces
it. Its history and every judged ledger answer are kept; rows nobody had
judged yet are closed. Undoable with undo_family_decision.
Args:
note_id: the idea.
reason: why it is retired.
"""
return await family_svc.retire(current_user_id(), note_id, reason=reason)
async def undo_family_decision(decision_id: int, reason: str) -> dict:
"""Reverse a family decision — a promotion, a retirement or a proposal —
restoring the state it recorded as `before`. Only the latest idea-level
decision on an idea can be undone; the undo is logged too, so the history
keeps both.
Args:
decision_id: the decision (list_family_decisions).
reason: why it is undone.
"""
return await family_svc.undo(current_user_id(), decision_id, reason=reason)
async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict:
"""The family decision log, newest first: every proposal, promotion,
veto, retirement and undo, with its reason, evidence and the precedents
it followed. `undoable` marks the decision an undo would reverse.
Args:
note_id: only this idea's decisions. 0 = all.
limit / offset: page through the log.
"""
rows = await family_svc.list_decisions(
current_user_id(), idea_id=note_id or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"decisions": rows, "limit": limit, "offset": offset}
def register(mcp) -> None:
for fn in (
list_family_ideas, get_family_idea, propose_family_idea,
promote_family_idea, retire_family_idea, undo_family_decision,
list_family_decisions,
):
mcp.tool(name=fn.__name__)(fn)
+11 -2
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import family as family_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import task_logs as task_logs_svc
@@ -172,12 +173,20 @@ async def update_milestone(
if order_index >= 0:
fields["order_index"] = order_index
await refuse_guessed_ids(title, description, body)
# Asked BEFORE the write: only the transition into done is a closing.
closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id)
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
data = milestone.to_dict()
if closing:
# Family canon's milestone trigger (milestone 463): a plan closing on
# a platform is the moment to ask whether what it built is shared.
hint = await family_svc.milestone_trigger(uid, milestone)
if hint:
data["family_hint"] = hint
return await moment_delivery.attach_moment_rules(
uid, "update_milestone", {"status": status, "project_id": project_id},
milestone.to_dict(),
uid, "update_milestone", {"status": status, "project_id": project_id}, data,
)
+6
View File
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
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 notes as notes_svc
from scribe.services import supersession as supersession_svc
from scribe.services import systems as systems_svc
@@ -237,6 +238,9 @@ async def create_note(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
# Family canon's write-time triggers (milestone 463): a cited source of a
# pattern, or the same idea in another project on a shared platform.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
@@ -319,6 +323,8 @@ async def update_note(
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
)
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
if body:
await family_svc.attach_family_hint(uid, data, note, created=False)
return data
+4
View File
@@ -14,6 +14,7 @@ from scribe.mcp._context import current_user_id
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 snippets as snippets_svc
from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services import systems as systems_svc
@@ -224,6 +225,9 @@ async def create_snippet(
advice = snippets_svc.trigger_advice(when_to_use)
if advice:
data["trigger_advice"] = advice
# Family canon (milestone 463): the same shape recorded in another project
# on a shared platform opens an evaluation of the earlier one.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
+6
View File
@@ -28,6 +28,7 @@ from scribe.mcp._context import current_user_id
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 milestones as milestones_svc
from scribe.services import notes as notes_svc
# Imported by NAME, not reached through notes_svc: minted_kind is pure
@@ -363,6 +364,9 @@ async def create_task(
data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
# A task that says it is "matching" another project's work names the
# source of a pattern — family canon's citation trigger (milestone 463).
await family_svc.attach_family_hint(uid, data, note, created=True)
return await placement_svc.attach_placement(uid, data, note)
@@ -457,6 +461,8 @@ async def update_task(
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
)
await placement_svc.attach_placement(uid, data, note)
if body:
await family_svc.attach_family_hint(uid, data, note, created=False)
if status in _CLOSING_STATUSES:
data["report_back"] = REPORT_BACK_CUE
# The operator's own adjustments to the completion report, retrieved