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