feat: CI scoring overhaul — principal roll-up, commit-type filter, new weights

Step 1: principal column + commit_type column in pipeline.db. Static map
populates principal for local agents (rio→m3taversal etc.). VPS agents
(epimetheus, argus) have no principal.

Step 2: _classify_commit_type in merge.py. Pipeline commits (inbox/,
entities/, agents/) get commit_type='pipeline' and skip CI attribution
entirely. Knowledge commits (domains/, core/, foundations/, decisions/)
get full attribution.

Step 3 (Argus): Dashboard has dual view — by-principal (default,
governance) and by-agent (drill-down). Already implemented by Argus.

CI weights updated (Cory-approved):
- Challenger: 0.35 (was 0.20)
- Synthesizer: 0.25 (was 0.15)
- Reviewer: 0.20 (was 0.10)
- Sourcer: 0.15 (unchanged)
- Extractor: 0.05 (was 0.40)

Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>
This commit is contained in:
m3taversal 2026-03-26 14:53:54 +00:00
parent 1dfc6dcc5c
commit cfb80d3496
2 changed files with 180 additions and 31 deletions

View file

@ -180,28 +180,94 @@ def _version_changes(conn, days: int = 30) -> list[dict]:
return changes return changes
def _contributor_leaderboard(conn, limit: int = 20) -> list[dict]: def _has_column(conn, table: str, column: str) -> bool:
"""Top contributors by CI score.""" """Check if a column exists in a table (graceful schema migration support)."""
cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(c["name"] == column for c in cols)
def _contributor_leaderboard(conn, limit: int = 20, view: str = "principal") -> list[dict]:
"""Top contributors by CI score.
view="agent" one row per contributor handle (original behavior)
view="principal" rolls up agent contributions to their principal (human)
"""
has_principal = _has_column(conn, "contributors", "principal")
rows = conn.execute( rows = conn.execute(
"SELECT handle, tier, claims_merged, sourcer_count, extractor_count, " "SELECT handle, tier, claims_merged, sourcer_count, extractor_count, "
"challenger_count, synthesizer_count, reviewer_count, domains, last_contribution " "challenger_count, synthesizer_count, reviewer_count, domains, last_contribution"
"FROM contributors ORDER BY claims_merged DESC LIMIT ?", + (", principal" if has_principal else "") +
(limit,), " FROM contributors ORDER BY claims_merged DESC",
).fetchall() ).fetchall()
weights = {"sourcer": 0.15, "extractor": 0.40, "challenger": 0.20, "synthesizer": 0.15, "reviewer": 0.10} # Weights reward quality over volume (Cory-approved)
result = [] weights = {"sourcer": 0.15, "extractor": 0.05, "challenger": 0.35, "synthesizer": 0.25, "reviewer": 0.20}
for r in rows: role_keys = list(weights.keys())
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
result.append({ if view == "principal" and has_principal:
"handle": r["handle"], # Aggregate by principal — agents with a principal roll up to the human
"tier": r["tier"], buckets: dict[str, dict] = {}
"claims_merged": r["claims_merged"] or 0, for r in rows:
"ci": round(ci, 2), principal = r["principal"]
"domains": json.loads(r["domains"]) if r["domains"] else [], key = principal if principal else r["handle"]
"last_contribution": r["last_contribution"], if key not in buckets:
}) buckets[key] = {
return sorted(result, key=lambda x: x["ci"], reverse=True) "handle": key,
"tier": r["tier"],
"claims_merged": 0,
"domains": set(),
"last_contribution": None,
"agents": [],
**{f"{role}_count": 0 for role in role_keys},
}
b = buckets[key]
b["claims_merged"] += r["claims_merged"] or 0
for role in role_keys:
b[f"{role}_count"] += r[f"{role}_count"] or 0
if r["domains"]:
b["domains"].update(json.loads(r["domains"]))
if r["last_contribution"]:
if not b["last_contribution"] or r["last_contribution"] > b["last_contribution"]:
b["last_contribution"] = r["last_contribution"]
# Upgrade tier (veteran > contributor > new)
tier_rank = {"veteran": 2, "contributor": 1, "new": 0}
if tier_rank.get(r["tier"], 0) > tier_rank.get(b["tier"], 0):
b["tier"] = r["tier"]
if principal:
b["agents"].append(r["handle"])
result = []
for b in buckets.values():
ci = sum(b[f"{role}_count"] * w for role, w in weights.items())
result.append({
"handle": b["handle"],
"tier": b["tier"],
"claims_merged": b["claims_merged"],
"ci": round(ci, 2),
"domains": sorted(b["domains"])[:5],
"last_contribution": b["last_contribution"],
"agents": b["agents"],
})
else:
# By-agent view (original behavior)
result = []
for r in rows:
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
entry = {
"handle": r["handle"],
"tier": r["tier"],
"claims_merged": r["claims_merged"] or 0,
"ci": round(ci, 2),
"domains": json.loads(r["domains"]) if r["domains"] else [],
"last_contribution": r["last_contribution"],
}
if has_principal:
entry["principal"] = r["principal"]
result.append(entry)
result = sorted(result, key=lambda x: x["ci"], reverse=True)
return result[:limit]
# ─── Vital signs (Vida's five) ─────────────────────────────────────────────── # ─── Vital signs (Vida's five) ───────────────────────────────────────────────
@ -386,7 +452,8 @@ async def handle_dashboard(request):
snapshots = _snapshot_history(conn, days=7) snapshots = _snapshot_history(conn, days=7)
changes = _version_changes(conn, days=30) changes = _version_changes(conn, days=30)
vital_signs = _compute_vital_signs(conn) vital_signs = _compute_vital_signs(conn)
contributors = _contributor_leaderboard(conn, limit=10) contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
except sqlite3.Error as e: except sqlite3.Error as e:
return web.Response( return web.Response(
text=_render_error(f"Pipeline database unavailable: {e}"), text=_render_error(f"Pipeline database unavailable: {e}"),
@ -394,7 +461,7 @@ async def handle_dashboard(request):
status=503, status=503,
) )
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, now) html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, now)
return web.Response(text=html, content_type="text/html") return web.Response(text=html, content_type="text/html")
@ -420,10 +487,19 @@ async def handle_api_vital_signs(request):
async def handle_api_contributors(request): async def handle_api_contributors(request):
"""GET /api/contributors — contributor leaderboard.""" """GET /api/contributors — contributor leaderboard.
Query params:
limit: max entries (default 50)
view: "principal" (default, rolls up agents) or "agent" (one row per handle)
"""
conn = _conn(request) conn = _conn(request)
limit = int(request.query.get("limit", "50")) limit = int(request.query.get("limit", "50"))
return web.json_response({"contributors": _contributor_leaderboard(conn, limit)}) view = request.query.get("view", "principal")
if view not in ("principal", "agent"):
view = "principal"
contributors = _contributor_leaderboard(conn, limit, view=view)
return web.json_response({"contributors": contributors, "view": view})
async def handle_api_domains(request): async def handle_api_domains(request):
@ -445,7 +521,7 @@ def _render_error(message: str) -> str:
</head><body><div class="err"><h1>Argus</h1><p>{message}</p><p>Check if <code>teleo-pipeline.service</code> is running and pipeline.db exists.</p></div></body></html>""" </head><body><div class="err"><h1>Argus</h1><p>{message}</p><p>Check if <code>teleo-pipeline.service</code> is running and pipeline.db exists.</p></div></body></html>"""
def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, now) -> str: def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, now) -> str:
"""Render the full operational dashboard as HTML with Chart.js.""" """Render the full operational dashboard as HTML with Chart.js."""
# Prepare chart data # Prepare chart data
@ -532,12 +608,23 @@ def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, no
total = sum(statuses.values()) total = sum(statuses.values())
domain_rows += f"<tr><td>{domain}</td><td>{total}</td><td class='green'>{m}</td><td class='red'>{c}</td><td>{o}</td></tr>" domain_rows += f"<tr><td>{domain}</td><td>{total}</td><td class='green'>{m}</td><td class='red'>{c}</td><td>{o}</td></tr>"
# Contributor rows # Contributor rows — principal view (default)
contributor_rows = "".join( principal_rows = "".join(
f'<tr><td>{c["handle"]}</td><td>{c["tier"]}</td>' f'<tr><td>{c["handle"]}'
+ (f'<span style="color:#8b949e;font-size:11px"> ({", ".join(c["agents"])})</span>' if c.get("agents") else "")
+ f'</td><td>{c["tier"]}</td>'
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>' f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>' f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
for c in contributors[:10] for c in contributors_principal[:10]
)
# Contributor rows — agent view
agent_rows = "".join(
f'<tr><td>{c["handle"]}'
+ (f'<span style="color:#8b949e;font-size:11px"> → {c["principal"]}</span>' if c.get("principal") else "")
+ f'</td><td>{c["tier"]}</td>'
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
for c in contributors_agent[:10]
) )
# Breaker status # Breaker status
@ -740,11 +827,21 @@ def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, no
</div> </div>
</div> </div>
<div class="section"> <div class="section">
<div class="section-title">Top Contributors (by CI)</div> <div class="section-title" style="display:flex;align-items:center;gap:12px">
Top Contributors (by CI)
<span style="font-size:11px;font-weight:400">
<button id="btn-principal" onclick="toggleContribView('principal')" style="background:#21262d;color:#58a6ff;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Human</button>
<button id="btn-agent" onclick="toggleContribView('agent')" style="background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Agent</button>
</span>
</div>
<div class="card"> <div class="card">
<table> <table id="contrib-principal">
<tr><th>Handle</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr> <tr><th>Contributor</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
{contributor_rows if contributor_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"} {principal_rows if principal_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
</table>
<table id="contrib-agent" style="display:none">
<tr><th>Agent</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
{agent_rows if agent_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
</table> </table>
</div> </div>
</div> </div>
@ -909,6 +1006,28 @@ new Chart(document.getElementById('originChart'), {{
}}); }});
}} // end if (timestamps.length > 0) }} // end if (timestamps.length > 0)
function toggleContribView(view) {{
const principal = document.getElementById('contrib-principal');
const agent = document.getElementById('contrib-agent');
const btnP = document.getElementById('btn-principal');
const btnA = document.getElementById('btn-agent');
if (view === 'agent') {{
principal.style.display = 'none';
agent.style.display = '';
btnA.style.background = '#21262d';
btnA.style.color = '#58a6ff';
btnP.style.background = 'transparent';
btnP.style.color = '#8b949e';
}} else {{
principal.style.display = '';
agent.style.display = 'none';
btnP.style.background = '#21262d';
btnP.style.color = '#58a6ff';
btnA.style.background = 'transparent';
btnA.style.color = '#8b949e';
}}
}}
</script> </script>
</body></html>""" </body></html>"""

View file

@ -409,11 +409,33 @@ async def _delete_remote_branch(branch: str):
# --- Contributor attribution --- # --- Contributor attribution ---
def _classify_commit_type(diff: str) -> str:
"""Classify a PR as 'knowledge' or 'pipeline' by files changed.
Knowledge: claims, decisions, core, foundations (full CI weight)
Pipeline: inbox, entities, agents, archive (zero CI weight)
"""
knowledge_prefixes = ("domains/", "core/", "foundations/", "decisions/")
pipeline_prefixes = ("inbox/", "entities/", "agents/")
has_knowledge = False
for line in diff.split("\n"):
if line.startswith("+++ b/") or line.startswith("--- a/"):
path = line.split("/", 1)[1] if "/" in line else ""
if any(path.startswith(p) for p in knowledge_prefixes):
has_knowledge = True
break
return "knowledge" if has_knowledge else "pipeline"
async def _record_contributor_attribution(conn, pr_number: int, branch: str): async def _record_contributor_attribution(conn, pr_number: int, branch: str):
"""Record contributor attribution after a successful merge. """Record contributor attribution after a successful merge.
Parses git trailers and claim frontmatter to identify contributors Parses git trailers and claim frontmatter to identify contributors
and their roles. Upserts into contributors table. and their roles. Upserts into contributors table.
Pipeline commits (inbox/, entities/, agents/) get commit_type='pipeline'
and don't increment role counts.
""" """
import re as _re import re as _re
from datetime import date as _date, datetime as _dt from datetime import date as _date, datetime as _dt
@ -425,6 +447,14 @@ async def _record_contributor_attribution(conn, pr_number: int, branch: str):
if not diff: if not diff:
return return
# Classify commit type — pipeline commits don't count toward CI
commit_type = _classify_commit_type(diff)
conn.execute("UPDATE prs SET commit_type = ? WHERE number = ?", (commit_type, pr_number))
if commit_type == "pipeline":
logger.info("PR #%d: pipeline commit — skipping CI attribution", pr_number)
return
# Parse Pentagon-Agent trailer from branch commit messages # Parse Pentagon-Agent trailer from branch commit messages
agents_found: set[str] = set() agents_found: set[str] = set()
rc, log_output = await _git( rc, log_output = await _git(