"""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 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 whenever anything is mounted: a stored process declares its own moments, which only the load itself can resolve, and a load is rare enough that asking costs nothing. """ 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 } 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 # ── The reply moment: the backstop at the end of a turn ───────────────── # # The Stop hook's door, folded in from milestone 456 step 8. A reply is the # one act no tool call marks, and the moment where "please verify this on # your end" gets said — so it is checked twice over: the rules MOUNTED on the # reply moments (deterministic, somebody said they belong here), and the # reply text against every rule's trigger (the backstop for whatever the # earlier arms missed). An unopened rule from either half HOLDS the reply for # one read, in these words; the hook never holds the rewrite. # The reply's head and tail. The embedder reads ~512 tokens, and the part of # a report that asks something of the reader — "let me know if it works" — # is at the END, where a head-only query would cut it off. _REPLY_HEAD_CHARS = 600 _REPLY_TAIL_CHARS = 1200 _REACHED_BY_REPLY = "the reply that ends this turn" def reply_moments(reply: str) -> list[dict]: """The moments a finished reply reaches: always `reply.report`, and `reply.ask` when a line of its prose ends in a question.""" reached = [{"moment": "reply.report", "tool": "", "match": _REACHED_BY_REPLY, "via": moment_actions.DEFAULT}] in_code = False for line in (reply or "").splitlines(): stripped = line.strip() if stripped.startswith("```"): in_code = not in_code continue if not in_code and stripped.endswith("?"): reached.append({"moment": "reply.ask", "tool": "", "match": "a question in the reply", "via": moment_actions.DEFAULT}) break return reached def _reply_query(reply: str) -> str: text = " ".join((reply or "").split()) if len(text) <= _REPLY_HEAD_CHARS + _REPLY_TAIL_CHARS: return text return text[:_REPLY_HEAD_CHARS] + " … " + text[-_REPLY_TAIL_CHARS:] def reply_hold_reason(held: list[dict]) -> str: """The text the agent reads INSTEAD of its reply going out. A practice, not a prohibition (rule 165), on the act checkpoint's model: nothing here knows the reply is wrong. It names each rule with why it was raised, gives the remedy as one call per rule, and says the reply may go out unchanged — so a reader who finds the rules beside the point is out in as many calls as there are rules, and is never held twice. """ if not held: return "" parts = [] for item in held: why = (f"mounted on {item['moment']}" if item.get("moment") else f"scores {item['score']} against this reply") trigger = f"; it applies when {item['trigger']}" if item.get("trigger") else "" parts.append(f"“{item['title']}” ({why}{trigger}) — get_rule({item['rule_id']})") noun = "a standing rule" if len(held) == 1 else "standing rules" return ( f"Held for one read before this reply goes out: {noun} this session " f"has not opened: " + "; ".join(parts) + ". Read " + ("it" if len(held) == 1 else "them") + ", then send the reply — unchanged if it already does what the rule " "asks, which is a judgement only you can make. This check runs once " "per rule; the rewrite is never held." ) async def reply_hold( user_id: int, reply: str, *, project_id: int | None = None, exclude: frozenset[int] = frozenset(), held: frozenset[int] = frozenset(), stopped: frozenset[int] = frozenset(), ) -> dict: """Whether this finished reply is held, and in what words. `held` is what the session OPENED (the act checkpoint's exemption); `stopped` is what an earlier hold already put in front of it, so a rule holds a session once. Past the act checkpoint's per-session cap nothing holds — a mis-set bar degrades to a quiet session, never a stuck one. Returns {} or {"reason", "rule_ids", "moments"}. Fails open to {}. """ from scribe.services import plugin_context as pc from scribe.services import rule_usage, rulebooks from scribe.services.retrieval_surfaces import budget_for, floor_for try: if not (reply or "").strip() or len(stopped) >= pc.CHECKPOINT_SESSION_CAP: return {} reached = reply_moments(reply) names = [hit["moment"] for hit in reached] skip = set(held) | set(stopped) room = pc.CHECKPOINT_SESSION_CAP - len(stopped) out: list[dict] = [] # The mounted half: deterministic, so every unopened RULE on these # moments holds. A preference claims no such force. for rule, at in await rulebooks.rules_on_moments(user_id, names, project_id or None): if rule.id in skip or rule.kind == "preference": continue out.append({"rule_id": rule.id, "title": rule.title, "moment": at, "trigger": (rule.when_to_apply or "").strip()}) # Trimmed BEFORE recording: a rule the cap cut was shown to nobody. out = out[:room] mounted = [item["rule_id"] for item in out] fresh = [rid for rid in mounted if rid not in exclude] if fresh: try: rule_usage.record_rule_surfaced( user_id=user_id, rule_ids=fresh, source=rp.MOMENT_RULE_SOURCE, detail={item["rule_id"]: item["moment"] for item in out if item["rule_id"] in fresh}, ) except Exception: # noqa: BLE001 - observation never breaks the observed logger.debug("reply mount surfacing not recorded", exc_info=True) # The semantic half: the backstop. A mounted rule already holding is # treated as held here, so one rule is never named twice. floor = await floor_for(user_id, rp.REPLY_RULE.source) result = await rp.run_rule_arm( rp.REPLY_RULE, rp.RuleMoment( user_id=user_id, query=_reply_query(reply), project_id=project_id, where="to this reply", checkpoint_where="this reply", exclude=exclude, held=frozenset(skip | set(mounted)), ), floor=floor, budget=await budget_for(user_id, rp.REPLY_RULE.source), io=pc._rule_io(), checkpoint_floor=floor, ) if result.checkpoint and len(out) < room: cp = result.checkpoint out.append({"rule_id": cp["rule_id"], "title": cp["title"], "score": cp["score"], "trigger": cp.get("trigger", "")}) if not out: return {} return { "reason": reply_hold_reason(out), "rule_ids": [item["rule_id"] for item in out], "moments": names, } except Exception: # noqa: BLE001 - a recall aid never stops a session by failing logger.debug("reply hold failed", exc_info=True) return {}