feat(rulings): a command or edit touching an area's files shows its rulings, once per session (milestone 444 step 4, #4757)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped

A System's rulings (the Rulings section of its description) now reach the
work by path, not by similarity. Both PreToolUse arms resolve the files a
command or edit names to the Systems whose path_patterns cover them, and the
first touch in a session shows each area's rulings in one line; a repeat is
a one-line reference. A lookup, so no floor, no budget, no retrieval_logs row.

- services/system_rulings: parse_rulings, command_paths (reads and writes,
  relative to the repo root from any cwd; flags, URLs, globs skipped),
  rulings_for_paths
- /tool-rules takes root, cwd and seen_ruling_systems; /prior-art takes
  seen_ruling_systems; both return ruling_system_ids
- hooks share <sid>.rulings.ids (cleared on compaction by the ledger naming
  convention); the Bash hook sends the repo root and cwd
- system_usage_events (migration 0114): surfacings by source, pulls from
  get_system; carried by backup (v20) through the system map
- writing-records: rulings also arrive when the area's files are touched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:08:32 -04:00
co-authored by Claude Opus 5.5
parent a113c72b4f
commit 556872c039
17 changed files with 808 additions and 13 deletions
+58 -1
View File
@@ -13,6 +13,7 @@ from scribe.models.rule_version import RuleVersion
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.note_usage import NoteUsageEvent
from scribe.models.rule_usage import RuleUsageEvent
from scribe.models.system_usage import SystemUsageEvent
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
@@ -88,8 +89,12 @@ logger = logging.getLogger(__name__)
# v19 (2026-10) added lesson_no_rule (#4631): the "no rule fits" answer and its
# reason. Without it a restored lesson that was judged to stand alone reads as
# never judged, and lands back on the unjudged list.
# v20 (2026-10) added systems.path_patterns and system_usage_events (milestone
# 444): the files that are each area, and whether an area's rulings were read
# once shown. The usage rows restore through the SYSTEM map, for the reason
# the rule twin restores through the rule map.
# Bump when the serialized schema changes.
BACKUP_VERSION = 19
BACKUP_VERSION = 20
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -131,6 +136,9 @@ _BACKED_UP = [
"lesson_rule_links",
# v19 (2026-10): "no rule fits" answers (#4631).
"lesson_no_rule",
# v20 (2026-10): System usage telemetry (milestone 444), for the reason
# its note and rule twins travel.
"system_usage_events",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -231,6 +239,7 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"note_usage_events": {"id"},
# Same as the note twin: the surrogate key is re-issued on insert.
"rule_usage_events": {"id"},
"system_usage_events": {"id"},
# Same again — and everything else travels, because each remaining column
# is part of the argument: what moved, from what, to what, by whom, why.
"retrieval_tuning_events": {"id"},
@@ -319,6 +328,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"lesson_no_rule": set(),
"note_usage_events": {"id"},
"rule_usage_events": {"id"},
"system_usage_events": {"id"},
"retrieval_tuning_events": {"id"},
"design_systems": {
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
@@ -444,6 +454,17 @@ def _usage_event_rows(rows) -> list[dict]:
]
def _system_usage_event_rows(rows) -> list[dict]:
return [
{
"user_id": r.user_id, "system_id": r.system_id, "event": r.event,
"source": r.source, "project_id": r.project_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
def _rule_usage_event_rows(rows) -> list[dict]:
return [
{
@@ -802,6 +823,9 @@ async def export_full_backup() -> dict:
rule_usage_events = (
await session.execute(select(RuleUsageEvent))
).scalars().all()
system_usage_events = (
await session.execute(select(SystemUsageEvent))
).scalars().all()
# Oldest first, so a restored history reads in the order the dials
# actually moved — the sequence IS the argument when a surface has been
# walked up and down.
@@ -854,6 +878,7 @@ async def export_full_backup() -> dict:
"design_tokens": _design_token_rows(design_tokens),
"note_usage_events": _usage_event_rows(usage_events),
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
"system_usage_events": _system_usage_event_rows(system_usage_events),
"retrieval_tuning_events": _retrieval_tuning_event_rows(
retrieval_tuning_events
),
@@ -991,6 +1016,12 @@ async def export_user_backup(user_id: int) -> dict:
rule_usage_events = (await session.execute(
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
)).scalars().all() if _rule_ids else []
# Scoped through the SYSTEM, for the reason the rule usage events
# above are scoped through the rule: `user_id` is who it fired for.
_system_ids = [sy.id for sy in systems]
system_usage_events = (await session.execute(
select(SystemUsageEvent).where(SystemUsageEvent.system_id.in_(_system_ids))
)).scalars().all() if _system_ids else []
# Scoped on user_id, and here that IS the right column — unlike the
# rule usage events directly above. These record changes to this user's
# OWN retrieval settings, which is what `user_id` means on this table;
@@ -1054,6 +1085,7 @@ async def export_user_backup(user_id: int) -> dict:
"design_tokens": _design_token_rows(design_tokens),
"note_usage_events": _usage_event_rows(usage_events),
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
"system_usage_events": _system_usage_event_rows(system_usage_events),
"retrieval_tuning_events": _retrieval_tuning_event_rows(
retrieval_tuning_events
),
@@ -1566,6 +1598,22 @@ def _build_rule_usage_event(row: dict, maps: _Maps) -> RuleUsageEvent | None:
)
def _build_system_usage_event(row: dict, maps: _Maps) -> SystemUsageEvent | None:
"""Resolved through the SYSTEM map — see `_build_rule_usage_event`."""
sid = maps.systems.get(row.get("system_id", 0))
if sid is None:
return None
return SystemUsageEvent(
user_id=maps.users.get(row.get("user_id") or 0),
system_id=sid,
event=row.get("event", ""),
source=row.get("source", ""),
project_id=(maps.projects.get(row["project_id"])
if row.get("project_id") else None),
created_at=_dt(row.get("created_at")),
)
def _build_repo_binding(row: dict, maps: _Maps) -> RepoBinding | None:
"""Small, but losing these means every bound repo quietly stops loading
its project at session start."""
@@ -1816,6 +1864,7 @@ async def _restore_v2(data: dict) -> dict:
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
"system_usage_events": 0,
"repo_bindings": 0,
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
"code_shape_uses": 0, "canonical_systems": 0,
@@ -2105,6 +2154,14 @@ async def _restore_v2(data: dict) -> dict:
session.add(event)
stats["rule_usage_events"] += 1
# And the System twin, after the Systems (step 16 fills their map).
for ev in data.get("system_usage_events", []):
event = _build_system_usage_event(ev, maps)
if event is None:
continue
session.add(event)
stats["system_usage_events"] += 1
# 20. Repo bindings
for rb_data in data.get("repo_bindings", []):
binding = _build_repo_binding(rb_data, maps)