feat(moments): the moment catalog rules will mount on, readable in-session (milestone 458 step 1, #4919)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 32s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 32s
Fourteen generic moments of work (session.start, work.start … reply.ask) plus the skill.<name> family, each with what it means and the kinds of action that reach it, written for any kind of work rather than software alone. The catalog is code because every install needs the same mount points; which actions reach a moment is per-install data (step 2). require_moment refuses an unknown name with the catalog listed, so a typo cannot become a mount that never fires. list_moments (read-only) and GET /api/retrieval/moments hand out the same catalog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""The moment catalog — the vocabulary rules mount on (milestone 458 step 1).
|
||||
|
||||
What this pins is what a mount depends on: every name is well-formed and
|
||||
resolvable, an unknown name is refused rather than stored, the catalog speaks
|
||||
for any kind of work rather than only software, and both doors — the MCP tool
|
||||
and the REST read — hand out the same catalog.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services import moments
|
||||
from tests.helpers import FakeMCP
|
||||
|
||||
_NAME = re.compile(r"^[a-z]+\.[a-z]+$")
|
||||
|
||||
|
||||
def test_the_catalog_is_not_empty():
|
||||
"""The sweeps below are vacuous over an empty catalog (rule 167)."""
|
||||
assert len(moments.MOMENTS) >= 10
|
||||
|
||||
|
||||
def test_every_moment_is_keyed_by_its_own_well_formed_name():
|
||||
for key, m in moments.MOMENTS.items():
|
||||
assert key == m.name, f"{key!r} is filed under a name it does not carry"
|
||||
assert _NAME.match(key), f"{key!r} is not <area>.<verb>"
|
||||
|
||||
|
||||
def test_every_moment_says_what_it_means_and_how_it_is_reached():
|
||||
for m in moments.MOMENTS.values():
|
||||
assert m.means.strip() and m.reached_by.strip(), m.name
|
||||
assert "\n" not in m.means, f"{m.name}: `means` is one line"
|
||||
|
||||
|
||||
def test_the_skill_family_is_not_a_catalog_entry():
|
||||
"""A family is a shape, not a moment — no rule mounts on `skill.<name>`
|
||||
literally, and a catalog key starting with the prefix would shadow it."""
|
||||
assert not any(k.startswith(moments.SKILL_PREFIX) for k in moments.MOMENTS)
|
||||
|
||||
|
||||
# Software-only vocabulary, the same guard the completion query carries. The
|
||||
# moments are named for any work a person drives through an agent; the actions
|
||||
# particular to one kind of work belong in the mappings, not in the meaning.
|
||||
_DEV_ONLY = (r"\bCI\b", r"\bcommit", r"\bpull request", r"\bcode\b", r"\btest",
|
||||
r"\bgit\b", r"\brepo(s|sitor\w*)?\b", r"\bbranch", r"\bcompil", r"\bbuild\b")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["means", "reached_by"])
|
||||
def test_the_catalog_assumes_no_particular_domain(field):
|
||||
found = {
|
||||
m.name: hits
|
||||
for m in [*moments.MOMENTS.values(), moments.SKILL_FAMILY]
|
||||
if (hits := [w for w in _DEV_ONLY
|
||||
if re.search(w, getattr(m, field), re.IGNORECASE)])
|
||||
}
|
||||
assert not found, f"software-only vocabulary in `{field}`: {found}"
|
||||
|
||||
|
||||
def test_the_guard_can_fail():
|
||||
assert re.search(_DEV_ONLY[1], "after the commit lands", re.IGNORECASE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(moments.MOMENTS))
|
||||
def test_every_catalog_moment_is_mountable(name):
|
||||
assert moments.require_moment(name) == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,clean", [
|
||||
(" Work.Finish ", "work.finish"),
|
||||
("skill.verification", "skill.verification"),
|
||||
("skill.scribe:writing-plans", "skill.scribe:writing-plans"),
|
||||
("skill.scribe-proc-release_notes", "skill.scribe-proc-release_notes"),
|
||||
])
|
||||
def test_a_name_is_normalised_before_it_is_judged(raw, clean):
|
||||
assert moments.require_moment(raw) == clean
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"work.finished", "finish", "", "skill.", "skill.two words", "work.",
|
||||
])
|
||||
def test_an_unknown_moment_is_refused_with_the_catalog(bad):
|
||||
"""A typo stored as a mount never fires — the silent failure moments end."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
moments.require_moment(bad)
|
||||
assert "work.finish" in str(exc.value)
|
||||
assert moments.SKILL_FAMILY.name in str(exc.value)
|
||||
|
||||
|
||||
def test_the_catalog_payload_carries_every_moment_and_the_family():
|
||||
data = moments.catalog()
|
||||
assert [m["name"] for m in data["moments"]] == list(moments.MOMENTS)
|
||||
assert data["total"] == len(moments.MOMENTS)
|
||||
assert data["families"][0]["prefix"] == moments.SKILL_PREFIX
|
||||
|
||||
|
||||
async def test_the_tool_returns_the_service_catalog():
|
||||
from scribe.mcp.tools import moments as tool
|
||||
|
||||
assert await tool.list_moments() == moments.catalog()
|
||||
|
||||
|
||||
def test_the_tool_is_registered_and_read_only():
|
||||
from scribe.mcp.server import _READ_ONLY_TOOLS
|
||||
from scribe.mcp.tools import moments as tool
|
||||
|
||||
mcp = FakeMCP()
|
||||
tool.register(mcp)
|
||||
assert mcp.names == ["list_moments"]
|
||||
assert "list_moments" in _READ_ONLY_TOOLS
|
||||
|
||||
|
||||
def test_both_doors_read_one_catalog():
|
||||
"""Rule 33 parity: the tool and the route call the same service, so the
|
||||
session and the Settings view cannot name different moments."""
|
||||
from scribe.mcp.tools import moments as tool
|
||||
from scribe.routes import retrieval as routes
|
||||
|
||||
assert tool.moments_svc is moments
|
||||
assert routes.moments_svc is moments
|
||||
Reference in New Issue
Block a user