fix(moments): the tools import the delivery module, and the tool-list cache leaves the swept directory (milestone 458 step 4a, #4922)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 32s

Two guards caught 7865313:

- test_mcp_tool_processes reads every coroutine in a tool module's
  namespace as a tool, and a name-imported attach_moment_rules looked
  like one. All seven modules now call moment_delivery.attach_moment_rules,
  and the parity guard accepts the attribute form.
- The session-ledger convention: a file in a swept directory must be a
  .ids ledger. The tool-list cache describes the install, not the
  context, so it moves to its own directory, scribe-moment, where a
  compaction does not sweep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 12:24:30 -04:00
co-authored by Claude Opus 5.5
parent 78653130d6
commit b3b616b20a
11 changed files with 32 additions and 27 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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).", "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.05.1621", "version": "2026.10.05.1624",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+5 -3
View File
@@ -60,9 +60,11 @@ safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# #
# Five minutes. A mount or a mapping made in this session reaches the hook # 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. # 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 # Its own directory, outside SCRIBE_LEDGER_DIRS, so a compaction does not
# install, not what this context holds. # sweep it: it describes the install, not what this context holds.
tools_file="$state_dir/${safe_sid}.moment.tools" cache_dir="${TMPDIR:-/tmp}/scribe-moment"
mkdir -p "$cache_dir" 2>/dev/null || true
tools_file="$cache_dir/${safe_sid}.tools"
now=$(date +%s 2>/dev/null) || now=0 now=$(date +%s 2>/dev/null) || now=0
stamp="" stamp=""
[ -f "$tools_file" ] && stamp=$(head -n 1 "$tools_file" 2>/dev/null) [ -f "$tools_file" ] && stamp=$(head -n 1 "$tools_file" 2>/dev/null)
+2 -2
View File
@@ -19,7 +19,7 @@ from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.mcp.tools import systems as systems_tools from scribe.mcp.tools import systems as systems_tools
from scribe.services.note_usage import attach_usage, record_pulled from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
# The payload shape lives in the service (`lesson_to_dict`), shared with the # The payload shape lives in the service (`lesson_to_dict`), shared with the
@@ -246,7 +246,7 @@ async def create_lesson(
await _offer_candidates(uid, data, what, when_to_apply, project_id) await _offer_candidates(uid, data, what, when_to_apply, project_id)
elif no_rule.strip(): elif no_rule.strip():
await _name_convergence(uid, data, note.id) await _name_convergence(uid, data, note.id)
return await attach_moment_rules(uid, "create_lesson", {"project_id": project_id}, data) return await moment_delivery.attach_moment_rules(uid, "create_lesson", {"project_id": project_id}, data)
async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None: async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
+3 -3
View File
@@ -19,7 +19,7 @@ from scribe.services import task_logs as task_logs_svc
from scribe.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.services.record_refs import refuse_guessed_ids from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
async def list_milestones(project_id: int) -> dict: async def list_milestones(project_id: int) -> dict:
@@ -133,7 +133,7 @@ async def create_milestone(
body=body or None, body=body or None,
status=status, status=status,
) )
return await attach_moment_rules( return await moment_delivery.attach_moment_rules(
uid, "create_milestone", {"project_id": project_id}, milestone.to_dict(), uid, "create_milestone", {"project_id": project_id}, milestone.to_dict(),
) )
@@ -175,7 +175,7 @@ async def update_milestone(
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields) milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None: if milestone is None:
raise ValueError(f"milestone {milestone_id} not found") raise ValueError(f"milestone {milestone_id} not found")
return await attach_moment_rules( return await moment_delivery.attach_moment_rules(
uid, "update_milestone", {"status": status, "project_id": project_id}, uid, "update_milestone", {"status": status, "project_id": project_id},
milestone.to_dict(), milestone.to_dict(),
) )
+2 -2
View File
@@ -23,7 +23,7 @@ from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
async def list_notes( async def list_notes(
@@ -237,7 +237,7 @@ async def create_note(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) 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) await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note")) data.update(dedup_svc.note_overlap_response(overlaps, "note"))
return await attach_moment_rules(uid, "create_note", {"project_id": project_id}, data) return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
async def update_note( async def update_note(
+2 -2
View File
@@ -14,7 +14,7 @@ from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled from scribe.services.note_usage import record_pulled
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
async def list_processes( async def list_processes(
@@ -107,7 +107,7 @@ async def create_process(
) )
if system_ids: if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids) await systems_svc.set_record_systems(uid, note.id, system_ids)
return await attach_moment_rules(uid, "create_process", {}, note.to_dict()) return await moment_delivery.attach_moment_rules(uid, "create_process", {}, note.to_dict())
async def get_process(name_or_id: str, project_id: int = 0) -> dict: async def get_process(name_or_id: str, project_id: int = 0) -> dict:
+4 -4
View File
@@ -22,7 +22,7 @@ from scribe.services import trash as trash_svc
from scribe.services.rule_usage import ( from scribe.services.rule_usage import (
record_rule_outcome, record_rule_pulled, record_rule_outcome, record_rule_pulled,
) )
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
# ── Rulebook CRUD ─────────────────────────────────────────────────────── # ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -537,7 +537,7 @@ async def create_rule(
) )
data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments) data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments)
data.update(dedup_svc.overlap_response(overlaps, "rule")) data.update(dedup_svc.overlap_response(overlaps, "rule"))
return await attach_moment_rules(uid, "create_rule", {}, data) return await moment_delivery.attach_moment_rules(uid, "create_rule", {}, data)
async def create_project_rule( async def create_project_rule(
@@ -646,7 +646,7 @@ async def create_project_rule(
) )
data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments) data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments)
data.update(dedup_svc.overlap_response(overlaps, "rule")) data.update(dedup_svc.overlap_response(overlaps, "rule"))
return await attach_moment_rules(uid, "create_project_rule", {"project_id": project_id}, data) return await moment_delivery.attach_moment_rules(uid, "create_project_rule", {"project_id": project_id}, data)
async def update_rule( async def update_rule(
@@ -879,7 +879,7 @@ async def create_preference(
) )
data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments) data = await rulebooks_svc.rule_detail(uid, rule, system_ids, moments)
data.update(dedup_svc.overlap_response(overlaps, "preference")) data.update(dedup_svc.overlap_response(overlaps, "preference"))
return await attach_moment_rules(uid, "create_preference", {}, data) return await moment_delivery.attach_moment_rules(uid, "create_preference", {}, data)
async def update_preference( async def update_preference(
+2 -2
View File
@@ -17,7 +17,7 @@ from scribe.services import dedup as dedup_svc
from scribe.services import snippets as snippets_svc from scribe.services import snippets as snippets_svc
from scribe.services.note_usage import attach_usage, record_pulled from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services import systems as systems_svc from scribe.services import systems as systems_svc
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
async def list_snippets( async def list_snippets(
@@ -224,7 +224,7 @@ async def create_snippet(
advice = snippets_svc.trigger_advice(when_to_use) advice = snippets_svc.trigger_advice(when_to_use)
if advice: if advice:
data["trigger_advice"] = advice data["trigger_advice"] = advice
return await attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data) return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
async def get_snippet(snippet_id: int, project_id: int = 0) -> dict: async def get_snippet(snippet_id: int, project_id: int = 0) -> dict:
+3 -3
View File
@@ -46,7 +46,7 @@ from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids from scribe.services.record_refs import refuse_guessed_ids
from scribe.services.text import elide from scribe.services.text import elide
from scribe.services.moment_delivery import attach_moment_rules from scribe.services import moment_delivery
# A work log entry is prose, often long — the discipline asks for what was # A work log entry is prose, often long — the discipline asks for what was
@@ -467,7 +467,7 @@ async def update_task(
if prefs: if prefs:
data["reply_preferences"] = prefs data["reply_preferences"] = prefs
data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE
return await attach_moment_rules( return await moment_delivery.attach_moment_rules(
uid, "update_task", {"status": status, "project_id": project_id}, data, uid, "update_task", {"status": status, "project_id": project_id}, data,
) )
@@ -737,7 +737,7 @@ async def start_planning(
) )
if isinstance(result, dict): if isinstance(result, dict):
result.update(dedup_svc.batch_overlap_response(overlaps)) result.update(dedup_svc.batch_overlap_response(overlaps))
await attach_moment_rules(uid, "start_planning", {"project_id": project_id}, result) await moment_delivery.attach_moment_rules(uid, "start_planning", {"project_id": project_id}, result)
return result return result
+4 -3
View File
@@ -221,9 +221,10 @@ def _no_moment_delivery(request):
update_task, the create_* record tools and the milestone tools now hand 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 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 database reads. Patched BENEATH the attach, at `deliver_for_act`, so the
the tools bind `attach_moment_rules` by name at import time. Skipped for attach itself — its fail-open and its non-dict pass-through — still runs.
integration tests; tests/test_moment_delivery.py binds the real one. Skipped for integration tests; tests/test_moment_delivery.py binds the
real one.
""" """
if request.node.get_closest_marker("integration"): if request.node.get_closest_marker("integration"):
yield yield
+4 -2
View File
@@ -346,8 +346,10 @@ def _attached_names(path: Path) -> dict[str, set[str]]:
if not isinstance(node, ast.AsyncFunctionDef): if not isinstance(node, ast.AsyncFunctionDef):
continue continue
for call in ast.walk(node): for call in ast.walk(node):
if (isinstance(call, ast.Call) and isinstance(call.func, ast.Name) func = call.func if isinstance(call, ast.Call) else None
and call.func.id == "attach_moment_rules" and len(call.args) > 1 name = (func.id if isinstance(func, ast.Name)
else func.attr if isinstance(func, ast.Attribute) else "")
if (name == "attach_moment_rules" and len(call.args) > 1
and isinstance(call.args[1], ast.Constant)): and isinstance(call.args[1], ast.Constant)):
out.setdefault(node.name, set()).add(call.args[1].value) out.setdefault(node.name, set()).add(call.args[1].value)
return out return out