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
+51 -6
View File
@@ -48,6 +48,7 @@ from scribe.services.retrieval_telemetry import record_retrieval
from scribe.services.settings import get_setting
from scribe.services.systems import system_names_for
from scribe.services.text import elide
from scribe.services import system_rulings as system_rulings_svc
logger = logging.getLogger(__name__)
@@ -2187,6 +2188,7 @@ async def build_write_path_hint(
exclude_derive: list[str] | None = None,
exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
seen_ruling_systems: list[int] | None = None,
) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -2253,7 +2255,7 @@ async def build_write_path_hint(
cfg = await get_writepath_config(user_id)
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
"suggested": [], "divergence": [], "derive": [], "derive_keys": [],
"rule_ids": []}
"rule_ids": [], "ruling_system_ids": []}
path = (path or "").strip()
if not cfg["enabled"] or not path:
return empty
@@ -2590,9 +2592,21 @@ async def build_write_path_hint(
design_text, design_dedup = await _design_arm(
user_id, project_id, path, set(exclude_derive or []),
)
# The rulings arm (milestone 444), decided here for the design arm's
# reasons: a lookup by path, so a write that matched no prior art still
# carries its area's rulings, and it never switches the ranked arms on.
rulings = await system_rulings_svc.rulings_for_paths(
user_id, project_id, [path], seen=seen_ruling_systems,
source="rulings_write_path",
)
if not staleness and not synced and not menu and not suggested and not divergence and not derive:
if design_text:
return {**empty, "context": design_text, "derive_keys": [design_dedup]}
if design_text or rulings["lines"]:
return {
**empty,
"context": "\n".join(rulings["lines"] + ([design_text] if design_text else [])),
"derive_keys": [design_dedup] if design_dedup else [],
"ruling_system_ids": rulings["system_ids"],
}
return empty
owners = await owner_names_for({
@@ -2614,7 +2628,9 @@ async def build_write_path_hint(
# Seeded with the staleness line, which is decided above the early
# return and so cannot wait for this list to exist.
lines: list[str] = list(staleness)
# First after staleness: it BINDS, where everything below is prior art.
# First after staleness: rulings and the design system BIND, where
# everything below is prior art.
lines.extend(rulings["lines"])
if design_text:
lines.append(design_text)
sync_note_ids: list[int] = []
@@ -2890,6 +2906,7 @@ async def build_write_path_hint(
),
"rule_ids": rule_ids,
"checkpoint": checkpoint,
"ruling_system_ids": rulings["system_ids"],
}
@@ -2901,18 +2918,46 @@ async def build_tool_rule_hint(
project_id: int = 0,
exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
root: str = "",
cwd: str = "",
seen_ruling_systems: list[int] | None = None,
) -> dict:
"""Standing rules that may apply to the action about to be taken — matched
directly (`_tool_rule_hint`, where the design is written), then reached
through a linked lesson (`_rules_via_lessons`, #4633)."""
through a linked lesson (`_rules_via_lessons`, #4633) — and the rulings of
any area whose files the command names (milestone 444).
`root` and `cwd` are the repo's absolute root and the command's working
directory, from the hook; they are what turn the paths a command names
into the repo-relative paths a System's patterns are written in."""
out = await _tool_rule_hint(
user_id, tool_name, command, project_id=project_id,
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
)
return await _add_rules_via_lessons(
out = await _add_rules_via_lessons(
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
)
# Its own arm, not part of the rule search: a lookup by path, with no
# floor and no budget, so it adds to the rule lines rather than competing
# with them. First, because a ruling is the operator's own decision.
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
# on every command, and the hook reads an absent list as an empty one.
try:
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
if paths and (await get_writepath_config(user_id)).get("enabled"):
rulings = await system_rulings_svc.rulings_for_paths(
user_id, project_id, paths,
seen=seen_ruling_systems, source="rulings_pre_tool",
)
if rulings["lines"]:
out["context"] = "\n".join(
rulings["lines"] + ([out["context"]] if out.get("context") else [])
)
out["ruling_system_ids"] = rulings["system_ids"]
except Exception:
logger.debug("pre-tool rulings arm failed", exc_info=True)
return out
async def _tool_rule_hint(