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:
@@ -662,3 +662,100 @@ async def run_rule_arm(
|
||||
except Exception: # noqa: BLE001 - a recall aid never breaks its act
|
||||
logger.debug("%s arm failed", arm.source, exc_info=True)
|
||||
return RuleResult()
|
||||
|
||||
|
||||
# ── The moment arm (milestone 458) ───────────────────────────────────────
|
||||
#
|
||||
# A LOOKUP beside the ranked arms, not one of them. A rule mounted on a moment
|
||||
# arrives because somebody said it belongs there, not because the work's words
|
||||
# resemble it — which is the whole point: a rule about WHEN work is finished
|
||||
# never scores against what is said while finishing it. So there is no score,
|
||||
# no floor and no band, and it writes no retrieval_logs row (the rulings arm's
|
||||
# precedent, #4769): every score-shaped warning would misread a lookup. What it
|
||||
# records is the surfacing, with the moment beside each row, so a mount's
|
||||
# pull-through can be read per moment.
|
||||
#
|
||||
# It shares everything else with the ranked arms: the line renderer, the
|
||||
# session ledger (a repeat renders as a citation, never hidden — #3750) and
|
||||
# the fresh-only surfacing rows (#3752).
|
||||
|
||||
MOMENT_RULE_SOURCE = "moment_rule"
|
||||
|
||||
# A cap against a misconfiguration, not a ranking budget. Mounts are
|
||||
# deliberate, so the ordinary case is a handful; a rulebook that mounted
|
||||
# dozens of rules on one moment would otherwise bury the act it annotates.
|
||||
# The remedy for a moment that hits this is fewer mounts (unmount through
|
||||
# update_rule), which is why it is not a tuning dial.
|
||||
MOMENT_RULE_LIMIT = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MomentIO:
|
||||
"""The mounted-rule lookup and the recorder the moment arm reports to."""
|
||||
|
||||
lookup: Callable[..., Any]
|
||||
"""`rulebooks.rules_on_moments`, or a stand-in with its signature."""
|
||||
|
||||
record_rule_surfaced: Callable[..., Any]
|
||||
|
||||
|
||||
def _reached_by(hit: dict) -> str:
|
||||
"""How the line names what reached a moment: the action, as mapped."""
|
||||
return f"`{hit.get('match') or hit.get('tool') or '?'}`"
|
||||
|
||||
|
||||
async def run_moment_arm(
|
||||
reached: list[dict], moment: RuleMoment, *, io: MomentIO,
|
||||
limit: int = MOMENT_RULE_LIMIT,
|
||||
) -> RuleResult:
|
||||
"""Deliver the rules mounted on the moments this act reached.
|
||||
|
||||
`reached` is `moment_actions.resolve`'s answer — each moment with the
|
||||
action that reached it — and every line says both ("at work.deliver,
|
||||
reached by `git push`"), so a moment that fired on the wrong act is
|
||||
visible in the line itself and can be corrected in the session with
|
||||
`unmap_action` (step 2's ruling). Fresh rules come first and carry their
|
||||
trigger; a rule the session was already shown is cited, not repeated in
|
||||
full. Fails open to an empty result.
|
||||
"""
|
||||
try:
|
||||
names = [hit["moment"] for hit in reached]
|
||||
if not names:
|
||||
return RuleResult()
|
||||
pairs = await io.lookup(moment.user_id, names, moment.project_id or None)
|
||||
if not pairs:
|
||||
return RuleResult()
|
||||
by_moment = {hit["moment"]: hit for hit in reached}
|
||||
# Stable: catalog order within each half, fresh half first.
|
||||
shown = sorted(pairs, key=lambda pair: pair[0].id in moment.exclude)[:limit]
|
||||
|
||||
lines = []
|
||||
for rule, at in shown:
|
||||
seen = rule.id in moment.exclude
|
||||
lines.append(_rule_hint_line(
|
||||
rule,
|
||||
where=f"at {at}, reached by {_reached_by(by_moment.get(at, {}))}",
|
||||
seen=seen, held=rule.id in moment.held,
|
||||
# A repeat is cited rather than quoted: the session was told
|
||||
# which moment brought it the first time.
|
||||
compact=seen,
|
||||
))
|
||||
fresh = [(rule, at) for rule, at in shown if rule.id not in moment.exclude]
|
||||
rule_ids = [rule.id for rule, _at in fresh]
|
||||
if rule_ids:
|
||||
try:
|
||||
io.record_rule_surfaced(
|
||||
user_id=moment.user_id, rule_ids=rule_ids,
|
||||
source=MOMENT_RULE_SOURCE,
|
||||
detail={rule.id: at for rule, at in fresh},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||
_telemetry_failed(MOMENT_RULE_SOURCE)
|
||||
return RuleResult(
|
||||
lines=lines, rule_ids=rule_ids,
|
||||
shown_rule_ids=[rule.id for rule, _at in shown],
|
||||
shown=[(None, rule) for rule, _at in shown],
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a recall aid never breaks its act
|
||||
logger.debug("%s arm failed", MOMENT_RULE_SOURCE, exc_info=True)
|
||||
return RuleResult()
|
||||
|
||||
Reference in New Issue
Block a user