CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 29s
A System tag is how `list_system_records` gathers an area's pile, so a
record that cannot be tagged is reachable by search and by nothing else.
`update_lesson` did not take `system_ids` while `create_lesson` did, which
made tagging available exactly once — at the moment of least information.
A lesson is usually written at the end of a piece of work, which is
precisely when that argument gets dropped, and after that the record could
never be filed at all.
Filling in the rest of the table found three more gaps, and the issue's own
generalisation was wrong. It read as "the REST door can do something the
MCP door cannot", on three instances in a row. In fact:
* `create_preference` is the exact MIRROR of the lesson bug — update
takes `system_ids`, create does not. The same capability missing from
the opposite end of the same lifecycle.
* `routes/notes.py` handled `system_ids` NOWHERE, while the MCP door
handled both ends. That runs the opposite way round from the premise.
* `create_process` / `update_process` took it at neither door, though a
process is a note and has always been taggable in the data model.
The real pattern is that whichever door nobody exercised for a kind is the
one that never grew the parameter — which is a better statement of #4248
than the one recorded there, and is not something a reviewer reliably
notices, because each door is only ever read on its own.
Milestones are NOT a fifth gap. `RecordSystem.note_id` is a ForeignKey to
`notes.id` and milestones are their own table, so they cannot be tagged at
any door by construction. Pinned in the test so the next pass does not
re-open it.
So the guard is the point, not the four parameters. `update_lesson` alone
would have left the shape that produced it intact. The new test asserts the
TABLE — every kind taggable anywhere is taggable everywhere it is written —
and keys the registry-coverage check on the SIGNATURE rather than on a
grep, so a module that only names the argument in prose is not swept in and
no hand-kept skip list can go stale. A fifth kind fails there rather than
shipping half-wired, the same reasoning test_derived_mirror_generic_door.py
records for derived mirrors (#3734).
One inconsistency found and deliberately not changed here: for the same
operation `routes/tasks.py` scopes `set_record_systems` by the caller while
`routes/lessons.py` and `routes/snippets.py` scope it by the owner. The new
notes code follows tasks.py and says why in a comment (#47 — an editor-share
holder should tag from what they can see rather than inherit the owner's
reach). Recorded in #4249 rather than fixed as a drive-by.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
1164 lines
56 KiB
Python
1164 lines
56 KiB
Python
"""MCP tools for the Scribe Rulebook system.
|
|
|
|
Rulebook / topic / rule CRUD and the rule-to-rule
|
|
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
|
|
service, and the record shape comes from rule_brief / rule_detail there rather
|
|
than being rebuilt here.
|
|
|
|
(The header used to say "Sixteen tools" and had been wrong for two milestones;
|
|
the count lives in the registration test, which fails when it drifts.)
|
|
|
|
Destructive ops (delete_*) require confirmed=True; otherwise return a
|
|
preview-style warning. Mirrors the pattern in delete_event and the design
|
|
spec.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.services import dedup as dedup_svc
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from scribe.services import trash as trash_svc
|
|
from scribe.services.rule_usage import (
|
|
record_rule_outcome, record_rule_pulled,
|
|
)
|
|
|
|
|
|
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
|
|
|
async def list_rulebooks() -> dict:
|
|
"""List all rulebooks owned by the current user.
|
|
|
|
Returns id, title, description for each.
|
|
"""
|
|
uid = current_user_id()
|
|
rows = await rulebooks_svc.list_rulebooks(uid)
|
|
return {"rulebooks": [rb.to_dict() for rb in rows]}
|
|
|
|
|
|
async def get_rulebook(rulebook_id: int) -> dict:
|
|
"""Fetch a rulebook by id with its full topic list."""
|
|
uid = current_user_id()
|
|
rb = await rulebooks_svc.get_rulebook(rulebook_id, uid)
|
|
if rb is None:
|
|
raise ValueError(f"rulebook {rulebook_id} not found")
|
|
topics = await rulebooks_svc.list_topics(rulebook_id, uid)
|
|
data = rb.to_dict()
|
|
data["topics"] = [t.to_dict() for t in topics]
|
|
return data
|
|
|
|
|
|
async def create_rulebook(title: str, description: str = "") -> dict:
|
|
"""Create a new rulebook (a themed grouping of GLOBAL rules).
|
|
|
|
A rule in a rulebook is global: it applies in every project its owner works
|
|
on, and reaches a session by retrieval when the work makes it relevant
|
|
(milestone 414). There is no subscribing a project to a rulebook, and no
|
|
muting one per project — that machinery is gone. So a rulebook's rules must
|
|
stay general, agnostic to any single project. Project-specific rules go in
|
|
create_project_rule.
|
|
|
|
Args:
|
|
title: Rulebook name.
|
|
description: Optional short description of what this rulebook covers.
|
|
"""
|
|
uid = current_user_id()
|
|
rb = await rulebooks_svc.create_rulebook(
|
|
user_id=uid, title=title, description=description,
|
|
)
|
|
return rb.to_dict()
|
|
|
|
|
|
async def update_rulebook(
|
|
rulebook_id: int, title: str = "", description: str = "",
|
|
) -> dict:
|
|
"""Update an existing rulebook. Only non-empty fields are changed.
|
|
|
|
Args:
|
|
rulebook_id: Rulebook to update.
|
|
title: New title. Empty string leaves unchanged.
|
|
description: New description. Empty string leaves unchanged.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if title:
|
|
fields["title"] = title
|
|
if description:
|
|
fields["description"] = description
|
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
|
if rb is None:
|
|
raise ValueError(f"rulebook {rulebook_id} not found")
|
|
return rb.to_dict()
|
|
|
|
|
|
async def delete_rulebook(rulebook_id: int, confirmed: bool = False) -> dict:
|
|
"""Permanently delete a rulebook (cascades to all its topics and rules).
|
|
|
|
Pass confirmed=True to actually delete. Without confirmation, returns a
|
|
preview describing what will be cascaded.
|
|
"""
|
|
uid = current_user_id()
|
|
rb = await rulebooks_svc.get_rulebook(rulebook_id, uid)
|
|
if rb is None:
|
|
raise ValueError(f"rulebook {rulebook_id} not found")
|
|
if not confirmed:
|
|
topics = await rulebooks_svc.list_topics(rulebook_id, uid)
|
|
rule_count = 0
|
|
for t in topics:
|
|
rule_count += len(await rulebooks_svc.list_rules(uid, topic_id=t.id))
|
|
return {
|
|
"warning": (
|
|
f"Rulebook {rulebook_id} ('{rb.title}') contains "
|
|
f"{len(topics)} topics and {rule_count} rules; all will be "
|
|
f"deleted. Pass confirmed=True to proceed."
|
|
),
|
|
"confirmed_required": True,
|
|
}
|
|
batch = await trash_svc.delete(uid, "rulebook", rulebook_id)
|
|
return {"deleted": rulebook_id, "title": rb.title, "deleted_batch_id": batch,
|
|
"message": f'Rulebook {rulebook_id} ("{rb.title}") moved to trash. '
|
|
f"Restore with restore('{batch}')."}
|
|
|
|
|
|
# ── Topic CRUD ─────────────────────────────────────────────────────────
|
|
|
|
async def list_topics(rulebook_id: int) -> dict:
|
|
"""List topics inside a rulebook."""
|
|
uid = current_user_id()
|
|
rows = await rulebooks_svc.list_topics(rulebook_id, uid)
|
|
return {"topics": [t.to_dict() for t in rows]}
|
|
|
|
|
|
async def create_topic(
|
|
rulebook_id: int, title: str,
|
|
description: str = "", order_index: int = 0,
|
|
) -> dict:
|
|
"""Create a topic within a rulebook.
|
|
|
|
Args:
|
|
rulebook_id: Rulebook to add the topic to.
|
|
title: Topic name (e.g. "git-workflow").
|
|
description: Optional description.
|
|
order_index: Display order (0-based; default 0).
|
|
"""
|
|
uid = current_user_id()
|
|
topic = await rulebooks_svc.create_topic(
|
|
rulebook_id=rulebook_id, user_id=uid,
|
|
title=title, description=description, order_index=order_index,
|
|
)
|
|
return topic.to_dict()
|
|
|
|
|
|
async def update_topic(
|
|
topic_id: int, title: str = "",
|
|
description: str = "", order_index: int = -1,
|
|
) -> dict:
|
|
"""Update a topic. Sentinels: title="" / description="" leave unchanged;
|
|
order_index=-1 leaves unchanged.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if title:
|
|
fields["title"] = title
|
|
if description:
|
|
fields["description"] = description
|
|
if order_index >= 0:
|
|
fields["order_index"] = order_index
|
|
topic = await rulebooks_svc.update_topic(topic_id, uid, **fields)
|
|
if topic is None:
|
|
raise ValueError(f"topic {topic_id} not found")
|
|
return topic.to_dict()
|
|
|
|
|
|
async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
|
|
"""Delete a topic and all its rules. Requires confirmed=True."""
|
|
uid = current_user_id()
|
|
topic = await rulebooks_svc.get_topic(topic_id, uid)
|
|
if topic is None:
|
|
raise ValueError(f"topic {topic_id} not found")
|
|
if not confirmed:
|
|
rules = await rulebooks_svc.list_rules(uid, topic_id=topic_id)
|
|
return {
|
|
"warning": (
|
|
f"Topic {topic_id} ('{topic.title}') contains {len(rules)} "
|
|
f"rules; all will be deleted. Pass confirmed=True to proceed."
|
|
),
|
|
"confirmed_required": True,
|
|
}
|
|
batch = await trash_svc.delete(uid, "topic", topic_id)
|
|
return {"deleted": topic_id, "title": topic.title, "deleted_batch_id": batch,
|
|
"message": f'Topic {topic_id} ("{topic.title}") moved to trash. '
|
|
f"Restore with restore('{batch}')."}
|
|
|
|
|
|
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
|
|
|
def _rule_summary(r) -> dict:
|
|
"""The list-row shape for a rule: what an agent needs to APPLY it. The
|
|
full record (why, how_to_apply, timestamps) is get_rule's job.
|
|
|
|
One line, because the shape itself lives in the service — this was one of
|
|
three hand-written copies that had already drifted apart (note 3026).
|
|
"""
|
|
return rulebooks_svc.rule_brief(r)
|
|
|
|
|
|
async def list_rules(
|
|
rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
|
|
) -> dict:
|
|
"""List rules — filter by rulebook, topic, and/or project.
|
|
|
|
Args:
|
|
rulebook_id: 0 = no filter; positive = restrict to that rulebook.
|
|
topic_id: 0 = no filter; positive = restrict to that topic.
|
|
project_id: 0 = no filter; positive = that project's OWN rules.
|
|
Global rules apply to every project, so they are listed by
|
|
rulebook or topic (or unfiltered), not under each project.
|
|
|
|
rulebook_id and topic_id AND-combine; project_id lists a project's rules
|
|
on its own. Ownership-scoped.
|
|
"""
|
|
uid = current_user_id()
|
|
rows = await rulebooks_svc.list_rules(
|
|
user_id=uid,
|
|
rulebook_id=rulebook_id or None,
|
|
topic_id=topic_id or None,
|
|
project_id=project_id or None,
|
|
)
|
|
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
|
|
|
|
|
|
async def get_rule(rule_id: int) -> dict:
|
|
"""Fetch a rule by id — full statement + why + how_to_apply.
|
|
|
|
Also carries what a listing leaves out: the global `systems` this rule is
|
|
about, and its `relations`. Read the relations before acting on the rule —
|
|
a rule with a `co_surfaces` edge is half of a shape, and an `overrides`
|
|
edge means one of the pair is not in force here.
|
|
"""
|
|
uid = current_user_id()
|
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
|
if rule is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
# THE pull that matters. The write-path rule arm's own message ends "Read
|
|
# it with get_rule(N)", so this is the exact action the hint asks for and
|
|
# the only evidence that one landed. Recorded after the access check, so a
|
|
# refused read is not counted as a pull.
|
|
record_rule_pulled(user_id=uid, rule_id=int(rule.id), source="mcp_get_rule")
|
|
return await rulebooks_svc.rule_detail(uid, rule)
|
|
|
|
|
|
async def rule_outcome(rule_id: int, outcome: str, why: str = "") -> dict:
|
|
"""Record what a rule you read ACTUALLY CHANGED — applied, or departed from.
|
|
|
|
Call this after a rule has been surfaced to you and you have acted. It is
|
|
the only way the system can tell a rule that is working from a rule that
|
|
is being read and ignored: `surfaced` says it was offered, `get_rule` says
|
|
it was opened, and until this exists neither says whether it made any
|
|
difference. A rule obeyed every time and a rule ignored every time leave
|
|
identical telemetry, and the second is the one worth knowing about.
|
|
|
|
`outcome` is one of:
|
|
|
|
"applied" — it changed what you did, or it confirmed the approach you
|
|
were already taking. `why` is optional; following a rule is
|
|
the ordinary case and does not need an argument.
|
|
"departed" — you read it and deliberately did not follow it. `why` is
|
|
REQUIRED and is the whole value of the call: a departure
|
|
without its reason is indistinguishable from a miss when
|
|
somebody reads this back, and "somebody" is usually you, in
|
|
a later session, with none of today's context.
|
|
|
|
There is deliberately NO value for "read it and ignored it". That state is
|
|
real, and it is the one this measurement exists to expose — but it is not
|
|
something you can report, because noticing it is the same act as not doing
|
|
it. It is derived instead: a rule you opened and never came back to. The
|
|
honest way to keep yourself out of that bucket is to call this, not to
|
|
reach for a word that describes it.
|
|
|
|
A departure is a legitimate answer and is not a confession. Rules are
|
|
written for the common case; recording the edge you found is how the rule
|
|
gets better, and a corpus where nothing is ever departed from is a corpus
|
|
nobody is really reading.
|
|
"""
|
|
uid = current_user_id()
|
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
|
if rule is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
choice = (outcome or "").strip().lower()
|
|
if choice not in ("applied", "departed"):
|
|
raise ValueError(
|
|
f"outcome must be 'applied' or 'departed', got {outcome!r}"
|
|
)
|
|
if choice == "departed" and not (why or "").strip():
|
|
raise ValueError(
|
|
"a departure needs its reason — pass `why`. Without it the record "
|
|
"cannot be told from a rule that was simply missed."
|
|
)
|
|
record_rule_outcome(
|
|
user_id=uid, rule_id=int(rule.id), outcome=choice,
|
|
source="mcp_rule_outcome", detail=why,
|
|
)
|
|
return {
|
|
"rule_id": int(rule.id),
|
|
"title": rule.title,
|
|
"outcome": choice,
|
|
"why": (why or "").strip() or None,
|
|
"recorded": True,
|
|
}
|
|
|
|
|
|
async def create_rule(
|
|
topic_id: int, title: str, statement: str, when_to_apply: str,
|
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
|
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
|
system_ids: list[int] | None = None, force: bool = False,
|
|
) -> dict:
|
|
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
|
|
|
PROPOSE RULES READILY, AND WRITE ONE WHEN THE OPERATOR SAYS YES. Noticing
|
|
that something has hardened into a standing instruction is valuable work,
|
|
and a session that notices it and says nothing has thrown the observation
|
|
away. So raise it whenever you see one. The single step that belongs
|
|
between noticing and writing is the operator's yes: a rule binds every
|
|
future session, and they are the person it binds.
|
|
|
|
Their yes is also the only moment the rule is reliably IN FRONT of them.
|
|
After the write it may not be again for months — a conditional rule is not
|
|
read aloud at session start, and a project-scoped one does not appear in
|
|
an unfiltered list_rules() at all. So the proposal is the review.
|
|
|
|
When the operator asks for a rule in so many words, that IS the yes —
|
|
write it and move on. The loop below is for the rule you thought of.
|
|
|
|
FIRST, ASK WHAT KIND OF THING YOU ARE HOLDING. Force is the axis, and one
|
|
question sorts it: *what happens if someone doesn't do this?*
|
|
|
|
* "something breaks, or a boundary is crossed" → a RULE. It must be
|
|
followed, so it is the operator's to agree to. Propose it here.
|
|
* "it gets done a way the operator didn't want" → a PREFERENCE
|
|
(create_preference). How they want work done; ignoring it costs
|
|
consistency rather than correctness. No approval loop — record it.
|
|
* "they lose time rediscovering it" → a LESSON (create_lesson). A better
|
|
way to think about a problem, or a solution that transfers, met again
|
|
at the moment it applies. It binds nobody, so it needs no yes.
|
|
|
|
Asking this raises the value of noticing rather than lowering it: the
|
|
observation is worth keeping in all three cases, and what changes is
|
|
only which door it goes through. A proposal that turns out to be a
|
|
lesson has not failed — it has been routed.
|
|
|
|
A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it:
|
|
|
|
1. WHAT it would require — the statement, in the words it would carry,
|
|
not a gloss of them. The operator is agreeing to text.
|
|
2. INTENT — what it changes about how work gets done, and what goes
|
|
wrong today without it. "Be careful about X" is not an intent; the
|
|
behaviour that would differ tomorrow is.
|
|
3. WHY NOW — the incident, observation or decision behind it. Pass that
|
|
record as arose_from_id, and say it in the conversation too: the
|
|
field is for the reader six months out, the sentence is for the
|
|
person deciding.
|
|
4. HOW IT WOULD BE ENFORCED — a test, a CI check, a hook, a schema
|
|
constraint, a duplicate gate, a review step... or nothing, in which
|
|
case say so plainly: "nothing — this is prose a session has to
|
|
remember." Answer this one honestly and it will sometimes dissolve
|
|
the rule, which is the point rather than a side effect. What a test
|
|
can assert should BE that test; a rule is what remains when nothing
|
|
mechanical can hold the thing. A rulebook grows by default and
|
|
shrinks only on purpose, so a question that prevents a rule is worth
|
|
more than any question that improves one's wording.
|
|
|
|
THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer these
|
|
answers, and make "LET'S TALK ABOUT IT" the easy one:
|
|
|
|
* "Approve it AS WRITTEN" — you create it with the statement exactly as
|
|
shown. This is what makes element 1 load-bearing: they approved TEXT,
|
|
so that text is what gets stored, verbatim.
|
|
* "LET'S TALK ABOUT IT" — the wording, the scope, whether it
|
|
wants to be a rule at all. Most good rules arrive this way, so treat
|
|
this answer as the expected one rather than a setback.
|
|
* "MAKE IT A PREFERENCE" — they want it done this way, but nothing
|
|
breaks if it isn't. create_preference records it without a loop, and
|
|
it stays yours to keep current as they correct you.
|
|
* "MAKE IT A LESSON" — worth knowing, binding nobody. create_lesson
|
|
stores it against the SITUATION it applies to, so a later session
|
|
meets it at that moment rather than having to go looking. Reach for
|
|
this whenever the answer to "what happens if someone doesn't do
|
|
this" was "they lose time".
|
|
* "NO" — let it go. If the observation is still worth keeping and none
|
|
of the above fits, it is a note (create_note): recorded, findable,
|
|
and binding on nobody.
|
|
|
|
The middle three are not consolation prizes. They are where most good
|
|
observations belong, and the reason the kind question is worth asking
|
|
out loud rather than settled silently before the proposal.
|
|
|
|
Where the interface offers structured choices, ask it that way — a
|
|
question with named options is answered in a click, while the same
|
|
question inside a paragraph is answered by scrolling past. Where it does
|
|
not, write the answers out as a list. Either way ask once and let the
|
|
answer stand; re-raising a declined proposal argues a rule
|
|
into existence, which is the thing this whole loop exists to prevent.
|
|
|
|
A rulebook rule is GLOBAL — it applies in every project — so it must read as
|
|
a general standard: never pin it to one project's files, paths, or quirks.
|
|
For a rule that applies to a single project only, use create_project_rule
|
|
instead (no rulebook+topic ceremony). A standard only some projects share
|
|
is still global in reach; write it so it names the kind of work it is
|
|
about, and it arrives where that work happens.
|
|
|
|
Write it general WITHOUT hedging for the exceptions. A project that needs
|
|
to strengthen, narrow or replace this rule writes its own and links it
|
|
with relate_rules(kind="overrides"), and one that adds local specifics
|
|
uses "elaborates" — so the general form does not have to anticipate every
|
|
project it will ever reach. A rulebook rule padded with "unless…" clauses
|
|
for two projects is two project rules that were never written.
|
|
|
|
Before writing a rule at all, check whether another entity already models
|
|
the thing. A rule is prose an agent must remember and apply; the others
|
|
are structure a tool can resolve, render and check. Visual standards are a
|
|
DESIGN SYSTEM (tokens inherit, resolve per mode, render to a stylesheet —
|
|
none of that survives being prose). A repeatable procedure is a PROCESS.
|
|
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
|
|
is a standing instruction about how to work and nothing else can hold it.
|
|
|
|
ONE RULE = ONE THING YOU COULD VIOLATE. If a clause can be broken on its
|
|
own, and fixing that breakage doesn't require the neighbouring clauses, it
|
|
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
|
|
(kind="co_surfaces"), never merged into one row: a merged rule cannot be
|
|
cited or surfaced a clause at a time, and it grows without
|
|
limit because adding to it is always cheaper than adding a rule.
|
|
|
|
Args:
|
|
topic_id: The topic to attach the rule to.
|
|
title: A short imperative title (e.g. "dev is home").
|
|
statement: The actionable instruction (required). 1-2 sentences.
|
|
when_to_apply: WHEN this rule fires — the trigger, not the
|
|
instruction. State the moment or the material: "before any git
|
|
push", "when adding a value to a CHECK-gated column", "when a
|
|
release is being cut". REQUIRED, and not as ceremony: nothing is
|
|
preloaded, so this is the whole of how the rule is found when it
|
|
matters — and it is half of what the rule is EMBEDDED as, so a
|
|
rule without one is not merely hard to find, it is stored in a
|
|
different shape from every rule it competes with. Name the SYMPTOM
|
|
— the words someone would type while stuck — rather than the
|
|
category: "the CI job passed locally and fails on the runner with
|
|
a permission error" retrieves; "when touching CI config" does not.
|
|
This field is also the rule's RETRIEVAL SURFACE — it and the
|
|
statement are what a search is matched against, so it should
|
|
carry the SYMPTOM, not just the situation: the words someone
|
|
would actually type while stuck. Measured (note 3078): a rule
|
|
whose trigger named only its situation did not surface at all
|
|
for the problem it solves; adding the symptom to the same field
|
|
brought it back as the top hit. Where a rule prevents a specific
|
|
failure, put that failure's vocabulary here — the error text,
|
|
the wrong behaviour, the dead end.
|
|
The two spellings, side by side:
|
|
RETRIEVES: "the migration failed with a check violation on a
|
|
column we just extended"
|
|
COLLAPSES: "when working on migrations"
|
|
The second names a CATEGORY. No session ever produces a
|
|
category — it produces the command, the error, the half-formed
|
|
ask — so a trigger written that way leaves the embedded
|
|
document to be carried by the title alone.
|
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
|
rule is about. This is what lets a rule reach a project that is
|
|
working in that area, so a CI rule surfaces on a CI change.
|
|
arose_from_id: The note or task that CAUSED this rule (an incident, a
|
|
decision). Prefer this over naming the record inside `why`, which
|
|
cannot be followed and does not survive a rewording.
|
|
why: Optional rationale — the reason the rule exists.
|
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
|
verify_with: How to CHECK this rule is still true. Set it only when
|
|
the rule asserts a fact about something outside your control — a
|
|
runner's shell, a bot's config, whether a tool exists. Those go
|
|
false silently, with nobody present. Give a command, a path, a
|
|
URL or a query; something runnable beats prose, because prose
|
|
has to be re-interpreted by whoever finds it.
|
|
LEAVE IT EMPTY for a rule that is a DECISION — a preference, a
|
|
standard, a way of working. A decision has no truth value: it
|
|
changes when you change it, and you know that you did. An empty
|
|
verify_with is not a gap, it is the marker for "there is nothing
|
|
to go and check," and the whole signal is worthless the moment
|
|
it is filled in out of tidiness.
|
|
expires_when: The STATE under which this rule stops being true —
|
|
"when the runner can be given a bash shell", "when the dashboard
|
|
approval setting is turned off". Deliberately not a date: a
|
|
constraint expires when the ground under it moves, not on a
|
|
schedule. Pairs with verify_with; both empty is the normal case.
|
|
order_index: Display order within the topic (default 0).
|
|
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
|
already in this topic BLOCKS creation and returns its id so you update
|
|
it instead. Set true only for a genuinely distinct rule.
|
|
"""
|
|
uid = current_user_id()
|
|
if not force:
|
|
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
|
|
if dup is not None:
|
|
return dedup_svc.duplicate_response(dup, "rule")
|
|
rule = await rulebooks_svc.create_rule(
|
|
topic_id=topic_id, user_id=uid,
|
|
title=title, statement=statement, when_to_apply=when_to_apply,
|
|
arose_from_id=arose_from_id,
|
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
|
verify_with=verify_with, expires_when=expires_when,
|
|
)
|
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
|
|
|
|
|
async def create_project_rule(
|
|
project_id: int, statement: str, when_to_apply: str, title: str = "",
|
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
|
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
|
system_ids: list[int] | None = None, force: bool = False,
|
|
) -> dict:
|
|
"""Create a rule scoped to a single project (no rulebook needed).
|
|
|
|
Use this for anything SPECIFIC to one project — its files, paths, layout,
|
|
or quirks. This is the correct home for the project-specific detail that
|
|
must NOT go into a rulebook (where it would be global, and reach every
|
|
other project). General standards belong in a rulebook instead
|
|
(create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; the
|
|
rule surfaces by retrieval in this project's sessions only, and is listed
|
|
in get_project's project_rules and in list_rules(project_id=...).
|
|
|
|
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
|
|
loop: the kind question that comes first (what happens if someone doesn't
|
|
do this — a rule binds, a preference guides, a lesson informs), the four
|
|
things a proposal states (what it would require, its intent, why now, and
|
|
how it would be enforced) and the one-word question that closes it
|
|
(approve as written / talk about it / make it a preference / make it a
|
|
lesson / no). All of it applies here unchanged. Reach for that loop MORE
|
|
readily on this surface, not less: a project rule stays out of an
|
|
unfiltered list_rules(), and a conditional one stays out of session start
|
|
too, so the operator's yes is the one moment this rule is certain to have
|
|
been seen by the person it binds.
|
|
|
|
Check first whether a rule is the right shape at all — create_rule's
|
|
opening asks that question and it applies identically here. A visual
|
|
standard is a design system; a procedure is a process (create_process);
|
|
reusable code is a snippet (create_snippet). Each of those is structure a
|
|
tool can resolve, render and check, where a rule is only prose someone
|
|
has to remember and apply.
|
|
|
|
ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that
|
|
STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then
|
|
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
|
|
connected instead of drifting into a contradiction nobody notices. A rule
|
|
that merely adds local detail to an inherited one uses "elaborates".
|
|
|
|
Args:
|
|
project_id: The project to attach the rule to.
|
|
statement: The actionable instruction (required). 1-2 sentences.
|
|
title: Short imperative title. If empty, derived from the first ~50
|
|
characters of statement.
|
|
when_to_apply: WHEN this rule fires — the trigger, not the
|
|
instruction, and the rule's retrieval surface: name the SYMPTOM,
|
|
the words someone would type while stuck. Show the moment rather
|
|
than classifying it:
|
|
RETRIEVES: "the CI job passed locally and fails on the runner
|
|
with a permission error"
|
|
COLLAPSES: "when touching CI config"
|
|
See create_rule for the full argument.
|
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
|
rule is about. Worth setting even on a project rule: it is what
|
|
lets a conditional one surface when the project is working in
|
|
that area.
|
|
arose_from_id: The note or task that CAUSED this rule. Reach for it
|
|
harder here than on a rulebook rule — a project rule usually
|
|
comes from one traceable incident in this repo, where a family
|
|
rule is more often a standing preference with no single origin.
|
|
The link is what lets a later reader judge whether the incident
|
|
still describes the project.
|
|
why: Optional rationale — the reason the rule exists.
|
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
|
verify_with: How to check this rule is still true — see create_rule.
|
|
Set it when the rule asserts a fact about someone else's software;
|
|
leave it empty when the rule is a decision. Project rules are the
|
|
likelier home for a real check: they name this project's files,
|
|
paths and quirks, which is exactly the kind of claim that rots.
|
|
expires_when: The state under which the rule stops being true — see
|
|
create_rule. A state, not a date.
|
|
order_index: Display order within the project's rule list (default 0).
|
|
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
|
already on this project BLOCKS creation and returns its id so you
|
|
update it instead. Set true only for a genuinely distinct rule.
|
|
"""
|
|
uid = current_user_id()
|
|
derived_title = title.strip() or statement.strip().split(".")[0][:50]
|
|
if not force:
|
|
dup = await dedup_svc.find_duplicate_rule(derived_title, project_id=project_id)
|
|
if dup is not None:
|
|
return dedup_svc.duplicate_response(dup, "rule")
|
|
rule = await rulebooks_svc.create_project_rule(
|
|
project_id=project_id, user_id=uid,
|
|
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
|
arose_from_id=arose_from_id,
|
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
|
verify_with=verify_with, expires_when=expires_when,
|
|
)
|
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
|
|
|
|
|
async def update_rule(
|
|
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
|
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
|
system_ids: list[int] | None = None, arose_from_id: int = 0,
|
|
verify_with: str = "", expires_when: str = "", kind: str = "",
|
|
clear_fields: list[str] | None = None,
|
|
) -> dict:
|
|
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
|
|
|
|
`kind` here is how a rule BECOMES a preference, and it is a real change of
|
|
force rather than a relabelling — so make it deliberately and say so. The
|
|
rule keeps its id, its history and its typed edges, which is why this is a
|
|
field rather than a new record: everything that cites it by number stays
|
|
correct. Ordinary edits to an existing preference belong in
|
|
update_preference, which asks for what taught the change.
|
|
|
|
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
|
|
milestone 394, so a rule with no trigger is not a quiet rule — it is one
|
|
no session will ever be shown. `system_ids` REPLACES the rule's areas
|
|
(pass [] to clear), and they decide which PROJECTS a rule binds by area.
|
|
|
|
RETROFITTING A TRIGGER HAS ITS OWN TRAP, and it is not the one create_rule
|
|
warns about. There the field is empty and the instruction is "write one".
|
|
Here a trigger usually already EXISTS and reads perfectly well as English —
|
|
"during hard debugging", "when reading any request from the operator",
|
|
"before starting an action while a previous one is still settling" — so the
|
|
honest-looking verdict is that it is fine. It is not. Those three named a
|
|
CATEGORY rather than a moment, and a category is not a thing any session
|
|
ever types. `rule_document()` puts this field in twice, as the title's
|
|
other half and again above the body, so it dominates the vector: a trigger
|
|
describing the abstraction collapses the record toward its title and the
|
|
rule never arrives. Measured in #3835 across 113 rules, and again in #3855
|
|
on six preferences written before this was understood.
|
|
|
|
So when you touch a rule with an old trigger, re-read it against the query
|
|
that would have to match it — the command about to run, the code being
|
|
written, the operator's actual message — and rewrite it in that vocabulary
|
|
if it does not. Prefer the words someone produces while the rule applies,
|
|
including the rationalisation they would be drafting to talk themselves out
|
|
of it — that rationalisation is often the only text in existence at the
|
|
moment the rule should fire:
|
|
RETRIEVES: "catching yourself drafting 'this is small enough to not
|
|
count' about a rule you have already read"
|
|
COLLAPSES: "when the next action would conflict with a standing rule"
|
|
See create_rule for the full argument and the measurement behind it.
|
|
|
|
TO EMPTY A FIELD, NAME IT: clear_fields=["verify_with"]. Passing "" cannot
|
|
do it — "" means "leave this alone" here, which is what lets you update
|
|
two fields without wiping the other six. Clearable: why, how_to_apply,
|
|
when_to_apply, verify_with, expires_when, arose_from_id. Clearing and
|
|
setting the same field in one call clears it first, so the new value wins.
|
|
|
|
Editing `verify_with` DROPS the rule's verification stamp. The stamp
|
|
certifies a check, not a rule; once the check is reworded the old stamp
|
|
vouches for something that no longer exists, so the rule re-enters the
|
|
staleness sweep as never-verified.
|
|
|
|
Args:
|
|
verify_with: How to check the rule is still true — set it when the
|
|
rule asserts a fact about someone else's software, leave it empty
|
|
when the rule is a decision. See create_rule.
|
|
expires_when: The state under which the rule stops being true. A
|
|
state, not a date. See create_rule.
|
|
clear_fields: Names of fields to empty, as above.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if title:
|
|
fields["title"] = title
|
|
if statement:
|
|
fields["statement"] = statement
|
|
if when_to_apply:
|
|
fields["when_to_apply"] = when_to_apply
|
|
if kind:
|
|
fields["kind"] = kind
|
|
if arose_from_id:
|
|
fields["arose_from_id"] = arose_from_id
|
|
if why:
|
|
fields["why"] = why
|
|
if how_to_apply:
|
|
fields["how_to_apply"] = how_to_apply
|
|
if verify_with:
|
|
fields["verify_with"] = verify_with
|
|
if expires_when:
|
|
fields["expires_when"] = expires_when
|
|
if order_index >= 0:
|
|
fields["order_index"] = order_index
|
|
rule = await rulebooks_svc.update_rule(
|
|
rule_id, uid, clear=clear_fields or (), **fields,
|
|
)
|
|
if rule is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
|
|
|
|
|
# ── Preferences ─────────────────────────────────────────────────────────
|
|
#
|
|
# Separate tools rather than a `kind=` argument on create_rule, and the reason
|
|
# is the docstring rather than the data. create_rule's docstring IS the
|
|
# approval gate: it tells its caller to propose, offer three answers, and
|
|
# wait. A preference reached through that door would be read through that
|
|
# prose, and the caller would hesitate over exactly the act this kind exists
|
|
# to make routine. Two doors, two contracts, one table.
|
|
#
|
|
# Reads stay shared — get_rule and list_rules return preferences as they are,
|
|
# because a preference IS a rule row and a reader asking "what governs this"
|
|
# wants both. Only the WRITE contracts differ.
|
|
|
|
|
|
async def create_preference(
|
|
topic_id: int, title: str, statement: str, when_to_apply: str,
|
|
arose_from_id: int, why: str = "", how_to_apply: str = "",
|
|
order_index: int = 0, system_ids: list[int] | None = None,
|
|
force: bool = False,
|
|
) -> dict:
|
|
"""Record how the operator wants work done. No approval loop — write it.
|
|
|
|
A PREFERENCE IS NOT A RULE, and the axis is force rather than importance:
|
|
|
|
* a RULE is what must be FOLLOWED — ignoring it breaks something or
|
|
crosses a boundary. It is the operator's decision, so create_rule
|
|
proposes and waits for them.
|
|
* a PREFERENCE is how they want it DONE — ignoring it costs consistency,
|
|
not correctness. Noticing one and recording it is ordinary work.
|
|
|
|
If the answer to "what happens if someone doesn't do this" is "something
|
|
breaks", you are holding a rule: propose it with create_rule instead.
|
|
|
|
WHY IT IS WORTH RECORDING AT ALL. A preference stated in one session dies
|
|
with that session, and the next one re-derives it or asks again. The point
|
|
is consistency: the tenth time you do something it goes the way the ninth
|
|
did, without the operator having to say so a tenth time.
|
|
|
|
`when_to_apply` IS REQUIRED, and not as ceremony. A rule's trigger is
|
|
two-thirds of its embedded document, so a preference without one is a
|
|
record that will never surface at the moment it applies — written,
|
|
findable by nobody, and silently useless. Name the moment in the words a
|
|
session would actually be producing then: the command it is about to run,
|
|
the code it is writing, the thing the operator just asked for.
|
|
|
|
Show the moment rather than classifying it:
|
|
RETRIEVES: "the operator pasted a stack trace and said it is still
|
|
broken"
|
|
COLLAPSES: "during hard debugging"
|
|
The second is a category, and no session ever produces a category — it
|
|
produces the command, the error text, the half-formed ask. A trigger
|
|
naming the abstraction collapses the record toward its title and it
|
|
never arrives. Both of those describe the same preference; only one of
|
|
them can be found at the moment it applies.
|
|
|
|
`arose_from_id` IS REQUIRED for the same kind of reason. A preference is
|
|
expected to change as the work teaches it, and a corpus that drifts with
|
|
no record of what taught each change is one nobody can audit. Point it at
|
|
the task or note where this became clear.
|
|
|
|
WHAT A PREFERENCE NEVER DOES: change what gets RECORDED. It shapes how
|
|
work is done — pacing, phrasing, which tool to reach for, how much to
|
|
check first. A dev-log, an issue and a snippet read the same whoever
|
|
produced them, because the record has to outlive the person and their
|
|
preferences.
|
|
|
|
A near-duplicate BLOCKS and returns the existing id. That is the whole
|
|
reason this corpus can stay small while being written freely: the second
|
|
preference about a thing UPDATES the first rather than sitting beside it,
|
|
and two preferences that quietly disagree are worse than none — retrieval
|
|
surfaces whichever scores higher and nobody learns the other exists. The
|
|
gate is title-based within the topic and does not care about kind, so it
|
|
also catches a preference restating a rule that already binds.
|
|
|
|
Args:
|
|
topic_id: The rulebook topic to file it under. A preference is
|
|
user-scoped: it follows the operator across every project, which
|
|
is what separates it from a project rule.
|
|
title: What the preference is about. Half the embedded document —
|
|
worth as much care as the statement.
|
|
statement: How the operator wants it done, in their terms.
|
|
when_to_apply: The moment it applies. Required; see above.
|
|
arose_from_id: The task or note that taught this. Required; see above.
|
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
|
preference is about, which is what lets it reach a session working
|
|
in that area. `update_preference` took this and create did not, so
|
|
a preference could only be filed after the fact (#4249).
|
|
force: Bypass the near-duplicate gate. For a genuinely distinct
|
|
preference, not for one that is "mostly" different — a mostly
|
|
different preference is an update.
|
|
"""
|
|
uid = current_user_id()
|
|
if not when_to_apply.strip():
|
|
raise ValueError(
|
|
"when_to_apply is required: a preference with no trigger never "
|
|
"surfaces at the moment it applies. Name that moment in the words "
|
|
"a session would be producing then."
|
|
)
|
|
if not arose_from_id:
|
|
raise ValueError(
|
|
"arose_from_id is required: preferences change as the work teaches "
|
|
"them, and a change with no record of what taught it cannot be "
|
|
"audited. Pass the task or note where this became clear."
|
|
)
|
|
if not force:
|
|
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
|
|
if dup is not None:
|
|
return dedup_svc.duplicate_response(dup, "rule")
|
|
rule = await rulebooks_svc.create_rule(
|
|
topic_id=topic_id, user_id=uid,
|
|
title=title, statement=statement, when_to_apply=when_to_apply,
|
|
kind="preference", arose_from_id=arose_from_id,
|
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
|
)
|
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
|
|
|
|
|
async def update_preference(
|
|
rule_id: int, arose_from_id: int, statement: str = "",
|
|
when_to_apply: str = "", title: str = "", why: str = "",
|
|
how_to_apply: str = "", order_index: int = -1,
|
|
system_ids: list[int] | None = None, clear_fields: list[str] | None = None,
|
|
) -> dict:
|
|
"""Bring a preference up to date. Doing this mid-work is expected.
|
|
|
|
THIS IS THE TOOL THAT MAKES A PREFERENCE DIFFERENT FROM A RULE. A rule
|
|
waits for its author; a preference is kept current by whoever is working.
|
|
When the operator corrects you, or you notice the preference on file no
|
|
longer matches how they actually want this done, edit it — that is the
|
|
feature, not a liberty being taken. A preference nothing ever updates has
|
|
become a rule nobody enforces.
|
|
|
|
So: no proposal, no three answers, no waiting. Update it and say in the
|
|
conversation that you did, so the operator can disagree while it is still
|
|
in front of them.
|
|
|
|
`arose_from_id` IS REQUIRED, and it is the price of the ungated write.
|
|
Every edit here is versioned, and the operator can read what changed and
|
|
put it back — but a diff with no reason attached leaves them deciding
|
|
whether to trust a change they cannot account for. Point at the task or
|
|
note that taught it.
|
|
|
|
WHEN NOT TO EDIT. If what you learned is that something MUST be done a
|
|
certain way — that skipping it breaks something or crosses a boundary —
|
|
that is a rule, and rules are the operator's call: propose it with
|
|
create_rule rather than hardening a preference in place. Softening in the
|
|
other direction is equally an edit worth flagging out loud.
|
|
|
|
EDITING `when_to_apply` IS THE HIGHEST-LEVERAGE EDIT HERE, and the easiest
|
|
to skip, because a preference's trigger is load-bearing in a way a rule's
|
|
is not. Preferences get a RESERVED slot at the prompt boundary, filled by a
|
|
kind-filtered query at limit=1 — so the corpus does not merely rank against
|
|
rules, it ranks against ITSELF, and the trigger is almost all of what
|
|
separates one preference from the next. Six preferences whose triggers all
|
|
named a category ("during hard debugging", "when reading any request from
|
|
the operator") made that slot pick close to arbitrarily on every prompt.
|
|
|
|
So whenever you are here for any reason, read the trigger against the
|
|
operator's message that should have summoned it. If it describes a
|
|
situation rather than quoting the moment, rewrite it in the words they
|
|
actually type — and in the words YOU would be producing while about to get
|
|
this wrong:
|
|
RETRIEVES: "the operator said 'clean this up' or 'make it work like',
|
|
naming an outcome rather than a change"
|
|
COLLAPSES: "when reading any request from the operator"
|
|
update_rule carries the full argument.
|
|
|
|
Empty strings leave fields unchanged; clear_fields empties them by name,
|
|
exactly as update_rule does.
|
|
|
|
Args:
|
|
rule_id: The preference to update.
|
|
arose_from_id: What taught this change. Required; see above.
|
|
when_to_apply: The moment it applies, in session vocabulary. See above.
|
|
"""
|
|
uid = current_user_id()
|
|
if not arose_from_id:
|
|
raise ValueError(
|
|
"arose_from_id is required: this edit is the record of how the "
|
|
"operator's preference changed, and a change with no reason "
|
|
"attached cannot be judged. Pass the task or note that taught it."
|
|
)
|
|
fields: dict = {"arose_from_id": arose_from_id}
|
|
if title:
|
|
fields["title"] = title
|
|
if statement:
|
|
fields["statement"] = statement
|
|
if when_to_apply:
|
|
fields["when_to_apply"] = when_to_apply
|
|
if why:
|
|
fields["why"] = why
|
|
if how_to_apply:
|
|
fields["how_to_apply"] = how_to_apply
|
|
if order_index >= 0:
|
|
fields["order_index"] = order_index
|
|
rule = await rulebooks_svc.update_rule(
|
|
rule_id, uid, clear=clear_fields or (), **fields,
|
|
)
|
|
if rule is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
|
|
|
|
|
async def rule_history(rule_id: int, version_id: int = 0) -> dict:
|
|
"""What a rule USED TO SAY, newest change first.
|
|
|
|
Read this before you argue with a rule, and before you rewrite one. A
|
|
rule that has been reworded may have been reworded for a reason you are
|
|
about to rediscover the hard way — and the wording it replaced is often
|
|
the fastest way to see what the current one is guarding against. The
|
|
rescoping of rule 79 is the case this exists for: the superseded
|
|
statement had to be hand-copied into a task log to survive the edit.
|
|
|
|
EACH ENTRY HOLDS THE TEXT THE EDIT REPLACED, not the text it introduced.
|
|
So "what did this say before the most recent change?" is the first entry,
|
|
and the text the change PRODUCED is the rule as it stands now — read that
|
|
with get_rule. Pair the two and you have the diff.
|
|
|
|
An empty history is ordinary and means the rule has never been reworded,
|
|
not that its history was lost. Nothing is written before milestone 323,
|
|
so a rule edited before then starts empty too.
|
|
|
|
Args:
|
|
rule_id: The rule whose history to read.
|
|
version_id: 0 (default) lists the history — when each change
|
|
happened, by whom, and the title as it then stood. Pass an id
|
|
from that list to read that snapshot IN FULL. The list omits
|
|
statement and why on purpose: a rule's statement runs to
|
|
thousands of characters, and a history carrying every field would
|
|
cost more to read than the answer is worth.
|
|
|
|
There is deliberately no restore. Putting an old wording back is a
|
|
decision, so it goes through update_rule — which snapshots what it
|
|
replaces, leaving the undo visible in the history like any other edit. A
|
|
one-click revert would erase the only record of why the rewrite happened.
|
|
"""
|
|
uid = current_user_id()
|
|
if version_id:
|
|
version = await rulebooks_svc.get_rule_version(rule_id, version_id, uid)
|
|
if version is None:
|
|
raise ValueError(
|
|
f"version {version_id} not found on rule {rule_id}"
|
|
)
|
|
return version.to_dict(include_text=True)
|
|
|
|
versions = await rulebooks_svc.list_rule_versions(rule_id, uid)
|
|
if versions is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
# Fail-open, like the deletes: a missing title must not turn a readable
|
|
# history into an error.
|
|
try:
|
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
|
except Exception:
|
|
rule = None
|
|
return {
|
|
"rule_id": rule_id,
|
|
"title": rule.title if rule else "",
|
|
"versions": [v.to_dict(include_text=False) for v in versions],
|
|
"total": len(versions),
|
|
# Said in-band because an empty list is the ordinary case and reads
|
|
# like a missing feature otherwise.
|
|
"note": (
|
|
"Each entry holds the text the edit REPLACED. The current wording "
|
|
"is on the rule itself — get_rule(%d)." % rule_id
|
|
if versions else
|
|
"This rule has never been reworded."
|
|
),
|
|
}
|
|
|
|
|
|
async def move_rule(rule_id: int, topic_id: int = 0, project_id: int = 0) -> dict:
|
|
"""Move a rule to a new home, keeping its id, history, areas and edges.
|
|
|
|
A rule's home IS its reach. In a rulebook topic it is GLOBAL: it applies in
|
|
every project and reaches any session whose work matches it. On a project
|
|
it applies to that project only. So this is how a project rule that turns
|
|
out to hold everywhere becomes global (pass `topic_id`), and how a global
|
|
rule that only one project needs becomes that project's (pass
|
|
`project_id`). Name exactly one.
|
|
|
|
Reach for this INSTEAD of recreating the rule in the other home and
|
|
deleting the original: that loses the id every record cites it by, its
|
|
edit history, its area tags and its relations.
|
|
|
|
A move is a decision about where a rule binds, so propose it and move on a
|
|
yes, the way create_rule proposes a new rule — and record why where the
|
|
decision lives (a task or note). The rule's history does not record a
|
|
move: it holds what the rule SAID, and a move changes none of that.
|
|
|
|
Refused with a message when: neither or both destinations are named, the
|
|
destination is not yours, the rule is already there, or the topic already
|
|
has a rule with this title (rename one first).
|
|
"""
|
|
uid = current_user_id()
|
|
rule = await rulebooks_svc.move_rule(
|
|
rule_id, uid, topic_id=topic_id, project_id=project_id,
|
|
)
|
|
if rule is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
return await rulebooks_svc.rule_detail(uid, rule)
|
|
|
|
|
|
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
|
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
|
|
uid = current_user_id()
|
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
|
if rule is None:
|
|
raise ValueError(f"rule {rule_id} not found")
|
|
if not confirmed:
|
|
return {
|
|
"warning": (
|
|
f"Rule {rule_id} ('{rule.title}') will be moved to the trash "
|
|
f"(recoverable via restore). Pass confirmed=True to proceed."
|
|
),
|
|
"confirmed_required": True,
|
|
}
|
|
batch = await trash_svc.delete(uid, "rule", rule_id)
|
|
return {"deleted": rule_id, "title": rule.title, "deleted_batch_id": batch,
|
|
"message": f'Rule {rule_id} ("{rule.title}") moved to trash. '
|
|
f"Restore with restore('{batch}')."}
|
|
|
|
|
|
async def relate_rules(
|
|
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
|
) -> dict:
|
|
"""Draw a typed edge between two rules. Both must be yours.
|
|
|
|
Reach for this INSTEAD of merging or duplicating:
|
|
|
|
- kind="co_surfaces" — these two fail together, so they must arrive
|
|
together. Use it when you are tempted to fold one rule into another
|
|
because "either could surface without the other": that instinct is
|
|
right and merging is the wrong fix, because a merged rule cannot be
|
|
cited or surfaced a clause at a time. Symmetric — draw it
|
|
once, it reads from both ends.
|
|
- kind="overrides" — this rule supersedes that one for its scope. Use it
|
|
when a project rule is stricter than, or replaces, an inherited one,
|
|
instead of writing a near-copy that will drift from its parent.
|
|
- kind="elaborates" — this rule adds local specifics to that one, and
|
|
should arrive with it rather than instead of it.
|
|
|
|
Idempotent: re-drawing an existing edge returns it.
|
|
|
|
Args:
|
|
note: WHY the edge holds. Worth writing for the same reason a rule
|
|
carries `why` — a later reader deciding whether it still applies
|
|
needs the reasoning, not just the fact.
|
|
"""
|
|
uid = current_user_id()
|
|
relation = await rulebooks_svc.add_rule_relation(
|
|
uid, from_rule_id, to_rule_id, kind, note,
|
|
)
|
|
if relation is None:
|
|
raise ValueError(
|
|
f"rule {from_rule_id} or {to_rule_id} not found (both must be yours)"
|
|
)
|
|
return relation.to_dict()
|
|
|
|
|
|
async def unrelate_rules(relation_id: int) -> dict:
|
|
"""Remove one edge between rules (from relate_rules / get_rule.relations)."""
|
|
uid = current_user_id()
|
|
if not await rulebooks_svc.remove_rule_relation(uid, relation_id):
|
|
raise ValueError(f"relation {relation_id} not found")
|
|
return {"deleted": relation_id}
|
|
|
|
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
|
|
|
async def rules_due_for_verification(
|
|
older_than_days: int = 0, never_only: bool = False,
|
|
) -> dict:
|
|
"""Which standing rules assert a FACT that nobody has confirmed lately.
|
|
|
|
A rulebook holds two kinds of thing. Most rules are DECISIONS — how the
|
|
operator wants to work. They have no truth value and cannot rot. A few
|
|
assert a fact about someone else's software: what a CI runner does, which
|
|
tools exist, what a setting is currently set to. Those go false silently,
|
|
with nobody present, and they keep being handed to every session as
|
|
binding instructions long after they stopped being true.
|
|
|
|
This lists the second kind, oldest verification first, never-checked at
|
|
the top. Each row carries the rule's `verify_with` in full — you are
|
|
about to go and run it — plus `expires_when`, and `days_since_verified`.
|
|
|
|
Reach for it when you are curating the rulebook, when a rule's advice
|
|
just contradicted what you observed, or periodically. Then, for each row:
|
|
run the check, and call mark_rule_verified with what you found.
|
|
|
|
Rules with no `verify_with` never appear here. That is correct: they are
|
|
decisions, and there is nothing to go and check. Do not "fix" their
|
|
absence by giving them checks — the list is only worth reading while
|
|
everything on it genuinely can go false.
|
|
|
|
Args:
|
|
older_than_days: only rules last verified longer ago than this.
|
|
Never-checked rules always qualify. 0 = no age filter.
|
|
never_only: only rules nobody has ever verified.
|
|
|
|
NOT filterable by project, deliberately: a project is bound by its own
|
|
rules AND every global rule, and a filter that dropped the global ones
|
|
would UNDER-report — which is the exact failure this whole surface exists
|
|
to prevent. Read the whole list.
|
|
"""
|
|
uid = current_user_id()
|
|
rules = await rulebooks_svc.rules_due_for_verification(
|
|
uid, older_than_days=older_than_days, never_only=never_only,
|
|
)
|
|
return {
|
|
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
|
"total": len(rules),
|
|
}
|
|
|
|
|
|
async def mark_rule_verified(rule_id: int, still_true: bool = True) -> dict:
|
|
"""Record that you ran a rule's check — and what it said.
|
|
|
|
Call this AFTER actually running the rule's `verify_with`, never on the
|
|
strength of the rule sounding plausible. A stamp nobody earned is worse
|
|
than no stamp: it moves the rule to the bottom of the sweep and buys it
|
|
another long silence.
|
|
|
|
`still_true=False` writes NOTHING. A rule whose check failed is not in a
|
|
special state to be recorded — it is WRONG, and the only honest next
|
|
moves are to correct it, retire it, or find out why. So it stays at the
|
|
top of the sweep until someone deals with it, and the response tells you
|
|
what the rule said would end it.
|
|
|
|
Args:
|
|
rule_id: the rule whose check you ran.
|
|
still_true: True if the check passed. False if the fact it asserts is
|
|
no longer true — say so, that is the outcome worth having.
|
|
"""
|
|
uid = current_user_id()
|
|
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, still_true)
|
|
if rule is None:
|
|
raise ValueError(
|
|
f"rule {rule_id} not found, or carries no verify_with "
|
|
f"(nothing to verify is not the same as verified)"
|
|
)
|
|
data = await rulebooks_svc.rule_detail(uid, rule)
|
|
if still_true:
|
|
data["verified"] = True
|
|
return data
|
|
data["verified"] = False
|
|
data["next"] = (
|
|
"This rule is no longer true and is still binding on every session "
|
|
"that loads it. Correct it with update_rule, retire it with "
|
|
"delete_rule, or open a task to work out what replaced it. Its "
|
|
"verified_at is deliberately untouched, so it stays at the top of "
|
|
"rules_due_for_verification until one of those happens."
|
|
)
|
|
return data
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
|
list_topics, create_topic, update_topic, delete_topic,
|
|
list_rules, get_rule, rule_outcome,
|
|
create_rule, create_project_rule, update_rule, move_rule, delete_rule,
|
|
create_preference, update_preference,
|
|
relate_rules, unrelate_rules,
|
|
rules_due_for_verification, mark_rule_verified,
|
|
rule_history,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|