Merge pull request 'Merge dev → main: a family idea links to the rule topic holding its norms' (#207) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m10s
CI & Build / Build & push image (push) Successful in 1m7s

This commit was merged in pull request #207.
This commit is contained in:
2026-10-06 18:00:52 -04:00
10 changed files with 195 additions and 8 deletions
+11
View File
@@ -17,12 +17,23 @@ export interface Criterion {
test: string; test: string;
} }
export interface IdeaTopic {
id: number;
title: string;
rulebook_id: number;
/** Present on a single idea's read, not on the list. */
rules?: { id: number; title: string }[];
}
export interface FamilyIdea { export interface FamilyIdea {
note_id: number; note_id: number;
status: IdeaStatus; status: IdeaStatus;
applies_when: string; applies_when: string;
canon_version: number; canon_version: number;
topic_id: number | null; topic_id: number | null;
/** The rule topic holding the idea's binding norms; null when none is
* linked or the reader does not own its rulebook. */
topic: IdeaTopic | null;
title: string; title: string;
note_type: string; note_type: string;
is_task: boolean; is_task: boolean;
+7
View File
@@ -259,6 +259,12 @@ onMounted(load);
<div v-if="idea.platforms.length" class="fam-platforms"> <div v-if="idea.platforms.length" class="fam-platforms">
<code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code> <code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code>
</div> </div>
<p v-if="idea.topic" class="fam-muted fam-topic">
Binding rules:
<router-link
:to="{ path: '/rules', query: { rb: String(idea.topic.rulebook_id), topic: String(idea.topic.id) } }"
>{{ idea.topic.title }}</router-link>
</p>
</div> </div>
<button <button
v-if="idea.status !== 'retired'" v-if="idea.status !== 'retired'"
@@ -376,6 +382,7 @@ onMounted(load);
.fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; } .fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
.fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); } .fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; } .fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
.fam-topic { margin: 0.35rem 0 0; }
.fam-chip { .fam-chip {
font-family: var(--fs-font-mono); font-family: var(--fs-font-mono);
font-size: 0.72rem; font-size: 0.72rem;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).", "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", "version": "2026.10.06.1827",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+3
View File
@@ -33,6 +33,9 @@ wrote what you wrote:
needs support you can name; **one criterion with no support vetoes it**. 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 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. precedent too, so the reason is the record — never skip writing it.
4. If rules already bind the idea — a rule topic written for it — link that
topic with `set_family_topic`. The note teaches; the topic's rules bind,
and every assessment then reads them beside the idea.
A milestone closing on a platform project hints without recording anything: A milestone closing on a platform project hints without recording anything:
ask whether what it built is something every project on the platform will ask whether what it built is something every project on the platform will
+1 -1
View File
@@ -196,7 +196,7 @@ _WRITE_TOOLS = frozenset({
"undo_family_decision", "undo_family_decision",
# family canon — the adoption ledger # family canon — the adoption ledger
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict", "revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
"set_family_references", "set_family_references", "set_family_topic",
"bind_repo", "unbind_repo", "bind_repo", "unbind_repo",
# snippets, processes, the shape ledger # snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet", "create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+20 -1
View File
@@ -372,12 +372,31 @@ async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
return {"references": refs} 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: def register(mcp) -> None:
for fn in ( for fn in (
list_family_ideas, get_family_idea, propose_family_idea, list_family_ideas, get_family_idea, propose_family_idea,
promote_family_idea, retire_family_idea, undo_family_decision, promote_family_idea, retire_family_idea, undo_family_decision,
list_family_decisions, revise_family_idea, get_family_adoption, list_family_decisions, revise_family_idea, get_family_adoption,
list_family_adoptions, assess_family_adoption, resolve_family_conflict, list_family_adoptions, assess_family_adoption, resolve_family_conflict,
set_family_references, set_family_references, set_family_topic,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
+6 -4
View File
@@ -23,8 +23,9 @@ The tables, and the one job each does:
- `family_ideas` — a note's family state: candidate, canon or retired, its - `family_ideas` — a note's family state: candidate, canon or retired, its
applicability test, its canon version, its linked rule topic. applicability test, its canon version, its linked rule topic.
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope - `family_idea_platforms` — the platforms an idea is for. The ONLY scope
source: a linked topic takes its scope from here rather than carrying its source: a linked topic carries no platforms of its own, so the two can
own, so the two can never disagree. never disagree. The scope decides which projects owe the idea an answer;
the topic's rules still reach a session by ordinary rule retrieval.
- `family_idea_references` — the reference implementations. - `family_idea_references` — the reference implementations.
- `family_adoptions` — one row per (project, idea): the project's answer. - `family_adoptions` — one row per (project, idea): the project's answer.
- `family_decisions` — the append-only log of every promotion and every - `family_decisions` — the append-only log of every promotion and every
@@ -165,8 +166,9 @@ class FamilyIdea(Base, TimestampMixin):
comparing the two numbers, never stored as a flag that could go stale. comparing the two numbers, never stored as a flag that could go stale.
`topic_id` is the rule topic holding the idea's binding norms, if it has `topic_id` is the rule topic holding the idea's binding norms, if it has
any (the note↔topic link #3236 asked for). The topic takes its scope from any (the note↔topic link #3236 asked for), set by `set_topic`. The topic
this idea; it never carries platforms of its own. never carries platforms of its own; the idea's readouts list its rules,
so an assessment judges a project against them.
""" """
__tablename__ = "family_ideas" __tablename__ = "family_ideas"
+60
View File
@@ -52,7 +52,9 @@ from scribe.models.family import (
) )
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import access from scribe.services import access
from scribe.services import rulebooks as rulebooks_svc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,6 +142,26 @@ async def _platform_slugs(session, note_id: int) -> list[str]:
return list(rows.scalars().all()) return list(rows.scalars().all())
async def _topic(session, user_id: int, topic_id: int | None, *, rules: bool) -> dict | None:
"""The rule topic holding an idea's binding norms, for a reader. Rulebooks
belong to their owner, so a topic the reader does not own reads as no
topic."""
if not topic_id:
return None
topic = await rulebooks_svc.get_topic(topic_id, user_id)
if topic is None:
return None
out = {"id": topic.id, "title": topic.title, "rulebook_id": topic.rulebook_id}
if rules:
out["rules"] = [
{"id": r.id, "title": r.title} for r in (await session.execute(
select(Rule).where(Rule.topic_id == topic.id, Rule.deleted_at.is_(None))
.order_by(Rule.order_index.asc(), Rule.id.asc())
)).scalars().all()
]
return out
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None: async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
"""The idea's state, as a decision's before/after. Slugs, never ids: an id """The idea's state, as a decision's before/after. Slugs, never ids: an id
inside JSON cannot be remapped by a restore (the #3182 trap).""" inside JSON cannot be remapped by a restore (the #3182 trap)."""
@@ -187,6 +209,7 @@ async def get_idea(user_id: int, note_id: int) -> dict | None:
"is_task": note.is_task, "is_task": note.is_task,
"project_id": note.project_id, "project_id": note.project_id,
"platforms": await _platform_slugs(session, note_id), "platforms": await _platform_slugs(session, note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
"adoptions": counts, "adoptions": counts,
"decisions": [_decision_dict(d) for d in decisions], "decisions": [_decision_dict(d) for d in decisions],
"undoable_decision_id": _undoable_id(decisions), "undoable_decision_id": _undoable_id(decisions),
@@ -226,6 +249,7 @@ async def list_ideas(
"is_task": note_status is not None, "is_task": note_status is not None,
"project_id": project_id, "project_id": project_id,
"platforms": await _platform_slugs(session, idea.note_id), "platforms": await _platform_slugs(session, idea.note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=False),
}) })
out.append(d) out.append(d)
return out return out
@@ -677,6 +701,42 @@ async def revise(
"ledger_rows_opened": opened, "ledger_rows_closed": closed} "ledger_rows_opened": opened, "ledger_rows_closed": closed}
async def set_topic(user_id: int, note_id: int, topic_id: int) -> dict:
"""Link an idea to the rule topic holding its binding norms, or unlink it
with topic_id 0. The note carries the idea; the topic carries the rules a
project on its platforms must follow. One idea per topic, since a topic's
norms belong to one standard.
Not a change of substance, so the version does not move: the norms were
rules before the link and are the same rules after it. Re-linking is the
undo, as with the references."""
if not await access.can_write_note(user_id, note_id):
raise ValueError(f"note {note_id} not found or no write access")
async with async_session() as session:
idea = await session.get(FamilyIdea, note_id)
if idea is None:
raise ValueError(f"#{note_id} is not a family idea")
if topic_id:
if await rulebooks_svc.get_topic(topic_id, user_id) is None:
raise ValueError(f"topic {topic_id} not found in a rulebook you own")
holder = (await session.execute(
select(FamilyIdea.note_id, Note.title)
.join(Note, Note.id == FamilyIdea.note_id)
.where(FamilyIdea.topic_id == topic_id, FamilyIdea.note_id != note_id)
)).first()
if holder is not None:
raise ValueError(
f"topic {topic_id} already carries the norms of #{holder[0]} "
f"“{holder[1]}” — unlink it there first, or merge the two ideas"
)
idea.topic_id = topic_id or None
idea.updated_at = _now()
await session.commit()
out = idea.to_dict()
out["topic"] = await _topic(session, user_id, idea.topic_id, rules=True)
return out
async def _existing_decision_ids(ids: list[int]) -> list[int]: async def _existing_decision_ids(ids: list[int]) -> list[int]:
ids = [int(i) for i in ids if i] ids = [int(i) for i in ids if i]
if not ids: if not ids:
+1 -1
View File
@@ -181,6 +181,6 @@ def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
def test_every_adoption_tool_is_classified(): def test_every_adoption_tool_is_classified():
reads = {"get_family_adoption", "list_family_adoptions"} reads = {"get_family_adoption", "list_family_adoptions"}
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea", writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
"set_family_references"} "set_family_references", "set_family_topic"}
assert reads <= _READ_ONLY_TOOLS assert reads <= _READ_ONLY_TOOLS
assert writes <= _WRITE_TOOLS assert writes <= _WRITE_TOOLS
@@ -11,6 +11,7 @@ test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
the trigger does WITH a hit. The ranking itself is the shared semantic the trigger does WITH a hit. The ranking itself is the shared semantic
search, tested where that lives. search, tested where that lives.
""" """
import uuid
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -24,6 +25,7 @@ from scribe.models.family import (
from scribe.models.milestone import Milestone from scribe.models.milestone import Milestone
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User from scribe.models.user import User
from scribe.services import family as family_svc from scribe.services import family as family_svc
from tests.helpers import ensure_user from tests.helpers import ensure_user
@@ -291,3 +293,86 @@ async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_
assert hint and "Android app" in hint assert hint and "Android app" in hint
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
# --- the note↔topic link ----------------------------------------------------------
@pytest_asyncio.fixture
async def topics(family):
"""A topic of the owner's with two rules, a second empty one, and one in
the outsider's rulebook. Titles carry a nonce: rulebooks are not purged
with the notes, and a topic's title is unique within its rulebook."""
nonce = uuid.uuid4().hex[:8]
async with async_session() as s:
mine = Rulebook(owner_user_id=family["owner"], title=f"family norms {nonce}")
theirs = Rulebook(owner_user_id=family["outsider"], title=f"their norms {nonce}")
s.add_all([mine, theirs])
await s.flush()
norms = RulebookTopic(rulebook_id=mine.id, title="distribution")
spare = RulebookTopic(rulebook_id=mine.id, title="spare")
foreign = RulebookTopic(rulebook_id=theirs.id, title="theirs")
s.add_all([norms, spare, foreign])
await s.flush()
s.add_all([
Rule(topic_id=norms.id, title="One release key", statement="Sign with one key.",
order_index=0),
Rule(topic_id=norms.id, title="No debug signer", statement="Check the signer.",
order_index=1),
])
await s.commit()
return {"norms": norms.id, "spare": spare.id, "foreign": foreign.id,
"rulebook": mine.id}
async def test_an_idea_links_to_its_topic_and_its_reads_name_the_rules(family, topics):
await _promote(family)
out = await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
assert out["topic_id"] == topics["norms"]
assert [r["title"] for r in out["topic"]["rules"]] == ["One release key", "No debug signer"]
# Where the norms are found is not what they say: the version holds.
assert out["canon_version"] == 1
idea = await family_svc.get_idea(family["owner"], family["note"])
assert idea["topic"]["rulebook_id"] == topics["rulebook"]
assert len(idea["topic"]["rules"]) == 2
[listed] = [i for i in await family_svc.list_ideas(family["owner"])
if i["note_id"] == family["note"]]
assert listed["topic"] == {"id": topics["norms"], "title": "distribution",
"rulebook_id": topics["rulebook"]}
async def test_zero_unlinks_the_topic(family, topics):
await _promote(family)
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
out = await family_svc.set_topic(family["owner"], family["note"], 0)
assert out["topic_id"] is None and out["topic"] is None
async def test_a_topic_outside_the_callers_rulebooks_is_refused(family, topics):
await _promote(family)
with pytest.raises(ValueError, match="rulebook you own"):
await family_svc.set_topic(family["owner"], family["note"], topics["foreign"])
assert (await _idea(family["note"])).topic_id is None
async def test_one_topic_carries_one_ideas_norms(family, topics):
await _promote(family)
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
async with async_session() as s:
other = Note(user_id=family["owner"], project_id=family["b"], title="Another idea",
body="something else every Android app needs")
s.add(other)
await s.flush()
s.add(FamilyIdea(note_id=other.id, status="candidate"))
await s.commit()
other_id = other.id
with pytest.raises(ValueError, match="already carries the norms"):
await family_svc.set_topic(family["owner"], other_id, topics["norms"])
# Re-linking the same idea to its own topic is not a conflict.
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
async def test_someone_who_cannot_write_the_idea_cannot_link_it(family, topics):
await _promote(family)
with pytest.raises(ValueError, match="no write access"):
await family_svc.set_topic(family["outsider"], family["note"], topics["spare"])