How this dashboard works
A quick reference for every number on the dashboard — what it measures, the formula, and where to see it. Skim the headings; each section is two lines + a one-line formula.
Two views, two audiences: /flow is the AI-first executive landing — bottom-line narrative, headline KPIs, AI in teams. /overview is the engineering detail view — every per-metric table, AI Period comparison, longest-stuck list.
Anatomy of a ticket
Hover any metric for its formula.
Quick guide — which number do I quote?
📊 Talking to leadership
Quote Wall-clock (raw) — Jira Flow Lead Time (created → done) or Cycle Time (start → done) in pure calendar days. Includes everything; what stakeholders feel. Note: this is a Jira ticket-flow metric, not DORA Lead Time for Changes (which is code-committed → production-deployed).
🛠️ Talking to engineers / retro
Quote the activeversion — "How long tickets actually take" or "How fast we actually work". Business hours only, pauses subtracted. Fair to the team because blockers outside their control don't count.
🎯 Where is work stuck?
Look at Where Work Waitson Overview — the stage with the highest median time is the bottleneck. For drill-down: open the project's Flow page (Time in Status).
Are we faster with AI?
Three places give layered answers:/flowhas the 1-sentence AI Bottom Line + AI ROI banner with Δ% vs prior period./overview AI Impactshows AI vs Human medians on 5 KPIs.Before vs After AIasks the longitudinal question: is the team faster now than before AI was introduced?
Medians, n, and cohorts — how to read every number
Every "AI vs Human" comparison on this dashboard follows the same rules. Read this once and the rest is just numbers.
What is a median (p50)?
Sort every ticket's lead time from smallest to largest, pick the middle one. That's the median. Half the tickets were faster, half were slower.
Example: tickets took 1h, 2h, 4h, 10h, 50h, 500h, 800h (7 tickets) → median = the 4th value = 10h. The 800h outlier doesn't move it. Average of the same list = 195h — misleading.
Why we use it everywhere.One ticket that sat 6 months in Backlog can blow up an average. Median ignores tails and gives you "what a typical ticket looks like."
What is "n"?
Sample size — how many tickets / PRs fed into the median for that row.
Critical:n does NOT change the median's value. It only tells you how reliable that median is.
- n ≥ 10:reliable — the median is stable, one outlier ticket can't swing it much.
- n < 10: directional — value is still shown but flagged. One unusual ticket can shift the median dramatically.
AI cohort vs Human cohort vs All
- AI cohort— the ticket's assignee at completion time is an active agent (their account is in
agent_accountswithactive = true). For PR-level metrics, the PR's ADO author is an active agent. Manage the active list at /settings/agents. - Human cohort — everyone who is NOT an active agent, including unassigned tickets. We include unassigned because excluding them would quietly shrink the baseline and bias the comparison.
- All — every measurable ticket / PR in the window. Always equals AI + Human in count. Useful as a project-wide reference.
Tickets and PRs are bucketed strictly into one cohort. A ticket reassigned from human to AI mid-flight lands in the cohort matching the final assignee at completion.
Worked example — AI 126h (n=24) vs Human 15.8d (n=85)
A common question: "Does Human take longer because they did more tickets (85 vs 24)?"
No.n is just a count. The median is the middle value among those n; the count of tickets doesn't make the value larger or smaller.
- AI: 24 tickets sorted by lead time → the 12th–13th values average to 126h.
- Human: 85 tickets sorted by lead time → the 43rd value is 15.8 days.
Why Human is actually higher here comes from the composition of the work, not the count:
- Selection bias. AI typically picks up smaller, well-scoped tickets (refactors, audits, config fixes) — those naturally complete faster.
- Backlog drag.Human cohort often includes older tickets that sat in Backlog before being picked up — that "shelf time" gets added to lead.
- Zombie cleanup. If the zombie filter is in
flagmode, very old tickets that get swept inflate the Human median. Switch toexcludeon the project config to drop them. - Ticket size mix. Human-driven work spans the whole portfolio including large epics; AI work is typically narrower in scope.
To investigateclick "🧠 Generate AI deep analysis" on /anatomy— the LLM will name the specific tickets dragging each side's median.
When to trust the Δ%, when to ignore
| Condition | Tone | What it means |
|---|---|---|
| Both n ≥ 10, |Δ| ≤ 5% | parity | Within noise. AI and Human perform the same here. |
| Both n ≥ 10, |Δ| ≤ 200% | readable signal | Real difference. Quote it. |
| |Δ| > 200% | inspect | Almost always 1-2 outlier tickets dragging a median. Open AI deep analysis. |
| Either n < 10 | directional | A hint, not a conclusion. Widen the range or wait for more samples. |
How our app measures these statuses & timings
The diagram above shows the canonical lifecycle. Our metrics never hard-code the status names— every JIRA project has its own workflow (e.g. ADS uses "Approved for Sprint", TDOT uses "New", etc), so each metric reads a list of statuses from metric_configs at compute time. Defaults below cover the most common JIRA naming; override them on Project Configuration if your team uses different labels.
How these defaults were chosen. We ran an audit script (scripts/verify-metric-defaults.mjs) over the synced data of all 35 projects, enumerated every literal status name that actually appears in issue_transitions, grouped them by JIRA's canonical statusCategory(Done / In Progress / To Do), and picked the broadest sensible set for each metric — excluding cancellations ("Removed", "Deprecated"). Result: ~130 additional in-flight tickets newly visible to WIP across the five biggest projects, with cycle/lead time samples up ~10% per project. Existing projects whose configs still matched the OLD defaults were auto-migrated on the first request after the deploy; any project that had been manually customised is left untouched.
| Metric | Config fields | Default values |
|---|---|---|
| Cycle Time | startStatuses, endStatuses, pauseStatuses | start (status transition): In Progress · In Development · In Testing · Discovery · Delivery end (status transition): Done · Closed · Released On Live · Released On Stage · Resolved pause (time subtracted): On Hold · Waiting for Client · Waiting for approval Formula: endTransition − firstStartTransition − pauseTime |
| Lead Time | endStatuses, pauseStatuses (no startStatuses — start is always the ticket's createdAt) | start: issue.createdAt (JIRA creation timestamp — implicit, not a status)end (status transition): Done · Closed · Released On Live · Released On Stage · Resolved pause (time subtracted): same as Cycle Time Formula: endTransition − createdAt − pauseTime. That's the "Created → Done" bracket on the timeline above — wider than Cycle Time because it includes everything before pickup (triage, grooming, waiting). |
| Throughput | lead_time.endStatuses (inherits Lead Time completion statuses) | Done · Closed · Released On Live · Released On Stage · Resolved (Removed/Deprecated = cancelled, excluded). Throughput has no separate status list; metric_configs.throughput is intentionally empty. |
| Time to Ready | readyStatuses | Selected for Development · Approved for Sprint · Approved · Ready · Triaged · In Progress · In Development |
| Time to First PR | startStatuses, includeDrafts | In Progress · In Development · drafts counted |
| WIP | activeStatuses, pauseStatuses | Active WIP uses activeStatuses - pauseStatuses. Blocked WIP uses pauseStatuses, so overlapping pause statuses are not dropped; they move from active to blocked. |
| Time in Status | stages (ordered) | Canonical lifecycle order mapped to every observed literal status name in the default config (37 statuses). Aggregations expose p50, p75 and p90. |
| Bug Rate | bugIssueTypes | Bug · Incident |
| Planned vs Unplanned | unplannedIssueTypes, unplannedLabels, endStatuses | types: Bug · Incident · labels: [] · end: Done · Closed · Released On Live · Released On Stage · Resolved. Stored as planned_vs_unplanned.endStatuses; defaults match completion statuses but are separately configurable, not inherited from Throughput. |
How transitions are sourced.Every JIRA status change is read from the ticket's changelog (returned inline by POST /search/jql?expand=changelog) and stored in issue_transitions(issueKey, fromStatus, toStatus, changedAt, authorAccountId). Each metric then queries: find the FIRST transition whose toStatus ∈ startStatuses for start times, and the LAST transition whose toStatus ∈ endStatuses in rangefor completions — which is more reliable than JIRA's resolved_at field that workflows occasionally forget to set.
How pauses are subtracted. For Cycle Time and Lead Time, every interval the ticket spent in a pauseStatuses status (default: On Hold · Waiting for Client · Waiting for approval) is computed via computePauseMs(transitions, pauseStatuses, periodStart, periodEnd, calendar) and subtracted from the raw duration. The interval for a pause starts at its changedAtand ends at the next transition (or periodEnd if none). Pause time is measured in business hours via the project's working_calendar, so a ticket parked on Friday and unparked on Monday doesn't artificially eat the weekend. Visible per-row on metric pages as the +Xd paused suffix next to the cycle/lead value. The active set in our data has ~5,200 pause transitions across all projects — this is a meaningful adjustment, not a cosmetic one.
Lead Time & Cycle Time — two variants side by side
Industry standards disagree on whether to subtract pauses. Reinertsen and some Kanban practitioners treat Lead Time as wall-clock (customer-facing) while others subtract pauses (team execution view). Note: DORA Lead Time for Changes is a different metric — it measures code-committed → production-deployed (see the per-project Lead Time for Changes page), not Jira-ticket-flow. To avoid confusion, we call ours Jira Flow Lead Time. To avoid hiding either signal, we compute BOTH variants (raw + active) and expose them on the Lead Time page (and visualise both on the "Anatomy of a ticket" diagram above — solid bracket = raw; dashed = active).
| Variant | Formula | Industry source | Use when |
|---|---|---|---|
| Lead Time — raw (wall-clock) | completionAt − createdAt | Reinertsen, Kanban Lead Time (Jira-flow proxy; not DORA Lead Time for Changes) | Stakeholder / customer view — total wait. Comparing to industry benchmarks. Surfaces systemic waits. |
| Lead Time — active (÷ pauses, business hours) | businessHours(createdAt → completionAt) − pauseTime | Kanban variant (active flow) | Team-execution view — excludes blockers outside team's control (PM waiting, vendor, weekend). |
| Cycle Time — raw (wall-clock) | completionAt − firstStartTransition | Some Kanban writeups | When you want apples-to-apples comparison with industry cycle-time benchmarks. |
| Cycle Time — active (÷ pauses, business hours) | businessHours(firstStart → completionAt) − pauseTime | Kanban (active flow); our default | Engineering speed once work has started — most useful internal signal. |
Recommendation: use Jira Flow Lead Time (raw) in conversations with leadership and when comparing to Kanban/Reinertsen benchmarks. Don't compare it directly to DORA Lead Time for Changes — those are different scopes (Jira ticket vs code-to-prod). Use Cycle Time (active)for team retrospectives and engineering-pace tracking. The Lead Time page shows both p50 figures side-by-side so you can quote the right one.
How we count deployments (DORA)
Deployment Frequency, Change Failure Rate and Lead Time for Changes all read from a single table — deployments— populated from Azure DevOps. We don't infer deploys from JIRA statuses or git tags.
ADO scope — single-org limitation. ADO repo and pipeline mappings are scoped to one ADO organisation per install (the one pointed at by the ADO_ORG_URL environment variable). Arkadium setups that span more than one Azure DevOps organisation need a separate instance of this dashboard per org — the discovery walk and the PR / deployment sync never cross orgs.
- What gets registered as a pipeline. When you run Sync, our auto-discovery walks every ADO release definition (and, optionally, deploy-named YAML build pipelines) in the ADO project where the mapped JIRA repo lives. Each becomes an
ado_pipeline_configsrow tied to your JIRA project key. - What gets stored as a deployment. For each registered pipeline, we pull recent runs/releases via the ADO REST API. We record the rolled-up result across its environments:
inProgress / succeeded / failed / partiallySucceeded / canceled. Deployment Frequency and Change Failure Rate both use the same classifier. Only succeeded counts toward Deployment Frequency;failed,partiallySucceededandcanceledenter the CFR denominator and count as failures unless marked manual not-failure;inProgressis excluded.deployments.manualFailure=trueforces failure and also removes the row from Deployment Frequency. - How a PR maps to a deploy. For Lead Time for Changes, each merged PR is associated with the next deploy after its merge time on a pipeline registered for the same JIRA project. We never link PRs across projects.
- What "production" means. We currently treat ANY successful deploy as a deploy event. If your release definition has a separate Prod env, configure the pipeline filter on Project Configuration to keep only that env. (Default = all envs.)
Verification — what to check if a number looks off
- Did sync run successfully? Open /logs; any project with a recent error row will have stale data. The /sync tile shows a red ⚠ failing chip on misbehaving projects.
- Are the statuses configured for your workflow?If a project uses "Code Review" instead of "In Review", our default
WIP / Time in Statuslists won't catch it. Open Project Configuration and add the missing name. - Is the ADO pipeline registered?If Deployment Frequency is zero for a project that clearly deploys, the release definition wasn't auto-discovered (maybe mapped repo lives in another ADO project). Add it manually on Project Configuration.
- Does an outlier ticket need exclusion? Use /exclusions. Issue exclusions are centralized rows with
scope = "all": the ticket is removed from issue metrics and linked-PR lanes. PR-only exclusions are for PRs without a linked JIRA key or for a single PR outlier. Metrics recompute on the next request.
What this dashboard is for
One question: how is the team doing, and where is AI useful? The Overview page (the home page) is the single executive view; per-project pages let you drill into one team in detail.
Source of truth is JIRA + Azure DevOps. The app pulls data into a local SQLite snapshot when you press Sync; nothing is recomputed live against JIRA. That means numbers are as fresh as the last sync (see top of Sync page).
What "AI" means in the numbers
An "agent" is a JIRA account marked active in Settings → Agents. Today there are 7: Oliver, Amelia, Anastasia, Sakura, Nikolaos, Veronica, Robocop.
- For JIRA-issue metrics (cycle / lead / throughput / bug rate / etc.) —
assignee ∈ agents= AI work. - For ADO PR metrics —
PR author ∈ agents= AI PR. - Reporter (the creator) is tracked separately in AI Activity only — not in the throughput-style metrics, because creating a task is not the same as doing it.
Leadership requirements — and where each lives
See teams holistically
"We need to bring the app to life so we can start seeing teams holistically."
Where: /overview
How: Single story-scroll page: Right Now KPIs → 8-week Trend → AI Impact deltas → Top Teams board → AI Activity per agent. No drill-down required.
Know team performance in numbers (baseline)
"Unfortunately we don't really know our team's performance in numbers."
Where: Overview + 12 per-project metric pages
How: Jira-flow (cycle, lead, throughput) + DORA code-path (deploys, change failure, lead-time-for-changes), plus quality (bug rate), planning (planned vs unplanned), and PR flow (PR cycle, review, acceptance, time-to-first-PR).
See where AI is most useful
"See how agents are actually being used and what impact they have on the team."
Where: Overview "AI Impact" and "AI Activity" sections
How: Every metric splits agent vs human. Impact section shows the delta (Δ%). Activity panel counts what agents actually do (assigned, authored PRs, reported, status moves, comments).
Lead time as a key non-code signal
"Lead time can be a good metric to tap into what you are saying."
Where: Lead Time per project
How: Days from issue creation to resolution. Applies to any JIRA work — code, planning, analysis, infra — not only engineering tickets.
How fast a task is ready to be worked
"For example, how fast a task is ready to be worked."
Where: Time to Ready per project
How: Hours from issue creation to the first move into a "ready" status (Selected for Dev / Ready / Approved / Approved for Sprint / In Progress). Configurable per project.
Metrics not only about code
"How well and how fast a team performs is not only related to coding."
Where: Lead time, cycle time, time-to-ready, status breakdown, planned vs unplanned
How: These all operate on any JIRA tickets (planning items, analyses, ops tasks). The PR-specific metrics (PR cycle, PR review, PR acceptance, deployment frequency) are the code-only subset.
Track AI as planner, analyst, task writer — not just developer
"The agent isn't only for developers now, it's also being used for planning, issue analysis and writing tasks."
Where: Overview → AI Activity panel
How: Reported = issues created by an agent (task writing). Transitions = status moves performed by an agent (planning / triage). Comments = comments left by an agent (analysis / notes). All three are off-code signals.
Overview KPIs — formulas
Issues done (Right Now)
How many tickets got finished in the chosen range. Uses the canonical completedIssueSet — same cohort that drives Top Teams and AI Impact, so the rollup reconciles end to end.
count(issues) where LAST transition into a configured end status falls in range AND current status is still in end statuses AND project in scope
Example: Last 30 days, leadership scope → 336
Tells you: Pure volume. A larger number is more output — but says nothing about quality on its own.
AI participation %
What share of the canonical completedIssueSet is owned by an agent (as assignee at completion time).
issuesDone(assignee ∈ active agents) / issuesDone × 100
Example: 42 of 336 leadership-scope issues → 12.9%
Tells you: How much of the team's throughput AI is producing — over time, this trend is the headline.
Cycle Time (active)
Typical active hours between starting work on a ticket and finishing it. As of audit23 T1, Overview and the per-project Cycle tile both use the active cycle math: business-hours between firstStart and completion, with every interval the ticket spent in a configured pauseStatuses subtracted. Active time = business hours minus pauseStatuses time. Median (p50) is robust to outliers. Start statuses are the union of cycle_time.startStatuses across the projects in scope (defaults: In Progress · In Development · In Testing · Discovery · Delivery). Matches the "Cycle Time (active, ÷ pauses)" band in Anatomy of a ticket.
median(businessHours(firstStart → completionAt) − pauseTime) in hours — the active variant
Example: Cycle Time (active) = 2.9d means half of tickets finished faster than 2.9 active business days from first In-Progress-ish move (pauses already subtracted).
Tells you: How long it takes the team to push something through once they pick it up, ignoring time blocked outside their control. Smaller = faster execution.
Deployments / week
Average successful deployments per calendar week. Counts releases across ADO release definitions mapped to the project(s) — only result='succeeded', anchored on finishedAt.
count(successful deployments) in range / weeksInRange
Example: 6.3 / week, last 30 days, leadership scope
Tells you: One of the DORA "four keys" — the higher, the more delivery cadence. Watch in conjunction with change failure rate.
AI Impact rows — formulas
Each row is "agent median vs human median, with Δ%". Δ% is(agent − human) ÷ human × 100, colored green when better for the team (typically faster). All inside the chosen range and projects.
Cycle Time (active) (agent vs human)
Are agent-owned tickets going through the same active in-progress → done time as human-owned ones? Uses the same business-hours-minus-pauses math as the per-project Cycle Time active tile.
median(businessHours(firstStart → completionAt) − pauseTime) over agent-assigned vs human-assigned completed tickets
Tells you: If agents are noticeably faster, AI is shortening the cycle; if slower, they're working harder tickets or hitting friction.
Lead Time (active) (agent vs human)
Active ticket age — creation to canonical completion in project business hours, minus configured lead_time.pauseStatuses — split by who eventually owned it. Derived from the same completedIssueSet as Issues done so AI and Human n's match the headline cohort.
median(businessHours(createdAt → completionAt) − pauseTime) where completionAt = last in-range end transition, split by assignee, in hours
Tells you: Broader signal than cycle; includes time before pickup. Useful for non-code teams.
Time to Ready p50 (agent vs human)
Leadership-named example metric. How fast a ticket becomes "workable" — measured against the project's ready-status list.
median(firstReadyTransition − createdAt) over agent vs human assignee, in hours
Tells you: If agents process the planning/grooming pipeline faster, you'll see it here.
PRs merged (agent vs human)
Raw PR throughput by author.
count(pullRequests) where status = completed AND author ∈ agents (or humans), in range
Tells you: How many merged PRs AI authors are producing alongside humans.
PR review p50 (agent vs human)
Time from first reviewer touch to merge — this is PR Review Duration, the actual time spent in review. Sibling per-project metric Time to First Review measures businessHours(createdAt → firstReviewAt) — how long until the first reviewer touched the PR through the project working calendar. The two answer different questions: how long until review starts vs how long review takes. Both have dedicated per-project pages.
median(completedAt − firstReviewAt) for PRs author ∈ agents vs humans, in hours
Tells you: If agent PRs are reviewed faster, they're either smaller or clearer — both good.
AI Activity columns — formulas
Assigned
Completed tickets where the agent was the assignee at completion. Uses the canonical completedIssueSet so the count matches Overview's AI participation × Issues done exactly.
count(completedIssueSet) where assignee ∈ agent in range
Authored PRs
PRs the agent opened.
count(pullRequests) where author = agent.adoUserId AND createdAt in range
Reported
Tickets the agent created (task writing). Reporter is JIRA's "created by".
count(issues) where reporter ∈ agent AND createdAt in range
Transitions
Status moves the agent performed (any → any). A proxy for "agent doing workflow ops" — moving cards, triaging, approving.
count(issue_transitions) where author = agent AND changedAt in range
Comments
JIRA comments left by the agent. Useful proxy for "analysis" or "writeups" that don't produce code.
count(issue_comments) where author = agent AND createdAt in range
Tells you: 0 until a sync runs with the comments-fetch enabled. After one sync, this populates incrementally.
Before vs After AI — comparing eras
A second lens on AI impact. Instead of agent-vs-human at the same moment, this splits each project's timeline at the date AI agents started doing meaningful work, then compares the same team's metrics before vs after.
Why this matters.AI-vs-human at the same instant can be confounded by selection bias (agents picking easier tickets). Before-vs-after with a fixed split point shows whether the whole team's baseline moved.
Per-project configuration.Each project has its own AI introduction date because teams adopted agents at different times. Set on the project's config page (AI Period block):
- Auto-detect picks the earliest of: first agent-assigned ticket, first agent-reported ticket, first agent comment, first agent transition.
- Manual — set any date with the picker. The marker on the panel shows which source was used (auto / manual).
- Toggle Mode: Off, Before only, After only, or Compare (side-by-side with Δ%).
What you see on Overview. A rollup table with one row per metric. Each row exposes a per-metric ⓘ tooltip with the exact formula; the headers (Metric / Before AI / After AI / Δ) and the per-project card pieces (split date, mode badge, weeks·n, each KPI in the side-box) all have their own ⓘ — hover for the explanation.
- Lead time p50 (raw, days) — median-of-per-project-medians. Per-project formula:
completionAt − createdAtin calendar days, then p50. completionAt is the last in-window transition into a configured end status — the same value used by the canonicalcompletedIssueSet. - Throughput / week — mean of
count(resolved) ÷ weeksacross projects. - AI participation % — per-project
aiAssignedDone ÷ totalDone × 100, then averaged across projects.
Note — AI PeriodaiPctis assignee-only: a ticket counts as AI iff its current assignee is an agent. The broader "AI touched" predicate (reporter OR comment author OR transition author OR assignee) is what the AI Impact and Agent Activity tiles use, and it is not the same metric — Before-vs-After AI deliberately picks the narrower lens so the split is unambiguous about ownership. - Deploys / week — mean of per-project
count(successful ADO deploys) ÷ weeks. Uses the shared DORA classifier (result = "succeeded", excluding manual failures), anchored onfinishedAt. DORA #1. - Change failure rate — weighted by deployment count.
classified failures ÷ classified finished deploys × 100per project,Σ(rate · weight) ÷ Σ weightacross projects. DORA #4.
Δ% — change from Before to After. (after − before) ÷ before × 100. Color rule: ▲ / ▼ green = improvement (direction depends on the metric: more throughput is good, lower lead time is good), ▲ / ▼ amber = regression. ⚠ = sample too small on at least one side (n < 30). — = no Δ available (Before or After was empty, or Before = 0).
Expand Per-project breakdown to see one card per project with both eras side-by-side, sample size (n), the split date, and the active mode badge.
Before window anchoring.The Before period does NOT start at the Unix epoch — it's anchored at the project's first recorded ticket (MIN(issues.createdAt)) so per-week throughput stays comparable regardless of when the project itself was created.
How aggregation works.
- Percentile metrics use
median-of-per-project-medians(resistant to outliers). - Throughput / deploys use the
mean of per-project rates. - Change failure rate uses a
weighted averageby deployment count, so a project with 100 deploys counts more than one with 10. - Sample size (n) is the total resolved tickets across projects in that era.
⚠ Caveats — read before quoting.
- Correlation ≠ causation. Team size, scope mix, hiring, and process changes co-vary with AI adoption.
- Sample size matters. Two thresholds:
- n < 30 per side — value is dimmed and Δ% is replaced with ⚠ to prevent over-reading a percentage change. Drill into Per-project breakdown to see which project is thin.
- n < 10 in Before for a Compare project — see auto-fallback below.
- Compare → After-only auto-fallback.If a project has < 10 pre-AI tickets while configured as “Compare”, we silently switch it to “After only” and label the card with the reason (e.g. "not enough pre-AI data (n=3) — showing After only"). Cleaner than printing a spurious Δ%.
- Work-mix shift. If the team did mostly bugs before AI and mostly features after (or vice-versa), the comparison is unfair. Mention this when quoting.
When to switch modes.
- Off — project never used AI, or the comparison adds no value.
- Before only— historical baseline view for projects we're about to introduce AI into.
- After only— new project / new team without pre-AI history. Or when the pre-AI history is contaminated and you don't want it weighing the rollup.
- Compare — the headline view for any team that has 1+ month of data on each side.
All per-project metrics — quick reference
Cycle Time
view on ADS →businessHours(firstTransitionTo(cycle_time.startStatuses) → completionAt) − pauseTime
Unit: days
Default displayed value is the active variant: business hours minus cycle_time.pauseStatuses. The page also shows the raw wall-clock variant side by side. Start statuses are configurable per project.
Lead Time
view on ADS →businessHours(createdAt → completionAt) − pauseTime
Unit: days
Default displayed value is the active variant: business hours minus lead_time.pauseStatuses. The page also shows raw wall-clock Lead Time (completionAt − createdAt). completionAt comes from the canonical completedIssueSet.
Time to Ready
view on ADS →firstTransitionTo(ready.readyStatuses) − createdAt
Unit: hours
Ready statuses configurable. Defaults: Selected for Development, Approved for Sprint, Approved, Ready, Triaged, In Progress, In Development. NOTE: the per-project Time-to-Ready page measures the EVENT cohort (first-ready in range); Overview measures the same metric on the completed-in-range cohort. Both formulas are valid for different questions.
Throughput
view on ADS →count(completedIssueSet) per ISO week
Unit: tickets/week
Bar chart of weekly completions; AI vs human stacked. Uses the canonical completedIssueSet — same as Overview's Issues done.
Bug Rate
view on ADS →count(issues where issueType ∈ bugIssueTypes) created per week
Unit: bugs/week
Issue type list configurable; defaults to Bug, Incident.
Planned vs Unplanned
view on ADS →unplanned / total × 100, where unplanned = issueType ∈ unplannedTypes OR label ∈ unplannedLabels
Unit: % unplanned
Tracks interrupts vs deliberate work. Defaults: unplanned types = [Bug, Incident]. NOTE: this is NOT 'no sprint at create time' — sprint info isn't tracked in our schema.
Status Breakdown
view on ADS →business time spent in each workflow status for issues with transitions in the selected range
Unit: hours per status
Shows TIME spent in each workflow stage, not a count snapshot. This is the per-status flow distribution — i.e. which stage absorbed the most time on average.
Pull Requests
view on ADS →count(PRs merged) per week, by author segment
Unit: PRs/week
Lists every merged PR; group/sort/export available.
PR Cycle Time
view on ADS →businessHours(createdAt → completedAt)
Unit: hours
Total PR lifecycle from open to merge, measured through the project's working calendar. PRs are filtered by completedAt in range.
Time to First Review
view on ADS →businessHours(createdAt → firstReviewAt)
Unit: hours
How long until the first reviewer touched the PR — the first-touch metric, measured through the project's working calendar and filtered by PR createdAt in range. Sibling to PR Review Duration (below): together they decompose the full review window into 'wait to start' and 'review itself'.
PR Review Duration
view on ADS →completedAt − firstReviewAt
Unit: hours
How long the review takes once it has started. Sibling metric to Time to First Review. The Overview AI Impact 'PR review p50' row uses THIS formula.
PR Acceptance Rate
view on ADS →completed / (completed + abandoned) × 100
Unit: %
Quality signal: how much of what's submitted gets in.
Time to First PR
view on ADS →businessHours(first transition into time_to_first_pr.startStatuses → first linked PR createdAt)
Unit: hours
How long from starting a ticket until code is up for review. The start status list and draft-PR inclusion are configurable; defaults are In Progress / In Development and include drafts. Negative deltas are clamped to 0.
Deployment Frequency
view on ADS →count(deployments) per week
Unit: deploys/week
DORA key #1. Only succeeded deployments (result='succeeded'), anchored on finishedAt.
Change Failure Rate
view on ADS →failed / total deploys × 100
Unit: %
DORA key #4. failed, partiallySucceeded and canceled count toward 'failed'; manualFailure=true forces failure, and manualFailure=false marks a non-success result as not-failure without making it a successful deploy. inProgress is excluded. Lower is better.
Lead Time for Changes
view on ADS →businessMsBetween(pr.completedAt, firstSuccessfulDeploy.finishedAt) in hours
Unit: hours
DORA key #2. Code-to-prod latency: business hours from PR merge to the first successful deployment for the same ADO project after the merge. Weekends and off-hours are excluded so blockers outside the team's working calendar don't inflate the number.
Flow metrics (newer)
WIP — current Work in Progress
How many tickets are open in an active state right now. Pause statuses are counted separately as Blocked WIP, not dropped. The Overview's WIP KPI is the same active number.
count(open issues where status ∈ activeStatuses - pauseStatuses)
Example: WIP = 270 means 270 tickets are in In Progress / In Review / QA / Testing / Ready For Live / etc right now.
Tells you: High WIP often means the team is overloaded or losing focus. Compare to throughput — if WIP grows while throughput stalls, the team is pulling more in than it ships.
Time in Status / Flow
Shows where work actually waits — by stage of the workflow. The Flow page also flags the slowest stage as the bottleneck.
for each ticket, sum hours between consecutive status transitions; aggregate per stage (median, p75, p90)
Example: If In Review median = 18h and Coding median = 4h, the bottleneck is reviewer availability — not engineering speed.
Tells you: Long 'In Review' median = reviewers slow. Long 'QA' median = handoffs blocked. Long 'In Progress' = scope creep or too much WIP.
Where Work Waits — Active Flow Bottlenecks (Overview)
Bar list of active workflow stages ordered by median wait, with the slowest banner-highlighted as the team-wide active bottleneck. Audit16 P0 split: this card answers 'where does started work get stuck' — separate from where work waited BEFORE being picked up.
across projects in scope: median over per-project medianHours per stage; bulk-computed via work-waits-bulk.ts in one SQL pass. End statuses (Done · Closed · Released On Live · Released On Stage · Resolved) are EXCLUDED entirely. Intake/backlog statuses (New · Open · Backlog · To Do · Approved · Selected for Development · Approved for Sprint · Ready · Triaged) are routed to the sibling 'Backlog / Intake Aging' card.
Tells you: The single most useful number for asking 'where do we lose time once work has started'.
Backlog / Intake Aging (Overview, sibling card)
Bar list of intake/backlog stages ordered by median time waiting BEFORE execution started. Useful for refinement/triage health — not the same question as flow bottlenecks.
same compute as Active Flow Bottlenecks, but filtered to intake-only statuses: New · Open · Backlog · To Do · Approved · Selected for Development · Approved for Sprint · Ready · Triaged
Tells you: If this card's medians are huge while Active Flow Bottlenecks is healthy, the team isn't slow — they have too much pre-execution debt.
Longest Stuck Right Now
Triage list: the 8 tickets in the team that have been stuck in active states the longest.
active tickets sorted by max(now − firstActiveTransition, now − createdAt) descending
Tells you: Direct action list. Click into them, find out why, decide: finish, drop, or unblock.
Data flow & freshness
- JIRA + ADO are the external sources of truth for issues, transitions, comments, PRs and deployments. Agent accounts, project metadata, repo/pipeline mappings, calendars, metric configs and exclusions are local app settings in SQLite.
- Pressing Sync kicks off a worker pool that fetches external data via REST and writes to local SQLite. The page itself never talks to JIRA/ADO.
- SQLite lives on a Kubernetes
PersistentVolumeClaim— survives redeploys and pod restarts. - Every metric page is a pure read from SQLite. Metric range/assignee/aggregation filters are applied by the server while computing the metric; DataTable search, column filters, sorting, grouping, pagination and export are client-side over the already-loaded rows.
JIRA sync — what we actually do
- One paginated call per page-of-50.
POST /rest/api/3/search/jqlwithexpand=changelogandfields: [..., comment]— the response returns the issue, its full status changelog AND its comments INLINE for each ticket. - Per-issue fallback only when needed. If JIRA reports the inline changelog or comments are paginated (total > what fit in one page), we call
/issue/<key>/changelogor/issue/<key>/commentfor that specific issue. Verified strictly — we'd rather make one extra HTTP call than silently drop data. - Incremental. Each project records
lastSyncedAt; subsequent runs only refetch issues whoseupdated >= lastSyncedAt. - Resilience. Every JIRA request has a 30-second hard timeout (
AbortController). On 429 / 502 / 503 / 504 we retry up to 5× with exponential backoff or the server'sRetry-Afterheader — so a transient throttle never kills the sync. - Concurrency. Up to 3 projects in flight via a sliding-window worker pool (a slot frees the moment any project finishes; no batch barriers). Inside each project, up to 5 issue requests in parallel. Per-project hard timeout: 30 minutes (only ever needed for a truly hung response — IT's 11.8k-issue cold sync now finishes in ~1-2 min thanks to inline data).
- Audit trail. Every project sync attempt — success or error — is written to
sync_runs. See /logs for the last-24h dashboard, the most-recent failure's full error message, and a sortable history of all runs.
ADO sync — what we actually do
- Repo mapping. A one-time discovery walks each ADO project and registers repos whose default branch references a known JIRA project key, storing the relation in
ado_repos. - Pipeline discovery. For every ADO project where a mapped repo lives, every classic Release Definition is registered as an
ado_pipeline_configsrow. If a project has no Release Definitions and the "Also discover YAML/build deploy pipelines" toggle is on, build pipelines whose name matchesdeploy / release / cd / prod / publishare added as a fallback. - PR sync. Pulls merged + abandoned PRs into
pull_requests; first-review touches and merge times come from the PR's thread data, so PR cycle / review / acceptance metrics work without extra wiring. - Deployment sync. Each pipeline's recent runs go to
deploymentswith a rolled-up environment result:inProgress / succeeded / failed / partiallySucceeded / canceled. Deployment Frequency and Change Failure Rate use the same classifier: succeeded counts as a successful deploy unless manualFailure=true, failed/partiallySucceeded/canceled enter the CFR denominator, manualFailure=true forces a change failure, manualFailure=false marks a non-success result as not-failure, and inProgress is excluded from the CFR denominator.
Where to see what happened
- /logs — last 500 sync runs with status, duration, issues fetched, full error message. Top-of-page KPIs show OK / failed counts for the last 24 h.
- On /sync and /projects: any project whose most recent
sync_runsrow is an error gets a red ⚠ failing chip. Hover for the reason; click to jump straight to/logs. - While a sync is running, a small spinner appears next to the project key on both pages — the polling status comes from
/api/sync/statusevery 2 s. - The Sync page has a Clear synced data (keep config & agents) button if you need to force a full re-fetch — wipes synced issues / PRs / deployments + resets sync cursors but preserves all per-project configuration and the agent roster.
Filters & configuration
Top-bar (Overview)
- Range — 7 / 30 / 90 / 180 days. Same length immediately before = the "previous period" the Δ% chips compare against.
- Favorites only — restrict to projects you starred on /projects.
- Projects — explicit multiselect. Top of the dropdown: Select all · None (= all) · Invert · Favorites for bulk operations. Closes on outside click + Esc.
- Type — Task / Story / Bug / Epic / Spike. Filters every KPI, trend bucket, AI impact row, top-team count and longest-stuck list.
- Priority — Highest..Lowest. Same scope as Type.
Per-project (metric pages)
- Range — 7 / 14 / 30 / 90 days or custom dates; Assignee (All / AI agents / Humans) and the aggregation toggle (median / p75 / p90) where relevant.
- DataTable controls on every list — global search, per-column filters (toggle the "Filters" button in the toolbar), sortable headers, group-by (any column with a label), paginate (25 / 50 / 100 / 200 / All), export the visible view as CSV or JSON. All state is persisted via localStorage per-table, so reopening the page returns you to the same view.
- Exclude an outlier — open /exclusions. Issue exclusions write
scope = "all"and apply across issue metrics plus linked-PR lanes; PR-only exclusions remove a specific PR from PR-level lanes.
Per-project configuration
- Project Configuration page (link from any project page) — edit the status / type lists for every metric. Defaults are audit-derived from the actual data of all 35 synced projects (see table above); override only the names your project uses differently.
- ADO repos & pipelines on the same page — see/add/remove the mapped repos and registered release/pipeline definitions for the project.
- Favorites (★ on any project tile) — pin to the top of /projects and the Overview Projects dropdown.
- Disable a project — hides it from Overview / Top Teams / per-project rollups but doesn't delete data. Toggle on /sync.
- Import / Export settings — the JSON block on /sync exports the entire config (project meta + metric configs + ADO repos / pipelines + calendars + exclusions + agents + optionally the synced issues / PRs / etc) and the same shape imports back. Granular checkboxes pick which parts.
What this does NOT do (yet)
- Track AI use outside JIRA/ADO — pure chat sessions, ad-hoc analyses without a ticket, off-platform planning. No data source.
- Track PR reviewers — only PR authors today (Veronica/Nikolaos as approvers not yet counted).
- Send digests — no email/Slack push; you open the page.
- Auto-sync — manual trigger only.
- Public access — internal AKS, VPN required.
Data Quality panel
Bottom of Overview. Seven honest checks against the data the rest of the page is computed from, rendered as a 4-column table (Check · Status · Current value · What it means). Hover the ⓘ on each column header for what that column shows; hover the ⓘ next to each check name for Formula · Example · When to worry · What to do. Amber = a caveat to mention when quoting the rollup; not necessarily a bug.
- Sync freshness — count of projects whose last sync (MAX(startedAt)) is older than 24h. Stale > 24h means quoted numbers may lag real-world JIRA state.
- Latest sync status — for each project, the status of the MOST RECENT sync_runs row. This is the current-health signal: green here means every project's data came from a successful pull, even if older runs in the 24h window failed.
- Historical errors (last 24h) — count of
sync_runs.status = 'error'rows in the last 24h. Transient noise unless Latest sync status above is also red. Was previously merged with the row above; split out so a recovered project doesn't look broken. - Metric configs — projects missing one or more of the 11
metric_configsrows. Compute falls back toDEFAULT_CONFIGSfor missing rows, but a missing row often signals an unfinished onboarding. - AI Period coverage — number of projects with
ai_period_settings.mode != 'off'. Drives Before vs After AI. When this is0/N, the Before vs After AI section auto-hides. - Stage config coverage — semantic check: for each status currently in use (≥1 unresolved issue OR ≥50 transitions in 30d), is it listed in every observing project's
time_in_status.stages? If not, Where Work Waits silently drops it. Cancellation statuses (Removed / Deprecated / Cancelled / Won't Do / Rejected / Duplicate) are whitelisted — they intentionally end tickets without delivering value, not workflow waypoints. Also checks that everywipconfig haspauseStatusespopulated. - PR ↔ JIRA linkage — per project, share of completed PRs whose branch name or PR title contains a JIRA issue key. <60% means PR/JIRA join metrics for that project are unreliable; quote PR-only metrics (PR Throughput, Review Duration) instead.
Migration when defaults change. When DEFAULT_CONFIGS in src/lib/config.tsgains new statuses, stored project configs don't auto-update — they only get upgraded on the next page visit via the LEGACY_DEFAULTS exact-match in ensureDefaultConfigs(). To proactively migrate all 35 projects at once, run npm run db:migrate-configs (calls ensureDefaultConfigsfor every project; prints before/after counts of wait-status presence). The cancellation whitelist excludes Removed/Deprecated/Cancelled/Won't Do/Rejected/Duplicate statuses.
Change Failure Rate weighting. The Before vs After AI rollup's CFR row is a weighted average across projects. The weight is totalDeploymentCount from the shared DORA classifier (successful deployments plus finished non-success deployments, with manual failure/not-failure overrides applied), NOT just successful deploys — otherwise a project with high failure rates gets under-weighted and the rollup looks optimistic. Throughput / Deploys-per-week n-counters still use successfulDeploymentCount (those measure delivery cadence, not exposure to failure).
AI Narrative Layer
The dashboard has a content-aware AI layer that writes the executive narrative on top of the deterministic metrics. The AI never calculates metrics; it explains pre-computed numbers from the evidence pack and cites evidence IDs for every claim. When AI is off or fails, the deterministic templated narrative fills in seamlessly.
How a request becomes a narrative
computeEngineeringFlowReportbuilds the deterministic JSON (KPIs asMetricNumberwith current/previous/deltaPct/n/confidence, AI-owned + AI-touched cohorts, team health matrix, longestStuck, warnings).buildEvidencePackflattens the report into evidence facts (kpi.*, ai.owned.*, ai.touched.*, top_team.*, team_health.*, active_wait.*, intake_aging.*, data_quality.*).- The variant-specific prompt + the evidence pack are sent to Azure OpenAI (
gpt-5.4via the v1 Responses API with strict Structured Outputs). - A deterministic verifier validates that every
evidenceIdscitation in the narrative exists in the pack. If invalid, it retries once with the errors appended to the prompt; if still invalid, the deterministic narrative fallback kicks in. - On success, the structured
ReportNarrativeJSON is wired into Page 1 of the PDF (with a small AI-narrated chip in the footer) and into the JSON response (narrativefield).
Report variants
Same evidence pack, different writing instructions. All return the same ReportNarrative JSON shape so the PDF and Teams renderers can be variant-agnostic.
| Variant | Audience | Length | Reasoning effort |
|---|---|---|---|
leadership-brief (default) | Exec leadership — 60-second Teams brief | ≈ 200 words | low |
executive-pdf | Polished 4–6 page PDF for leadership review | ≈ 4 pages | medium |
diagnostic-full | Team leads / Eng managers — detailed drill-down | ≈ 6+ pages | high |
ai-impact-deep-dive | AI initiative — where AI helps / hurts | ≈ 3 pages | high |
bottleneck-hunter | Active flow + intake aging detail | ≈ 2 pages | medium |
data-quality | Trust assessment — can leadership rely on the numbers? | ≈ 1 page | low |
team-comparison | Teams ranked, neutral language | ≈ 2 pages | medium |
meeting-prep | Talking points for next meeting | ≈ 1 page | low |
full | Everything — kitchen sink | ≈ 8+ pages | high |
/overview AI brief banner
Sticky banner at the top of /overview rendering narrative.bottomLine + the top 4 keySignals for the current scope. Auto-refreshes when scope or range changes. Compact "AI brief unavailable" line + retry button when the AI service is off / down. Manual refresh button bypasses any cache.
Cohort and structural extensions
- AI-owned: strict attribution — agent is the assignee at completion. The historical AI Impact rows. Available as
report.aiImpact.owned. - AI-touched: broader cohort — agent is assignee or reporter or comment author or transition author on the ticket. Surfaces AI participation that isn't visible from assignee alone. Omitted when no AI activity in scope. Available as
report.aiImpact.touched. - Team health matrix: every top team now carries
leadP50Hours,cycleP50Hours,timeToReadyP50Hours,activeWipNow,blockedWipNow,topBottleneckStage,topBottleneckHours,aiOwnedPct. Same canonical cohort as headline throughput. - Longest stuck: top 8 active tickets sorted by
max(now − firstActiveTransition, now − createdAt)with assignee + isAgent flag — drives narrative recommendations. - Warnings: data-quality-derived findings (
sync_freshness,stage_coverage,ai_period,sample_size,data_quality) — the narrative cites them in the dataQuality section. - Period boundaries:
period.start / end / previousStart / previousEnd / rangeDayson every report so the period is unambiguous. - Versioning:
reportVersion: "engineering-flow.v8"andmetricDefinitionVersion: "2026-05-24.audit18.v1"for reproducibility.
API endpoints
POST /api/reports/engineering-flow # JSON report (full body + optional narrative)
POST /api/reports/engineering-flow/pdf # PDF render (narrative wired into Page 1)
POST /api/reports/narrative # narrative-only (faster — used by the /overview banner)
POST /api/reports/parse-intent # natural language → strict ReportRequest JSON
GET /api/reports/snapshots # list persisted snapshots
GET /api/reports/snapshots/[id] # fetch one snapshot
Request body for the first three:
{
"scope": { "projectKeys": ["AN","EVO",...], "name": "Leadership scope" },
"rangeDays": 30,
"variant": "leadership-brief", // or any of the 9 variants above
"depth": "brief" | "standard" | "deep",
"language": "en" | "ru",
"includeNarrative": true, // default true; set false to skip the LLM
"persist": false, // set true to save the snapshot for follow-ups
"filters": { "issueType": "...", "priority": "..." }
}Trust + fallback behaviour
- AI never blocks reporting. When
AI_REPORTS_ENABLED=false, API key missing, Azure 5xx, schema invalid, or verifier still failing after retry →narrative: nullis returned and the deterministic templated narrative is used in the PDF. - The verifier (cheap second pass) checks every
evidenceIdscitation against the evidence pack. Citations to unknown IDs reject the narrative. High-confidence recommendations with empty evidenceIds reject. Gated byAI_VERIFIER_ENABLED; can be skipped without disabling the rest. - Snapshots persist the full report + narrative under a UUID. The intent parser, the banner, and future Teams bots can reference snapshot IDs to resolve evidence citations back to specific numbers.
Environment
AI_REPORTS_ENABLED=true AZURE_OPENAI_ENDPOINT=https://<resource>.openai.azure.com AZURE_OPENAI_API_KEY=<rotated> AZURE_OPENAI_REPORT_MODEL=gpt-5.4 AZURE_OPENAI_API_VERSION=preview AZURE_OPENAI_REASONING_EFFORT=low | medium | high AZURE_OPENAI_VERBOSITY=low | medium | high AZURE_OPENAI_MAX_OUTPUT_TOKENS=20000 # reasoning models burn many tokens internally AZURE_OPENAI_TIMEOUT_MS=60000 AI_VERIFIER_ENABLED=true | false AI_REPORT_CACHE_TTL_SEC=300 # safety net; primary cache is content-addressed AI_REPORT_DEFAULT_VARIANT=leadership-brief AI_REPORT_DEFAULT_LANGUAGE=en
Engineering Flow Report endpoints
Two POST endpoints that return the same report shape, one as JSON and one rendered to a 2-page A4 PDF (executive brief on page 1, detail tables on page 2). Both are deterministic from {scope, rangeDays, now}.
POST /api/reports/engineering-flow
POST /api/reports/engineering-flow/pdf
Body:
{
"scope": { "projectKeys": ["AN","EVO","ADS"], "name": "Leadership scope" },
"rangeDays": 30
}Same compute as Overview — Issues done, AI Impact (with sample sizes), Top Teams, Where Work Waits, Agent Activity, AI Period rollup, Data Quality. No new metrics. Use the JSON endpoint when you want to embed the data into another system; use the PDF endpoint when you want a shareable artifact.
Questions? The repo lives at custom-agile-metrics-app. Specs and plans for ongoing work live under docs/superpowers/.