Files
FabledScribe/src/scribe/services/system_rulings.py
T
bvandeusenandClaude Opus 5.5 556872c039
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
feat(rulings): a command or edit touching an area's files shows its rulings, once per session (milestone 444 step 4, #4757)
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>
2026-10-02 23:08:32 -04:00

197 lines
7.6 KiB
Python

"""An area's rulings, delivered when its files are touched (milestone 444).
A RULING is the operator's decision about how one area of the work must behave.
It lives in a `Rulings` section at the end of the area's System description
(writing-records.md says how one is written). The description already rides
every record filed under the System; this module is the other half: the first
command or edit in a session that touches the System's files
(`System.path_patterns`) shows its rulings, one line per System.
A LOOKUP, NOT A SEARCH. A shell command scores against prose decisions at noise
level, so nothing here is ranked: a path either falls under a System's
patterns or it does not. It takes no budget and no floor from the retrieval
arms beside it, and writes no retrieval_logs row — there is no score
distribution for it to join. Its surfacings go to `system_usage_events`.
ONCE IN FULL, THEN A REFERENCE (the #3750 shape). The hook keeps the Systems
already shown this session and passes them back; a repeat renders as one short
line rather than the rulings again, and is not counted as a surfacing.
Fails open everywhere: a delivery aid must never break the act it rides on.
"""
from __future__ import annotations
import logging
import posixpath
import re
import shlex
from scribe.services import systems as systems_svc
from scribe.services.system_usage import record_system_surfaced
from scribe.services.text import elide
logger = logging.getLogger(__name__)
# The section heading: `Rulings`, optionally as a markdown heading, bold, or
# with a trailing colon. The LAST one wins — the section sits at the end.
_HEADING = re.compile(r"^[ \t]*(?:#{1,6}[ \t]*)?\**Rulings\**[ \t]*:?[ \t]*$", re.I | re.M)
_BULLET = re.compile(r"^[ \t]*[-*][ \t]+(.*)$")
# Per line, so one System with a long list cannot crowd the others out.
RULING_CHARS = 300
RULINGS_PER_SYSTEM = 8
# A command is not a file list: these bound how much of it is read as paths.
_MAX_PATHS = 40
_LINE_SUFFIX = re.compile(r":\d+(?::\d+)?$")
_HAS_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,10}$")
_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
def parse_rulings(description: str | None) -> list[str]:
"""The rulings in a System description, one string per ruling.
A bullet under the last `Rulings` heading is a ruling; an indented line
after one continues it. The section ends at the first line that is
neither — a heading or paragraph written after it is not a ruling.
"""
text = description or ""
found = list(_HEADING.finditer(text))
if not found:
return []
out: list[str] = []
for line in text[found[-1].end():].splitlines():
if not line.strip():
continue
bullet = _BULLET.match(line)
if bullet:
if bullet.group(1).strip():
out.append(bullet.group(1).strip())
elif out and line[:1] in (" ", "\t"):
out[-1] = f"{out[-1]} {line.strip()}"
else:
break
return out
def _relative(token: str, root: str, cwd_rel: str) -> str:
"""`token` as a repo-relative path, or "" when it is not one."""
if token.startswith("/"):
if not root or not token.startswith(root.rstrip("/") + "/"):
return ""
token = token[len(root.rstrip("/")) + 1:]
elif cwd_rel:
token = f"{cwd_rel}/{token}"
path = posixpath.normpath(token)
if path in (".", "") or path == ".." or path.startswith("../"):
return ""
return path
def command_paths(command: str, *, root: str = "", cwd: str = "") -> list[str]:
"""The repo-relative paths a shell command names, in order, deduplicated.
Reads and writes alike: a session forms its picture of an area by reading
it, which is exactly when that area's rulings should reach it. A token is
taken as a path when it has a `/` or a file extension; flags, URLs and
globs are not paths. `root` is the repo's absolute root and `cwd` the
command's working directory, both from the hook — an absolute path
outside the root, or a relative one that climbs out of it, is dropped.
"""
command = command or ""
try:
tokens = shlex.split(command, comments=False, posix=True)
except ValueError:
tokens = command.split()
root = (root or "").rstrip("/")
cwd_rel = ""
if root and cwd:
cwd = cwd.rstrip("/")
if cwd.startswith(root + "/"):
cwd_rel = cwd[len(root) + 1:]
out: list[str] = []
for raw in tokens:
token = raw.strip().strip("'\"").rstrip(";,)|&")
if token.startswith("-"):
if "=" not in token:
continue
token = token.split("=", 1)[1]
elif _ASSIGNMENT.match(token):
token = token.split("=", 1)[1]
if not token or "://" in token or any(c in token for c in "*?[]{}$`<>"):
continue
# `file.py:120` and a test id `file.py::test_x` name the file.
token = _LINE_SUFFIX.sub("", token.split("::", 1)[0])
if "/" not in token and not _HAS_EXTENSION.search(token):
continue
path = _relative(token, root, cwd_rel)
if path and path not in out:
out.append(path)
if len(out) >= _MAX_PATHS:
break
return out
def _full_line(system, path: str, rulings: list[str]) -> str:
shown = [elide(r, RULING_CHARS)[0] for r in rulings[:RULINGS_PER_SYSTEM]]
more = len(rulings) - len(shown)
listed = " ".join(f"({i}) {r}" for i, r in enumerate(shown, 1))
if more > 0:
listed += f" (+{more} more in `get_system({system.id})`)"
return (
f"> Rulings for `{path}` — the operator's decisions about "
f"{system.name} (System {system.id}): {listed} "
"Work here keeps to them; something that would depart from one is a "
"question for the operator, not an option to choose. "
"(Shown once per session.)"
)
def _reference_line(system, path: str) -> str:
return (
f"> `{path}` is in {system.name} (System {system.id}) — its rulings "
"were shown earlier this session and still apply."
)
async def rulings_for_paths(
user_id: int,
project_id: int,
paths: list[str],
*,
seen: list[int] | set[int] | None = None,
source: str,
) -> dict:
"""Rulings lines for the Systems whose files `paths` touch.
Returns {"lines": [...], "system_ids": [...]} — `system_ids` are the
Systems shown IN FULL on this call, for the hook to add to the session's
seen list and for the usage ledger; a System in `seen` gets a reference
line and is in neither. A System with patterns but no rulings says
nothing: there is no decision to deliver, and the charter already rides
its records.
"""
out: dict = {"lines": [], "system_ids": []}
if not project_id or not paths:
return out
try:
already = {int(s) for s in (seen or [])}
for system, hit in await systems_svc.systems_for_paths(user_id, project_id, paths):
rulings = parse_rulings(system.description)
if not rulings:
continue
if system.id in already:
out["lines"].append(_reference_line(system, hit[0]))
else:
out["lines"].append(_full_line(system, hit[0], rulings))
out["system_ids"].append(system.id)
if out["system_ids"]:
record_system_surfaced(
user_id=user_id, system_ids=out["system_ids"], source=source,
project_id=project_id,
)
except Exception:
logger.debug("rulings arm failed", exc_info=True)
return {"lines": [], "system_ids": []}
return out