{% extends "base.html" %} {% block content %}

BVP Quadrant Scatter

Business Value Points vs composite cost. Tasks = small dots, arcs = larger dots. Drag a slider below to preview re-ranking client-side; Commit persists via fw bvp weight --from-watchtower (§ACD).

{% if weights %} {# ── T-1929: live weight sliders ── #}

Live weight sliders

Drag a slider to preview re-ranking client-side (no commit). Press Commit to persist via fw bvp weight --from-watchtower (§ACD, D8). Rationale ≥30 chars required (R6).

{% for d_id, w in weights.items() %} {#- T-2080: show driver name next to the id for at-a-glance reading. Falls back to id-only when the name field is missing. -#} {% endfor %}
DriverServer weightSliderCurrent
{{ d_id }} {% if driver_names and driver_names[d_id] %} {{ driver_names[d_id] }} {% endif %} {#- T-2084: per-driver 0-5 scoring rubric expand. Native
= no JS, accessible by keyboard, no Pico tooltip dependency. Source: policy/bvp-scoring-rubric.md (D1-D4) + value-drivers.yaml rationale (F1+). Gracefully absent when _driver_rubrics() returns no entry for this driver. -#} {% if driver_rubrics and driver_rubrics[d_id] %}
(?) {#- T-2085: list-style:none — the explicit N below is the only number; without this the
    auto-marker rendered "0. 0 — …" double-numbering. Source rubric.md uses one bold number per row, this matches. -#}
      {#- T-2086: rubric entries are (label, desc) tuples. Label is a single score ("3") or a range ("1–2") — keeps source semantics where one rationale row spans two scores. -#} {% for label, desc in driver_rubrics[d_id] %}
    • {{ label }} — {{ desc }}
    • {% endfor %}
{% endif %}
{{ w }} {{ w }} {# T-1965 (T-1958 B): per-row Remove for free drivers only — D1-D4 are protected #} {% if not d_id.startswith('D') %} {#- T-2079: htmx replaces the JS click handler. hx-prompt collects the rationale (sent as HX-Prompt header); driver id travels in the URL query. No vanilla fetch() path means no navigation surprises if JS errors elsewhere on the page. -#} {% else %} protected {% endif %}
{#- T-2079: htmx submits — native form action removed. The `changes` field is computed from liveWeights and added to the request via the `htmx:configRequest` listener below. Rationale + no-changes validation runs in that listener and calls evt.preventDefault() on failure. -#}
{#- T-2079: per-row Remove handler IIFE replaced by hx-post + hx-prompt on each button. CSRF is auto-injected by web/static/csrf-htmx.js. -#}
{# ── T-2332 (T-2330 S2): driver proposal queue ── #} {%- if pending_proposals %}

Pending driver proposals ({{ pending_proposals|length }})

Agent-filed proposals awaiting your decision. Approve runs fw bvp driver --add --from-watchtower (the Sovereign rail is preserved). Reject appends a state: rejected row with your rationale — both decisions are auditable in .context/bvp-driver-proposals.jsonl.

{%- for p in pending_proposals %} {%- endfor %}
ID Name Weight Rationale Author Actions
{{ p.id }} {{ p.name }} {{ p.weight }} {{ p.rationale }} {{ p.author or 'unknown' }}{% if p.task %} · {{ p.task }}{% endif %}
{%- endif %} {# ── T-1964 (T-1958 A): add free driver form ── #}

Add free driver

Add a free driver (D1-D4 are protected — immutable in identity). Total drivers cap at 9 (M1). At cap, you must drop an existing free driver to add a new one (add-one-drop-one). §ACD: posted via fw bvp driver --add --from-watchtower. Rationale ≥30 chars (R6).

{#- T-2079: htmx replaces native form submit so a JS error can't navigate the browser to the JSON API URL (the prior failure mode where users saw "Method Not Allowed" was a GET on /api/bvp/driver/add after native submit). CSRF header injected automatically by web/static/csrf-htmx.js. -#}
{% if weights|length >= 9 %} {% endif %}
3
Total drivers = {{ weights|length }} (cap=9). D1-D4 cannot be dropped.
{#- T-2079: vanilla fetch() IIFE removed — htmx now drives the submit. -#}
{% endif %} {% if empty %}

No scored entities yet. The scatter is empty until tasks/arcs carry confirmed bvp_scores: (via fw bvp confirm T-XXX --i-am-human) or estimator-proposed bvp_scores_proposed: (via the T-1922 worker; advisory). Proposed scores render at reduced opacity until confirmed.

Once scoring lands, this page will show a four-quadrant scatter:

{% else %}

Quadrant scatter — {{ task_points|length }} task(s), {{ arc_points|length }} arc(s). Hover a dot to see cost composite (blast_radius × 0.6 + tier × 0.3 + effort × 0.1) or T-shirt fallback (Q2).

Legend: filled = confirmed task  ·  outlined = proposed task (estimator output, advisory — confirm via fw bvp confirm T-XXX --i-am-human)  ·  arc (confirmed)  ·  arc (proposed)

{% endif %}
Raw data ({{ task_points|length + arc_points|length }} point(s)) {% for p in arc_points %} {% endfor %} {% for p in task_points %} {# T-2192: WF column makes inceptions glanceable in the raw-data table without hovering each dot #} {% endfor %}
KindIDNameWFBVP_normCostSourceStatus
arc {{ p.id }} {{ p.name }} {{ "%.3f"|format(p.bvp_norm) }} {{ "%.2f"|format(p.cost) if p.cost is not none else "—" }} {{ p.cost_source }} {{ p.status }}
task {{ p.id }} {{ p.name }}{% if p.workflow_type == 'inception' %}inception{% elif p.workflow_type %}{{ p.workflow_type }}{% else %}—{% endif %} {{ "%.3f"|format(p.bvp_norm) }} {{ "%.2f"|format(p.cost) }} {{ p.cost_source }} {{ p.status }}
{#- T-2170 AC#1+#2+#6: per-driver score table — collapsed by default (data-driven iteration over weights.items() so new drivers like F-AUTONOMY auto-render when activated, zero template change). Column headers carry data-driver-id (machine-readable) + title= with one-line rubric summary (D1-D4 from policy/bvp-scoring-rubric.md, free drivers from policy/value-drivers.yaml `rubric:` field, both surfaced via _driver_rubrics()). Score cells render the per-driver score from p.scores (estimator-proposed or human-confirmed) or "—" when absent. AC#3 (facet axis-swap) + AC#5 (Playwright pin) ship in a Slice 2 sibling task. -#} {%- if not empty %}
Per-driver scores ({{ task_points|length }} task(s) × {{ weights|length }} driver(s))

Per-driver 0-5 scores. Headers carry data-driver-id + a title= rubric tooltip (hover the header). Confirmed scores come from bvp_scores:; estimator-proposed scores come from the latest bvp_scores_proposed: entry. "—" = driver not scored for this task (e.g. new driver added after the proposal cron).

{%- for d_id, w in weights.items() %} {%- endfor %} {%- for p in task_points %} {%- for d_id, w in weights.items() %} {%- endfor %} {%- endfor %}
ID Name {{ d_id }} {%- if driver_names and driver_names[d_id] %}
{{ driver_names[d_id] }}{% endif %}
w={{ w }}
{{ p.id }} {{ p.name }} {%- if p.scores and d_id in p.scores -%} {{ p.scores[d_id] }} {%- else -%} {%- endif -%}
{%- endif %}
{% if weights or not empty %} {% endif %} {% if weights %} {% endif %} {% if not empty %} {% endif %} {% endblock %}