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
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user