From 83dd6a90b36a556e6d45e44e1cf180f8dba2da4f Mon Sep 17 00:00:00 2001 From: rhyslutsky Date: Thu, 6 Aug 2026 12:52:56 -0400 Subject: [PATCH] Remove vendored throughput-forecast files; skill now installed globally Per review on #655/#666: rather than relocating gh_fetch.py to dq-documentation while still vendoring project-specific copies here, the whole skill is now installed at ~/.claude/skills/github-throughput-forecast/ instead - available for any GitHub Projects v2 board, nothing committed into any one repo's history. Removes .claude/scripts/{gh_fetch,forecast,forecast-html}.py, .claude/skills/throughput-forecast/, and the settings.json that existed solely for their Bash allowlist. create-ticket (#654/#656) untouched - separate concern, out of scope. No merged history reverted - this is a normal forward delete commit. PR #665 closed unmerged rather than revised (its fixes carried over into the global install already). Closes #666 --- .claude/scripts/forecast-html.py | 503 --------- .claude/scripts/forecast.py | 952 ------------------ .claude/scripts/gh_fetch.py | 430 -------- .claude/settings.json | 10 - .claude/skills/throughput-forecast/SKILL.md | 166 --- .../skills/throughput-forecast/config.yaml | 49 - ...666-remove-vendored-throughput-forecast.md | 59 ++ 7 files changed, 59 insertions(+), 2110 deletions(-) delete mode 100644 .claude/scripts/forecast-html.py delete mode 100644 .claude/scripts/forecast.py delete mode 100644 .claude/scripts/gh_fetch.py delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/throughput-forecast/SKILL.md delete mode 100644 .claude/skills/throughput-forecast/config.yaml create mode 100644 Documentation/work-log/666-remove-vendored-throughput-forecast.md 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'
{" ".join(parts)}
' - - # ---------- Overview panel ---------- - def build_overview(): - cards = [ - dual_card("Clear the open queue", - f"{team['q1']['w50']}w", f"{team['q1']['w85']}w", - [f"{team['backlog']} items · 85% by {esc(team['q1']['date85'])}", - f"95% floor {team['q1']['w95']}w"], - accent=C_BLUE), - dual_card(f"Ship by {esc(m['by_date'])}", - f"≥ {team['q2']['c50']}", f"≥ {team['q2']['c85']}", - [f"{m['n_weeks']} wks out · 95% floor ≥ {team['q2']['c95']}", - "at-least-N items, team-wide"], - accent=C_TEAL), - ] - if cycle: - cards.append(card("Cycle-time SLE", f"{cycle['p85']:.0f}d", "85% per-item", - [f"typical {cycle['p50']:.0f}d · worst {cycle['p95']:.0f}d", - f"{esc(m['basis'])} · n={cycle['n']}"], - accent=C_BLUE_SH3)) - cards.append(card("Aging WIP over SLE", bands.get("breach", 0), - "pull-first", [f"{bands.get('warn',0)} aging · " - f"{bands.get('healthy',0)} healthy", - f"of {bands.get('total',0)} in flight"], - accent=ST_BREACH)) - cardhtml = f'
{"".join(cards)}
' - - # Team throughput columns - wk_labels = [f"w{i+1}" for i in range(len(team["sample"]))] - tp = ('

Weekly throughput — team

' - '
items reaching Done per complete week, oldest → newest ' - f'({m["sample_weeks"]} weeks)
' - + 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.
') - return cardhtml + tp + q1 + q2 + note - - # ---------- Cycle-time panel ---------- - def build_cycle(): - if not cycle: - return '
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).
') - return ('

Parked WIP · in flight > ' + str(PARK) + ' days

' - + summary + _legend + hbar(_wip_rows(parked_wip), int(oldest * 1.02), unit="d")) - - # ---------- Per-component panels ---------- - def build_component(s): - cards = [ - card("Open queue", s["backlog"], None, ["items awaiting delivery"]), - dual_card("Clear it", f"{s['q1']['w50']}w", f"{s['q1']['w85']}w", - [f"85% by {esc(s['q1']['date85'])}", - f"95% floor {s['q1']['w95']}w"], - accent=C_BLUE), - dual_card(f"By {esc(m['by_date'])}", - f"≥ {s['q2']['c50']}", f"≥ {s['q2']['c85']}", - [f"95% floor ≥ {s['q2']['c95']}"]), - card("Weekly rate", f"{s['mean']}/wk", None, - [f"range {s['min']}–{s['max']} · {m['sample_weeks']} wks"]), - ] - wk_labels = [f"w{i+1}" for i in range(len(s["sample"]))] - chart = ('

Weekly throughput

' - + col_chart(s["sample"], wk_labels, max(max(s["sample"]), 1), - mean=s["mean"], outliers=outlier_idx(s)) - + outlier_caption(s)) - return (f'

{esc(s["name"])}

' - f'
{"".join(cards)}
{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'
{epics_chip}{body}
' - for i, (_, body) in enumerate(panels)) - tab_rules = "".join( - f'#t{i}:checked~.layout .panels .pan{i}{{display:block;}}' - f'#t{i}:checked~.layout .sidebar label[for=t{i}]{{background:{C_BLUE};color:#fff;border-color:{C_BLUE};}}' - for i in range(len(panels))) - - CSS = f""" -body {{ font-family: {BODY_FONT}; margin: 0; background: {C_TAN}; color: {C_DARK}; }} -input[name="tab"] {{ position: absolute; opacity: 0; pointer-events: none; }} -.layout {{ display: flex; max-width: 1080px; margin: 0 auto; gap: 20px; padding: 24px 16px 48px; }} -.sidebar {{ flex: 0 0 170px; position: sticky; top: 16px; align-self: flex-start; - display: flex; flex-direction: column; gap: 8px; }} -.side-head {{ font-family: {HEADLINE_FONT}; font-weight: 700; color: {C_BLUE_SH3}; - font-size: 13px; padding: 4px 2px 8px; }} -.sidebar label {{ display: block; background: #FFFFFF; border: 1px solid {C_GRAY_T2}; - border-radius: 10px; padding: 9px 12px; font-size: 14px; font-weight: 600; - color: {C_BLUE_SH3}; cursor: pointer; }} -.sidebar label:hover {{ border-color: {C_BLUE}; }} -.panels {{ flex: 1; min-width: 0; }} -.panel {{ display: none; }} -{tab_rules} -header {{ border-bottom: 4px solid {C_RED}; padding: 0 0 12px; margin-bottom: 20px; - display: flex; justify-content: space-between; align-items: center; gap: 16px; }} -.logo svg {{ width: 96px; height: auto; display: block; }} -h1 {{ font-family: {HEADLINE_FONT}; font-size: 26px; font-weight: 700; margin: 0 0 4px; color: {C_BLUE}; }} -h2 {{ font-family: {HEADLINE_FONT}; font-size: 17px; font-weight: 700; margin: 26px 0 4px; color: {C_BLUE_SH3}; }} -h2.plat-title {{ margin-top: 0; font-size: 21px; color: {C_BLUE}; }} -.sub {{ color: {C_BLUE_SH3}; font-size: 12px; margin-bottom: 10px; }} -.cards {{ display: flex; flex-wrap: wrap; gap: 12px; }} -.card {{ flex: 1 1 200px; background: #FFFFFF; border-radius: 12px; padding: 14px 16px; - border: 1px solid {C_GRAY_T2}; }} -.card .name {{ font-family: {HEADLINE_FONT}; font-size: 13px; font-weight: 700; color: {C_BLUE}; }} -.card .value {{ font-size: 30px; font-weight: 800; margin: 4px 0 2px; color: {C_DARK}; }} -.card .detail {{ font-size: 12px; color: {C_BLUE_SH3}; line-height: 1.5; }} -.dual {{ display: flex; gap: 12px; margin: 6px 0 4px; }} -.dv {{ flex: 1 1 0; min-width: 0; }} -.dvlab {{ font-size: 10px; font-weight: 700; color: {C_BLUE_SH3}; white-space: nowrap; letter-spacing: .2px; }} -.dvnum {{ font-size: 25px; font-weight: 800; line-height: 1.1; color: {C_DARK}; }} -.dvnum.accent {{ color: {C_BLUE}; }} -.dv:last-child {{ border-left: 1px solid {C_GRAY_T2}; padding-left: 12px; }} -.pill {{ display: inline-block; font-size: 11px; font-weight: 700; border-radius: 999px; - padding: 2px 10px; background: {C_TEAL_T2}; color: {C_BLUE_SH3}; margin-top: 4px; }} -.scopechip {{ display: inline-block; font-size: 11px; font-weight: 700; border-radius: 999px; - padding: 3px 12px; margin: 0 0 14px; background: {C_TAN_SH1}; color: {C_BLUE_SH3}; - border: 1px solid {C_GRAY_T2}; }} -.note {{ background: {C_TAN_SH1}; border-left: 4px solid {C_RED}; padding: 8px 12px; - font-size: 13px; border-radius: 0 8px 8px 0; margin: 14px 0; color: {C_DARK}; line-height: 1.5; }} -/* horizontal magnitude bars: [ label gutter | track | value gutter ] */ -.chart {{ position: relative; background: #FFFFFF; border: 1px solid {C_GRAY_T2}; - border-radius: 10px; padding: 28px 14px 16px; }} /* top strip = axis-label headroom */ -.brow {{ display: flex; align-items: center; gap: 10px; margin: 4px 0; }} -.blabel {{ flex: 0 0 118px; font-size: 12px; font-weight: 600; color: {C_DARK}; - text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }} -.blabel a {{ color: {C_BLUE}; text-decoration: underline; }} -.blabel a:hover {{ color: {C_BLUE_SH3}; }} -.btrack {{ position: relative; flex: 1; background: {C_TAN_SH1}; border-radius: 5px; height: 16px; }} -.bfill {{ height: 100%; border-radius: 0 5px 5px 0; min-width: 2px; }} -.bfill.bar-clip {{ border-radius: 0; -webkit-mask-image: linear-gradient(90deg,#000 82%,transparent 99%); - mask-image: linear-gradient(90deg,#000 82%,transparent 99%); }} -.bval {{ flex: 0 0 46px; font-size: 11px; font-weight: 700; color: {C_BLUE_SH3}; - white-space: nowrap; }} -/* axis reference lines live in an overlay inset to exactly match the track column - (left = padding14 + label118 + gap10 = 142; right = padding14 + value46 + gap10 = 70); - top/bottom match chart padding so the dashed lines span the bars only, and each - label floats up into the top strip above the first bar. */ -.trackregion {{ position: absolute; top: 28px; bottom: 16px; left: 142px; right: 70px; - pointer-events: none; }} -.axline {{ position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px dashed {C_RED}; }} -.axline span {{ position: absolute; top: -18px; left: 4px; font-size: 9px; font-weight: 700; - color: {C_RED}; white-space: nowrap; background: #FFFFFF; padding: 0 2px; }} -/* throughput columns */ -.colchart {{ position: relative; display: flex; align-items: flex-end; gap: 6px; height: 150px; - background: #FFFFFF; border: 1px solid {C_GRAY_T2}; border-radius: 10px; - padding: 14px 14px 6px; }} -.col {{ flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; - height: 100%; }} -.col .cbar {{ width: 60%; max-width: 34px; background: {C_BLUE}; border-radius: 4px 4px 0 0; min-height: 2px; }} -.col .cbar.out {{ background: {ST_WARN}; }} -.col .omark {{ font-size: 10px; font-weight: 700; color: {ST_WARN}; line-height: 1; margin-bottom: 1px; }} -.col .cval {{ font-size: 10px; font-weight: 700; color: {C_BLUE_SH3}; margin-bottom: 2px; }} -.col .clab {{ font-size: 9px; color: {C_BLUE_SH3}; margin-top: 3px; }} -.meanline {{ position: absolute; left: 14px; right: 14px; border-top: 2px dashed {C_RED}; }} -.meanline span {{ position: absolute; right: 0; top: -12px; font-size: 9px; font-weight: 700; color: {C_RED}; }} -.legend {{ display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: {C_DARK}; margin: 4px 0 10px; }} -.legend i {{ display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 5px; - vertical-align: -1px; }} -footer {{ margin-top: 32px; font-size: 11px; color: {C_BLUE_SH3}; line-height: 1.6; }} -@media (max-width: 720px) {{ - .layout {{ flex-direction: column; }} - .sidebar {{ position: static; flex-direction: row; flex-wrap: wrap; }} - .blabel {{ flex-basis: 84px; }} - .trackregion {{ left: 108px; right: 70px; }} -}} -""" - - # Report labels are driven by --project. When it's absent, don't double the - # word or dangle a "·" — fall back to a clean "Delivery Forecast". - proj = m.get('project') or "" - ppfx = f"{esc(proj)} " if proj else "" # "DQLS " or "" - pside = f"Forecast · {esc(proj)}" if proj else "Forecast" - OUT.write_text(f""" - - -{ppfx}Forecast — {esc(m['today'])} - -{radios} -
- -
-

{ppfx}Delivery Forecast

-
throughput-based (no story points) · {esc(m['sample_weeks'])} weeks · -window: {esc(m['window'])} · {esc(m['trials']):} trials · generated {esc(m['today'])}
-
{('') if LOGO_SVG else ''}
-{panel_divs} -
Monte-Carlo forecast from the team’s actual weekly throughput — the Kanban -replacement for velocity/sprint commitment. Q1/Q2 read at 50/85/95% confidence; quote the -85% line to stakeholders and keep 50% internal. Q3 SLE basis: {esc(m['basis'])}. Valid only -while team & flow match the last {esc(m['sample_weeks'])} weeks — re-run after a reorg or -holiday. Generated by forecast-html.py from a forecast.py JSON dump.
-
-""") - 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)