diff --git a/.claude/scripts/forecast-html.py b/.claude/scripts/forecast-html.py
deleted file mode 100644
index 8fb78de6..00000000
--- a/.claude/scripts/forecast-html.py
+++ /dev/null
@@ -1,503 +0,0 @@
-#!/usr/bin/env python3
-"""Render a forecast.py JSON dump as a single self-contained HTML report.
-
-Companion to forecast.py:
- 1. python3 forecast.py --done … --open … --json-out forecast.json
- 2. python3 forecast-html.py forecast.json report.html
-
-Takes only file-path args and never touches the network, so it's safe to run
-anywhere and prompt-free when allow-listed by exact path.
-
-Charts are plain HTML/CSS bars (no JS, no external assets) so the file stays a
-single static document safe to open anywhere or attach to chat. Per the dataviz
-method: horizontal magnitude bars with rounded data-ends anchored to a baseline,
-one primary hue for magnitude, a reserved status palette (breach/aging/healthy)
-that ALWAYS ships with an icon + text label + sort order (never color alone), and
-native hover tooltips via SVG/element titles.
-
-To re-skin for your brand, swap the C_* palette constants and the HEADLINE/BODY
-fonts below (and drop in a logo in the header if you want one). The default
-palette is validated for contrast; keep an eye on it if you change the hues.
-
-Usage: python3 forecast-html.py [in_json] [out_html]
- (defaults: /tmp/forecast.json → /tmp/forecast_report.html)
-"""
-
-import html
-import json
-import sys
-from pathlib import Path
-
-IN = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/forecast.json")
-OUT = Path(sys.argv[2] if len(sys.argv) > 2 else "/tmp/forecast_report.html")
-
-# --- Report palette (swap these hexes for your brand; contrast-validated) ---
-C_RED = "#E10321" # Core Red
-C_BLUE = "#0B4CDB" # Core Blue — primary magnitude hue for bars
-C_TEAL = "#5EDEFC" # Core Teal
-C_TAN = "#FBF6F0" # Core Tan — page background
-C_DARK = "#041531" # Core Dark — primary text
-C_TAN_SH1 = "#F2ECE3" # Tan Shade 1 — note bg, bar tracks
-C_TEAL_T2 = "#DBF8FF" # Teal Tint 2 — pill bg (AAA 13.75 w/ Blue Shade 3)
-C_BLUE_SH3 = "#06244F" # Blue Shade 3 — secondary text, pill text
-C_GRAY_T2 = "#D3D8E5" # Gray Tint 2 — hairlines
-
-# Reserved status palette (validated: contrast >=3:1 on white & tan; the red/amber
-# CVD closeness is in the 8-12 floor band, legal here because every status mark
-# ships an icon + text label + sort order — secondary encoding, never color alone).
-ST_BREACH = "#C21807" # critical — over the 85% SLE
-ST_WARN = "#B8730E" # warning — aging (between typical and SLE)
-ST_HEALTHY = "#1F8A4C" # good — within the typical line
-BAND = {
- "breach": {"color": ST_BREACH, "icon": "\U0001F534", "label": "over SLE"},
- "warn": {"color": ST_WARN, "icon": "\U0001F7E1", "label": "aging"},
- "healthy": {"color": ST_HEALTHY, "icon": "\U0001F7E2", "label": "healthy"},
- "none": {"color": C_BLUE, "icon": "•", "label": ""},
-}
-
-HEADLINE_FONT = "'Degular Display', Degular, 'Avenir Next', 'Helvetica Neue', Arial, sans-serif"
-BODY_FONT = "'Aktiv Grotesk', 'Helvetica Neue', Helvetica, Arial, sans-serif"
-
-# Optional brand logo, inlined so the report stays a single self-contained file.
-# Leave empty for the neutral template; set to an inline string to
-# show a logo in the header.
-LOGO_SVG = ""
-
-
-def esc(v):
- return html.escape(str(v)) if v is not None else "—"
-
-
-def card(name, value, pill, detail_lines, accent=None):
- detail = " ".join(d for d in detail_lines if d)
- pill_html = f'{esc(pill)}' if pill else ""
- style = f' style="border-left:4px solid {accent}"' if accent else ""
- return (f'
{esc(name)}
'
- f'
{esc(value)}
{pill_html}'
- f'
{detail}
')
-
-
-def dual_card(name, v50, v85, detail_lines, accent=None):
- """Card that gives BOTH confidence figures equal billing: 50% for the
- internal/team read, 85% (accent) for the stakeholder/commit read."""
- detail = " ".join(d for d in detail_lines if d)
- style = f' style="border-left:4px solid {accent}"' if accent else ""
- return (f'
{esc(name)}
'
- f'
'
- f'
50% · team
'
- f'
{esc(v50)}
'
- f'
85% · stakeholder
'
- f'
{esc(v85)}
'
- f'
'
- f'
{detail}
')
-
-
-def hbar(rows, axis_max, unit="", axis_lines=None, clip=None):
- """Horizontal magnitude bars. rows = [(label, value, value_label, color, tip)]
- or 6-tuples with a trailing href — when present the row label becomes a link.
- axis_lines = [(value, label)] reference markers. clip caps the axis (over-cap
- bars render full-width with a real-value label + a clipped edge)."""
- axis_max = max(axis_max, 1)
- marks = ""
- for val, lab in (axis_lines or []):
- pos = min(100, 100 * val / axis_max)
- marks += (f'
{esc(lab)}
')
- region = f'
{marks}
' if marks else ""
- body = ""
- for row in rows:
- label, value, vlabel, color, tip = row[:5]
- href = row[5] if len(row) > 5 else None
- label_html = (f'{esc(label)}'
- if href else esc(label))
- capped = clip is not None and value > clip
- shown = min(value, axis_max)
- w = 100 * shown / axis_max
- edge = "bar-clip" if capped else ""
- body += (
- f'
{label_html}
'
- f'
'
- f'
'
- f'
{esc(vlabel)}
')
- return f'
{region}{body}
'
-
-
-def col_chart(values, labels, vmax, mean=None, outliers=None):
- """Vertical throughput columns, one per week, height-encoded. `outliers` is a
- set of 0-based indices to flag (amber bar + ▲ mark + title)."""
- vmax = max(vmax, 1)
- outliers = outliers or set()
- cols = ""
- for idx, (v, lab) in enumerate(zip(values, labels)):
- h = 100 * v / vmax
- is_out = idx in outliers
- barcls = "cbar out" if is_out else "cbar"
- ttl = f'{lab}: {v}' + (" — statistical outlier" if is_out else "")
- mark = '▲' if is_out else ""
- cols += (f'
'
- f'{mark}{v}'
- f''
- f'{esc(lab)}
')
- mean_line = ""
- if mean is not None:
- pos = 100 * mean / vmax
- mean_line = (f'
mean {mean}
')
- return f'
{mean_line}{cols}
'
-
-
-def main():
- data = json.loads(IN.read_text())
- m = data["meta"]
- series = data["series"]
- team = series[0]
- comps = series[1:]
- cycle = data.get("cycle")
- wip = data.get("wip", [])
- bands = data.get("wip_bands", {})
- policy = m.get("outlier_policy", "keep")
-
- def outlier_idx(s):
- return {o["wk"] - 1 for o in s.get("outliers", [])}
-
- def outlier_caption(s):
- outs, note = s.get("outliers", []), s.get("outlier_note")
- if not outs and not note:
- return ""
- parts = []
- if outs:
- marks = ", ".join(f'w{o["wk"]} = {o["value"]} (robust z {o["z"]:+.1f})' for o in outs)
- if policy == "keep":
- parts.append(f'▲ {marks} flagged as a statistical outlier, still '
- 'in the forecast sample (policy: keep). A single '
- 'artifact week — e.g. a bulk ticket close — biases the '
- 'forecast optimistic; re-run with --outliers '
- 'drop or winsorize to adapt.')
- else:
- parts.append(f'▲ {marks} flagged as a statistical outlier.')
- if note:
- parts.append(f'Applied: {esc(note)}.')
- return f'
'
- + col_chart(team["sample"], wk_labels, max(team["sample"]),
- mean=team["mean"], outliers=outlier_idx(team))
- + outlier_caption(team))
-
- # Q1 — weeks to clear, all series
- q1_max = max(s["q1"]["w95"] for s in series)
- q1_rows = [(s["name"], s["q1"]["w85"], f'{s["q1"]["w85"]}w',
- C_BLUE if s is team else C_BLUE_SH3,
- f'{s["name"]}: 50% {s["q1"]["w50"]}w, 85% {s["q1"]["w85"]}w '
- f'({esc(s["q1"]["date85"])}), 95% {s["q1"]["w95"]}w')
- for s in series]
- q1 = ('
Q1 · How long to clear each queue
'
- '
bar = 85% confidence (commit-safe); hover for the 50/85/95 range + date
'
- + hbar(q1_rows, q1_max, unit="w"))
-
- # Q2 — items by date
- q2_max = max(s["q2"]["c50"] for s in series) or 1
- q2_rows = [(s["name"], s["q2"]["c85"], f'≥ {s["q2"]["c85"]}',
- C_BLUE if s is team else C_BLUE_SH3,
- f'{s["name"]}: ≥95% {s["q2"]["c95"]}, ≥85% {s["q2"]["c85"]}, ≥50% {s["q2"]["c50"]}')
- for s in series]
- q2 = (f'
Q2 · How many done by {esc(m["by_date"])}
'
- '
bar = at least this many at 85% confidence
'
- + hbar(q2_rows, q2_max))
-
- note = ('
Component forecasts are independent — the '
- 'TEAM line is not the sum of the component lines (variances don’t add). '
- 'Trust TEAM for whole-team promises; use component lines to spot bottlenecks '
- 'and lopsided queues.
No cycle-time sample in this run (no `created` field pulled).
'
- cards = [card("50% typical", f"{cycle['p50']:.0f}d", None, ["half of items finish within"]),
- card("85% SLE", f"{cycle['p85']:.0f}d", "commitment",
- ["the per-item promise — quote this"], accent=C_BLUE),
- card("95% worst", f"{cycle['p95']:.0f}d", None,
- [f"longest {cycle['max']:.0f}d · n={cycle['n']}"])]
- hist = cycle["hist"]
- hmax = max((b["count"] for b in hist), default=1)
- rows = [(b["label"] + "d", b["count"], str(b["count"]), C_BLUE,
- f'{b["count"]} items resolved in {b["label"]} days')
- for b in hist]
- chart = ('
Cycle-time distribution
'
- f'
{esc(m["basis"])}, calendar days — where the '
- 'time actually goes
' + hbar(rows, hmax))
- note = ('
Read with care: this is '
- f'{esc(m["basis"])}, which includes backlog wait — a thick '
- '30–90d shoulder inflates the 85% line. The tighter, standard measure '
- 'is started→Done (active time via the changelog); re-run the '
- 'forecast with --changelog for the true SLE.
')
- return f'
{"".join(cards)}
{chart}{note}'
-
- # ---------- Aging WIP panels (split: actionable ≤180d vs parked >180d) ------
- PARK = 180 # days: above this an item is "parked" (almost certainly stale)
- active_wip = [w for w in wip if w["age"] <= PARK]
- parked_wip = [w for w in wip if w["age"] > PARK]
-
- browse = m.get("jira_browse_base") # e.g. https://you.atlassian.net/browse
-
- def _wip_rows(items):
- rows = []
- for w in items:
- b = BAND.get(w["band"], BAND["none"])
- tip = (f'{w["key"]} · {w["status"]} · {w["age"]} {esc(m["age_label"])}'
- f' — {b["label"] or "in flight"}')
- href = f'{browse}/{w["key"]}' if browse else None
- rows.append((f'{b["icon"]} {w["key"]}', w["age"], f'{w["age"]}d',
- b["color"], tip, href))
- return rows
-
- _legend = ('
'
- f'\U0001F534 over SLE — pull now'
- f'\U0001F7E1 aging — watch'
- f'\U0001F7E2 within typical'
- '
')
-
- def build_wip_active():
- if not wip:
- return '
No in-flight items in the aging-WIP statuses.
'
- if not active_wip:
- return ('
Every in-flight item has been open >'
- f'{PARK}d — see the Parked WIP tab.
')
- sle85 = cycle["p85"] if cycle else None
- sle50 = cycle["p50"] if cycle else None
- # axis just past the oldest active item (no clipping on this page), with
- # enough room that the SLE line never sits at the far edge.
- axis = max(int(max(w["age"] for w in active_wip) * 1.08),
- int((sle85 or 1) * 1.25), 1)
- axis_lines = []
- if sle50 is not None:
- axis_lines.append((sle50, f'typical {sle50:.0f}d'))
- if sle85 is not None:
- axis_lines.append((sle85, f'85% SLE {sle85:.0f}d'))
- b = sum(1 for w in active_wip if w["band"] == "breach")
- wn = sum(1 for w in active_wip if w["band"] == "warn")
- h = sum(1 for w in active_wip if w["band"] == "healthy")
- summary = (f'
{b} over SLE · {wn} aging · {h} healthy '
- f'({len(active_wip)} in flight ≤{PARK}d). '
- + (f'{len(parked_wip)} older items (>{PARK}d) are on the '
- 'Parked WIP tab.' if parked_wip else '')
- + ' Ticked lines are the SLE bands.
')
- return ('
Q4 · Aging work in progress — pull these before starting new work
'
- + summary + _legend + hbar(_wip_rows(active_wip), axis, unit="d",
- axis_lines=axis_lines))
-
- def build_wip_parked():
- if not parked_wip:
- return f'
No items parked beyond {PARK} days. \U0001F389
'
- oldest = max(w["age"] for w in parked_wip)
- sle85 = cycle["p85"] if cycle else None
- summary = (f'
{len(parked_wip)} items have been in flight '
- f'over {PARK} days'
- + (f' — far past the {sle85:.0f}d SLE' if sle85 else '')
- + '. These are almost certainly stale: confirm they’re still real '
- f'work, or close/split them. Bars scaled to the oldest ({oldest}d).
{chart}')
-
- # ---------- Assemble tabs ----------
- panels = [("Overview", build_overview())]
- if cycle:
- panels.append(("Cycle Time", build_cycle()))
- panels.append(("Aging WIP", build_wip_active()))
- if parked_wip:
- panels.append(("Parked WIP", build_wip_parked()))
- for s in comps:
- panels.append((s["name"], build_component(s)))
-
- # Every tab references throughput/queue data, so surface the Epic-exclusion
- # scope on each one (assembled here so future tabs inherit it automatically).
- epics_chip = ('
'
- '⊘ Epics excluded from all figures
'
- if m.get("epics_excluded") else '')
-
- radios = "".join(f''
- for i in range(len(panels)))
- labels = "".join(f'' for i, (name, _) in enumerate(panels))
- panel_divs = "".join(f'
throughput-based (no story points) · {esc(m['sample_weeks'])} weeks ·
-window: {esc(m['window'])} · {esc(m['trials']):} trials · generated {esc(m['today'])}
-
{('
' + LOGO_SVG + '
') if LOGO_SVG else ''}
-{panel_divs}
-
-
-""")
- print(f"Wrote {OUT} ({len(panels)} tabs: {', '.join(n for n, _ in panels)})")
-
-
-if __name__ == "__main__":
- main()
diff --git a/.claude/scripts/forecast.py b/.claude/scripts/forecast.py
deleted file mode 100644
index 5ac0af58..00000000
--- a/.claude/scripts/forecast.py
+++ /dev/null
@@ -1,952 +0,0 @@
-#!/usr/bin/env python3
-"""
-forecast.py — throughput-based delivery forecast.
-
-Forecasts delivery WITHOUT story-point estimation, using only the team's
-historical weekly throughput (items reaching a Done status) via a probabilistic
-(Monte Carlo) simulation. Replaces sprint commitment / velocity for a Kanban
-team. Forecasts the team as a whole and, optionally, broken down by component.
-
-Four questions, one toolkit:
- 1. "How long to clear N items?" -> weeks-to-complete distribution (Monte Carlo)
- 2. "How many done by ?" -> items-completed distribution (Monte Carlo)
- 3. "What can we promise per item?" -> cycle-time Service Level Expectation (SLE)
- 4. "What should we pull first?" -> aging work-in-progress vs the SLE
-
-Q1/Q2 are the stakeholder-facing FORECAST. Q3/Q4 are the team-facing FLOW
-metrics that make a team behave like Kanban day-to-day: Q3 replaces the story-
-point estimate with a measured per-item commitment, Q4 is the daily-standup
-"finish-before-you-start" artifact.
-
-DATA SOURCE:
- The PRIMARY path is FILE MODE: run fetch.py once (one REST pass — Done + open
- queue + all changelogs inline) to write done.json / open.json, then pass those
- to --done/--open/--changelog here. The engine itself never touches the network
- in file mode; it's pure math over two JSON files, so it's tracker-agnostic (any
- tracker can produce the same JSON envelope — see the SETUP-GUIDE).
-
- PURE-MATH mode (numbers passed in as flags) and LIVE mode (--live, direct REST
- with --jira-base/--project) remain as fallbacks:
-
- --throughput "37,41,28,27,31,55,20" weekly item counts, oldest->newest
- --items N backlog size to clear
- --cycle-times "3,5,8,2,14,..." per-item cycle time in DAYS (created->Done)
- --wip-item "ABC-1234:In Progress:12" one in-flight item (key:status:age_days)
-
-Usage:
- # FILE MODE (preferred): ingest fetch.py's output directly — one command.
- forecast.py --done done.json --open open.json --by-date 2026-07-21 \
- --project ABC --jira-base https://you.atlassian.net
- # add --changelog done.json open.json -> Q3/Q4 switch to started→Done
- # (active time from the changelog)
-
- # PURE-MATH: numbers passed in
- forecast.py --throughput "37,41,28,27,31,55,20" --items 60
- forecast.py --throughput "..." --items 60 \
- --cycle-times "3,5,8,2,14,6,21,4" \
- --wip-item "ABC-1234:In Progress:18" --wip-item "ABC-1250:Code Review:5"
-
- # LIVE (direct REST)
- forecast.py --live --jira-base https://you.atlassian.net --project ABC \
- [--weeks 10] [--done-status "Done"]
-
-Defaults: --by-date = 3 weeks out | --trials 10000 | --seed 42
-
-Exit codes: 0 ok | 2 config/token | 3 curl/network | 4 non-JSON | 5 API error
-"""
-
-import argparse
-import json
-import math
-import os
-import random
-import subprocess
-import sys
-import tempfile
-from collections import Counter, defaultdict
-from datetime import datetime, date, timedelta
-
-SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json")
-JIRA_BASE = None # set from --jira-base in main() (live mode / report links)
-
-
-def backlog_jql(project, statuses):
- """Live-mode open-queue JQL, built from --project + --wip-status."""
- status_in = ",".join(f'"{s}"' for s in statuses)
- return (f"project = {project} AND statusCategory != Done "
- f"AND status in ({status_in})")
-
-
-def exclude_epics(jql):
- """Insert `AND issuetype != Epic` before any trailing ORDER BY. Epics are
- typically placeholders/containers, not deliverable flow — excluded by
- default (override with --include-epics)."""
- marker = " ORDER BY "
- idx = jql.upper().rfind(marker)
- clause = " AND issuetype != Epic"
- return jql + clause if idx == -1 else jql[:idx] + clause + jql[idx:]
-
-
-def _is_epic(node):
- """True if a raw issue node is an Epic (defensive file-mode guard, so a stale
- done.json/open.json that predates the JQL exclusion can't skew the math)."""
- it = (node.get("fields", {}) or {}).get("issuetype") or {}
- return (it.get("name") or "").strip().lower() == "epic"
-
-
-# --------------------------------------------------------------------------- #
-# Atlassian REST plumbing (live mode only; file mode never hits the network)
-# --------------------------------------------------------------------------- #
-def load_token():
- try:
- with open(SETTINGS_PATH) as f:
- cfg = json.load(f)
- except FileNotFoundError:
- sys.exit(f"ERROR: settings file not found at {SETTINGS_PATH}")
- except json.JSONDecodeError as e:
- sys.exit(f"ERROR: settings file is not valid JSON: {e}")
- try:
- auth = cfg["mcpServers"]["atlassian"]["headers"]["Authorization"]
- except KeyError:
- sys.exit("ERROR: mcpServers.atlassian.headers.Authorization not found")
- if not auth.startswith("Basic "):
- sys.exit("ERROR: Authorization header is not Basic auth")
- return auth
-
-
-def run_curl(method, url, auth_header, body=None):
- fd, config_path = tempfile.mkstemp(prefix="atlassian-", suffix=".curlrc")
- try:
- os.fchmod(fd, 0o600)
- with os.fdopen(fd, "w") as f:
- f.write(f'header = "Authorization: {auth_header}"\n')
- f.write('header = "Accept: application/json"\n')
- if body is not None:
- f.write('header = "Content-Type: application/json"\n')
- cmd = ["curl", "-sS", "--fail-with-body", "--max-time", "30",
- "--config", config_path, "-X", method, url]
- if body is not None:
- cmd += ["-d", body]
- result = subprocess.run(cmd, capture_output=True, text=True)
- if result.returncode != 0:
- print(f"ERROR: curl exited {result.returncode}", file=sys.stderr)
- print(result.stderr, file=sys.stderr)
- sys.exit(3)
- return result.stdout
- finally:
- try:
- os.unlink(config_path)
- except OSError:
- pass
-
-
-def parse_response(raw):
- try:
- data = json.loads(raw)
- except json.JSONDecodeError:
- print("ERROR: API did not return JSON. First 500 chars:", file=sys.stderr)
- print(raw[:500], file=sys.stderr)
- sys.exit(4)
- if isinstance(data, dict) and (data.get("errorMessages") or data.get("errors")):
- print("ERROR: Atlassian returned a structured error:", file=sys.stderr)
- print(json.dumps(data, indent=2), file=sys.stderr)
- sys.exit(5)
- return data
-
-
-def jql_all(jql, auth, fields):
- """Paginate /search/jql via nextPageToken; return every issue node."""
- issues, token = [], None
- while True:
- payload = {"jql": jql, "fields": fields, "maxResults": 100}
- if token:
- payload["nextPageToken"] = token
- data = parse_response(
- run_curl("POST", f"{JIRA_BASE}/rest/api/3/search/jql",
- auth, body=json.dumps(payload)))
- issues.extend(data.get("issues", []))
- token = data.get("nextPageToken")
- if not token:
- return issues
-
-
-def jql_count(jql, auth):
- """Exact-ish backlog size via the approximate-count endpoint."""
- data = parse_response(
- run_curl("POST", f"{JIRA_BASE}/rest/api/3/search/approximate-count",
- auth, body=json.dumps({"jql": jql})))
- return int(data.get("count", 0))
-
-
-# --------------------------------------------------------------------------- #
-# Throughput
-# --------------------------------------------------------------------------- #
-def weekly_throughput(auth, weeks, done_statuses, project, include_epics=False):
- """Items resolved per complete ISO week over the lookback window.
-
- Drops the current (partial) week so it doesn't bias the sample low.
- """
- lookback_days = (weeks + 1) * 7
- status_list = ",".join(f'"{s}"' for s in done_statuses)
- jql = (f'project = {project} AND status in ({status_list}) '
- f'AND resolutiondate >= -{lookback_days}d ORDER BY resolutiondate ASC')
- if not include_epics:
- jql = exclude_epics(jql)
- issues = jql_all(jql, auth, ["resolutiondate"])
-
- this_week = date.today().isocalendar()[:2] # (iso_year, iso_week)
- buckets = Counter()
- for it in issues:
- rd = it["fields"].get("resolutiondate")
- if not rd:
- continue
- d = datetime.strptime(rd[:10], "%Y-%m-%d").date()
- key = d.isocalendar()[:2]
- if key == this_week:
- continue # exclude the in-progress week
- buckets[key] += 1
-
- # Keep only the most recent `weeks` complete weeks, oldest -> newest.
- ordered = sorted(buckets.items())[-weeks:]
- return [count for _, count in ordered], ordered
-
-
-# --------------------------------------------------------------------------- #
-# Monte Carlo
-# --------------------------------------------------------------------------- #
-def sim_weeks_to_finish(backlog, sample, trials, rng):
- """Distribution of #weeks to clear `backlog` items."""
- out = []
- for _ in range(trials):
- remaining, w = backlog, 0
- while remaining > 0:
- remaining -= rng.choice(sample)
- w += 1
- if w > 520: # 10yr safety valve (throughput all-zero etc.)
- break
- out.append(w)
- out.sort()
- return out
-
-
-def sim_items_by_date(n_weeks, sample, trials, rng):
- """Distribution of #items completed over `n_weeks`."""
- out = [sum(rng.choice(sample) for _ in range(n_weeks)) for _ in range(trials)]
- out.sort()
- return out
-
-
-def pct(sorted_list, p):
- if not sorted_list:
- return 0
- idx = min(int(round(p / 100 * (len(sorted_list) - 1))), len(sorted_list) - 1)
- return sorted_list[idx]
-
-
-def _median(vals):
- s = sorted(vals)
- n = len(s)
- if n == 0:
- return 0.0
- return float(s[n // 2]) if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
-
-
-def detect_outliers(sample, thresh=3.5):
- """Flag weeks that deviate from the series by a ROBUST modified z-score
- (median + MAD; Iglewicz–Hoaglin). Robust to the outlier itself — unlike a
- mean/σ z-score, which a spike inflates enough to hide. Needs >=4 weeks.
-
- Returns [(index, value, mz), ...] for each flagged week (high OR low)."""
- n = len(sample)
- if n < 4:
- return []
- med = _median(sample)
- mad = _median([abs(x - med) for x in sample])
- if mad > 0:
- scale = 0.6745 / mad
- else:
- # >half the weeks identical → MAD is 0; fall back to mean-abs-deviation
- # (the Iglewicz–Hoaglin recommendation) so we don't divide by zero.
- mean_ad = sum(abs(x - med) for x in sample) / n
- if mean_ad == 0:
- return [] # every week identical → nothing to flag
- scale = 0.7979 / mean_ad # 1 / 1.253314
- return [(i, x, scale * (x - med)) for i, x in enumerate(sample)
- if abs(scale * (x - med)) > thresh]
-
-
-def apply_outlier_policy(sample, flagged, policy):
- """Return (sim_sample, note) per policy. keep: unchanged. winsorize: cap each
- flagged week at the nearest in-band week (preserves sample size). drop: remove
- flagged weeks, but keep >=3 so the simulation still has a sample."""
- if not flagged or policy == "keep":
- return list(sample), None
- fidx = {i for i, _, _ in flagged}
- kept = [x for i, x in enumerate(sample) if i not in fidx]
- if policy == "drop":
- if len(kept) < 3:
- return list(sample), "too few weeks left to drop — kept as-is"
- return kept, f"dropped {len(fidx)} outlier week(s) from the sim sample"
- if policy == "winsorize":
- hi, lo = (max(kept), min(kept)) if kept else (max(sample), min(sample))
- med = _median(sample)
- adj = [(hi if x > med else lo) if i in fidx else x
- for i, x in enumerate(sample)]
- return adj, f"winsorized {len(fidx)} outlier week(s) to [{lo}, {hi}]"
- return list(sample), None
-
-
-# --------------------------------------------------------------------------- #
-# File mode — ingest fetch.py's result files directly (all the ISO-week
-# bucketing, per-component split, and the started→Done math when --changelog
-# files are supplied). This is what makes the whole forecast run a SINGLE
-# allow-listed command: no intermediate flag string, no `$(cat …)`, no heredoc —
-# so it never prompts.
-# --------------------------------------------------------------------------- #
-def _day(s):
- """Date portion of a Jira timestamp like 2026-04-14T13:53:10.217-0400."""
- return date.fromisoformat(s[:10])
-
-
-def _load_nodes(paths):
- nodes = []
- for path in paths:
- try:
- with open(path) as f:
- data = json.load(f)
- except FileNotFoundError:
- sys.exit(f"ERROR: file not found: {path}")
- except json.JSONDecodeError as e:
- sys.exit(f"ERROR: {path} is not valid JSON: {e}")
- nodes.extend((data.get("issues", {}) or {}).get("nodes", []) or [])
- return nodes
-
-
-def _comp_names(node, wanted):
- cs = node.get("fields", {}).get("components") or []
- return [c["name"] for c in cs if c.get("name") in wanted]
-
-
-def _status_transitions(changelog):
- out = []
- for h in (changelog or {}).get("histories", []) or []:
- ts = h.get("created", "")
- for it in h.get("items", []) or []:
- if it.get("field") == "status":
- out.append((ts, it.get("toString")))
- out.sort(key=lambda x: x[0])
- return out
-
-
-def _started_day(node, start_status, reset_statuses):
- """Date the final active stint began (first In Progress, reset on bounce to
- Ready-for-Dev/Cancelled), or (None, reason) if it can't be determined."""
- cl = node.get("changelog") or {}
- total = cl.get("total")
- hist = cl.get("histories", []) or []
- truncated = isinstance(total, int) and total > len(hist)
- start = None
- for ts, to in _status_transitions(cl):
- if to in reset_statuses:
- start = None # bounced to backlog → reset the clock
- elif to == start_status and start is None:
- start = ts # first In Progress of the current stint
- if start is None:
- return None, ("truncated" if truncated else "nostart")
- if truncated:
- return None, "truncated"
- return _day(start), None
-
-
-def synthesize_from_files(args):
- """Populate args.throughput/items/component/cycle_times/wip_item/cycle_basis
- from fetch.py's result files, exactly as if the numeric flags had been passed
- — then the existing pure-math path runs unchanged. Sanity summary → stderr."""
- if not args.open_files:
- sys.exit("ERROR: --done requires --open (both fetch.py files are needed)")
- today = (datetime.strptime(args.today, "%Y-%m-%d").date()
- if args.today else date.today())
- # throughput window: an explicit [--since, --until] (e.g. two calendar quarters)
- # or the trailing -Nd fallback. --today is the aging-WIP now-clock only.
- win_lo = win_hi = None
- if args.since:
- since = datetime.strptime(args.since, "%Y-%m-%d").date()
- until = (datetime.strptime(args.until, "%Y-%m-%d").date()
- if args.until else today)
- win_lo, win_hi = since, until
- drop = {since.isocalendar()[:2], until.isocalendar()[:2]}
- if since <= today <= until: # window includes the live week
- drop.add(today.isocalendar()[:2])
- else:
- cutoff = date.fromordinal(today.toordinal() - args.lookback_days)
- drop = {today.isocalendar()[:2], cutoff.isocalendar()[:2]}
- comps = args.components
- compset = set(comps)
-
- # throughput + created→Done cycle times (Done query)
- team_wk, comp_wk, cycle_days = Counter(), defaultdict(Counter), []
- epics_skipped = 0
- for n in _load_nodes(args.done):
- if not args.include_epics and _is_epic(n):
- epics_skipped += 1
- continue
- rd = n.get("fields", {}).get("resolutiondate")
- if not rd:
- continue
- d = _day(rd)
- if win_lo is not None and not (win_lo <= d <= win_hi):
- continue # outside the explicit window → ignore
- wk = d.isocalendar()[:2]
- team_wk[wk] += 1
- for c in _comp_names(n, compset):
- comp_wk[c][wk] += 1
- created = n.get("fields", {}).get("created")
- if created:
- cycle_days.append(max(0, (d - _day(created)).days))
- complete = sorted(w for w in team_wk if w not in drop)
- if args.weeks is not None: # keep the most recent N complete weeks
- complete = complete[-args.weeks:]
- team_series = [team_wk[w] for w in complete]
- comp_series = {c: [comp_wk[c][w] for w in complete] for c in comps}
-
- # open queue + created→now aging WIP (open query)
- team_open, comp_open, wip = 0, Counter(), []
- wipset = set(args.wip_status)
- for n in _load_nodes(args.open_files):
- if not args.include_epics and _is_epic(n):
- epics_skipped += 1
- continue
- team_open += 1
- for c in _comp_names(n, compset):
- comp_open[c] += 1
- status = n.get("fields", {}).get("status", {}).get("name", "")
- if status in wipset:
- created = n.get("fields", {}).get("created")
- if created:
- wip.append({"key": n.get("key", "?"), "status": status,
- "age": max(0, (today - _day(created)).days)})
-
- cycle_days.sort()
- raw_n, dropped = len(cycle_days), 0
- if args.cycle_max_days is not None:
- kept = [d for d in cycle_days if d <= args.cycle_max_days]
- dropped = raw_n - len(kept)
- cycle_days = kept
-
- # started→Done basis (Q3/Q4) when changelog files are supplied
- basis, excluded, started_used = "created", [], 0
- if args.changelog:
- basis = "started"
- reset = set(args.reset_status)
- started, started_ages, seen = [], {}, set()
- for n in _load_nodes(args.changelog):
- if not args.include_epics and _is_epic(n):
- continue
- key = n.get("key")
- if key in seen:
- continue
- seen.add(key)
- f = n.get("fields", {})
- sd, reason = _started_day(n, args.start_status, reset)
- if sd is None:
- excluded.append(f"{key}:{reason}")
- continue
- rd = f.get("resolutiondate")
- if rd:
- started.append(max(0, (_day(rd) - sd).days)) # completed → cycle time
- else:
- started_ages[key] = max(0, (today - sd).days) # in-flight → age
- started.sort()
- if args.cycle_max_days is not None:
- started = [d for d in started if d <= args.cycle_max_days]
- cycle_days = started
- for w in wip:
- if w["key"] in started_ages:
- w["age"] = started_ages[w["key"]]
- started_used += 1
-
- wip.sort(key=lambda x: x["age"], reverse=True)
-
- # hand off to the existing pure-math path
- args.throughput = ",".join(map(str, team_series))
- args.items = team_open
- args.component = [f"{c}:{','.join(map(str, comp_series[c]))}:{comp_open[c]}"
- for c in comps]
- args.cycle_times = ",".join(map(str, cycle_days)) if cycle_days else None
- args.wip_item = [f"{w['key']}:{w['status']}:{w['age']}" for w in wip]
- args.cycle_basis = basis
-
- def note(s): print(s, file=sys.stderr)
- window_desc = (f"{args.since}→{args.until or today.isoformat()}" if args.since
- else f"last {args.lookback_days}d")
- trim = f" (trimmed to last {args.weeks} wks)" if args.weeks else ""
- note("# forecast (file mode) summary")
- note(f"# now-clock : {today.isoformat()} window: {window_desc}{trim}")
- if args.include_epics:
- note("# epics : INCLUDED (--include-epics)")
- else:
- note(f"# epics : excluded (placeholders); {epics_skipped} "
- "filtered from the input files")
- note(f"# complete weeks : {len(complete)} kept "
- f"({', '.join(f'{y}-W{w:02d}' for (y, w) in complete)})")
- note(f"# dropped(partial): {', '.join(sorted(f'{y}-W{w:02d}' for (y, w) in drop))}")
- note(f"# team throughput: {team_series} (open queue {team_open})")
- for c in comps:
- note(f"# {c:<8}: tp {comp_series[c]} open {comp_open[c]}")
- if cycle_days:
- lbl = "started→Done" if basis == "started" else "created→Done"
- cap = (f" [capped ≤{args.cycle_max_days:.0f}d, dropped {dropped} of {raw_n}]"
- if (basis == "created" and args.cycle_max_days is not None) else "")
- note(f"# cycle times : N={len(cycle_days)}{cap} ({lbl})")
- note(f"# percentiles : p50 {pct(cycle_days,50):.0f}d p75 "
- f"{pct(cycle_days,75):.0f}d p85 {pct(cycle_days,85):.0f}d p95 "
- f"{pct(cycle_days,95):.0f}d max {cycle_days[-1]:.0f}d")
- if args.changelog:
- note(f"# started ages : {started_used}/{len(wip)} in-flight items "
- f"matched a changelog (rest kept created→now age)")
- if excluded:
- reasons = Counter(e.rsplit(":", 1)[1] for e in excluded)
- summary = ", ".join(f"{r} ×{c}" for r, c in reasons.most_common())
- note(f"# changelog excluded: {len(excluded)} ({summary}); "
- f"first 10: {' '.join(excluded[:10])}")
- note(f"# aging WIP : {len(wip)} in flight "
- f"(statuses: {', '.join(args.wip_status)})")
-
-
-# --------------------------------------------------------------------------- #
-# Report
-# --------------------------------------------------------------------------- #
-def bar(n, scale):
- return "█" * max(0, min(40, int(round(n / scale)))) if scale else ""
-
-
-def main():
- p = argparse.ArgumentParser(description="Monte Carlo delivery forecast from throughput")
- p.add_argument("--project", default=None,
- help="project key/label for the report title & ticket links "
- "(required for --live)")
- p.add_argument("--jira-base", default=None,
- help="Atlassian site, e.g. https://you.atlassian.net — required "
- "for --live; in file mode it only sets the report's ticket links")
- p.add_argument("--throughput", default=None,
- help='pure-math mode: comma weekly counts oldest->newest, '
- 'e.g. "37,41,28,27,31,55,20"')
- p.add_argument("--items", type=int, default=None, help="backlog size to clear")
- p.add_argument("--component", action="append", default=[],
- help='per-component series, repeatable. Format '
- '"Name:t1,t2,...:items" e.g. "Web:6,6,6,8,9,27,6:233"')
- p.add_argument("--live", action="store_true",
- help="pull data from REST (needs a REST-scoped token)")
- p.add_argument("--weeks", type=int, default=None,
- help="[file mode] keep only the most recent N COMPLETE weeks of "
- "throughput (e.g. 10 quick, 26 steadier); omit to keep all "
- "complete weeks in the window. [live] REST lookback weeks "
- "(defaults to 10 when omitted).")
- p.add_argument("--backlog-jql", default=None,
- help="[live] full override; else built from --project + --wip-status")
- p.add_argument("--include-epics", action="store_true",
- help="include Epic issues (default: exclude — Epics are usually "
- "placeholders, not deliverable flow). Applies to live JQL "
- "and to the file-mode defensive filter.")
- p.add_argument("--cycle-times", default=None,
- help='pure-math: comma per-item cycle times in DAYS '
- '(created->Done) for completed items, e.g. "3,5,8,2,14". '
- 'Enables the cycle-time SLE (Q3).')
- p.add_argument("--cycle-file", default=None,
- help="read --cycle-times from a file instead (comma- or "
- "whitespace-separated days). Avoids a long shell string.")
- p.add_argument("--wip-item", action="append", default=[],
- help='one in-flight item, repeatable. "KEY:STATUS:AGE_DAYS" '
- 'e.g. "ABC-1234:In Progress:12". Enables aging WIP (Q4).')
- p.add_argument("--wip-file", default=None,
- help='read WIP items from a file, one "KEY:STATUS:AGE_DAYS" per '
- "line. Merged with any --wip-item flags.")
- p.add_argument("--cycle-basis", choices=["created", "started"], default="created",
- help='what the cycle times / WIP ages measure: "created" '
- '(created→Done, includes backlog wait) or "started" '
- '(started→Done active time). Sets Q3/Q4 wording only.')
- p.add_argument("--outliers", choices=["keep", "winsorize", "drop"], default="keep",
- help="how to handle throughput weeks flagged as statistical "
- "outliers (robust median/MAD): keep (flag only — default), "
- "winsorize (cap to the nearest in-band week), or drop "
- "(remove from the sim sample). Applied per series.")
- p.add_argument("--outlier-threshold", type=float, default=3.5,
- help="modified z-score above which a week is flagged an outlier "
- "(default 3.5, the Iglewicz–Hoaglin convention)")
- p.add_argument("--by-date", default=None, help="YYYY-MM-DD (default: +3 weeks)")
- p.add_argument("--trials", type=int, default=10000)
- p.add_argument("--done-status", default="Done")
- p.add_argument("--seed", type=int, default=42)
- p.add_argument("--json-out", default=None,
- help="also write the computed forecast (all of Q1–Q4) as "
- "structured JSON to this path — feeds forecast-html.py "
- "for the branded HTML report. The terminal report still prints.")
- # ---- file mode: ingest fetch.py's result files directly (one-command run) ----
- p.add_argument("--done", nargs="+", default=None,
- help="fetch.py's done.json for the Done/throughput query. "
- "Enables file mode: throughput/items/cycle-times/WIP are "
- "computed in-process (no hand-off, no prompts).")
- p.add_argument("--open", nargs="+", default=None, dest="open_files",
- help="fetch.py's open.json for the open-queue query (file mode)")
- p.add_argument("--changelog", nargs="+", default=None,
- help="files carrying inline changelogs (pass the same "
- "done.json/open.json). When given, Q3/Q4 switch to "
- "started→Done (active time); --cycle-basis is set to started.")
- p.add_argument("--components", nargs="+", default=[],
- help="[file mode] components to break out (default: none — team "
- "line only; pass e.g. --components web api to split by team)")
- p.add_argument("--lookback-days", type=int, default=77,
- help="[file mode] trailing -Nd throughput window (default 77); "
- "ignored if --since is given")
- p.add_argument("--since", default=None,
- help="[file mode] throughput window START (YYYY-MM-DD), e.g. a "
- "quarter start. When set, the window is [--since, --until] "
- "and --lookback-days is ignored; the Done JQL should match.")
- p.add_argument("--until", default=None,
- help="[file mode] throughput window END (YYYY-MM-DD), inclusive; "
- "default --today")
- p.add_argument("--today", default=None,
- help="[file mode] reference NOW date YYYY-MM-DD (default today); "
- "sets the aging-WIP age clock (window is set by "
- "--since/--until or --lookback-days)")
- p.add_argument("--wip-status", nargs="+",
- default=["In Progress", "In Review"],
- help="[file mode] in-flight statuses for Q4 — set to your active "
- "columns (default is a generic placeholder)")
- p.add_argument("--cycle-max-days", type=float, default=None,
- help="[file mode] drop Done items whose cycle time exceeds N days "
- "from the SLE sample (trims the backlog-aging tail)")
- p.add_argument("--start-status", default="In Progress",
- help="[changelog] status whose first entry starts the clock")
- p.add_argument("--reset-status", nargs="+", default=["To Do", "Cancelled"],
- help="[changelog] statuses that reset the clock on return to "
- "backlog — set to yours (default is a generic placeholder)")
- args = p.parse_args()
-
- if args.done:
- synthesize_from_files(args) # populates the pure-math args from raw files
-
- # ---- file-based inputs (so the Q3/Q4 data never has to be a shell string) --
- # An explicit --cycle-file / --wip-file overrides/augments whatever file mode
- # (or the pure-math flags) produced — handy for a manually-computed SLE sample.
- if args.cycle_file:
- try:
- raw = open(args.cycle_file).read()
- except OSError as e:
- sys.exit(f"ERROR: --cycle-file: {e}")
- vals = [x for x in raw.replace("\n", ",").replace(" ", ",").split(",") if x.strip()]
- args.cycle_times = ",".join(vals)
- if args.wip_file:
- try:
- lines = open(args.wip_file).read().splitlines()
- except OSError as e:
- sys.exit(f"ERROR: --wip-file: {e}")
- args.wip_item = args.wip_item + [ln.strip() for ln in lines if ln.strip()]
-
- rng = random.Random(args.seed)
- done_statuses = [s.strip() for s in args.done_status.split(",")]
-
- if args.throughput and not args.live:
- # ---- PURE-MATH MODE (supported) ----
- try:
- sample = [int(x) for x in args.throughput.split(",") if x.strip() != ""]
- except ValueError:
- sys.exit("ERROR: --throughput must be comma-separated integers")
- # pure-math mode has no calendar context; label neutrally oldest->newest
- ordered = [(f"wk{i+1}", c) for i, c in enumerate(sample)]
- if args.items is None:
- sys.exit("ERROR: --items is required in pure-math mode")
- backlog = args.items
- else:
- # ---- LIVE MODE (direct REST) ----
- global JIRA_BASE
- if not args.jira_base or not args.project:
- sys.exit("ERROR: --live requires --jira-base and --project")
- JIRA_BASE = args.jira_base.rstrip("/")
- auth = load_token()
- sample, ordered = weekly_throughput(auth, args.weeks or 10, done_statuses,
- args.project, include_epics=args.include_epics)
- if not sample or sum(sample) == 0:
- sys.exit("ERROR: no completed items found via REST — check the "
- "Authorization value in settings.json and that --project / "
- "--done-status match your board, or use pure-math mode "
- "(--throughput \"...\" --items N).")
- bjql = args.backlog_jql or backlog_jql(args.project, args.wip_status)
- if not args.include_epics:
- bjql = exclude_epics(bjql)
- backlog = args.items if args.items is not None else jql_count(bjql, auth)
-
- if args.by_date:
- target = datetime.strptime(args.by_date, "%Y-%m-%d").date()
- else:
- target = date.today() + timedelta(weeks=3)
- today = date.today()
- n_weeks = max(1, math.ceil((target - today).days / 7))
-
- # ----- assemble series: TEAM first, then each --component ----------------
- series = [{"name": "TEAM (all)", "sample": sample, "backlog": backlog}]
- for spec in args.component:
- try:
- name, tp, items = spec.split(":")
- cs = [int(x) for x in tp.split(",") if x.strip() != ""]
- series.append({"name": name.strip(), "sample": cs, "backlog": int(items)})
- except ValueError:
- sys.exit(f'ERROR: --component must be "Name:t1,t2,...:items" (got: {spec})')
-
- # ----- outlier detection + optional handling (per series) ----------------
- # Flag on the observed history; the chosen policy decides what the sim uses.
- for s in series:
- raw = list(s["sample"])
- s["flagged_raw"] = detect_outliers(raw, args.outlier_threshold)
- adj, onote = apply_outlier_policy(raw, s["flagged_raw"], args.outliers)
- s["sample_raw"] = raw
- s["sample"] = adj
- s["outlier_note"] = onote
- # indices still flagged in the DISPLAYED (sim) sample — for chart marks
- s["outliers_display"] = detect_outliers(adj, args.outlier_threshold)
-
- # ----- simulate every series --------------------------------------------
- for s in series:
- s["mean"] = sum(s["sample"]) / len(s["sample"])
- s["weeks"] = sim_weeks_to_finish(s["backlog"], s["sample"], args.trials, rng)
- s["items"] = sim_items_by_date(n_weeks, s["sample"], args.trials, rng)
-
- # ----- cycle-time SLE (Q3) ----------------------------------------------
- cycle_sample, sle = None, {}
- if args.cycle_times:
- try:
- cycle_sample = sorted(
- float(x) for x in args.cycle_times.split(",") if x.strip() != "")
- except ValueError:
- sys.exit("ERROR: --cycle-times must be comma-separated numbers (days)")
- if not cycle_sample:
- sys.exit("ERROR: --cycle-times had no values")
- sle = {q: pct(cycle_sample, q) for q in (50, 85, 95)}
-
- # ----- aging WIP (Q4) ---------------------------------------------------
- wip = []
- for spec in args.wip_item:
- try:
- head, age = spec.rsplit(":", 1)
- key, status = head.split(":", 1)
- wip.append({"key": key.strip(), "status": status.strip(),
- "age": float(age)})
- except ValueError:
- sys.exit(f'ERROR: --wip-item must be "KEY:STATUS:AGE_DAYS" (got: {spec})')
- wip.sort(key=lambda x: x["age"], reverse=True)
-
- if args.cycle_basis == "started":
- basis_label = "started → Done (active time)"
- basis_clock = "days of work starting"
- age_label = "active days since work started"
- else:
- basis_label = "created → Done"
- basis_clock = "days of being created"
- age_label = "days since created"
-
- wk = len(sample)
- breakdown = len(series) > 1
- NW = max(len(s["name"]) for s in series)
-
- # ----- render ------------------------------------------------------------
- L = []
- _title = f"{args.project} FORECAST" if args.project else "DELIVERY FORECAST"
- _head = f"┌─ {_title} "
- L.append(_head + "─" * max(3, 63 - len(_head)))
- L.append(f"│ Run: {today.isoformat()} Trials: {args.trials:,} "
- f"Done = {'/'.join(done_statuses)} Sample: {wk} complete weeks")
- L.append("└" + "─" * 62)
- epics_line = ("⚠ Epics INCLUDED (--include-epics)" if args.include_epics
- else "Epics excluded from all figures (placeholders, not deliverable flow)")
- L.append(epics_line)
- L.append("")
-
- # Throughput table
- L.append(f"THROUGHPUT (items reaching Done per week, oldest → newest)")
- L.append(f" {'series':<{NW}} weekly counts{' '*max(0, 3*wk-13)} mean min max")
- for s in series:
- counts = " ".join(f"{c:>2}" for c in s["sample"])
- L.append(f" {s['name']:<{NW}} {counts} {s['mean']:>4.1f} "
- f"{min(s['sample']):>3} {max(s['sample']):>3}")
- if any(s["flagged_raw"] for s in series):
- L.append("")
- L.append(f" OUTLIER WEEKS (robust median/MAD flag, z>{args.outlier_threshold:g}"
- f"; policy: {args.outliers})")
- for s in series:
- if not s["flagged_raw"]:
- continue
- marks = ", ".join(f"w{i+1}={v:g} (z {mz:+.1f})"
- for i, v, mz in s["flagged_raw"])
- extra = f" → {s['outlier_note']}" if s["outlier_note"] else ""
- L.append(f" ! {s['name']:<{NW}} {marks}{extra}")
- if args.outliers == "keep":
- L.append(" policy=keep: flagged only, still in the sample — "
- "re-run with --outliers drop|winsorize to adapt.")
- L.append(" (w-index is the observed history; a genuine big week is not "
- "an artifact — confirm before dropping.)")
- L.append("")
- L.append("─" * 63)
-
- # Q1 — how long to clear each backlog
- L.append("Q1. HOW LONG to clear the current open queue?")
- L.append(" (open = Ready for Dev + In Progress + Code Review + QA Ready)")
- L.append("")
- L.append(f" {'series':<{NW}} queue 50% 85% 95% 85% date")
- L.append(f" {'-'*NW} ----- ---- ---- ---- ----------")
- for s in series:
- w50, w85, w95 = pct(s["weeks"], 50), pct(s["weeks"], 85), pct(s["weeks"], 95)
- d85 = today + timedelta(weeks=w85)
- L.append(f" {s['name']:<{NW}} {s['backlog']:>5} "
- f"{w50:>2}w {w85:>2}w {w95:>2}w {d85.isoformat()}")
- L.append("")
- L.append("─" * 63)
-
- # Q2 — how many done by the horizon
- L.append(f"Q2. HOW MANY done by {target.isoformat()} ({n_weeks} weeks out)?")
- L.append(" (at least N items, by confidence)")
- L.append("")
- L.append(f" {'series':<{NW}} 95% 85% 50%")
- L.append(f" {'-'*NW} --- --- ---")
- for s in series:
- L.append(f" {s['name']:<{NW}} {pct(s['items'],5):>4} "
- f"{pct(s['items'],15):>4} {pct(s['items'],50):>4}")
- L.append("")
- L.append("─" * 63)
-
- # Q3 — cycle-time Service Level Expectation
- if cycle_sample:
- L.append("Q3. CYCLE TIME — Service Level Expectation (the estimate replacement)")
- L.append(f" (per-item {basis_label}, calendar days; N = {len(cycle_sample)} items)")
- L.append("")
- L.append(f" 50% typical within {sle[50]:>4.0f} days")
- L.append(f" 85% SLE within {sle[85]:>4.0f} days ← the team's per-item commitment")
- L.append(f" 95% worst within {sle[95]:>4.0f} days")
- L.append("")
- L.append(f' Say to stakeholders: "we don\'t estimate — 85% of items reach Done')
- L.append(f' within {sle[85]:.0f} {basis_clock}." No story points required.')
- L.append("")
- L.append("─" * 63)
-
- # Q4 — aging work in progress
- if wip:
- L.append("Q4. AGING WORK IN PROGRESS — pull these before starting new work")
- if sle:
- L.append(f" (age = {age_label}; flagged against the "
- f"{sle[85]:.0f}-day 85% SLE)")
- else:
- L.append(f" (age = {age_label}; pass --cycle-times to flag vs the SLE)")
- L.append("")
- breach = [w for w in wip if sle and w["age"] >= sle[85]]
- warn = [w for w in wip if sle and sle[50] <= w["age"] < sle[85]]
- healthy = [w for w in wip if not sle or w["age"] < sle[50]]
- KW = max((len(w["key"]) for w in wip), default=8)
- SW = max((len(w["status"]) for w in wip), default=11)
- for w in breach:
- L.append(f" 🔴 {w['key']:<{KW}} {w['status']:<{SW}} {w['age']:>4.0f}d"
- f" OVER SLE — pull now")
- for w in warn:
- L.append(f" 🟡 {w['key']:<{KW}} {w['status']:<{SW}} {w['age']:>4.0f}d"
- f" aging — watch")
- if not sle:
- for w in wip:
- L.append(f" • {w['key']:<{KW}} {w['status']:<{SW}} {w['age']:>4.0f}d")
- elif healthy:
- L.append(f" 🟢 {len(healthy)} item(s) within the {sle[50]:.0f}-day typical line")
- L.append("")
- if sle:
- L.append(f" {len(breach)} over SLE · {len(warn)} aging · {len(healthy)} healthy"
- f" (of {len(wip)} in flight)")
- L.append("")
- L.append("─" * 63)
-
- L.append("Read 85% as the commit-safe line. Quote it to stakeholders; keep 50% internal.")
- if breakdown:
- L.append("NOTE: component forecasts are independent — the TEAM line is NOT the sum of")
- L.append(" component lines (variances don't add). Trust TEAM for whole-team")
- L.append(" promises; use component lines to spot bottlenecks & lopsided queues.")
- L.append(f"Valid only while team & flow match the last {wk} weeks — re-run after reorg/holiday.")
- print("\n".join(L))
-
- # ----- structured JSON dump (feeds forecast-html.py) ------------------
- if args.json_out:
- window_desc = (f"{args.since} → {args.until or today.isoformat()}"
- if args.done and args.since
- else f"last {args.lookback_days}d" if args.done
- else "throughput supplied directly")
- series_out = []
- for s in series:
- series_out.append({
- "name": s["name"],
- "sample": s["sample"],
- "sample_raw": s["sample_raw"],
- "mean": round(s["mean"], 1),
- "min": min(s["sample"]),
- "max": max(s["sample"]),
- "backlog": s["backlog"],
- "outliers": [{"wk": i + 1, "value": v, "z": round(mz, 1)}
- for i, v, mz in s["outliers_display"]],
- "outlier_note": s["outlier_note"],
- "q1": {"w50": pct(s["weeks"], 50), "w85": pct(s["weeks"], 85),
- "w95": pct(s["weeks"], 95),
- "date85": (today + timedelta(weeks=pct(s["weeks"], 85))).isoformat()},
- "q2": {"c95": pct(s["items"], 5), "c85": pct(s["items"], 15),
- "c50": pct(s["items"], 50)},
- })
- wip_out = []
- for w in wip:
- band = ("breach" if sle and w["age"] >= sle[85]
- else "warn" if sle and w["age"] >= sle[50]
- else "healthy" if sle else "none")
- wip_out.append({"key": w["key"], "status": w["status"],
- "age": round(w["age"]), "band": band})
- payload = {
- "meta": {
- "project": args.project, # None when no --project given
- "jira_browse_base": (args.jira_base.rstrip("/") + "/browse"
- if args.jira_base else None),
- "today": today.isoformat(),
- "by_date": target.isoformat(),
- "n_weeks": n_weeks,
- "trials": args.trials,
- "done_statuses": done_statuses,
- "sample_weeks": wk,
- "window": window_desc,
- "basis": basis_label,
- "basis_clock": basis_clock,
- "age_label": age_label,
- "outlier_policy": args.outliers,
- "outlier_threshold": args.outlier_threshold,
- "epics_excluded": not args.include_epics,
- },
- "series": series_out,
- "cycle": ({"n": len(cycle_sample),
- "p50": sle[50], "p85": sle[85], "p95": sle[95],
- "max": cycle_sample[-1],
- "hist": _histogram(cycle_sample)} if cycle_sample else None),
- "wip": wip_out,
- "wip_bands": {
- "breach": sum(1 for w in wip_out if w["band"] == "breach"),
- "warn": sum(1 for w in wip_out if w["band"] == "warn"),
- "healthy": sum(1 for w in wip_out if w["band"] == "healthy"),
- "total": len(wip_out),
- },
- }
- with open(args.json_out, "w") as f:
- json.dump(payload, f, indent=2)
- print(f"# wrote forecast JSON → {args.json_out}", file=sys.stderr)
-
-
-def _histogram(sorted_days, bins=(0, 7, 14, 30, 60, 90, 180, 365, 100000)):
- """Bucket cycle-time days into labeled bins for the distribution chart."""
- labels = ["0–7", "8–14", "15–30", "31–60", "61–90", "91–180", "181–365", "365+"]
- counts = [0] * len(labels)
- for d in sorted_days:
- for i in range(len(labels)):
- if bins[i] <= d < bins[i + 1]:
- counts[i] += 1
- break
- return [{"label": labels[i], "count": counts[i]} for i in range(len(labels))]
-
-
-if __name__ == "__main__":
- main()
diff --git a/.claude/scripts/gh_fetch.py b/.claude/scripts/gh_fetch.py
deleted file mode 100644
index 7898b404..00000000
--- a/.claude/scripts/gh_fetch.py
+++ /dev/null
@@ -1,430 +0,0 @@
-#!/usr/bin/env python3
-"""
-gh_fetch.py — pull throughput-forecast data from a GitHub Projects v2 board in
-one command.
-
-GitHub-Projects analog of the Jira-based fetch.py in the original
-throughput-forecast skill. Paginates the project's items, and for every
-`Issue` item nests its status-change timeline in the SAME query (no N+1 —
-confirmed live at ~1 rate-limit point per 50-item page), then reshapes the
-result into the exact envelope forecast.py's file mode already reads:
-
- /done.json {"issues":{"nodes":[...]}}
- /open.json {"issues":{"nodes":[...]}}
-
-Each node's `changelog.histories[]` is built from the item's
-`ProjectV2ItemStatusChangedEvent` timeline — the direct GitHub analog of
-Jira's changelog status-transition entries — so forecast.py's started→Done
-Service Level Expectation (Q3) and aging-WIP (Q4) work unmodified.
-
-Auth: shells out to `gh api graphql`, reusing whatever `gh auth login`
-already set up on this machine — no token is read from or written to
-settings.json.
-
-Item routing:
- - DraftIssue and PullRequest project items are EXCLUDED by default (they
- aren't deliverable flow / are already reachable via an issue's linked
- PRs) — pass --include-drafts to include drafts. --include-prs is
- intentionally NOT implemented: live testing found that a PullRequest's
- `timelineItems.totalCount` does NOT respect the `itemTypes` filter the
- way it does for Issue (a real PR showed totalCount=17 with 0 matching
- nodes), so the truncated-changelog backfill logic can't be trusted for
- PRs without separate handling. Passing --include-prs exits with an
- error rather than silently producing wrong changelogs.
- - An item whose current Status is in neither --done-status nor --statuses
- is dropped but counted (`skipped_status`) and logged to stderr — this
- board has 7 status columns (not Jira's typical 3), so a misconfigured
- --statuses list would otherwise silently drop real items.
-
-Usage:
- gh_fetch.py --org willowtreeapps --project-number 50 \
- --done-status "Done" \
- --statuses "Pre Backlog" "Backlog" "Ready to select" "In progress" \
- "Development Complete (In Review)" "Ready for Demo" \
- --out-dir /tmp/forecast
-
-Exit codes: 0 ok | 2 config/gh-auth | 3 GraphQL/network error | 6 zero Done items.
-"""
-
-import argparse
-import json
-import os
-import subprocess
-import sys
-import time
-from datetime import date, datetime
-
-ITEMS_QUERY = """
-query($org: String!, $number: Int!, $cursor: String) {
- organization(login: $org) {
- projectV2(number: $number) {
- items(first: 50, after: $cursor) {
- pageInfo { hasNextPage endCursor }
- nodes {
- fieldValues(first: 20) {
- nodes {
- __typename
- ... on ProjectV2ItemFieldSingleSelectValue {
- name
- field { ... on ProjectV2FieldCommon { name } }
- }
- }
- }
- content {
- __typename
- ... on Issue {
- number
- title
- createdAt
- closedAt
- state
- url
- issueType { name }
- labels(first: 20) { nodes { name } }
- repository { name }
- timelineItems(itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT], first: 50) {
- totalCount
- pageInfo { hasNextPage endCursor }
- nodes {
- ... on ProjectV2ItemStatusChangedEvent {
- createdAt
- previousStatus
- status
- }
- }
- }
- }
- }
- }
- }
- }
- }
-}
-"""
-
-TIMELINE_BACKFILL_QUERY = """
-query($owner: String!, $repo: String!, $number: Int!, $after: String) {
- repository(owner: $owner, name: $repo) {
- issue(number: $number) {
- timelineItems(itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT], first: 100, after: $after) {
- totalCount
- pageInfo { hasNextPage endCursor }
- nodes {
- ... on ProjectV2ItemStatusChangedEvent {
- createdAt
- previousStatus
- status
- }
- }
- }
- }
- }
-}
-"""
-
-
-def gh_graphql(query, retries=3, **variables):
- cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
- for k, v in variables.items():
- if v is None:
- cmd += ["-F", f"{k}=null"]
- elif isinstance(v, bool):
- cmd += ["-F", f"{k}={'true' if v else 'false'}"]
- elif isinstance(v, int):
- cmd += ["-F", f"{k}={v}"]
- else:
- cmd += ["-f", f"{k}={v}"]
- for attempt in range(retries + 1):
- result = subprocess.run(cmd, capture_output=True, text=True)
- if result.returncode != 0:
- if attempt < retries:
- time.sleep(2 ** attempt)
- continue
- sys.exit(f"ERROR(3): gh api graphql failed: {result.stderr[:800]}")
- try:
- data = json.loads(result.stdout)
- except json.JSONDecodeError as e:
- sys.exit(f"ERROR(3): non-JSON response: {e}\n{result.stdout[:500]}")
- if "errors" in data:
- sys.exit(f"ERROR(3): GraphQL errors: {json.dumps(data['errors'])[:800]}")
- return data["data"]
- sys.exit("ERROR(3): exhausted retries")
-
-
-def check_gh_auth():
- result = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
- if result.returncode != 0:
- sys.exit(f"ERROR(2): gh is not authenticated — run `gh auth login`.\n{result.stderr}")
-
-
-def fetch_all_items(org, project_number):
- """Paginate the project's items, nested timelineItems included per Issue."""
- nodes, cursor, pages = [], None, 0
- while True:
- data = gh_graphql(ITEMS_QUERY, org=org, number=project_number, cursor=cursor)
- items = data["organization"]["projectV2"]["items"]
- nodes.extend(items["nodes"])
- pages += 1
- if not items["pageInfo"]["hasNextPage"]:
- print(f"# project items: {len(nodes)} nodes, {pages} page(s)", file=sys.stderr)
- return nodes
- cursor = items["pageInfo"]["endCursor"]
-
-
-def backfill_truncated_timelines(owner, repo, issue_nodes):
- """For any Issue whose nested timelineItems was truncated (>50 status
- changes — rare), re-page it directly via the issue's own timelineItems.
- Mirrors the original fetch.py's backfill_truncated() pattern."""
- n_backfilled = 0
- for node in issue_nodes:
- content = node["content"]
- tl = content["timelineItems"]
- if not tl["pageInfo"]["hasNextPage"]:
- continue
- n_backfilled += 1
- all_nodes = list(tl["nodes"])
- cursor = tl["pageInfo"]["endCursor"]
- while True:
- data = gh_graphql(TIMELINE_BACKFILL_QUERY, owner=owner, repo=repo,
- number=content["number"], after=cursor)
- page = data["repository"]["issue"]["timelineItems"]
- all_nodes.extend(page["nodes"])
- if not page["pageInfo"]["hasNextPage"]:
- break
- cursor = page["pageInfo"]["endCursor"]
- content["timelineItems"] = {"totalCount": len(all_nodes), "nodes": all_nodes,
- "pageInfo": {"hasNextPage": False}}
- return n_backfilled
-
-
-def current_status(project_item):
- for fv in project_item["fieldValues"]["nodes"]:
- if fv.get("__typename") == "ProjectV2ItemFieldSingleSelectValue":
- field = fv.get("field") or {}
- if field.get("name") == "Status":
- return fv.get("name")
- return None
-
-
-def build_histories(timeline_nodes):
- return [{"created": n["createdAt"], "items": [{"field": "status", "toString": n["status"]}]}
- for n in timeline_nodes]
-
-
-def components_for(content, breakdown_field):
- if breakdown_field == "labels":
- return [{"name": l["name"]} for l in content["labels"]["nodes"]]
- if breakdown_field == "issuetype":
- it = content.get("issueType")
- return [{"name": it["name"]}] if it else []
- return []
-
-
-def day_from_iso(s):
- return date.fromisoformat(s[:10])
-
-
-def is_bulk_import_artifact(timeline_nodes, done_statuses):
- """True if this item's ENTIRE timeline is a single previousStatus="" entry
- landing directly on a done-status — i.e. it was already closed before
- being added to the project and never actually flowed through the board.
-
- Discovered live on Project #50: 141 of 169 Done items shared the exact
- same transition timestamp (2024-05-28T18:04:24Z, the moment the project
- was bulk-populated), each with previousStatus="" -> "Done" as their ONLY
- timeline entry, while their real (spread-out, months/years earlier)
- closedAt dates showed the genuine history. Treating that shared instant
- as 141 real "completions" would fabricate a single fake mega-throughput
- week and badly bias the forecast. A REAL multi-step history that happens
- to end at Done is not affected by this check — only a lone, empty-origin
- entry is (the reliable "no real board journey" signature)."""
- return (len(timeline_nodes) == 1
- and timeline_nodes[0]["previousStatus"] == ""
- and timeline_nodes[0]["status"] in done_statuses)
-
-
-def resolutiondate_for(content, timeline_nodes, done_statuses, stats):
- """Last status-transition into a done-status wins; fall back to closedAt
- if no such transition exists, OR if the only transition is a bulk-import
- artifact (see is_bulk_import_artifact). Flags (to stderr, at the end) any
- remaining case where the two disagree by more than a day — a genuine
- judgment call, not a solved fact (see PR description / work-log)."""
- done_set = set(done_statuses)
- if is_bulk_import_artifact(timeline_nodes, done_set):
- stats["resolutiondate_import_artifact"] += 1
- return content.get("closedAt")
- last_done_ts = None
- for n in timeline_nodes:
- if n["status"] in done_set:
- last_done_ts = n["createdAt"]
- if last_done_ts:
- if content.get("closedAt"):
- d1 = day_from_iso(last_done_ts)
- d2 = day_from_iso(content["closedAt"])
- if abs((d1 - d2).days) > 1:
- stats["resolutiondate_disagreements"] += 1
- return last_done_ts
- stats["resolutiondate_fallback_closedat"] += 1
- return content.get("closedAt")
-
-
-def main():
- p = argparse.ArgumentParser(description="Pull throughput-forecast data from GitHub Projects v2")
- p.add_argument("--org", required=True, help="GitHub org that owns the project")
- p.add_argument("--project-number", type=int, required=True, help="Project number, e.g. 50")
- p.add_argument("--repo", default=None,
- help="repo name, for the backfill query owner/repo (default: infer "
- "from the first Issue item encountered)")
- p.add_argument("--out-dir", default="/tmp/forecast")
- p.add_argument("--done-status", nargs="+", default=["Done"],
- help='status name(s) counted as delivered (default "Done")')
- p.add_argument("--statuses", nargs="+",
- default=["Backlog", "In progress", "In review"],
- help="open-queue (active/backlog) status names — set to your "
- "board's committed columns (default is a generic placeholder)")
- p.add_argument("--breakdown-field", choices=["none", "labels", "issuetype"],
- default="none",
- help="what to use as the forecast's --components axis "
- "(default none — team-only forecast)")
- p.add_argument("--include-drafts", action="store_true",
- help="include DraftIssue project items (default: excluded — "
- "not deliverable flow)")
- p.add_argument("--include-prs", action="store_true",
- help="NOT IMPLEMENTED — PullRequest.timelineItems.totalCount does "
- "not respect the itemTypes filter in live testing (seen "
- "totalCount=17 with 0 matching nodes for a real PR), so the "
- "truncated-changelog backfill can't be trusted for PRs yet. "
- "Passing this flag exits with an error.")
- p.add_argument("--since", default=None,
- help="only include items created on/after this date (YYYY-MM-DD); "
- "default: no filter (fetch everything)")
- args = p.parse_args()
-
- if args.include_prs:
- sys.exit("ERROR(2): --include-prs is not implemented — see the docstring for "
- "why (PullRequest timelineItems totalCount is unreliable). Needs its "
- "own verification before enabling; track as a follow-up.")
-
- check_gh_auth()
- os.makedirs(args.out_dir, exist_ok=True)
- t0 = time.time()
-
- done_statuses = args.done_status
- open_statuses = args.statuses
- since_cutoff = datetime.strptime(args.since, "%Y-%m-%d").date() if args.since else None
-
- all_nodes = fetch_all_items(args.org, args.project_number)
-
- issue_nodes = [n for n in all_nodes if n["content"] and n["content"]["__typename"] == "Issue"]
- n_backfilled = 0
- if issue_nodes:
- owner = args.org
- repo = args.repo or issue_nodes[0]["content"]["repository"]["name"]
- n_backfilled = backfill_truncated_timelines(owner, repo, issue_nodes)
-
- stats = {
- "total": len(all_nodes),
- "drafts_excluded": 0,
- "prs_excluded": 0,
- "skipped_since_filter": 0,
- "skipped_status": 0,
- "done": 0,
- "open": 0,
- "resolutiondate_fallback_closedat": 0,
- "resolutiondate_disagreements": 0,
- "resolutiondate_import_artifact": 0,
- }
- done_nodes, open_nodes = [], []
-
- for node in all_nodes:
- content = node["content"]
- if content is None:
- stats["skipped_status"] += 1
- continue
- typename = content["__typename"]
- if typename == "DraftIssue":
- if not args.include_drafts:
- stats["drafts_excluded"] += 1
- continue
- elif typename == "PullRequest":
- stats["prs_excluded"] += 1
- continue
- elif typename != "Issue":
- stats["skipped_status"] += 1
- continue
-
- created_at = content.get("createdAt")
- if since_cutoff and created_at and day_from_iso(created_at) < since_cutoff:
- stats["skipped_since_filter"] += 1
- continue
-
- status_name = current_status(node)
- timeline_nodes = (content.get("timelineItems") or {}).get("nodes", [])
- histories = build_histories(timeline_nodes)
- key = f"{content.get('repository', {}).get('name', args.repo or args.org)}#{content.get('number')}"
-
- base_fields = {
- "created": created_at,
- "components": components_for(content, args.breakdown_field),
- "issuetype": {"name": content["issueType"]["name"]} if content.get("issueType") else None,
- }
- changelog = {"total": len(timeline_nodes), "histories": histories}
-
- if status_name in done_statuses:
- rd = resolutiondate_for(content, timeline_nodes, done_statuses, stats)
- if not rd:
- # No transition into a done-status and no closedAt — can't place
- # it in time; drop rather than guess.
- stats["skipped_status"] += 1
- continue
- fields = dict(base_fields, resolutiondate=rd)
- done_nodes.append({"key": key, "fields": fields, "changelog": changelog})
- stats["done"] += 1
- elif status_name in open_statuses:
- fields = dict(base_fields, status={"name": status_name})
- open_nodes.append({"key": key, "fields": fields, "changelog": changelog})
- stats["open"] += 1
- else:
- stats["skipped_status"] += 1
-
- if not done_nodes:
- sys.exit("ERROR(6): 0 Done items found — check --org/--project-number/"
- "--done-status against your board. Refusing to write files from "
- "an empty pull.")
-
- done_path = os.path.join(args.out_dir, "done.json")
- open_path = os.path.join(args.out_dir, "open.json")
- with open(done_path, "w") as f:
- json.dump({"issues": {"nodes": done_nodes}}, f)
- with open(open_path, "w") as f:
- json.dump({"issues": {"nodes": open_nodes}}, f)
-
- print(f"# total project items : {stats['total']}", file=sys.stderr)
- print(f"# done : {stats['done']}", file=sys.stderr)
- print(f"# open : {stats['open']}", file=sys.stderr)
- print(f"# drafts excluded : {stats['drafts_excluded']} "
- f"({'included' if args.include_drafts else 'default: excluded'})", file=sys.stderr)
- print(f"# PRs excluded : {stats['prs_excluded']} (always excluded — see docstring)",
- file=sys.stderr)
- if args.since:
- print(f"# skipped (before --since) : {stats['skipped_since_filter']}", file=sys.stderr)
- print(f"# skipped (status not in --done-status/--statuses): {stats['skipped_status']}",
- file=sys.stderr)
- print(f"# timelines backfilled : {n_backfilled} truncated issue(s)", file=sys.stderr)
- print(f"# resolutiondate fallback to closedAt (no Done transition found): "
- f"{stats['resolutiondate_fallback_closedat']}", file=sys.stderr)
- print(f"# resolutiondate bulk-import artifacts (lone empty-origin transition, "
- f"used closedAt instead): {stats['resolutiondate_import_artifact']}", file=sys.stderr)
- print(f"# resolutiondate/closedAt disagreements (>1 day, non-artifact): "
- f"{stats['resolutiondate_disagreements']}", file=sys.stderr)
- accounted = (stats["done"] + stats["open"] + stats["drafts_excluded"]
- + stats["prs_excluded"] + stats["skipped_since_filter"] + stats["skipped_status"])
- print(f"# sanity: done+open+excluded+skipped = {accounted} (total = {stats['total']})",
- file=sys.stderr)
- print(f"# elapsed: {time.time() - t0:.1f}s", file=sys.stderr)
- print(done_path)
- print(open_path)
-
-
-if __name__ == "__main__":
- main()
diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 67f53515..00000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/claude-code-settings.json",
- "permissions": {
- "allow": [
- "Bash(python3 .claude/scripts/gh_fetch.py *)",
- "Bash(python3 .claude/scripts/forecast.py *)",
- "Bash(python3 .claude/scripts/forecast-html.py *)"
- ]
- }
-}
diff --git a/.claude/skills/throughput-forecast/SKILL.md b/.claude/skills/throughput-forecast/SKILL.md
deleted file mode 100644
index 34172143..00000000
--- a/.claude/skills/throughput-forecast/SKILL.md
+++ /dev/null
@@ -1,166 +0,0 @@
----
-name: throughput-forecast
-description: >-
- Forecast delivery from a team's historical weekly throughput — no story points,
- no velocity. Answers "how long to clear this backlog?" and "how many items done
- by a date?" at 50/85/95% confidence, for the team as a whole and (optionally)
- broken down by component. Also reports the cycle-time Service Level Expectation
- (the per-item estimate replacement) and an aging work-in-progress list. Use when
- the user asks to forecast delivery, predict a completion date, estimate how much
- fits before a date, replace velocity/sprint commitment for a Kanban team, or asks
- "when will this be done?" / "can we finish by a date?". Triggers on: "delivery
- forecast", "throughput forecast", "Monte Carlo", "probabilistic forecast", "when
- will the backlog be done", "cycle time", "SLE", "service level expectation",
- "aging WIP", "what should we pull first". This is the GitHub Projects v2 port of
- WillowTree's Jira-based throughput-forecast skill — same forecast engine,
- different data source.
----
-
-# Throughput Forecasting (GitHub Projects v2)
-
-Project-agnostic, throughput-based delivery forecasting for a Kanban workflow,
-sourced from a **GitHub Projects v2** board instead of Jira. Replaces sprint
-commitment / velocity with a probabilistic forecast computed by simulation
-(Monte Carlo) from the team's *actual* weekly throughput — the count of items
-reaching a Done status per week. No estimation, no story points.
-
-It answers four questions. Q1/Q2 are the stakeholder-facing **forecast** (each at
-50 / 85 / 95% confidence); Q3/Q4 are the team-facing **flow metrics**:
-
-1. **How long** to clear a backlog of N items? *(Monte Carlo)*
-2. **How many** items done by a target date? *(Monte Carlo)*
-3. **What can we promise per item?** — the cycle-time **Service Level Expectation
- (SLE)**, the measured per-item commitment that replaces the story-point estimate.
-4. **What should we pull first?** — **aging work-in-progress**: in-flight items
- already older than the SLE, i.e. the "finish-before-you-start" standup list.
-
-Q1/Q2 also break down **by component** if `config.yaml`'s `breakdown_field` is set
-(`labels` or `issuetype`). Q3/Q4 render only when their data is available.
-
-**Setup / porting:** the forecast engine (`forecast.py`/`forecast-html.py`) is
-copied byte-for-byte from the original Jira-based skill (`willowtreeapps/
-dq-documentation:skills/throughput-forecast/scripts/`) and needed **zero
-changes** for this port. It always runs here in **file mode** — pure math, no
-network calls — reading the two JSON files `gh_fetch.py` produces. The copy
-also still carries `forecast.py`'s Jira `--live` path (`load_token()`,
-`run_curl()`, direct REST against `--jira-base`) from the source skill; this
-skill never passes `--live`, so that path is dead code here, not something in
-use — left in place rather than trimmed so this file stays a straight diff
-against the upstream source for future fixes. Only the gather step differs:
-`gh_fetch.py` pulls from the GitHub Projects v2 GraphQL API instead of Jira
-REST. **Project values** (org, project number, repo, statuses, breakdown
-axis) live in `config.yaml` — read it first and pass them as flags.
-
-## First — orient the user (before pulling data)
-
-Open with a quick 2–3 sentence blurb. Adapt, don't paste verbatim:
-
-> **throughput-forecast** predicts delivery from your team's *actual* weekly
-> throughput — no story points, no velocity. Ask it "how long to clear the
-> backlog?", "how many items ship by a date?", "what's our cycle-time SLE?" —
-> and it flags aging work-in-progress to pull before starting anything new.
-> This runs against GitHub Project #50, not Jira.
-
-## Then — confirm the throughput window (ASK before pulling)
-
-Ask which trailing window to forecast from — **10 weeks** (quick) or **26 weeks**
-(~2 quarters — steadier, smooths noise). Default 26 unless they want the quick
-read. Carry the chosen `WEEKS` into `forecast.py --weeks WEEKS`.
-
-## Data pull — one GraphQL pass (~seconds; trivial rate-limit cost)
-
-Read `config.yaml` for `github.org`, `github.project_number`, `github.repo`,
-`backlog.statuses`, `throughput.done_statuses`, `breakdown_field`, and
-`cycle.*`. Then:
-
-```bash
-python3 .claude/scripts/gh_fetch.py \
- --org --project-number --repo \
- --done-status \
- --statuses \
- --breakdown-field \
- --out-dir /tmp/forecast
-
-python3 .claude/scripts/forecast.py \
- --done /tmp/forecast/done.json --open /tmp/forecast/open.json \
- --changelog /tmp/forecast/done.json /tmp/forecast/open.json \
- --project "Vocable-Android" \
- --components \
- --wip-status \
- --reset-status --start-status \
- --weeks --by-date --seed 42
-```
-
-`gh_fetch.py` shells out to `gh api graphql` (reusing the machine's existing `gh
-auth login` — no token in `settings.json`), nests each Issue's status-change
-timeline in the same paginated query (confirmed ~1 rate-limit point per 50-item
-page — no per-item N+1 calls), and back-fills any item with >50 status changes
-the same way the Jira version back-fills truncated changelogs. Passing the same
-files to `--changelog` makes **started→Done the every-run default** (the honest,
-active-time SLE), exactly as in the Jira version — `forecast.py` doesn't know or
-care that the data came from GitHub instead of Jira.
-
-A sanity summary (item counts by bucket, timelines backfilled, resolutiondate
-fallback/disagreement counts) prints to **gh_fetch.py's stderr** — read it before
-quoting numbers; the done+open+excluded+skipped counts should sum to the
-project's total item count. A second summary (weeks kept/dropped, cycle-time
-percentiles, excluded counts) prints from `forecast.py` itself. If `gh_fetch.py`
-exits `ERROR(6)` (zero Done items), the `--org`/`--project-number`/`--done-status`
-is wrong — fix it, don't hand-assemble numbers.
-
-| `gh_fetch.py` flag | Meaning |
-|------|---------|
-| `--org` / `--project-number` | which GitHub Projects v2 board |
-| `--repo` | repo name (for the timeline-backfill query and issue keys) |
-| `--done-status …` | status name(s) counted as delivered |
-| `--statuses …` | open-queue (active/backlog) status names |
-| `--breakdown-field none\|labels\|issuetype` | what feeds forecast.py's `--components` axis |
-| `--include-drafts` | include DraftIssue project items (default: excluded) |
-| `--include-prs` | **not implemented** — see the script's docstring; a real PR showed `timelineItems.totalCount` not respecting the itemTypes filter, so the truncated-changelog backfill can't be trusted for PRs yet |
-| `--since YYYY-MM-DD` | only include items created on/after this date |
-
-`forecast.py`'s own flags are unchanged from the Jira version — see its
-`--help` or the original skill's docs for the full table (`--weeks`,
-`--by-date`, `--outliers`, `--cycle-max-days`, `--json-out`, etc.).
-
-## DraftIssue and PullRequest are excluded (default) — the Epic-exclusion analog
-
-The Jira version excludes `issuetype = Epic` by default (placeholders, not
-deliverable flow). GitHub Projects v2 has no Epic concept, but has two
-analogous "not deliverable flow" categories: **DraftIssue** items (notes, not
-real issues — excluded by default, `--include-drafts` to opt in) and
-**PullRequest** items (already reachable via an issue's linked PRs — always
-excluded; `--include-prs` is not yet implemented, see above).
-
-## Cycle-time basis — started→Done (Q3/Q4)
-
-Same as the Jira version: a created→Done cycle time bakes in backlog wait and
-inflates the SLE. `--changelog` turns on **started→Done** (active time): start
-= first entry to `--start-status`, reset on any return to a `--reset-status`.
-The status-change timeline `gh_fetch.py` pulls (`ProjectV2ItemStatusChangedEvent`)
-is a direct analog of Jira's changelog, so this works identically.
-
-## HTML report (optional)
-
-After the terminal summary, ask whether they want just the terminal read or a
-shareable **HTML report**. If yes, re-run with `--json-out` and render:
-
-```bash
-python3 .claude/scripts/forecast.py … --json-out /tmp/forecast.json
-python3 .claude/scripts/forecast-html.py /tmp/forecast.json ~/Desktop/forecast-.html
-```
-
-Same `forecast-html.py` as the Jira version, unmodified — it only ever
-consumes `forecast.py --json-out`'s output.
-
-## After running
-
-- Lead with the **85% confidence** lines. Quote 85% to stakeholders, keep 50%
- for the team.
-- Quote the **cycle-time 85% SLE** as the per-item commitment.
-- Call out any **over-SLE** items by key — the "pull before you start new
- work" tickets for the next standup.
-- The forecast is valid only while team size & flow are stable — re-run after
- a reorg or a long holiday.
-- **Component forecasts are independent** — the TEAM line is not the sum of
- the component lines (variances don't add).
diff --git a/.claude/skills/throughput-forecast/config.yaml b/.claude/skills/throughput-forecast/config.yaml
deleted file mode 100644
index 4a963286..00000000
--- a/.claude/skills/throughput-forecast/config.yaml
+++ /dev/null
@@ -1,49 +0,0 @@
-# throughput-forecast — GitHub Projects v2 instance config for vocable-android.
-# The scripts take the same values as CLI flags; this file is the reference
-# for what to pass. See SKILL.md for the full command.
-
-github:
- org: "willowtreeapps"
- project_number: 50
- repo: "vocable-android" # single-repo project today; used for the
- # timeline-backfill query and issue keys
-
-# Optional breakdown axis for Q1/Q2 (forecast.py's --components). This board
-# has no "Components" field and sparse labels, so the default is a team-only
-# forecast. Set to "issuetype" to break out by Task/Bug/Feature instead, or
-# "labels" if labels start getting used consistently.
-breakdown_field: none # none | labels | issuetype
-components: [] # e.g. ["Task", "Bug", "Feature"] if breakdown_field: issuetype
-
-throughput:
- # Status name(s) that mean "delivered". (--done-status)
- done_statuses: ["Done"]
- # The skill should ask the user to pick a trailing window before pulling:
- # 10 weeks (quick) or 26 weeks (~2 quarters — steadier, smooths noise).
- window_weeks: [10, 26]
- default_window_weeks: 26
-
-backlog:
- # Active/committed columns = the open queue. Confirmed board columns for
- # Project #50 (7 statuses — more than Jira's typical 3, so double-check
- # this list against the board before relying on the numbers). (--statuses)
- statuses: ["Pre Backlog", "Backlog", "Ready to select", "In progress",
- "Development Complete (In Review)", "Ready for Demo"]
- # GitHub-side "not deliverable flow" categories (the Epic-exclusion analog).
- include_drafts: false # DraftIssue project items (--include-drafts)
- include_prs: false # PullRequest project items — always excluded by
- # gh_fetch.py today; see its docstring for why
- # (--include-prs is not yet implemented)
-
-forecast:
- horizon_weeks: 3 # default "how many done by then?" horizon
- trials: 10000
- seed: 42 # fixed for reproducibility; change to vary the draw
-
-cycle:
- # The started→Done SLE (Q3) / aging-WIP (Q4) pass measures ACTIVE time from
- # the status-change timeline. start = first entry to start_status; the
- # clock resets on any return to a reset_status.
- start_status: "In progress"
- reset_statuses: ["Backlog", "Pre Backlog"]
- wip_statuses: ["In progress", "Development Complete (In Review)", "Ready for Demo"]
diff --git a/Documentation/work-log/666-remove-vendored-throughput-forecast.md b/Documentation/work-log/666-remove-vendored-throughput-forecast.md
new file mode 100644
index 00000000..4e8cd6fe
--- /dev/null
+++ b/Documentation/work-log/666-remove-vendored-throughput-forecast.md
@@ -0,0 +1,59 @@
+# #666 — Remove vendored throughput-forecast files; install globally instead
+
+## What
+
+Removed everything the throughput-forecast GitHub-Projects port (#653, #664)
+had vendored into this repo:
+
+- `.claude/scripts/{gh_fetch.py,forecast.py,forecast-html.py}`
+- `.claude/skills/throughput-forecast/{SKILL.md,config.yaml}`
+- `.claude/settings.json` (existed solely for this skill's Bash allowlist —
+ removed entirely rather than left with an empty `permissions.allow`)
+
+`create-ticket` (#654/#656) is untouched — separate concern, explicitly out
+of scope. `.claude/settings.local.json` is unrelated pre-existing config, not
+touched either.
+
+## Why
+
+A reviewer on PR #655 flagged that `gh_fetch.py` belonged in the shared
+`dq-documentation` skill repo, not vendored per-app — that skill's
+`forecast.py`/`forecast-html.py`/`config.yaml` are already written to be
+reused by any team. The original plan (tracked in this issue) was to move
+`gh_fetch.py` there while `vocable-android` kept its own project-specific
+config. On reflection, that still leaves an unnecessary footprint in this
+repo — no part of the skill needs to be **read from** vocable-android's own
+git history, since Claude Code skills are discovered from `.claude/skills/`
+per *session*, not per repo, and can just as easily be installed **globally**
+at `~/.claude/skills/`, available for any project's GitHub board without
+committing anything into any one app's repo. That's the direction actually
+taken: PR #665 (the parent-breakdown work) was closed unmerged rather than
+revised, and the whole skill was installed at
+`~/.claude/skills/github-throughput-forecast/` instead — see that skill's
+own `SKILL.md` for the generalized, non-repo-specific version of the setup.
+
+## Key decisions
+
+**Nothing here required reverting merged history.** #655/#656 are merged
+into `feature/voice-selection`, and other branches have since branched off
+it — reverting those merge commits would risk rippling into unrelated work.
+Instead, this is a normal forward-moving delete commit, same as any other
+cleanup — safe regardless of what else has branched off `feature/voice-selection`
+since.
+
+**#665 was closed, not merged-then-reverted.** It never merged, so its
+parent-breakdown/zero-series fixes exist only in the global skill install
+now (carried over when copying the files there), not lost — just relocated
+before ever landing in this repo.
+
+**`run_daily_forecast.py` (from #664) was never part of this cleanup** — it
+was never merged (only ever sat on #664/#665's now-closed branch), so there
+was nothing to remove here. It's being relocated to a personal,
+non-repo-committed location as a separate step, pointed at the new global
+skill scripts.
+
+## Links
+
+- Issue: #666
+- Superseded: PR #655 (merged, files now removed), PR #665 (closed unmerged)
+- Replacement: `~/.claude/skills/github-throughput-forecast/` (local, global install — not in any repo)