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