generated from coulomb/repo-seed
Implement weekly coding retro schedule
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user