diff --git a/.gitignore b/.gitignore index 14127e6..9effc31 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build/ .coverage htmlcov/ uv.lock +.repo-manager/ diff --git a/README.md b/README.md index 4b0a302..353d441 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,9 @@ Architecture: make install # or: uv pip install -e ".[dev]" make test rmgr --version +rmgr observe --path . +rmgr reconcile --path . +rmgr update-task-status --path . --task-id --status progress ``` + +Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md). diff --git a/docs/evidence/t05-command-result.json b/docs/evidence/t05-command-result.json new file mode 100644 index 0000000..5f42f69 --- /dev/null +++ b/docs/evidence/t05-command-result.json @@ -0,0 +1,14 @@ +{ + "command": "repo.work.update_task_status", + "status": "applied", + "correlation_id": "3a152f55-acca-41de-a310-d388ddf5a1d1", + "evidence": { + "status": "applied", + "git_sha": "0b71f90b7782255e1e15b0def16d15299ca09e15", + "files_touched": [ + "workplans/DEMO-WP-0001.md" + ], + "observed_at": "2026-08-09T20:49:14.382153+00:00", + "index_path": "/tmp/rmgr-e2e-1946474/.repo-manager/index.json" + } +} diff --git a/docs/evidence/t05-e2e-proof.json b/docs/evidence/t05-e2e-proof.json new file mode 100644 index 0000000..721ad50 --- /dev/null +++ b/docs/evidence/t05-e2e-proof.json @@ -0,0 +1,21 @@ +{ + "task": "RMGR-WP-0001-T05", + "fixture": "live-proof temp repo", + "before_sha": "1e8623d758b7c4599bb0c48bb9450990a1a948a1", + "after_sha": "0b71f90b7782255e1e15b0def16d15299ca09e15", + "command_result": { + "command": "repo.work.update_task_status", + "status": "applied", + "correlation_id": "3a152f55-acca-41de-a310-d388ddf5a1d1", + "evidence": { + "status": "applied", + "git_sha": "0b71f90b7782255e1e15b0def16d15299ca09e15", + "files_touched": [ + "workplans/DEMO-WP-0001.md" + ], + "observed_at": "2026-08-09T20:49:14.382153+00:00", + "index_path": "/tmp/rmgr-e2e-1946474/.repo-manager/index.json" + } + }, + "git_log": "0b71f90b7782255e1e15b0def16d15299ca09e15 repo.work.update_task_status DEMO-WP-0001-T01 -> done" +} diff --git a/docs/evidence/t05-vertical-slice.md b/docs/evidence/t05-vertical-slice.md new file mode 100644 index 0000000..58e58d5 --- /dev/null +++ b/docs/evidence/t05-vertical-slice.md @@ -0,0 +1,41 @@ +# RMGR-WP-0001-T05 — vertical slice proof + +**Date:** 2026-08-09 +**Contract:** `helixforge.repo-manager` 0.1 (subset implemented) + +## Slice steps + +| # | Requirement | Implementation | Proof | +| --- | --- | --- | --- | +| 1 | Represent a repository | `rmgr observe` / `observe_repository` | Snapshot JSON with slug, classification, revision | +| 2 | Index declared work | `rmgr reconcile` → `.repo-manager/index.json` | workplan + task records from `workplans/` | +| 3 | Emit normalized change | events on index: `repo.command.applied`, `repo.work.indexed` | event log with `correlation_id` | +| 4 | File-backed transition | `rmgr update-task-status` | task `status:` patched in workplan file | +| 5 | Git evidence | `gitops.commit_paths` | `evidence.git_sha` required; commit message includes correlation_id | +| 6 | Rebuilt projection | re-observe after command | task status + head_sha match evidence | + +## Automated proof + +```bash +cd ~/repo-manager && pytest tests/test_e2e_vertical_slice.py -q +``` + +## Live CLI proof + +Artifacts: + +- `docs/evidence/t05-e2e-proof.json` +- `docs/evidence/t05-command-result.json` + +## Hub-core note + +Events are written to the local index event log in contract-shaped JSON. HTTP +delivery to hub-core `port.events` is dual-run work (extraction P3 / hub-core +ports), not required to prove Git authority + projection rebuild. + +## Limits of this slice (honest) + +- Projection store is **local JSON**, not Postgres yet (ADR-001 production DB still planned). +- No FastAPI server in this slice; CLI implements the same commands. +- Authz is not enforced (dev path); production requires policy port. +- Single command type implemented: `repo.work.update_task_status`. diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index d2eefb8..66773e2 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -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 diff --git a/src/repo_manager/commands/__init__.py b/src/repo_manager/commands/__init__.py new file mode 100644 index 0000000..e8dd4af --- /dev/null +++ b/src/repo_manager/commands/__init__.py @@ -0,0 +1,3 @@ +from repo_manager.commands.task_status import update_task_status + +__all__ = ["update_task_status"] diff --git a/src/repo_manager/commands/task_status.py b/src/repo_manager/commands/task_status.py new file mode 100644 index 0000000..901c0a2 --- /dev/null +++ b/src/repo_manager/commands/task_status.py @@ -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, + ) diff --git a/src/repo_manager/gitops.py b/src/repo_manager/gitops.py new file mode 100644 index 0000000..cde9c7b --- /dev/null +++ b/src/repo_manager/gitops.py @@ -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 diff --git a/src/repo_manager/index_store.py b/src/repo_manager/index_store.py new file mode 100644 index 0000000..75ef9bb --- /dev/null +++ b/src/repo_manager/index_store.py @@ -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) diff --git a/src/repo_manager/observe.py b/src/repo_manager/observe.py new file mode 100644 index 0000000..4bef0ba --- /dev/null +++ b/src/repo_manager/observe.py @@ -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 diff --git a/src/repo_manager/parse/__init__.py b/src/repo_manager/parse/__init__.py new file mode 100644 index 0000000..2d05601 --- /dev/null +++ b/src/repo_manager/parse/__init__.py @@ -0,0 +1,3 @@ +from repo_manager.parse.workplan import ParsedTask, ParsedWorkplan, parse_workplan_file + +__all__ = ["ParsedTask", "ParsedWorkplan", "parse_workplan_file"] diff --git a/src/repo_manager/parse/workplan.py b/src/repo_manager/parse/workplan.py new file mode 100644 index 0000000..eb1b2aa --- /dev/null +++ b/src/repo_manager/parse/workplan.py @@ -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 diff --git a/tests/test_e2e_vertical_slice.py b/tests/test_e2e_vertical_slice.py new file mode 100644 index 0000000..e59cf3d --- /dev/null +++ b/tests/test_e2e_vertical_slice.py @@ -0,0 +1,117 @@ +"""E2E vertical slice: observe → index → command → git evidence → rebuild.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from repo_manager.commands.task_status import update_task_status +from repo_manager.gitops import head_sha +from repo_manager.index_store import default_index_path, load_index +from repo_manager.observe import observe_repository + + +WORKPLAN = """--- +id: DEMO-WP-0001 +type: workplan +title: "Demo workplan" +status: active +--- + +# Demo workplan + +## First task + +```task +id: DEMO-WP-0001-T01 +status: todo +priority: high +``` + +Do the thing. +""" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _init_fixture(tmp_path: Path) -> Path: + repo = tmp_path / "demo-repo" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / "workplans").mkdir() + (repo / "workplans" / "DEMO-WP-0001-demo.md").write_text(WORKPLAN, encoding="utf-8") + (repo / ".repo-classification.yaml").write_text( + "repo_classification:\n category: experimental\n domain: infotech\n", + encoding="utf-8", + ) + (repo / "INTENT.md").write_text("# INTENT\n\nDemo fixture repository.\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + return repo + + +def test_e2e_vertical_slice(tmp_path: Path): + repo = _init_fixture(tmp_path) + before = head_sha(repo) + assert before + + # 1–2. Observe + index declared work + snap, index = observe_repository(repo, slug="demo-repo") + assert snap["slug"] == "demo-repo" + assert snap["classification"]["category"] == "experimental" + assert snap["index"]["workplan_count"] == 1 + assert snap["index"]["task_count"] == 1 + tasks = [r for r in index.work_records if r.kind == "task"] + assert tasks[0].id == "DEMO-WP-0001-T01" + assert tasks[0].status == "todo" + + # 3–4. Apply governed file-backed transition + git evidence + result = update_task_status( + repo, + "DEMO-WP-0001-T01", + "progress", + correlation_id="00000000-0000-4000-8000-000000000001", + reason="e2e proof", + ) + assert result.status == "applied", result + assert result.evidence.get("git_sha") + assert result.evidence["git_sha"] != before + assert result.evidence["files_touched"] == ["workplans/DEMO-WP-0001-demo.md"] + + # File content updated + text = (repo / "workplans" / "DEMO-WP-0001-demo.md").read_text(encoding="utf-8") + assert "status: progress" in text + + # 5. Rebuilt projection + snap2, index2 = observe_repository(repo, slug="demo-repo") + task2 = next(r for r in index2.work_records if r.id == "DEMO-WP-0001-T01") + assert task2.status == "progress" + assert snap2["revision"]["head_sha"] == result.evidence["git_sha"] + + # Index on disk includes command event (normalized change) + idx_path = default_index_path(repo) + assert idx_path.is_file() + stored = load_index(idx_path) + types = [e.get("type") for e in stored.events] + assert "repo.command.applied" in types + applied = next(e for e in stored.events if e.get("type") == "repo.command.applied") + assert applied["correlation_id"] == "00000000-0000-4000-8000-000000000001" + assert applied["git_sha"] == result.evidence["git_sha"] + + # Proof artifact for humans + proof = { + "slice": "RMGR-WP-0001-T05", + "repo_slug": "demo-repo", + "before_sha": before, + "after_sha": result.evidence["git_sha"], + "command": result.to_dict(), + "snapshot_after": snap2, + } + proof_path = tmp_path / "e2e-proof.json" + proof_path.write_text(json.dumps(proof, indent=2), encoding="utf-8") + assert proof_path.stat().st_size > 0 diff --git a/workplans/RMGR-WP-0001-foundation.md b/workplans/RMGR-WP-0001-foundation.md index c9b7068..90002e2 100644 --- a/workplans/RMGR-WP-0001-foundation.md +++ b/workplans/RMGR-WP-0001-foundation.md @@ -4,7 +4,7 @@ type: workplan title: "Repo Manager architecture and foundation" domain: infotech repo: repo-manager -status: active +status: finished owner: codex topic_slug: repo-manager created: "2026-08-09" @@ -104,7 +104,7 @@ skeleton. Package scaffold + version test landed (no business logic yet). ```task id: RMGR-WP-0001-T05 -status: todo +status: done priority: high state_hub_task_id: "5036a248-1c47-423f-be8d-4fd232896c31" ``` @@ -114,6 +114,13 @@ work, emit a normalized change through hub-core contracts, apply one authorized file-backed transition, and prove the resulting Git evidence and rebuilt projection. +**Result (2026-08-09):** Vertical slice implemented — `rmgr observe|reconcile| +update-task-status`. Parser, JSON projection (`.repo-manager/index.json`), +git-backed writeback with required `evidence.git_sha`, contract-shaped events. +Proof: `pytest tests/test_e2e_vertical_slice.py` + `docs/evidence/t05-*`. +Limits: local JSON index (not Postgres yet); hub-core HTTP emit deferred; +authz not enforced on CLI. + ## Acceptance - [x] The authority and data model are explicit. @@ -121,4 +128,4 @@ projection. - (semantics + catalog done; automated suite with runtime in T04/T05) - [x] State Hub extraction candidates have dispositions. - [x] The implementation foundation has a recorded decision. -- [ ] One repository completes the end-to-end vertical slice. +- [x] One repository completes the end-to-end vertical slice.