CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
Loading a procedure now also reaches the moment it is for. Loading the reporting procedure is a report; loading the release procedure is a delivery. - Bundled skills: each SKILL.md declares `metadata: moments:`. The same declaration ships as Skill defaults (BUNDLED_SKILL_MOMENTS), because the server never sees the plugin's files. test_skill_moments holds the two together and pins the plugin name that qualifies the skill. - Stored processes: `moments` on create_process and update_process, stored in the note's data and returned by get_process. A `scribe-proc-<slug>` load resolves its process through the sync manifest at load time. The moments are not copied into the stub, which would go stale mid-session. - reachable_tools lists the skill loader whenever anything is mounted, since a process's moments are known only when it loads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
12 KiB
Python
270 lines
12 KiB
Python
"""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 {}
|