From 3049b463199525f0a5ba4200fa94e7335e1de07a Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Sun, 10 May 2026 13:13:42 -0400 Subject: [PATCH 1/3] chore(dashboard): MVP1 progress dashboard generator + initial output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `scripts/build_mvp1_dashboard.py` (stdlib-only) that walks `docs/02_product/planned_features/` + `docs/00_overview/implemented_features/`, parses each feature's idea / spec / plan / pipeline_status artifacts, and writes a single self-contained HTML dashboard at `docs/00_overview/mvp1_dashboard.html` with: * KPI row: Features done (X / 12 scoped MVP1), Path to MVP1 (remaining items), Open bugs, Open chores. Plus a secondary line showing backlog ideas (idea-only feat_/infra_ folders not yet scoped) and in-flight count. * Kanban view: Idea / Spec / Plan / Implementing / Done columns; type- colored cards (feat = indigo, infra = amber, chore = slate, bug = red, epic = blue); filter chips by type. * Dependency DAG: Mermaid `graph LR` of scoped feat_/infra_/chore_ nodes with stage-colored classes and edges parsed from each spec's "Depends on" line. * Per-card detail: name (linked to feature_spec.md), one-liner extracted from `**Outcome:**` (spec) or `## Problem` (idea), PR# + merged date for shipped features, deferred-phase note for partial completions. Source of truth is the folder structure, not `mvp1-user-stories.md` (which stays as the human-curated narrative). Regenerate after any spec/plan/pipeline_status edit with `make dashboard`. The script strips trailing whitespace from output lines to match the project's pre-commit hook so subsequent regenerations are idempotent. Build/CI changes: * `pyproject.toml`: extend ruff per-file-ignores so `scripts/**` skips the `D` (pydocstyle) rule — same rationale as `backend/tests/`, operator tooling not library code. * `Makefile`: new `dashboard` target shows up in `make help`. Initial dashboard snapshot: 2 / 12 MVP1 features done (17%); 14 items remaining (10 features + 2 bugs + 2 chores); feat_study_lifecycle in-flight (Phase 1 done, Phase 2 deferred). Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 8 +- docs/00_overview/mvp1_dashboard.html | 681 ++++++++++++++++++++++ pyproject.toml | 2 + scripts/build_mvp1_dashboard.py | 817 +++++++++++++++++++++++++++ 4 files changed, 1507 insertions(+), 1 deletion(-) create mode 100644 docs/00_overview/mvp1_dashboard.html create mode 100755 scripts/build_mvp1_dashboard.py diff --git a/Makefile b/Makefile index 9c46369e..afd6005b 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,8 @@ .PHONY: help fmt lint typecheck test test-unit test-integration test-contract \ ui-lint ui-typecheck ui-test ui-build \ pre-commit pre-commit-install \ - up down logs reset migrate migrate-create seed-clusters + up down logs reset migrate migrate-create seed-clusters \ + dashboard help: ## Show this help message @echo "" @@ -116,3 +117,8 @@ migrate-create: ## Create new migration: make migrate-create name= (runs docker compose exec -T api alembic revision --autogenerate --rev-id "$${NEXT_REV}" -m "$(name)" @echo "" @echo "Run 'make fmt' to apply ruff formatting to the new revision file." + +# ---------- MVP1 Dashboard ---------- + +dashboard: ## Regenerate docs/00_overview/mvp1_dashboard.html from feature folders + @python3 scripts/build_mvp1_dashboard.py diff --git a/docs/00_overview/mvp1_dashboard.html b/docs/00_overview/mvp1_dashboard.html new file mode 100644 index 00000000..4cadd1c5 --- /dev/null +++ b/docs/00_overview/mvp1_dashboard.html @@ -0,0 +1,681 @@ + + + + +RelyLoop MVP1 Dashboard + + + +
+

RelyLoop MVP1 Dashboard

+
+ Generated 2026-05-10 17:13 UTC from docs/02_product/planned_features/ + + docs/00_overview/implemented_features/. + See state.md for the active branch context, + CLAUDE.md for conventions, and + mvp1-user-stories.md + for the user-story narrative. +
+
+ +
+ +
+

MVP1 Progress

+
+
+
Features done
+
2 / 12
+
17% of scoped MVP1 features
+
+
+
+
Path to MVP1
+
14
+
items left = features + bugs + chores
+
+
+
Open bugs
+
2
+
tracked bug_* idea files
+
+
+
Open chores
+
2
+
idea-stage chore_* (debt)
+
+
+
+ + Backlog ideas: + 2 idea-only feat/infra folders (not yet scoped into MVP1) + + + In flight: + 1 feature(s) actively shipping + +
+
+ +
+

Pipeline

+
+ Filter: + + + + + +
+
+
+

Idea 6

+ +
+ +
+ Infra + +
+
CI runs `make test-unit && make test-integration && make test-contract` against a service-container Postgres on `localhost:5432` — a synthetic environment that masks every real-world `make up` failure
+ + +
+ + +
+ +
+ Infra + +
+
The frontend stack landed during `infra_foundation` is already 1–2 majors behind across the board. Specifically (locked → npm latest as of 2026-05-09):
+ + +
+ + +
+ +
+ Chore + +
+
Starlette has renamed `HTTP_422_UNPROCESSABLE_ENTITY` to `HTTP_422_UNPROCESSABLE_CONTENT`. Three call sites still use the old name:
+ + +
+ + +
+ +
+ Chore + +
+
`backend/tests/integration/test_clusters_api.py` only registers an **Elasticsearch** cluster in every test:
+ + +
+ + +
+ +
+ Bug + +
+
Idea (deferred from `infra_adapter_elastic` Story 5.1)
+ + +
+ + +
+ +
+ Bug + +
+
The user's working `.env` (containing the OpenAI API key referenced by [`CLAUDE.md`](../../../CLAUDE.md) "Cross-model review policy") was renamed to `.env.old` during the agent's implementation sessio
+ + +
+ +
+ +
+

Spec 9

+ +
+ +
+ Feature + +
+
A chat surface at `/chat/{conversation_id}` streams OpenAI completions via SSE.
+ + +
+ + +
+ +
+ Feature + +
+
When a study transitions to `completed`, the digest worker generates: a narrative summary (LLM-authored), a parameter-importance map (computed by `optuna.importance`), and a recommended config.
+ +
depends on: feat_study_lifecyclefeat_llm_judgments
+
+ + +
+ +
+ Feature + +
+
`POST /api/v1/proposals/{id}/open_pr` enqueues a Git worker job that clones the configured repo, edits `*.params.json`, commits with a structured message, pushes a branch, opens a GitHub PR, attaches
+ +
depends on: infra_foundationinfra_adapter_elasticfeat_digest_proposal
+
+ + +
+ +
+ Feature + +
+
GitHub posts to `POST /webhooks/github` with HMAC-SHA256 signature; the receiver verifies the signature, looks up the proposal by `pr_url`, updates `pr_state` and `pr_merged_at`.
+ +
depends on: infra_foundationfeat_github_pr_worker
+
+ + +
+ +
+ Feature + +
+
A relevance engineer selects a query set + cluster + target + rubric and the system runs the current template to fetch top-K hits per query, asks OpenAI to rate each (query, doc) on a 0–3 scale with r
+ +
depends on: infra_foundationinfra_adapter_elasticfeat_study_lifecycle
+
+ + +
+ +
+ Feature + +
+
Two routes — `/proposals` (filterable list) and `/proposals/{id}` (config diff + metric delta + "Open PR" button + post-open PR-state mirror) — plug into the existing `feat_studies_ui` Next.js app.
+ +
depends on: feat_studies_uifeat_digest_proposalfeat_github_pr_workerfeat_github_webhook
+
+ + +
+ +
+ Feature + +
+
A Next.js app provides 9 of the 11 MVP1 routes from [`ui-architecture.md` §"Routes (MVP1)"](../../../01_architecture/ui-architecture.md): dashboard, clusters list/detail, query sets list/detail, judgm
+ +
depends on: infra_foundationfeat_study_lifecyclefeat_digest_proposalfeat_llm_judgmentsinfra_adapter_elastic
+
+ + +
+ +
+ Infra + +
+
Optuna RDB storage co-tenants with the application Postgres; TPE sampler + median pruner are the MVP1 defaults; pytrec_eval scores trials against judgment lists for nDCG@10, MAP, P@K, recall@K, MRR, a
+ + +
+ + +
+ +
+ Chore + +
+
The release tag `v0.1.0` is pushed with: a worked tutorial at `docs/08_guides/tutorial-first-study.md`, sample data (50-query set + pre-baked judgment list + sample ES index of ~1,000 docs), README po
+ + +
+ +
+ +
+

Plan 0

+ +
+ +
+

Implementing 1

+ +
+ +
+ Feature + PR #16merged 2026-05-10 +
+
A relevance engineer creates a study via API or chat, the orchestrator enqueues N parallel `run_trial` jobs, trials accumulate in real time on the study detail page, the orchestrator detects stop-cond
+
deferred: Phase 2
+ +
+ +
+ +
+

Done 2

+ +
+ +
+ Infra + PR #4merged 2026-05-10 +
+
A single `ElasticAdapter` implements the `SearchAdapter` Protocol and serves both Elasticsearch (8.11+ / 9.x) and OpenSearch (2.x / 3.x), distinguished by a `engine_type` column.
+ + +
+ + +
+ +
+ Infra + PR #4merged 2026-05-09 +
+
A relevance engineer can `git clone`, `docker compose up`, see all subsystems healthy in <60s on a 16GB laptop, and have a CI pipeline that gates every PR on lint, type-check, test, and an 80% coverag
+ + +
+ +
+
+
+ +
+

Dependency graph (feat_ + infra_)

+
+
graph LR + classDef done fill:#dcfce7,stroke:#14532d,color:#14532d; + classDef implement fill:#ffedd5,stroke:#9a3412,color:#9a3412; + classDef plan fill:#fef9c3,stroke:#854d0e,color:#854d0e; + classDef spec fill:#dbeafe,stroke:#1e40af,color:#1e40af; + classDef idea fill:#f1f5f9,stroke:#334155,color:#334155; + chore_tutorial_polish["tutorial polish"] + class chore_tutorial_polish spec; + feat_chat_agent["chat agent"] + class feat_chat_agent spec; + feat_digest_proposal["digest proposal"] + class feat_digest_proposal spec; + feat_github_pr_worker["github pr worker"] + class feat_github_pr_worker spec; + feat_github_webhook["github webhook"] + class feat_github_webhook spec; + feat_llm_judgments["llm judgments"] + class feat_llm_judgments spec; + feat_proposals_ui["proposals ui"] + class feat_proposals_ui spec; + feat_studies_ui["studies ui"] + class feat_studies_ui spec; + feat_study_lifecycle["study lifecycle"] + class feat_study_lifecycle implement; + infra_optuna_eval["optuna eval"] + class infra_optuna_eval spec; + infra_foundation["foundation"] + class infra_foundation done; + infra_adapter_elastic["adapter elastic"] + class infra_adapter_elastic done; + feat_study_lifecycle --> feat_digest_proposal + feat_llm_judgments --> feat_digest_proposal + infra_foundation --> feat_github_pr_worker + infra_adapter_elastic --> feat_github_pr_worker + feat_digest_proposal --> feat_github_pr_worker + infra_foundation --> feat_github_webhook + feat_github_pr_worker --> feat_github_webhook + infra_foundation --> feat_llm_judgments + infra_adapter_elastic --> feat_llm_judgments + feat_study_lifecycle --> feat_llm_judgments + feat_studies_ui --> feat_proposals_ui + feat_digest_proposal --> feat_proposals_ui + feat_github_pr_worker --> feat_proposals_ui + feat_github_webhook --> feat_proposals_ui + infra_foundation --> feat_studies_ui + feat_study_lifecycle --> feat_studies_ui + feat_digest_proposal --> feat_studies_ui + feat_llm_judgments --> feat_studies_ui + infra_adapter_elastic --> feat_studies_ui
+ +
+
+ +
+ +
+ Single source of truth: the feature folder structure. + Regenerate after any spec/plan/pipeline_status change with + make dashboard. +
+ + + + + diff --git a/pyproject.toml b/pyproject.toml index 3b189915..3b065103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,8 @@ ignore = [ # asserts, subprocess, fixtures-as-credentials "migrations/env.py" = ["E402", "D"] # alembic generated boilerplate "migrations/versions/*.py" = ["D", "E501"] +"scripts/**" = ["D"] # operator tooling (e.g., dashboard generator) + # — same rationale as tests: not library code [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/scripts/build_mvp1_dashboard.py b/scripts/build_mvp1_dashboard.py new file mode 100755 index 00000000..26617f02 --- /dev/null +++ b/scripts/build_mvp1_dashboard.py @@ -0,0 +1,817 @@ +#!/usr/bin/env python3 +"""Build the RelyLoop MVP1 dashboard HTML. + +Walks `docs/02_product/planned_features/` and `docs/00_overview/implemented_features/`, +parses each feature folder's pipeline artifacts (idea.md / feature_spec.md / +implementation_plan.md / pipeline_status.md), and writes a single self-contained +HTML dashboard at `docs/00_overview/mvp1_dashboard.html`. + +Source of truth is the folder structure + the feature artifacts, not +mvp1-user-stories.md (which is a curated narrative that drifts). The dashboard +shows: + +* KPI row — features done / remaining, MVP1 percentage, open bugs, open chores +* Kanban — Idea / Spec / Plan / Implement / Done columns +* Dependency graph — Mermaid render of "X depends on Y" +* Per-card detail — type badge, one-liner, status, PR number, merged date + +Run via `make dashboard` or `python3 scripts/build_mvp1_dashboard.py` from +the repo root. No third-party dependencies — stdlib only. +""" + +from __future__ import annotations + +import datetime as dt +import html +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLANNED_DIR = REPO_ROOT / "docs/02_product/planned_features" +IMPLEMENTED_DIR = REPO_ROOT / "docs/00_overview/implemented_features" +OUTPUT = REPO_ROOT / "docs/00_overview/mvp1_dashboard.html" + +# Feature directory name → human title fragment. Anything not in this map +# falls back to the folder name with the prefix stripped. +PREFIX_LABELS = { + "feat": "Feature", + "infra": "Infra", + "chore": "Chore", + "bug": "Bug", + "epic": "Epic", +} + +STAGES = ["idea", "spec", "plan", "implement", "done"] +STAGE_LABELS = { + "idea": "Idea", + "spec": "Spec", + "plan": "Plan", + "implement": "Implementing", + "done": "Done", +} + + +@dataclass +class Feature: + folder: str # full folder name (e.g., "feat_study_lifecycle") + prefix: str # "feat" | "infra" | "chore" | "bug" | "epic" + short_name: str # folder name without prefix (e.g., "study_lifecycle") + path: Path # absolute path to the folder + location: str # "planned" | "implemented" + stage: str # one of STAGES + status_line: str # one-line status from the latest stage's artifact + one_liner: str # the Outcome / Problem one-liner + depends_on: list[str] = field(default_factory=list) # folder names + pr_number: int | None = None + merged_date: str | None = None + deferred_phase: str | None = None # human note if a phase*_idea.md is present + + @property + def display_name(self) -> str: + return self.short_name.replace("_", " ").title() + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + + +def _read(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8") + + +def _split_prefix(folder: str) -> tuple[str, str]: + """Return (prefix, short_name). Folder is like 'feat_study_lifecycle'.""" + if "_" not in folder: + return ("", folder) + head, _, tail = folder.partition("_") + if head not in PREFIX_LABELS: + return ("", folder) + return (head, tail) + + +def _strip_date_prefix(folder: str) -> str: + """Implemented features are dated: '2026_05_10_infra_adapter_elastic'.""" + m = re.match(r"^\d{4}_\d{2}_\d{2}_(.+)$", folder) + return m.group(1) if m else folder + + +def _extract_status_line(text: str) -> str: + """Pull the `**Status:**` value from a markdown header block.""" + m = re.search(r"^\*\*Status:\*\*\s*(.+)$", text, flags=re.MULTILINE) + return m.group(1).strip() if m else "" + + +def _extract_one_liner(text: str) -> str: + """Best-effort: prefer Outcome bullet, fall back to Problem bullet.""" + for label in ("Outcome", "Problem"): + m = re.search( + rf"^- \*\*{label}:\*\*\s*(.+?)$", + text, + flags=re.MULTILINE, + ) + if m: + line = m.group(1).strip() + # Strip wrapping markdown links/code; keep first sentence. + sentence = re.split(r"(?<=[.!?])\s+", line, maxsplit=1)[0] + return sentence + return "" + + +def _extract_idea_problem(text: str) -> str: + """Pull the first paragraph under the `## Problem` heading.""" + m = re.search(r"^##\s+Problem\s*$", text, flags=re.MULTILINE) + if not m: + return "" + rest = text[m.end() :] + # First non-empty paragraph. + para = next((p for p in rest.split("\n\n") if p.strip()), "") + para = re.sub(r"\s+", " ", para).strip() + # Cap length. + if len(para) > 240: + para = para[:237] + "..." + return para + + +def _extract_depends_on(text: str) -> list[str]: + """Parse `- Depends on: ...` line into a list of folder names.""" + m = re.search(r"^-\s+Depends on:\s*(.+)$", text, flags=re.MULTILINE) + if not m: + return [] + line = m.group(1) + # Match `[`name`]` or bare backticked names; strip out punctuation. + folders = re.findall(r"`([a-z0-9_]+)`", line) + # Filter to folder-like patterns (must contain underscore + recognized prefix). + return [f for f in folders if any(f.startswith(p + "_") for p in PREFIX_LABELS)] + + +def _extract_pr_number(text: str) -> int | None: + """Look for the most recent `PR #N` reference.""" + matches = re.findall(r"PR\s*#(\d+)", text) + return int(matches[-1]) if matches else None + + +def _extract_merged_date(text: str) -> str | None: + """Look for `merged YYYY-MM-DD` in any artifact.""" + m = re.search(r"merged\s+(\d{4}-\d{2}-\d{2})", text) + return m.group(1) if m else None + + +def _detect_deferred_phase(folder_path: Path) -> str | None: + """A `phase*_idea.md` file flags partial-feature shipping.""" + deferred = sorted(folder_path.glob("phase*_idea.md")) + if not deferred: + return None + return ", ".join(d.stem.replace("_idea", "").replace("phase", "Phase ") for d in deferred) + + +def _detect_implement_status(text: str) -> str: + """Return 'complete' / 'in_progress' / 'not_started' from pipeline_status.md.""" + impl = re.search( + r"^##\s+Implement(?:[^\n]*)\s*$(.+?)(?=^##|\Z)", + text, + flags=re.MULTILINE | re.DOTALL, + ) + if not impl: + return "not_started" + body = impl.group(1) + if re.search(r"Status[^\n]*Complete", body): + return "complete" + if re.search(r"Status[^\n]*\bIn Progress\b", body, flags=re.IGNORECASE): + return "in_progress" + if re.search(r"Status[^\n]*Not started", body): + return "not_started" + return "not_started" + + +# --------------------------------------------------------------------------- +# Feature loaders +# --------------------------------------------------------------------------- + + +def _load_planned(folder_path: Path) -> Feature | None: + folder = folder_path.name + if folder == "feature_templates": + return None + prefix, short = _split_prefix(folder) + if not prefix: + return None + + idea = _read(folder_path / "idea.md") + spec = _read(folder_path / "feature_spec.md") + plan = _read(folder_path / "implementation_plan.md") + pipe = _read(folder_path / "pipeline_status.md") + deferred = _detect_deferred_phase(folder_path) + + # Stage derivation — most-advanced-artifact wins, but pipeline_status's + # Implement section can downgrade to "implement" stage with in-progress. + stage = "idea" + if spec: + stage = "spec" + if plan: + stage = "plan" + if pipe: + impl_status = _detect_implement_status(pipe) + if impl_status == "complete": + # Check if there's deferred phase work — if so, stay at "implement" + # (partial-completion); else "done". + stage = "implement" if deferred else "done" + elif impl_status == "in_progress": + stage = "implement" + # not_started: leave at "plan" + + # Status line — pick the most informative artifact for the current stage. + status_text = pipe or plan or spec or idea + status_line = _extract_status_line(status_text) or "" + + # One-liner. + one_liner = _extract_one_liner(spec) if spec else _extract_idea_problem(idea) + + feature = Feature( + folder=folder, + prefix=prefix, + short_name=short, + path=folder_path, + location="planned", + stage=stage, + status_line=status_line, + one_liner=one_liner, + depends_on=_extract_depends_on(spec), + pr_number=_extract_pr_number(pipe + plan + spec), + merged_date=_extract_merged_date(pipe + plan + spec), + deferred_phase=deferred, + ) + return feature + + +def _load_implemented(folder_path: Path) -> Feature | None: + folder = folder_path.name + short_with_prefix = _strip_date_prefix(folder) + prefix, short = _split_prefix(short_with_prefix) + if not prefix: + return None + + spec = _read(folder_path / "feature_spec.md") + plan = _read(folder_path / "implementation_plan.md") + pipe = _read(folder_path / "pipeline_status.md") + + one_liner = _extract_one_liner(spec) + pr = _extract_pr_number(pipe + plan + spec) + merged = _extract_merged_date(pipe + plan + spec) + + # Date prefix from the folder is the canonical merged date. + m = re.match(r"^(\d{4})_(\d{2})_(\d{2})_", folder) + if m and not merged: + merged = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" + + return Feature( + folder=short_with_prefix, + prefix=prefix, + short_name=short, + path=folder_path, + location="implemented", + stage="done", + status_line=_extract_status_line(pipe + plan + spec) or "Complete", + one_liner=one_liner, + depends_on=_extract_depends_on(spec), + pr_number=pr, + merged_date=merged, + ) + + +def load_all() -> list[Feature]: + features: list[Feature] = [] + if PLANNED_DIR.exists(): + for child in sorted(PLANNED_DIR.iterdir()): + if not child.is_dir(): + continue + f = _load_planned(child) + if f: + features.append(f) + if IMPLEMENTED_DIR.exists(): + for child in sorted(IMPLEMENTED_DIR.iterdir()): + if not child.is_dir(): + continue + f = _load_implemented(child) + if f: + features.append(f) + return features + + +# --------------------------------------------------------------------------- +# HTML rendering +# --------------------------------------------------------------------------- + + +CSS = """ +* { box-sizing: border-box; } +body { + margin: 0; + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f6f7fb; + color: #1f2530; +} +header { + padding: 24px 32px 16px; + border-bottom: 1px solid #e2e6ee; + background: #fff; + position: sticky; + top: 0; + z-index: 5; +} +header h1 { + margin: 0; + font-size: 22px; + font-weight: 600; + letter-spacing: -0.01em; +} +header .meta { + margin-top: 4px; + color: #5b6477; + font-size: 13px; +} +main { padding: 24px 32px 64px; max-width: 1600px; margin: 0 auto; } +section { margin-bottom: 32px; } +section > h2 { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #6b7385; +} + +/* KPI row */ +.kpi-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +.kpi { + background: #fff; + border: 1px solid #e2e6ee; + border-radius: 10px; + padding: 16px 18px; +} +.kpi .label { color: #5b6477; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; } +.kpi .value { font-size: 28px; font-weight: 700; margin-top: 4px; } +.kpi .sub { color: #6b7385; font-size: 12px; margin-top: 2px; } +.kpi.complete .value { color: #15803d; } +.kpi.warn .value { color: #b45309; } +.kpi.bug .value { color: #b91c1c; } +.kpi-secondary { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 24px; + font-size: 12px; + color: #5b6477; +} + +/* Progress bar */ +.bar { + margin-top: 8px; + height: 6px; + background: #eaecf2; + border-radius: 3px; + overflow: hidden; +} +.bar > span { + display: block; + height: 100%; + background: linear-gradient(90deg, #4f46e5, #14b8a6); +} + +/* Kanban */ +.kanban { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} +.col { + background: #fff; + border: 1px solid #e2e6ee; + border-radius: 10px; + padding: 12px; + min-height: 200px; +} +.col h3 { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; +} +.col h3 .count { + font-weight: 500; + color: #6b7385; + background: #eaecf2; + border-radius: 12px; + padding: 2px 8px; + font-size: 11px; +} +.col.idea h3 { color: #475569; } +.col.spec h3 { color: #1d4ed8; } +.col.plan h3 { color: #b45309; } +.col.implement h3 { color: #c2410c; } +.col.done h3 { color: #15803d; } +.col.idea { border-top: 3px solid #94a3b8; } +.col.spec { border-top: 3px solid #3b82f6; } +.col.plan { border-top: 3px solid #eab308; } +.col.implement { border-top: 3px solid #f97316; } +.col.done { border-top: 3px solid #22c55e; } + +/* Cards */ +.card { + background: #fbfcfe; + border: 1px solid #e2e6ee; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 8px; + font-size: 13px; + position: relative; +} +.card .name { + font-weight: 600; + margin-bottom: 4px; + word-break: break-word; +} +.card .name a { color: inherit; text-decoration: none; } +.card .name a:hover { text-decoration: underline; } +.card .one-liner { + color: #4b5360; + font-size: 12px; + margin-bottom: 6px; +} +.card .meta { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + align-items: center; + font-size: 11px; + color: #6b7385; +} +.card .meta .badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.card .deps { font-size: 11px; color: #6b7385; margin-top: 4px; } +.card .deps .dep-chip { + display: inline-block; + padding: 1px 6px; + margin-right: 3px; + margin-top: 2px; + border-radius: 10px; + background: #eef2f7; + color: #475569; + font-size: 10px; +} + +/* Type accents on the left edge */ +.card.feat { border-left: 3px solid #6366f1; } +.card.infra { border-left: 3px solid #f59e0b; } +.card.chore { border-left: 3px solid #64748b; } +.card.bug { border-left: 3px solid #ef4444; } +.card.epic { border-left: 3px solid #0ea5e9; } +.badge.feat { background: #eef2ff; color: #4338ca; } +.badge.infra { background: #fef3c7; color: #92400e; } +.badge.chore { background: #f1f5f9; color: #475569; } +.badge.bug { background: #fee2e2; color: #991b1b; } +.badge.epic { background: #e0f2fe; color: #075985; } + +.card .pr { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: #dcfce7; + color: #14532d; + font-weight: 600; +} +.card .deferred { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: #fef3c7; + color: #92400e; + font-size: 10px; + font-weight: 600; + margin-top: 4px; +} + +/* Filters */ +.filters { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 16px; +} +.filters span.label { color: #6b7385; font-size: 12px; margin-right: 6px; } +.filters button { + font: inherit; + background: #fff; + border: 1px solid #d4d9e3; + border-radius: 16px; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + color: #475569; +} +.filters button.active { background: #1f2530; color: #fff; border-color: #1f2530; } +.filters button:hover { border-color: #1f2530; } + +/* Dependency graph */ +.mermaid-wrap { + background: #fff; + border: 1px solid #e2e6ee; + border-radius: 10px; + padding: 16px; + overflow-x: auto; +} +.mermaid-wrap pre { margin: 0; font-size: 12px; } + +footer { + text-align: center; + color: #6b7385; + font-size: 12px; + padding: 16px 0 32px; +} + +/* Hidden via filter */ +.card[data-hidden="1"] { display: none; } +""" + + +def _classify_kpi(features: list[Feature]) -> dict[str, int]: + """Distinguish *scoped* MVP1 work (anything past the idea stage in + feat_/infra_/chore_/epic_) from idea-only backlog items and from open + bugs. CLAUDE.md's canonical MVP1 list has 12 entries — 8 features + + 3 infra + 1 chore (`chore_tutorial_polish` is a release-readiness gate, + not a debt item) — so scoped chores count toward the same bucket. + """ + kpi = { + "scoped_features": 0, # feat_/infra_/chore_/epic_ with at least a spec + "done_features": 0, + "open_bugs": 0, + "open_chores_idea": 0, # idea-only chore_* (debt, not MVP1 scope) + "backlog_ideas": 0, # idea-only feat_/infra_ (not yet scoped) + "remaining": 0, # scoped not-done + open bugs + open chores-idea + } + for f in features: + if f.prefix in ("feat", "infra", "chore", "epic"): + if f.stage == "idea": + if f.prefix == "chore": + kpi["open_chores_idea"] += 1 + else: + kpi["backlog_ideas"] += 1 + continue + kpi["scoped_features"] += 1 + if f.stage == "done": + kpi["done_features"] += 1 + elif f.prefix == "bug": + if f.stage != "done": + kpi["open_bugs"] += 1 + kpi["remaining"] = ( + (kpi["scoped_features"] - kpi["done_features"]) + kpi["open_bugs"] + kpi["open_chores_idea"] + ) + return kpi + + +def _card_html(f: Feature) -> str: + spec_path = f.path / "feature_spec.md" + target = ( + spec_path.relative_to(REPO_ROOT) if spec_path.exists() else f.path.relative_to(REPO_ROOT) + ) + href = f"../../{target}" + + deps_html = "" + if f.depends_on: + chips = "".join(f'{html.escape(d)}' for d in f.depends_on) + deps_html = f'
depends on: {chips}
' + + pr_html = "" + if f.pr_number: + pr_link = f"https://github.com/SoundMindsAI/relyloop/pull/{f.pr_number}" + pr_html = f'PR #{f.pr_number}' + + merged_html = "" + if f.merged_date: + merged_html = f"merged {html.escape(f.merged_date)}" + + deferred_html = "" + if f.deferred_phase: + deferred_html = f'
deferred: {html.escape(f.deferred_phase)}
' + + one_liner = (f.one_liner or f.status_line)[:200] + return f""" +
+ +
+ {html.escape(PREFIX_LABELS.get(f.prefix, f.prefix))} + {pr_html}{merged_html} +
+
{html.escape(one_liner)}
+ {deferred_html} + {deps_html} +
+""" + + +def _column_html(stage: str, features: list[Feature]) -> str: + cards = "\n".join(_card_html(f) for f in features) + return f""" +
+

{STAGE_LABELS[stage]} {len(features)}

+ {cards} +
+""" + + +def _mermaid_graph(features: list[Feature]) -> str: + """Generate a Mermaid graph showing the dependency DAG.""" + lines = ["graph LR"] + # Style classes per stage. + lines.append(" classDef done fill:#dcfce7,stroke:#14532d,color:#14532d;") + lines.append(" classDef implement fill:#ffedd5,stroke:#9a3412,color:#9a3412;") + lines.append(" classDef plan fill:#fef9c3,stroke:#854d0e,color:#854d0e;") + lines.append(" classDef spec fill:#dbeafe,stroke:#1e40af,color:#1e40af;") + lines.append(" classDef idea fill:#f1f5f9,stroke:#334155,color:#334155;") + + # Show every scoped MVP1 node (feat_/infra_ + scoped chore_/epic_); + # idea-only debt items aren't part of the dependency DAG. + scoped = { + f.folder + for f in features + if f.prefix in ("feat", "infra", "chore", "epic") and f.stage != "idea" + } + for f in features: + if f.folder not in scoped: + continue + node_id = f.folder + label = f.short_name.replace("_", " ") + lines.append(f' {node_id}["{label}"]') + lines.append(f" class {node_id} {f.stage};") + + for f in features: + if f.folder not in scoped: + continue + for dep in f.depends_on: + if dep not in scoped: + continue + lines.append(f" {dep} --> {f.folder}") + + return "\n".join(lines) + + +def render(features: list[Feature]) -> str: + kpi = _classify_kpi(features) + pct = ( + round(kpi["done_features"] * 100 / kpi["scoped_features"]) if kpi["scoped_features"] else 0 + ) + by_stage: dict[str, list[Feature]] = {s: [] for s in STAGES} + for f in features: + by_stage[f.stage].append(f) + + # Inside each stage, prioritize feat/infra over chore/bug for visual scan. + type_order = {"feat": 0, "infra": 1, "epic": 2, "chore": 3, "bug": 4} + for s in STAGES: + by_stage[s].sort(key=lambda f: (type_order.get(f.prefix, 99), f.short_name)) + + columns = "".join(_column_html(s, by_stage[s]) for s in STAGES) + mermaid = _mermaid_graph(features) + now = dt.datetime.now(tz=dt.UTC).strftime("%Y-%m-%d %H:%M UTC") + + return f""" + + + +RelyLoop MVP1 Dashboard + + + +
+

RelyLoop MVP1 Dashboard

+
+ Generated {now} from docs/02_product/planned_features/ + + docs/00_overview/implemented_features/. + See state.md for the active branch context, + CLAUDE.md for conventions, and + mvp1-user-stories.md + for the user-story narrative. +
+
+ +
+ +
+

MVP1 Progress

+
+
+
Features done
+
{kpi["done_features"]} / {kpi["scoped_features"]}
+
{pct}% of scoped MVP1 features
+
+
+
+
Path to MVP1
+
{kpi["remaining"]}
+
items left = features + bugs + chores
+
+
+
Open bugs
+
{kpi["open_bugs"]}
+
tracked bug_* idea files
+
+
+
Open chores
+
{kpi["open_chores_idea"]}
+
idea-stage chore_* (debt)
+
+
+
+ + Backlog ideas: + {kpi["backlog_ideas"]} idea-only feat/infra folders (not yet scoped into MVP1) + + + In flight: + {len(by_stage["implement"])} feature(s) actively shipping + +
+
+ +
+

Pipeline

+
+ Filter: + + + + + +
+
{columns}
+
+ +
+

Dependency graph (feat_ + infra_)

+
+
{html.escape(mermaid)}
+ +
+
+ +
+ +
+ Single source of truth: the feature folder structure. + Regenerate after any spec/plan/pipeline_status change with + make dashboard. +
+ + + + + +""" + + +def _strip_trailing_ws(text: str) -> str: + """Match pre-commit's trailing-whitespace hook so re-runs are idempotent.""" + return "\n".join(line.rstrip() for line in text.splitlines()) + "\n" + + +def main() -> int: + features = load_all() + output_html = _strip_trailing_ws(render(features)) + OUTPUT.write_text(output_html, encoding="utf-8") + print(f"wrote {OUTPUT} ({len(features)} features)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 96e2d08a4c2df20f6c2266f55aaa5156c4fbd0d8 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Sun, 10 May 2026 13:24:48 -0400 Subject: [PATCH 2/3] chore(dashboard): pre-commit hook + idempotent timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `mvp1-dashboard-regen` pre-commit hook that runs `scripts/build_mvp1_dashboard.py` whenever a file under `docs/02_product/planned_features/`, `docs/00_overview/implemented_features/`, or the generator script itself is staged. Standard pre-commit "files were modified" abort UX — same as ruff-format / prettier. Why pre-commit instead of a literal git post-merge hook: a post-merge hook would regenerate the dashboard locally after `git pull` and require a direct commit to main to land it, violating CLAUDE.md Absolute Rule #1. The pre-commit hook makes dashboard freshness a *PR-merge invariant* (every PR that touches features ships the regenerated dashboard alongside the change), which is the right shape for this project's "feature branch + PR" workflow. Idempotency fix: the dashboard's "as-of" stamp now uses the most-recent mtime of any feature-folder .md file (or the generator script itself) instead of `datetime.now()`. Prevents per-commit timestamp churn that would otherwise create spurious dashboard diffs on every unrelated PR. Verified by running `make dashboard` twice in a row → byte-identical output. Header text reworded from "Generated …" to "Reflects feature-folder state as of …" so the semantic is clear: this is a snapshot of folder state, not a transient build artifact. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pre-commit-config.yaml | 19 +++++++++++++++++++ docs/00_overview/mvp1_dashboard.html | 5 +++-- scripts/build_mvp1_dashboard.py | 28 +++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2cef7694..6aebef58 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,6 +74,25 @@ repos: files: ^ui/src/.*\.(ts|tsx|js|jsx)$ pass_filenames: false + # --------------------------------------------------------------------------- + # MVP1 dashboard — regenerate when feature folders change so the committed + # dashboard always matches the folder state. Mirrors the impl-execute Step 8 + # finalization workflow but runs on every commit instead of only at merge time. + # + # Standard pre-commit pattern: if the script modifies mvp1_dashboard.html, + # the hook reports "files were modified" and aborts the commit, forcing the + # contributor to `git add docs/00_overview/mvp1_dashboard.html && git commit`. + # Same UX as ruff-format / prettier when those reformat staged files. + # --------------------------------------------------------------------------- + - repo: local + hooks: + - id: mvp1-dashboard-regen + name: Regenerate MVP1 dashboard if feature folders changed + entry: python3 scripts/build_mvp1_dashboard.py + language: system + files: ^(docs/02_product/planned_features/|docs/00_overview/implemented_features/|scripts/build_mvp1_dashboard\.py$) + pass_filenames: false + # --------------------------------------------------------------------------- # Secret scanning — gitleaks # --------------------------------------------------------------------------- diff --git a/docs/00_overview/mvp1_dashboard.html b/docs/00_overview/mvp1_dashboard.html index 4cadd1c5..bba6b596 100644 --- a/docs/00_overview/mvp1_dashboard.html +++ b/docs/00_overview/mvp1_dashboard.html @@ -250,8 +250,9 @@

RelyLoop MVP1 Dashboard

- Generated 2026-05-10 17:13 UTC from docs/02_product/planned_features/ + - docs/00_overview/implemented_features/. + Reflects feature-folder state as of 2026-05-10 (latest mtime of any + docs/02_product/planned_features/ or + docs/00_overview/implemented_features/ file). See state.md for the active branch context, CLAUDE.md for conventions, and mvp1-user-stories.md diff --git a/scripts/build_mvp1_dashboard.py b/scripts/build_mvp1_dashboard.py index 26617f02..bccf7174 100755 --- a/scripts/build_mvp1_dashboard.py +++ b/scripts/build_mvp1_dashboard.py @@ -283,6 +283,24 @@ def _load_implemented(folder_path: Path) -> Feature | None: ) +def _data_freshness() -> dt.datetime: + """Return the most-recent mtime across feature folders + this script. + + Determines the "data as-of" date displayed on the dashboard. Using + mtime instead of `datetime.now()` keeps regeneration idempotent so + the pre-commit hook doesn't produce churn on every unrelated commit. + The script itself is included so layout/parser changes shift the + timestamp. + """ + candidates: list[float] = [Path(__file__).stat().st_mtime] + for root in (PLANNED_DIR, IMPLEMENTED_DIR): + if not root.exists(): + continue + for path in root.rglob("*.md"): + candidates.append(path.stat().st_mtime) + return dt.datetime.fromtimestamp(max(candidates), tz=dt.UTC) + + def load_all() -> list[Feature]: features: list[Feature] = [] if PLANNED_DIR.exists(): @@ -688,7 +706,10 @@ def render(features: list[Feature]) -> str: columns = "".join(_column_html(s, by_stage[s]) for s in STAGES) mermaid = _mermaid_graph(features) - now = dt.datetime.now(tz=dt.UTC).strftime("%Y-%m-%d %H:%M UTC") + # Use the most-recent mtime of any feature-folder file (or this script + # itself) instead of `now()` — keeps regeneration idempotent so the + # pre-commit hook doesn't churn the dashboard on every unrelated commit. + now = _data_freshness().strftime("%Y-%m-%d") return f""" @@ -701,8 +722,9 @@ def render(features: list[Feature]) -> str:

RelyLoop MVP1 Dashboard

- Generated {now} from docs/02_product/planned_features/ + - docs/00_overview/implemented_features/. + Reflects feature-folder state as of {now} (latest mtime of any + docs/02_product/planned_features/ or + docs/00_overview/implemented_features/ file). See state.md for the active branch context, CLAUDE.md for conventions, and mvp1-user-stories.md From efef58dd00aa2800d521bd6de72c8c8b405d7dd0 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Sun, 10 May 2026 13:36:48 -0400 Subject: [PATCH 3/3] chore(dashboard): emit GitHub-native Markdown alongside the HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reason: HTML files don't render inline in GitHub's web UI (the .html shows as raw source when clicked from the repo browser). The user asked for a view they can read on github.com directly, without enabling Pages or going through htmlpreview.github.io. This commit makes `make dashboard` emit two files: * `docs/00_overview/mvp1_dashboard.html` — rich local view (filter chips, type-color cards, sticky header). Open locally in a browser. * `docs/00_overview/MVP1_DASHBOARD.md` — GitHub-native view (KPI table, stage-per-section tables, Mermaid graph). Renders inline when browsing github.com. GitHub markdown supports Mermaid blocks since Feb 2022, so the dependency DAG renders too. The Markdown intentionally trades visual richness for native rendering: no filter chips (no JS), no per-cell colors (no CSS), but the information architecture is identical and the cross-link to the HTML view is one click away. Two PR-extraction bugs caught during the rewrite: 1. `_extract_pr_number()` was returning `matches[-1]` (last `PR #N` reference), which picked up dependency cites in implementation_plan.md instead of the feature's own PR (e.g., `infra_adapter_elastic` was showing PR #4 because the plan cites `infra_foundation`'s PR #4 as a dep). Fix: prioritize the `## Implement` section of pipeline_status.md, then the plan's `**Status:**` header, then a `merged`-context match across all sources, then first-match. Also broadened the regex to accept `PR: [#N]` markdown-link format (which was failing the `PR\s*#` shape). 2. Markdown relative links were emitting `../docs/02_product/...` when they should have been `../02_product/...` (the markdown lives at `docs/00_overview/`, so `..` already lands at `docs/`). Fix: use `os.path.relpath(target, OUTPUT_MD.parent)` instead of constructing the path manually. Verified: both files idempotent across consecutive `make dashboard` runs; PR numbers match git log + CLAUDE.md (#4 / #16 / #18); planned- features links resolve correctly from `docs/00_overview/`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/00_overview/MVP1_DASHBOARD.md | 118 +++++++++++++++ docs/00_overview/mvp1_dashboard.html | 4 +- scripts/build_mvp1_dashboard.py | 209 +++++++++++++++++++++++++-- 3 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 docs/00_overview/MVP1_DASHBOARD.md diff --git a/docs/00_overview/MVP1_DASHBOARD.md b/docs/00_overview/MVP1_DASHBOARD.md new file mode 100644 index 00000000..45d05095 --- /dev/null +++ b/docs/00_overview/MVP1_DASHBOARD.md @@ -0,0 +1,118 @@ +# RelyLoop MVP1 Dashboard + +_Reflects feature-folder state as of **2026-05-10** (latest mtime of any planned/implemented feature `.md` file). Regenerated by `make dashboard` and the `mvp1-dashboard-regen` pre-commit hook. For the rich local view (filter chips, type colors), open [`mvp1_dashboard.html`](mvp1_dashboard.html) in a browser._ + +## MVP1 Progress + +| Metric | Value | +|---|---| +| Features done | **2 / 12** (17%) | +| Path to MVP1 | **14** items remaining (features + bugs + chores) | +| Open bugs | 2 | +| Open chores | 2 (idea-stage debt) | +| Backlog ideas | 2 idea-only feat/infra (not yet scoped into MVP1) | +| In flight | 1 feature(s) actively shipping | + +## Pipeline + +### Done (2) + +| Feature | Type | One-liner | Depends on | Status | +|---|---|---|---|---| +| [infra_adapter_elastic](implemented_features/2026_05_10_infra_adapter_elastic/feature_spec.md) | Infra | A single `ElasticAdapter` implements the `SearchAdapter` Protocol and serves both Elasticsearch (8.11+ / 9.x) and OpenSearch (2.x / 3.x), distinguished by a `engine_type` column. | — | [PR #16](https://github.com/SoundMindsAI/relyloop/pull/16) merged 2026-05-10 | +| [infra_foundation](implemented_features/2026_05_09_infra_foundation/feature_spec.md) | Infra | A relevance engineer can `git clone`, `docker compose up`, see all subsystems healthy in <60s on a 16GB laptop, and have a CI pipeline that gates every PR on lint, type-check, test, and an 80% coverag | — | [PR #4](https://github.com/SoundMindsAI/relyloop/pull/4) merged 2026-05-09 | + +### Implementing (1) + +| Feature | Type | One-liner | Depends on | Status | +|---|---|---|---|---| +| [feat_study_lifecycle](../02_product/planned_features/feat_study_lifecycle/feature_spec.md) | Feature | A relevance engineer creates a study via API or chat, the orchestrator enqueues N parallel `run_trial` jobs, trials accumulate in real time on the study detail page, the orchestrator detects stop-cond | — | [PR #18](https://github.com/SoundMindsAI/relyloop/pull/18) merged 2026-05-10 | + +### Plan (0) + +_None._ + +### Spec (9) + +| Feature | Type | One-liner | Depends on | Status | +|---|---|---|---|---| +| [feat_chat_agent](../02_product/planned_features/feat_chat_agent/feature_spec.md) | Feature | A chat surface at `/chat/{conversation_id}` streams OpenAI completions via SSE. | — | Draft | +| [feat_digest_proposal](../02_product/planned_features/feat_digest_proposal/feature_spec.md) | Feature | When a study transitions to `completed`, the digest worker generates: a narrative summary (LLM-authored), a parameter-importance map (computed by `optuna.importance`), and a recommended config. | `feat_study_lifecycle` `feat_llm_judgments` | Draft | +| [feat_github_pr_worker](../02_product/planned_features/feat_github_pr_worker/feature_spec.md) | Feature | `POST /api/v1/proposals/{id}/open_pr` enqueues a Git worker job that clones the configured repo, edits `*.params.json`, commits with a structured message, pushes a branch, opens a GitHub PR, attaches | `infra_foundation` `infra_adapter_elastic` `feat_digest_proposal` | Draft | +| [feat_github_webhook](../02_product/planned_features/feat_github_webhook/feature_spec.md) | Feature | GitHub posts to `POST /webhooks/github` with HMAC-SHA256 signature; the receiver verifies the signature, looks up the proposal by `pr_url`, updates `pr_state` and `pr_merged_at`. | `infra_foundation` `feat_github_pr_worker` | Draft | +| [feat_llm_judgments](../02_product/planned_features/feat_llm_judgments/feature_spec.md) | Feature | A relevance engineer selects a query set + cluster + target + rubric and the system runs the current template to fetch top-K hits per query, asks OpenAI to rate each (query, doc) on a 0–3 scale with r | `infra_foundation` `infra_adapter_elastic` `feat_study_lifecycle` | Draft | +| [feat_proposals_ui](../02_product/planned_features/feat_proposals_ui/feature_spec.md) | Feature | Two routes — `/proposals` (filterable list) and `/proposals/{id}` (config diff + metric delta + "Open PR" button + post-open PR-state mirror) — plug into the existing `feat_studies_ui` Next.js app. | `feat_studies_ui` `feat_digest_proposal` `feat_github_pr_worker` `feat_github_webhook` | Draft | +| [feat_studies_ui](../02_product/planned_features/feat_studies_ui/feature_spec.md) | Feature | A Next.js app provides 9 of the 11 MVP1 routes from [`ui-architecture.md` §"Routes (MVP1)"](../../../01_architecture/ui-architecture.md): dashboard, clusters list/detail, query sets list/detail, judgm | `infra_foundation` `feat_study_lifecycle` `feat_digest_proposal` `feat_llm_judgments` `infra_adapter_elastic` | Draft | +| [infra_optuna_eval](../02_product/planned_features/infra_optuna_eval/feature_spec.md) | Infra | Optuna RDB storage co-tenants with the application Postgres; TPE sampler + median pruner are the MVP1 defaults; pytrec_eval scores trials against judgment lists for nDCG@10, MAP, P@K, recall@K, MRR, a | — | Draft | +| [chore_tutorial_polish](../02_product/planned_features/chore_tutorial_polish/feature_spec.md) | Chore | The release tag `v0.1.0` is pushed with: a worked tutorial at `docs/08_guides/tutorial-first-study.md`, sample data (50-query set + pre-baked judgment list + sample ES index of ~1,000 docs), README po | — | Draft | + +### Idea (6) + +| Feature | Type | One-liner | Depends on | Status | +|---|---|---|---|---| +| [infra_ci_smoke_makeup](../02_product/planned_features/infra_ci_smoke_makeup/idea.md) | Infra | CI runs `make test-unit && make test-integration && make test-contract` against a service-container Postgres on `localhost:5432` — a synthetic environment that masks every real-world `make up` failure | — | Idea — captured during `infra_foundation` PR #4 first-run testing | +| [infra_frontend_stack_refresh](../02_product/planned_features/infra_frontend_stack_refresh/idea.md) | Infra | The frontend stack landed during `infra_foundation` is already 1–2 majors behind across the board. Specifically (locked → npm latest as of 2026-05-09): | — | Idea — surfaced during dependency audit on `feature/infra-foundation` | +| [chore_starlette_422_deprecation](../02_product/planned_features/chore_starlette_422_deprecation/idea.md) | Chore | Starlette has renamed `HTTP_422_UNPROCESSABLE_ENTITY` to `HTTP_422_UNPROCESSABLE_CONTENT`. Three call sites still use the old name: | — | Idea — captured during `infra_foundation` Story 5.1 test backfill | +| [chore_test_both_engines](../02_product/planned_features/chore_test_both_engines/idea.md) | Chore | `backend/tests/integration/test_clusters_api.py` only registers an **Elasticsearch** cluster in every test: | — | Idea (deferred from `infra_adapter_elastic` — refactor sweep, 2026-05-09) | +| [bug_capability_check_test_isolation](../02_product/planned_features/bug_capability_check_test_isolation/idea.md) | Bug | Idea (deferred from `infra_adapter_elastic` Story 5.1) | — | Idea (deferred from `infra_adapter_elastic` Story 5.1) | +| [bug_env_file_corrupted_during_session](../02_product/planned_features/bug_env_file_corrupted_during_session/idea.md) | Bug | The user's working `.env` (containing the OpenAI API key referenced by [`CLAUDE.md`](../../../CLAUDE.md) "Cross-model review policy") was renamed to `.env.old` during the agent's implementation sessio | — | Idea — captured during `infra_foundation` Story 4.4 implementation | + +## Dependency graph + +Scoped feat/infra/chore nodes only. Idea-stage debt is omitted. + +```mermaid +graph LR + classDef done fill:#dcfce7,stroke:#14532d,color:#14532d; + classDef implement fill:#ffedd5,stroke:#9a3412,color:#9a3412; + classDef plan fill:#fef9c3,stroke:#854d0e,color:#854d0e; + classDef spec fill:#dbeafe,stroke:#1e40af,color:#1e40af; + classDef idea fill:#f1f5f9,stroke:#334155,color:#334155; + chore_tutorial_polish["tutorial polish"] + class chore_tutorial_polish spec; + feat_chat_agent["chat agent"] + class feat_chat_agent spec; + feat_digest_proposal["digest proposal"] + class feat_digest_proposal spec; + feat_github_pr_worker["github pr worker"] + class feat_github_pr_worker spec; + feat_github_webhook["github webhook"] + class feat_github_webhook spec; + feat_llm_judgments["llm judgments"] + class feat_llm_judgments spec; + feat_proposals_ui["proposals ui"] + class feat_proposals_ui spec; + feat_studies_ui["studies ui"] + class feat_studies_ui spec; + feat_study_lifecycle["study lifecycle"] + class feat_study_lifecycle implement; + infra_optuna_eval["optuna eval"] + class infra_optuna_eval spec; + infra_foundation["foundation"] + class infra_foundation done; + infra_adapter_elastic["adapter elastic"] + class infra_adapter_elastic done; + feat_study_lifecycle --> feat_digest_proposal + feat_llm_judgments --> feat_digest_proposal + infra_foundation --> feat_github_pr_worker + infra_adapter_elastic --> feat_github_pr_worker + feat_digest_proposal --> feat_github_pr_worker + infra_foundation --> feat_github_webhook + feat_github_pr_worker --> feat_github_webhook + infra_foundation --> feat_llm_judgments + infra_adapter_elastic --> feat_llm_judgments + feat_study_lifecycle --> feat_llm_judgments + feat_studies_ui --> feat_proposals_ui + feat_digest_proposal --> feat_proposals_ui + feat_github_pr_worker --> feat_proposals_ui + feat_github_webhook --> feat_proposals_ui + infra_foundation --> feat_studies_ui + feat_study_lifecycle --> feat_studies_ui + feat_digest_proposal --> feat_studies_ui + feat_llm_judgments --> feat_studies_ui + infra_adapter_elastic --> feat_studies_ui +``` + +--- + +Source of truth: feature folders under [`docs/02_product/planned_features/`](../02_product/planned_features/) and [`docs/00_overview/implemented_features/`](implemented_features/). See [`state.md`](../../state.md) for active-branch context and [`CLAUDE.md`](../../CLAUDE.md) for conventions. diff --git a/docs/00_overview/mvp1_dashboard.html b/docs/00_overview/mvp1_dashboard.html index bba6b596..d0bf5f84 100644 --- a/docs/00_overview/mvp1_dashboard.html +++ b/docs/00_overview/mvp1_dashboard.html @@ -510,7 +510,7 @@

Implementing 1

Feature - PR #16merged 2026-05-10 + PR #18merged 2026-05-10
A relevance engineer creates a study via API or chat, the orchestrator enqueues N parallel `run_trial` jobs, trials accumulate in real time on the study detail page, the orchestrator detects stop-cond
deferred: Phase 2
@@ -526,7 +526,7 @@

Done 2

Infra - PR #4merged 2026-05-10 + PR #16merged 2026-05-10
A single `ElasticAdapter` implements the `SearchAdapter` Protocol and serves both Elasticsearch (8.11+ / 9.x) and OpenSearch (2.x / 3.x), distinguished by a `engine_type` column.
diff --git a/scripts/build_mvp1_dashboard.py b/scripts/build_mvp1_dashboard.py index bccf7174..6ee307cf 100755 --- a/scripts/build_mvp1_dashboard.py +++ b/scripts/build_mvp1_dashboard.py @@ -23,6 +23,7 @@ import datetime as dt import html +import os import re import sys from dataclasses import dataclass, field @@ -31,7 +32,8 @@ REPO_ROOT = Path(__file__).resolve().parent.parent PLANNED_DIR = REPO_ROOT / "docs/02_product/planned_features" IMPLEMENTED_DIR = REPO_ROOT / "docs/00_overview/implemented_features" -OUTPUT = REPO_ROOT / "docs/00_overview/mvp1_dashboard.html" +OUTPUT_HTML = REPO_ROOT / "docs/00_overview/mvp1_dashboard.html" +OUTPUT_MD = REPO_ROOT / "docs/00_overview/MVP1_DASHBOARD.md" # Feature directory name → human title fragment. Anything not in this map # falls back to the folder name with the prefix stripped. @@ -149,10 +151,43 @@ def _extract_depends_on(text: str) -> list[str]: return [f for f in folders if any(f.startswith(p + "_") for p in PREFIX_LABELS)] -def _extract_pr_number(text: str) -> int | None: - """Look for the most recent `PR #N` reference.""" - matches = re.findall(r"PR\s*#(\d+)", text) - return int(matches[-1]) if matches else None +def _extract_pr_number(pipe: str, plan: str, spec: str) -> int | None: + """Find this feature's PR number, not dependency cites. + + Priority order: + 1. The `## Implement` section of pipeline_status.md — most authoritative + for shipped features. Accepts both `PR #N` and `[#N]` markdown-link + formats. + 2. The plan's `**Status:**` header (catches in-flight features). + 3. A `merged`-context match across all artifacts (catches features + described in narrative form elsewhere). + 4. First `#N` reference, as a last-resort fallback. + """ + # 1. Scope to pipeline_status.md's Implement section first. + impl = re.search( + r"^##\s+Implement[^\n]*\n(.+?)(?=^##|\Z)", + pipe, + flags=re.MULTILINE | re.DOTALL, + ) + if impl: + m = re.search(r"#(\d+)", impl.group(1)) + if m: + return int(m.group(1)) + # 2. Plan's Status header. + m = re.search(r"^\*\*Status:\*\*[^\n]*PR\s*#(\d+)", plan, flags=re.MULTILINE) + if m: + return int(m.group(1)) + # 3. Merged-context across all sources. + combined = pipe + "\n" + plan + "\n" + spec + m = re.search(r"PR[^a-zA-Z\n]{0,5}#(\d+)[^.\n]{0,80}merged", combined) + if m: + return int(m.group(1)) + m = re.search(r"merged[^.\n]{0,80}PR[^a-zA-Z\n]{0,5}#(\d+)", combined) + if m: + return int(m.group(1)) + # 4. Last-resort: first PR reference. + matches = re.findall(r"PR[^a-zA-Z\n]{0,5}#(\d+)", combined) + return int(matches[0]) if matches else None def _extract_merged_date(text: str) -> str | None: @@ -241,7 +276,7 @@ def _load_planned(folder_path: Path) -> Feature | None: status_line=status_line, one_liner=one_liner, depends_on=_extract_depends_on(spec), - pr_number=_extract_pr_number(pipe + plan + spec), + pr_number=_extract_pr_number(pipe, plan, spec), merged_date=_extract_merged_date(pipe + plan + spec), deferred_phase=deferred, ) @@ -260,7 +295,7 @@ def _load_implemented(folder_path: Path) -> Feature | None: pipe = _read(folder_path / "pipeline_status.md") one_liner = _extract_one_liner(spec) - pr = _extract_pr_number(pipe + plan + spec) + pr = _extract_pr_number(pipe, plan, spec) merged = _extract_merged_date(pipe + plan + spec) # Date prefix from the folder is the canonical merged date. @@ -690,7 +725,7 @@ def _mermaid_graph(features: list[Feature]) -> str: return "\n".join(lines) -def render(features: list[Feature]) -> str: +def render_html(features: list[Feature]) -> str: kpi = _classify_kpi(features) pct = ( round(kpi["done_features"] * 100 / kpi["scoped_features"]) if kpi["scoped_features"] else 0 @@ -827,11 +862,163 @@ def _strip_trailing_ws(text: str) -> str: return "\n".join(line.rstrip() for line in text.splitlines()) + "\n" +# --------------------------------------------------------------------------- +# Markdown renderer (GitHub-native view alongside the rich HTML) +# --------------------------------------------------------------------------- + + +def _md_escape_cell(text: str) -> str: + """Escape characters that break GitHub markdown table cells.""" + if not text: + return "" + return text.replace("|", "\\|").replace("\n", " ") + + +def _md_link(label: str, target: Path) -> str: + """Build a relative markdown link from the markdown output's location. + + Markdown lives at `docs/00_overview/MVP1_DASHBOARD.md`, so paths are + computed relative to `docs/00_overview/` (using os.path.relpath which + correctly emits `../` segments for parent traversal). + """ + rel = os.path.relpath(target, OUTPUT_MD.parent) + # POSIX-style separators so GitHub renders correctly on any platform. + return f"[{label}]({Path(rel).as_posix()})" + + +def _md_feature_link(f: Feature) -> str: + """Link the feature name to its primary artifact.""" + primary = f.path / "feature_spec.md" + if not primary.exists(): + primary = f.path / "idea.md" + if not primary.exists(): + primary = f.path + return _md_link(f.folder, primary) + + +def _md_status_cell(f: Feature) -> str: + pr_url = f"https://github.com/SoundMindsAI/relyloop/pull/{f.pr_number}" + if f.pr_number and f.merged_date: + return f"[PR #{f.pr_number}]({pr_url}) merged {f.merged_date}" + if f.pr_number: + return f"[PR #{f.pr_number}]({pr_url})" + if f.deferred_phase: + return f"deferred: {f.deferred_phase}" + return _md_escape_cell(f.status_line) or "—" + + +def _md_deps_cell(f: Feature) -> str: + if not f.depends_on: + return "—" + return " ".join(f"`{d}`" for d in f.depends_on) + + +def _md_stage_section(stage: str, features: list[Feature]) -> str: + if not features: + return f"### {STAGE_LABELS[stage]} (0)\n\n_None._\n" + rows = ["| Feature | Type | One-liner | Depends on | Status |", "|---|---|---|---|---|"] + for f in features: + rows.append( + "| " + + " | ".join( + [ + _md_feature_link(f), + PREFIX_LABELS.get(f.prefix, f.prefix), + _md_escape_cell((f.one_liner or f.status_line)[:200]), + _md_deps_cell(f), + _md_status_cell(f), + ] + ) + + " |" + ) + return f"### {STAGE_LABELS[stage]} ({len(features)})\n\n" + "\n".join(rows) + "\n" + + +def render_markdown(features: list[Feature]) -> str: + """Render the GitHub-native dashboard view. + + Mirrors the HTML's information architecture using GitHub-native + primitives (tables + Mermaid block) so the file renders inline when + browsed on github.com without any preview proxy. + """ + kpi = _classify_kpi(features) + pct = ( + round(kpi["done_features"] * 100 / kpi["scoped_features"]) if kpi["scoped_features"] else 0 + ) + by_stage: dict[str, list[Feature]] = {s: [] for s in STAGES} + for f in features: + by_stage[f.stage].append(f) + type_order = {"feat": 0, "infra": 1, "epic": 2, "chore": 3, "bug": 4} + for s in STAGES: + by_stage[s].sort(key=lambda f: (type_order.get(f.prefix, 99), f.short_name)) + + asof = _data_freshness().strftime("%Y-%m-%d") + mermaid = _mermaid_graph(features) + + lines: list[str] = [] + lines.append("# RelyLoop MVP1 Dashboard") + lines.append("") + lines.append( + f"_Reflects feature-folder state as of **{asof}** " + "(latest mtime of any planned/implemented feature `.md` file). " + "Regenerated by `make dashboard` and the `mvp1-dashboard-regen` pre-commit hook. " + "For the rich local view (filter chips, type colors), open " + "[`mvp1_dashboard.html`](mvp1_dashboard.html) in a browser._" + ) + lines.append("") + lines.append("## MVP1 Progress") + lines.append("") + lines.append("| Metric | Value |") + lines.append("|---|---|") + lines.append( + f"| Features done | **{kpi['done_features']} / {kpi['scoped_features']}** ({pct}%) |" + ) + lines.append( + f"| Path to MVP1 | **{kpi['remaining']}** items remaining (features + bugs + chores) |" + ) + lines.append(f"| Open bugs | {kpi['open_bugs']} |") + lines.append(f"| Open chores | {kpi['open_chores_idea']} (idea-stage debt) |") + lines.append( + f"| Backlog ideas | {kpi['backlog_ideas']} idea-only feat/infra " + "(not yet scoped into MVP1) |" + ) + lines.append(f"| In flight | {len(by_stage['implement'])} feature(s) actively shipping |") + lines.append("") + lines.append("## Pipeline") + lines.append("") + # Done first (most useful at the top), then the active stages, then idea backlog. + for stage in ("done", "implement", "plan", "spec", "idea"): + lines.append(_md_stage_section(stage, by_stage[stage])) + lines.append("## Dependency graph") + lines.append("") + lines.append("Scoped feat/infra/chore nodes only. Idea-stage debt is omitted.") + lines.append("") + lines.append("```mermaid") + lines.append(mermaid) + lines.append("```") + lines.append("") + lines.append("---") + lines.append("") + lines.append( + "Source of truth: feature folders under " + "[`docs/02_product/planned_features/`](../02_product/planned_features/) and " + "[`docs/00_overview/implemented_features/`](implemented_features/). " + "See [`state.md`](../../state.md) for active-branch context and " + "[`CLAUDE.md`](../../CLAUDE.md) for conventions." + ) + return "\n".join(lines) + "\n" + + def main() -> int: features = load_all() - output_html = _strip_trailing_ws(render(features)) - OUTPUT.write_text(output_html, encoding="utf-8") - print(f"wrote {OUTPUT} ({len(features)} features)") + output_html = _strip_trailing_ws(render_html(features)) + OUTPUT_HTML.write_text(output_html, encoding="utf-8") + output_md = _strip_trailing_ws(render_markdown(features)) + OUTPUT_MD.write_text(output_md, encoding="utf-8") + print( + f"wrote {OUTPUT_HTML.relative_to(REPO_ROOT)} + " + f"{OUTPUT_MD.relative_to(REPO_ROOT)} ({len(features)} features)" + ) return 0