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

1
.gitignore vendored
View file

@ -11,3 +11,4 @@ build/
.coverage
htmlcov/
uv.lock
.repo-manager/

View file

@ -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 <ID> --status progress
```
Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md).

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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`.

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

View file

@ -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
# 12. 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"
# 34. 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

View file

@ -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.