feat(moments): actions map onto moments, with in-session corrections (milestone 458 step 2, #4920)
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m23s
CI & Build / Python tests (push) Successful in 2m0s
CI & Build / Build & push image (push) Successful in 36s

moment_actions.resolve(tool, input) names every moment a call reaches and
the action that reached it. One call can reach several: kubectl apply is
a run, a deliver and a reach outside the workspace. Command tools match by
how each segment of the line starts, with a word boundary; other tools by
field=value arguments. The MCP server prefix and case are ignored.

56 shipped defaults cover the harness tools, Scribe tools and common
command shapes. moment_mappings (migration 0116) holds what an install
adds and the defaults it switches off. A removal is a stored row, so an
upgrade does not switch the default back on.

Per the operator ruling, corrections happen in the session: map_action
and unmap_action (write tools) return now_reaches so the fix can be
confirmed in the same reply. list_moments now shows each moment's
actions on this install. REST mirrors both doors, recorded as human.
Backup v21 carries the mappings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:58:00 -04:00
co-authored by Claude Opus 5.5
parent 2ff7f2f34f
commit cf3de5bae1
13 changed files with 1039 additions and 29 deletions
+47
View File
@@ -0,0 +1,47 @@
"""moment_mappings — an install's own corrections to which actions reach which
moment (milestone 458 step 2)
Revision ID: 0116
Revises: 0115
Create Date: 2026-10-05
The moments and their default actions are code; this table holds what an
install adds ("make ship" is a deliver here) and the defaults it switches off.
User-owned configuration, so it cascades with the user. No CHECK on `effect`,
which the service validates (rule 36).
"""
import sqlalchemy as sa
from alembic import op
revision = "0116"
down_revision = "0115"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"moment_mappings",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("tool", sa.Text(), nullable=False),
sa.Column("match", sa.Text(), nullable=False, server_default=""),
sa.Column("moment", sa.Text(), nullable=False),
sa.Column("effect", sa.Text(), nullable=False, server_default="add"),
sa.Column("reason", sa.Text(), nullable=False, server_default=""),
sa.Column("actor", sa.Text(), nullable=False, server_default="model"),
sa.UniqueConstraint(
"user_id", "tool", "match", "moment", name="uq_moment_mapping",
),
)
def downgrade() -> None:
op.drop_table("moment_mappings")
+3
View File
@@ -207,6 +207,9 @@ _WRITE_TOOLS = frozenset({
# reads, and it appends the reason to the audit trail (#4102).
"tune_retrieval",
"migrate_retrieval_floor",
# Which actions reach which moment on this install (milestone 458). Each
# changes what fires for every later session, so a read key is refused.
"map_action", "unmap_action",
# A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
# prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu",
+89 -14
View File
@@ -1,17 +1,24 @@
"""The moment catalog as an MCP tool (milestone 458 step 1).
"""Moments as MCP tools: the catalog, and the in-session corrections to it (milestone 458).
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.
A session needs the vocabulary in hand to mount a rule, to read a line that
names a moment, and to fix a misfire — and the operator's ruling is that the
fix happens in the session, not on a settings page:
"making the user leave the session to fix a misfire is not desirable and
the llm session should be able to offer corrections"
So the mapping writes are tools, built to be offered mid-work and made on the
operator's yes.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import moment_actions as actions_svc
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.
"""The moments of work that rules mount on, and which actions reach each one here.
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
@@ -20,17 +27,85 @@ async def list_moments() -> dict:
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 `<area>.<verb>`; a named
procedure (a skill or a stored process) is its own moment,
`skill.<name>`, listed under `families`.
names a moment and you want its meaning, or when an action reached the
wrong moment — or none — and you are about to correct it with
`map_action` / `unmap_action`.
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.
Names are `<area>.<verb>`; a named procedure (a skill or a stored process)
is its own moment, `skill.<name>`, listed under `families`. Each moment
says what is happening at it (`means`) and the kinds of action that
typically reach it (`reached_by`).
`actions` maps each moment to the actions that reach it on this install:
`via: "default"` ships with the product, `via: "install"` is this
install's own mapping. `removed_defaults` are shipped defaults this install
switched off.
"""
return moments_svc.catalog()
out = moments_svc.catalog()
out.update(await actions_svc.actions_by_moment(current_user_id()))
return out
async def map_action(tool: str, moment: str, match: str = "", reason: str = "") -> dict:
"""Make an action reach a moment on this install — the in-session fix for a missed moment.
Use it when an action plainly happened at a moment and the moment did not
fire: the operator ships with `make ship`, and nothing mounted on
`work.deliver` arrived. Offer the mapping when you notice, in one line
("that `make ship` was a deliver and nothing fired — map it?"), and make it
on their yes. The correction lasts: every later session on this install
gets it.
Args:
tool: the tool as the harness names it — `Bash`, `Edit`,
`update_task`. An MCP server prefix is ignored.
moment: a moment from `list_moments`, or `skill.<name>`.
match: which calls of the tool. Empty = every call. For a tool that
runs a command, how the command starts (`make ship`, `./deploy.sh`)
— it is checked against each part of a compound command line. For
any other tool, its arguments as `field=value` pairs, comma-separated
(`status=done`).
reason: what misfired, or what this action is for here. Optional; it
is shown beside the mapping when someone reviews it later.
Returns the change made and `now_reaches`: every moment that action
reaches after the change, so you can confirm it in the same reply.
Mapping a shipped default this install had switched off switches it back
on.
"""
return await actions_svc.map_action(
current_user_id(), tool, match, moment, reason=reason, actor="model",
)
async def unmap_action(tool: str, moment: str, match: str = "", reason: str = "") -> dict:
"""Stop an action reaching a moment on this install — the fix for a moment that fires wrongly.
Use it when a moment fires on an action that is not that moment here — a
`curl` to a local test server reaching `env.reach`, say — and the rules it
brings are noise every time. Offer it when you notice, and make it on the
operator's yes.
The arguments name the mapping exactly as `list_moments` shows it. This
install's own mapping is removed; a shipped default is switched off for
this install only, and stays off across upgrades. `map_action` with the
same arguments switches a default back on.
Args:
tool: the tool as `list_moments` names it.
moment: the moment it should stop reaching.
match: the mapping's match, as listed (empty for a whole-tool mapping).
reason: why it misfires here — worth giving for a default, since it is
the record of why this install differs from the product.
Returns the change made and `now_reaches` for that action.
"""
return await actions_svc.unmap_action(
current_user_id(), tool, match, moment, reason=reason, actor="model",
)
def register(mcp) -> None:
mcp.tool(name="list_moments")(list_moments)
mcp.tool(name="map_action")(map_action)
mcp.tool(name="unmap_action")(unmap_action)
+1
View File
@@ -62,6 +62,7 @@ from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
from scribe.models.rule_usage import RuleUsageEvent # noqa: E402, F401
from scribe.models.system_usage import SystemUsageEvent # noqa: E402, F401
from scribe.models.retrieval_judgment import RetrievalJudgment # noqa: E402, F401
from scribe.models.moment_mapping import MomentMapping # noqa: E402, F401
from scribe.models.project import Project # noqa: E402, F401
from scribe.models.milestone import Milestone # noqa: E402, F401
from scribe.models.task_log import TaskLog # noqa: E402, F401
+75
View File
@@ -0,0 +1,75 @@
from sqlalchemy import BigInteger, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
class MomentMapping(Base, CreatedAtMixin):
"""One install's correction to which actions reach which moment (milestone 458).
The moments are the product's vocabulary and the shipped defaults say how
the common actions reach them (`services/moment_actions.py`). What no
default can know is how THIS operator works: one delivers with a push,
another with `make ship`, a third by publishing a document. A row here is
that local knowledge, written in-session the moment a misfire is noticed.
`effect` is "add" (this action reaches this moment) or "remove" (a shipped
default that misfires here is switched off). Removal is a row rather than
an edit to the defaults because the defaults are code: an install can only
say "not here", and saying so must survive an upgrade that ships the same
default again.
Text rather than a CHECK on `effect`, for retrieval_tuning_events' reason
(rule 36): the service validates it, and a constraint would buy nothing but
a migration the day a third effect is wanted.
ONE ROW PER (user, tool, match, moment), so mapping the same thing twice
is an update of the reason rather than a duplicate, and an add and a remove
of the same mapping cannot both stand.
CASCADES with the user, unlike the telemetry tables: this is the user's own
configuration, not a history that should outlive them.
"""
__tablename__ = "moment_mappings"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
# The tool as the harness names it, with any MCP server prefix stripped
# (`mcp__plugin_x__update_task` → `update_task`), so a mapping does not
# depend on what an install called its server.
tool: Mapped[str] = mapped_column(Text, nullable=False)
# "" = every call of the tool. For a tool that runs a command, how the
# command starts ("make ship"); for any other tool, `field=value` pairs.
match: Mapped[str] = mapped_column(Text, nullable=False, default="")
moment: Mapped[str] = mapped_column(Text, nullable=False)
effect: Mapped[str] = mapped_column(Text, nullable=False, default="add")
# Why — what misfired, or what this action is for here. Optional at the
# boundary: the correction is usually self-explanatory, and a required
# reason would be friction on the exact in-session fix this table exists
# to make cheap.
reason: Mapped[str] = mapped_column(Text, nullable=False, default="")
# "model" | "human", for retrieval_tuning_events' reason: both act as the
# same user, and "did I do this or did the session?" is the first question.
actor: Mapped[str] = mapped_column(Text, nullable=False, default="model")
__table_args__ = (
UniqueConstraint(
"user_id", "tool", "match", "moment", name="uq_moment_mapping",
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"tool": self.tool,
"match": self.match,
"moment": self.moment,
"effect": self.effect,
"reason": self.reason,
"actor": self.actor,
"created_at": iso(self.created_at),
}
+39 -5
View File
@@ -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 moment_actions as moment_actions_svc
from scribe.services import moments as moments_svc
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
@@ -110,10 +111,43 @@ async def tuning_history_route():
@retrieval_bp.route("/moments", methods=["GET"])
@login_required
async def moments_route():
"""The moments of work that rules mount on (milestone 458).
"""The moments of work that rules mount on (milestone 458), with the
actions that reach each one on this install.
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.
The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments.
"""
return jsonify(moments_svc.catalog())
out = moments_svc.catalog()
out.update(await moment_actions_svc.actions_by_moment(get_current_user_id()))
return jsonify(out)
async def _mapping_change(change):
"""map/unmap from the browser: the MCP tools' service, recorded as human."""
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if not data.get("tool") or not data.get("moment"):
return jsonify({"error": "tool and moment are required"}), 400
try:
result = await change(
get_current_user_id(), str(data["tool"]), str(data.get("match") or ""),
str(data["moment"]), reason=str(data.get("reason") or ""), actor="human",
)
except ValueError as e:
# Unknown moment, a match the tool cannot take, nothing to remove —
# the service says what would work, so pass it through.
return jsonify({"error": str(e)}), 400
return jsonify(result)
@retrieval_bp.route("/moments/mappings", methods=["POST"])
@login_required
async def map_action_route():
return await _mapping_change(moment_actions_svc.map_action)
@retrieval_bp.route("/moments/mappings", methods=["DELETE"])
@login_required
async def unmap_action_route():
return await _mapping_change(moment_actions_svc.unmap_action)
+65 -2
View File
@@ -14,6 +14,7 @@ from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.note_usage import NoteUsageEvent
from scribe.models.rule_usage import RuleUsageEvent
from scribe.models.system_usage import SystemUsageEvent
from scribe.models.moment_mapping import MomentMapping
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
@@ -93,8 +94,12 @@ logger = logging.getLogger(__name__)
# 444): the files that are each area, and whether an area's rulings were read
# once shown. The usage rows restore through the SYSTEM map, for the reason
# the rule twin restores through the rule map.
# v21 (2026-10) added moment_mappings (milestone 458): which of this install's
# actions reach which moment, and the shipped defaults it switched off. Each
# row is a correction an operator made in-session; a restore that dropped them
# would silently put every misfire back.
# Bump when the serialized schema changes.
BACKUP_VERSION = 20
BACKUP_VERSION = 21
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -139,6 +144,8 @@ _BACKED_UP = [
# v20 (2026-10): System usage telemetry (milestone 444), for the reason
# its note and rule twins travel.
"system_usage_events",
# v21 (2026-10): an install's action → moment corrections (milestone 458).
"moment_mappings",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -247,6 +254,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
# Same again — and everything else travels, because each remaining column
# is part of the argument: what moved, from what, to what, by whom, why.
"retrieval_tuning_events": {"id"},
# Every other column is the correction itself, or who made it and why.
"moment_mappings": {"id"},
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"repo_bindings": {"id", "created_at", "updated_at"},
@@ -334,6 +343,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"rule_usage_events": {"id"},
"system_usage_events": {"id"},
"retrieval_tuning_events": {"id"},
"moment_mappings": {"id"},
"design_systems": {
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
},
@@ -486,6 +496,21 @@ def _rule_usage_event_rows(rows) -> list[dict]:
]
def _moment_mapping_rows(rows) -> list[dict]:
"""An install's action → moment corrections (milestone 458). Not
`to_dict()`, for _retrieval_tuning_event_rows' reason: the restore needs
`user_id` to remap, and the MCP reader omits it."""
return [
{
"user_id": r.user_id, "tool": r.tool, "match": r.match,
"moment": r.moment, "effect": r.effect, "reason": r.reason,
"actor": r.actor,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _retrieval_tuning_event_rows(rows) -> list[dict]:
"""The record of why a retrieval dial is where it is (#4102).
@@ -836,6 +861,9 @@ async def export_full_backup() -> dict:
retrieval_tuning_events = (await session.execute(
select(RetrievalTuningEvent).order_by(RetrievalTuningEvent.id)
)).scalars().all()
moment_mappings = (await session.execute(
select(MomentMapping).order_by(MomentMapping.id)
)).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
code_shape_events = (await session.execute(
@@ -886,6 +914,7 @@ async def export_full_backup() -> dict:
"retrieval_tuning_events": _retrieval_tuning_event_rows(
retrieval_tuning_events
),
"moment_mappings": _moment_mapping_rows(moment_mappings),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
@@ -1035,6 +1064,13 @@ async def export_user_backup(user_id: int) -> dict:
.where(RetrievalTuningEvent.user_id == user_id)
.order_by(RetrievalTuningEvent.id)
)).scalars().all()
# The user's own configuration: user_id is the owner, nothing to
# route around.
moment_mappings = (await session.execute(
select(MomentMapping)
.where(MomentMapping.user_id == user_id)
.order_by(MomentMapping.id)
)).scalars().all()
rule_relations = (await session.execute(
select(RuleRelation).where(
RuleRelation.from_rule_id.in_(_rule_ids),
@@ -1093,6 +1129,7 @@ async def export_user_backup(user_id: int) -> dict:
"retrieval_tuning_events": _retrieval_tuning_event_rows(
retrieval_tuning_events
),
"moment_mappings": _moment_mapping_rows(moment_mappings),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
@@ -1301,6 +1338,23 @@ def _build_setting(row: dict, maps: _Maps) -> Setting | None:
return Setting(user_id=uid, key=row["key"], value=row.get("value", ""))
def _build_moment_mapping(row: dict, maps: _Maps) -> MomentMapping | None:
"""No remapping beyond the user: `tool` and `moment` are names, not keys."""
uid = maps.users.get(row.get("user_id") or 0)
if uid is None or not row.get("tool") or not row.get("moment"):
return None
return MomentMapping(
user_id=uid,
tool=row["tool"],
match=row.get("match") or "",
moment=row["moment"],
effect=row.get("effect") or "add",
reason=row.get("reason") or "",
actor=row.get("actor") or "model",
created_at=_dt(row.get("created_at")),
)
def _build_retrieval_tuning_event(row: dict, maps: _Maps) -> RetrievalTuningEvent | None:
"""No id remapping beyond the user: `surface` is a registry NAME, not a
foreign key, which is what lets this history survive a restore into an
@@ -1874,7 +1928,7 @@ async def _restore_v2(data: dict) -> dict:
"code_shape_uses": 0, "canonical_systems": 0,
"rule_systems": 0, "rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
"lesson_no_rule": 0,
"lesson_no_rule": 0, "moment_mappings": 0,
}
async with async_session() as session:
@@ -1984,6 +2038,15 @@ async def _restore_v2(data: dict) -> dict:
session.add(event)
stats["retrieval_tuning_events"] += 1
# 8c. Moment mappings (v21) — the install's corrections to which
# actions reach which moment. Names only, so only the user remaps.
for mm_data in data.get("moment_mappings", []):
mapping = _build_moment_mapping(mm_data, maps)
if mapping is None:
continue
session.add(mapping)
stats["moment_mappings"] += 1
# 9. Rulebooks (v3)
for rb_data in data.get("rulebooks", []):
rb = _build_rulebook(rb_data, maps)
+393
View File
@@ -0,0 +1,393 @@
"""Which actions reach which moment: shipped defaults plus each install's own (milestone 458 step 2).
The catalog (`services/moments.py`) says what the moments ARE. This module says
how the work gets there. One call can reach several moments: `kubectl apply`
is a run, a deliver and a reach outside the workspace all at once, and a rule
mounted on any of them should arrive.
AN ACTION is a tool plus an optional `match`:
- `match` empty — every call of that tool (`Edit` → work.change).
- A tool that runs a command (its input carries `command`) — how the command
starts, tested against each segment of a compound line, so
`cd app && make ship` reaches what `make ship` reaches. Leading `VAR=value`
assignments are skipped; a word boundary is required, so `git push` does
not match `git pushd`.
- Any other tool — `field=value` pairs, comma-separated, all of which must
hold (`update_task` with `status=done`).
Tool names are compared without any MCP server prefix and without case:
`mcp__plugin_x__update_task` and `update_task` are one tool, because what an
install called its server is not something a mapping should depend on.
WHY THE DEFAULTS ARE CODE AND THE CORRECTIONS ARE ROWS
The defaults cover the actions every install shares — the harness's own tools,
Scribe's own tools, the commonest command shapes. They ship, and improve, with
the product. What no default can know is how one operator works, so an install
ADDS mappings and REMOVES defaults that misfire for it, in-session, through
`map_action` / `unmap_action`. A removal is stored rather than applied to the
defaults so it survives an upgrade that ships the same default again.
The concrete commands below are examples of reaching a moment, which is why
they may name particular tools when the moments themselves may not.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Iterable
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.moment_mapping import MomentMapping
from scribe.services import moments as catalog
logger = logging.getLogger(__name__)
ADD, REMOVE = "add", "remove"
EFFECTS = (ADD, REMOVE)
DEFAULT, INSTALL = "default", "install"
# The input field that makes a tool a command-runner, and the tools known to
# be one. The field drives MATCHING (any tool whose call carries a command is
# matched by prefix); the names drive VALIDATION, so a prefix-style match on a
# tool that takes no command is refused rather than stored to never fire.
COMMAND_FIELD = "command"
COMMAND_TOOLS = frozenset({"bash"})
# The harness's skill loader: loading a procedure reaches `skill.<its name>`,
# derived from the call rather than listed, since the names are the
# procedures' own.
SKILL_TOOL, SKILL_FIELD = "skill", "skill"
_SEGMENT_SPLIT = re.compile(r"&&|\|\||[;|\n]")
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+")
@dataclass(frozen=True)
class Action:
tool: str
match: str
moment: str
def _defaults() -> tuple[Action, ...]:
out: list[Action] = []
def on(moment: str, tool: str, *matches: str) -> None:
for m in matches or ("",):
out.append(Action(tool, m, moment))
# The harness's own tools.
on("work.change", "Edit")
on("work.change", "Write")
on("work.change", "MultiEdit")
on("work.change", "NotebookEdit")
on("work.run", "Bash")
on("work.delegate", "Task")
on("work.delegate", "Agent")
on("work.plan", "EnterPlanMode")
on("work.plan", "ExitPlanMode")
on("reply.ask", "AskUserQuestion")
# Scribe's own tools — every install has these, so their moments ship.
on("work.start", "update_task", "status=in_progress")
on("work.finish", "update_task", "status=done", "status=cancelled")
on("work.finish", "update_milestone", "status=done")
on("work.plan", "start_planning")
on("work.plan", "create_milestone")
for tool in ("create_note", "create_lesson", "create_rule",
"create_project_rule", "create_preference", "create_snippet",
"create_process"):
on("work.record", tool)
# The commonest command shapes. An install's own (`make ship`,
# `./deploy.sh`) are what map_action is for.
on("work.deliver", "Bash",
"git push", "git merge", "gh pr merge", "gh release create",
"docker push", "kubectl apply", "helm install", "helm upgrade",
"terraform apply", "npm publish", "twine upload", "cargo publish")
on("work.verify", "Bash",
"pytest", "python -m pytest", "npm test", "npm run test", "yarn test",
"pnpm test", "go test", "go vet", "cargo test", "make test",
"make check", "ruff check", "mypy", "terraform plan",
"terraform validate")
on("env.reach", "Bash",
"ssh", "scp", "curl", "wget", "kubectl", "helm")
return tuple(out)
DEFAULT_ACTIONS: tuple[Action, ...] = _defaults()
# ── matching ──────────────────────────────────────────────────────────────
def tool_key(name: str) -> str:
"""The tool's bare, lowercased name: no MCP server prefix."""
clean = (name or "").strip()
if clean.startswith("mcp__"):
clean = clean.rsplit("__", 1)[-1]
return clean.lower()
def _squash(text: str) -> str:
return " ".join((text or "").split())
def _segments(command: str) -> list[str]:
out = []
for part in _SEGMENT_SPLIT.split(command or ""):
seg = part.strip().lstrip("(").strip()
while _ENV_ASSIGN.match(seg):
seg = _ENV_ASSIGN.sub("", seg, count=1)
seg = _squash(seg)
if seg:
out.append(seg)
return out
def _pairs(match: str) -> dict[str, str] | None:
"""`a=b, c=d` → {"a": "b", "c": "d"}; None when it is not that shape."""
out: dict[str, str] = {}
for part in (match or "").split(","):
key, sep, value = part.partition("=")
if not sep or not key.strip():
return None
out[key.strip().lower()] = value.strip().lower()
return out or None
def action_matches(action: Action, tool: str, tool_input: dict | None) -> bool:
if tool_key(action.tool) != tool_key(tool):
return False
if not action.match:
return True
tool_input = tool_input or {}
command = tool_input.get(COMMAND_FIELD)
if isinstance(command, str):
want = _squash(action.match)
return any(seg == want or seg.startswith(want + " ")
for seg in _segments(command))
pairs = _pairs(action.match)
if pairs is None:
return False
given = {str(k).lower(): v for k, v in tool_input.items()}
return all(
str(given.get(k, "")).strip().lower() == v for k, v in pairs.items()
)
def effective_actions(mappings: Iterable) -> list[tuple[Action, str]]:
"""The defaults this install has not removed, then its own additions.
`mappings` are MomentMapping rows, or anything with the same four fields.
"""
removed: set[tuple[str, str, str]] = set()
added: list[Action] = []
for m in mappings:
key = (tool_key(m.tool), _squash(m.match), m.moment)
if m.effect == REMOVE:
removed.add(key)
elif m.effect == ADD:
added.append(Action(m.tool, _squash(m.match), m.moment))
out = [
(a, DEFAULT) for a in DEFAULT_ACTIONS
if (tool_key(a.tool), a.match, a.moment) not in removed
]
out.extend((a, INSTALL) for a in added)
return out
def resolve(tool: str, tool_input: dict | None, mappings: Iterable = ()) -> list[dict]:
"""Every moment this call reaches, each with the action that reached it.
One entry per moment, in catalog order. When two actions reach the same
moment the more specific one — the longer match — is the one named, since
"reached by `git push`" says more than "reached by Bash".
"""
best: dict[str, dict] = {}
for action, via in effective_actions(mappings):
if not action_matches(action, tool, tool_input):
continue
held = best.get(action.moment)
if held is None or len(action.match) > len(held["match"]):
best[action.moment] = {
"moment": action.moment, "tool": action.tool,
"match": action.match, "via": via,
}
if tool_key(tool) == SKILL_TOOL:
name = str((tool_input or {}).get(SKILL_FIELD) or "").strip().lower()
moment = catalog.SKILL_PREFIX + name
if name and catalog.is_moment(moment):
best[moment] = {"moment": moment, "tool": tool, "match": f"skill={name}",
"via": DEFAULT}
order = {name: i for i, name in enumerate(catalog.MOMENTS)}
return sorted(best.values(), key=lambda h: (order.get(h["moment"], len(order)), h["moment"]))
def _sample_input(tool: str, match: str) -> dict:
"""A call that this action would describe — for reporting what it reaches."""
if not match:
return {}
if tool_key(tool) in COMMAND_TOOLS:
return {COMMAND_FIELD: match}
return dict(_pairs(match) or {})
def _clean(tool: str, match: str, moment: str) -> tuple[str, str, str]:
"""Validate a mapping's three parts, or refuse with what would work."""
bare = (tool or "").strip()
if bare.startswith("mcp__"):
bare = bare.rsplit("__", 1)[-1]
if not bare:
raise ValueError("tool is required — the tool as the harness names it, e.g. Bash or update_task")
clean_match = _squash(match)
if clean_match and tool_key(bare) not in COMMAND_TOOLS and _pairs(clean_match) is None:
raise ValueError(
f"{bare} does not run a command, so `match` names its arguments as "
f"field=value pairs (e.g. status=done), not {clean_match!r}. Leave "
f"it empty to map every {bare} call."
)
return bare, clean_match, catalog.require_moment(moment)
def _is_default(tool: str, match: str, moment: str) -> bool:
return any(
tool_key(a.tool) == tool_key(tool) and a.match == match and a.moment == moment
for a in DEFAULT_ACTIONS
)
# ── the install's own ─────────────────────────────────────────────────────
async def list_mappings(user_id: int) -> list[MomentMapping]:
async with async_session() as session:
rows = await session.execute(
select(MomentMapping)
.where(MomentMapping.user_id == user_id)
.order_by(MomentMapping.id)
)
return list(rows.scalars().all())
async def moments_for(user_id: int, tool: str, tool_input: dict | None) -> list[dict]:
"""The moments a call reaches for this user.
Fails OPEN to the defaults: an unreadable mapping table must cost the
install its corrections, not every moment.
"""
try:
mappings = await list_mappings(user_id)
except Exception:
logger.warning("moment mappings unreadable; using the defaults", exc_info=True)
mappings = []
return resolve(tool, tool_input, mappings)
async def _find(session, user_id: int, tool: str, match: str, moment: str):
rows = await session.execute(
select(MomentMapping).where(
MomentMapping.user_id == user_id,
MomentMapping.moment == moment,
MomentMapping.match == match,
)
)
return next((r for r in rows.scalars().all() if tool_key(r.tool) == tool_key(tool)), None)
async def _reaches(user_id: int, tool: str, match: str) -> list[dict]:
return resolve(tool, _sample_input(tool, match), await list_mappings(user_id))
async def map_action(
user_id: int, tool: str, match: str, moment: str,
*, reason: str = "", actor: str = "model",
) -> dict:
"""Make an action reach a moment for this install.
Mapping a shipped default this install had removed restores it; mapping
one already in force changes nothing and says so.
"""
tool, match, moment = _clean(tool, match, moment)
reason = (reason or "").strip()
async with async_session() as session:
row = await _find(session, user_id, tool, match, moment)
if _is_default(tool, match, moment):
if row is not None and row.effect == REMOVE:
await session.delete(row)
await session.commit()
change = "restored the shipped default"
else:
change = "already a shipped default — nothing to add"
elif row is not None:
row.reason = reason or row.reason
row.actor = actor
await session.commit()
change = "already mapped — reason updated" if reason else "already mapped"
else:
session.add(MomentMapping(
user_id=user_id, tool=tool, match=match, moment=moment,
effect=ADD, reason=reason, actor=actor,
))
await session.commit()
change = "mapped"
return {
"change": change, "tool": tool, "match": match, "moment": moment,
"now_reaches": await _reaches(user_id, tool, match),
}
async def unmap_action(
user_id: int, tool: str, match: str, moment: str,
*, reason: str = "", actor: str = "model",
) -> dict:
"""Stop an action reaching a moment for this install.
An install's own mapping is deleted. A shipped default is switched off by
a stored removal, so the next release does not switch it back on.
"""
tool, match, moment = _clean(tool, match, moment)
reason = (reason or "").strip()
async with async_session() as session:
row = await _find(session, user_id, tool, match, moment)
if row is not None and row.effect == ADD:
await session.delete(row)
await session.commit()
change = "removed this install's mapping"
elif _is_default(tool, match, moment):
if row is None:
session.add(MomentMapping(
user_id=user_id, tool=tool, match=match, moment=moment,
effect=REMOVE, reason=reason, actor=actor,
))
await session.commit()
change = "switched off the shipped default"
else:
change = "the shipped default was already off"
else:
raise ValueError(
f"nothing maps {tool}{' ' + repr(match) if match else ''} onto "
f"{moment}, so there is nothing to remove. list_moments shows "
f"which actions reach each moment."
)
return {
"change": change, "tool": tool, "match": match, "moment": moment,
"now_reaches": await _reaches(user_id, tool, match),
}
async def actions_by_moment(user_id: int) -> dict:
"""Each moment's actions in force, and the defaults this install removed."""
mappings = await list_mappings(user_id)
by_moment: dict[str, list[dict]] = {}
for action, via in effective_actions(mappings):
by_moment.setdefault(action.moment, []).append(
{"tool": action.tool, "match": action.match, "via": via}
)
removed = [m.to_dict() for m in mappings if m.effect == REMOVE]
return {"actions": by_moment, "removed_defaults": removed}
+119
View File
@@ -0,0 +1,119 @@
"""Real-Postgres tests for an install's moment mappings (milestone 458 step 2).
What these pin is what the in-session correction promises the operator: a
mapping made is in force on the next call, a default switched off stays off,
switching it back on leaves no residue, one user's corrections are not
another's, and asking to remove what nothing maps is refused rather than
recorded. Every one of those is a claim about rows, so none of it is mocked.
"""
import pytest
import pytest_asyncio
from sqlalchemy import delete, select
from scribe.models import async_session
from scribe.models.moment_mapping import MomentMapping
from scribe.services import moment_actions as ma
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER_USERNAME = "moment_mapping_owner"
STRANGER_USERNAME = "moment_mapping_stranger"
SHIP = {"command": "make ship"}
CURL = {"command": "curl localhost:8000"}
@pytest_asyncio.fixture
async def users():
async with async_session() as s:
owner = await ensure_user(s, OWNER_USERNAME)
stranger = await ensure_user(s, STRANGER_USERNAME)
await s.commit()
ids = (owner.id, stranger.id)
# At SETUP: the lane shares one database, so a previous run's rows
# are cleared before this one reads anything.
await s.execute(delete(MomentMapping).where(MomentMapping.user_id.in_(ids)))
await s.commit()
return ids
async def _reached(uid, tool, tool_input):
return [h["moment"] for h in await ma.moments_for(uid, tool, tool_input)]
async def _rows(uid):
async with async_session() as s:
return (await s.execute(
select(MomentMapping).where(MomentMapping.user_id == uid)
)).scalars().all()
async def test_a_mapping_is_in_force_on_the_next_call(users):
uid, _ = users
assert "work.deliver" not in await _reached(uid, "Bash", SHIP)
out = await ma.map_action(uid, "Bash", "make ship", "work.deliver",
reason="this install ships with make")
assert out["change"] == "mapped"
assert "work.deliver" in [h["moment"] for h in out["now_reaches"]]
assert "work.deliver" in await _reached(uid, "Bash", SHIP)
async def test_mapping_twice_is_one_row(users):
uid, _ = users
await ma.map_action(uid, "Bash", "make ship", "work.deliver")
again = await ma.map_action(uid, "Bash", "make ship", "work.deliver", reason="why")
assert again["change"].startswith("already mapped")
rows = await _rows(uid)
assert len(rows) == 1 and rows[0].reason == "why"
async def test_one_users_corrections_are_not_anothers(users):
uid, other = users
await ma.map_action(uid, "Bash", "make ship", "work.deliver")
assert "work.deliver" not in await _reached(other, "Bash", SHIP)
async def test_unmapping_an_installs_mapping_deletes_it(users):
uid, _ = users
await ma.map_action(uid, "Bash", "make ship", "work.deliver")
out = await ma.unmap_action(uid, "Bash", "make ship", "work.deliver")
assert out["change"] == "removed this install's mapping"
assert await _rows(uid) == []
assert "work.deliver" not in await _reached(uid, "Bash", SHIP)
async def test_a_default_switched_off_stays_off_and_switches_back_cleanly(users):
uid, _ = users
assert "env.reach" in await _reached(uid, "Bash", CURL)
off = await ma.unmap_action(uid, "Bash", "curl", "env.reach",
reason="curl here only hits the local dev server")
assert off["change"] == "switched off the shipped default"
assert await _reached(uid, "Bash", CURL) == ["work.run"]
[row] = await _rows(uid)
assert row.effect == ma.REMOVE
by_moment = await ma.actions_by_moment(uid)
assert [r["match"] for r in by_moment["removed_defaults"]] == ["curl"]
assert {"tool": "Bash", "match": "curl", "via": ma.DEFAULT} not in by_moment["actions"]["env.reach"]
on = await ma.map_action(uid, "Bash", "curl", "env.reach")
assert on["change"] == "restored the shipped default"
assert await _rows(uid) == []
assert "env.reach" in await _reached(uid, "Bash", CURL)
async def test_mapping_a_default_already_in_force_writes_nothing(users):
uid, _ = users
out = await ma.map_action(uid, "update_task", "status=done", "work.finish")
assert out["change"].startswith("already a shipped default")
assert await _rows(uid) == []
async def test_removing_what_nothing_maps_is_refused(users):
uid, _ = users
with pytest.raises(ValueError, match="nothing to remove"):
await ma.unmap_action(uid, "Bash", "make ship", "work.deliver")
assert await _rows(uid) == []
+181
View File
@@ -0,0 +1,181 @@
"""Which actions reach which moment (milestone 458 step 2) — the pure half.
Matching and resolution take no database: the defaults are code and an
install's mappings arrive as rows, so every case below hands `resolve` the
rows it needs. The writes that store those rows are exercised against real
Postgres in `test_integration_moment_mappings.py`.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from scribe.services import moment_actions as ma
from scribe.services import moments
def _row(tool, match, moment, effect=ma.ADD):
return SimpleNamespace(tool=tool, match=match, moment=moment, effect=effect)
def _reached(tool, tool_input=None, mappings=()):
return [h["moment"] for h in ma.resolve(tool, tool_input, mappings)]
# ── the defaults ─────────────────────────────────────────────────────────
def test_every_default_reaches_a_real_moment():
"""A default naming a moment that is not in the catalog would never be
mounted on — the typo require_moment refuses at the door."""
bad = [a for a in ma.DEFAULT_ACTIONS if not moments.is_moment(a.moment)]
assert not bad, bad
def test_no_default_is_listed_twice():
keys = [(ma.tool_key(a.tool), a.match, a.moment) for a in ma.DEFAULT_ACTIONS]
assert len(keys) == len(set(keys))
def test_every_default_is_a_mapping_the_door_would_accept():
"""The defaults pass the same validation an install's mapping does, so a
default and an install's copy of it can never disagree about its shape."""
for a in ma.DEFAULT_ACTIONS:
assert ma._clean(a.tool, a.match, a.moment) == (a.tool, a.match, a.moment)
# ── matching ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("command", [
"git push origin dev",
"git push",
"cd app && git push",
"make lint; git push --tags",
"GIT_TRACE=1 git push",
" git push ",
])
def test_a_command_prefix_matches_any_segment_of_the_line(command):
assert "work.deliver" in _reached("Bash", {"command": command})
@pytest.mark.parametrize("command", ["git pushd", "echo git push", "git status"])
def test_a_command_prefix_needs_a_word_boundary_and_the_head_of_a_segment(command):
assert "work.deliver" not in _reached("Bash", {"command": command})
def test_one_action_reaches_every_moment_it_is():
"""`kubectl apply` is a run, a deliver and a reach outside the workspace."""
assert _reached("Bash", {"command": "kubectl apply -f deploy.yaml"}) == [
"work.run", "work.deliver", "env.reach",
]
def test_the_most_specific_action_is_the_one_named():
hits = ma.resolve("Bash", {"command": "git push"})
deliver = next(h for h in hits if h["moment"] == "work.deliver")
assert deliver["match"] == "git push"
run = next(h for h in hits if h["moment"] == "work.run")
assert run["match"] == ""
def test_an_ordinary_command_is_only_a_run():
assert _reached("Bash", {"command": "ls -la"}) == ["work.run"]
def test_an_unknown_tool_reaches_nothing():
assert _reached("SomeOtherTool", {"x": 1}) == []
@pytest.mark.parametrize("name", [
"mcp__plugin_scribe_scribe__update_task", "mcp__scribe__update_task",
"update_task", "Update_Task",
])
def test_the_server_prefix_and_case_do_not_matter(name):
assert _reached(name, {"task_id": 1, "status": "done"}) == ["work.finish"]
@pytest.mark.parametrize("status,expected", [
("done", ["work.finish"]),
("cancelled", ["work.finish"]),
("in_progress", ["work.start"]),
("todo", []),
("", []),
])
def test_arguments_select_the_moment(status, expected):
assert _reached("update_task", {"task_id": 1, "status": status}) == expected
def test_a_whole_tool_mapping_ignores_its_arguments():
assert _reached("Edit", {"file_path": "x", "old_string": "a"}) == ["work.change"]
def test_loading_a_procedure_reaches_its_own_moment():
assert _reached("Skill", {"skill": "scribe:writing-plans"}) == [
"skill.scribe:writing-plans",
]
def test_a_procedure_name_that_is_not_a_moment_reaches_nothing():
assert _reached("Skill", {"skill": "two words"}) == []
assert _reached("Skill", {}) == []
# ── an install's own ─────────────────────────────────────────────────────
def test_an_install_mapping_adds_a_moment():
rows = [_row("Bash", "make ship", "work.deliver")]
assert "work.deliver" in _reached("Bash", {"command": "make ship"}, rows)
assert "work.deliver" not in _reached("Bash", {"command": "make ship"})
def test_removing_a_default_removes_only_that_default():
"""`curl` stops reaching env.reach here; it is still a run, and `ssh`
still reaches out — a removal overrides nothing it did not name."""
rows = [_row("Bash", "curl", "env.reach", ma.REMOVE)]
assert _reached("Bash", {"command": "curl localhost:8000"}, rows) == ["work.run"]
assert "env.reach" in _reached("Bash", {"command": "ssh box"}, rows)
def test_a_removal_matches_the_default_whatever_the_tool_is_called():
rows = [_row("mcp__x__update_task", "status=done", "work.finish", ma.REMOVE)]
assert _reached("update_task", {"status": "done"}, rows) == []
def test_an_install_mapping_is_named_as_the_installs():
rows = [_row("Bash", "make ship", "work.deliver")]
hit = next(h for h in ma.resolve("Bash", {"command": "make ship"}, rows)
if h["moment"] == "work.deliver")
assert (hit["via"], hit["match"]) == (ma.INSTALL, "make ship")
# ── validation ───────────────────────────────────────────────────────────
def test_a_prefix_on_a_tool_that_runs_no_command_is_refused():
"""It would be stored and never match — a mapping that looks made and is
not, which is the misfire this table exists to fix."""
with pytest.raises(ValueError, match="field=value"):
ma._clean("update_task", "done", "work.finish")
def test_an_unknown_moment_is_refused():
with pytest.raises(ValueError, match="unknown moment"):
ma._clean("Bash", "make ship", "work.ship")
def test_a_mapping_is_normalised_before_it_is_stored():
assert ma._clean("mcp__x__update_task", " status=done ", " Work.Finish") == (
"update_task", "status=done", "work.finish",
)
assert ma._clean("Bash", "make ship", "work.deliver")[1] == "make ship"
def test_a_tool_is_required():
with pytest.raises(ValueError, match="tool is required"):
ma._clean(" ", "", "work.run")
# ── failing open ─────────────────────────────────────────────────────────
async def test_an_unreadable_mapping_table_costs_the_corrections_not_the_moments():
with patch.object(ma, "list_mappings", AsyncMock(side_effect=RuntimeError("db down"))):
hits = await ma.moments_for(7, "Bash", {"command": "git push"})
assert [h["moment"] for h in hits] == ["work.run", "work.deliver"]
+22 -7
View File
@@ -93,27 +93,42 @@ def test_the_catalog_payload_carries_every_moment_and_the_family():
assert data["families"][0]["prefix"] == moments.SKILL_PREFIX
async def test_the_tool_returns_the_service_catalog():
async def test_the_tool_returns_the_catalog_with_this_installs_actions():
from unittest.mock import AsyncMock, patch
from scribe.mcp.tools import moments as tool
assert await tool.list_moments() == moments.catalog()
by_moment = {"actions": {"work.change": []}, "removed_defaults": []}
with patch.object(tool, "current_user_id", lambda: 7), \
patch.object(tool.actions_svc, "actions_by_moment",
AsyncMock(return_value=by_moment)) as read:
out = await tool.list_moments()
read.assert_awaited_once_with(7)
assert out["moments"] == moments.catalog()["moments"]
assert out["actions"] == by_moment["actions"]
assert out["removed_defaults"] == []
def test_the_tool_is_registered_and_read_only():
from scribe.mcp.server import _READ_ONLY_TOOLS
def test_the_tools_are_registered_and_classified():
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.mcp.tools import moments as tool
mcp = FakeMCP()
tool.register(mcp)
assert mcp.names == ["list_moments"]
assert mcp.names == ["list_moments", "map_action", "unmap_action"]
assert "list_moments" in _READ_ONLY_TOOLS
assert {"map_action", "unmap_action"} <= _WRITE_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."""
"""Rule 33 parity: the tool and the routes call the same services, so the
session and the Settings view cannot name different moments or disagree
about what a mapping does."""
from scribe.mcp.tools import moments as tool
from scribe.routes import retrieval as routes
from scribe.services import moment_actions
assert tool.moments_svc is moments
assert routes.moments_svc is moments
assert tool.actions_svc is moment_actions
assert routes.moment_actions_svc is moment_actions
+1
View File
@@ -43,6 +43,7 @@ def test_every_endpoint_is_reachable_on_the_app():
"/api/retrieval/surfaces/<surface>",
"/api/retrieval/tuning-history",
"/api/retrieval/moments",
"/api/retrieval/moments/mappings",
}
+4 -1
View File
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 20
assert backup.BACKUP_VERSION == 21
def _exportable_note(**over):
@@ -141,6 +141,7 @@ def _column_guard_targets():
from scribe.models.rule_usage import RuleUsageEvent
from scribe.models.system_usage import SystemUsageEvent
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.models.moment_mapping import MomentMapping
from scribe.models.note_version import NoteVersion
from scribe.models.rule_version import RuleVersion
from scribe.models.project import Project
@@ -177,6 +178,7 @@ def _column_guard_targets():
"retrieval_tuning_events": (
RetrievalTuningEvent, backup._retrieval_tuning_event_rows,
),
"moment_mappings": (MomentMapping, backup._moment_mapping_rows),
"design_systems": (DesignSystem, backup._design_system_rows),
"design_tokens": (DesignToken, backup._design_token_rows),
"repo_bindings": (RepoBinding, backup._repo_binding_rows),
@@ -294,6 +296,7 @@ def _import_guard_targets():
"rule_usage_events": backup._build_rule_usage_event,
"system_usage_events": backup._build_system_usage_event,
"retrieval_tuning_events": backup._build_retrieval_tuning_event,
"moment_mappings": backup._build_moment_mapping,
"design_systems": backup._build_design_system,
"design_tokens": backup._build_design_token,
"repo_bindings": backup._build_repo_binding,