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)