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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user