feat(consistency): coordination hygiene checks and MCP workplan_id aliases
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Add C-25..C-30 fix-consistency checks for blocked-workplan inbox sweeps,
stale unread triage, workplan ID prefix/collision lint, and SCOPE freshness.
Extend brief generation and get_domain_summary with inbox hygiene warnings.
Complete workplan_id aliases on remaining MCP tools and retry transient
_api_get failures to reduce false stale-reference errors under load.
This commit is contained in:
tegwick 2026-07-08 00:56:21 +02:00
parent cc656a2b16
commit 9693946755
5 changed files with 819 additions and 58 deletions

View file

@ -27,6 +27,12 @@ Checks:
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait
C-24 repo-classification-missing WARN No Registered repo lacks a valid .repo-classification.yaml on disk
C-25 blocked-unblock-sweep WARN No Blocked workplan has unread inbox from counterpart blocker may have cleared
C-26 workplan-id-prefix WARN No Workplan frontmatter id uses non-canonical prefix for this repo
C-27 workplan-id-collision WARN No Same workplan id appears in multiple repos
C-28 inbox-stale-unread WARN No Unread inbox messages older than INBOX_STALE_DAYS
C-29 inbox-work-unpromoted WARN No Stale unread message looks like a multi-step work request without a workplan
C-30 scope-current-state-stale WARN No SCOPE.md Current State contradicts live workplan statuses
Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
@ -62,7 +68,8 @@ import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@ -124,6 +131,20 @@ VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
STALE_UNREAD_DAYS = int(os.environ.get("INBOX_STALE_DAYS", "3"))
_API_GET_RETRIES = int(os.environ.get("CONSISTENCY_API_GET_RETRIES", "3"))
_API_GET_RETRY_BASE_DELAY = float(os.environ.get("CONSISTENCY_API_GET_RETRY_DELAY", "0.5"))
_WP_FILE_PREFIX_RE = re.compile(r"^([A-Za-z][A-Za-z0-9-]*-WP)-\d+", re.IGNORECASE)
_WP_FILE_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE)
_WP_ID_PREFIX_RE = re.compile(r"^([A-Z][A-Z0-9-]*-WP)-\d+", re.IGNORECASE)
_WP_ID_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE)
_BLOCKED_ON_RE = re.compile(r"^message-from:(?P<agent>.+)$")
_WORK_REQUEST_RE = re.compile(
r"\b(implement|workplan|multi-?step|please\s+(implement|add|create|fix|build))\b",
re.IGNORECASE,
)
_OPEN_WORKPLAN_STATUSES = {"proposed", "ready", "active", "blocked", "backlog"}
# Legacy file/API aliases translated before comparison and PATCHing.
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES)
@ -541,22 +562,40 @@ def _api_get(
# Only append trailing slash to the path component, not to query strings
if "?" not in path and not path.endswith("/"):
path += "/"
try:
with _httpx.Client(base_url=api_base, timeout=10.0, follow_redirects=True) as c:
filtered = {k: v for k, v in (params or {}).items() if v is not None}
r = c.get(path, params=filtered if filtered else None)
r.raise_for_status()
return r.json()
except _httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
filtered = {k: v for k, v in (params or {}).items() if v is not None}
last_error: Exception | None = None
for attempt in range(_API_GET_RETRIES):
try:
with _httpx.Client(base_url=api_base, timeout=10.0, follow_redirects=True) as c:
r = c.get(path, params=filtered if filtered else None)
r.raise_for_status()
return r.json()
except _httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
last_error = exc
if exc.response.status_code >= 500 and attempt < _API_GET_RETRIES - 1:
time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1))
continue
if return_error:
return {"_error": str(exc)}
return None
if return_error:
return {"_error": str(exc)}
return None
except Exception as exc:
if return_error:
return {"_error": str(exc)}
return None
except (_httpx.TimeoutException, _httpx.ConnectError, _httpx.NetworkError) as exc:
last_error = exc
if attempt < _API_GET_RETRIES - 1:
time.sleep(_API_GET_RETRY_BASE_DELAY * (attempt + 1))
continue
if return_error:
return {"_error": str(exc)}
return None
except Exception as exc:
last_error = exc
if return_error:
return {"_error": str(exc)}
return None
if return_error and last_error is not None:
return {"_error": str(last_error)}
return None
def _api_patch(api_base: str, path: str, body: dict) -> Any:
@ -1282,6 +1321,12 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
# workstream from the file, leaving the first as an invisible orphan.
_check_ghost_duplicates(api_base, workplan_infos, file_ws_ids, report)
_check_blocked_unblock_sweep(api_base, repo_slug, repo_dir, workplan_infos, report)
_check_inbox_hygiene(api_base, repo_slug, report)
_check_workplan_prefixes(repo_dir, repo_slug, workplan_infos, report)
_check_workplan_id_collisions(api_base, repo_slug, repo_dir, workplan_infos, report)
_check_scope_freshness(repo_dir, workplan_infos, report)
_sync_workplan_bindings(api_base, repo_slug, workplan_infos, repo_dir, report)
return report
@ -1532,6 +1577,350 @@ def _git_commit_writeback(
# Worker orientation brief (.custodian-brief.md)
# ---------------------------------------------------------------------------
def default_wp_prefix(repo_slug: str) -> str:
first = repo_slug.split("-", 1)[0].upper()
return f"{first}-WP"
def infer_wp_prefix(repo_path: Path, repo_slug: str) -> str:
"""Prefer established on-disk workplan prefixes over first-token derivation."""
counts: Counter[str] = Counter()
workplans_dir = repo_path / "workplans"
if workplans_dir.is_dir():
for workplan in iter_workplan_files(workplans_dir):
if workplan.name.startswith("ADHOC"):
continue
try:
text = workplan.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
id_match = _WP_ID_PREFIX_RE.search(text) or _WP_ID_BARE_RE.search(text)
if id_match:
counts[id_match.group(1).upper()] += 1
continue
file_match = _WP_FILE_PREFIX_RE.match(workplan.name) or _WP_FILE_BARE_RE.match(workplan.name)
if file_match:
counts[file_match.group(1).upper()] += 1
if not counts:
return default_wp_prefix(repo_slug)
return counts.most_common(1)[0][0]
def parse_blocked_on(value: str) -> str | None:
if not value:
return None
match = _BLOCKED_ON_RE.match(value.strip())
return match.group("agent").strip() if match else None
def _parse_message_timestamp(value: Any) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _summarise_inbox_message(msg: dict[str, Any], *, now: datetime) -> dict[str, Any]:
created = _parse_message_timestamp(msg.get("created_at"))
age_days = (now - created).days if created else None
return {
"id": str(msg.get("id", ""))[:8],
"from_agent": msg.get("from_agent", ""),
"subject": msg.get("subject", ""),
"age_days": age_days,
}
def collect_inbox_hygiene(api_base: str, repo_slug: str) -> dict[str, Any]:
messages = _api_get(
api_base,
"/messages",
{"to_agent": repo_slug, "unread_only": True, "limit": 100},
) or []
if not isinstance(messages, list):
return {
"stale_unread": [],
"missing_thread": [],
"work_requests_unpromoted": [],
"stale_unread_count": 0,
}
now = datetime.now(timezone.utc)
stale_cutoff = now - timedelta(days=STALE_UNREAD_DAYS)
thread_cutoff = now - timedelta(days=1)
stale_unread: list[dict[str, Any]] = []
missing_thread: list[dict[str, Any]] = []
work_requests_unpromoted: list[dict[str, Any]] = []
for msg in messages:
created = _parse_message_timestamp(msg.get("created_at"))
if created is None:
continue
summary = _summarise_inbox_message(msg, now=now)
if created < stale_cutoff:
stale_unread.append(summary)
if not msg.get("thread_id") and created < thread_cutoff:
missing_thread.append(summary)
body = f"{msg.get('subject', '')} {msg.get('body', '')}"
if _WORK_REQUEST_RE.search(body) and created < stale_cutoff:
work_requests_unpromoted.append(summary)
return {
"stale_unread": stale_unread,
"missing_thread": missing_thread,
"work_requests_unpromoted": work_requests_unpromoted,
"stale_unread_count": len(stale_unread),
}
def _check_blocked_unblock_sweep(
api_base: str,
repo_slug: str,
repo_dir: Path,
workplan_infos: list[tuple[Path, dict, str]],
report: ConsistencyReport,
) -> None:
for wp_file, meta, _ in workplan_infos:
file_status = normalise_workstream_status(str(meta.get("status", "")).strip())
if file_status != "blocked":
continue
blocked_on = str(meta.get("blocked_on", "")).strip()
counterpart = parse_blocked_on(blocked_on)
if not counterpart:
continue
messages = _api_get(
api_base,
"/messages",
{
"to_agent": repo_slug,
"from_agent": counterpart,
"unread_only": True,
"limit": 20,
},
) or []
if not isinstance(messages, list) or not messages:
continue
fname = workplan_display_path(repo_dir, wp_file)
wp_id = str(meta.get("id", wp_file.stem)).strip()
report.add(
severity="WARN",
check_id="C-25",
message=(
f"Blocked workplan '{wp_id}' waiting on {blocked_on!r} has "
f"{len(messages)} unread message(s) from {counterpart}"
"blocker may have cleared"
),
file_path=fname,
file_value=blocked_on,
fixable=False,
)
def _check_inbox_hygiene(api_base: str, repo_slug: str, report: ConsistencyReport) -> None:
hygiene = collect_inbox_hygiene(api_base, repo_slug)
if hygiene["stale_unread_count"]:
preview = ", ".join(
f"{m['from_agent']}:{m['id']}" for m in hygiene["stale_unread"][:5]
)
extra = "" if hygiene["stale_unread_count"] <= 5 else ", ..."
report.add(
severity="WARN",
check_id="C-28",
message=(
f"{hygiene['stale_unread_count']} unread inbox message(s) older than "
f"{STALE_UNREAD_DAYS} day(s): {preview}{extra}"
),
fixable=False,
)
if hygiene["missing_thread"]:
preview = ", ".join(
f"{m['from_agent']}:{m['id']}" for m in hygiene["missing_thread"][:5]
)
report.add(
severity="WARN",
check_id="C-28",
message=(
f"{len(hygiene['missing_thread'])} unread message(s) lack thread_id "
f"for supersession tracking: {preview}"
),
fixable=False,
)
for msg in hygiene["work_requests_unpromoted"]:
report.add(
severity="WARN",
check_id="C-29",
message=(
f"Unread work request from {msg['from_agent']} ({msg['id']}) may need "
f"a workplan file: {msg['subject'][:120]}"
),
fixable=False,
)
def _check_workplan_prefixes(
repo_dir: Path,
repo_slug: str,
workplan_infos: list[tuple[Path, dict, str]],
report: ConsistencyReport,
) -> None:
canonical = infer_wp_prefix(repo_dir, repo_slug)
for wp_file, meta, _ in workplan_infos:
if wp_file.name.startswith("ADHOC"):
continue
wp_id = str(meta.get("id", "")).strip()
if not wp_id:
continue
match = _WP_ID_PREFIX_RE.match(wp_id) or _WP_ID_BARE_RE.match(wp_id)
if not match:
continue
prefix = match.group(1).upper()
if prefix == canonical.upper():
continue
report.add(
severity="WARN",
check_id="C-26",
message=(
f"Workplan id '{wp_id}' uses prefix '{prefix}' but repo canonical "
f"prefix is '{canonical}' — new plans must conform"
),
file_path=workplan_display_path(repo_dir, wp_file),
file_value=wp_id,
db_value=canonical,
fixable=False,
)
def _scan_repo_workplan_ids(repos: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]:
id_map: dict[str, list[tuple[str, str]]] = {}
for repo in repos:
slug = repo["slug"]
path = resolve_repo_path(repo)
if not path or not Path(path).is_dir():
continue
workplans_dir = Path(path) / "workplans"
if not workplans_dir.is_dir():
continue
for wp_file in iter_workplan_files(workplans_dir):
if wp_file.name.startswith("ADHOC"):
continue
try:
text = wp_file.read_text(encoding="utf-8")
except OSError:
continue
if not text.startswith("---"):
continue
meta, _ = parse_frontmatter(text)
if not meta or meta.get("_parse_error"):
continue
wp_id = str(meta.get("id", "")).strip()
if wp_id:
id_map.setdefault(wp_id, []).append((slug, wp_file.name))
return id_map
def _check_workplan_id_collisions(
api_base: str,
repo_slug: str,
repo_dir: Path,
workplan_infos: list[tuple[Path, dict, str]],
report: ConsistencyReport,
) -> None:
repos = _api_get(api_base, "/repos") or []
if not isinstance(repos, list):
return
id_map = _scan_repo_workplan_ids(repos)
for wp_file, meta, _ in workplan_infos:
wp_id = str(meta.get("id", "")).strip()
if not wp_id:
continue
locations = id_map.get(wp_id, [])
if len(locations) <= 1:
continue
report.add(
severity="WARN",
check_id="C-27",
message=(
f"Workplan id '{wp_id}' collides across repos: "
+ ", ".join(f"{slug}/{fname}" for slug, fname in locations)
),
file_path=workplan_display_path(repo_dir, wp_file),
file_value=wp_id,
fixable=False,
)
def _scope_current_state_lines(repo_dir: Path) -> dict[str, str]:
scope_path = repo_dir / "SCOPE.md"
if not scope_path.exists():
return {}
text = scope_path.read_text(encoding="utf-8", errors="replace")
match = re.search(r"## Current State\s*\n(.*?)(?:\n## |\Z)", text, re.DOTALL)
if not match:
return {}
result: dict[str, str] = {}
for line in match.group(1).splitlines():
stripped = line.strip()
if not stripped.startswith("-"):
continue
parts = stripped[1:].split(":", 1)
if len(parts) != 2:
continue
result[parts[0].strip().lower()] = parts[1].strip()
return result
def _repo_has_open_workplans(workplan_infos: list[tuple[Path, dict, str]]) -> bool:
for wp_file, meta, _ in workplan_infos:
if wp_file.parent.name == "archived":
continue
status = normalise_workstream_status(str(meta.get("status", "")).strip())
if status in _OPEN_WORKPLAN_STATUSES:
return True
return False
def _check_scope_freshness(
repo_dir: Path,
workplan_infos: list[tuple[Path, dict, str]],
report: ConsistencyReport,
) -> None:
scope = _scope_current_state_lines(repo_dir)
if not scope:
return
has_open_workplans = _repo_has_open_workplans(workplan_infos)
scope_status = scope.get("status", "").lower()
warnings: list[str] = []
if "active" in scope_status and not has_open_workplans:
warnings.append(
"SCOPE.md Current State says active but no open workplans remain — may be stale"
)
if has_open_workplans and any(
token in scope_status for token in ("finished", "archived", "idle", "dormant")
):
warnings.append(
"SCOPE.md Current State does not reflect active workplans — may be stale"
)
implementation = scope.get("implementation", "").lower()
if has_open_workplans and implementation and "not yet started" in implementation:
warnings.append(
"SCOPE.md mentions work not yet started but active workplans exist — may be stale"
)
for warning in warnings:
report.add(
severity="WARN",
check_id="C-30",
message=warning,
file_path="SCOPE.md",
fixable=False,
)
_BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->"
_TASK_STATUS_ICON = {"done": "", "cancel": "", "progress": "", "wait": "!", "todo": "·"}
_OPEN_STATUSES = set(OPEN_TASK_STATUSES)
@ -1704,6 +2093,99 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
else:
lines += ["## Active Workstreams", "", "*(none — repo may need first-session setup)*"]
hygiene = collect_inbox_hygiene(api_base, repo_slug)
if hygiene["stale_unread_count"] or hygiene["missing_thread"] or hygiene["work_requests_unpromoted"]:
lines += ["", "## Inbox Hygiene", ""]
if hygiene["stale_unread_count"]:
lines.append(
f"**Stale unread:** {hygiene['stale_unread_count']} message(s) older than "
f"{STALE_UNREAD_DAYS} day(s) — triage at session start."
)
if hygiene["missing_thread"]:
lines.append(
f"**Missing thread_id:** {len(hygiene['missing_thread'])} unread message(s) "
"lack supersession chains."
)
for msg in hygiene["work_requests_unpromoted"][:3]:
lines.append(
f"- ! {msg['from_agent']}: {msg['subject'][:100]} `{msg['id']}`"
)
blocked_warnings: list[str] = []
workplans_dir = Path(repo_path) / "workplans"
if workplans_dir.is_dir():
for wp_file in iter_workplan_files(workplans_dir):
try:
text = wp_file.read_text(encoding="utf-8")
except OSError:
continue
if not text.startswith("---"):
continue
meta, _ = parse_frontmatter(text)
if not meta or meta.get("_parse_error"):
continue
if normalise_workstream_status(str(meta.get("status", "")).strip()) != "blocked":
continue
blocked_on = str(meta.get("blocked_on", "")).strip()
counterpart = parse_blocked_on(blocked_on)
if not counterpart:
continue
messages = _api_get(
api_base,
"/messages",
{
"to_agent": repo_slug,
"from_agent": counterpart,
"unread_only": True,
"limit": 5,
},
) or []
if isinstance(messages, list) and messages:
wp_id = str(meta.get("id", wp_file.stem)).strip()
blocked_warnings.append(
f"- ! **{wp_id}** — blocker may have cleared: "
f"{len(messages)} unread message(s) from `{counterpart}`"
)
if blocked_warnings:
lines += ["", "## Blocked Workplans", ""]
lines.extend(blocked_warnings)
scope_warnings: list[str] = []
scope = _scope_current_state_lines(Path(repo_path))
has_open_workplans = False
if workplans_dir.is_dir():
for wp_file in iter_workplan_files(workplans_dir):
try:
text = wp_file.read_text(encoding="utf-8")
except OSError:
continue
if not text.startswith("---"):
continue
meta, _ = parse_frontmatter(text)
if not meta or meta.get("_parse_error"):
continue
if wp_file.parent.name == "archived":
continue
status = normalise_workstream_status(str(meta.get("status", "")).strip())
if status in _OPEN_WORKPLAN_STATUSES:
has_open_workplans = True
break
if scope:
scope_status = scope.get("status", "").lower()
if "active" in scope_status and not has_open_workplans:
scope_warnings.append(
"SCOPE.md says active but no open workplans remain — refresh Current State."
)
if has_open_workplans and any(
token in scope_status for token in ("finished", "archived", "idle", "dormant")
):
scope_warnings.append(
"SCOPE.md Current State does not reflect active workplans — may be stale."
)
if scope_warnings:
lines += ["", "## SCOPE Freshness", ""]
lines.extend(f"- {warning}" for warning in scope_warnings)
lines += [
"",
"---",