Compare commits

...

3 Commits

Author SHA1 Message Date
Croissant Le Doux
7e26de1b6c feat: calibration from closed-issue actuals → forecast flips off cold-start (#1)
Close the D3 loop. The forecast now learns from the team's own estimate-vs-actual
history (the working time #5 infers from git events) instead of guessing forever.

core (@commitea/core/calibration-v0):
- fitCalibration(samples): lognormal fit on log(actual/estimate) — global +
  per-bucket (once a bucket clears the floor) + per-person bias. coldStart until
  n >= 20 closed-with-estimate issues.
- calibrationSamples(): pull those samples from the closed backlog via lifecycle
  inference (estimate label vs inferred actualWorkingDays).
- toDurationModel(): project the fit to the params forecast consumes.
- forecast() gains options.model: when past cold-start, fitted params drive the
  sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart
  now reflects the model. nearestBucket extracted + exported.

app:
- AppShell fits calibration once from the reconciled backlog, feeds the model into
  forecastBacklog (cone), and drives the Calibration screen + Runway header.
- Focus cone footer, Runway note, and Calibration screen now say cold-start (N/20)
  vs calibrated (on N closed) from real data; Calibration scatter / bucket bias /
  per-person all fitted, degrading honestly on a thin dataset.

Known refinement: same-day closes yield 0 working-day actuals (day-granular) and
are excluded, so a fast-moving repo can sit at n=0 — honest, but a fractional
(hours-based) actual would let those count. Per-person uses gitea login, not
display name, until the person map lands.

Note: also re-lands #10 (Monte Carlo) and #5 (lifecycle) which merged into their
stacked base branches but never propagated to main (stacked-merge trap); this
branch is cut from main and carries all three so main is whole again.

Verified: 74 core tests green (9 calibration + 2 forecast-switch added), desktop
typecheck clean, 14 fixture e2e green, live spec asserts the real cold-start
calibration surface (Runway note + screen badge fitted from actuals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:51:50 -04:00
Croissant Le Doux
9cedd8646e feat: lifecycle inference from the issue timeline (#5)
Fill the board's Steeping / In-review columns (and the calibration actuals)
from real gitea timeline events, replacing the three-column-only v0.

core (@commitea/core):
- inferLifecycle(issue, events, asOf): five-column inference — closed → done;
  open PR ref → review; commit ref → steeping; any triage signal → triage;
  else diagnosis. Earliest event of each kind fixes the stage timestamp.
- Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the
  calibration fit (D3) learns from — and steepingDays (first commit → now) for
  the board age badge.
- workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular.
- normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline
  (label/milestone → triage, commit_ref → commit, pull_ref → pull, close,
  reopen), drop the rest. Paginated.

app:
- reconcile now fetches every issue's timeline and returns it keyed by number;
  threaded through the bridge → useBacklog → board/focus.
- issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review
  columns, steeping-age `days` badge, focus-card steeping badge.

Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely
referenced in a PR body can read as In-review; distinguishing closing refs from
mentions needs the PR link's state (later). Re-opening multi-segment actuals
also deferred.

Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five
transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts
the board's Done column is populated from real events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:42:46 -04:00
Croissant Le Doux
be70c8607d feat: Monte Carlo forecast → real burn-up cone (#10)
Replace the demo cone on Morning service with a real, seeded Monte Carlo
forecast over the open backlog. The LLM never does this — it's plain,
reproducible code (evidence-based scheduling).

core (@commitea/core/forecast-v0):
- Code-resident lognormal cold-start priors per estimate bucket (D3):
  sampled actual = estimate * exp(N(mu, sigma)), mu > 0 (actuals run long),
  sigma shrinks as tickets grow. Replaced by the team's empirical fit at
  n >= 20 (#5 supplies the actuals).
- forecast(): seeded mulberry32 + Box-Muller over the scheduler's
  deterministic order (order is fixed from estimates/deps; only durations
  vary, so the cone stretches, never reorders). Returns p50/p80/p95 landing
  + a per-issue burn-up curve (p10/p50/p90). 12 unit tests; reproducible.

renderer:
- lib/dates.ts: working-day -> calendar mapper (skips weekends) + buildBurnUpData.
- BurnUpCone gains a data-driven twin; falls back byte-identical to the
  fixture cone when no forecast (demo mode unchanged).
- Focus card shows the real "80% of the open backlog lands by <range>",
  real scope count, and names the cold-start priors.

v0 scope (each a later slice): single serial worker (capacity is #8);
cold-start priors only (empirical fit is #5); no historical actual polyline
(needs lifecycle events, #5). Header chrome (reconcile time, ahead/behind
badge) stays fixture until milestone due dates land.

Verified: 51 core tests green, desktop typecheck clean, 14 fixture e2e green,
live spec asserts the real cone renders (25 open issues, "lands by Nov 11-27").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:42:46 -04:00
20 changed files with 1289 additions and 71 deletions

View File

@@ -16,17 +16,35 @@ test.describe('live backlog', () => {
await win.waitForLoadState('domcontentloaded') await win.waitForLoadState('domcontentloaded')
const rail = win.getByRole('navigation', { name: 'Primary' }) const rail = win.getByRole('navigation', { name: 'Primary' })
// The pot — waiting here also lets the reconcile (issues + deps) complete // The pot — waiting here also lets the reconcile (issues + deps + timelines) complete
await rail.getByRole('button', { name: 'The pot' }).click() await rail.getByRole('button', { name: 'The pot' }).click()
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 }) await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
// Lifecycle inference (#5): columns come from the real event stream — merged
// work lands in Done, so that column is non-empty (proves timelines drove it,
// not the three-column fallback which would still show closed issues in Done).
await expect(win.getByText('Done', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These // Back to Focus — the deterministic scheduler's real Now/Next/Later. These
// rationale phrases are emitted only by the scheduler, never by the demo fixture. // rationale phrases are emitted only by the scheduler, never by the demo fixture.
await rail.getByRole('button', { name: 'Morning service' }).click() await rail.getByRole('button', { name: 'Morning service' }).click()
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible() await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
// Real Monte Carlo cone — this headline is emitted only for real forecasts.
await expect(win.getByText(/80% of the open backlog lands by/)).toBeVisible()
await expect(win.getByText(/Cold-start priors/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
// Runway → calibration surface, fitted from real closed-issue actuals (#1).
// With <20 estimated closes the repo is honestly cold-start; the note proves
// the fit ran on real data, not the fixture's "calibrated on 27".
await rail.getByRole('button', { name: 'Runway' }).click()
await expect(
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible()
await win.getByRole('button', { name: 'Full report' }).click()
await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' })
await app.close() await app.close()
}) })
}) })

View File

@@ -9,7 +9,7 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { createGiteaClient, type GiteaConfig } from '@commitea/core' import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
@@ -51,7 +51,7 @@ export function registerGiteaIpc(): void {
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo })) ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
ipcMain.handle('gitea:reconcile', async () => { ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [] } if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
// dependency edges among the open scope (the scheduler only plans what's left) // dependency edges among the open scope (the scheduler only plans what's left)
const open = issues.filter((i) => i.state === 'open') const open = issues.filter((i) => i.state === 'open')
@@ -59,7 +59,12 @@ export function registerGiteaIpc(): void {
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })), open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
) )
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d }))) const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
return { configured: true, issues, milestones, deps } // lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
const timelineEntries = await Promise.all(
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
)
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
return { configured: true, issues, milestones, deps, timelines }
}) })
ipcMain.handle('gitea:getIssue', async (_event, index: number) => { ipcMain.handle('gitea:getIssue', async (_event, index: number) => {

View File

@@ -1,15 +1,21 @@
import type { BurnUpData } from '../../lib/dates.js'
/** /**
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. The * Charts — geometry, not decoration. Ported from the handoff's Chart.js. When a
* fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the * `data` prop is supplied the cone is drawn from real Monte Carlo output
* shapes (cone from today, 80% band, actual polyline, today rule) are final. * (#10); without it, the fixed sample paths below stand in (demo mode). The
* shapes (cone from today, 80% band, today rule) are final.
*/ */
export interface BurnUpConeProps { export interface BurnUpConeProps {
width?: number width?: number
height?: number height?: number
/** Real forecast geometry. When present it replaces the demo sample paths. */
data?: BurnUpData
} }
export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) { export function BurnUpCone({ width = 640, height = 220, data }: BurnUpConeProps) {
if (data) return <BurnUpConeReal width={width} height={height} data={data} />
const pad = { l: 34, r: 96, t: 16, b: 26 } const pad = { l: 34, r: 96, t: 16, b: 26 }
const W = width - pad.l - pad.r const W = width - pad.l - pad.r
const H = height - pad.t - pad.b const H = height - pad.t - pad.b
@@ -89,6 +95,62 @@ export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) {
) )
} }
/** Data-driven twin of BurnUpCone — same visual grammar, real forecast geometry. */
function BurnUpConeReal({ width, height, data }: { width: number; height: number; data: BurnUpData }) {
const pad = { l: 34, r: 96, t: 16, b: 26 }
const W = width - pad.l - pad.r
const H = height - pad.t - pad.b
const x = (day: number) => pad.l + (day / data.horizonDays) * W
const y = (frac: number) => pad.t + (1 - frac) * H
const hi = data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`).join(' ')
const lo = data.band.map((p) => `${x(p.loDay)},${y(p.fraction)}`).join(' ')
const mid = data.band.map((p) => `${x(p.midDay)},${y(p.fraction)}`).join(' ')
const cone = [
...data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`),
...[...data.band].reverse().map((p) => `${x(p.loDay)},${y(p.fraction)}`),
].join(' ')
return (
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
))}
{/* scope */}
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
scope · {data.scope} {data.scope === 1 ? 'issue' : 'issues'}
</text>
{/* cone */}
<polygon points={cone} fill="var(--cone-fill)" />
<polyline points={hi} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={lo} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
<polyline points={mid} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
{/* origin = today, 0% of remaining scope */}
<circle cx={x(0)} cy={y(0)} r="3.5" fill="var(--cone-actual)" />
{/* today rule */}
<line x1={x(0)} x2={x(0)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
<text x={x(0) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
today
</text>
{/* 80% band label */}
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
80%
</text>
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
{data.rangeLabel}
</text>
{/* x labels */}
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
{data.startLabel}
</text>
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
{data.endLabel}
</text>
</svg>
)
}
export interface RunwayBarMilestone { export interface RunwayBarMilestone {
tone: 'ok' | 'warn' tone: 'ok' | 'warn'
pos: number pos: number

View File

@@ -1,11 +1,12 @@
import React from 'react' import React from 'react'
import { CALIBRATION } from '../../data/fixtures.js' import { CALIBRATION, type CalibrationData } from '../../data/fixtures.js'
import { Badge, Card, Icon } from '../ui/index.js' import { Badge, Card, Icon } from '../ui/index.js'
// Calibration report — estimate-vs-actual evidence behind the cones // Calibration report — estimate-vs-actual evidence behind the cones.
export function CalibrationScreen({ onBack }: { onBack: () => void }) { // `data` (real fit from closed-issue actuals) overrides the demo fixture.
const c = CALIBRATION export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) {
const c = data ?? CALIBRATION
// scatter chart geometry // scatter chart geometry
const W = 420, const W = 420,
@@ -68,7 +69,11 @@ export function CalibrationScreen({ onBack }: { onBack: () => void }) {
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Calibration</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Calibration</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p>
</div> </div>
{c.active ? (
<Badge tone="ok" dot>curve active · n 20</Badge> <Badge tone="ok" dot>curve active · n 20</Badge>
) : (
<Badge tone="warn" dot>cold-start · {c.n}/20</Badge>
)}
</header> </header>
</div> </div>
@@ -170,7 +175,9 @@ export function CalibrationScreen({ onBack }: { onBack: () => void }) {
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span> <span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span>
</div> </div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with. {c.active
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
: 'Not enough closed history yet — Im forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
</p> </p>
</Card> </Card>
</div> </div>

View File

@@ -1,21 +1,23 @@
import React from 'react' import React from 'react'
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js' import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
import { type FocusView } from '../../lib/backlog.js' import { type ForecastView, type FocusView } from '../../lib/backlog.js'
import { BurnUpCone } from '../charts/chart.js' import { BurnUpCone } from '../charts/chart.js'
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
/** /**
* Morning service — the Now/Next/Later focus cards + the milestone burn-up cone. * Morning service — the Now/Next/Later focus cards + the burn-up cone.
* `focus` (real scheduler output) overrides the demo fixture when gitea is * `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures
* configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice). * when gitea is configured; both fall back to the handoff demo otherwise.
*/ */
export function FocusScreen({ export function FocusScreen({
onOpenIssue, onOpenIssue,
focus, focus,
forecast,
}: { }: {
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
focus?: FocusView focus?: FocusView
forecast?: ForecastView
}) { }) {
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later } const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => ( const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
@@ -94,13 +96,23 @@ export function FocusScreen({
</div> </div>
<Card <Card
overline="Milestone · Beta" overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>} title={
forecast ? (
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
) : (
<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 312</span></>
)
}
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />} actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
> >
<BurnUpCone /> <BurnUpCone data={forecast?.cone} />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
The cone has narrowed since Friday. Im quietly pleased. {forecast
? forecast.coldStart
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`
: 'The cone has narrowed since Friday. Im quietly pleased.'}
</p> </p>
</Card> </Card>
</div> </div>

View File

@@ -8,15 +8,22 @@ import { RUNWAY, CAPACITY } from '../../data/fixtures.js'
export function RunwayScreen({ export function RunwayScreen({
onOpenCalibration, onOpenCalibration,
onOpenMilestone, onOpenMilestone,
calibration,
}: { }: {
onOpenCalibration: () => void onOpenCalibration: () => void
onOpenMilestone: () => void onOpenMilestone: () => void
calibration?: { n: number; coldStart: boolean }
}) { }) {
const calibNote = calibration
? calibration.coldStart
? `cold-start priors · ${calibration.n}/20 closed issues estimated`
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
: 'calibrated on 27 closed issues'
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}> <header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · calibrated on 27 closed issues</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · {calibNote}</p>
</header> </header>
<Card overline="Milestones" flush> <Card overline="Milestones" flush>

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueRef } from '../../data/fixtures.js' import type { IssueRef } from '../../data/fixtures.js'
import { issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' import { backlogCalibration, forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js' import { useBacklog } from '../../lib/use-backlog.js'
import { PrimitivesGallery } from '../gallery.js' import { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js' import { BoardScreen } from '../screens/board-screen.js'
@@ -86,8 +86,16 @@ export function AppShell() {
const [issue, setIssue] = useState<IssueRef | null>(null) const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([]) const [readIds, setReadIds] = useState<number[]>([])
const backlog = useBacklog() const backlog = useBacklog()
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined const boardColumns =
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus =
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
const calibration =
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast =
backlog.status === 'ready'
? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined)
: undefined
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
@@ -158,7 +166,7 @@ export function AppShell() {
const renderScreen = () => { const renderScreen = () => {
switch (view) { switch (view) {
case 'focus': case 'focus':
return <FocusScreen onOpenIssue={openIssue} focus={focus} /> return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
case 'standup': case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} /> return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board': case 'board':
@@ -174,10 +182,11 @@ export function AppShell() {
<RunwayScreen <RunwayScreen
onOpenCalibration={() => setView('calibration')} onOpenCalibration={() => setView('calibration')}
onOpenMilestone={() => setView('milestone')} onOpenMilestone={() => setView('milestone')}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
/> />
) )
case 'calibration': case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} /> return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} />
case 'milestone': case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} /> return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} />
case 'inbox': case 'inbox':

View File

@@ -1,4 +1,4 @@
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge { export interface GiteaBridge {
@@ -8,6 +8,8 @@ export interface GiteaBridge {
issues: GiteaIssue[] issues: GiteaIssue[]
milestones: GiteaMilestone[] milestones: GiteaMilestone[]
deps: DependencyEdge[] deps: DependencyEdge[]
/** Normalized lifecycle events keyed by issue number. */
timelines: Record<number, LifecycleEvent[]>
}> }>
getIssue(index: number): Promise<GiteaIssue | null> getIssue(index: number): Promise<GiteaIssue | null>
} }

View File

@@ -1,14 +1,32 @@
import { import {
type CalibrationModel,
type CalibrationSample,
calibrationSamples,
COLD_START_THRESHOLD,
type DependencyEdge, type DependencyEdge,
fitCalibration,
forecast,
type GiteaIssue, type GiteaIssue,
inferColumnV0, inferLifecycle,
type LifecycleColumn, type LifecycleColumn,
type LifecycleEvent,
type LifecycleInference,
PRIOR_BUCKETS,
schedule, schedule,
type ScheduledItem, type ScheduledItem,
selectFocus, selectFocus,
toDurationModel,
} from '@commitea/core' } from '@commitea/core'
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js' import { type BoardColumn, type BoardIssue, type CalibrationData, type FocusIssue } from '../data/fixtures.js'
import { type BurnUpData, buildBurnUpData } from './dates.js'
type Timelines = Record<number, LifecycleEvent[]>
/** Infer every issue's lifecycle once; callers index by issue number. */
function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map<number, LifecycleInference> {
return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)]))
}
const COLUMN_LABELS: Record<LifecycleColumn, string> = { const COLUMN_LABELS: Record<LifecycleColumn, string> = {
diagnosis: 'Diagnosis', diagnosis: 'Diagnosis',
@@ -25,24 +43,32 @@ function initials(login: string): string {
} }
/** /**
* Shape real gitea issues into the Board's five columns via lifecycle-v0. * Shape real gitea issues into the Board's five columns via lifecycle inference
* `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr` * (#5). Steeping / In-review are now populated from the event stream (first
* are likewise event-derived and omitted here. * commit ref → steeping, first PR ref → review); the `days` badge is the
* steeping age in working days.
*/ */
export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] { export function issuesToBoardColumns(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): BoardColumn[] {
const inf = inferAll(issues, timelines, asOf)
return COLUMN_ORDER.map((key) => ({ return COLUMN_ORDER.map((key) => ({
id: key, id: key,
label: COLUMN_LABELS[key], label: COLUMN_LABELS[key],
issues: issues issues: issues
.filter((i) => inferColumnV0(i) === key) .filter((i) => inf.get(i.number)!.column === key)
.map( .map((i): BoardIssue => {
(i): BoardIssue => ({ const li = inf.get(i.number)!
return {
id: i.number, id: i.number,
title: i.title, title: i.title,
labels: i.labels, labels: i.labels,
who: i.assignee ? initials(i.assignee) : '·', who: i.assignee ? initials(i.assignee) : '·',
days: li.steepingDays != null ? `${li.steepingDays}d` : undefined,
}
}), }),
),
})) }))
} }
@@ -52,9 +78,138 @@ export interface FocusView {
later: FocusIssue | null later: FocusIssue | null
} }
function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { function toFocusIssue(item: ScheduledItem | null, inf?: Map<number, LifecycleInference>): FocusIssue | null {
if (!item) return null if (!item) return null
return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale } const steepingDays = inf?.get(item.number)?.steepingDays ?? null
return {
id: item.number,
title: item.title,
labels: item.labels,
rationale: item.rationale,
steeping: steepingDays != null ? `${steepingDays}d` : undefined,
}
}
function toSchedulable(issues: GiteaIssue[]) {
return issues
.filter((i) => i.state === 'open')
.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
}))
}
export interface ForecastView {
scope: number
cone: BurnUpData
p80Label: string
rangeLabel: string
/** true while the forecast still runs on code priors (calibration not yet trusted). */
coldStart: boolean
/** Closed-with-estimate issues feeding calibration so far. */
calibratedN: number
}
/**
* Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored
* burn-up cone. When a calibration model is supplied and past cold-start, its
* fitted params drive the sim. Returns null when there's nothing to forecast.
* `today` is injectable for tests.
*/
export function forecastBacklog(
issues: GiteaIssue[],
deps: DependencyEdge[],
today: Date = new Date(),
calibration?: CalibrationModel,
): ForecastView | null {
const model = calibration ? toDurationModel(calibration) : undefined
const f = forecast(toSchedulable(issues), deps, model ? { model } : {})
const cone = buildBurnUpData(f, today)
if (!cone) return null
return {
scope: f.scope,
cone,
p80Label: cone.p80Label,
rangeLabel: cone.rangeLabel,
coldStart: f.coldStart,
calibratedN: calibration?.n ?? 0,
}
}
/** Fit the calibration model from the closed backlog's inferred actuals (#1). */
export function calibrateBacklog(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): CalibrationModel {
return fitCalibration(calibrationSamples(issues, timelines, asOf))
}
/** Calibration model + its screen view in one pass over the closed backlog. */
export function backlogCalibration(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): { model: CalibrationModel; data: CalibrationData } {
const samples = calibrationSamples(issues, timelines, asOf)
const model = fitCalibration(samples)
return { model, data: calibrationData(model, samples, issues) }
}
const pctFromMu = (mu: number) => Math.round((Math.exp(mu) - 1) * 100)
/**
* Shape the calibration model + its samples into the screen's view. Buckets and
* people only earn a bias once their sample clears the fit floor; everything
* degrades honestly on a thin (cold-start) dataset.
*/
export function calibrationData(
model: CalibrationModel,
samples: CalibrationSample[],
openIssues: GiteaIssue[],
): CalibrationData {
const labels = PRIOR_BUCKETS.map((b) => {
const inBucket = samples.filter((s) => s.bucket === b)
const fit = model.byBucket[b]
const mu = fit ? fit.mu : model.global.mu
return {
label: `est/${b}d`,
n: fit ? fit.n : inBucket.length,
median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '—',
bias: fit ? pctFromMu(fit.mu) : null,
}
})
const people = Object.entries(model.byPerson).map(([who, pb]) => ({
who,
n: pb.n,
bias: pctFromMu(model.global.mu + pb.biasMu),
note: '',
}))
const openEst = openIssues
.filter((i) => i.state === 'open')
.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
const effect = model.coldStart
? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '—' }
: {
raw: `${openEst}d estimated`,
banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,
p50: `${Math.round(openEst * Math.exp(model.global.mu))}d`,
}
return {
n: model.n,
active: !model.coldStart,
labels,
people,
scatter: samples.map((s) => [s.estimateDays, s.actualWorkingDays]),
fit: Number(Math.exp(model.global.mu).toFixed(2)),
effect,
}
} }
/** /**
@@ -62,18 +217,18 @@ function toFocusIssue(item: ScheduledItem | null): FocusIssue | null {
* as Now/Next/Later. Estimates + priority come from label facts; dependency * as Now/Next/Later. Estimates + priority come from label facts; dependency
* edges come from gitea's native issue dependencies. * edges come from gitea's native issue dependencies.
*/ */
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView { export function scheduleFocus(
const open = issues.filter((i) => i.state === 'open') issues: GiteaIssue[],
const plan = schedule( deps: DependencyEdge[],
open.map((i) => ({ timelines: Timelines = {},
number: i.number, asOf: Date = new Date(),
title: i.title, ): FocusView {
labels: i.labels, const plan = schedule(toSchedulable(issues), deps)
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
})),
deps,
)
const f = selectFocus(plan) const f = selectFocus(plan)
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) } const inf = inferAll(issues, timelines, asOf)
return {
now: toFocusIssue(f.now, inf),
next: toFocusIssue(f.next, inf),
later: toFocusIssue(f.later, inf),
}
} }

View File

@@ -0,0 +1,70 @@
import type { Forecast } from '@commitea/core'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
/** Advance `base` by whole working days (skipping Sat/Sun). Fractions round to nearest day. */
export function addWorkingDays(base: Date, workingDays: number): Date {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate())
let remaining = Math.max(0, Math.round(workingDays))
while (remaining > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) remaining -= 1
}
return d
}
/** "Mar 6" */
export function formatShort(d: Date): string {
return `${MONTHS[d.getMonth()]} ${d.getDate()}`
}
/** "Feb 24 Mar 6" (collapses the month when both ends share it → "Mar 312"). */
export function formatRange(lo: Date, hi: Date): string {
if (lo.getMonth() === hi.getMonth()) return `${MONTHS[lo.getMonth()]} ${lo.getDate()}${hi.getDate()}`
return `${formatShort(lo)} ${formatShort(hi)}`
}
export interface BurnUpBandPoint {
fraction: number
loDay: number
midDay: number
hiDay: number
}
/** Chart-ready geometry + calendar labels derived from a Monte Carlo forecast. */
export interface BurnUpData {
scope: number
horizonDays: number
band: BurnUpBandPoint[]
p80Day: number
p80Label: string
rangeLabel: string
startLabel: string
endLabel: string
}
/**
* Map a working-day forecast onto a calendar-anchored burn-up cone. `today`
* anchors day 0; the cone emanates from (today, 0) and widens to the right.
* The historical "actual" polyline behind today awaits lifecycle events (#5).
*/
export function buildBurnUpData(f: Forecast, today: Date): BurnUpData | null {
if (f.scope === 0 || f.curve.length === 0) return null
const last = f.curve[f.curve.length - 1]
const horizonDays = Math.max(1, Math.ceil(last.p90Day * 1.05))
const band: BurnUpBandPoint[] = [
{ fraction: 0, loDay: 0, midDay: 0, hiDay: 0 },
...f.curve.map((p) => ({ fraction: p.fraction, loDay: p.p10Day, midDay: p.p50Day, hiDay: p.p90Day })),
]
return {
scope: f.scope,
horizonDays,
band,
p80Day: f.p80Day,
p80Label: formatShort(addWorkingDays(today, f.p80Day)),
rangeLabel: formatRange(addWorkingDays(today, f.p50Day), addWorkingDays(today, last.p90Day)),
startLabel: formatShort(today),
endLabel: formatShort(addWorkingDays(today, horizonDays)),
}
}

View File

@@ -1,12 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
export type BacklogState = export type BacklogState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'unconfigured' } | { status: 'unconfigured' }
| { status: 'error'; message: string } | { status: 'error'; message: string }
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] } | {
status: 'ready'
issues: GiteaIssue[]
milestones: GiteaMilestone[]
deps: DependencyEdge[]
timelines: Record<number, LifecycleEvent[]>
}
/** /**
* Reconcile the managed repo once on mount, through the main-process bridge. * Reconcile the managed repo once on mount, through the main-process bridge.
@@ -24,7 +30,13 @@ export function useBacklog(): BacklogState {
if (!alive) return if (!alive) return
setState( setState(
r.configured r.configured
? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps } ? {
status: 'ready',
issues: r.issues,
milestones: r.milestones,
deps: r.deps,
timelines: r.timelines,
}
: { status: 'unconfigured' }, : { status: 'unconfigured' },
) )
}) })

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
import {
CALIBRATION_BUCKET_FLOOR,
calibrationSamples,
type CalibrationSample,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration-v0.js'
function sample(over: Partial<CalibrationSample> = {}): CalibrationSample {
return { issue: 1, estimateDays: 2, actualWorkingDays: 2, bucket: 2, person: null, ...over }
}
describe('fitCalibration', () => {
it('is cold-start below the threshold and reports the honest n', () => {
const m = fitCalibration([sample(), sample({ actualWorkingDays: 4 })])
expect(m.n).toBe(2)
expect(m.coldStart).toBe(true)
})
it('flips off cold-start at the threshold', () => {
const many = Array.from({ length: COLD_START_THRESHOLD }, (_, i) =>
sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }),
)
const m = fitCalibration(many)
expect(m.n).toBe(COLD_START_THRESHOLD)
expect(m.coldStart).toBe(false)
})
it('recovers the global median ratio (mu = mean log-ratio)', () => {
// every actual is exactly 2x its estimate → mu = ln 2
const m = fitCalibration(Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 4 })))
expect(m.global.mu).toBeCloseTo(Math.log(2), 6)
})
it('fits a bucket only once it clears the floor', () => {
const twos = Array.from({ length: CALIBRATION_BUCKET_FLOOR }, (_, i) =>
sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }),
)
const oneThin = [sample({ issue: 99, estimateDays: 5, actualWorkingDays: 9, bucket: 5 })]
const m = fitCalibration([...twos, ...oneThin])
expect(m.byBucket[2]?.n).toBe(CALIBRATION_BUCKET_FLOOR)
expect(m.byBucket[5]).toBeUndefined() // only 1 sample, below floor
})
it('drops non-positive estimates/actuals', () => {
const m = fitCalibration([sample({ actualWorkingDays: 0 }), sample({ estimateDays: 0 }), sample()])
expect(m.n).toBe(1)
})
it('derives a per-person bias relative to global', () => {
// one person consistently runs longer than the mean
const base = Array.from({ length: 20 }, (_, i) => sample({ issue: i, actualWorkingDays: 2, person: 'ak' }))
const slow = Array.from({ length: 3 }, (_, i) => sample({ issue: 100 + i, actualWorkingDays: 6, person: 'sm' }))
const m = fitCalibration([...base, ...slow])
expect(m.byPerson['sm'].biasMu).toBeGreaterThan(0)
expect(m.byPerson['ak'].biasMu).toBeLessThan(0)
})
})
describe('toDurationModel', () => {
it('projects the fit down to the params forecast needs', () => {
const m = fitCalibration(
Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 })),
)
const dm = toDurationModel(m)
expect(dm.coldStart).toBe(false)
expect(dm.byBucket[2].mu).toBeCloseTo(m.byBucket[2].mu, 6)
expect(dm.global.mu).toBeCloseTo(m.global.mu, 6)
})
})
describe('calibrationSamples', () => {
const asOf = new Date('2026-02-01T00:00:00Z')
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1,
title: '#1',
body: '',
state: 'closed',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: null,
assignees: [],
createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-12T09:00:00Z',
closedAt: '2026-01-12T09:00:00Z',
url: '',
...over,
}
}
const events = (i: number): Record<number, LifecycleEvent[]> => ({
[i]: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }],
})
it('samples closed, estimated issues with a resolvable actual', () => {
const i = issue({ number: 7, labels: ['est/2d'], assignee: 'sm', closedAt: '2026-01-12T09:00:00Z' })
const [s] = calibrationSamples([i], events(7), asOf)
expect(s.issue).toBe(7)
expect(s.estimateDays).toBe(2)
expect(s.bucket).toBe(2)
expect(s.person).toBe('sm')
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
expect(s.actualWorkingDays).toBe(3)
})
it('skips open issues and closed ones without an estimate', () => {
const open = issue({ number: 8, state: 'open', labels: ['est/2d'], closedAt: null })
const noEst = issue({ number: 9, labels: [] })
expect(calibrationSamples([open, noEst], { ...events(8), ...events(9) }, asOf)).toEqual([])
})
})

View File

@@ -0,0 +1,127 @@
/**
* Calibration, v0 — fit the team's own estimate-vs-actual history so the
* forecast stops guessing (D3). The "actual" is the working time lifecycle
* inference derives from git events (#5), never manual tracking. Fit a
* lognormal on log(actual / estimate) globally and per estimate bucket; until
* the sample clears the cold-start threshold, the forecast keeps using the
* code-resident priors and this model just reports progress toward it.
*/
import { type DurationModel, type LognormalPrior, nearestBucket } from '../forecast/forecast-v0.js'
import { inferLifecycle, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
/** Global sample size at which the fit takes over from the cold-start priors. */
export const COLD_START_THRESHOLD = 20
/** Minimum per-bucket sample before that bucket earns its own fit. */
export const CALIBRATION_BUCKET_FLOOR = 3
/** Fallback spread when a group is too small to estimate one. */
const DEFAULT_SIGMA = 0.4
/** One closed issue's estimate vs its inferred actual. */
export interface CalibrationSample {
issue: number
estimateDays: number
actualWorkingDays: number
bucket: number
person: string | null
}
export interface BucketFit extends LognormalPrior {
n: number
}
export interface PersonBias {
/** Additive to global mu (log space). */
biasMu: number
n: number
}
export interface CalibrationModel {
/** Closed issues with an estimate + a resolvable actual. */
n: number
/** true while n < COLD_START_THRESHOLD — forecast keeps the code priors. */
coldStart: boolean
global: LognormalPrior
byBucket: Record<number, BucketFit>
byPerson: Record<string, PersonBias>
}
function mean(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0) / xs.length
}
/** Sample standard deviation; falls back to DEFAULT_SIGMA below 2 points. */
function stddev(xs: number[], mu: number): number {
if (xs.length < 2) return DEFAULT_SIGMA
const variance = xs.reduce((a, x) => a + (x - mu) ** 2, 0) / (xs.length - 1)
return Math.sqrt(variance) || DEFAULT_SIGMA
}
/** Fit a calibration model from estimate-vs-actual samples. Pure. */
export function fitCalibration(samples: CalibrationSample[]): CalibrationModel {
const usable = samples.filter((s) => s.estimateDays > 0 && s.actualWorkingDays > 0)
const n = usable.length
const coldStart = n < COLD_START_THRESHOLD
const logRatios = usable.map((s) => Math.log(s.actualWorkingDays / s.estimateDays))
const globalMu = n ? mean(logRatios) : 0
const global: LognormalPrior = { mu: globalMu, sigma: n ? stddev(logRatios, globalMu) : DEFAULT_SIGMA }
const byBucket: Record<number, BucketFit> = {}
const byPerson: Record<string, PersonBias> = {}
const groups = new Map<number, number[]>()
const people = new Map<string, number[]>()
for (const s of usable) {
const lr = Math.log(s.actualWorkingDays / s.estimateDays)
;(groups.get(s.bucket) ?? groups.set(s.bucket, []).get(s.bucket)!).push(lr)
if (s.person) (people.get(s.person) ?? people.set(s.person, []).get(s.person)!).push(lr)
}
for (const [bucket, lrs] of groups) {
if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue
const mu = mean(lrs)
byBucket[bucket] = { mu, sigma: stddev(lrs, mu), n: lrs.length }
}
for (const [person, lrs] of people) {
if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue
byPerson[person] = { biasMu: mean(lrs) - globalMu, n: lrs.length }
}
return { n, coldStart, global, byBucket, byPerson }
}
/** The subset of a model `forecast` consumes. */
export function toDurationModel(model: CalibrationModel): DurationModel {
const byBucket: Record<number, LognormalPrior> = {}
for (const [bucket, fit] of Object.entries(model.byBucket)) {
byBucket[Number(bucket)] = { mu: fit.mu, sigma: fit.sigma }
}
return { coldStart: model.coldStart, global: model.global, byBucket }
}
/**
* Extract calibration samples from the closed backlog: each closed issue that
* carries an estimate and yields an inferred actual working duration.
*/
export function calibrationSamples(
issues: GiteaIssue[],
timelines: Record<number, LifecycleEvent[]>,
asOf: Date,
): CalibrationSample[] {
const out: CalibrationSample[] = []
for (const issue of issues) {
if (issue.state !== 'closed') continue
const estimateDays = issue.facts.estimateDays
if (estimateDays == null) continue
const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf)
if (inf.actualWorkingDays == null || inf.actualWorkingDays <= 0) continue
out.push({
issue: issue.number,
estimateDays,
actualWorkingDays: inf.actualWorkingDays,
bucket: nearestBucket(estimateDays),
person: issue.assignee,
})
}
return out
}

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
import { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast-v0.js'
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
}
describe('priorForEstimate', () => {
it('returns the exact prior for a bucket day count', () => {
expect(priorForEstimate(3)).toBe(COLD_START_PRIORS[3])
expect(priorForEstimate(8)).toBe(COLD_START_PRIORS[8])
})
it('snaps a non-bucket estimate to the nearest bucket', () => {
expect(priorForEstimate(4)).toBe(COLD_START_PRIORS[3]) // tie → smaller bucket
expect(priorForEstimate(6)).toBe(COLD_START_PRIORS[5])
expect(priorForEstimate(100)).toBe(COLD_START_PRIORS[8])
})
it('every prior is pessimistic (median actual runs longer than the estimate)', () => {
for (const p of Object.values(COLD_START_PRIORS)) expect(p.mu).toBeGreaterThan(0)
})
})
describe('forecast', () => {
const scope = [
issue(1, { estimateDays: 2 }),
issue(2, { estimateDays: 3 }),
issue(3, { estimateDays: 1 }),
]
it('is reproducible: same seed → identical result', () => {
const a = forecast(scope, [], { trials: 500, seed: 42 })
const b = forecast(scope, [], { trials: 500, seed: 42 })
expect(a).toEqual(b)
})
it('percentiles are ordered p50 <= p80 <= p95', () => {
const f = forecast(scope, [], { trials: 3000 })
expect(f.p50Day).toBeLessThanOrEqual(f.p80Day)
expect(f.p80Day).toBeLessThanOrEqual(f.p95Day)
})
it('the burn-up curve is monotonic in both fraction and day', () => {
const f = forecast(scope, [], { trials: 3000 })
expect(f.curve).toHaveLength(3)
for (let i = 1; i < f.curve.length; i++) {
expect(f.curve[i].fraction).toBeGreaterThan(f.curve[i - 1].fraction)
expect(f.curve[i].p50Day).toBeGreaterThan(f.curve[i - 1].p50Day)
}
expect(f.curve.at(-1)!.fraction).toBeCloseTo(1)
})
it('lo/mid/hi are ordered within each cone point', () => {
const f = forecast(scope, [], { trials: 3000 })
for (const pt of f.curve) {
expect(pt.p10Day).toBeLessThanOrEqual(pt.p50Day)
expect(pt.p50Day).toBeLessThanOrEqual(pt.p90Day)
}
})
it('the median landing runs longer than the raw estimate sum (pessimism)', () => {
const rawSum = 2 + 3 + 1
const f = forecast(scope, [], { trials: 4000 })
expect(f.p50Day).toBeGreaterThan(rawSum)
})
it('reports scope and cold-start honestly', () => {
const f = forecast(scope, [], { trials: 100 })
expect(f.scope).toBe(3)
expect(f.coldStart).toBe(true)
})
it('a dependency cycle yields an empty, zeroed forecast', () => {
const edges: DependencyEdge[] = [
{ issue: 1, dependsOn: 2 },
{ issue: 2, dependsOn: 1 },
]
const f = forecast([issue(1), issue(2)], edges, { trials: 100 })
expect(f.scope).toBe(0)
expect(f.curve).toEqual([])
expect(f.p80Day).toBe(0)
})
it('an empty scope forecasts nothing', () => {
const f = forecast([], [], { trials: 100 })
expect(f.scope).toBe(0)
expect(f.curve).toEqual([])
})
it('unestimated issues fall back to the default-estimate prior', () => {
const f = forecast([issue(1, { estimateDays: null }), issue(2, { estimateDays: null })], [], {
trials: 500,
})
expect(f.scope).toBe(2)
expect(f.p50Day).toBeGreaterThan(0)
})
it('a cold-start model changes nothing — the code priors still drive it', () => {
const priors = forecast(scope, [], { trials: 1000, seed: 7 })
const cold = forecast(scope, [], {
trials: 1000,
seed: 7,
model: { coldStart: true, global: { mu: 5, sigma: 0.1 }, byBucket: {} },
})
expect(cold.coldStart).toBe(true)
expect(cold.p50Day).toBeCloseTo(priors.p50Day, 6)
})
it('a fitted model drives the sim once past cold-start', () => {
// an optimistic fit (mu < 0, tight sigma) should land the scope sooner than the pessimistic priors
const priors = forecast(scope, [], { trials: 2000, seed: 7 })
const fitted = forecast(scope, [], {
trials: 2000,
seed: 7,
model: { coldStart: false, global: { mu: -0.2, sigma: 0.1 }, byBucket: {} },
})
expect(fitted.coldStart).toBe(false)
expect(fitted.p50Day).toBeLessThan(priors.p50Day)
})
})

View File

@@ -0,0 +1,193 @@
/**
* Monte Carlo forecast, v0. The LLM never does this — it's plain, seeded,
* reproducible code (evidence-based scheduling). It samples an actual duration
* per open issue from a lognormal prior, walks the scheduler's deterministic
* order on a single serial worker, and reads percentiles off the resulting
* completion-day distribution.
*
* v0 simplifications (each a later slice, not a hack):
* - single serial worker; per-person capacity + parallelism is #8.
* - cold-start priors only; the team's empirical calibration fit lands at
* n >= 20 closed-with-estimate issues (#5 supplies the actuals).
* - forecast covers remaining (open) scope from today forward; the historical
* burn-up "actual" polyline needs lifecycle event dates (#5).
*/
import {
type DependencyEdge,
schedule,
type SchedulableIssue,
} from '../scheduler/scheduler-v0.js'
export interface LognormalPrior {
/** Median log-ratio: sampled median duration = estimate * e^mu. */
mu: number
/** Spread of log(actual / estimate). */
sigma: number
}
/**
* Code-resident cold-start priors (D3). Lognormal on log(actual / estimate):
* sampled actual = estimateDays * exp(N(mu, sigma)). mu > 0 encodes the honest
* fact that actuals run long; sigma shrinks as tickets grow (a snag doubles a
* 1-day task but barely dents an 8-day one). The team's fitted model replaces
* these at n >= 20 (see calibration model in pm-state.md).
*/
export const COLD_START_PRIORS: Record<number, LognormalPrior> = {
1: { mu: 0.25, sigma: 0.55 },
2: { mu: 0.22, sigma: 0.48 },
3: { mu: 0.2, sigma: 0.44 },
5: { mu: 0.18, sigma: 0.4 },
8: { mu: 0.16, sigma: 0.36 },
}
export const PRIOR_BUCKETS = [1, 2, 3, 5, 8]
/** Nearest estimate bucket (ties resolve to the smaller bucket). */
export function nearestBucket(days: number): number {
let best = PRIOR_BUCKETS[0]
for (const b of PRIOR_BUCKETS) {
if (Math.abs(b - days) < Math.abs(best - days)) best = b
}
return best
}
/** The cold-start prior for the bucket nearest to `days`. */
export function priorForEstimate(days: number): LognormalPrior {
return COLD_START_PRIORS[nearestBucket(days)]
}
/** The lognormal parameters `forecast` needs, per estimate bucket. */
export interface DurationModel {
coldStart: boolean
/** Fallback params (used when a bucket lacks its own fit). */
global: LognormalPrior
/** Per-bucket fitted params; missing buckets fall back to `global`. */
byBucket: Record<number, LognormalPrior>
}
export interface ForecastOptions {
/** Simulation trials. More = smoother tails, linear cost. */
trials?: number
/** PRNG seed. Fixed by default so a forecast is reproducible. */
seed?: number
/**
* Fitted duration model. When present and not cold-start, its params drive
* the sim; otherwise the code-resident cold-start priors do.
*/
model?: DurationModel
}
/** Resolve the lognormal params for an estimate, preferring a fitted model. */
export function durationParams(days: number, model?: DurationModel): LognormalPrior {
if (model && !model.coldStart) {
return model.byBucket[nearestBucket(days)] ?? model.global
}
return priorForEstimate(days)
}
export interface BurnUpPoint {
/** Cumulative fraction of remaining scope complete (0 < f <= 1]. */
fraction: number
/** Working-day offset from today at the p10 / p50 / p90 of reaching it. */
p10Day: number
p50Day: number
p90Day: number
}
export interface Forecast {
/** Open issues in scope (== scheduled count). */
scope: number
trials: number
/** true while code priors drive the sim; false once the empirical fit is in (v1). */
coldStart: boolean
/** Working-day offsets from today for the whole scope landing. */
p50Day: number
p80Day: number
p95Day: number
/** Burn-up cone, one point per scheduled issue, fraction ascending. */
curve: BurnUpPoint[]
}
const DEFAULT_TRIALS = 2000
const DEFAULT_SEED = 0x9e3779b9
/** mulberry32 — small, fast, seedable PRNG (no reliance on Math.random). */
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** BoxMuller standard normal from a uniform PRNG. */
function standardNormal(rng: () => number): number {
let u1 = rng()
const u2 = rng()
if (u1 < 1e-12) u1 = 1e-12
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
}
function percentile(sortedAsc: number[], q: number): number {
const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(q * (sortedAsc.length - 1))))
return sortedAsc[idx]
}
/**
* Monte Carlo over the deterministic schedule order. Order is fixed (it's
* derived from estimate labels + dependencies, not sampled), so only durations
* vary across trials — the cone stretches, it never reorders.
*/
export function forecast(
issues: SchedulableIssue[],
edges: DependencyEdge[],
options: ForecastOptions = {},
): Forecast {
const trials = options.trials ?? DEFAULT_TRIALS
const seed = options.seed ?? DEFAULT_SEED
const coldStart = options.model ? options.model.coldStart : true
const order = schedule(issues, edges).items // empty when a dependency cycle exists
const n = order.length
if (n === 0) {
return { scope: 0, trials, coldStart, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] }
}
const priors = order.map((it) => durationParams(it.durationDays, options.model))
const rng = mulberry32(seed)
// endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
for (let t = 0; t < trials; t++) {
let cursor = 0
for (let k = 0; k < n; k++) {
const p = priors[k]
const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng))
cursor += sampled
endByRank[k][t] = cursor
}
}
const curve: BurnUpPoint[] = endByRank.map((row, k) => {
const sorted = [...row].sort((a, b) => a - b)
return {
fraction: (k + 1) / n,
p10Day: percentile(sorted, 0.1),
p50Day: percentile(sorted, 0.5),
p90Day: percentile(sorted, 0.9),
}
})
const total = [...endByRank[n - 1]].sort((a, b) => a - b)
return {
scope: n,
trials,
coldStart,
p50Day: percentile(total, 0.5),
p80Day: percentile(total, 0.8),
p95Day: percentile(total, 0.95),
curve,
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { createGiteaClient, normalizeIssue } from './client.js' import { createGiteaClient, normalizeIssue, normalizeTimeline } from './client.js'
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js' import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
const CONFIG: GiteaConfig = { const CONFIG: GiteaConfig = {
@@ -44,6 +44,31 @@ function stubFetch(body: unknown, status = 200): {
return { fetch, calls } return { fetch, calls }
} }
describe('normalizeTimeline', () => {
it('maps known gitea event types to lifecycle signals and drops the rest', () => {
const events = normalizeTimeline([
{ type: 'label', created_at: '2026-01-06T09:00:00Z' },
{ type: 'milestone', created_at: '2026-01-06T09:05:00Z' },
{ type: 'commit_ref', created_at: '2026-01-07T12:00:00Z' },
{ type: 'pull_ref', created_at: '2026-01-08T12:00:00Z' },
{ type: 'comment', created_at: '2026-01-08T13:00:00Z' }, // dropped
{ type: 'add_dependency', created_at: '2026-01-08T14:00:00Z' }, // dropped
{ type: 'close', created_at: '2026-01-12T09:00:00Z' },
])
expect(events).toEqual([
{ type: 'triage', at: '2026-01-06T09:00:00Z' },
{ type: 'triage', at: '2026-01-06T09:05:00Z' },
{ type: 'commit', at: '2026-01-07T12:00:00Z' },
{ type: 'pull', at: '2026-01-08T12:00:00Z' },
{ type: 'close', at: '2026-01-12T09:00:00Z' },
])
})
it('skips events missing a timestamp', () => {
expect(normalizeTimeline([{ type: 'commit_ref', created_at: '' }])).toEqual([])
})
})
describe('createGiteaClient.getIssue', () => { describe('createGiteaClient.getIssue', () => {
it('returns a normalized, typed issue from canned JSON', async () => { it('returns a normalized, typed issue from canned JSON', async () => {
const { fetch } = stubFetch(RAW_ISSUE) const { fetch } = stubFetch(RAW_ISSUE)

View File

@@ -6,6 +6,7 @@
* get the full set. * get the full set.
*/ */
import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js'
import { extractLabelFacts } from '../labels/label-schema.js' import { extractLabelFacts } from '../labels/label-schema.js'
import { import {
GiteaApiError, GiteaApiError,
@@ -54,6 +55,33 @@ interface RawMilestoneFull {
closed_issues: number closed_issues: number
} }
/** The subset of a gitea timeline comment we read. */
interface RawTimelineComment {
type: string
created_at: string
}
/** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */
const TIMELINE_TYPE_MAP: Record<string, LifecycleEventType> = {
label: 'triage',
milestone: 'triage',
assignees: 'triage',
commit_ref: 'commit',
pull_ref: 'pull',
close: 'close',
reopen: 'reopen',
}
/** Map a raw gitea timeline to normalized lifecycle events. Pure. */
export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] {
const out: LifecycleEvent[] = []
for (const c of raw) {
const type = TIMELINE_TYPE_MAP[c.type]
if (type && c.created_at) out.push({ type, at: c.created_at })
}
return out
}
export interface ListIssuesOptions { export interface ListIssuesOptions {
/** @default 'all' */ /** @default 'all' */
state?: 'open' | 'closed' | 'all' state?: 'open' | 'closed' | 'all'
@@ -68,6 +96,8 @@ export interface GiteaClient {
listMilestones(): Promise<GiteaMilestone[]> listMilestones(): Promise<GiteaMilestone[]>
/** The issue indices this issue depends on (its blockers). */ /** The issue indices this issue depends on (its blockers). */
getIssueDependencies(index: number): Promise<number[]> getIssueDependencies(index: number): Promise<number[]>
/** Normalized lifecycle events for one issue (all pages of its timeline). */
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
} }
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */ /** Map raw gitea issue JSON to the normalized domain shape. Pure. */
@@ -163,5 +193,12 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[] const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
return raw.map((d) => d.number) return raw.map((d) => d.number)
}, },
async getIssueTimeline(index) {
const raw = await requestAll<RawTimelineComment>(
(page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`,
)
return normalizeTimeline(raw)
},
} }
} }

View File

@@ -9,7 +9,7 @@ export {
} from './labels/label-schema.js' } from './labels/label-schema.js'
export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js' export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js'
export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js' export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js' export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
export { GiteaApiError } from './gitea/types.js' export { GiteaApiError } from './gitea/types.js'
export type { export type {
@@ -22,8 +22,19 @@ export type {
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js' export {
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js' inferColumnV0,
inferLifecycle,
LIFECYCLE_COLUMNS,
workingDaysBetween,
} from './lifecycle/lifecycle-v0.js'
export type {
LifecycleColumn,
LifecycleEvent,
LifecycleEventType,
LifecycleInference,
LifecycleStages,
} from './lifecycle/lifecycle-v0.js'
export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js' export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
export type { export type {
@@ -33,3 +44,33 @@ export type {
ScheduledItem, ScheduledItem,
SchedulePlan, SchedulePlan,
} from './scheduler/scheduler-v0.js' } from './scheduler/scheduler-v0.js'
export {
COLD_START_PRIORS,
durationParams,
forecast,
nearestBucket,
PRIOR_BUCKETS,
priorForEstimate,
} from './forecast/forecast-v0.js'
export type {
BurnUpPoint,
DurationModel,
Forecast,
ForecastOptions,
LognormalPrior,
} from './forecast/forecast-v0.js'
export {
CALIBRATION_BUCKET_FLOOR,
calibrationSamples,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration/calibration-v0.js'
export type {
BucketFit,
CalibrationModel,
CalibrationSample,
PersonBias,
} from './calibration/calibration-v0.js'

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { inferColumnV0 } from './lifecycle-v0.js' import {
inferColumnV0,
inferLifecycle,
type LifecycleEvent,
workingDaysBetween,
} from './lifecycle-v0.js'
const base = { state: 'open' as const, labels: [] as string[], milestone: null } const base = { state: 'open' as const, labels: [] as string[], milestone: null }
@@ -27,3 +32,93 @@ describe('inferColumnV0', () => {
expect(['steeping', 'review']).not.toContain(col) expect(['steeping', 'review']).not.toContain(col)
}) })
}) })
describe('workingDaysBetween', () => {
it('counts weekdays in [start, end), excluding weekends', () => {
// Mon 2026-01-05 → Mon 2026-01-12 spans a full week = 5 working days
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-12'))).toBe(5)
})
it('is 0 for same day or reversed', () => {
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-05'))).toBe(0)
expect(workingDaysBetween(new Date('2026-01-12'), new Date('2026-01-05'))).toBe(0)
})
it('skips a weekend inside the interval', () => {
// Fri 2026-01-09 → Mon 2026-01-12: only Fri counts (Sat/Sun excluded) = 1
expect(workingDaysBetween(new Date('2026-01-09'), new Date('2026-01-12'))).toBe(1)
})
})
describe('inferLifecycle', () => {
const openBase = { ...base, createdAt: '2026-01-05T09:00:00Z', closedAt: null }
const asOf = new Date('2026-01-14T09:00:00Z')
const ev = (type: LifecycleEvent['type'], at: string): LifecycleEvent => ({ type, at })
it('bare open issue with no events is diagnosis', () => {
const inf = inferLifecycle(openBase, [], asOf)
expect(inf.column).toBe('diagnosis')
expect(inf.stages.opened).toBe(openBase.createdAt)
expect(inf.actualWorkingDays).toBeNull()
expect(inf.steepingDays).toBeNull()
})
it('a triage event (or a current label) moves it to triage', () => {
expect(inferLifecycle(openBase, [ev('triage', '2026-01-06T09:00:00Z')], asOf).column).toBe('triage')
expect(inferLifecycle({ ...openBase, labels: ['p/1'] }, [], asOf).column).toBe('triage')
})
it('a commit ref moves an open issue to steeping and counts its working age', () => {
const inf = inferLifecycle(
{ ...openBase, labels: ['est/2d'] },
[ev('triage', '2026-01-05T10:00:00Z'), ev('commit', '2026-01-07T12:00:00Z')],
asOf,
)
expect(inf.column).toBe('steeping')
expect(inf.stages.steeping).toBe('2026-01-07T12:00:00Z')
// Wed 2026-01-07 → Wed 2026-01-14 = 5 working days
expect(inf.steepingDays).toBe(5)
})
it('a pull ref outranks a commit ref → review', () => {
const inf = inferLifecycle(
openBase,
[ev('commit', '2026-01-07T12:00:00Z'), ev('pull', '2026-01-08T12:00:00Z')],
asOf,
)
expect(inf.column).toBe('review')
expect(inf.steepingDays).toBeNull() // only steeping issues carry an age
})
it('closed → done, with actual working time from work-start to close', () => {
const inf = inferLifecycle(
{ ...openBase, state: 'closed', closedAt: '2026-01-12T09:00:00Z' },
[ev('commit', '2026-01-07T09:00:00Z'), ev('pull', '2026-01-08T09:00:00Z'), ev('close', '2026-01-12T09:00:00Z')],
asOf,
)
expect(inf.column).toBe('done')
expect(inf.stages.done).toBe('2026-01-12T09:00:00Z')
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
expect(inf.actualWorkingDays).toBe(3)
})
it('takes the earliest event of each kind for stage timestamps', () => {
const inf = inferLifecycle(
openBase,
[ev('commit', '2026-01-09T09:00:00Z'), ev('commit', '2026-01-07T09:00:00Z')],
asOf,
)
expect(inf.stages.steeping).toBe('2026-01-07T09:00:00Z')
})
it('falls back to opened when a closed issue has no work-start signal', () => {
const inf = inferLifecycle(
{ ...openBase, state: 'closed', closedAt: '2026-01-08T09:00:00Z' },
[ev('close', '2026-01-08T09:00:00Z')],
asOf,
)
// Mon 2026-01-05 → Thu 2026-01-08 = Mon,Tue,Wed = 3 working days
expect(inf.actualWorkingDays).toBe(3)
})
})

View File

@@ -1,12 +1,10 @@
/** /**
* Lifecycle inference, v0 — the coarse column an issue sits in, derived from * Lifecycle inference.
* *only* what a single issues-list read gives us (state, labels, milestone).
* *
* The real five-column inference (P1-5) needs the issue timeline: first * `inferColumnV0` places the coarse three columns from a single issues-list
* branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy. * read (state/labels/milestone). `inferLifecycle` (#5) adds the issue timeline:
* Until that lands, v0 can only place three columns honestly; `steeping` and * first commit ref → Steeping, first PR ref → In review, close → Done — and
* `review` stay empty rather than guess. Board renders all five columns and * derives the estimate-vs-actual working time that feeds calibration (D3).
* fills the middle two once the event stream is available.
*/ */
import type { GiteaIssue } from '../gitea/types.js' import type { GiteaIssue } from '../gitea/types.js'
@@ -23,10 +21,109 @@ export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [
/** /**
* Closed → done. Open with any human intent applied (a label or a milestone) * Closed → done. Open with any human intent applied (a label or a milestone)
* → triage. Open and bare → diagnosis. Never returns steeping/review in v0. * → triage. Open and bare → diagnosis. Never returns steeping/review — that's
* `inferLifecycle`, which reads the event stream.
*/ */
export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn { export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
if (issue.state === 'closed') return 'done' if (issue.state === 'closed') return 'done'
const hasIntent = issue.labels.length > 0 || issue.milestone !== null const hasIntent = issue.labels.length > 0 || issue.milestone !== null
return hasIntent ? 'triage' : 'diagnosis' return hasIntent ? 'triage' : 'diagnosis'
} }
/** A timeline signal, normalized from gitea's raw event stream. */
export type LifecycleEventType = 'triage' | 'commit' | 'pull' | 'close' | 'reopen'
export interface LifecycleEvent {
type: LifecycleEventType
/** ISO timestamp. */
at: string
}
/** Timestamps for the stages an issue has reached (absent = not yet reached). */
export interface LifecycleStages {
opened: string
/** First label / milestone / assignment. */
triaged?: string
/** First commit referencing the issue — work began. */
steeping?: string
/** First PR referencing the issue — in review. */
review?: string
/** Closed. */
done?: string
}
export interface LifecycleInference {
column: LifecycleColumn
stages: LifecycleStages
/**
* Working days from work-start (steeping → triaged → opened, first available)
* to done. Only for closed issues — this is the "actual" calibration learns
* from. null while open.
*/
actualWorkingDays: number | null
/** Working days the issue has been steeping (first commit → asOf); null unless currently steeping. */
steepingDays: number | null
}
const DAY_MS = 86_400_000
/**
* Whole working days (MonFri) in the half-open interval [start, end). Same day
* or reversed → 0. Day-granular by design — estimates are in days.
*/
export function workingDaysBetween(start: Date, end: Date): number {
const s = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())
const e = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate())
if (e <= s) return 0
let count = 0
for (let t = s; t < e; t += DAY_MS) {
const dow = new Date(t).getUTCDay()
if (dow !== 0 && dow !== 6) count += 1
}
return count
}
/**
* The five-column inference. Column reflects the furthest stage still in play:
* closed → done; else an open PR ref → review; else a commit ref → steeping;
* else any triage signal (or a current label/milestone) → triage; else
* diagnosis. Stage timestamps are the earliest event of each kind.
*/
export function inferLifecycle(
issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone' | 'createdAt' | 'closedAt'>,
events: LifecycleEvent[],
asOf: Date,
): LifecycleInference {
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at))
const firstOf = (type: LifecycleEventType) => sorted.find((e) => e.type === type)?.at
const stages: LifecycleStages = { opened: issue.createdAt }
const triagedAt = firstOf('triage')
const steepingAt = firstOf('commit')
const reviewAt = firstOf('pull')
if (triagedAt) stages.triaged = triagedAt
if (steepingAt) stages.steeping = steepingAt
if (reviewAt) stages.review = reviewAt
const doneAt = issue.state === 'closed' ? (issue.closedAt ?? firstOf('close')) : undefined
if (doneAt) stages.done = doneAt
let column: LifecycleColumn
if (issue.state === 'closed') column = 'done'
else if (reviewAt) column = 'review'
else if (steepingAt) column = 'steeping'
else if (triagedAt || issue.labels.length > 0 || issue.milestone !== null) column = 'triage'
else column = 'diagnosis'
let actualWorkingDays: number | null = null
if (stages.done) {
const start = stages.steeping ?? stages.triaged ?? stages.opened
actualWorkingDays = workingDaysBetween(new Date(start), new Date(stages.done))
}
let steepingDays: number | null = null
if (column === 'steeping' && stages.steeping) {
steepingDays = workingDaysBetween(new Date(stages.steeping), asOf)
}
return { column, stages, actualWorkingDays, steepingDays }
}