feat(RMGR-WP-0001): complete T05 end-to-end vertical slice

Implement observe/reconcile/update-task-status CLI path: workplan parse,
JSON projection index, git-backed task status writeback with correlation
events, and E2E pytest plus evidence artifacts. Finish foundation workplan.
This commit is contained in:
tegwick 2026-08-09 22:49:31 +02:00
parent 3cc67a9bd0
commit 8b5634ff2a
15 changed files with 873 additions and 20 deletions

View file

@ -1,9 +1,11 @@
"""CLI entry point ``rmgr`` (skeleton — commands land with extract phases)."""
"""CLI entry point ``rmgr``."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main(argv: list[str] | None = None) -> int:
@ -11,37 +13,94 @@ def main(argv: list[str] | None = None) -> int:
prog="rmgr",
description="Repo Manager CLI (helixforge.repo-manager)",
)
parser.add_argument(
"--version",
action="store_true",
help="Print package version and exit",
)
parser.add_argument("--version", action="store_true", help="Print package version")
sub = parser.add_subparsers(dest="command")
sub.add_parser("version", help="Print version")
# Placeholders — implemented as extraction phases land (RMGR-WP-0001-T05+).
p_rec = sub.add_parser(
"reconcile",
help="Run consistency reconcile (not yet implemented)",
)
p_obs = sub.add_parser("observe", help="Observe repository + print snapshot JSON")
p_obs.add_argument("--path", default=".", help="Repository checkout path")
p_obs.add_argument("--slug", default=None, help="Override repo slug")
p_rec = sub.add_parser("reconcile", help="Rebuild local work-record index from files")
p_rec.add_argument("--path", default=".", help="Repository checkout path")
p_rec.add_argument("--fix", action="store_true", help="Apply safe fixes")
p_rec.add_argument("--slug", default=None)
p_rec.add_argument(
"--write-index",
action="store_true",
default=True,
help="Write .repo-manager/index.json (default true)",
)
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
p_cmd = sub.add_parser(
"update-task-status",
help="Command repo.work.update_task_status (file + git commit)",
)
p_cmd.add_argument("--path", default=".", help="Repository checkout path")
p_cmd.add_argument("--task-id", required=True, help="Canonical task id e.g. RMGR-WP-0001-T05")
p_cmd.add_argument(
"--status",
required=True,
choices=["wait", "todo", "progress", "done", "cancel"],
)
p_cmd.add_argument("--reason", default="rmgr CLI")
p_cmd.add_argument("--correlation-id", default=None)
p_cmd.add_argument(
"--no-commit",
action="store_true",
help="Patch file only (invalid as full applied evidence; for tests)",
)
args = parser.parse_args(argv)
if args.version or args.command in (None, "version"):
from repo_manager import __version__
print(__version__)
return 0 if args.command or args.version else 0
if args.command == "observe":
from repo_manager.observe import observe_repository
snap, _idx = observe_repository(Path(args.path), slug=args.slug)
print(json.dumps(snap, indent=2))
return 0
if args.command == "reconcile":
print(
"rmgr reconcile: not implemented yet "
"(see docs/adr-001-implementation-foundation.md, phase P0)",
file=sys.stderr,
from repo_manager.index_store import append_event, save_index
from repo_manager.observe import observe_repository
root = Path(args.path)
snap, index = observe_repository(root, slug=args.slug)
append_event(
index,
{
"type": "repo.reconciled",
"workplan_count": snap["index"]["workplan_count"],
"task_count": snap["index"]["task_count"],
},
)
return 2
if not args.no_write_index:
path = save_index(index)
print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2))
else:
print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2))
return 0
if args.command == "update-task-status":
from repo_manager.commands.task_status import update_task_status
result = update_task_status(
Path(args.path),
args.task_id,
args.status,
correlation_id=args.correlation_id,
reason=args.reason,
commit=not args.no_commit,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1
parser.print_help()
return 0

View file

@ -0,0 +1,3 @@
from repo_manager.commands.task_status import update_task_status
__all__ = ["update_task_status"]

View file

@ -0,0 +1,212 @@
"""Governed command: repo.work.update_task_status (vertical-slice implementation)."""
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from repo_manager.gitops import GitError, commit_paths, head_sha
from repo_manager.index_store import append_event, default_index_path, load_index, save_index
from repo_manager.observe import observe_repository
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"})
@dataclass
class CommandResult:
status: str # applied | rejected | failed
evidence: dict[str, Any]
error: dict[str, Any] | None = None
correlation_id: str = ""
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"command": "repo.work.update_task_status",
"status": self.status,
"correlation_id": self.correlation_id,
"evidence": self.evidence,
}
if self.error:
out["error"] = self.error
return out
def _patch_task_status_in_file(path: Path, task_canonical_id: str, status: str) -> bool:
text = path.read_text(encoding="utf-8")
def _replace(match: re.Match[str]) -> str:
block = match.group(0)
meta = _parse_yaml_block(match.group(1))
tid = meta.get("id")
if tid is None or str(tid) != task_canonical_id:
return block
replaced = re.sub(
r"^(status:\s*)\S+",
rf"\g<1>{status}",
block,
count=1,
flags=re.MULTILINE,
)
if replaced != block:
return replaced
# insert status if missing
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
new_text = _TASK_BLOCK_RE.sub(_replace, text)
if new_text == text:
return False
path.write_text(new_text, encoding="utf-8")
return True
def update_task_status(
repo_root: Path,
task_id: str,
status: str,
*,
correlation_id: str | None = None,
reason: str = "rmgr command",
commit: bool = True,
) -> CommandResult:
"""Apply task status change to the workplan file and optionally git-commit."""
correlation_id = correlation_id or str(uuid.uuid4())
repo_root = repo_root.resolve()
if status not in VALID_TASK_STATUSES:
return CommandResult(
status="rejected",
correlation_id=correlation_id,
evidence={"status": "rejected"},
error={
"code": "validation_error",
"message": f"invalid task status {status!r}",
"correlation_id": correlation_id,
},
)
# Locate task via fresh observation
_snapshot, index = observe_repository(repo_root)
match = next(
(r for r in index.work_records if r.kind == "task" and r.id == task_id),
None,
)
if match is None:
return CommandResult(
status="rejected",
correlation_id=correlation_id,
evidence={"status": "rejected"},
error={
"code": "not_found",
"message": f"task {task_id!r} not found in workplans",
"correlation_id": correlation_id,
},
)
path = repo_root / match.source_path
if not path.is_file():
return CommandResult(
status="failed",
correlation_id=correlation_id,
evidence={"status": "failed"},
error={
"code": "not_found",
"message": f"source file missing: {match.source_path}",
"correlation_id": correlation_id,
},
)
expected_head = head_sha(repo_root)
changed = _patch_task_status_in_file(path, task_id, status)
if not changed:
return CommandResult(
status="rejected",
correlation_id=correlation_id,
evidence={"status": "rejected", "files_touched": []},
error={
"code": "conflict",
"message": "task block not patched (already at status or id mismatch)",
"correlation_id": correlation_id,
},
)
git_sha: str | None = None
if commit:
try:
git_sha = commit_paths(
repo_root,
[match.source_path],
message=(
f"repo.work.update_task_status {task_id} -> {status}\n\n"
f"correlation_id: {correlation_id}\nreason: {reason}\n"
),
)
except GitError as exc:
return CommandResult(
status="failed",
correlation_id=correlation_id,
evidence={
"status": "failed",
"files_touched": [match.source_path],
"git_sha": None,
},
error={
"code": "internal",
"message": str(exc),
"correlation_id": correlation_id,
},
)
# Rebuild projection
_snap2, index2 = observe_repository(repo_root)
event = {
"type": "repo.command.applied",
"command": "repo.work.update_task_status",
"correlation_id": correlation_id,
"task_id": task_id,
"new_status": status,
"git_sha": git_sha,
"files_touched": [match.source_path],
"expected_head_sha_before": expected_head,
}
append_event(index2, event)
append_event(
index2,
{
"type": "repo.work.indexed",
"correlation_id": correlation_id,
"kind": "task",
"id": task_id,
"source_path": match.source_path,
},
)
save_index(index2)
evidence = {
"status": "applied",
"git_sha": git_sha,
"files_touched": [match.source_path],
"observed_at": index2.observed_at,
"index_path": str(default_index_path(repo_root)),
}
if not git_sha:
# contract: file-mutating applied without git_sha is invalid — force fail if no commit
return CommandResult(
status="failed",
correlation_id=correlation_id,
evidence=evidence,
error={
"code": "internal",
"message": "applied file mutation without git_sha",
"correlation_id": correlation_id,
},
)
return CommandResult(
status="applied",
correlation_id=correlation_id,
evidence=evidence,
)

View file

@ -0,0 +1,66 @@
"""Git helpers via subprocess (ADR-001)."""
from __future__ import annotations
import subprocess
from pathlib import Path
class GitError(RuntimeError):
pass
def _run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
if check and proc.returncode != 0:
raise GitError(proc.stderr.strip() or proc.stdout.strip() or f"git {' '.join(args)} failed")
return proc
def head_sha(repo: Path) -> str | None:
proc = _run(repo, "rev-parse", "HEAD", check=False)
if proc.returncode != 0:
return None
return proc.stdout.strip() or None
def is_git_repo(repo: Path) -> bool:
return (repo / ".git").exists() or _run(repo, "rev-parse", "--git-dir", check=False).returncode == 0
def commit_paths(
repo: Path,
paths: list[str],
message: str,
*,
author_name: str = "repo-manager",
author_email: str = "repo-manager@local",
) -> str:
"""Stage paths and commit. Returns new HEAD sha."""
env_author = [
"-c",
f"user.name={author_name}",
"-c",
f"user.email={author_email}",
]
for path in paths:
_run(repo, "add", "--", path)
proc = subprocess.run(
["git", *env_author, "commit", "-m", message],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
raise GitError(proc.stderr.strip() or proc.stdout.strip() or "git commit failed")
sha = head_sha(repo)
if not sha:
raise GitError("commit succeeded but HEAD missing")
return sha

View file

@ -0,0 +1,83 @@
"""Local JSON projection store for the vertical-slice proof (pre-Postgres)."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class WorkRecordEntry:
kind: str
id: str | None
status: str | None
title: str | None
source_path: str
uuid: str | None = None
parent_id: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class RepoIndex:
slug: str
repo_root: str
head_sha: str | None
observed_at: str
work_records: list[WorkRecordEntry] = field(default_factory=list)
events: list[dict[str, Any]] = field(default_factory=list)
schema: str = "repo_manager.index.v0"
def to_dict(self) -> dict[str, Any]:
return {
"schema": self.schema,
"slug": self.slug,
"repo_root": self.repo_root,
"head_sha": self.head_sha,
"observed_at": self.observed_at,
"work_records": [asdict(r) for r in self.work_records],
"events": self.events,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RepoIndex:
records = [
WorkRecordEntry(**{k: v for k, v in r.items() if k in WorkRecordEntry.__dataclass_fields__})
for r in data.get("work_records", [])
]
return cls(
slug=data["slug"],
repo_root=data["repo_root"],
head_sha=data.get("head_sha"),
observed_at=data.get("observed_at") or _now(),
work_records=records,
events=list(data.get("events") or []),
schema=data.get("schema") or "repo_manager.index.v0",
)
def default_index_path(repo_root: Path) -> Path:
return repo_root / ".repo-manager" / "index.json"
def save_index(index: RepoIndex, path: Path | None = None) -> Path:
path = path or default_index_path(Path(index.repo_root))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(index.to_dict(), indent=2) + "\n", encoding="utf-8")
return path
def load_index(path: Path) -> RepoIndex:
return RepoIndex.from_dict(json.loads(path.read_text(encoding="utf-8")))
def append_event(index: RepoIndex, event: dict[str, Any]) -> None:
event = {**event, "emitted_at": event.get("emitted_at") or _now()}
index.events.append(event)

107
src/repo_manager/observe.py Normal file
View file

@ -0,0 +1,107 @@
"""Build repository observation + work-record index from files."""
from __future__ import annotations
import re
from pathlib import Path
import yaml
from repo_manager.gitops import head_sha, is_git_repo
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
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:
return data["repo_classification"]
return data if isinstance(data, dict) else None
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,
)
)
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,
)
)
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
index = RepoIndex(
slug=slug,
repo_root=str(repo_root),
head_sha=sha,
observed_at=_now(),
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"),
"record_count": len(records),
},
}
return snapshot, index

View file

@ -0,0 +1,3 @@
from repo_manager.parse.workplan import ParsedTask, ParsedWorkplan, parse_workplan_file
__all__ = ["ParsedTask", "ParsedWorkplan", "parse_workplan_file"]

View file

@ -0,0 +1,114 @@
"""Workplan file parser (P0 extract — simplified from State Hub consistency_check)."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
def _parse_yaml_block(raw: str) -> dict[str, Any]:
try:
data = yaml.safe_load(raw) or {}
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
return _parse_yaml_block(parts[1].strip()), parts[2]
@dataclass
class ParsedTask:
id: str | None
title: str | None
status: str | None
state_hub_task_id: str | None
raw: dict[str, Any] = field(default_factory=dict)
@dataclass
class ParsedWorkplan:
path: str
id: str | None
title: str | None
status: str | None
state_hub_workstream_id: str | None
frontmatter: dict[str, Any]
tasks: list[ParsedTask]
def parse_task_blocks(body: str) -> list[ParsedTask]:
headings = [
(m.start(), len(m.group(1)), m.group(2).strip())
for m in _HEADING_RE.finditer(body)
]
results: list[ParsedTask] = []
for m in _TASK_BLOCK_RE.finditer(body):
meta = _parse_yaml_block(m.group(1).strip())
prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()]
title = meta.get("title")
if not title and prev:
title = prev[-1][2]
results.append(
ParsedTask(
id=str(meta["id"]) if meta.get("id") is not None else None,
title=str(title) if title else None,
status=str(meta["status"]) if meta.get("status") is not None else None,
state_hub_task_id=(
str(meta["state_hub_task_id"]).strip().strip('"')
if meta.get("state_hub_task_id") is not None
else None
),
raw=meta,
)
)
return results
def parse_workplan_text(text: str, *, relative_path: str) -> ParsedWorkplan:
fm, body = parse_frontmatter(text)
return ParsedWorkplan(
path=relative_path,
id=str(fm["id"]) if fm.get("id") is not None else None,
title=str(fm["title"]) if fm.get("title") is not None else None,
status=str(fm["status"]) if fm.get("status") is not None else None,
state_hub_workstream_id=(
str(fm["state_hub_workstream_id"]).strip().strip('"')
if fm.get("state_hub_workstream_id") is not None
else None
),
frontmatter=fm,
tasks=parse_task_blocks(body),
)
def parse_workplan_file(path: Path, *, repo_root: Path) -> ParsedWorkplan:
text = path.read_text(encoding="utf-8")
rel = str(path.relative_to(repo_root))
return parse_workplan_text(text, relative_path=rel)
def iter_workplan_files(repo_root: Path) -> list[Path]:
wp_dir = repo_root / "workplans"
if not wp_dir.is_dir():
return []
files: list[Path] = []
for p in sorted(wp_dir.rglob("*.md")):
if p.name.startswith("."):
continue
# skip archived copies optionally still include them
files.append(p)
return files