Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/tools/project-state/common.py
Fimeg a8caf27c14 project-state: weekly evidence, carried state, and an agent's door
Five weeks of forge evidence per week rather than a rolling window, so a
finding survives under one key and "closed after three weeks" is computable.
Rules run deterministically; agent.py hands the packet to whoever writes the
prose and never calls a model itself.

Model choice fails closed — no default, no substitute, and the exact named
model must be served or the pass refuses to run.
2026-08-18 19:53:19 -04:00

276 lines
10 KiB
Python

"""Shared pieces for the project-state pass: forge client, weeks, dimensions.
See docs/tasks/79-project-state.md for what this is for and why it is shaped
this way.
"""
import datetime as dt
import json
import os
import re
import subprocess
import urllib.parse
import urllib.request
from zoneinfo import ZoneInfo
LOCAL = ZoneInfo("America/Toronto")
OWNER = "Fimeg"
PROJECTS = os.path.expanduser("~/Projects")
# Listed, not discovered. A repo joining Souveraine is a deliberate addition
# here, and one that goes quiet should be visible as a quiet row rather than
# silently stop being read. Same reasoning as tools/ci-status.sh.
REPOS = [
"souveraine", "SouveraineOS", "Pixel3Arch", "souveraine-viewtop",
"souveraine-updater", "souveraine-lens", "souveraine-speech",
"souveraine-player", "souveraine-usb", "culver", "linux-blueline",
"hexagonrpc", "smithay", "pocketboot", "libcmatrix", "upower",
]
# The SAF sections, plus the two the pipeline owns and SAF has no page for.
DIMENSIONS = [
"memory", "runtime", "reflection", "identity", "surface",
"sensorium", "release", "reliability", "federation", "adoption",
]
UNASSIGNED = "unassigned"
# Longer keywords score higher, so "device state" beats a bare "state" and a
# path full of a subsystem's name beats one incidental word in a subject.
KEYWORDS = {
"memory": ["memory", "memfs", "compaction", "archivist", "recall", "vault", "memoir"],
"runtime": ["server", "handler", "runtime", "substrate", "provider", "tool call",
"stream", "sse", "prompt", "context", "turn", "inference", "token"],
"reflection": ["reflection", "subconscious", "cadence", "n+1", "aster", "introspect"],
"identity": ["identity", "authority", "sessiond", "principal", "secrets", "machined",
"trust", "login", "credential", "permission", "polkit", "device state",
"device_state", "admission"],
"surface": ["shell", "panel", "tui", "surface", "qml", "dock", "dial", "osk",
"keyboard", "island", "overview", "compositor", "viewtop", "window",
"gesture", "lockscreen", "card", "render"],
"sensorium": ["sensor", "grip", "battery", "charge", "audio", "microphone", "camera",
"haptic", "bluetooth", "airpods", "librepods", "modem", "bearer",
"brightness", "fingerprint", "touch", "squeeze"],
"release": ["release", "package", "pkgbuild", "pacman", "repo-add", "distribution",
"artifact", "signing", "manifest"],
"reliability": ["crash", "stall", "deadlock", "leak", "regression", "revert",
"hotfix", "clippy", "flake", "panic", "timeout", "retry", "workflow"],
"federation": ["federation", "enrol", "enroll", "peer", "mirror", "distributed",
"multi-device", "household", "sync"],
"adoption": ["license", "licence", "install", "provision", "onboard",
"quickstart", "getting started", "contributor"],
}
# Two path shapes that no keyword scan should have to guess at.
PATH_RULES = [
("reliability", r"(^|/)\.gitea/"),
("release", r"(^|/)(packaging|pkgs?)/|PKGBUILD"),
]
# Only for a commit that neither its paths nor its subject place. A repo whose
# whole purpose is one dimension answers for it.
REPO_DEFAULT = {
"souveraine": "runtime",
"Pixel3Arch": "sensorium",
"linux-blueline": "sensorium",
"souveraine-viewtop": "surface",
"souveraine-lens": "adoption",
"souveraine-updater": "release",
"souveraine-player": "surface",
"souveraine-speech": "sensorium",
"souveraine-usb": "federation",
"culver": "surface",
"hexagonrpc": "sensorium",
"smithay": "surface",
"pocketboot": "release",
"libcmatrix": "surface",
"upower": "sensorium",
}
# Commit subjects are written for the man who owns the machines. Evidence stays
# raw because it never leaves the forge; this is here for whatever gets promoted
# to a public surface later. Same patterns as the ledger's collector.
REDACTIONS = [
(re.compile(r'\b(?:10|127)(?:\.\d{1,3}){2,3}(?::\d+)?\b'), "[host]"),
(re.compile(r'\b192\.168(?:\.\d{1,3}){1,2}(?::\d+)?\b'), "[host]"),
(re.compile(r'\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){1,2}(?::\d+)?\b'), "[host]"),
(re.compile(r'\b(?:WIUF[-\w]*|wiufph|archdev)\b', re.I), "[host]"),
(re.compile(r'/home/casey|/root\b'), "~"),
]
def redact(text):
for pattern, mask in REDACTIONS:
text = pattern.sub(mask, text)
return text
def score(text):
"""Dimension scores for any text; ties go to the earlier dimension."""
low = (text or "").lower()
hits = {d: sum(len(k) for k in words if k in low) for d, words in KEYWORDS.items()}
hits = {d: s for d, s in hits.items() if s}
if not hits:
return None, {}
best = max(hits, key=lambda d: (hits[d], -DIMENSIONS.index(d)))
return best, hits
def classify_text(text):
"""For a task row or a document: what is this about, or nothing."""
best, _ = score(text)
return (best, "text") if best else (UNASSIGNED, "none")
def classify(repo, subject, paths=None):
"""One dimension for a commit, and the basis that chose it.
Paths and subject are both read and weighted alike. Paths alone put
`compaction: target the active conversation` under the server module it
edits; the subject is the author saying which subsystem he thinks he moved.
"""
joined = " ".join(paths or [])
_, from_paths = score(joined)
_, from_subject = score(subject)
total = {d: from_paths.get(d, 0) + from_subject.get(d, 0)
for d in set(from_paths) | set(from_subject)}
if total:
best = max(total, key=lambda d: (total[d], -DIMENSIONS.index(d)))
return best, "paths" if from_paths.get(best, 0) else "subject"
for dim, pattern in PATH_RULES:
if joined and re.search(pattern, joined, re.I):
return dim, "paths"
if repo in REPO_DEFAULT:
return REPO_DEFAULT[repo], "repo"
return UNASSIGNED, "none"
# ---- weeks -----------------------------------------------------------------
def parse_week(spec, today=None):
"""'2026-W33', 'current', 'last' -> (label, monday, sunday) as local dates."""
today = today or dt.datetime.now(LOCAL).date()
if spec in ("current", "last", None):
monday = today - dt.timedelta(days=today.isoweekday() - 1)
if spec == "last":
monday -= dt.timedelta(days=7)
else:
m = re.fullmatch(r"(\d{4})-?W(\d{1,2})", spec.upper())
if not m:
raise ValueError(f"week must look like 2026-W33, got {spec!r}")
monday = dt.date.fromisocalendar(int(m.group(1)), int(m.group(2)), 1)
year, week, _ = monday.isocalendar()
return f"{year}-W{week:02d}", monday, monday + dt.timedelta(days=6)
def week_bounds_utc(monday, sunday):
"""Local week -> UTC instants, so a Sunday-evening commit stays in its week."""
start = dt.datetime.combine(monday, dt.time.min, LOCAL).astimezone(dt.timezone.utc)
end = dt.datetime.combine(sunday + dt.timedelta(days=1), dt.time.min, LOCAL).astimezone(dt.timezone.utc)
return start, end
def week_monday(label):
year, week = label.split("-W")
return dt.date.fromisocalendar(int(year), int(week), 1)
def label_for(monday):
y, w, _ = monday.isocalendar()
return f"{y}-W{w:02d}"
def previous_week(label):
return label_for(week_monday(label) - dt.timedelta(days=7))
def week_span(first, last):
"""Every ISO week label from first to last inclusive, in order."""
monday, end, out = week_monday(first), week_monday(last), []
while monday <= end:
out.append(label_for(monday))
monday += dt.timedelta(days=7)
return out
def iso_z(when):
return when.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def parse_iso(value):
if not value:
return None
try:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
# ---- forge -----------------------------------------------------------------
class Forge:
# Gitea clamps a page to MAX_RESPONSE_ITEMS (50) and ignores a larger limit
# without saying so. The ledger lost 24 of 74 repos to this for months.
PAGE = 50
def __init__(self, base=None, token=None):
self.base = (base or os.environ.get("GITEA_URL") or "http://10.10.20.120:4455").rstrip("/")
self.token = token or os.environ.get("GITEA_TOKEN") or self._stored_token()
self.calls = 0
def _stored_token(self):
path = os.path.expanduser("~/.git-credentials")
host = urllib.parse.urlsplit(self.base).hostname or ""
try:
for line in open(path):
parsed = urllib.parse.urlsplit(line.strip())
if parsed.hostname and parsed.hostname.split("%")[0] == host:
return parsed.password or ""
except OSError:
pass
return ""
def get(self, path):
req = urllib.request.Request(f"{self.base}/api/v1/{path}")
if self.token:
req.add_header("Authorization", f"token {self.token}")
self.calls += 1
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def pages(self, path, max_pages=40):
sep = "&" if "?" in path else "?"
out = []
for page in range(1, max_pages + 1):
chunk = self.get(f"{path}{sep}limit={self.PAGE}&page={page}")
if isinstance(chunk, dict):
chunk = chunk.get("workflow_runs") or chunk.get("data") or []
if not chunk:
return out
out += chunk
if len(chunk) < self.PAGE:
return out
raise RuntimeError(f"{path}: {max_pages} pages and still going")
def version(self):
return self.get("version").get("version", "?")
# ---- local checkouts -------------------------------------------------------
def checkout(repo):
path = os.path.join(PROJECTS, repo)
return path if os.path.isdir(os.path.join(path, ".git")) else None
def git(path, *args):
proc = subprocess.run(["git", "-C", path, *args], capture_output=True, text=True)
return proc.stdout if proc.returncode == 0 else ""
def commit_paths(repo, sha):
"""Changed paths from the local clone, empty when it doesn't have the SHA."""
path = checkout(repo)
if not path:
return []
out = git(path, "show", "--name-only", "--format=", "--no-renames", sha)
return [line for line in out.splitlines() if line.strip()]