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
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>
172 lines
6.7 KiB
Python
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),
|
|
}
|