feat(moments): mounted rules arrive when their moment happens, through every door (milestone 458 step 4a, #4922)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m19s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m19s
CI & Build / Build & push image (push) Skipped
A rule mounted on a moment now reaches the session when an act reaches
that moment, with no semantic match involved:
- run_moment_arm on the pipeline: a lookup, not a ranked search. Each
line names the moment and the act that reached it ("at work.deliver,
reached by `git push`"), so a misfire is visible where it lands and
can be unmapped in-session. A repeat is cited, not quoted; fresh
rules are recorded surfaced under source moment_rule with the moment
in detail. No retrieval_logs row, as for the other lookups, so no
latency is persisted for this arm.
- rule_scope: a rule's home clause, moved out of semantic_search_rules
so the moment lookup scopes by the same one.
- rulebooks.rules_on_moments / mounted_moments.
- The plugin door: a catch-all PreToolUse hook (scribe_moment.sh). It
keeps /moment-tools' answer on disk for five minutes, so a call to a
tool that cannot reach a mounted rule sends nothing, and an install
that has mounted nothing sends one request per window. It shares the
rules ledger with the other arms and fails open silently.
- The MCP door: Scribe's own tools named by the shipped mappings carry
moment_rules in their response, so a client without the plugin gets
them too. The hook skips those tools. A guard pins the attach on
every one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Delivering the rules mounted on a moment, through every door (milestone 458 step 4).
|
||||
|
||||
One function decides what an act reaches and what arrives with it; the doors
|
||||
differ only in how they carry the answer:
|
||||
|
||||
- the plugin's catch-all PreToolUse hook → `/api/plugin/moment`, with the
|
||||
session ledger, so a rule already named this session is cited not repeated;
|
||||
- Scribe's own MCP tools → `attach_moment_rules`, in the tool's response, so
|
||||
a client without the plugin still gets `work.finish` when a task closes;
|
||||
- the Stop hook → `deliver_moments`, for the reply moments no tool call marks.
|
||||
|
||||
The pipeline stage is `retrieval_pipeline.run_moment_arm`; this module only
|
||||
resolves the act to its moments and hands both doors the same result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from scribe.services import moment_actions
|
||||
from scribe.services import moments as catalog
|
||||
from scribe.services import retrieval_pipeline as rp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _io() -> rp.MomentIO:
|
||||
"""Resolved at CALL time, so a test patching either module's name is seen."""
|
||||
from scribe.services import rule_usage, rulebooks
|
||||
|
||||
return rp.MomentIO(
|
||||
lookup=rulebooks.rules_on_moments,
|
||||
record_rule_surfaced=rule_usage.record_rule_surfaced,
|
||||
)
|
||||
|
||||
|
||||
async def reachable_tools(user_id: int) -> list[str]:
|
||||
"""The tools a call to which could deliver a mounted rule, as tool keys.
|
||||
|
||||
What the plugin's catch-all hook reads once per session window, so the
|
||||
calls that cannot reach a mounted rule — most of them, and every one on
|
||||
an install that has mounted nothing — never leave the machine. An action
|
||||
counts only when its moment carries a mount; the skill loader counts when
|
||||
any `skill.<name>` moment does.
|
||||
"""
|
||||
from scribe.services import rulebooks
|
||||
|
||||
mounted = await rulebooks.mounted_moments(user_id)
|
||||
if not mounted:
|
||||
return []
|
||||
mappings = await moment_actions.list_mappings(user_id)
|
||||
keys = {
|
||||
moment_actions.tool_key(action.tool)
|
||||
for action, _via in moment_actions.effective_actions(mappings)
|
||||
if action.moment in mounted
|
||||
}
|
||||
if any(name.startswith(catalog.SKILL_PREFIX) for name in mounted):
|
||||
keys.add(moment_actions.SKILL_TOOL)
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
async def deliver_moments(
|
||||
user_id: int, reached: list[dict], *, project_id: int | None = None,
|
||||
exclude: frozenset[int] = frozenset(), held: frozenset[int] = frozenset(),
|
||||
) -> rp.RuleResult:
|
||||
"""The mounted rules for moments already known to have happened."""
|
||||
return await rp.run_moment_arm(
|
||||
reached,
|
||||
rp.RuleMoment(user_id=user_id, query="", project_id=project_id,
|
||||
exclude=exclude, held=held),
|
||||
io=_io(),
|
||||
)
|
||||
|
||||
|
||||
async def deliver_for_act(
|
||||
user_id: int, tool: str, tool_input: dict | None, *,
|
||||
project_id: int | None = None,
|
||||
exclude: frozenset[int] = frozenset(), held: frozenset[int] = frozenset(),
|
||||
) -> tuple[list[dict], rp.RuleResult]:
|
||||
"""Which moments this act reached, and the mounted rules they deliver."""
|
||||
reached = await moment_actions.moments_for(user_id, tool, tool_input or {})
|
||||
if not reached:
|
||||
return [], rp.RuleResult()
|
||||
result = await deliver_moments(
|
||||
user_id, reached, project_id=project_id, exclude=exclude, held=held,
|
||||
)
|
||||
return reached, result
|
||||
|
||||
|
||||
async def attach_moment_rules(
|
||||
user_id: int, tool: str, arguments: dict | None, data: dict,
|
||||
) -> dict:
|
||||
"""Carry the rules mounted on this tool call's moments in its own response.
|
||||
|
||||
The MCP door (#4286's attach shape): an in-band decoration on a payload
|
||||
already being returned, fail-open, and absent when there is nothing to
|
||||
say. The project is the record's own when it has one, else the caller's
|
||||
argument — a project's mounted rules belong to work in that project.
|
||||
|
||||
No session ledger reaches this door, so a mounted rule arrives every time
|
||||
its moment happens here. That is the right failure for the moments these
|
||||
tools mark: closing a task is exactly when a rule about finishing applies,
|
||||
however many tasks were closed before it.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
try:
|
||||
project_id = data.get("project_id") or (arguments or {}).get("project_id") or None
|
||||
_reached, result = await deliver_for_act(
|
||||
user_id, tool, arguments or {}, project_id=project_id,
|
||||
)
|
||||
if result.lines:
|
||||
data["moment_rules"] = {
|
||||
"lines": result.lines,
|
||||
"rule_ids": result.shown_rule_ids,
|
||||
"open_with": "get_rule(id)",
|
||||
}
|
||||
except Exception: # noqa: BLE001 - a decoration never breaks the payload
|
||||
logger.debug("moment rules for %s could not be attached", tool, exc_info=True)
|
||||
return data
|
||||
Reference in New Issue
Block a user