Files
FabledGrimoire/src/scribe/mcp/tools/family.py
T
bvandeusenandClaude Opus 5.5 17777e9bb1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 39s
docs(6204): shipped instruction text describes the product, not this instance's records
MCP tool docstrings and comments, the server's orientation notes, the plugin
skills, every hook script and the plugin README/PACKAGING drop record ids,
milestone numbers, dates and incident stories, and state each non-obvious why
as a present-tense fact. History stays in the task logs.

Corrections found on the way:
- plugin README/PACKAGING said every hook is a GET; moment, rule-opened and
  reply-rules POST, so the plugin's key needs write scope. Both now say so,
  name Settings -> MCP Access, and list every wired hook and endpoint.
- the contract-change hint printed "(lesson #4207)", this instance's record,
  to every install. It no longer cites a record.
- tasks.py claimed there is no delete_task; there is.
- rulebooks.py cited delete_event and a design spec that no longer exist.
- scribe_after_write.sh carried an always-empty etag_q; removed.
- test_write_path_trigger pinned "| tac | scribe_defs | head -1", text that
  existed only in a comment about the old code; it now pins the real tail -1
  pipeline.
- docs/development.md: CI runs on Gitea Actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:45:09 -04:00

404 lines
17 KiB
Python

"""Family canon MCP tools — the promotion engine's agent door.
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
from scribe.services import family_adoption as adoption_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,
decision history (brief — each reason once; list_family_decisions has
the full rows), ledger counts, THE THREE CRITERIA, the `ledger`
(every project's answer, with `needs_recheck`), 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)
idea["ledger"] = await adoption_svc.list_adoptions(uid, idea_id=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 revision, a retirement, a
proposal, or one project's assessment — restoring the state it recorded
as `before`. Only the latest decision on an idea (or on one project's
answer to it) can be undone; the undo is logged too, so the history
keeps both. Undoing an owed answer closes its task; undoing back to owed
reopens it.
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, project_id: int = 0, limit: int = 50, offset: int = 0,
) -> dict:
"""The family decision log, newest first: every proposal, promotion,
veto, revision, retirement, assessment and undo, with its reason,
evidence and the precedents it followed. `undoable` marks the decision an
undo would reverse — per idea, and per project's answer.
Args:
note_id: only this idea's decisions. 0 = all.
project_id: only this project's assessments. 0 = all.
limit / offset: page through the log.
"""
rows = await family_svc.list_decisions(
current_user_id(), idea_id=note_id or None, project_id=project_id or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"decisions": rows, "limit": limit, "offset": offset}
async def revise_family_idea(
note_id: int, reason: str, applies_when: str = "", platforms: list[str] | None = None,
evidence: list[str] | None = None,
) -> dict:
"""Record that a canon idea's SUBSTANCE changed — you rewrote its note's
approach, its traps or its checklist, or its applicability or platforms
moved. The canon version moves, so every project's answer given against
the old version reads `needs_recheck` until it is assessed again.
A typo fix is not a revision. A change a project that adopted the old
version would need to act on is.
Args:
note_id: the canon idea.
reason: what changed, in a sentence.
applies_when: a new 'when it applies'. Empty = keep the current one.
platforms: new platform slugs. Omit = keep the current ones.
evidence: what prompted the change, if anything.
"""
return await family_svc.revise(
current_user_id(), note_id, reason=reason,
applies_when=applies_when or None, platforms=platforms, evidence=evidence,
)
async def get_family_adoption(project_id: int, idea_id: int) -> dict:
"""Everything one assessment needs: the idea (its 'when it applies',
platforms and version), this project's current answer, THE FOUR
OUTCOMES, the `precedents` — this idea's answers in other projects and
this project's answers to the nearest ideas — and the reference
implementations beside this project's languages. Read it before
assess_family_adoption.
Args:
project_id: the project answering.
idea_id: the canon idea.
"""
return await adoption_svc.get_adoption(current_user_id(), project_id, idea_id)
async def list_family_adoptions(
project_id: int = 0, idea_id: int = 0, status: str = "", needs_recheck: bool = False,
platform: str = "",
) -> dict:
"""The adoption ledger: each project's answer to each canon idea that
reaches it — `unassessed`, `adopted`, `variant`, `exempt` or `owed`, with
its reason, the canon version it was given against, `needs_recheck` when
the idea has moved on since, and the owed task.
Args:
project_id: one project's answers. 0 = all you can read.
idea_id: one idea's answers. 0 = all canon.
status: one outcome. Empty = all.
needs_recheck: only answers given against an older canon version.
platform: only ideas for this platform slug.
"""
rows = await adoption_svc.list_adoptions(
current_user_id(), project_id=project_id or None, idea_id=idea_id or None,
status=status or None, recheck_only=needs_recheck, platform=platform or None,
)
return {"adoptions": rows}
async def assess_family_adoption(
project_id: int,
idea_id: int,
outcome: str,
reason: str,
evidence: list[str] | None = None,
precedent_ids: list[int] | None = None,
) -> dict:
"""Answer one canon family idea for one project. YOU decide; nobody
approves. Judge in this order and stop at the first that holds:
1. exempt — the idea's 'when it applies' is false for this project.
`reason` names the fact about the project that makes it false.
2. variant — it applies, and the project departs for a reason that names
a FACT about itself the canon did not account for. 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 (resolve_family_conflict).
3. adopted — it applies and the project does it. `evidence` names where
(a file, a commit, a task, a CI run).
4. owed — none of the above. A task is filed in THIS project naming the
gap and the reference implementation for its language, filed under
the project's System matching the idea's area (retag it with
update_task if that guess is wrong). Nothing edits another
repository; the project picks the task up itself.
Read get_family_adoption first: answer consistently with its precedents
unless this project differs in a way you can name in `reason`. The
engine also records the precedents it found itself.
The owed task follows the answer: adopted closes it as done, exempt or
variant cancels it, owed again reopens it. The same answer given twice
records nothing the second time.
When the project's code is in the shape ledger, prefer answering THERE:
classify_shapes the shape against the idea (idea_id=…) as an instance or
a variant, and this answer moves with it. An answer the shapes already
give otherwise is refused — the two ledgers never disagree; reclassify
the shapes if they are wrong.
Args:
project_id: the project answering.
idea_id: the canon idea.
outcome: exempt | variant | adopted | owed.
reason: why — required for every outcome.
evidence: where it is done (required for adopted), or what you checked.
precedent_ids: earlier family decisions you followed, if any.
"""
return await adoption_svc.assess(
current_user_id(), project_id, idea_id, outcome=outcome, reason=reason,
evidence=evidence, precedent_ids=precedent_ids,
)
async def resolve_family_conflict(
idea_id: int,
canon_project_id: int,
other_project_id: int,
ground: str,
fold: str,
reason: str,
grounds_checked: dict | None = None,
evidence: list[str] | None = None,
conditions: dict | None = None,
precedent_ids: list[int] | None = None,
) -> dict:
"""Settle two projects that solve the same canon idea differently, each
for reasons it believes. YOU decide, by THE CONFLICT ORDER — the first
ground that applies wins, and for every ground above it you say in
`grounds_checked` why it did not decide:
1. operator_stance — one side follows a stance the operator stated (a
rule, a preference, a recorded decision). Name it in `evidence`.
2. covers_failure — one side covers a recorded failure (an incident, an
issue, a lesson) the other does not. Name it in `evidence`. The other
side becomes owed.
3. split_by_condition — both are right under different conditions. The
canon splits: `conditions={"canon": …, "other": …}`, and each side is
canon where its condition holds.
4. most_recent_complete — none of the above: the side verified most
recently and covering the most wins. Name the verification.
`canon_project_id` is the side whose approach the idea's note states
after this. If the note says something else now, rewrite it first
(update_note) — the note is the canon.
The losing side's reasoning, `fold`, is appended to the idea's note as a
trap (ground 2), an alternative (1, 4) or the branch for its condition
(3). It is never dropped. The version moves, so every other project's
answer reads `needs_recheck`. The canon side is answered adopted; the
other owed (with a task in its project) or, in a split, adopted.
Args:
idea_id: the canon idea.
canon_project_id: the side whose approach is canon after this.
other_project_id: the side that loses, or the split's other branch.
ground: operator_stance | covers_failure | split_by_condition | most_recent_complete.
fold: the other side's reasoning, as it should read in the note.
reason: the decision in one or two sentences.
grounds_checked: {earlier ground: why it did not decide}.
evidence: the stance, the failure or the verification.
conditions: for a split, {"canon": condition, "other": condition}.
precedent_ids: earlier family decisions you followed, if any.
"""
return await adoption_svc.resolve_conflict(
current_user_id(), idea_id, canon_project_id=canon_project_id,
other_project_id=other_project_id, ground=ground, grounds_checked=grounds_checked,
fold=fold, evidence=evidence, reason=reason, conditions=conditions,
precedent_ids=precedent_ids,
)
async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
"""Set a family idea's reference implementations — the snippets an owed
task points a project at, ideally one per language. Replaces the list.
The idea is what transfers; a reference is where to start, not code to
copy.
Args:
note_id: the family idea.
snippet_ids: snippets implementing it. [] clears the list.
"""
refs = await adoption_svc.set_references(current_user_id(), note_id, snippet_ids or [])
return {"references": refs}
async def set_family_topic(note_id: int, topic_id: int) -> dict:
"""Link a family idea to the rule topic that holds its binding norms — the
rules every project on its platforms follows. The note says when the idea
applies and why; the topic's rules say what must hold. One idea per topic.
get_family_idea and get_family_adoption then list those rules, so an
assessment judges against them.
Use it when an idea already has rules written for it, or when you write
rules that exist to carry one idea. The version does not move: linking
changes where the norms are found, not what they say.
Args:
note_id: the family idea.
topic_id: a topic in a rulebook you own (list_rulebooks,
list_topics). 0 unlinks.
"""
return await family_svc.set_topic(current_user_id(), note_id, topic_id)
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, revise_family_idea, get_family_adoption,
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
set_family_references, set_family_topic,
):
mcp.tool(name=fn.__name__)(fn)