From 779a82eb97c8cd3259a6ea686a838cec3b651b02 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 7 Apr 2026 01:01:12 +0100 Subject: [PATCH 1/7] fix: add date_errors to substantive fixer tag routing date_errors was evaluated but never routed to any fixer, leaving PRs stuck permanently. Now classified as FIXABLE with targeted prompt guidance. Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/pipeline-v2/lib/substantive_fixer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ops/pipeline-v2/lib/substantive_fixer.py b/ops/pipeline-v2/lib/substantive_fixer.py index 386b6bc44..6b7e8caf8 100644 --- a/ops/pipeline-v2/lib/substantive_fixer.py +++ b/ops/pipeline-v2/lib/substantive_fixer.py @@ -29,7 +29,7 @@ from .llm import openrouter_call logger = logging.getLogger("pipeline.substantive_fixer") # Issue type routing -FIXABLE_TAGS = {"confidence_miscalibration", "title_overclaims", "scope_error", "frontmatter_schema"} +FIXABLE_TAGS = {"confidence_miscalibration", "title_overclaims", "scope_error", "frontmatter_schema", "date_errors"} CONVERTIBLE_TAGS = {"near_duplicate"} UNFIXABLE_TAGS = {"factual_discrepancy"} @@ -78,6 +78,8 @@ def _build_fix_prompt( issue_descriptions.append("TITLE: Reviewer says the title asserts more than the evidence supports.") elif tag == "scope_error": issue_descriptions.append("SCOPE: Reviewer says the claim needs explicit scope qualification.") + elif tag == "date_errors": + issue_descriptions.append("DATES: Reviewer flagged incorrect, missing, or inconsistent dates in the claim. Check created dates, event dates cited in the body, and any temporal claims against the source material.") elif tag == "near_duplicate": issue_descriptions.append("DUPLICATE: Reviewer says this substantially duplicates an existing claim.") -- 2.45.2 From 8118bf17b44e9ce7623747899e6c5b791745f417 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 7 Apr 2026 01:28:10 +0100 Subject: [PATCH 2/7] fix: reweave regex fallback uses consistent YAML list format The regex fallback was writing list entries as ' - "title"' (2-space indent + quotes) while existing frontmatter uses '- title' (0-space indent, no quotes). This caused YAML parse failures during merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/pipeline-v2/reweave.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ops/pipeline-v2/reweave.py b/ops/pipeline-v2/reweave.py index 2d404d30d..518078b01 100644 --- a/ops/pipeline-v2/reweave.py +++ b/ops/pipeline-v2/reweave.py @@ -535,8 +535,8 @@ def _write_edge_regex(neighbor_path: Path, fm_text: str, body_text: str, field_re = re.compile(rf"^{edge_type}:\s*$", re.MULTILINE) inline_re = re.compile(rf'^{edge_type}:\s*\[', re.MULTILINE) - entry_line = f' - "{orphan_title}"' - rw_line = f' - "{orphan_title}|{edge_type}|{date_str}"' + entry_line = f'- {orphan_title}' + rw_line = f'- {orphan_title}|{edge_type}|{date_str}' if field_re.search(fm_text): # Multi-line list exists — find end of list, append @@ -548,7 +548,7 @@ def _write_edge_regex(neighbor_path: Path, fm_text: str, body_text: str, new_lines.append(line) if re.match(rf"^{edge_type}:\s*$", line): in_field = True - elif in_field and not line.startswith(" -"): + elif in_field and not line.startswith(("- ", " -")): # End of list — insert before this line new_lines.insert(-1, entry_line) in_field = False @@ -576,7 +576,7 @@ def _write_edge_regex(neighbor_path: Path, fm_text: str, body_text: str, new_lines.append(line) if re.match(r"^reweave_edges:\s*$", line): in_rw = True - elif in_rw and not line.startswith(" -"): + elif in_rw and not line.startswith(("- ", " -")): new_lines.insert(-1, rw_line) in_rw = False inserted_rw = True -- 2.45.2 From 77c05887d91cdcfc038037cdefa2dae6c19d8322 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 7 Apr 2026 02:28:07 +0100 Subject: [PATCH 3/7] wire cascade, cross_domain, and review_records into pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - merge.py: import + await cascade_after_merge and cross_domain_after_merge after reciprocal edges, before branch deletion. Both non-fatal. Added conn.commit() before slow branch deletion (Ganymede Q4). - db.py: add record_review() helper + migration v18 (review_records table with indexes). Schema version 17→18. - evaluate.py: call record_review() at all 3 verdict points: domain_rejected → outcome=rejected approved → outcome=approved changes_requested → outcome=approved-with-changes Notes field captures review text (capped 4000 chars). Pentagon-Agent: Ship --- ops/pipeline-v2/lib/db.py | 56 ++++++++++++++++++++++++++++++++- ops/pipeline-v2/lib/evaluate.py | 16 ++++++++++ ops/pipeline-v2/lib/merge.py | 16 ++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/ops/pipeline-v2/lib/db.py b/ops/pipeline-v2/lib/db.py index 1bd2abe4e..1d15bc00b 100644 --- a/ops/pipeline-v2/lib/db.py +++ b/ops/pipeline-v2/lib/db.py @@ -9,7 +9,7 @@ from . import config logger = logging.getLogger("pipeline.db") -SCHEMA_VERSION = 17 +SCHEMA_VERSION = 18 SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( @@ -492,6 +492,30 @@ def migrate(conn: sqlite3.Connection): conn.commit() logger.info("Migration v17: added prompt_version, pipeline_version to prs table") + if current < 18: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS review_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pr_number INTEGER NOT NULL, + claim_path TEXT, + domain TEXT, + agent TEXT, + reviewer TEXT, + reviewer_model TEXT, + outcome TEXT NOT NULL, + rejection_reason TEXT, + disagreement_type TEXT, + notes TEXT, + batch_id TEXT, + claims_in_batch INTEGER, + reviewed_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_review_records_pr ON review_records(pr_number); + CREATE INDEX IF NOT EXISTS idx_review_records_agent ON review_records(agent); + """) + conn.commit() + logger.info("Migration v18: created review_records table") + if current < SCHEMA_VERSION: conn.execute( "INSERT OR REPLACE INTO schema_version (version) VALUES (?)", @@ -511,6 +535,36 @@ def audit(conn: sqlite3.Connection, stage: str, event: str, detail: str = None): ) +def record_review( + conn: sqlite3.Connection, + pr_number: int, + outcome: str, + *, + domain: str = None, + agent: str = None, + reviewer: str = None, + reviewer_model: str = None, + rejection_reason: str = None, + disagreement_type: str = None, + notes: str = None, + claims_in_batch: int = None, +): + """Write a review record. Called at each eval verdict point.""" + conn.execute( + """INSERT INTO review_records + (pr_number, domain, agent, reviewer, reviewer_model, outcome, + rejection_reason, disagreement_type, notes, batch_id, claims_in_batch) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + pr_number, domain, agent, reviewer, reviewer_model, outcome, + rejection_reason, disagreement_type, + notes[:4000] if notes else None, + str(pr_number), # batch_id = PR number + claims_in_batch, + ), + ) + + def append_priority_log(conn: sqlite3.Connection, path: str, stage: str, priority: str, reasoning: str): """Append a priority assessment to a source's priority_log. diff --git a/ops/pipeline-v2/lib/evaluate.py b/ops/pipeline-v2/lib/evaluate.py index 7dca3c3e3..ff6dab8a9 100644 --- a/ops/pipeline-v2/lib/evaluate.py +++ b/ops/pipeline-v2/lib/evaluate.py @@ -705,6 +705,11 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: db.audit( conn, "evaluate", "domain_rejected", json.dumps({"pr": pr_number, "agent": agent, "issues": domain_issues}) ) + db.record_review( + conn, pr_number, "rejected", + domain=domain, agent=agent, reviewer=agent, reviewer_model="gpt-4o", + notes=(domain_review or "")[:4000], + ) # Disposition: check if this PR should be terminated or kept open await _dispose_rejected_pr(conn, pr_number, eval_attempts, domain_issues) @@ -776,6 +781,11 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: json.dumps({"pr": pr_number, "tier": tier, "domain": domain, "leo": leo_verdict, "domain_agent": agent, "auto_merge": is_agent_pr}), ) + db.record_review( + conn, pr_number, "approved", + domain=domain, agent=agent, reviewer="leo", reviewer_model="sonnet" if tier == "STANDARD" else "opus", + notes=(leo_review or "")[:4000] if leo_review else None, + ) if is_agent_pr: logger.info("PR #%d: APPROVED + auto_merge (agent branch %s)", pr_number, branch_name) else: @@ -806,6 +816,12 @@ async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict: {"pr": pr_number, "tier": tier, "leo": leo_verdict, "domain": domain_verdict, "issues": all_issues} ), ) + db.record_review( + conn, pr_number, "approved-with-changes", + domain=domain, agent=agent, reviewer="leo", + reviewer_model="sonnet" if tier == "STANDARD" else "opus", + notes=(leo_review or domain_review or "")[:4000], + ) logger.info( "PR #%d: CHANGES REQUESTED (leo=%s, domain=%s, issues=%s)", pr_number, diff --git a/ops/pipeline-v2/lib/merge.py b/ops/pipeline-v2/lib/merge.py index d6c8dfcf1..49a20c677 100644 --- a/ops/pipeline-v2/lib/merge.py +++ b/ops/pipeline-v2/lib/merge.py @@ -48,6 +48,8 @@ except ImportError: import sys sys.path.insert(0, os.path.dirname(__file__)) from worktree_lock import async_main_worktree_lock +from .cascade import cascade_after_merge +from .cross_domain import cross_domain_after_merge from .forgejo import get_agent_token, get_pr_diff, repo_path logger = logging.getLogger("pipeline.merge") @@ -1516,6 +1518,20 @@ async def _merge_domain_queue(conn, domain: str) -> tuple[int, int]: # New claim A with supports:[B] → add supports:[A] on B's frontmatter await _reciprocal_edges(main_sha, branch_sha) + # Cascade: notify agents whose beliefs/positions depend on changed claims + try: + await cascade_after_merge(main_sha, branch_sha, pr_num, config.MAIN_WORKTREE, conn=conn) + except Exception: + logger.exception("PR #%d: cascade failed (non-fatal)", pr_num) + + # Cross-domain citation index: log entity-based connections between domains + try: + await cross_domain_after_merge(main_sha, branch_sha, pr_num, config.MAIN_WORKTREE, conn=conn) + except Exception: + logger.exception("PR #%d: cross_domain failed (non-fatal)", pr_num) + + conn.commit() # Commit DB writes before slow branch deletion + # Delete remote branch immediately (Ganymede Q4) await _delete_remote_branch(branch) -- 2.45.2 From ab3c2e072a6b48e9e70c7d1eca9ce2dc9370997b Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 7 Apr 2026 11:38:25 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20prevent=20reweave=20PR=20flood=20?= =?UTF-8?q?=E2=80=94=20freshen=20base,=20cleanup=20branches=20on=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the reweave merge failure cycle: 1. reweave.py: fetch + reset to origin/main before branch creation, eliminating the stale-base problem that caused ~75% merge failure rate 2. merge.py: delete remote branch when closing reweave PRs (in reconcile, merge failure, and conflict retry paths) — prevents discover_external_prs from rediscovering stale branches and creating new PRs every 18 minutes 3. merge.py: skip cherry-pick retry for reweave branches — reweave modifies existing files so cherry-pick always fails, go straight to close+delete Pentagon-Agent: Ship Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/pipeline-v2/lib/merge.py | 66 ++++++++++++++++++++++++++++++------ ops/pipeline-v2/reweave.py | 22 +++++++++++- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/ops/pipeline-v2/lib/merge.py b/ops/pipeline-v2/lib/merge.py index 49a20c677..fd9593005 100644 --- a/ops/pipeline-v2/lib/merge.py +++ b/ops/pipeline-v2/lib/merge.py @@ -1432,13 +1432,22 @@ async def _merge_domain_queue(conn, domain: str) -> tuple[int, int]: continue if not pick_ok: - # Cherry-pick failed — this is a genuine conflict (not a race condition). - # No retry needed: cherry-pick onto fresh main means main can't have moved. - logger.warning("PR #%d cherry-pick failed: %s", pr_num, pick_msg) - conn.execute( - "UPDATE prs SET status = 'conflict', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?", - (pick_msg[:500], pr_num), - ) + logger.warning("PR #%d merge/cherry-pick failed: %s", pr_num, pick_msg) + # Reweave: close immediately, don't retry (Ship: same rationale as ff-push failure) + if branch.startswith("reweave/"): + conn.execute( + "UPDATE prs SET status = 'closed', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?", + (f"reweave merge failed (closed, not retried): {pick_msg[:400]}", pr_num), + ) + await forgejo_api("PATCH", repo_path(f"pulls/{pr_num}"), {"state": "closed"}) + await forgejo_api("POST", repo_path(f"issues/{pr_num}/comments"), + {"body": f"Reweave merge failed — closing. Next nightly reweave will create a fresh branch.\n\nError: {pick_msg[:200]}"}) + await _delete_remote_branch(branch) + else: + conn.execute( + "UPDATE prs SET status = 'conflict', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?", + (pick_msg[:500], pr_num), + ) db.audit(conn, "merge", "cherry_pick_failed", json.dumps({"pr": pr_num, "error": pick_msg[:200]})) failed += 1 continue @@ -1483,10 +1492,24 @@ async def _merge_domain_queue(conn, domain: str) -> tuple[int, int]: if not merge_ok: logger.error("PR #%d merge failed: %s", pr_num, merge_msg) - conn.execute( - "UPDATE prs SET status = 'conflict', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?", - (merge_msg[:500], pr_num), - ) + # Reweave PRs: close immediately on failure. Cherry-pick retry + # will always fail (reweave modifies existing files). Next nightly + # run creates a fresh branch from current main — retry is wasteful. + # (Ship: prevents reweave flood + wasted retry cycles) + if branch.startswith("reweave/"): + conn.execute( + "UPDATE prs SET status = 'closed', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?", + (f"reweave merge failed (closed, not retried): {merge_msg[:400]}", pr_num), + ) + await forgejo_api("PATCH", repo_path(f"pulls/{pr_num}"), {"state": "closed"}) + await forgejo_api("POST", repo_path(f"issues/{pr_num}/comments"), + {"body": f"Reweave merge failed — closing. Next nightly reweave will create a fresh branch.\n\nError: {merge_msg[:200]}"}) + await _delete_remote_branch(branch) + else: + conn.execute( + "UPDATE prs SET status = 'conflict', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?", + (merge_msg[:500], pr_num), + ) db.audit(conn, "merge", "merge_failed", json.dumps({"pr": pr_num, "error": merge_msg[:200]})) failed += 1 continue @@ -1583,6 +1606,11 @@ async def _reconcile_db_state(conn): continue if forgejo_state == "closed" and not is_merged and db_status not in ("closed",): + # Clean up branch too — stale branches get rediscovered as new PRs + # (Ship: prevents reweave flood where closed PRs leave branches that + # trigger discover_external_prs → new PR → fail → close → repeat) + if branch: + await _delete_remote_branch(branch) conn.execute( "UPDATE prs SET status = 'closed', last_error = 'reconciled: closed on Forgejo' WHERE number = ?", (pr_number,), @@ -1775,6 +1803,22 @@ async def _retry_conflict_prs(conn) -> tuple[int, int]: branch = row["branch"] attempts = row["conflict_rebase_attempts"] or 0 + # Reweave branches modify existing files — cherry-pick will always fail. + # Close immediately and delete branch. Next nightly reweave creates fresh. + # (Ship: prevents wasting 3 retry cycles on branches that can never cherry-pick) + if branch.startswith("reweave/"): + logger.info("Reweave PR #%d: skipping retry, closing + deleting branch", pr_number) + conn.execute( + "UPDATE prs SET status = 'closed', last_error = 'reweave: closed (retry skipped, next nightly creates fresh)' WHERE number = ?", + (pr_number,), + ) + await forgejo_api("PATCH", repo_path(f"pulls/{pr_number}"), {"state": "closed"}) + await forgejo_api("POST", repo_path(f"issues/{pr_number}/comments"), + {"body": "Reweave conflict — closing instead of retrying. Cherry-pick always fails on reweave branches (they modify existing files). Next nightly reweave will create a fresh branch from current main."}) + await _delete_remote_branch(branch) + failed += 1 + continue + logger.info("Conflict retry [%d/%d] PR #%d branch=%s", attempts + 1, MAX_CONFLICT_REBASE_ATTEMPTS, pr_number, branch) diff --git a/ops/pipeline-v2/reweave.py b/ops/pipeline-v2/reweave.py index 518078b01..a705e888f 100644 --- a/ops/pipeline-v2/reweave.py +++ b/ops/pipeline-v2/reweave.py @@ -597,7 +597,14 @@ def _write_edge_regex(neighbor_path: Path, fm_text: str, body_text: str, def create_branch(repo_root: Path, branch_name: str) -> bool: - """Create and checkout a new branch. Cleans up stale local/remote branches from prior failed runs.""" + """Create and checkout a new branch from fresh origin/main. + + Cleans up stale local/remote branches from prior failed runs, then + fetches + resets to origin/main so the branch is never based on stale state. + (Ship: reduces reweave merge failure rate from ~75% to near-zero by + eliminating the stale-base problem that causes superset assertion failures + and force-with-lease races.) + """ # Delete stale local branch if it exists (e.g., from a failed earlier run today) subprocess.run(["git", "branch", "-D", branch_name], cwd=str(repo_root), capture_output=True) # ignore errors if branch doesn't exist @@ -610,6 +617,19 @@ def create_branch(repo_root: Path, branch_name: str) -> bool: subprocess.run(["git", "push", push_url, "--delete", branch_name], cwd=str(repo_root), capture_output=True) # ignore errors if branch doesn't exist + # Freshen to origin/main before branching — ensures branch base matches + # the main HEAD that _merge_reweave_pr will read at merge time. + try: + subprocess.run(["git", "fetch", "origin", "main"], + cwd=str(repo_root), check=True, capture_output=True, timeout=30) + subprocess.run(["git", "checkout", "main"], + cwd=str(repo_root), check=True, capture_output=True) + subprocess.run(["git", "reset", "--hard", "origin/main"], + cwd=str(repo_root), check=True, capture_output=True) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.error("Failed to freshen to origin/main: %s", e) + return False + try: subprocess.run(["git", "checkout", "-b", branch_name], cwd=str(repo_root), check=True, capture_output=True) -- 2.45.2 From 0e966bbfdf74abe58bed3471e7ad9c58241d1f56 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Tue, 7 Apr 2026 12:54:06 +0100 Subject: [PATCH 5/7] ship: add contributor attribution tracing to PR lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration v19: submitted_by column on prs + sources tables - extract.py: propagates proposed_by from source frontmatter → PR record - merge.py: sets submitted_by from Forgejo author for human PRs - dashboard_prs.py: redesigned with Contributor column, improved claim visibility in expanded rows, cost estimates, evaluator chain display - dashboard_routes.py: submitted_by + source_path in pr-lifecycle API - backfill_submitted_by.py: one-time backfill (1525/1777 PRs matched) Co-Authored-By: Claude Opus 4.6 (1M context) --- ops/diagnostics/backfill_submitted_by.py | 138 +++++++++++ ops/diagnostics/dashboard_prs.py | 279 ++++++++++++++--------- ops/diagnostics/dashboard_routes.py | 5 +- ops/pipeline-v2/lib/db.py | 16 +- ops/pipeline-v2/lib/extract.py | 43 ++++ ops/pipeline-v2/lib/merge.py | 10 +- 6 files changed, 378 insertions(+), 113 deletions(-) create mode 100644 ops/diagnostics/backfill_submitted_by.py diff --git a/ops/diagnostics/backfill_submitted_by.py b/ops/diagnostics/backfill_submitted_by.py new file mode 100644 index 000000000..8c0933582 --- /dev/null +++ b/ops/diagnostics/backfill_submitted_by.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""One-time backfill: populate submitted_by on prs table from source archive files. + +Matches PRs to sources via branch name slug → source filename. +Reads proposed_by and intake_tier from source frontmatter. + +Run: python3 backfill_submitted_by.py +""" + +import os +import re +import sqlite3 +from pathlib import Path + +DB_PATH = os.environ.get("DB_PATH", "/opt/teleo-eval/pipeline/pipeline.db") +ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", "/opt/teleo-eval/workspaces/main/inbox/archive")) + + +def parse_frontmatter(path: Path) -> dict: + """Parse YAML-like frontmatter from a markdown file.""" + text = path.read_text(encoding="utf-8", errors="replace") + if not text.startswith("---"): + return {} + end = text.find("---", 3) + if end == -1: + return {} + fm = {} + for line in text[3:end].strip().split("\n"): + line = line.strip() + if not line or ":" not in line: + continue + key, _, val = line.partition(":") + key = key.strip() + val = val.strip().strip('"').strip("'") + if val.lower() == "null" or val == "": + val = None + fm[key] = val + return fm + + +def slug_from_branch(branch: str) -> str: + """Extract source slug from branch name like 'extract/2026-04-06-slug-hash'.""" + if "/" in branch: + branch = branch.split("/", 1)[1] + # Strip trailing hex hash (e.g., -3e68, -a6af) + branch = re.sub(r"-[0-9a-f]{4}$", "", branch) + return branch + + +def main(): + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + + # Build source index: filename stem → frontmatter + source_index = {} + if ARCHIVE_DIR.exists(): + for f in ARCHIVE_DIR.glob("*.md"): + fm = parse_frontmatter(f) + source_index[f.stem] = fm + print(f"Indexed {len(source_index)} source files from {ARCHIVE_DIR}") + + # Get all PRs without submitted_by + prs = conn.execute( + "SELECT number, branch FROM prs WHERE submitted_by IS NULL AND branch IS NOT NULL" + ).fetchall() + print(f"Found {len(prs)} PRs without submitted_by") + + updated = 0 + for pr in prs: + branch = pr["branch"] + slug = slug_from_branch(branch) + + # Try to match slug to a source file + fm = source_index.get(slug) + if not fm: + # Try partial matching: slug might be a substring of the source filename + for stem, sfm in source_index.items(): + if slug in stem or stem in slug: + fm = sfm + break + + if fm: + proposed_by = fm.get("proposed_by") + intake_tier = fm.get("intake_tier") + + if proposed_by: + contributor = proposed_by.strip().strip('"').strip("'") + elif intake_tier == "research-task": + # Derive agent from branch prefix + prefix = branch.split("/", 1)[0] if "/" in branch else "unknown" + agent_map = { + "extract": "pipeline", "ingestion": "pipeline", + "rio": "rio", "theseus": "theseus", "vida": "vida", + "clay": "clay", "astra": "astra", "leo": "leo", + "reweave": "pipeline", + } + agent = agent_map.get(prefix, prefix) + contributor = f"{agent} (self-directed)" + elif intake_tier == "directed": + contributor = "directed (unknown)" + else: + contributor = None + + if contributor: + conn.execute( + "UPDATE prs SET submitted_by = ?, source_path = ? WHERE number = ?", + (contributor, f"inbox/archive/{slug}.md", pr["number"]), + ) + updated += 1 + else: + # For extract/ branches, mark as pipeline self-directed + if branch.startswith("extract/") or branch.startswith("ingestion/"): + conn.execute( + "UPDATE prs SET submitted_by = 'pipeline (self-directed)' WHERE number = ?", + (pr["number"],), + ) + updated += 1 + elif branch.startswith(("rio/", "theseus/", "vida/", "clay/", "astra/", "leo/")): + agent = branch.split("/", 1)[0] + conn.execute( + "UPDATE prs SET submitted_by = ? WHERE number = ?", + (f"{agent} (self-directed)", pr["number"]), + ) + updated += 1 + elif branch.startswith("reweave/"): + conn.execute( + "UPDATE prs SET submitted_by = 'pipeline (reweave)' WHERE number = ?", + (pr["number"],), + ) + updated += 1 + + conn.commit() + conn.close() + print(f"Updated {updated}/{len(prs)} PRs with submitted_by") + + +if __name__ == "__main__": + main() diff --git a/ops/diagnostics/dashboard_prs.py b/ops/diagnostics/dashboard_prs.py index 121d9266e..0fd21c24f 100644 --- a/ops/diagnostics/dashboard_prs.py +++ b/ops/diagnostics/dashboard_prs.py @@ -1,8 +1,8 @@ """PR Lifecycle dashboard — single-page view of every PR through the pipeline. -Sortable table: PR#, summary, agent, domain, outcome, TTM, date. -Click any row to expand the full trace (triage reasoning, review text, cascade). -Hero cards: total PRs, merge rate, median TTM, median eval rounds. +Sortable table: PR#, summary, claims, domain, contributor, outcome, evals, evaluator, cost, date. +Click any row to expand: claim titles, eval chain, timeline, reviews, issues. +Hero cards: total PRs, merge rate, total claims, est. cost. Data sources: prs table, audit_log (eval rounds), review_records. Owner: Ship @@ -14,19 +14,23 @@ from shared_ui import render_page EXTRA_CSS = """ + .content-wrapper { max-width: 1600px !important; } .filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; } .filters select, .filters input { background: #161b22; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 6px 10px; font-size: 12px; } .filters select:focus, .filters input:focus { border-color: #58a6ff; outline: none; } .pr-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; } - .pr-table th:nth-child(1) { width: 60px; } /* PR# */ - .pr-table th:nth-child(2) { width: 38%; } /* Summary */ - .pr-table th:nth-child(3) { width: 10%; } /* Agent */ - .pr-table th:nth-child(4) { width: 14%; } /* Domain */ - .pr-table th:nth-child(5) { width: 10%; } /* Outcome */ - .pr-table th:nth-child(6) { width: 7%; } /* TTM */ - .pr-table th:nth-child(7) { width: 10%; } /* Date */ + .pr-table th:nth-child(1) { width: 50px; } /* PR# */ + .pr-table th:nth-child(2) { width: 28%; } /* Summary */ + .pr-table th:nth-child(3) { width: 50px; } /* Claims */ + .pr-table th:nth-child(4) { width: 11%; } /* Domain */ + .pr-table th:nth-child(5) { width: 10%; } /* Contributor */ + .pr-table th:nth-child(6) { width: 10%; } /* Outcome */ + .pr-table th:nth-child(7) { width: 44px; } /* Evals */ + .pr-table th:nth-child(8) { width: 12%; } /* Evaluator */ + .pr-table th:nth-child(9) { width: 60px; } /* Cost */ + .pr-table th:nth-child(10) { width: 80px; } /* Date */ .pr-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 8px 6px; } .pr-table td:nth-child(2) { white-space: normal; overflow: visible; line-height: 1.4; } .pr-table th { cursor: pointer; user-select: none; position: relative; padding: 8px 18px 8px 6px; } @@ -46,11 +50,23 @@ EXTRA_CSS = """ .pr-table td .summary-text { font-size: 12px; color: #c9d1d9; } .pr-table td .review-snippet { font-size: 11px; color: #f85149; margin-top: 2px; opacity: 0.8; } .pr-table td .model-tag { font-size: 10px; color: #6e7681; background: #161b22; border-radius: 3px; padding: 1px 4px; } + .pr-table td .contributor-tag { font-size: 11px; color: #d2a8ff; } + .pr-table td .contributor-self { font-size: 11px; color: #6e7681; font-style: italic; } .pr-table td .expand-chevron { display: inline-block; width: 12px; color: #484f58; font-size: 10px; transition: transform 0.2s; } .pr-table tr.expanded .expand-chevron { transform: rotate(90deg); color: #58a6ff; } .trace-panel { background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin: 4px 0 8px 0; font-size: 12px; display: none; } .trace-panel.open { display: block; } + .trace-panel h4 { color: #58a6ff; font-size: 12px; margin: 12px 0 6px 0; } + .trace-panel h4:first-child { margin-top: 0; } + .claim-list { list-style: none; padding: 0; margin: 0; } + .claim-list li { padding: 4px 0 4px 16px; border-left: 2px solid #238636; color: #c9d1d9; font-size: 12px; line-height: 1.5; } + .claim-list li .claim-confidence { font-size: 10px; color: #8b949e; margin-left: 6px; } + .issues-box { background: #1c1210; border: 1px solid #f8514933; border-radius: 6px; + padding: 8px 12px; margin: 4px 0; font-size: 12px; color: #f85149; } + .eval-chain { background: #161b22; border-radius: 6px; padding: 8px 12px; margin: 4px 0; font-size: 12px; } + .eval-chain .chain-step { display: inline-block; margin-right: 6px; } + .eval-chain .chain-arrow { color: #484f58; margin: 0 4px; } .trace-timeline { list-style: none; padding: 0; } .trace-timeline li { padding: 4px 0; border-left: 2px solid #30363d; padding-left: 12px; margin-left: 8px; } .trace-timeline li .ts { color: #484f58; font-size: 11px; } @@ -66,9 +82,6 @@ EXTRA_CSS = """ .pagination button:hover { border-color: #58a6ff; } .pagination button:disabled { opacity: 0.4; cursor: default; } .pagination .page-info { color: #8b949e; font-size: 12px; } - .stat-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; } - .stat-row .mini-stat { font-size: 11px; color: #8b949e; } - .stat-row .mini-stat span { color: #c9d1d9; font-weight: 600; } """ @@ -80,15 +93,14 @@ def render_prs_page(now: datetime) -> str:
Total PRs
--
Merge Rate
--
-
Median Time-to-Merge
--
-
Median Eval Rounds
--
Total Claims
--
+
Est. Cost
--
- +