diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py
index dc1ce5ca..a397e689 100644
--- a/src/scribe/mcp/server.py
+++ b/src/scribe/mcp/server.py
@@ -154,6 +154,11 @@ _READ_ONLY_TOOLS = frozenset({
# most: it is the surface the "ask before acting" reflex calls, and a key
# that could not reach it would be denied exactly the check it should run.
"what_might_apply",
+ # The moment catalog (milestone 458). A pure read of a constant; the
+ # mount and mapping writes that use it are separate tools. Spelled out for
+ # retrieval_telemetry's reason, and needed by a read key so that a line
+ # naming a moment can be understood by whoever was shown it.
+ "list_moments",
})
# Every tool that WRITES, by name. Nothing reads this set at runtime — a tool
diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py
index 1343d149..78bb9671 100644
--- a/src/scribe/mcp/tools/__init__.py
+++ b/src/scribe/mcp/tools/__init__.py
@@ -6,7 +6,7 @@ from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
- retrieval_review, retrieval_tuning,
+ moments, retrieval_review, retrieval_tuning,
wide_net,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
)
@@ -18,6 +18,7 @@ def register_all(mcp) -> None:
retrieval_tuning.register(mcp)
retrieval_review.register(mcp)
wide_net.register(mcp)
+ moments.register(mcp)
notes.register(mcp)
tasks.register(mcp)
projects.register(mcp)
diff --git a/src/scribe/mcp/tools/moments.py b/src/scribe/mcp/tools/moments.py
new file mode 100644
index 00000000..212342a5
--- /dev/null
+++ b/src/scribe/mcp/tools/moments.py
@@ -0,0 +1,36 @@
+"""The moment catalog as an MCP tool (milestone 458 step 1).
+
+A session needs the vocabulary in hand to mount a rule, to correct which
+action reaches which moment, and to read a moment line it was shown — and it
+needs it without leaving the session for a settings page. So the catalog is a
+tool from the first step, ahead of the writes that will use it.
+"""
+from __future__ import annotations
+
+from scribe.services import moments as moments_svc
+
+
+async def list_moments() -> dict:
+ """The moments of work that rules mount on — their names and what each means.
+
+ A rule mounted on a moment arrives whenever that moment happens, whatever
+ the words of the work look like. That is how a rule reaches you when it is
+ about WHEN something is done rather than WHAT it is about: a rule on
+ finishing work belongs at `work.finish` and `reply.report`, where nothing
+ said needs to resemble it.
+
+ Read this when you are about to mount a rule, when a line you were shown
+ names a moment and you want its meaning, or when you are correcting which
+ of your actions reaches which moment. Names are `.`; a named
+ procedure (a skill or a stored process) is its own moment,
+ `skill.`, listed under `families`.
+
+ Each moment says what is happening at it (`means`) and the kinds of action
+ that typically reach it (`reached_by`). The actions are examples: which of
+ YOUR actions reach a moment varies by install and is corrected in-session.
+ """
+ return moments_svc.catalog()
+
+
+def register(mcp) -> None:
+ mcp.tool(name="list_moments")(list_moments)
diff --git a/src/scribe/routes/retrieval.py b/src/scribe/routes/retrieval.py
index 0846676b..1f76417d 100644
--- a/src/scribe/routes/retrieval.py
+++ b/src/scribe/routes/retrieval.py
@@ -17,6 +17,7 @@ import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
+from scribe.services import moments as moments_svc
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
logger = logging.getLogger(__name__)
@@ -104,3 +105,15 @@ async def tuning_history_route():
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({"events": events, "total": len(events)})
+
+
+@retrieval_bp.route("/moments", methods=["GET"])
+@login_required
+async def moments_route():
+ """The moments of work that rules mount on (milestone 458).
+
+ The same catalog `list_moments` returns, from the same service: the
+ Settings view of mounts and mappings reads its vocabulary here, so the two
+ doors cannot name different moments.
+ """
+ return jsonify(moments_svc.catalog())
diff --git a/src/scribe/services/moments.py b/src/scribe/services/moments.py
new file mode 100644
index 00000000..1616ca23
--- /dev/null
+++ b/src/scribe/services/moments.py
@@ -0,0 +1,157 @@
+"""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
+ """`.` — 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}",
+ 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.?"""
+ 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 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),
+ }
diff --git a/tests/test_moments.py b/tests/test_moments.py
new file mode 100644
index 00000000..1655e890
--- /dev/null
+++ b/tests/test_moments.py
@@ -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 ."
+
+
+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.`
+ 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
diff --git a/tests/test_routes_retrieval_tuning.py b/tests/test_routes_retrieval_tuning.py
index e61e6e08..883fdb52 100644
--- a/tests/test_routes_retrieval_tuning.py
+++ b/tests/test_routes_retrieval_tuning.py
@@ -42,6 +42,7 @@ def test_every_endpoint_is_reachable_on_the_app():
"/api/retrieval/surfaces",
"/api/retrieval/surfaces/",
"/api/retrieval/tuning-history",
+ "/api/retrieval/moments",
}