feat: advance repository records and provenance
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
329af60753
commit
35e86d7b85
24 changed files with 1618 additions and 51 deletions
114
src/repo_manager/classification.py
Normal file
114
src/repo_manager/classification.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Versioned implementation of the Custodian repository-classification contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
CONTRACT_VERSION = "1.0"
|
||||
CATEGORIES = frozenset({"experimental", "research", "project", "tooling", "product", "business"})
|
||||
DOMAINS = frozenset(
|
||||
{
|
||||
"infotech",
|
||||
"financials",
|
||||
"communication",
|
||||
"consumer",
|
||||
"health",
|
||||
"industrials",
|
||||
"energy",
|
||||
"utilities",
|
||||
"materials",
|
||||
"realestate",
|
||||
"crypto",
|
||||
"agents",
|
||||
"space",
|
||||
"government",
|
||||
}
|
||||
)
|
||||
BUSINESS_STAKE = frozenset(
|
||||
{
|
||||
"execution",
|
||||
"intelligence",
|
||||
"finance",
|
||||
"legal",
|
||||
"sales",
|
||||
"experience",
|
||||
"technology",
|
||||
"operations",
|
||||
"product",
|
||||
"people",
|
||||
"procurement",
|
||||
"sustainability",
|
||||
"automation",
|
||||
}
|
||||
)
|
||||
BUSINESS_MECHANICS = frozenset({"intention", "control", "coordination", "operation", "adaptation"})
|
||||
_TAG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassificationIssue:
|
||||
field: str
|
||||
message: str
|
||||
|
||||
|
||||
class ClassificationError(ValueError):
|
||||
def __init__(self, issues: list[ClassificationIssue]):
|
||||
self.issues = issues
|
||||
super().__init__("; ".join(f"{issue.field}: {issue.message}" for issue in issues))
|
||||
|
||||
|
||||
def _list(data: dict[str, Any], field: str, issues: list[ClassificationIssue]) -> list[str]:
|
||||
value = data.get(field, [])
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
||||
issues.append(ClassificationIssue(field, "must be a list of strings"))
|
||||
return []
|
||||
if len(value) != len(set(value)):
|
||||
issues.append(ClassificationIssue(field, "must not contain duplicates"))
|
||||
return value
|
||||
|
||||
|
||||
def validate_classification(data: dict[str, Any]) -> list[ClassificationIssue]:
|
||||
"""Validate required fields and controlled vocabularies from canon v1.0."""
|
||||
issues: list[ClassificationIssue] = []
|
||||
category = data.get("category")
|
||||
domain = data.get("domain")
|
||||
if category not in CATEGORIES:
|
||||
issues.append(ClassificationIssue("category", f"must be one of {', '.join(sorted(CATEGORIES))}"))
|
||||
if domain not in DOMAINS:
|
||||
issues.append(ClassificationIssue("domain", f"must be one of {', '.join(sorted(DOMAINS))}"))
|
||||
|
||||
secondary = _list(data, "secondary_domains", issues)
|
||||
unknown_domains = sorted(set(secondary) - DOMAINS)
|
||||
if unknown_domains:
|
||||
issues.append(ClassificationIssue("secondary_domains", f"unknown values: {', '.join(unknown_domains)}"))
|
||||
if domain in secondary:
|
||||
issues.append(ClassificationIssue("secondary_domains", "must not repeat the primary domain"))
|
||||
|
||||
tags = _list(data, "capability_tags", issues)
|
||||
invalid_tags = sorted(tag for tag in tags if not _TAG_RE.fullmatch(tag))
|
||||
if invalid_tags:
|
||||
issues.append(ClassificationIssue("capability_tags", f"not lowercase kebab-case: {', '.join(invalid_tags)}"))
|
||||
|
||||
stake = _list(data, "business_stake", issues)
|
||||
unknown_stake = sorted(set(stake) - BUSINESS_STAKE)
|
||||
if unknown_stake:
|
||||
issues.append(ClassificationIssue("business_stake", f"unknown values: {', '.join(unknown_stake)}"))
|
||||
|
||||
mechanics = _list(data, "business_mechanics", issues)
|
||||
unknown_mechanics = sorted(set(mechanics) - BUSINESS_MECHANICS)
|
||||
if unknown_mechanics:
|
||||
issues.append(
|
||||
ClassificationIssue("business_mechanics", f"unknown values: {', '.join(unknown_mechanics)}")
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def require_valid_classification(data: dict[str, Any]) -> dict[str, Any]:
|
||||
issues = validate_classification(data)
|
||||
if issues:
|
||||
raise ClassificationError(issues)
|
||||
return data
|
||||
|
|
@ -183,6 +183,65 @@ def main(argv: list[str] | None = None) -> int:
|
|||
register_parser.add_argument("--push", action="store_true")
|
||||
register_parser.add_argument("--no-commit", action="store_true")
|
||||
|
||||
p_intake = sub.add_parser("intake", help="Repository-owned intake records")
|
||||
intake_sub = p_intake.add_subparsers(dest="record_command")
|
||||
p_intake_create = intake_sub.add_parser("create", help="Create an intake")
|
||||
p_intake_create.add_argument("--path", default=".")
|
||||
p_intake_create.add_argument("--record-id", required=True)
|
||||
p_intake_create.add_argument("--title", required=True)
|
||||
p_intake_create.add_argument("--status", default="open")
|
||||
p_intake_create.add_argument("--data-json", type=_json_object, default=None)
|
||||
p_intake_route = intake_sub.add_parser("route", help="Route an intake")
|
||||
p_intake_route.add_argument("--path", default=".")
|
||||
p_intake_route.add_argument("--record-id", required=True)
|
||||
p_intake_route.add_argument("--route-to", required=True)
|
||||
p_intake_note = intake_sub.add_parser("note", help="Append an intake note")
|
||||
p_intake_note.add_argument("--path", default=".")
|
||||
p_intake_note.add_argument("--record-id", required=True)
|
||||
p_intake_note.add_argument("--note", required=True)
|
||||
p_intake_note.add_argument("--author", default="codex")
|
||||
p_intake_close = intake_sub.add_parser("close", help="Close an intake")
|
||||
p_intake_close.add_argument("--path", default=".")
|
||||
p_intake_close.add_argument("--record-id", required=True)
|
||||
p_intake_close.add_argument("--outcome", default=None)
|
||||
|
||||
p_decision = sub.add_parser("decision", help="Repository-owned decision records")
|
||||
decision_sub = p_decision.add_subparsers(dest="record_command")
|
||||
p_decision_create = decision_sub.add_parser("create", help="Create a decision")
|
||||
p_decision_create.add_argument("--path", default=".")
|
||||
p_decision_create.add_argument("--record-id", required=True)
|
||||
p_decision_create.add_argument("--title", required=True)
|
||||
p_decision_create.add_argument("--status", default="open")
|
||||
p_decision_create.add_argument("--data-json", type=_json_object, default=None)
|
||||
p_decision_update = decision_sub.add_parser("update", help="Update a decision")
|
||||
p_decision_update.add_argument("--path", default=".")
|
||||
p_decision_update.add_argument("--record-id", required=True)
|
||||
p_decision_update.add_argument("--title", default=None)
|
||||
p_decision_update.add_argument("--status", default=None)
|
||||
p_decision_update.add_argument("--data-json", type=_json_object, default=None)
|
||||
p_decision_resolve = decision_sub.add_parser("resolve", help="Resolve a decision")
|
||||
p_decision_resolve.add_argument("--path", default=".")
|
||||
p_decision_resolve.add_argument("--record-id", required=True)
|
||||
p_decision_resolve.add_argument("--rationale", required=True)
|
||||
p_decision_resolve.add_argument("--decided-by", required=True)
|
||||
|
||||
for record_parser in (
|
||||
p_intake_create,
|
||||
p_intake_route,
|
||||
p_intake_note,
|
||||
p_intake_close,
|
||||
p_decision_create,
|
||||
p_decision_update,
|
||||
p_decision_resolve,
|
||||
):
|
||||
record_parser.add_argument("--reason", default="rmgr CLI")
|
||||
record_parser.add_argument("--correlation-id", default=None)
|
||||
record_parser.add_argument("--idempotency-key", default=None)
|
||||
record_parser.add_argument("--expected-head-sha", default=None)
|
||||
record_parser.add_argument("--slug", default=None)
|
||||
record_parser.add_argument("--push", action="store_true")
|
||||
record_parser.add_argument("--no-commit", action="store_true")
|
||||
|
||||
add_rapp_parser(sub)
|
||||
|
||||
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
|
||||
|
|
@ -205,6 +264,29 @@ def main(argv: list[str] | None = None) -> int:
|
|||
p_scaf.add_argument("--force", action="store_true")
|
||||
p_scaf.add_argument("--no-commit", action="store_true")
|
||||
|
||||
p_provenance = sub.add_parser(
|
||||
"assistant-provenance",
|
||||
help="Install or report coding-assistant commit provenance",
|
||||
)
|
||||
provenance_sub = p_provenance.add_subparsers(dest="provenance_command")
|
||||
p_prov_install = provenance_sub.add_parser("install", help="Set global core.hooksPath")
|
||||
p_prov_install.add_argument(
|
||||
"--hooks-path",
|
||||
default=str(Path(__file__).resolve().parents[2] / ".githooks"),
|
||||
)
|
||||
p_prov_report = provenance_sub.add_parser("report", help="Report trailers from Git history")
|
||||
p_prov_report.add_argument("--path", default=".")
|
||||
p_prov_report.add_argument("--rev", default="HEAD")
|
||||
p_prov_report.add_argument("--max-count", type=int, default=None)
|
||||
|
||||
p_identifier = sub.add_parser("identifier", help="Deterministic work-record identifiers")
|
||||
identifier_sub = p_identifier.add_subparsers(dest="identifier_command")
|
||||
p_id_derive = identifier_sub.add_parser("derive", help="Derive one UUIDv5")
|
||||
p_id_derive.add_argument("--namespace", required=True)
|
||||
p_id_derive.add_argument("--record-id", required=True)
|
||||
p_id_preflight = identifier_sub.add_parser("preflight", help="Scan live identifier collisions")
|
||||
p_id_preflight.add_argument("--root", default=".")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.version or args.command in (None, "version"):
|
||||
|
|
@ -352,6 +434,38 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(json.dumps(result.to_dict(), indent=2))
|
||||
return 0 if result.status == "applied" else 1
|
||||
|
||||
if args.command in {"intake", "decision"}:
|
||||
selected = p_intake if args.command == "intake" else p_decision
|
||||
if not args.record_command:
|
||||
selected.print_help()
|
||||
return 2
|
||||
from repo_manager.commands.record import mutate_record
|
||||
|
||||
result = mutate_record(
|
||||
Path(args.path),
|
||||
args.command,
|
||||
args.record_id,
|
||||
operation=args.record_command,
|
||||
title=getattr(args, "title", None),
|
||||
status=getattr(args, "status", None),
|
||||
data=getattr(args, "data_json", None),
|
||||
route_to=getattr(args, "route_to", None),
|
||||
note=getattr(args, "note", None),
|
||||
author=getattr(args, "author", None),
|
||||
outcome=getattr(args, "outcome", None),
|
||||
rationale=getattr(args, "rationale", None),
|
||||
decided_by=getattr(args, "decided_by", None),
|
||||
correlation_id=args.correlation_id,
|
||||
reason=args.reason,
|
||||
commit=not args.no_commit,
|
||||
push=args.push,
|
||||
expected_head_sha=args.expected_head_sha,
|
||||
idempotency_key=args.idempotency_key,
|
||||
repo_slug=args.slug,
|
||||
)
|
||||
print(json.dumps(result.to_dict(), indent=2))
|
||||
return 0 if result.status == "applied" else 1
|
||||
|
||||
if args.command == "rapp":
|
||||
if args.rapp_command == "init":
|
||||
result = rapp_init(
|
||||
|
|
@ -438,6 +552,42 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(json.dumps(result.to_dict(), indent=2))
|
||||
return 0 if result.status == "applied" else 1
|
||||
|
||||
if args.command == "assistant-provenance":
|
||||
if not args.provenance_command:
|
||||
p_provenance.print_help()
|
||||
return 2
|
||||
from repo_manager.provenance import assistant_report, install_hook
|
||||
|
||||
if args.provenance_command == "install":
|
||||
result = install_hook(Path(args.hooks_path))
|
||||
else:
|
||||
result = assistant_report(Path(args.path), rev=args.rev, max_count=args.max_count)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
if args.command == "identifier":
|
||||
if not args.identifier_command:
|
||||
p_identifier.print_help()
|
||||
return 2
|
||||
from repo_manager.identifiers import derive_work_record_uuid, scan_live_identifier_collisions
|
||||
|
||||
if args.identifier_command == "derive":
|
||||
try:
|
||||
derived = derive_work_record_uuid(args.namespace, args.record_id)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||
return 1
|
||||
result = {
|
||||
"ok": True,
|
||||
"namespace": args.namespace,
|
||||
"record_id": args.record_id,
|
||||
"uuid": str(derived),
|
||||
}
|
||||
else:
|
||||
result = scan_live_identifier_collisions(Path(args.root))
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
|
|
|||
313
src/repo_manager/commands/record.py
Normal file
313
src/repo_manager/commands/record.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Governed mutation commands for repository-owned intake and decision records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.commands.workplan import CommandResult
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROTECTED = frozenset(
|
||||
{
|
||||
"id",
|
||||
"kind",
|
||||
"title",
|
||||
"status",
|
||||
"notes",
|
||||
"created",
|
||||
"updated",
|
||||
"routed_at",
|
||||
"closed_at",
|
||||
"decided_at",
|
||||
}
|
||||
)
|
||||
_OPERATIONS = {
|
||||
"intake": frozenset({"create", "route", "note", "close"}),
|
||||
"decision": frozenset({"create", "update", "resolve"}),
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", **evidence},
|
||||
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||
)
|
||||
|
||||
|
||||
def _command(kind: str, operation: str) -> str:
|
||||
verbs = {
|
||||
("intake", "create"): "repo.work.create_intake",
|
||||
("intake", "route"): "repo.work.route_intake",
|
||||
("intake", "note"): "repo.work.add_intake_note",
|
||||
("intake", "close"): "repo.work.close_intake",
|
||||
("decision", "create"): "repo.work.create_decision",
|
||||
("decision", "update"): "repo.work.update_decision",
|
||||
("decision", "resolve"): "repo.work.resolve_decision",
|
||||
}
|
||||
return verbs.get((kind, operation), f"repo.work.{operation}_{kind}")
|
||||
|
||||
|
||||
def _default_path(repo_root: Path, kind: str) -> Path:
|
||||
return repo_root / f"{kind}s" / f"{kind}s.md"
|
||||
|
||||
|
||||
def _find(repo_root: Path, kind: str, record_id: str) -> list[tuple[Path, Any]]:
|
||||
found: list[tuple[Path, Any]] = []
|
||||
for path in iter_record_files(repo_root):
|
||||
for record in parse_record_file(path, repo_root=repo_root):
|
||||
if record.kind == kind and (record.id == record_id or record.uuid == record_id):
|
||||
found.append((path, record))
|
||||
return found
|
||||
|
||||
|
||||
def _dump_block(record: dict[str, Any]) -> str:
|
||||
return "```yaml\n" + yaml.safe_dump(record, sort_keys=False, allow_unicode=True).rstrip() + "\n```"
|
||||
|
||||
|
||||
def _write_new(path: Path, kind: str, record: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
previous = path.read_text(encoding="utf-8") if path.is_file() else f"# {kind.title()} records\n"
|
||||
text = previous.rstrip() + f"\n\n## {record['id']} — {record['title']}\n\n" + _dump_block(record) + "\n"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _replace(path: Path, record: Any, data: dict[str, Any]) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = text[: record.block_start] + _dump_block(data) + text[record.block_end :]
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def mutate_record(
|
||||
repo_root: Path,
|
||||
kind: str,
|
||||
record_id: str,
|
||||
*,
|
||||
operation: str,
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
route_to: str | None = None,
|
||||
note: str | None = None,
|
||||
author: str | None = None,
|
||||
outcome: str | None = None,
|
||||
rationale: str | None = None,
|
||||
decided_by: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Mutate one intake or decision YAML block in its authoritative Markdown file."""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
command = _command(kind, operation)
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"kind": kind,
|
||||
"record_id": record_id,
|
||||
"operation": operation,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"route_to": route_to,
|
||||
"note": note,
|
||||
"author": author,
|
||||
"outcome": outcome,
|
||||
"rationale": rationale,
|
||||
"decided_by": decided_by,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
digest = idempotency.payload_hash(payload)
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != digest:
|
||||
return _reject(command, correlation_id, "conflict", "idempotency key reused")
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", command),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
if kind not in _OPERATIONS or operation not in _OPERATIONS[kind]:
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid {kind!r} operation {operation!r}")
|
||||
if not _ID_RE.fullmatch(record_id):
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid record id {record_id!r}")
|
||||
if data is not None and not isinstance(data, dict):
|
||||
return _reject(command, correlation_id, "validation_error", "data must be an object")
|
||||
protected = _PROTECTED.intersection(data or {})
|
||||
if protected:
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"data contains protected fields: {', '.join(sorted(protected))}",
|
||||
)
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and current_head != expected_head_sha:
|
||||
result = _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"precondition_failed",
|
||||
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
head_sha=current_head,
|
||||
)
|
||||
if result.error:
|
||||
result.error["retryable"] = True
|
||||
return result
|
||||
|
||||
matches = _find(repo_root, kind, record_id)
|
||||
if len(matches) > 1:
|
||||
return _reject(command, correlation_id, "conflict", f"record {record_id!r} is duplicated")
|
||||
match = matches[0] if matches else None
|
||||
now = _now()
|
||||
|
||||
if operation == "create":
|
||||
if match:
|
||||
return _reject(command, correlation_id, "conflict", f"record {record_id!r} exists")
|
||||
if not title or not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "create requires title")
|
||||
record_data: dict[str, Any] = {
|
||||
"id": record_id,
|
||||
"kind": kind,
|
||||
"title": title.strip(),
|
||||
"status": status or "open",
|
||||
**(data or {}),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}
|
||||
path = _default_path(repo_root, kind)
|
||||
_write_new(path, kind, record_data)
|
||||
else:
|
||||
if not match:
|
||||
return _reject(command, correlation_id, "not_found", f"record {record_id!r} not found")
|
||||
path, parsed = match
|
||||
record_data = dict(parsed.raw)
|
||||
if kind == "intake" and operation == "route":
|
||||
if not route_to or not route_to.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "route requires route_to")
|
||||
record_data.update(status="routed", routed_to=route_to.strip(), routed_at=now)
|
||||
elif kind == "intake" and operation == "note":
|
||||
if not note or not note.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "note requires content")
|
||||
record_data.setdefault("notes", []).append(
|
||||
{"content": note.strip(), "author": author or "codex", "created": now}
|
||||
)
|
||||
elif kind == "intake" and operation == "close":
|
||||
record_data.update(status="closed", closed_at=now)
|
||||
if outcome:
|
||||
record_data["outcome"] = outcome.strip()
|
||||
elif kind == "decision" and operation == "update":
|
||||
if title is None and status is None and not data:
|
||||
return _reject(command, correlation_id, "validation_error", "update requires a field")
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "title cannot be empty")
|
||||
record_data["title"] = title.strip()
|
||||
if status is not None:
|
||||
if not status.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "status cannot be empty")
|
||||
record_data["status"] = status.strip()
|
||||
record_data.update(data or {})
|
||||
else:
|
||||
if not rationale or not rationale.strip() or not decided_by or not decided_by.strip():
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
"resolve requires rationale and decided_by",
|
||||
)
|
||||
record_data.update(
|
||||
status="resolved",
|
||||
rationale=rationale.strip(),
|
||||
decided_by=decided_by.strip(),
|
||||
decided_at=now,
|
||||
)
|
||||
record_data["updated"] = now
|
||||
_replace(path, parsed, record_data)
|
||||
|
||||
relative_path = str(path.relative_to(repo_root))
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_message: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[relative_path],
|
||||
message=(
|
||||
f"{command} {record_id}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None},
|
||||
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_message = push_ff(repo_root)
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
append_event(
|
||||
index,
|
||||
{
|
||||
"type": "repo.command.applied",
|
||||
"command": command,
|
||||
"operation": operation,
|
||||
"correlation_id": correlation_id,
|
||||
"kind": kind,
|
||||
"id": record_id,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"source": "repo-manager",
|
||||
},
|
||||
)
|
||||
save_index(index)
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind=f"{kind}_{operation}",
|
||||
repo_slug=repo_slug or snapshot.get("slug"),
|
||||
detail={"id": record_id, "git_sha": git_sha, "correlation_id": correlation_id},
|
||||
)
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence.update(push_ok=push_ok, push_message=push_message)
|
||||
result = CommandResult(command=command, status="applied", correlation_id=correlation_id, evidence=evidence)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||
return result
|
||||
|
|
@ -187,4 +187,6 @@ def scaffold_repository(
|
|||
except GitError as exc:
|
||||
return CommandResult("failed", evidence, {"message": str(exc)}, cid)
|
||||
|
||||
return CommandResult("applied" if written else "rejected", evidence, None, cid)
|
||||
if not written:
|
||||
evidence["noop"] = True
|
||||
return CommandResult("applied", evidence, None, cid)
|
||||
|
|
|
|||
68
src/repo_manager/identifiers.py
Normal file
68
src/repo_manager/identifiers.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Deterministic work-record identifiers and live-collision preflight."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||
from repo_manager.prefix_registry import iter_repo_roots
|
||||
|
||||
DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
|
||||
DERIVATION_VERSION = "repo-manager.work-record-uuid.v1"
|
||||
LIVE_WORKPLAN_STATUSES = frozenset({"proposed", "ready", "active", "blocked", "backlog"})
|
||||
_NAMESPACE_RE = re.compile(r"^[a-z0-9][a-z0-9.-]{0,62}$")
|
||||
_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$")
|
||||
|
||||
|
||||
def derivation_name(namespace: str, identifier: str) -> str:
|
||||
"""Return the exact UTF-8 UUIDv5 name input defined by contract v1."""
|
||||
namespace = namespace.strip()
|
||||
identifier = identifier.strip()
|
||||
if not _NAMESPACE_RE.fullmatch(namespace):
|
||||
raise ValueError("namespace must be lowercase DNS-label style")
|
||||
if not _RECORD_ID_RE.fullmatch(identifier):
|
||||
raise ValueError("identifier must be a canonical workplan or task id")
|
||||
return f"{namespace}\n{identifier}"
|
||||
|
||||
|
||||
def derive_work_record_uuid(namespace: str, identifier: str) -> uuid.UUID:
|
||||
return uuid.uuid5(DERIVATION_NAMESPACE_UUID, derivation_name(namespace, identifier))
|
||||
|
||||
|
||||
def scan_live_identifier_collisions(root: Path) -> dict[str, Any]:
|
||||
"""Report live workplan/task identifiers that cannot safely be derived."""
|
||||
by_id: dict[str, list[dict[str, str]]] = defaultdict(list)
|
||||
repos = iter_repo_roots(root)
|
||||
for repo in repos:
|
||||
for path in iter_workplan_files(repo):
|
||||
parsed = parse_workplan_file(path, repo_root=repo)
|
||||
if parsed.status not in LIVE_WORKPLAN_STATUSES:
|
||||
continue
|
||||
if parsed.id:
|
||||
by_id[parsed.id].append(
|
||||
{"repo": repo.name, "path": parsed.path, "kind": "workplan", "status": parsed.status}
|
||||
)
|
||||
for task in parsed.tasks:
|
||||
if task.id and task.status not in {"done", "cancel"}:
|
||||
by_id[task.id].append(
|
||||
{
|
||||
"repo": repo.name,
|
||||
"path": parsed.path,
|
||||
"kind": "task",
|
||||
"status": task.status or "unknown",
|
||||
}
|
||||
)
|
||||
collisions = {identifier: entries for identifier, entries in sorted(by_id.items()) if len(entries) > 1}
|
||||
return {
|
||||
"ok": not collisions,
|
||||
"root": str(root.resolve()),
|
||||
"repos_scanned": [repo.name for repo in repos],
|
||||
"live_identifiers": len(by_id),
|
||||
"collisions": collisions,
|
||||
"safe_to_derive": not collisions,
|
||||
}
|
||||
|
||||
|
|
@ -7,8 +7,10 @@ from pathlib import Path
|
|||
|
||||
import yaml
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -23,8 +25,12 @@ def load_classification(repo_root: Path) -> dict | None:
|
|||
return None
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict) and "repo_classification" in data:
|
||||
return data["repo_classification"]
|
||||
return data if isinstance(data, dict) else None
|
||||
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]:
|
||||
|
|
@ -58,6 +64,11 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
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:
|
||||
|
|
@ -70,6 +81,25 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
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},
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -116,6 +146,8 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
|||
"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),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
78
src/repo_manager/parse/record.py
Normal file
78
src/repo_manager/parse/record.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Parse repository-owned intake and decision records from Markdown YAML fences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
_YAML_FENCE_RE = re.compile(r"```ya?ml\s*\n(.*?)\n```", re.DOTALL | re.IGNORECASE)
|
||||
SUPPORTED_RECORD_KINDS = frozenset({"intake", "decision"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedRecord:
|
||||
kind: str
|
||||
id: str
|
||||
title: str | None
|
||||
status: str | None
|
||||
uuid: str | None
|
||||
source_path: str
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
block_start: int = 0
|
||||
block_end: int = 0
|
||||
|
||||
|
||||
def parse_record_text(text: str, *, relative_path: str) -> list[ParsedRecord]:
|
||||
"""Return intake/decision records embedded in YAML fenced blocks."""
|
||||
records: list[ParsedRecord] = []
|
||||
for match in _YAML_FENCE_RE.finditer(text):
|
||||
try:
|
||||
data = yaml.safe_load(match.group(1)) or {}
|
||||
except yaml.YAMLError:
|
||||
continue
|
||||
if not isinstance(data, dict) or data.get("kind") not in SUPPORTED_RECORD_KINDS:
|
||||
continue
|
||||
record_id = data.get("id")
|
||||
if record_id is None:
|
||||
continue
|
||||
kind = str(data["kind"])
|
||||
uuid_field = "state_hub_intake_id" if kind == "intake" else "state_hub_decision_id"
|
||||
records.append(
|
||||
ParsedRecord(
|
||||
kind=kind,
|
||||
id=str(record_id),
|
||||
title=str(data["title"]) if data.get("title") is not None else None,
|
||||
status=str(data["status"]) if data.get("status") is not None else None,
|
||||
uuid=str(data[uuid_field]) if data.get(uuid_field) is not None else None,
|
||||
source_path=relative_path,
|
||||
raw=data,
|
||||
block_start=match.start(),
|
||||
block_end=match.end(),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def parse_record_file(path: Path, *, repo_root: Path) -> list[ParsedRecord]:
|
||||
return parse_record_text(
|
||||
path.read_text(encoding="utf-8"),
|
||||
relative_path=str(path.relative_to(repo_root)),
|
||||
)
|
||||
|
||||
|
||||
def iter_record_files(repo_root: Path) -> list[Path]:
|
||||
"""Find only the governed record locations; do not interpret arbitrary docs."""
|
||||
files: set[Path] = set()
|
||||
for relative in ("intakes", "decisions", "docs/intakes", "docs/decisions", "workplans"):
|
||||
root = repo_root / relative
|
||||
if root.is_dir():
|
||||
files.update(path for path in root.rglob("*.md") if not path.name.startswith("."))
|
||||
for name in ("INTAKES.md", "DECISIONS.md"):
|
||||
path = repo_root / name
|
||||
if path.is_file():
|
||||
files.add(path)
|
||||
return sorted(files)
|
||||
135
src/repo_manager/provenance.py
Normal file
135
src/repo_manager/provenance.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Install and report on coding-assistant commit provenance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
_TRAILER_RE = re.compile(r"^(Assistant(?:-Model|-Process|-Session)?):\s*(.+)$", re.MULTILINE)
|
||||
|
||||
|
||||
def install_hook(hooks_path: Path) -> dict[str, Any]:
|
||||
"""Configure the user's Git installation to use the governed hook directory."""
|
||||
hooks_path = hooks_path.expanduser().resolve()
|
||||
hook = hooks_path / "prepare-commit-msg"
|
||||
if not hook.is_file():
|
||||
return {"ok": False, "error": f"missing hook: {hook}"}
|
||||
if not hook.stat().st_mode & 0o111:
|
||||
return {"ok": False, "error": f"hook is not executable: {hook}"}
|
||||
completed = subprocess.run(
|
||||
["git", "config", "--global", "core.hooksPath", str(hooks_path)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode:
|
||||
return {"ok": False, "error": completed.stderr.strip() or "git config failed"}
|
||||
return {"ok": True, "hooks_path": str(hooks_path), "hook": str(hook)}
|
||||
|
||||
|
||||
def _cutover(repo_root: Path) -> str | None:
|
||||
path = repo_root / "config" / "assistant-provenance.yaml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
value = data.get("cutover_commit") if isinstance(data, dict) else None
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _commits(repo_root: Path, rev: str, max_count: int | None) -> list[dict[str, Any]]:
|
||||
command = ["git", "log", "--reverse", "--format=%H%x1f%aI%x1f%an%x1f%ae%x1f%B%x1e"]
|
||||
if max_count:
|
||||
command.append(f"--max-count={max_count}")
|
||||
command.append(rev)
|
||||
completed = subprocess.run(command, cwd=repo_root, check=True, capture_output=True, text=True)
|
||||
commits: list[dict[str, Any]] = []
|
||||
for raw in completed.stdout.split("\x1e"):
|
||||
raw = raw.strip("\n")
|
||||
if not raw:
|
||||
continue
|
||||
parts = raw.split("\x1f", 4)
|
||||
if len(parts) != 5:
|
||||
continue
|
||||
sha, authored_at, author, email, body = parts
|
||||
trailers = {key: value.strip() for key, value in _TRAILER_RE.findall(body)}
|
||||
commits.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"authored_at": authored_at,
|
||||
"author": author,
|
||||
"email": email,
|
||||
"assistant": trailers.get("Assistant"),
|
||||
"model": trailers.get("Assistant-Model"),
|
||||
"process": trailers.get("Assistant-Process"),
|
||||
"session": trailers.get("Assistant-Session"),
|
||||
}
|
||||
)
|
||||
return commits
|
||||
|
||||
|
||||
def assistant_report(repo_root: Path, *, rev: str = "HEAD", max_count: int | None = None) -> dict[str, Any]:
|
||||
"""Derive assistant activity and interleaved-session signals from Git alone."""
|
||||
repo_root = repo_root.resolve()
|
||||
commits = _commits(repo_root, rev, max_count)
|
||||
cutover = _cutover(repo_root)
|
||||
cutover_seen = False
|
||||
assistants: dict[str, dict[str, Any]] = defaultdict(
|
||||
lambda: {"commit_count": 0, "models": set(), "sessions": set(), "processes": set()}
|
||||
)
|
||||
unattributed = {"before_cutover": 0, "after_cutover": 0, "known_automation": 0}
|
||||
session_sequence: list[str] = []
|
||||
|
||||
for commit in commits:
|
||||
if cutover and commit["sha"].startswith(cutover):
|
||||
cutover_seen = True
|
||||
assistant = commit["assistant"]
|
||||
if assistant:
|
||||
entry = assistants[assistant]
|
||||
entry["commit_count"] += 1
|
||||
for field, bucket in (("model", "models"), ("session", "sessions"), ("process", "processes")):
|
||||
if commit[field]:
|
||||
entry[bucket].add(commit[field])
|
||||
if commit["session"]:
|
||||
session_sequence.append(commit["session"])
|
||||
elif commit["author"] == "custodian-sync" or commit["email"] == "custodian-sync@railiance.local":
|
||||
unattributed["known_automation"] += 1
|
||||
elif cutover_seen:
|
||||
unattributed["after_cutover"] += 1
|
||||
else:
|
||||
unattributed["before_cutover"] += 1
|
||||
|
||||
interleaved: set[tuple[str, str]] = set()
|
||||
positions: dict[str, list[int]] = defaultdict(list)
|
||||
for position, session in enumerate(session_sequence):
|
||||
positions[session].append(position)
|
||||
sessions = sorted(positions)
|
||||
for index, first in enumerate(sessions):
|
||||
for second in sessions[index + 1 :]:
|
||||
merged = [session_sequence[pos] for pos in sorted(positions[first] + positions[second])]
|
||||
compressed = [value for pos, value in enumerate(merged) if pos == 0 or value != merged[pos - 1]]
|
||||
if len(compressed) >= 3:
|
||||
interleaved.add((first, second))
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"repo": str(repo_root),
|
||||
"revision": rev,
|
||||
"cutover_commit": cutover,
|
||||
"commit_count": len(commits),
|
||||
"assistants": {
|
||||
name: {
|
||||
"commit_count": item["commit_count"],
|
||||
"models": sorted(item["models"]),
|
||||
"sessions": sorted(item["sessions"]),
|
||||
"processes": sorted(item["processes"]),
|
||||
}
|
||||
for name, item in sorted(assistants.items())
|
||||
},
|
||||
"interleaved_sessions": [list(pair) for pair in sorted(interleaved)],
|
||||
"unattributed": unattributed,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue