diff --git a/README.md b/README.md index 4e0247b..f3b7bb7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Architecture: - [ADR-001 Implementation foundation](docs/adr-001-implementation-foundation.md) - [Railiance app deployment guide](docs/RailianceAppDeploymentGuide.md) (`RMGR-WP-0006`) - [Repository standards v0.1](docs/repository-standards_v0.1.md) (`RMGR-WP-0004`) +- [Repository registers v0.1](docs/repository-registers_v0.1.md) (`RMGR-WP-0008`) ```bash make install # or: uv pip install -e ".[dev]" @@ -34,6 +35,12 @@ rmgr --version rmgr observe --path . rmgr reconcile --path . rmgr update-task-status --path . --task-id --status progress +rmgr workplan create --path . --workplan-id EX-WP-0001 --title "Example" --goal "Deliver X." +rmgr workplan update --path . --workplan-id EX-WP-0001 --status active +rmgr workplan delete --path . --workplan-id EX-WP-0001 --confirm # archives; never erases +rmgr register put --path . --kind technical-debt --entry-id TD-001 \ + --title "Debt item" --data-json '{"severity":"high"}' +rmgr register list --path . --kind technical-debt rmgr rapp wrap --path ../rapp-some-app --app some-app \ --ownership-repo some-app --from-app ../some-app rmgr rapp validate --path ../rapp-user-engine --family-root .. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 482ad24..2477ede 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,8 +15,8 @@ | workplan | RMGR-WP-0005 | active | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md | | workplan | RMGR-WP-0006 | finished | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md | | workplan | RMGR-WP-0007 | finished | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | -| workplan | RMGR-WP-0008 | proposed | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| workplan | RMGR-WP-0009 | proposed | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | +| workplan | RMGR-WP-0008 | active | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| workplan | RMGR-WP-0009 | ready | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | | task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md | | task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md | @@ -65,11 +65,11 @@ | task | RMGR-WP-0007-T05 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | | task | RMGR-WP-0007-T06 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | | task | RMGR-WP-0007-T07 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md | -| task | RMGR-WP-0008-T01 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T01 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T02 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T03 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T04 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | -| task | RMGR-WP-0008-T05 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | +| task | RMGR-WP-0008-T05 | progress | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T06 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0008-T07 | wait | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md | | task | RMGR-WP-0009-T01 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md | diff --git a/docs/observation-command-contracts_v0.1.md b/docs/observation-command-contracts_v0.1.md index 8cab0d5..92b1f72 100644 --- a/docs/observation-command-contracts_v0.1.md +++ b/docs/observation-command-contracts_v0.1.md @@ -262,7 +262,17 @@ command: | --- | --- | --- | --- | | `repo.work.update_task_status` | task_id, status, blocking_reason? | **yes** | `repo:command:work_status` | | `repo.work.update_workplan_status` | workplan_id, status | **yes** | `repo:command:work_status` | +| `repo.work.create_workplan` | workplan_id, title, goal, status?, owner?, domain?, topic_slug? | **yes** | `repo:command:workplan` | +| `repo.work.update_workplan` | workplan_id, title?, status?, owner?, domain?, topic_slug? | **yes** | `repo:command:workplan` | +| `repo.work.archive_workplan` | workplan_id, confirm_archive | **yes** | `repo:command:workplan` | | `repo.work.writeback_ids` | record_ref, uuid fields | **yes** | `repo:command:writeback_ids` | +| `repo.register.upsert_entry` | kind, entry_id, title?, status?, data? | **yes** | `repo:command:register` | +| `repo.register.defer_entry` | kind, entry_id, status? | **yes** | `repo:command:register` | +| `repo.register.add_note` | kind, entry_id, note, author? | **yes** | `repo:command:register` | + +`repo.work.archive_workplan` is the recoverable delete operation: it sets the +canonical status to `archived` and moves the file under the dated +`workplans/archived/` convention. It does not erase repository history. #### Agents diff --git a/docs/observation-command-contracts_v0.1.yaml b/docs/observation-command-contracts_v0.1.yaml index 29e9110..7f07030 100644 --- a/docs/observation-command-contracts_v0.1.yaml +++ b/docs/observation-command-contracts_v0.1.yaml @@ -115,9 +115,33 @@ commands: scope: repo:command:work_status mutates_git: true params: [workplan_id, status] + - type: repo.work.create_workplan + scope: repo:command:workplan + mutates_git: true + params: [workplan_id, title, goal, status, owner, domain, topic_slug] + - type: repo.work.update_workplan + scope: repo:command:workplan + mutates_git: true + params: [workplan_id, title, status, owner, domain, topic_slug] + - type: repo.work.archive_workplan + scope: repo:command:workplan + mutates_git: true + params: [workplan_id, confirm_archive] - type: repo.work.writeback_ids scope: repo:command:writeback_ids mutates_git: true + - type: repo.register.upsert_entry + scope: repo:command:register + mutates_git: true + params: [kind, entry_id, title, status, data] + - type: repo.register.defer_entry + scope: repo:command:register + mutates_git: true + params: [kind, entry_id, status] + - type: repo.register.add_note + scope: repo:command:register + mutates_git: true + params: [kind, entry_id, note, author] - type: repo.agents.assign scope: repo:command:agent_assign mutates_git: preferred diff --git a/docs/repository-registers_v0.1.md b/docs/repository-registers_v0.1.md new file mode 100644 index 0000000..a945ba9 --- /dev/null +++ b/docs/repository-registers_v0.1.md @@ -0,0 +1,56 @@ +# Repository registers v0.1 + +Repository registers are ordinary, versioned YAML files under `registers/`. +They are authoritative in the repository; Repo Manager's index is a replaceable +projection. + +## Shared envelope + +Each register uses one file named `registers/.yaml`: + +```yaml +schema: repo-manager.register.v0 +kind: technical-debt +entries: + - id: TD-001 + title: Coupled persistence + status: open + severity: high + created: "2026-08-21T10:00:00+00:00" + updated: "2026-08-21T10:00:00+00:00" + notes: [] +``` + +Entry ids are stable within their register. `created` is immutable; mutations +advance `updated`. Deletion-compatible routes map to `defer`, preserving the +entry and Git history. Notes are append-only objects with `content`, `author`, +and `created`. + +## Kinds and compatibility fields + +| Kind | Required create data | State Hub compatibility | +| --- | --- | --- | +| `sbom-inventory` | `package_name`, `ecosystem` | package/version/licence entries; snapshots and reports are derived | +| `repo-goals` | `description` | repository goal records | +| `upstream-contributions` | `type` | contribution type and target metadata | +| `technical-debt` | none beyond id/title | severity, debt type, location, notes | +| `extension-points` | none beyond id/title | priority, extension type, location | +| `register-entries` | `register_kind` | generic extension register entries | + +Additional kind-specific fields are retained as YAML scalars, lists, or +mappings. The envelope fields `id`, `title`, `status`, `notes`, `created`, and +`updated` may only be changed through their governed command parameters. + +## Commands + +```bash +rmgr register put --kind technical-debt --entry-id TD-001 \ + --title "Coupled persistence" --data-json '{"severity":"high"}' +rmgr register list --kind technical-debt +rmgr register note --kind technical-debt --entry-id TD-001 --note "Owner assigned." +rmgr register defer --kind technical-debt --entry-id TD-001 +``` + +Mutations use the same expected-HEAD, idempotency, Git commit, optional +push-seal, normalized-event, reindex, and dual-run-meter mechanism as workplan +and task mutations. diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 0e3f6cb..ba56fdc 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -15,6 +15,16 @@ from repo_manager.commands.rapp import validate as rapp_validate from repo_manager.commands.rapp import wrap as rapp_wrap +def _json_object(raw: str) -> dict: + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise argparse.ArgumentTypeError(str(exc)) from exc + if not isinstance(value, dict): + raise argparse.ArgumentTypeError("value must be a JSON object") + return value + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="rmgr", @@ -62,6 +72,98 @@ def main(argv: list[str] | None = None) -> int: help="Patch file only (invalid as full applied evidence; for tests)", ) + p_wp = sub.add_parser("workplan", help="Governed file-backed workplan mutations") + wp_sub = p_wp.add_subparsers(dest="workplan_command") + p_wp_create = wp_sub.add_parser("create", help="Create a workplan file") + p_wp_create.add_argument("--path", default=".") + p_wp_create.add_argument("--workplan-id", required=True) + p_wp_create.add_argument("--title", required=True) + p_wp_create.add_argument("--goal", required=True) + p_wp_create.add_argument( + "--status", + choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"], + default="proposed", + ) + p_wp_create.add_argument("--owner", default="codex") + p_wp_create.add_argument("--domain", default=None) + p_wp_create.add_argument("--topic-slug", default=None) + p_wp_create.add_argument("--filename", default=None) + + p_wp_update = wp_sub.add_parser("update", help="Update workplan metadata or status") + p_wp_update.add_argument("--path", default=".") + p_wp_update.add_argument("--workplan-id", required=True) + p_wp_update.add_argument("--title", default=None) + p_wp_update.add_argument( + "--status", + choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"], + default=None, + ) + p_wp_update.add_argument("--owner", default=None) + p_wp_update.add_argument("--domain", default=None) + p_wp_update.add_argument("--topic-slug", default=None) + + p_wp_archive = wp_sub.add_parser( + "delete", + help="Recoverable delete: set archived and move to workplans/archived", + ) + p_wp_archive.add_argument("--path", default=".") + p_wp_archive.add_argument("--workplan-id", required=True) + p_wp_archive.add_argument( + "--confirm", + action="store_true", + help="Confirm moving the workplan to the dated archive", + ) + + for wp_parser in (p_wp_create, p_wp_update, p_wp_archive): + wp_parser.add_argument("--reason", default="rmgr CLI") + wp_parser.add_argument("--correlation-id", default=None) + wp_parser.add_argument("--idempotency-key", default=None) + wp_parser.add_argument("--expected-head-sha", default=None) + wp_parser.add_argument("--slug", default=None) + wp_parser.add_argument("--push", action="store_true") + wp_parser.add_argument("--no-commit", action="store_true") + + register_kinds = [ + "sbom-inventory", + "repo-goals", + "upstream-contributions", + "technical-debt", + "extension-points", + "register-entries", + ] + p_register = sub.add_parser("register", help="Repository-owned register spine") + register_sub = p_register.add_subparsers(dest="register_command") + p_reg_list = register_sub.add_parser("list", help="List indexed register entries") + p_reg_list.add_argument("--path", default=".") + p_reg_list.add_argument("--kind", choices=register_kinds, default=None) + p_reg_list.add_argument("--slug", default=None) + p_reg_put = register_sub.add_parser("put", help="Create or update a register entry") + p_reg_put.add_argument("--path", default=".") + p_reg_put.add_argument("--kind", required=True, choices=register_kinds) + p_reg_put.add_argument("--entry-id", required=True) + p_reg_put.add_argument("--title", default=None) + p_reg_put.add_argument("--status", default=None) + p_reg_put.add_argument("--data-json", type=_json_object, default=None) + p_reg_defer = register_sub.add_parser("defer", help="Defer a register entry") + p_reg_defer.add_argument("--path", default=".") + p_reg_defer.add_argument("--kind", required=True, choices=register_kinds) + p_reg_defer.add_argument("--entry-id", required=True) + p_reg_defer.add_argument("--status", default="deferred") + p_reg_note = register_sub.add_parser("note", help="Append a note to a register entry") + p_reg_note.add_argument("--path", default=".") + p_reg_note.add_argument("--kind", required=True, choices=register_kinds) + p_reg_note.add_argument("--entry-id", required=True) + p_reg_note.add_argument("--note", required=True) + p_reg_note.add_argument("--author", default="codex") + for register_parser in (p_reg_put, p_reg_defer, p_reg_note): + register_parser.add_argument("--reason", default="rmgr CLI") + register_parser.add_argument("--correlation-id", default=None) + register_parser.add_argument("--idempotency-key", default=None) + register_parser.add_argument("--expected-head-sha", default=None) + register_parser.add_argument("--slug", default=None) + register_parser.add_argument("--push", action="store_true") + register_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") @@ -152,6 +254,72 @@ 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 == "workplan": + from repo_manager.commands.workplan import mutate_workplan + + if not args.workplan_command: + p_wp.print_help() + return 2 + operation = "archive" if args.workplan_command == "delete" else args.workplan_command + result = mutate_workplan( + Path(args.path), + args.workplan_id, + operation=operation, + title=getattr(args, "title", None), + goal=getattr(args, "goal", None), + status=getattr(args, "status", None), + owner=getattr(args, "owner", None), + topic_slug=getattr(args, "topic_slug", None), + domain=getattr(args, "domain", None), + filename=getattr(args, "filename", None), + confirm_archive=getattr(args, "confirm", False), + 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 == "register": + if not args.register_command: + p_register.print_help() + return 2 + if args.register_command == "list": + from repo_manager.observe import observe_repository + + _snapshot, index = observe_repository(Path(args.path), slug=args.slug) + prefix = f"register:{args.kind}" if args.kind else "register:" + entries = [r.__dict__ for r in index.work_records if r.kind.startswith(prefix)] + print(json.dumps({"ok": True, "entries": entries}, indent=2)) + return 0 + + from repo_manager.commands.register import mutate_register_entry + + result = mutate_register_entry( + Path(args.path), + args.kind, + args.entry_id, + operation="upsert" if args.register_command == "put" else args.register_command, + title=getattr(args, "title", None), + status=getattr(args, "status", None), + data=getattr(args, "data_json", None), + note=getattr(args, "note", None), + note_author=getattr(args, "author", 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( diff --git a/src/repo_manager/commands/register.py b/src/repo_manager/commands/register.py new file mode 100644 index 0000000..390eb87 --- /dev/null +++ b/src/repo_manager/commands/register.py @@ -0,0 +1,311 @@ +"""Governed mutation spine for repository-owned registers.""" + +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.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path + +_ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$") +_PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"}) +_REQUIRED_DATA = { + "sbom-inventory": frozenset({"package_name", "ecosystem"}), + "repo-goals": frozenset({"description"}), + "upstream-contributions": frozenset({"type"}), + "technical-debt": frozenset(), + "extension-points": frozenset(), + "register-entries": frozenset({"register_kind"}), +} + + +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 _load(path: Path, kind: str) -> dict[str, Any]: + if not path.is_file(): + return {"schema": REGISTER_SCHEMA, "kind": kind, "entries": []} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + raise ValueError(f"invalid register YAML: {exc}") from exc + if not isinstance(data, dict) or not isinstance(data.get("entries", []), list): + raise TypeError("register must be a mapping with an entries list") + if data.get("kind", kind) != kind: + raise ValueError(f"register kind does not match filename {kind!r}") + data.setdefault("schema", REGISTER_SCHEMA) + data.setdefault("kind", kind) + data.setdefault("entries", []) + return data + + +def _save(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(document, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + +def mutate_register_entry( + repo_root: Path, + kind: str, + entry_id: str, + *, + operation: str, + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + note: str | None = None, + note_author: 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: + """Upsert, defer, or annotate an entry in one of the shared register kinds.""" + command_map = { + "upsert": "repo.register.upsert_entry", + "defer": "repo.register.defer_entry", + "note": "repo.register.add_note", + } + correlation_id = correlation_id or str(uuid.uuid4()) + command = command_map.get(operation, "repo.register.upsert_entry") + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "kind": kind, + "entry_id": entry_id, + "operation": operation, + "title": title, + "status": status, + "data": data, + "note": note, + "note_author": note_author, + "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 operation not in command_map: + return _reject(command, correlation_id, "validation_error", f"invalid operation {operation!r}") + if kind not in SUPPORTED_REGISTER_KINDS: + return _reject(command, correlation_id, "validation_error", f"unsupported register {kind!r}") + if not _ENTRY_ID_RE.fullmatch(entry_id): + return _reject(command, correlation_id, "validation_error", f"invalid entry id {entry_id!r}") + if status is not None and not status.strip(): + return _reject(command, correlation_id, "validation_error", "status cannot be empty") + if data is not None and not isinstance(data, dict): + return _reject(command, correlation_id, "validation_error", "data must be an object") + protected = _PROTECTED_FIELDS.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 expected_head_sha != current_head: + 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 + + snapshot, _index = observe_repository(repo_root, slug=repo_slug) + slug = repo_slug or snapshot.get("slug") + path = register_path(repo_root, kind) + try: + document = _load(path, kind) + except (TypeError, ValueError) as exc: + return _reject(command, correlation_id, "conflict", str(exc)) + entries = document["entries"] + matches = [item for item in entries if isinstance(item, dict) and str(item.get("id")) == entry_id] + if len(matches) > 1: + return _reject(command, correlation_id, "conflict", f"duplicate entry id {entry_id!r}") + entry = matches[0] if matches else None + before = yaml.safe_dump(entry, sort_keys=True) if entry is not None else None + now = _now() + + if operation == "upsert": + if entry is None: + if not title or not title.strip(): + return _reject(command, correlation_id, "validation_error", "new entry requires title") + missing = sorted(key for key in _REQUIRED_DATA[kind] if key not in (data or {})) + if missing: + return _reject( + command, + correlation_id, + "validation_error", + f"{kind} entry missing data fields: {', '.join(missing)}", + ) + entry = { + "id": entry_id, + "title": title.strip(), + "status": (status or "open").strip(), + **(data or {}), + "created": now, + "updated": now, + } + entries.append(entry) + else: + if title is not None: + if not title.strip(): + return _reject(command, correlation_id, "validation_error", "title cannot be empty") + entry["title"] = title.strip() + if status is not None: + entry["status"] = status.strip() + entry.update(data or {}) + entry["updated"] = now + elif operation == "defer": + if entry is None: + return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found") + entry["status"] = (status or "deferred").strip() + entry["updated"] = now + else: + if entry is None: + return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found") + if not note or not note.strip(): + return _reject(command, correlation_id, "validation_error", "note requires content") + entry.setdefault("notes", []).append( + {"content": note.strip(), "author": note_author or "codex", "created": now} + ) + entry["updated"] = now + + after = yaml.safe_dump(entry, sort_keys=True) + if before == after: + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True}, + ) + if idempotency_key: + idempotency.put(idempotency_key, digest, result.to_dict()) + return result + + _save(path, document) + 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} {kind}/{entry_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_after, index_after = observe_repository(repo_root, slug=slug) + append_event( + index_after, + { + "type": "repo.command.applied", + "command": command, + "operation": operation, + "correlation_id": correlation_id, + "register_kind": kind, + "entry_id": entry_id, + "git_sha": git_sha, + "files_touched": [relative_path], + "source": "repo-manager", + }, + ) + append_event( + index_after, + { + "type": "repo.work.indexed", + "correlation_id": correlation_id, + "kind": f"register:{kind}", + "id": entry_id, + "source_path": relative_path, + }, + ) + save_index(index_after) + dual_run.record_mutation( + source="repo-manager", + kind=f"register_{operation}", + repo_slug=slug, + detail={ + "register_kind": kind, + "entry_id": entry_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["push_ok"] = push_ok + evidence["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 diff --git a/src/repo_manager/commands/workplan.py b/src/repo_manager/commands/workplan.py new file mode 100644 index 0000000..e171e5e --- /dev/null +++ b/src/repo_manager/commands/workplan.py @@ -0,0 +1,498 @@ +"""Governed workplan create, update, and archive commands.""" + +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any + +from repo_manager import dual_run, idempotency +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 + +VALID_WORKPLAN_STATUSES = frozenset( + {"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"} +) +_WORKPLAN_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-WP-[0-9]{4,}$") +_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P.*?)\r?\n---(?P\r?\n.*)?\Z", re.DOTALL) + + +@dataclass +class CommandResult: + command: str + 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": self.command, + "status": self.status, + "correlation_id": self.correlation_id, + "evidence": self.evidence, + } + if self.error: + out["error"] = self.error + return out + + +def _result_from_prior(command: str, prior: dict[str, Any], correlation_id: str) -> CommandResult: + 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"), + ) + + +def _rejected(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 _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except ValueError: + return False + + +def _slugify(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "workplan" + + +def _quoted(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _today() -> date: + return datetime.now(UTC).date() + + +def _patch_frontmatter(text: str, updates: dict[str, str]) -> str | None: + match = _FRONTMATTER_RE.match(text) + if not match: + return None + meta = match.group("meta") + for key, value in updates.items(): + rendered = value if key == "status" else _quoted(value) + pattern = re.compile(rf"^({re.escape(key)}:\s*).*$", re.MULTILINE) + if pattern.search(meta): + meta = pattern.sub(rf"\g<1>{rendered}", meta, count=1) + else: + meta = f"{meta.rstrip()}\n{key}: {rendered}" + return f"---\n{meta}\n---{match.group('body') or ''}" + + +def _find_workplan(index: Any, workplan_id: str) -> Any | None: + by_uuid = _is_uuid(workplan_id) + for record in index.work_records: + if record.kind != "workplan": + continue + if not by_uuid and record.id == workplan_id: + return record + if by_uuid and record.uuid and record.uuid.lower() == workplan_id.lower(): + return record + return None + + +def _create_text( + *, + workplan_id: str, + title: str, + goal: str, + status: str, + domain: str, + repo_slug: str, + owner: str, + topic_slug: str, +) -> str: + today = _today().isoformat() + return ( + "---\n" + f"id: {workplan_id}\n" + "type: workplan\n" + f"title: {_quoted(title)}\n" + f"domain: {domain}\n" + f"repo: {repo_slug}\n" + f"status: {status}\n" + f"owner: {owner}\n" + f"topic_slug: {topic_slug}\n" + f"created: {_quoted(today)}\n" + f"updated: {_quoted(today)}\n" + "---\n\n" + f"# {title}\n\n" + "## Goal\n\n" + f"{goal.strip()}\n" + ) + + +def mutate_workplan( + repo_root: Path, + workplan_id: str, + *, + operation: str, + title: str | None = None, + goal: str | None = None, + status: str | None = None, + owner: str | None = None, + topic_slug: str | None = None, + domain: str | None = None, + filename: str | None = None, + confirm_archive: bool = False, + 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: + """Create, update, or archive a file-backed workplan. + + Update/archive accepts a canonical workplan id or State Hub UUID. ``archive`` + is the recoverable implementation of the legacy delete operation. + """ + commands = { + "create": "repo.work.create_workplan", + "update": "repo.work.update_workplan", + "archive": "repo.work.archive_workplan", + } + correlation_id = correlation_id or str(uuid.uuid4()) + command = commands.get(operation, "repo.work.update_workplan") + repo_root = repo_root.resolve() + payload = { + "repo_root": str(repo_root), + "workplan_id": workplan_id, + "operation": operation, + "title": title, + "goal": goal, + "status": status, + "owner": owner, + "topic_slug": topic_slug, + "domain": domain, + "filename": filename, + "confirm_archive": confirm_archive, + "expected_head_sha": expected_head_sha, + } + payload_digest = idempotency.payload_hash(payload) + + if idempotency_key: + prior = idempotency.get(idempotency_key) + if prior: + if prior.get("payload_hash") != payload_digest: + return _rejected( + command, + correlation_id, + "conflict", + "idempotency key reused with different payload", + ) + return _result_from_prior(command, prior, correlation_id) + + if operation not in commands: + return _rejected(command, correlation_id, "validation_error", f"invalid operation {operation!r}") + if status is not None and status not in VALID_WORKPLAN_STATUSES: + return _rejected( + command, + correlation_id, + "validation_error", + f"invalid workplan status {status!r}", + ) + if operation == "archive" and not confirm_archive: + return _rejected( + command, + correlation_id, + "confirmation_required", + "archive requires confirm_archive=True", + ) + + current_head = head_sha(repo_root) + if expected_head_sha and current_head and expected_head_sha != current_head: + result = _rejected( + 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 + + snapshot, index = observe_repository(repo_root, slug=repo_slug) + slug = repo_slug or snapshot.get("slug") + record = _find_workplan(index, workplan_id) + touched: list[str] + changes: dict[str, Any] + + if operation == "create": + if not _WORKPLAN_ID_RE.fullmatch(workplan_id): + return _rejected( + command, + correlation_id, + "validation_error", + f"invalid canonical workplan id {workplan_id!r}", + ) + if record is not None: + return _rejected(command, correlation_id, "conflict", f"workplan {workplan_id!r} exists") + if not title or not title.strip() or not goal or not goal.strip(): + return _rejected( + command, + correlation_id, + "validation_error", + "create requires non-empty title and goal", + ) + create_status = status or "proposed" + if create_status not in VALID_WORKPLAN_STATUSES: + return _rejected( + command, + correlation_id, + "validation_error", + f"invalid workplan status {create_status!r}", + ) + name = filename or f"{workplan_id}-{_slugify(title)}.md" + if Path(name).name != name or not name.endswith(".md") or not name.startswith(f"{workplan_id}-"): + return _rejected(command, correlation_id, "validation_error", "unsafe workplan filename") + path = repo_root / "workplans" / name + if path.exists(): + return _rejected(command, correlation_id, "conflict", f"source file exists: workplans/{name}") + classification = snapshot.get("classification") or {} + create_domain = domain or classification.get("domain") or "infotech" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + _create_text( + workplan_id=workplan_id, + title=title.strip(), + goal=goal, + status=create_status, + domain=create_domain, + repo_slug=slug, + owner=owner or "codex", + topic_slug=topic_slug or create_domain, + ), + encoding="utf-8", + ) + touched = [str(path.relative_to(repo_root))] + changes = {"title": title.strip(), "status": create_status} + else: + if record is None: + return _rejected( + command, + correlation_id, + "not_found", + f"workplan {workplan_id!r} not found in workplans", + ) + path = repo_root / record.source_path + if not path.is_file(): + return CommandResult( + command=command, + status="failed", + correlation_id=correlation_id, + evidence={"status": "failed"}, + error={ + "code": "not_found", + "message": f"source file missing: {record.source_path}", + "correlation_id": correlation_id, + }, + ) + if operation == "update": + updates = { + key: value.strip() + for key, value in { + "title": title, + "status": status, + "owner": owner, + "topic_slug": topic_slug, + "domain": domain, + }.items() + if value is not None and value.strip() + } + if not updates: + return _rejected( + command, + correlation_id, + "validation_error", + "update requires at least one field", + ) + changes = { + key: value + for key, value in updates.items() + if key not in {"status", "title"} + or (key == "status" and value != record.status) + or (key == "title" and value != record.title) + } + if not changes: + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence={ + "status": "applied", + "git_sha": current_head, + "files_touched": [], + "noop": True, + "observed_at": index.observed_at, + }, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_digest, result.to_dict()) + return result + changes["updated"] = _today().isoformat() + new_text = _patch_frontmatter(path.read_text(encoding="utf-8"), changes) + if new_text is None: + return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid") + path.write_text(new_text, encoding="utf-8") + touched = [record.source_path] + else: + if record.status == "archived" and record.source_path.startswith("workplans/archived/"): + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence={ + "status": "applied", + "git_sha": current_head, + "files_touched": [], + "noop": True, + "observed_at": index.observed_at, + }, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_digest, result.to_dict()) + return result + archived_dir = repo_root / "workplans" / "archived" + archived_dir.mkdir(parents=True, exist_ok=True) + archived_path = archived_dir / f"{_today():%y%m%d}-{path.name}" + if archived_path.exists(): + return _rejected( + command, + correlation_id, + "conflict", + f"archive target exists: {archived_path.relative_to(repo_root)}", + ) + new_text = _patch_frontmatter( + path.read_text(encoding="utf-8"), + {"status": "archived", "updated": _today().isoformat()}, + ) + if new_text is None: + return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid") + archived_path.write_text(new_text, encoding="utf-8") + path.unlink() + touched = [record.source_path, str(archived_path.relative_to(repo_root))] + changes = {"status": "archived", "source_path": touched[-1]} + + git_sha: str | None = None + push_ok: bool | None = None + push_message: str | None = None + if commit: + try: + git_sha = commit_paths( + repo_root, + touched, + message=( + f"{command} {workplan_id} ({operation})\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": touched, "git_sha": None}, + error={"code": "internal", "message": str(exc), "correlation_id": correlation_id}, + ) + if push: + push_ok, push_message = push_ff(repo_root) + + _snapshot_after, index_after = observe_repository(repo_root, slug=slug) + event = { + "type": "repo.command.applied", + "command": command, + "operation": operation, + "correlation_id": correlation_id, + "workplan_id": record.id if record else workplan_id, + "workplan_uuid": record.uuid if record else None, + "changes": changes, + "git_sha": git_sha, + "files_touched": touched, + "expected_head_sha_before": current_head, + "source": "repo-manager", + } + append_event(index_after, event) + append_event( + index_after, + { + "type": "repo.work.indexed", + "correlation_id": correlation_id, + "kind": "workplan", + "id": record.id if record else workplan_id, + "source_path": touched[-1], + }, + ) + save_index(index_after) + dual_run.record_mutation( + source="repo-manager", + kind=f"workplan_{operation}", + repo_slug=slug, + detail={ + "workplan_id": record.id if record else workplan_id, + "changes": changes, + "git_sha": git_sha, + "correlation_id": correlation_id, + }, + ) + + evidence: dict[str, Any] = { + "status": "applied", + "git_sha": git_sha, + "files_touched": touched, + "observed_at": index_after.observed_at, + "index_path": str(default_index_path(repo_root)), + "source": "repo-manager", + } + if push: + evidence["push_ok"] = push_ok + evidence["push_message"] = push_message + result = CommandResult( + command=command, + status="applied", + correlation_id=correlation_id, + evidence=evidence, + ) + if idempotency_key: + idempotency.put(idempotency_key, payload_digest, result.to_dict()) + return result + + +def create_workplan(repo_root: Path, workplan_id: str, title: str, goal: str, **kwargs: Any) -> CommandResult: + return mutate_workplan( + repo_root, + workplan_id, + operation="create", + title=title, + goal=goal, + **kwargs, + ) + + +def update_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult: + return mutate_workplan(repo_root, workplan_id, operation="update", **kwargs) + + +def archive_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult: + return mutate_workplan(repo_root, workplan_id, operation="archive", **kwargs) diff --git a/src/repo_manager/observe.py b/src/repo_manager/observe.py index 4bef0ba..488e89d 100644 --- a/src/repo_manager/observe.py +++ b/src/repo_manager/observe.py @@ -9,6 +9,7 @@ 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.register import iter_register_files, parse_register_file from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file @@ -72,6 +73,19 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic ) ) + for path in iter_register_files(repo_root): + for entry in parse_register_file(path, repo_root=repo_root): + records.append( + WorkRecordEntry( + kind=f"register:{entry.kind}", + id=entry.id, + status=entry.status, + title=entry.title, + source_path=entry.source_path, + extra={"register_kind": entry.kind, "entry": entry.raw}, + ) + ) + sha = head_sha(repo_root) if is_git_repo(repo_root) else None index = RepoIndex( slug=slug, @@ -101,6 +115,7 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic "index": { "workplan_count": sum(1 for r in records if r.kind == "workplan"), "task_count": sum(1 for r in records if r.kind == "task"), + "register_entry_count": sum(1 for r in records if r.kind.startswith("register:")), "record_count": len(records), }, } diff --git a/src/repo_manager/parse/register.py b/src/repo_manager/parse/register.py new file mode 100644 index 0000000..29a8816 --- /dev/null +++ b/src/repo_manager/parse/register.py @@ -0,0 +1,73 @@ +"""Parser for repository-owned register files.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +REGISTER_SCHEMA = "repo-manager.register.v0" +SUPPORTED_REGISTER_KINDS = frozenset( + { + "sbom-inventory", + "repo-goals", + "upstream-contributions", + "technical-debt", + "extension-points", + "register-entries", + } +) + + +@dataclass +class ParsedRegisterEntry: + kind: str + id: str + title: str | None + status: str | None + source_path: str + raw: dict[str, Any] = field(default_factory=dict) + + +def register_path(repo_root: Path, kind: str) -> Path: + return repo_root / "registers" / f"{kind}.yaml" + + +def parse_register_file(path: Path, *, repo_root: Path) -> list[ParsedRegisterEntry]: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return [] + if not isinstance(data, dict): + return [] + kind = str(data.get("kind") or path.stem) + if kind not in SUPPORTED_REGISTER_KINDS: + return [] + entries = data.get("entries") or [] + if not isinstance(entries, list): + return [] + source_path = str(path.relative_to(repo_root)) + parsed: list[ParsedRegisterEntry] = [] + for item in entries: + if not isinstance(item, dict) or not item.get("id"): + continue + parsed.append( + ParsedRegisterEntry( + kind=kind, + id=str(item["id"]), + title=str(item["title"]) if item.get("title") is not None else None, + status=str(item["status"]) if item.get("status") is not None else None, + source_path=source_path, + raw=item, + ) + ) + return parsed + + +def iter_register_files(repo_root: Path) -> list[Path]: + directory = repo_root / "registers" + if not directory.is_dir(): + return [] + return sorted(path for path in directory.glob("*.yaml") if not path.name.startswith(".")) diff --git a/tests/test_dual_run.py b/tests/test_dual_run.py index 1570038..67ae598 100644 --- a/tests/test_dual_run.py +++ b/tests/test_dual_run.py @@ -55,11 +55,11 @@ def _clear_dual_run_cache(): dual_run.reload_config() -def test_flags_and_pilot(monkeypatch: pytest.MonkeyPatch): +def test_flags_and_pilot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("RM_WRITEBACK", raising=False) monkeypatch.delenv("RM_RECONCILE", raising=False) monkeypatch.delenv("RM_PILOT_REPOS", raising=False) - monkeypatch.delenv("RM_DUAL_RUN_CONFIG", raising=False) + monkeypatch.setenv("RM_DUAL_RUN_CONFIG", str(tmp_path / "missing-dual-run.yaml")) dual_run.reload_config() assert dual_run.writeback_enabled() is False diff --git a/tests/test_register_command.py b/tests/test_register_command.py new file mode 100644 index 0000000..2f87ee2 --- /dev/null +++ b/tests/test_register_command.py @@ -0,0 +1,144 @@ +"""Shared repository register spine coverage for RMGR-WP-0008-T05.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import yaml + +from repo_manager.commands.register import mutate_register_entry +from repo_manager.observe import observe_repository +from repo_manager.parse.register import SUPPORTED_REGISTER_KINDS + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _fixture(tmp_path: Path) -> Path: + repo = tmp_path / "pilot" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / ".repo-classification.yaml").write_text( + "repo_classification:\n category: tooling\n domain: infotech\n", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + return repo + + +def test_all_register_kinds_are_file_backed_and_indexed(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + payloads = { + "sbom-inventory": {"package_name": "pyyaml", "ecosystem": "pypi"}, + "repo-goals": {"description": "Keep repository records authoritative."}, + "upstream-contributions": {"type": "patch"}, + "technical-debt": {"severity": "medium"}, + "extension-points": {"ep_type": "api"}, + "register-entries": {"register_kind": "custom"}, + } + + for sequence, kind in enumerate(sorted(SUPPORTED_REGISTER_KINDS), start=1): + result = mutate_register_entry( + repo, + kind, + f"ENTRY-{sequence:02d}", + operation="upsert", + title=f"{kind} entry", + data=payloads[kind], + ) + assert result.status == "applied", result + assert result.evidence["git_sha"] + document = yaml.safe_load((repo / "registers" / f"{kind}.yaml").read_text()) + assert document["schema"] == "repo-manager.register.v0" + assert document["kind"] == kind + + snapshot, index = observe_repository(repo, slug="pilot") + records = [record for record in index.work_records if record.kind.startswith("register:")] + assert snapshot["index"]["register_entry_count"] == 6 + assert len(records) == 6 + + +def test_update_note_defer_and_idempotency(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + monkeypatch.setenv("RM_IDEMPOTENCY_PATH", str(tmp_path / "idempotency.json")) + created = mutate_register_entry( + repo, + "technical-debt", + "TD-001", + operation="upsert", + title="Coupled persistence", + data={"severity": "high"}, + idempotency_key="td-create", + ) + replay = mutate_register_entry( + repo, + "technical-debt", + "TD-001", + operation="upsert", + title="Coupled persistence", + data={"severity": "high"}, + idempotency_key="td-create", + ) + assert replay.evidence["git_sha"] == created.evidence["git_sha"] + + updated = mutate_register_entry( + repo, + "technical-debt", + "TD-001", + operation="upsert", + status="in_progress", + data={"severity": "critical"}, + ) + assert updated.status == "applied" + noted = mutate_register_entry( + repo, + "technical-debt", + "TD-001", + operation="note", + note="Migration owner assigned.", + note_author="operator", + ) + assert noted.status == "applied" + deferred = mutate_register_entry( + repo, + "technical-debt", + "TD-001", + operation="defer", + ) + assert deferred.status == "applied" + + document = yaml.safe_load((repo / "registers" / "technical-debt.yaml").read_text()) + entry = document["entries"][0] + assert entry["status"] == "deferred" + assert entry["severity"] == "critical" + assert entry["notes"][0]["author"] == "operator" + + +def test_schema_and_safety_validation(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + missing = mutate_register_entry( + repo, + "sbom-inventory", + "PKG-01", + operation="upsert", + title="package", + data={"package_name": "demo"}, + ) + assert missing.status == "rejected" + assert missing.error and "ecosystem" in missing.error["message"] + + protected = mutate_register_entry( + repo, + "technical-debt", + "TD-001", + operation="upsert", + title="Debt", + data={"id": "replacement"}, + ) + assert protected.status == "rejected" + assert protected.error and protected.error["code"] == "validation_error" diff --git a/tests/test_workplan_command.py b/tests/test_workplan_command.py new file mode 100644 index 0000000..fe7e732 --- /dev/null +++ b/tests/test_workplan_command.py @@ -0,0 +1,168 @@ +"""Governed workplan mutation coverage for RMGR-WP-0008-T01.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from repo_manager.commands.workplan import archive_workplan, create_workplan, update_workplan +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 + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +def _fixture(tmp_path: Path) -> Path: + repo = tmp_path / "pilot" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + (repo / "workplans").mkdir() + (repo / ".repo-classification.yaml").write_text( + "repo_classification:\n category: tooling\n domain: infotech\n", + encoding="utf-8", + ) + (repo / "workplans" / "P-WP-0001-first.md").write_text( + """--- +id: P-WP-0001 +type: workplan +title: "First" +domain: infotech +repo: pilot +status: proposed +owner: codex +topic_slug: infotech +created: "2026-08-21" +updated: "2026-08-21" +state_hub_workstream_id: "11111111-1111-4111-8111-111111111111" +--- + +# First + +## Task + +```task +id: P-WP-0001-T01 +status: todo +priority: high +``` + +Do it. +""", + encoding="utf-8", + ) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + return repo + + +def test_create_workplan_commits_indexes_and_meters(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + meter = tmp_path / "meter.jsonl" + monkeypatch.setenv("RM_METER_PATH", str(meter)) + + result = create_workplan( + repo, + "P-WP-0002", + "Second plan", + "Deliver the second governed slice.", + status="ready", + correlation_id="00000000-0000-4000-8000-000000000002", + repo_slug="pilot", + ) + + assert result.status == "applied" + assert result.evidence["git_sha"] == head_sha(repo) + path = repo / "workplans" / "P-WP-0002-second-plan.md" + assert path.is_file() + text = path.read_text(encoding="utf-8") + assert "status: ready" in text + assert "Deliver the second governed slice." in text + _snapshot, index = observe_repository(repo, slug="pilot") + assert next(r for r in index.work_records if r.id == "P-WP-0002").status == "ready" + stored = load_index(default_index_path(repo)) + event = next(e for e in stored.events if e.get("type") == "repo.command.applied") + assert event["command"] == "repo.work.create_workplan" + assert '"kind": "workplan_create"' in meter.read_text(encoding="utf-8") + + +def test_update_by_uuid_is_idempotent_and_honors_expected_head(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + monkeypatch.setenv("RM_IDEMPOTENCY_PATH", str(tmp_path / "idempotency.json")) + before = head_sha(repo) + + rejected = update_workplan( + repo, + "11111111-1111-4111-8111-111111111111", + status="active", + expected_head_sha="0" * 40, + ) + assert rejected.status == "rejected" + assert rejected.error and rejected.error["code"] == "precondition_failed" + + applied = update_workplan( + repo, + "11111111-1111-4111-8111-111111111111", + title="Renamed", + status="active", + expected_head_sha=before, + idempotency_key="workplan-update-1", + ) + assert applied.status == "applied" + assert applied.evidence["git_sha"] != before + text = (repo / "workplans" / "P-WP-0001-first.md").read_text(encoding="utf-8") + assert 'title: "Renamed"' in text + assert "status: active" in text + + replay = update_workplan( + repo, + "11111111-1111-4111-8111-111111111111", + title="Renamed", + status="active", + expected_head_sha=before, + idempotency_key="workplan-update-1", + ) + assert replay.status == "applied" + assert replay.evidence["git_sha"] == applied.evidence["git_sha"] + + +def test_delete_is_guarded_recoverable_archive(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + + rejected = archive_workplan(repo, "P-WP-0001") + assert rejected.status == "rejected" + assert rejected.error and rejected.error["code"] == "confirmation_required" + assert (repo / "workplans" / "P-WP-0001-first.md").is_file() + + applied = archive_workplan(repo, "P-WP-0001", confirm_archive=True) + assert applied.status == "applied" + assert not (repo / "workplans" / "P-WP-0001-first.md").exists() + archived = list((repo / "workplans" / "archived").glob("*-P-WP-0001-first.md")) + assert len(archived) == 1 + assert "status: archived" in archived[0].read_text(encoding="utf-8") + _snapshot, index = observe_repository(repo) + record = next(r for r in index.work_records if r.id == "P-WP-0001") + assert record.status == "archived" + assert record.source_path.startswith("workplans/archived/") + + +def test_create_rejects_unsafe_filename_and_duplicate_id(tmp_path: Path) -> None: + repo = _fixture(tmp_path) + + unsafe = create_workplan( + repo, + "P-WP-0002", + "Second", + "Goal", + filename="../outside.md", + ) + assert unsafe.status == "rejected" + assert unsafe.error and unsafe.error["code"] == "validation_error" + + duplicate = create_workplan(repo, "P-WP-0001", "Duplicate", "Goal") + assert duplicate.status == "rejected" + assert duplicate.error and duplicate.error["code"] == "conflict" diff --git a/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md b/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md index dc32209..ec229cc 100644 --- a/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md +++ b/workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md @@ -4,11 +4,11 @@ type: workplan title: "Receiving surface for work records and repository registers" domain: infotech repo: repo-manager -status: proposed +status: active owner: codex topic_slug: infotech created: "2026-08-20" -updated: "2026-08-20" +updated: "2026-08-21" parent_project: prj-state-hub-retirement parent_workplan: SHR-WP-0001 related: @@ -69,7 +69,7 @@ surfaces index and govern them, they do not become a second source of truth. ```task id: RMGR-WP-0008-T01 -status: todo +status: done priority: high ``` @@ -83,6 +83,14 @@ Unblocks slice **A2b** (~9 routes plus `table:workplans`). Follow the RMGR-WP-0002 dual-run shape — writeback, reconcile, mutation meter — rather than inventing a second mechanism. +Result (2026-08-21): `rmgr workplan create|update|delete` now owns the +file-backed mutation path. Update accepts canonical ids or State Hub UUIDs; +delete is a confirmed, recoverable archive operation. All three operations use +optimistic HEAD checks, idempotency, Git evidence, reindexing, normalized +events, mutation metering, and optional push-seal. State Hub has the matching +`rm_update_workplan` compatibility adapter. Repo Manager's full suite and the +adapter's focused compatibility tests pass. + ## Intake records ```task @@ -131,7 +139,7 @@ Unblocks slice **A2e** (~6 items) and the dependency routes inside A2b. ```task id: RMGR-WP-0008-T05 -status: todo +status: progress priority: high ``` @@ -152,6 +160,14 @@ Treat as one register spine with per-kind schemas rather than six unrelated features — they share repo scoping, append-mostly write patterns, and the same file-backed-authority rule. +Progress (2026-08-21): the shared spine is live for all six kinds as versioned +`registers/.yaml` files, with parser/index support and governed +`put`/`defer`/`note` commands. Kind-specific required fields cover the existing +State Hub create contracts, and the generic `rm_update_register_entry` adapter +can route compatibility writes. Remaining before `done`: lockfile ingestion, +licence/copyleft derived reports, route-level cutover fixtures, and migration of +the live register rows. + ## Topic and classification contract ```task diff --git a/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md b/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md index 230f004..a5a2f7e 100644 --- a/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md +++ b/workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md @@ -4,7 +4,7 @@ type: workplan title: "Coding-assistant commit provenance trailers" domain: infotech repo: repo-manager -status: proposed +status: ready owner: codex topic_slug: infotech created: "2026-08-21"