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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.04.0125",
"version": "2026.10.05.1621",
"author": {
"name": "Bryan Van Deusen"
},
+1
View File
@@ -39,6 +39,7 @@ another one means adding files, not moving or rewriting any.
| `hooks/scribe_prior_art.sh` | PreToolUse on editor writes: `GET /api/plugin/prior-art`. |
| `hooks/scribe_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. |
| `hooks/scribe_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
| `hooks/scribe_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Scribe's own (their responses carry `moment_rules`). |
| `hooks/scribe_report_check.sh` | Stop: when the turn closed a task, checks the reply for the completion sections and reports to `GET /api/plugin/report-check`; blocks once, with the reason the server returns. |
| `hooks/scribe_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
| `hooks/scribe_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/scribe-proc-*` stubs; `/scribe:sync` on demand. |
+9
View File
@@ -42,6 +42,15 @@
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_tool_rules.sh\""
}
]
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_moment.sh\""
}
]
}
],
"PostToolUse": [
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Scribe — PreToolUse moment arm: rules MOUNTED on a moment arrive when the
# moment happens (milestone 458 step 4).
#
# The other PreToolUse arms search: they turn the act into a query and hope a
# rule's words resemble it. A mount needs no resemblance. The operator said
# "this rule applies when work is delivered", the install maps `git push` onto
# work.deliver, and so `git push` brings the rule — whatever the command's
# words happen to be. The server resolves the call to its moments and looks up
# what is mounted there; this hook only carries the event and the answer.
#
# REGISTERED ON EVERY TOOL, KEPT OFF THE WIRE FOR MOST OF THEM. Which calls can
# reach a mounted rule depends on this install's mappings and mounts, so the
# matcher cannot say. Instead the hook keeps the server's answer to "which
# tools can?" (`/moment-tools`) on disk for a few minutes, and a call to any
# other tool costs one file read. An install that has mounted nothing gets an
# empty list and never sends a moment request at all.
#
# SCRIBE'S OWN TOOLS ARE SKIPPED. Closing a task or recording a lesson reaches
# its moment through the tool's own response (`moment_rules`), which works for
# a client without this plugin too; delivering it here as well would say it
# twice.
#
# SILENT ON OUTAGE, like scribe_tool_rules.sh and for its reason: this fires
# before many calls, and an outage line before each is noise. A failed list
# fetch is cached as an empty list, so a down instance costs one timeout per
# window rather than one per call.
#
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
command -v curl >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
[ -n "$tool_name" ] && [ -n "$session_id" ] || exit 0
case "$tool_name" in
mcp__*scribe*__*) exit 0 ;;
esac
scribe_config || exit 0
# The tool's KEY, as the server writes mappings: no MCP server prefix, lowercase.
tool_key=${tool_name##*__}
tool_key=$(printf '%s' "$tool_key" | tr '[:upper:]' '[:lower:]')
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# ── Which tools can reach a mounted rule: cached, first line a timestamp ──
#
# Five minutes. A mount or a mapping made in this session reaches the hook
# within that window; the MCP door delivers Scribe's own moments at once.
# Not a `.ids` file, so a compaction does not sweep it: it describes the
# install, not what this context holds.
tools_file="$state_dir/${safe_sid}.moment.tools"
now=$(date +%s 2>/dev/null) || now=0
stamp=""
[ -f "$tools_file" ] && stamp=$(head -n 1 "$tools_file" 2>/dev/null)
case "$stamp" in ''|*[!0-9]*) stamp=0 ;; esac
if [ "$now" -eq 0 ] || [ $((now - stamp)) -gt 300 ]; then
listed=$(curl -fsS --max-time 3 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/moment-tools" 2>/dev/null) || listed=""
{
printf '%s\n' "$now"
scribe_json_list "$(printf '%s' "$listed" | scribe_json_flat)" '.tools'
} > "$tools_file" 2>/dev/null || true
fi
tail -n +2 "$tools_file" 2>/dev/null | grep -Fqx -- "$tool_key" || exit 0
# ── The moment request ────────────────────────────────────────────────────
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
scope=$(scribe_scope_query "$lookup_dir")
[ -n "$scope" ] && repo_q="&${scope}"
# The shared session ledger, as scribe_tool_rules.sh reads and writes it: a
# rule this session was already shown is cited, not quoted again.
rulefile="$state_dir/${safe_sid}.rules.ids"
ledger_q=""
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && ledger_q="&exclude_rule_ids=${rule_seen}"
ledger_q="${ledger_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
# The event whole, since which field a mapping matches is the mapping's
# business. A write of a very large file is reduced to its name: the moments a
# write reaches never depend on its content.
if [ "${#event}" -gt 65536 ]; then
esc=$(printf '%s' "$tool_name" | scribe_json_escape) || exit 0
event="{\"tool_name\":\"${esc}\",\"tool_input\":{}}"
fi
query="${repo_q}${ledger_q}"
query=${query#&}
body=$(printf '%s' "$event" | curl -fsS --max-time 3 \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
--data-binary @- \
"${url%/}/api/plugin/moment${query:+?$query}" 2>/dev/null) || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
context=$(scribe_json_pick "$body_flat" '.context')
[ -n "$context" ] || exit 0
scribe_json_out PreToolUse "$context"
exit 0
+8
View File
@@ -341,6 +341,14 @@ SMOKE_EVENTS: dict[str, str] = {
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}}
),
# The moment arm (milestone 458). A command the shipped mappings send to
# work.deliver, so a configured run reaches the moment-tools fetch and the
# moment request; with no instance both fail and it must stay SILENT,
# since it fires before every call that can reach a mounted rule.
"scribe_moment.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "git push origin dev"}}
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
# The after-write hook (#2901) diffs the working tree; on CI's clean
+2 -1
View File
@@ -19,6 +19,7 @@ from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.mcp.tools import systems as systems_tools
from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services.moment_delivery import attach_moment_rules
# The payload shape lives in the service (`lesson_to_dict`), shared with the
@@ -245,7 +246,7 @@ async def create_lesson(
await _offer_candidates(uid, data, what, when_to_apply, project_id)
elif no_rule.strip():
await _name_convergence(uid, data, note.id)
return data
return await attach_moment_rules(uid, "create_lesson", {"project_id": project_id}, data)
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
+8 -2
View File
@@ -19,6 +19,7 @@ from scribe.services import task_logs as task_logs_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.moment_delivery import attach_moment_rules
async def list_milestones(project_id: int) -> dict:
@@ -132,7 +133,9 @@ async def create_milestone(
body=body or None,
status=status,
)
return milestone.to_dict()
return await attach_moment_rules(
uid, "create_milestone", {"project_id": project_id}, milestone.to_dict(),
)
async def update_milestone(
@@ -172,7 +175,10 @@ async def update_milestone(
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
return milestone.to_dict()
return await attach_moment_rules(
uid, "update_milestone", {"status": status, "project_id": project_id},
milestone.to_dict(),
)
async def delete_milestone(milestone_id: int) -> dict:
+2 -1
View File
@@ -23,6 +23,7 @@ from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.moment_delivery import attach_moment_rules
async def list_notes(
@@ -236,7 +237,7 @@ async def create_note(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
return data
return await attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
async def update_note(
+2 -1
View File
@@ -14,6 +14,7 @@ from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
from scribe.services.moment_delivery import attach_moment_rules
async def list_processes(
@@ -106,7 +107,7 @@ async def create_process(
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
return note.to_dict()
return await attach_moment_rules(uid, "create_process", {}, note.to_dict())
async def get_process(name_or_id: str, project_id: int = 0) -> dict:
+4 -3
View File
@@ -22,6 +22,7 @@ from scribe.services import trash as trash_svc
from scribe.services.rule_usage import (
record_rule_outcome, record_rule_pulled,
)
from scribe.services.moment_delivery import attach_moment_rules
# ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -536,7 +537,7 @@ async def create_rule(
)
data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments)
data.update(dedup_svc.overlap_response(overlaps, "rule"))
return data
return await attach_moment_rules(uid, "create_rule", {}, data)
async def create_project_rule(
@@ -645,7 +646,7 @@ async def create_project_rule(
)
data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments)
data.update(dedup_svc.overlap_response(overlaps, "rule"))
return data
return await attach_moment_rules(uid, "create_project_rule", {"project_id": project_id}, data)
async def update_rule(
@@ -878,7 +879,7 @@ async def create_preference(
)
data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments)
data.update(dedup_svc.overlap_response(overlaps, "preference"))
return data
return await attach_moment_rules(uid, "create_preference", {}, data)
async def update_preference(
+2 -1
View File
@@ -17,6 +17,7 @@ from scribe.services import dedup as dedup_svc
from scribe.services import snippets as snippets_svc
from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services import systems as systems_svc
from scribe.services.moment_delivery import attach_moment_rules
async def list_snippets(
@@ -223,7 +224,7 @@ async def create_snippet(
advice = snippets_svc.trigger_advice(when_to_use)
if advice:
data["trigger_advice"] = advice
return data
return await attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
+5 -1
View File
@@ -46,6 +46,7 @@ from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.text import elide
from scribe.services.moment_delivery import attach_moment_rules
# A work log entry is prose, often long — the discipline asks for what was
@@ -466,7 +467,9 @@ async def update_task(
if prefs:
data["reply_preferences"] = prefs
data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE
return data
return await attach_moment_rules(
uid, "update_task", {"status": status, "project_id": project_id}, data,
)
async def add_task_log(task_id: int, content: str) -> dict:
@@ -734,6 +737,7 @@ async def start_planning(
)
if isinstance(result, dict):
result.update(dedup_svc.batch_overlap_response(overlaps))
await attach_moment_rules(uid, "start_planning", {"project_id": project_id}, result)
return result
+53
View File
@@ -13,6 +13,7 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.config import Config
from scribe.services import lesson_rules as lesson_rules_svc
from scribe.services import moment_delivery as moment_delivery_svc
from scribe.services import plugin_context as plugin_ctx_svc
from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services import report_check as report_check_svc
@@ -241,6 +242,58 @@ async def pre_tool_rules():
return jsonify(result)
@plugin_bp.get("/moment-tools")
@login_required
async def moment_tools():
"""The tools whose calls can deliver a mounted rule (milestone 458 step 4).
Read by the plugin's catch-all PreToolUse hook once per session window and
kept on disk, so a call that cannot reach a mounted rule costs no request.
`tools` are tool KEYS — bare, lowercased, no MCP server prefix — the same
keys the moment mappings are written in. Empty on an install that has
mounted nothing, which keeps that hook entirely off the wire.
"""
return jsonify({"tools": await moment_delivery_svc.reachable_tools(g.user.id)})
@plugin_bp.post("/moment")
@login_required
async def moment():
"""The rules mounted on the moments this tool call reaches (milestone 458).
Body: the PreToolUse event as Claude Code delivers it — `tool_name` and
`tool_input` are read, nothing else. Sent whole because which input field
a mapping matches on is the mapping's business, not the hook's.
Query: `repo` / `project_id` for the scope, and the shared session ledger —
`exclude_rule_ids` (named this session: a repeat is cited, not quoted) and
`held_rule_ids` (opened) — exactly as /tool-rules takes them.
Returns `context` (the lines, each naming the moment and the action that
reached it, so a misfire is visible and can be unmapped in-session),
`rule_ids` (the FRESH ones, for the hook to append to the ledger) and
`moments` (the names reached). A lookup, not a ranked search: no
retrieval_logs row; each fresh rule is recorded surfaced with its moment.
"""
event = await request.get_json(silent=True) or {}
tool = str(event.get("tool_name") or "").strip()
tool_input = event.get("tool_input")
if not tool:
return jsonify({"context": "", "rule_ids": [], "moments": []})
project_id, _repo, _unbound = await _project_scope()
reached, result = await moment_delivery_svc.deliver_for_act(
g.user.id, tool, tool_input if isinstance(tool_input, dict) else {},
project_id=project_id,
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
)
return jsonify({
"context": "\n".join(result.lines),
"rule_ids": result.rule_ids,
"moments": [hit["moment"] for hit in reached],
})
@plugin_bp.get("/prior-art")
@login_required
@memoized_query_embeddings
+15 -22
View File
@@ -1528,8 +1528,8 @@ async def semantic_search_rules(
Returns an empty list if the embedder is unavailable or on any error.
"""
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.rulebook import Rule
from scribe.services.rule_scope import joined_to_homes, rule_home
# See the sibling search: stamped before anything can return (#3765).
if report is not None:
@@ -1545,30 +1545,23 @@ async def semantic_search_rules(
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
try:
# topic_id XOR project_id (migration 0059), so a rule matches exactly
# one arm of whichever clause applies. Inside the try: the access
# check reads the database too, and this function fails open.
global_rule = Rulebook.owner_user_id == user_id
if everywhere:
home = or_(global_rule, Project.user_id == user_id)
elif project_id and await can_read_project(user_id, project_id):
home = or_(global_rule, Rule.project_id == project_id)
else:
home = global_rule
# The home clause is shared with the moment lookup (rule_scope).
# Inside the try: the access check reads the database too, and this
# function fails open.
home = await rule_home(user_id, project_id, everywhere=everywhere)
async with async_session() as session:
rows = (await session.execute(
select(
Rule,
distance.label("distance"),
RuleEmbedding.chunk_index,
RuleEmbedding.chunk_text,
joined_to_homes(
select(
Rule,
distance.label("distance"),
RuleEmbedding.chunk_index,
RuleEmbedding.chunk_text,
)
.select_from(RuleEmbedding)
.join(Rule, RuleEmbedding.rule_id == Rule.id)
)
.select_from(RuleEmbedding)
.join(Rule, RuleEmbedding.rule_id == Rule.id)
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.outerjoin(Project, Rule.project_id == Project.id)
.where(
Rule.deleted_at.is_(None),
# No threshold predicate — see the note above
+119
View File
@@ -0,0 +1,119 @@
"""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 moments as catalog
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 when
any `skill.<name>` moment does.
"""
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
}
if any(name.startswith(catalog.SKILL_PREFIX) for name 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
+97
View File
@@ -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()
@@ -163,6 +163,15 @@ POINTS: dict[str, Point] = dict([
"the fixed question asked when a task finishes: how should this report read",
fixed_query=True),
# The moment arm (milestone 458). A LOOKUP, not a ranker: a rule mounted on
# a moment arrives when that moment happens, with no score, so it writes no
# retrieval_logs row and no score-shaped warning can apply. Its rows are in
# `rule_usage_events`, each carrying the moment in `detail`.
_p("moment_rule", UNBIDDEN,
"rules mounted on a moment of work, delivered when that moment happens",
expects_traffic=False,
quiet_because="speaks only once some rule is mounted on a moment; an "
"install where none is mounted is correctly silent here"),
# The rulings arm (milestone 444). A LOOKUP, not a ranker: a path falls
# under a System's patterns or it does not, so nothing here writes to
# retrieval_logs and no score-shaped warning can apply. Its rows are in
+55
View File
@@ -0,0 +1,55 @@
"""Which rules a caller may be shown — a rule's HOME, stated once (milestone 414).
A rule lives in a rulebook topic — GLOBAL, it applies wherever its owner
works — or on one project, where it applies to that project and nowhere else.
Every read that hands rules to a session answers the same question about that
home, so it is answered here and nowhere else:
- `project_id` unset: global rules only. A caller that does not say gets the
safe failure, which is surfacing less rather than another project's rules.
- `project_id=N`: global rules plus project N's own, and N's only when the
caller can read that project (access.can_read_project, so a shared project's
rules reach its collaborators too).
- `everywhere=True`: every rule the caller owns, in any home — the explicit
whole-rulebook question.
It was written inline in `semantic_search_rules` until the moment lookup
(milestone 458) needed the same answer. Two copies of a scope clause is how
one of them starts surfacing another project's rules, so it moved here first.
"""
from __future__ import annotations
from sqlalchemy import or_
from scribe.services.access import can_read_project
async def rule_home(user_id: int, project_id: int | None = None, *, everywhere: bool = False):
"""The WHERE clause for the rules this caller may be shown here.
Needs the statement joined through `joined_to_homes`, which outer-joins
the three tables the clause reads. A rule is in a topic XOR on a project
(migration 0059), so it matches exactly one arm of whichever clause
applies.
"""
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook
global_rule = Rulebook.owner_user_id == user_id
if everywhere:
return or_(global_rule, Project.user_id == user_id)
if project_id and await can_read_project(user_id, project_id):
return or_(global_rule, Rule.project_id == project_id)
return global_rule
def joined_to_homes(stmt):
"""Outer-join a statement over `Rule` to the tables `rule_home` reads."""
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
return (
stmt.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.outerjoin(Project, Rule.project_id == Project.id)
)
+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
]
+64
View File
@@ -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.
+20
View File
@@ -215,6 +215,26 @@ def _no_lesson_rule_links(request):
yield
@pytest.fixture(autouse=True)
def _no_moment_delivery(request):
"""Stub the moment lookup the mapped MCP tools attach (milestone 458).
update_task, the create_* record tools and the milestone tools now hand
back the rules mounted on the moment they mark, and finding those is two
database reads. Patched BENEATH the attach, at `deliver_for_act`, because
the tools bind `attach_moment_rules` by name at import time. Skipped for
integration tests; tests/test_moment_delivery.py binds the real one.
"""
if request.node.get_closest_marker("integration"):
yield
return
from scribe.services.retrieval_pipeline import RuleResult
with patch("scribe.services.moment_delivery.deliver_for_act",
AsyncMock(return_value=([], RuleResult()))):
yield
@pytest.fixture(autouse=True)
def _no_rule_arm():
"""Stub the write-path hint's standing-RULES arm (milestone 307).
+25 -4
View File
@@ -349,7 +349,8 @@ def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
@contextmanager
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}',
by_path: dict[str, bytes] | None = None):
"""A throwaway local HTTP listener for hook end-to-end tests: yields
``(port, seen)`` where ``seen`` collects every GET's parsed query string
(one dict per request, in order). Lets the shell be tested end to end —
@@ -357,6 +358,11 @@ def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
Three test modules each carried their own ``_Sink`` handler before #2904
consolidated them here; pass ``reply`` for the body the hook should see.
``by_path`` is for a hook that calls more than one route: each request is
answered with the reply for its path (``reply`` when none is listed), and
every entry in ``seen`` then also carries ``_path`` and, for a POST,
``_body`` — the raw request body, decoded.
"""
import http.server
import threading
@@ -365,12 +371,27 @@ def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
seen: list[dict] = []
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
def _answer(self, body: str | None):
parsed = urllib.parse.urlparse(self.path)
entry = urllib.parse.parse_qs(parsed.query)
out = reply
if by_path is not None:
entry["_path"] = parsed.path
out = by_path.get(parsed.path, reply)
if body is not None:
entry["_body"] = body
seen.append(entry)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(reply)
self.wfile.write(out)
def do_GET(self):
self._answer(None)
def do_POST(self):
size = int(self.headers.get("Content-Length") or 0)
self._answer(self.rfile.read(size).decode("utf-8", "replace"))
def log_message(self, *a):
pass
+45 -1
View File
@@ -6,16 +6,20 @@ claim depends on:
- only the owner can mount;
- the rule's detail reads its mounts back;
- a backup carries them, attached to the RESTORED rule's new id rather than
the old number.
the old number;
- the delivery lookup (step 4) answers by home: a project's mounted rule
reaches only work bound to that project, and nobody else's mount arrives.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import backup
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
@@ -155,3 +159,43 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
assert (await rulebooks_svc.list_rule_moments([restored_rule.id]))[restored_rule.id] == [
"work.finish", "reply.report",
]
async def _purge_projects(username: str) -> None:
async with async_session() as s:
for user in (await s.execute(
select(User).where(User.username == username)
)).scalars().all():
for project in (await s.execute(
select(Project).where(Project.user_id == user.id)
)).scalars().all():
await s.delete(project)
await s.commit()
async def test_the_delivery_lookup_answers_by_home(world):
uid, sid, rule = world["uid"], world["sid"], world["rule"]
await _purge_projects(OWNER_USERNAME)
project = await projects_svc.create_project(uid, "Moment delivery fixture")
local = await rulebooks_svc.create_project_rule(
project.id, uid, "Ship notes with the release",
"A release carries its notes.", when_to_apply="publishing a release",
)
await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"])
await rulebooks_svc.set_rule_moments(local.id, uid, ["work.deliver"])
# Unbound: the global rule, under the FIRST of the given moments it is on.
got = await rulebooks_svc.rules_on_moments(uid, ["reply.report", "work.finish"])
assert [(r.id, m) for r, m in got] == [(rule.id, "reply.report")]
assert await rulebooks_svc.rules_on_moments(uid, ["work.deliver"]) == []
# Bound to the project: its own rule arrives too.
got = await rulebooks_svc.rules_on_moments(uid, ["work.deliver"], project.id)
assert [(r.id, m) for r, m in got] == [(local.id, "work.deliver")]
# Somebody else's mount never arrives — not even naming their project.
assert await rulebooks_svc.rules_on_moments(sid, ["work.finish", "work.deliver"], project.id) == []
# The plugin's "can anything arrive" answer spans every home.
assert await rulebooks_svc.mounted_moments(uid) >= {"work.finish", "reply.report", "work.deliver"}
assert not (await rulebooks_svc.mounted_moments(sid)) & {"work.finish", "work.deliver"}
+373
View File
@@ -0,0 +1,373 @@
"""Delivering mounted rules when their moment happens (milestone 458 step 4).
The database is stubbed: the lookup's scoping is pinned against Postgres in
tests/test_integration_rule_moments.py. These pin what is decided above it:
- the line names the moment AND the act that reached it, so a misfire is
visible where it lands and can be unmapped in the session;
- a rule the session was already shown is cited, not repeated, and only the
fresh ones are recorded, each with its moment;
- every door fails open;
- the plugin is told which tools can reach anything, and nothing more;
- every Scribe tool the shipped mappings name carries the attach, and the
hook and the route agree on every name between them.
"""
from __future__ import annotations
import ast
import inspect
import json
import os
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from quart import Quart, g
from scribe.services import moment_actions
from scribe.services import moment_delivery as md
from scribe.services import retrieval_pipeline as rp
from scribe.services import rule_usage, rulebooks
from tests.helpers import fake_rule, http_sink, need_tools
# Bound before conftest's autouse stub replaces the module attribute.
_REAL_DELIVER = md.deliver_for_act
ROOT = Path(__file__).resolve().parents[1]
HOOK = ROOT / "plugin" / "hooks" / "scribe_moment.sh"
def _rule(rid, title="Done means delivered and checked"):
return fake_rule(id=rid, title=title, kind="rule",
when_to_apply="about to say a piece of work is finished")
def _moment(**kw):
return rp.RuleMoment(user_id=1, query="", project_id=kw.pop("project_id", 2), **kw)
PUSH = [{"moment": "work.deliver", "tool": "Bash", "match": "git push", "via": "default"}]
# ── the arm ─────────────────────────────────────────────────────────────
async def test_a_line_names_the_moment_and_the_act_that_reached_it():
lookup = AsyncMock(return_value=[(_rule(5), "work.deliver")])
surfaced = MagicMock()
result = await rp.run_moment_arm(
PUSH, _moment(), io=rp.MomentIO(lookup=lookup, record_rule_surfaced=surfaced),
)
assert len(result.lines) == 1
assert "at work.deliver, reached by `git push`" in result.lines[0]
assert "get_rule(5)" in result.lines[0]
assert result.rule_ids == [5]
lookup.assert_awaited_once_with(1, ["work.deliver"], 2)
surfaced.assert_called_once()
kw = surfaced.call_args.kwargs
assert kw["source"] == rp.MOMENT_RULE_SOURCE
assert kw["rule_ids"] == [5]
assert kw["detail"] == {5: "work.deliver"}
async def test_a_rule_already_shown_is_cited_after_the_fresh_ones_and_not_recorded():
lookup = AsyncMock(return_value=[(_rule(5), "work.deliver"), (_rule(9, "Other"), "work.deliver")])
surfaced = MagicMock()
result = await rp.run_moment_arm(
PUSH, _moment(exclude=frozenset({5})),
io=rp.MomentIO(lookup=lookup, record_rule_surfaced=surfaced),
)
assert result.shown_rule_ids == [9, 5]
assert result.rule_ids == [9]
assert result.lines[1].startswith("Also")
assert surfaced.call_args.kwargs["rule_ids"] == [9]
async def test_an_unbound_session_asks_for_global_rules_only():
lookup = AsyncMock(return_value=[])
await rp.run_moment_arm(PUSH, _moment(project_id=0),
io=rp.MomentIO(lookup=lookup, record_rule_surfaced=MagicMock()))
assert lookup.await_args.args[2] is None
async def test_the_arm_fails_open():
broken = AsyncMock(side_effect=RuntimeError("db down"))
result = await rp.run_moment_arm(
PUSH, _moment(), io=rp.MomentIO(lookup=broken, record_rule_surfaced=MagicMock()),
)
assert result.lines == [] and result.rule_ids == []
# A recorder that fails costs the telemetry, never the lines.
result = await rp.run_moment_arm(
PUSH, _moment(),
io=rp.MomentIO(lookup=AsyncMock(return_value=[(_rule(5), "work.deliver")]),
record_rule_surfaced=MagicMock(side_effect=RuntimeError("x"))),
)
assert len(result.lines) == 1
async def test_nothing_reached_asks_nothing():
lookup = AsyncMock()
result = await rp.run_moment_arm([], _moment(),
io=rp.MomentIO(lookup=lookup, record_rule_surfaced=MagicMock()))
assert result.lines == []
lookup.assert_not_awaited()
# ── the act → the moments → the rules ───────────────────────────────────
def _stub_db(pairs):
async def _moments_for(user_id, tool, tool_input):
return moment_actions.resolve(tool, tool_input, [])
return [
patch.object(md, "deliver_for_act", _REAL_DELIVER),
patch.object(moment_actions, "moments_for", _moments_for),
patch.object(rulebooks, "rules_on_moments", AsyncMock(return_value=pairs)),
patch.object(rule_usage, "record_rule_surfaced", MagicMock()),
]
async def _with(stack, coro_fn):
for p in stack:
p.start()
try:
return await coro_fn()
finally:
for p in reversed(stack):
p.stop()
async def test_a_task_closed_through_the_tool_carries_its_mounted_rules():
data = {"id": 40, "status": "done", "project_id": 2}
out = await _with(_stub_db([(_rule(11), "work.finish")]), lambda: md.attach_moment_rules(
1, "update_task", {"status": "done", "project_id": 7}, data,
))
assert out is data
assert out["moment_rules"]["rule_ids"] == [11]
assert "at work.finish, reached by `status=done`" in out["moment_rules"]["lines"][0]
assert out["moment_rules"]["open_with"] == "get_rule(id)"
async def test_the_records_project_wins_over_the_callers_argument():
lookup = AsyncMock(return_value=[])
stack = _stub_db([])
stack[2] = patch.object(rulebooks, "rules_on_moments", lookup)
await _with(stack, lambda: md.attach_moment_rules(
1, "update_task", {"status": "done", "project_id": 7}, {"project_id": 2},
))
assert lookup.await_args.args == (1, ["work.finish"], 2)
async def test_an_act_that_reaches_nothing_mounted_adds_nothing():
data = {"id": 40}
out = await _with(_stub_db([]), lambda: md.attach_moment_rules(
1, "update_task", {"status": "todo"}, data,
))
assert "moment_rules" not in out
async def test_the_attach_fails_open_and_passes_other_payloads_through():
data = {"id": 1}
with patch.object(md, "deliver_for_act", AsyncMock(side_effect=RuntimeError("x"))):
assert await md.attach_moment_rules(1, "create_note", {}, data) == {"id": 1}
marker = object()
assert await md.attach_moment_rules(1, "start_planning", {}, marker) is marker
# ── which tools the plugin needs to ask about ───────────────────────────
async def _reachable(mounted, mappings=()):
with patch.object(rulebooks, "mounted_moments", AsyncMock(return_value=set(mounted))), \
patch.object(moment_actions, "list_mappings", AsyncMock(return_value=list(mappings))):
return await md.reachable_tools(1)
async def test_an_install_with_nothing_mounted_keeps_the_hook_off_the_wire():
listing = AsyncMock()
with patch.object(rulebooks, "mounted_moments", AsyncMock(return_value=set())), \
patch.object(moment_actions, "list_mappings", listing):
assert await md.reachable_tools(1) == []
listing.assert_not_awaited()
async def test_only_the_tools_whose_moments_carry_a_mount_are_listed():
assert await _reachable({"work.deliver"}) == ["bash"]
assert await _reachable({"work.finish"}) == ["update_milestone", "update_task"]
assert await _reachable({"skill.release"}) == ["skill"]
async def test_an_installs_own_mapping_and_removal_both_count():
own = SimpleNamespace(tool="mcp__deploy__ship", match="", moment="work.deliver", effect="add")
assert "ship" in await _reachable({"work.deliver"}, [own])
gone = SimpleNamespace(tool="AskUserQuestion", match="", moment="reply.ask", effect="remove")
assert await _reachable({"reply.ask"}, [gone]) == []
# ── the plugin routes ───────────────────────────────────────────────────
async def test_the_route_reads_the_event_and_the_ledger():
from scribe.routes import plugin as routes
deliver = AsyncMock(return_value=(PUSH, rp.RuleResult(
lines=["a line"], rule_ids=[5], shown_rule_ids=[5, 9],
)))
app = Quart(__name__)
event = {"session_id": "s", "tool_name": "Bash", "tool_input": {"command": "git push"}}
async with app.test_request_context(
"/api/plugin/moment", method="POST", json=event,
query_string={"project_id": "3", "exclude_rule_ids": "9", "held_rule_ids": "4"},
):
g.user = SimpleNamespace(id=7)
with patch.object(routes.moment_delivery_svc, "deliver_for_act", deliver):
resp = await routes.moment.__wrapped__()
body = await resp.get_json()
assert body == {"context": "a line", "rule_ids": [5], "moments": ["work.deliver"]}
args, kw = deliver.await_args
assert args == (7, "Bash", {"command": "git push"})
assert kw == {"project_id": 3, "exclude": frozenset({9}), "held": frozenset({4})}
async def test_the_route_answers_an_empty_event_with_nothing():
from scribe.routes import plugin as routes
app = Quart(__name__)
async with app.test_request_context("/api/plugin/moment", method="POST", json={}):
g.user = SimpleNamespace(id=7)
resp = await routes.moment.__wrapped__()
assert await resp.get_json() == {"context": "", "rule_ids": [], "moments": []}
def test_both_routes_are_on_the_app():
from scribe.app import create_app
rules = {str(r.rule) for r in create_app().url_map.iter_rules()}
assert {"/api/plugin/moment", "/api/plugin/moment-tools"} <= rules
# ── the hook ────────────────────────────────────────────────────────────
def test_the_hook_is_registered_on_every_tool():
manifest = json.loads((ROOT / "plugin" / "hooks" / "hooks.json").read_text())
entries = {m.get("matcher"): [h["command"] for h in m["hooks"]]
for m in manifest["hooks"]["PreToolUse"]}
assert any("scribe_moment.sh" in c for c in entries.get("*", []))
def test_the_hook_and_the_routes_agree_on_every_name():
"""Rule 33 across the shell/Python seam: a renamed arg fails silently."""
src = HOOK.read_text()
assert "/api/plugin/moment-tools" in src and "/api/plugin/moment" in src
assert "exclude_rule_ids" in src and "scribe_held_query" in src
assert "scribe_rules_live" in src and "scribe_rules_append" in src
# The SHARED ledger, not one of its own.
assert '"${TMPDIR:-/tmp}/scribe-priorart"' in src and ".rules.ids" in src
for field in (".tools", ".rule_ids", ".context"):
assert f"'{field}'" in src
from scribe.routes import plugin as routes
route = inspect.getsource(routes.moment)
for name in ("exclude_rule_ids", "held_rule_ids", "tool_name", "tool_input"):
assert name in route
def test_the_hook_leaves_scribes_own_tools_to_their_responses():
assert "mcp__*scribe*__*) exit 0" in HOOK.read_text()
def test_the_hook_never_returns_a_permission_decision():
"""A mount is a recall aid: it informs the act and never holds it."""
code = [ln for ln in HOOK.read_text().splitlines() if not ln.lstrip().startswith("#")]
assert not any("permissionDecision" in ln or "scribe_json_deny" in ln for ln in code)
def _hook(tmp_path, port, tool="Bash", command="git push origin dev", session="s-moment"):
need_tools("bash", "curl", "awk")
env = {"PATH": os.environ["PATH"], "SCRIBE_URL": f"http://127.0.0.1:{port}",
"SCRIBE_TOKEN": "t", "TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": session, "cwd": str(tmp_path), "tool_name": tool,
"tool_input": {"command": command}}),
capture_output=True, text=True, env=env, timeout=30,
)
assert out.returncode == 0, out.stderr
return out.stdout
TOOLS = {"/api/plugin/moment-tools": b'{"tools":["bash"]}'}
def test_a_listed_tool_is_asked_about_and_its_rules_injected(tmp_path):
replies = dict(TOOLS)
replies["/api/plugin/moment"] = json.dumps(
{"context": "Standing rule that may apply at work.deliver", "rule_ids": [5],
"moments": ["work.deliver"]}).encode()
with http_sink(by_path=replies) as (port, seen):
out = _hook(tmp_path, port)
# The list is read once per window, not once per call.
_hook(tmp_path, port)
paths = [e["_path"] for e in seen]
assert paths == ["/api/plugin/moment-tools", "/api/plugin/moment", "/api/plugin/moment"]
sent = json.loads(seen[1]["_body"])
assert sent["tool_input"] == {"command": "git push origin dev"}
# The first call's fresh rule is on the shared ledger for the second.
assert seen[2]["exclude_rule_ids"] == ["5"]
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert "work.deliver" in ctx
def test_an_unlisted_tool_never_leaves_the_machine(tmp_path):
with http_sink(by_path=TOOLS) as (port, seen):
assert _hook(tmp_path, port, tool="Read") == ""
assert _hook(tmp_path, port, tool="mcp__plugin_scribe_scribe__update_task") == ""
assert [e["_path"] for e in seen] == ["/api/plugin/moment-tools"]
def test_an_install_with_nothing_mounted_sends_one_request_per_window(tmp_path):
with http_sink(by_path={"/api/plugin/moment-tools": b'{"tools":[]}'}) as (port, seen):
for _ in range(3):
assert _hook(tmp_path, port) == ""
assert len(seen) == 1
# ── the MCP door: every shipped Scribe action carries the attach ────────
def _attached_names(path: Path) -> dict[str, set[str]]:
"""{function name: {tool names passed to attach_moment_rules in it}}."""
out: dict[str, set[str]] = {}
for node in ast.parse(path.read_text()).body:
if not isinstance(node, ast.AsyncFunctionDef):
continue
for call in ast.walk(node):
if (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id == "attach_moment_rules" and len(call.args) > 1
and isinstance(call.args[1], ast.Constant)):
out.setdefault(node.name, set()).add(call.args[1].value)
return out
def test_every_scribe_tool_the_defaults_name_attaches_its_moment_rules():
tools_dir = ROOT / "src" / "scribe" / "mcp" / "tools"
defined: dict[str, dict[str, set[str]]] = {}
scribe_tools: set[str] = set()
for path in tools_dir.glob("*.py"):
tree = ast.parse(path.read_text())
scribe_tools |= {n.name for n in tree.body if isinstance(n, ast.AsyncFunctionDef)}
defined[path.name] = _attached_names(path)
shipped = {a.tool for a in moment_actions.DEFAULT_ACTIONS} & scribe_tools
assert {"update_task", "create_rule", "update_milestone"} <= shipped
for tool in sorted(shipped):
named = set().union(*(per_file.get(tool, set()) for per_file in defined.values()))
assert tool in named, (
f"{tool} is mapped onto a moment by the shipped defaults but does not "
f"attach its mounted rules — a client without the plugin would never "
f"receive them"
)