Implement weekly coding retro schedule

This commit is contained in:
2026-06-07 20:58:34 +02:00
parent 992fe94034
commit 14b2d40eb7
6 changed files with 526 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ Supported queries:
- next_steps: GET {STATE_HUB_URL}/state/next_steps
- workplan_index: GET {STATE_HUB_URL}/workstreams/workplan-index
- hub_inbox: GET {STATE_HUB_URL}/messages/?to_agent=hub&unread_only=true
- coding_retro: latest /progress/ item with event_type=coding_retro
- daily_triage_digest: curated scalar JSON digest for daily WSJF triage
- recently_on_scope_hourly: POST {STATE_HUB_URL}/recently-on-scope/hourly
@@ -94,6 +95,8 @@ class StateHubContextResolver(ContextResolver):
"unread_only": params.get("unread_only", True),
}
return _fetch_json("/messages/", query_params)
if query == "coding_retro":
return _coding_retro(params)
if query == "daily_triage_digest":
return _daily_triage_digest(params)
if query == "recently_on_scope_hourly":
@@ -206,6 +209,181 @@ def _sbom_age_days(last_sbom_at: Any) -> tuple[int, bool, str | None]:
return max(0, delta.days), True, last_sbom_at
def _coding_retro(params: dict[str, Any]) -> dict[str, Any]:
"""Return the latest weekly coding-retro read model from State Hub progress.
Helix Forge publishes this as a `progress` item with event_type=coding_retro.
The resolver keeps the workflow-facing shape stable even before the first
publication exists, so rules can safely iterate over
`context.retro.suggestions`.
"""
event_type = str(params.get("event_type") or "coding_retro")
limit = _bounded_int(params.get("limit", 100), default=100, minimum=1, maximum=500)
items = _fetch_json("/progress/", {"limit": limit})
if not isinstance(items, list):
return _empty_coding_retro(event_type)
item = _latest_progress_item(items, event_type)
if item is None:
return _empty_coding_retro(event_type)
detail = _progress_detail(item)
return {
"suggestions": _normalise_coding_retro_suggestions(
detail.get("suggestions")
),
"window": _coding_retro_window(detail, params),
"generated_at": _string_or_none(
detail.get("generated_at") or item.get("created_at")
),
"source_progress_id": _string_or_none(item.get("id")),
"event_type": event_type,
"summary": _short_text(item.get("summary", ""), 200),
}
def _empty_coding_retro(event_type: str) -> dict[str, Any]:
return {
"suggestions": [],
"window": None,
"generated_at": None,
"source_progress_id": None,
"event_type": event_type,
"summary": "",
}
def _latest_progress_item(
items: list[Any],
event_type: str,
) -> dict[str, Any] | None:
newest: dict[str, Any] | None = None
newest_key: tuple[datetime, int] | None = None
for index, item in enumerate(items):
if not isinstance(item, dict) or item.get("event_type") != event_type:
continue
key = (_parse_progress_timestamp(item.get("created_at")), index)
if newest_key is None or key > newest_key:
newest = item
newest_key = key
return newest
def _parse_progress_timestamp(value: Any) -> datetime:
if not isinstance(value, str) or not value:
return datetime.min.replace(tzinfo=timezone.utc)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return datetime.min.replace(tzinfo=timezone.utc)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _progress_detail(item: dict[str, Any]) -> dict[str, Any]:
detail = item.get("detail")
if detail is None:
detail = item.get("details")
if isinstance(detail, str):
try:
detail = json.loads(detail)
except ValueError:
return {}
if isinstance(detail, dict):
return detail
return {}
def _normalise_coding_retro_suggestions(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
suggestions: list[dict[str, Any]] = []
for raw in value:
suggestion = _normalise_coding_retro_suggestion(raw)
if suggestion is not None:
suggestions.append(suggestion)
return suggestions
def _normalise_coding_retro_suggestion(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
repo = _clean_scalar(
raw.get("repo") or raw.get("target_repo") or raw.get("repo_slug")
)
title = _clean_scalar(raw.get("title") or raw.get("summary"))
if not repo or not title:
return None
return {
"repo": repo,
"title": title,
"recommendation": _clean_scalar(
raw.get("recommendation") or raw.get("description") or raw.get("body")
),
"priority": _normalise_coding_retro_priority(raw.get("priority")),
"score": _normalise_score(raw.get("score")),
}
def _coding_retro_window(
detail: dict[str, Any],
params: dict[str, Any],
) -> Any:
window = detail.get("window")
if window is not None:
return window
derived = {
key: detail.get(key)
for key in ("window_start", "window_end", "since", "until")
if detail.get(key) is not None
}
if derived:
return derived
if params.get("window_days") is not None:
return {
"days": _bounded_int(
params.get("window_days"),
default=7,
minimum=1,
maximum=366,
)
}
return None
def _normalise_coding_retro_priority(value: Any) -> str:
priority = str(value or "medium").strip().lower()
if priority in {"high", "medium", "low"}:
return priority
return "medium"
def _normalise_score(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _bounded_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
number = default
return max(minimum, min(maximum, number))
def _clean_scalar(value: Any) -> str:
return " ".join(str(value or "").split())
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
return str(value)
def _daily_triage_digest(params: dict[str, Any]) -> str:
"""Return a compact JSON string safe to inject into an instruction prompt.