repo-manager/src/repo_manager/observe.py
tegwick 5dc2f3cdf6 feat(cache): add rebuild and provenance safeguards
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-21 23:23:03 +02:00

158 lines
5.7 KiB
Python

"""Build repository observation + work-record index from files."""
from __future__ import annotations
import re
from pathlib import Path
import yaml
from repo_manager.cache import source_fingerprint
from repo_manager.classification import require_valid_classification
from repo_manager.gitops import head_sha, is_git_repo
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
from repo_manager.parse.record import iter_record_files, parse_record_file
from repo_manager.parse.register import iter_register_files, parse_register_file
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
def _slug_from_path(repo_root: Path) -> str:
return re.sub(r"[^a-z0-9]+", "-", repo_root.name.lower()).strip("-") or "repo"
def load_classification(repo_root: Path) -> dict | None:
path = repo_root / ".repo-classification.yaml"
if not path.is_file():
return None
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if isinstance(data, dict) and "repo_classification" in data:
classification = data["repo_classification"]
else:
classification = data if isinstance(data, dict) else None
if not isinstance(classification, dict):
return None
return require_valid_classification(classification)
def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dict, RepoIndex]:
"""Return (RepositorySnapshot-like dict, rebuilt RepoIndex)."""
repo_root = repo_root.resolve()
slug = slug or _slug_from_path(repo_root)
classification = load_classification(repo_root)
intent_path = None
description = None
for name in ("INTENT.md", "GOAL.md"):
p = repo_root / name
if p.is_file():
intent_path = name
# first non-empty line of body as weak description
text = p.read_text(encoding="utf-8")
for line in text.splitlines():
line = line.strip()
if line and not line.startswith("#") and not line.startswith("---") and not line.startswith(">"):
description = line[:200]
break
break
records: list[WorkRecordEntry] = []
for path in iter_workplan_files(repo_root):
wp = parse_workplan_file(path, repo_root=repo_root)
records.append(
WorkRecordEntry(
kind="workplan",
id=wp.id,
status=wp.status,
title=wp.title,
source_path=wp.path,
uuid=wp.state_hub_workstream_id,
extra={
key: wp.frontmatter[key]
for key in ("depends_on", "related", "needs_human", "intervention_note")
if key in wp.frontmatter
},
)
)
for task in wp.tasks:
records.append(
WorkRecordEntry(
kind="task",
id=task.id,
status=task.status,
title=task.title,
source_path=wp.path,
uuid=task.state_hub_task_id,
parent_id=wp.id,
extra={
key: task.raw[key]
for key in ("depends_on", "needs_human", "intervention_note", "blocking_reason")
if key in task.raw
},
)
)
for path in iter_record_files(repo_root):
for record in parse_record_file(path, repo_root=repo_root):
records.append(
WorkRecordEntry(
kind=record.kind,
id=record.id,
status=record.status,
title=record.title,
source_path=record.source_path,
uuid=record.uuid,
extra={"record": record.raw},
)
)
for path in iter_register_files(repo_root):
for entry in parse_register_file(path, repo_root=repo_root):
records.append(
WorkRecordEntry(
kind=f"register:{entry.kind}",
id=entry.id,
status=entry.status,
title=entry.title,
source_path=entry.source_path,
extra={"register_kind": entry.kind, "entry": entry.raw},
)
)
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
fingerprint, source_files = source_fingerprint(repo_root)
index = RepoIndex(
slug=slug,
repo_root=str(repo_root),
head_sha=sha,
observed_at=_now(),
source_fingerprint=fingerprint,
source_files=source_files,
work_records=records,
)
snapshot = {
"api_version": "0.1",
"slug": slug,
"lifecycle": "active",
"domain": (classification or {}).get("domain"),
"classification": classification,
"purpose": {
"description": description,
"intent_path": intent_path,
},
"locations": {
"repo_root": str(repo_root),
},
"revision": {
"head_sha": sha,
"observed_at": index.observed_at,
},
"index": {
"workplan_count": sum(1 for r in records if r.kind == "workplan"),
"task_count": sum(1 for r in records if r.kind == "task"),
"register_entry_count": sum(1 for r in records if r.kind.startswith("register:")),
"intake_count": sum(1 for r in records if r.kind == "intake"),
"decision_count": sum(1 for r in records if r.kind == "decision"),
"record_count": len(records),
},
}
return snapshot, index