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
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user