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
+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}