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:
@@ -1160,6 +1160,70 @@ async def list_rule_moments(rule_ids: list[int]) -> dict[int, list[str]]:
|
||||
return out
|
||||
|
||||
|
||||
async def rules_on_moments(
|
||||
user_id: int, moments: list[str], project_id: int | None = None,
|
||||
) -> list[tuple[Rule, str]]:
|
||||
"""The rules mounted on any of these moments that this caller may be shown.
|
||||
|
||||
The delivery read (milestone 458 step 4), and a LOOKUP: no score, no bar.
|
||||
Scoped by the same home clause the semantic search uses (rule_scope), so
|
||||
a mount never delivers another project's rule. Each rule appears once,
|
||||
under the first of `moments` it is mounted on — the caller passes them in
|
||||
catalog order, so that is the most specific moment the work reached.
|
||||
"""
|
||||
from scribe.models.rulebook import rule_moments as rule_moments_t
|
||||
from scribe.services.rule_scope import joined_to_homes, rule_home
|
||||
|
||||
if not moments:
|
||||
return []
|
||||
home = await rule_home(user_id, project_id)
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
joined_to_homes(
|
||||
select(Rule, rule_moments_t.c.moment)
|
||||
.select_from(rule_moments_t)
|
||||
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
|
||||
)
|
||||
.where(
|
||||
rule_moments_t.c.moment.in_(list(moments)),
|
||||
Rule.deleted_at.is_(None),
|
||||
home,
|
||||
)
|
||||
.order_by(Rule.id)
|
||||
)).all()
|
||||
rank = {m: i for i, m in enumerate(moments)}
|
||||
first: dict[int, tuple[Rule, str]] = {}
|
||||
for rule, moment in rows:
|
||||
held = first.get(rule.id)
|
||||
if held is None or rank[moment] < rank[held[1]]:
|
||||
first[rule.id] = (rule, moment)
|
||||
return sorted(first.values(), key=lambda pair: (rank[pair[1]], pair[0].id))
|
||||
|
||||
|
||||
async def mounted_moments(user_id: int) -> set[str]:
|
||||
"""Every moment at least one of this caller's live rules is mounted on.
|
||||
|
||||
In ANY home (rule_scope's `everywhere`): this answers "can a call reach
|
||||
anything at all", which is what lets the plugin stay off the wire for a
|
||||
tool whose moments carry nothing. A superset is the safe direction — the
|
||||
delivery read still scopes by project.
|
||||
"""
|
||||
from scribe.models.rulebook import rule_moments as rule_moments_t
|
||||
from scribe.services.rule_scope import joined_to_homes, rule_home
|
||||
|
||||
home = await rule_home(user_id, everywhere=True)
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
joined_to_homes(
|
||||
select(rule_moments_t.c.moment).distinct()
|
||||
.select_from(rule_moments_t)
|
||||
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
|
||||
)
|
||||
.where(Rule.deleted_at.is_(None), home)
|
||||
)).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||
"""The canon tags for a batch of rules, keyed by rule id.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user