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

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:
2026-10-05 12:21:45 -04:00
co-authored by Claude Opus 5.5
parent cc26054437
commit 78653130d6
24 changed files with 1049 additions and 39 deletions
+12 -1
View File
@@ -101,6 +101,11 @@ logger = logging.getLogger(__name__)
# Add a source here only when a ranker picked it.
RANKED_SOURCES = (
"write_path_rule", "pre_tool_rule", "prompt_rule",
# A rule mounted on a moment (milestone 458). Not a ranker's pick, but
# not bulk either: somebody decided this rule applies at this moment, and
# that is exactly the claim a pull can confirm or refute. Its
# pull-through is the evidence for whether a mount earns its line.
"moment_rule",
# A reserved slot is a ranker's choice twice over — it ran a query AND
# decided a kind was worth guaranteeing a place. Left out, its line would
# be counted as bulk delivery and drop out of the denominator, so the one
@@ -154,7 +159,8 @@ def _schedule(rows: list[dict]) -> None:
def record_rule_surfaced(
*, user_id: int | None, rule_ids: list[int] | set[int], source: str
*, user_id: int | None, rule_ids: list[int] | set[int], source: str,
detail: dict[int, str] | None = None,
) -> None:
"""Fire-and-forget: record that these rules were shown to the agent.
@@ -173,6 +179,10 @@ def record_rule_surfaced(
from the other end — everything in a preload IS shown. `source` is what
separates the two afterwards (see `RANKED_SOURCES`); this function does not
care which kind it is recording.
`detail` maps a rule id to what to record beside its row — the moment a
mounted rule arrived at (milestone 458), so a mount's pull-through can be
read per moment rather than only per arm.
"""
try:
rows = [
@@ -181,6 +191,7 @@ def record_rule_surfaced(
"rule_id": int(rid),
"event": SURFACED,
"source": source,
**({"detail": detail[rid]} if detail and rid in detail else {}),
}
for rid in rule_ids
]