feat(consistency): coordination hygiene checks and MCP workplan_id aliases
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:
parent
cc656a2b16
commit
9693946755
5 changed files with 819 additions and 58 deletions
|
|
@ -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 += [
|
||||
"",
|
||||
"---",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue