diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f526886..b8f329b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -48,6 +48,7 @@ | workplan | STATE-WP-0076 | finished | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md | | workplan | STATE-WP-0077 | finished | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | | workplan | STATE-WP-0078 | finished | — | workplans/STATE-WP-0078-ops-run-read-projection.md | +| workplan | STATE-WP-0079 | proposed | — | workplans/STATE-WP-0079-retirement-strangler.md | | task | ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | | task | ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | | task | ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | @@ -272,3 +273,9 @@ | task | STATE-WP-0078-T01 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md | | task | STATE-WP-0078-T02 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md | | task | STATE-WP-0078-T03 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md | +| task | STATE-WP-0079-T01 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T02 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T03 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T04 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T05 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0079-T06 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | diff --git a/api/routers/tasks.py b/api/routers/tasks.py index 7a54a5c..b76746c 100644 --- a/api/routers/tasks.py +++ b/api/routers/tasks.py @@ -169,6 +169,8 @@ async def bulk_status_sync( updated: list[Task] = [] events: list[ProgressEvent] = [] author = body.author or "custodian" + # Cache repo resolution for dual-run writeback (RMGR-WP-0003) + repo_cache: dict = {} for update in body.updates: task = tasks_by_id[update.task_id] previous_status = status_value(task.status) @@ -182,6 +184,32 @@ async def bulk_status_sync( parent_workstream=ws, previous_task_status=previous_status, ) + if target_status != previous_status: + try: + from api.models.managed_repo import ManagedRepo + from api.services.repo_manager_dual_run import try_writeback_for_task + from api.services.workplan_files import resolve_repo_path + + repo_id = ws.repo_id if ws else None + if repo_id is not None: + if repo_id not in repo_cache: + repo_cache[repo_id] = await session.get(ManagedRepo, repo_id) + repo = repo_cache[repo_id] + if repo is not None: + try_writeback_for_task( + repo_path=resolve_repo_path(repo), + repo_slug=repo.slug, + task_id=str(task.id), + status=target_status, + reason="state-hub bulk-status-sync dual-run", + ) + except Exception: + import logging + + logging.getLogger(__name__).exception( + "repo-manager dual-run bulk writeback failed for task %s", + update.task_id, + ) event = ProgressEvent( task_id=task.id, workplan_id=task.workplan_id, diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py index b6a8731..3af5e40 100644 --- a/api/services/repo_manager_dual_run.py +++ b/api/services/repo_manager_dual_run.py @@ -1,11 +1,11 @@ -"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002 Stage B). +"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002/0003 Stage B). -When RM_WRITEBACK / RM_RECONCILE are enabled (and optional RM_PILOT_REPOS matches), -State Hub delegates checkout mutation to the ``rmgr`` CLI instead of native -file writeback. Rollback: unset the env flags. +Config file (same as repo-manager): ~/.repo-manager/dual-run.yaml +or RM_DUAL_RUN_CONFIG. Env overrides when set: -Shared env vars (see repo-manager dual_run.py): - RM_WRITEBACK, RM_RECONCILE, RM_PILOT_REPOS, RM_METER_PATH, RMGR_BIN + RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH + +Rollback: disable in config / unset env → native State Hub path. """ from __future__ import annotations @@ -16,32 +16,97 @@ import os import subprocess import sys import uuid +from functools import lru_cache from pathlib import Path from typing import Any +import yaml + logger = logging.getLogger(__name__) +_TRUE = frozenset({"1", "true", "yes", "on"}) +_FALSE = frozenset({"0", "false", "no", "off"}) -def _truthy(name: str) -> bool: - return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + +def config_path() -> Path: + raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml") + return Path(raw).expanduser() + + +@lru_cache(maxsize=1) +def _load_file_config() -> dict[str, Any]: + path = config_path() + if not path.is_file(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError): + return {} + return data if isinstance(data, dict) else {} + + +def reload_config() -> None: + _load_file_config.cache_clear() + + +def _env_bool(name: str) -> bool | None: + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return None + v = raw.strip().lower() + if v in _TRUE: + return True + if v in _FALSE: + return False + return None + + +def _cfg_bool(key: str, env_name: str, default: bool = False) -> bool: + ev = _env_bool(env_name) + if ev is not None: + return ev + cfg = _load_file_config() + if key in cfg: + val = cfg[key] + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.strip().lower() in _TRUE + return default def writeback_enabled() -> bool: - return _truthy("RM_WRITEBACK") + return _cfg_bool("writeback", "RM_WRITEBACK", False) def reconcile_enabled() -> bool: - return _truthy("RM_RECONCILE") + return _cfg_bool("reconcile", "RM_RECONCILE", False) + + +def writeback_push_enabled() -> bool: + return _cfg_bool("writeback_push", "RM_WRITEBACK_PUSH", False) def pilot_slugs() -> set[str] | None: raw = os.environ.get("RM_PILOT_REPOS") - if raw is None: + if raw is not None: + raw = raw.strip() + if not raw: + return set() + return {s.strip() for s in raw.split(",") if s.strip()} + cfg = _load_file_config() + if "pilot_repos" not in cfg: return None - raw = raw.strip() - if not raw: - return set() - return {s.strip() for s in raw.split(",") if s.strip()} + val = cfg["pilot_repos"] + if val is None: + return None + if isinstance(val, str): + if not val.strip(): + return set() + return {s.strip() for s in val.split(",") if s.strip()} + if isinstance(val, list): + return {str(s).strip() for s in val if str(s).strip()} + return None def slug_allowed(slug: str | None) -> bool: @@ -62,8 +127,13 @@ def reconcile_for_repo(slug: str | None) -> bool: def meter_path() -> Path: - raw = os.environ.get("RM_METER_PATH", "~/.repo-manager/mutation-meter.jsonl") - return Path(raw).expanduser() + raw = os.environ.get("RM_METER_PATH") + if raw: + return Path(raw).expanduser() + cfg = _load_file_config() + if cfg.get("meter_path"): + return Path(str(cfg["meter_path"])).expanduser() + return Path("~/.repo-manager/mutation-meter.jsonl").expanduser() def record_mutation( @@ -92,7 +162,6 @@ def record_mutation( def run_rmgr(args: list[str], *, timeout: int = 120) -> tuple[int, str, str]: - """Run rmgr CLI. Prefer PYTHONPATH to sibling repo-manager checkout.""" env = os.environ.copy() custom = os.environ.get("RMGR_BIN", "").strip() if custom: @@ -133,11 +202,12 @@ def rm_update_task_status( status: str, repo_slug: str | None = None, reason: str = "state-hub dual-run", - push: bool = False, + push: bool | None = None, correlation_id: str | None = None, ) -> dict[str, Any]: - """Invoke repo-manager task status writeback. Returns parsed command result dict.""" correlation_id = correlation_id or str(uuid.uuid4()) + if push is None: + push = writeback_push_enabled() args = [ "update-task-status", "--path", @@ -200,7 +270,6 @@ def try_writeback_for_task( status: str, reason: str = "state-hub dual-run PATCH /tasks", ) -> dict[str, Any] | None: - """If dual-run writeback applies, run RM and return result; else None (use native SH).""" if not writeback_for_repo(repo_slug): return None if not repo_path: @@ -217,7 +286,7 @@ def try_writeback_for_task( status=status, repo_slug=repo_slug, reason=reason, - push=False, # SH fix-consistency push-seal remains authority for push in pilot + push=writeback_push_enabled(), ) if result.get("status") == "applied": record_mutation( @@ -228,6 +297,7 @@ def try_writeback_for_task( "task_id": task_id, "status": status, "git_sha": (result.get("evidence") or {}).get("git_sha"), + "push_ok": (result.get("evidence") or {}).get("push_ok"), }, ) else: diff --git a/workplans/STATE-WP-0080-register-project-flavor-awareness.md b/workplans/STATE-WP-0080-register-project-flavor-awareness.md new file mode 100644 index 0000000..7394088 --- /dev/null +++ b/workplans/STATE-WP-0080-register-project-flavor-awareness.md @@ -0,0 +1,206 @@ +--- +id: STATE-WP-0080 +type: workplan +title: "statehub register: project-repository flavor awareness" +domain: infotech +repo: state-hub +status: proposed +owner: codex +topic_slug: infotech +created: "2026-08-16" +updated: "2026-08-16" +parent_project: prj-state-hub-retirement +parent_workplan: SHR-WP-0001 +related: + - CFED-WP-0001 + - SHR-WP-0001 +--- + +# statehub register: project-repository flavor awareness + +## Goal + +Make `statehub register` aware of the `prj-` project-repository flavor so that +scaffolding a project repo produces conformant files instead of files the +governing standard calls an anti-pattern. + +This is the residual recorded in `SHR-WP-0001-T01`: + +> Residual tooling gap: `statehub register` still scaffolds `INTENT.md` for +> ordinary repos — capture under a state-hub child workplan when T05 maps +> implementation streams (do not invent `INTENT.md` on `prj-` repos). + +It has now been hit twice — once when founding `prj-state-hub-retirement`, and +again on 2026-08-16 founding `prj-canon-federation`, where the scaffolded +`INTENT.md` and a generic `PRJ-WP-0001-statehub-bootstrap.md` both had to be +deleted by hand before the first `fix-consistency` run. + +## Root cause + +`statehub_register.py` (872 lines) contains **no reference to +`.repo-classification.yaml`, `category`, or `repo_flavor`**. It has no way to +distinguish a project repository from a durable product repository, so: + +- `KEY_CONTEXT_FILES` (`:25`) lists `INTENT.md` as a primary context file; +- `:248` writes `INTENT.md` unconditionally when absent; +- `:786`–`:790` treat a missing intent as a hard error, prompting interactively + and exiting with `ERROR: Intent is required to create INTENT.md.`; +- `:269` writes `{WP_PREFIX}-0001-statehub-bootstrap.md` with a prefix defaulted + from the repo slug (`:78`, `_default_wp_prefix`), yielding `PRJ-WP-` for every + `prj-` repo rather than a project-derived prefix; +- the generated `.custodian-brief.md` (`:634`) and templates (`:461`, `:509`, + `:525`, `:569`) all instruct agents to read `INTENT.md`. + +The governing standard is +`the-custodian/canon/standards/project-repository-flavor_v0.1.md`, which states: + +> Tooling that scaffolds repositories (e.g. `statehub register`) MUST treat +> `GOAL.md` + `repo_flavor: project` as sufficient purpose documentation for +> `prj-` repos and MUST NOT require inventing an `INTENT.md` that pretends the +> project is a permanent product. + +and names shipping both `INTENT.md` and `GOAL.md` an explicit anti-pattern. + +## Detect repository flavor + +```task +id: STATE-WP-0080-T01 +status: todo +priority: high +``` + +Give the register path a flavor signal. Read `.repo-classification.yaml` +`category` when present, and `GOAL.md` frontmatter `repo_flavor` as a secondary +signal; fall back to the `prj-` directory/slug prefix. Surface the result as a +single value threaded through inference and scaffolding. + +Treat classification as authoritative when present and disagreeing with the slug +prefix, and warn on the mismatch rather than guessing silently. + +## Scaffold GOAL.md instead of INTENT.md for project repos + +```task +id: STATE-WP-0080-T02 +status: wait +priority: high +``` + +For project-flavor repos: + +- do not write `INTENT.md`, and do not prompt for intent (`:786`–`:790`); +- accept an existing `GOAL.md` as sufficient purpose documentation; +- when `GOAL.md` is absent, scaffold one from a template carrying the four + sections the standard requires — Outcome, Invariants, Success gates, Project + retirement — plus the recommended frontmatter (`repo`, `repo_flavor`, + `project_status`, `started`, `reviewed`); +- error only if neither `GOAL.md` nor enough input to generate one exists. + +For all other flavors, behaviour is unchanged. + +## Fix workplan prefix inference + +```task +id: STATE-WP-0080-T03 +status: wait +priority: medium +``` + +`_default_wp_prefix` derives `PRJ-WP-` from any `prj-`-prefixed slug, which is +wrong for every project repo: the standard requires a prefix derived from the +project, not the flavor marker (`SHR-WP-` for State Hub Retirement, `CFED-WP-` +for canon federation). + +For project-flavor repos, strip the `prj-` marker before deriving, and prompt or +require `--wp-prefix` rather than emitting a flavor-derived default. A prefix +collision across two `prj-` repos is the failure this prevents. + +## Decide the bootstrap workplan for project repos + +```task +id: STATE-WP-0080-T04 +status: wait +priority: medium +``` + +`:269` writes a generic `statehub-bootstrap` workplan. For project repos this +competes with the foundation workplan the project actually needs — in both real +cases it was deleted immediately. + +Either skip it for project flavor, or replace it with a foundation-workplan +template shaped by the standard (confirm conventions, inventory participants, +map child workplans, define gates). Prefer skipping unless T05's template work +shows the foundation shape is genuinely reusable. + +## Update generated templates and briefs + +```task +id: STATE-WP-0080-T05 +status: wait +priority: medium +``` + +Make every generated artifact flavor-correct: + +- `KEY_CONTEXT_FILES` (`:25`) — include `GOAL.md`; +- `.custodian-brief.md` orientation line (`:634`) — `GOAL.md` for project repos; +- `AGENTS.md` / `CLAUDE.md` / `scripts/project_rules/` templates (`:461`, + `:509`, `:525`, `:569`) — session start order `GOAL.md` → `SCOPE.md` → + history → active workplans, per the standard's agent conventions; +- `_ensure_context_files` (`:431`) — do not treat missing `INTENT.md` as a gap + on project repos. + +Confirm `make update-agent-instructions` regenerates conformant files for an +existing `prj-` repo, not just at first registration. + +## Add a conformance check + +```task +id: STATE-WP-0080-T06 +status: wait +priority: low +``` + +The standard ships a conformance checklist that is currently only human-checked. +Add a consistency check that flags a project-flavor repo which ships `INTENT.md` +alongside `GOAL.md`, is missing a required file, or uses a flavor-derived +workplan prefix. + +Advisory severity is sufficient — this is a convention violation, not data +corruption. Follow the existing `C-NN` numbering and register it in the +consistency-check catalog. + +## Cover with tests + +```task +id: STATE-WP-0080-T07 +status: wait +priority: medium +``` + +Extend `tests/test_statehub_register_cli.py`: + +- registering a `prj-` repo with `category: project` writes `GOAL.md`, no + `INTENT.md`, and no flavor-derived prefix; +- registering an ordinary product repo is byte-identical to today's output + (regression guard — this workplan must not change durable-repo behaviour); +- classification/slug mismatch warns rather than silently choosing; +- re-running register on an existing conformant `prj-` repo is idempotent. + +## Carry the fix forward past State Hub retirement + +```task +id: STATE-WP-0080-T08 +status: wait +priority: medium +``` + +State Hub is being retired (`STATE-WP-0079`, `prj-state-hub-retirement`). +Repository scaffolding and representation are slated to land in `repo-manager`. + +Record where this flavor logic belongs after retirement and link it to the +inventory disposition for the register path, so the fix is not stranded in a +component scheduled for removal. If `repo-manager` is close enough to take it, +prefer implementing there and leaving `statehub register` with a thin +flavor-guard rather than a full templating path. + +Coordinate through `SHR-WP-0001` rather than duplicating its task list.