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>
197 lines
7.6 KiB
Python
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
|