Files
FabledGrimoire/src/scribe/services/moments.py
T
bvandeusenandClaude Opus 5.5 cc26054437
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 29s
feat(moments): rules mount on moments, through every rule door (milestone 458 step 3, #4921)
rule_moments (migration 0117) records which moments a rule arrives at, by
catalog name, cascading with the rule. rule_detail, the one seam every
rule door already returns through, gains moments beside system_ids: None
leaves the mounts alone, a list replaces them. get_rule and both
list_rules doors read them back, batched per page.

All five MCP rule/preference writes and the three REST ones take moments
and validate them before their create or update. An unknown name is
refused with the catalog listed and leaves no half-made rule behind; a
parity test pins that ordering on every door.

Backup v22 carries the mounts as a join table remapped through the rule
map; a real-Postgres round trip checks they land on the restored rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:03:51 -04:00

172 lines
6.7 KiB
Python

"""The moments of work that rules mount on (milestone 458).
WHY THIS EXISTS
Semantic retrieval answers "what does this text resemble?", and most rules are
not ABOUT a topic — they belong to a MOMENT in the work. A rule about when work
counts as finished governs the moment work is sent out, checked, closed and
reported, and nothing said at those moments needs to resemble the rule. Matched
on topic, it scores below every bar; mounted on its moments, it cannot miss.
So a rule names the moments it belongs to, and when one happens every rule
mounted on it arrives — by lookup, with no embedding and no score. Semantic
retrieval stays the second net, for whatever nobody mounted.
WHY THE CATALOG IS CODE, NOT DATA
The moments are the product's vocabulary, not an install's. Every install needs
the same mount points, or a rule written on one could not mean the same thing on
another, and the plugin's hooks, the skills that declare moments and the
instruction surfaces all name them. What varies per install is which ACTIONS
reach a moment — one operator delivers with a push, another with a deploy
script, a third by sending a document — and that mapping is data (step 2). The
moment is generic; the action that reaches it is local.
HOW TO WRITE ONE
Domain-neutral (rule 115): Scribe's work may be software, configuration,
infrastructure or anything else a person drives through an agent, so a
definition names the moment in words every one of those recognises. The test
beside this module refuses software-only vocabulary in both fields. Concrete
actions belong in the default mappings, where they are examples of reaching a
moment rather than the meaning of it.
Names are expensive to change once rules point at them. Adding one is cheap;
renaming one is a migration of every mount.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Moment:
"""One point in the work that rules can mount on."""
name: str
"""`<area>.<verb>` — the string a mount stores and a hook reports."""
means: str
"""What is happening at this moment, in one line, for any kind of work."""
reached_by: str
"""The kinds of action that typically reach it, said generically."""
def _m(name: str, means: str, reached_by: str) -> tuple[str, Moment]:
return name, Moment(name=name, means=means, reached_by=reached_by)
MOMENTS: dict[str, Moment] = dict([
_m("session.start",
"a working session begins, or resumes after its context was summarized",
"opening a session; resuming after the earlier conversation was summarized"),
_m("work.start",
"taking up a piece of work",
"marking a task as in progress; beginning a step of a plan"),
_m("work.plan",
"designing an approach before carrying it out",
"opening a plan; entering a planning mode; loading a planning procedure"),
_m("work.change",
"altering the thing being worked on",
"editing or creating a file; changing a setting or a document"),
_m("work.run",
"carrying out an action in the environment",
"running a command or a script"),
_m("work.verify",
"checking that the work does what it should",
"running a check; reading the result of an automated check; "
"loading a verification procedure"),
_m("work.deliver",
"sending work beyond the place it was made",
"publishing, merging, releasing, deploying or sending it to someone"),
_m("work.finish",
"declaring a piece of work done, or abandoning it",
"closing a task or an issue; marking it done or cancelled"),
_m("work.record",
"writing down what was learned or decided",
"creating a note, a lesson, a rule or a reusable example"),
_m("work.debug",
"working out why something does not behave as expected",
"reading a failure; loading a diagnosis procedure"),
_m("work.delegate",
"handing part of the work to another agent",
"dispatching a helper agent"),
_m("env.reach",
"touching a machine or a service outside the workspace",
"connecting to a host; calling a remote service; inspecting what runs there"),
_m("reply.report",
"reporting results to the person the work is for",
"the reply that ends a turn; loading a reporting procedure"),
_m("reply.ask",
"asking the person the work is for to decide or to act",
"a question in the reply; a structured question to the person"),
])
# A FAMILY rather than entries: one moment per named procedure, reached when it
# is loaded. The names are the procedures' own (bundled skills, stored
# processes), so they cannot be listed here — only their shape can.
SKILL_PREFIX = "skill."
SKILL_FAMILY = Moment(
name=f"{SKILL_PREFIX}<name>",
means="a named procedure is loaded",
reached_by="loading a skill or a stored process by its name",
)
_SKILL_NAME = re.compile(r"^[a-z0-9][a-z0-9_:-]*$")
def is_moment(name: str) -> bool:
"""Is this a name a rule can mount on — a catalog moment, or skill.<name>?"""
if name in MOMENTS:
return True
if name.startswith(SKILL_PREFIX):
return bool(_SKILL_NAME.match(name[len(SKILL_PREFIX):]))
return False
def require_moment(name: str) -> str:
"""The name, normalised, or a refusal that lists what is available.
A typo'd moment must not be mountable: the mount would be stored, read
back, and never fire — a rule that looks attached and is not, which is the
silent failure moments exist to end.
"""
clean = (name or "").strip().lower()
if is_moment(clean):
return clean
raise ValueError(
f"unknown moment {name!r}. Moments are: "
+ ", ".join(MOMENTS)
+ f", or {SKILL_FAMILY.name} for a named procedure"
)
def require_moments(names: list[str] | None) -> list[str] | None:
"""A rule's moments, normalised and de-duplicated — or the first refusal.
None passes through as None, which every rule door reads as "leave the
mounts alone"; a list, [] included, is the whole set after the write.
All are checked before any is stored, so a refusal leaves nothing
half-mounted.
"""
if names is None:
return None
if isinstance(names, str):
names = [names]
return list(dict.fromkeys(require_moment(n) for n in names))
def catalog() -> dict:
"""The catalog as plain data, for the MCP tool and the REST door alike."""
return {
"moments": [
{"name": m.name, "means": m.means, "reached_by": m.reached_by}
for m in MOMENTS.values()
],
"families": [
{"name": SKILL_FAMILY.name, "prefix": SKILL_PREFIX,
"means": SKILL_FAMILY.means, "reached_by": SKILL_FAMILY.reached_by},
],
"total": len(MOMENTS),
}