Compare commits

2 Commits

Author SHA1 Message Date
Croissant Le Doux
3873e652f2 Gantt bars use the capacity-aware lane layout (match the forecast)
ganttView ran schedule() — the single-serial-worker layout — while the Monte
Carlo forecast ran scheduleWithCapacity() over the real lanes. So the bars you
saw didn't match what was forecast (serial 1.0/day vs the team's actual lanes).

Switch ganttView to scheduleWithCapacity(open, deps, workers): startDay/endDay
now come from the same lane layout each forecast trial uses, and `who` shows the
lane an issue actually landed on (falling back to assignee, then blank). The
dogfood harness had the same split — updated it to match and to print the lane
per row.

Verified: typecheck clean, 14 fixture e2e green, live-onboarding e2e renders the
real capacity-aware Gantt with no uncaught errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:23:01 -04:00
Croissant Le Doux
63f0ea1735 Dogfood harness: run CommiTea's engine on its own backlog (#31)
scripts/dogfood-report.ts drives the real @commitea/core engine (schedule +
capacity-aware Monte Carlo forecast + per-milestone runway) against the live
christian/commitea backlog and prints the project report each screen derives.
Read-only; `yarn tsx scripts/dogfood-report.ts`. Adds tsx as a devDependency.

First run surfaced two real gaps the fixtures hid: every open issue was
unassigned (so capacity load-balanced work onto the slow half-time lane and the
standup plan-per-person was empty) and no milestone had a due date (so Runway
couldn't judge on-track/at-risk). Both were fixed as PM actions on the repo via
the write path — all open issues assigned, milestone due dates synthesized from
the forecast — so the app's own numbers are now honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:53:28 -04:00
4 changed files with 171 additions and 9 deletions

View File

@@ -34,6 +34,7 @@
"electron-vite": "^3.1.0", "electron-vite": "^3.1.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsx": "^4",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.1.0" "vite": "^6.1.0"
} }

View File

@@ -0,0 +1,142 @@
/**
* Dogfood (#31): run CommiTea's own deterministic engine against the live
* christian/commitea backlog and print the project report each screen derives —
* board, critical path, schedule, Monte Carlo forecast, per-milestone runway.
*
* This is the core brain (schedule + forecast + capacity), the exact code the
* desktop app runs. Read-only. Run: yarn tsx scripts/dogfood-report.ts
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
createGiteaClient,
forecast,
inferLifecycle,
parseCapacityConfig,
capacityPerWorkday,
scheduleWithCapacity,
type GiteaIssue,
type DependencyEdge,
type Worker,
} from '@commitea/core'
const here = dirname(fileURLToPath(import.meta.url))
function token(): string {
let dir = here
for (let i = 0; i < 6; i++) {
try {
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8'))
if (m) return m[1].trim()
} catch {
/* keep walking up */
}
dir = dirname(dir)
}
throw new Error('no GITEA_TOKEN in any .env.local up the tree')
}
/** working-day offset → calendar date (skip Sat/Sun), then a short label. */
function addWorkingDays(base: Date, n: number): Date {
const d = new Date(base)
let left = Math.ceil(n)
while (left > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) left--
}
return d
}
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
async function main() {
const client = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea', token: token() },
fetch,
)
const all = await client.listIssues({ state: 'all' })
const open = all.filter((i) => i.state === 'open')
const closed = all.filter((i) => i.state === 'closed')
// Real dependency edges (open issues only — the scheduler drops out-of-scope endpoints anyway).
const edges: DependencyEdge[] = []
for (const i of open) {
for (const dep of await client.getIssueDependencies(i.number)) edges.push({ issue: i.number, dependsOn: dep })
}
// Capacity lanes from the pm-state repo (a separate repo, exactly like the app's
// pmStateClient — NOT the managed repo).
const pmClient = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea-pm-state', token: token() },
fetch,
)
let workers: Worker[] = []
try {
const f = await pmClient.getFile('capacity/members.json')
if (f) {
const members = parseCapacityConfig(JSON.parse(Buffer.from(f.contentBase64, 'base64').toString('utf8')))
workers = members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
}
} catch {
/* no capacity → single serial worker */
}
const toSchedulable = (issues: GiteaIssue[]) =>
issues.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
const plan = scheduleWithCapacity(toSchedulable(open), edges, workers)
const today = new Date()
const f = forecast(toSchedulable(open), edges, { workers })
const bar = '─'.repeat(64)
console.log(`\n${bar}\n CommiTea, on itself — ${fmt(today)} ${today.getFullYear()}\n${bar}`)
console.log(` Board: ${open.length} open · ${closed.length} done`)
console.log(` Team: ${workers.length ? workers.map((w) => `${w.person} (${w.speed.toFixed(2)}/day)`).join(', ') : 'single serial worker'}`)
const unassigned = open.filter((i) => !i.assignee).length
if (unassigned) console.log(`${unassigned}/${open.length} open issues have no assignee`)
console.log(`\n Schedule (dependency + priority order, ${workers.length || 1} lane${workers.length === 1 ? '' : 's'}):`)
for (const it of plan.items) {
const crit = it.critical ? ' ★crit' : ''
const lane = it.worker ? ` [${it.worker}]` : ''
const blocks = it.blocks.length ? ` blocks ${it.blocks.map((b) => '#' + b).join(',')}` : ''
console.log(
` #${String(it.number).padEnd(3)} ${fmt(addWorkingDays(today, it.startDay))}${fmt(addWorkingDays(today, it.endDay))}${crit.padEnd(7)}${lane} ${it.title.slice(0, 38)}${blocks}`,
)
}
if (plan.cycle) console.log(` ⚠ dependency cycle: ${plan.cycle.join(' → ')}`)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
console.log(`\n Forecast (Monte Carlo, ${f.trials} trials, ${f.coldStart ? 'cold-start priors' : 'fitted'}):`)
console.log(` whole backlog lands p50 ${fmt(addWorkingDays(today, f.p50Day))} · p80 ${fmt(addWorkingDays(today, f.p80Day))} · p90 ${fmt(addWorkingDays(today, p90))}`)
// Per-milestone runway.
const ms = await client.listMilestones()
console.log(`\n Runway (per open milestone):`)
for (const m of ms.filter((x) => x.state === 'open')) {
const scope = open.filter((i) => i.milestone?.id === m.id)
if (!scope.length) continue
const mf = forecast(toSchedulable(scope), edges, { workers })
const mp90 = mf.curve.length ? mf.curve[mf.curve.length - 1].p90Day : mf.p95Day
const due = m.dueOn ? fmt(new Date(m.dueOn)) : 'no due date'
console.log(
` ${m.title.padEnd(28)} ${scope.length} open · p50 ${fmt(addWorkingDays(today, mf.p50Day))}p90 ${fmt(addWorkingDays(today, mp90))} (due: ${due})`,
)
}
console.log(`\n${bar}\n`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@@ -1,4 +1,4 @@
import { forecast, inferLifecycle, schedule, workingDaysBetween } from '@commitea/core' import { forecast, inferLifecycle, scheduleWithCapacity, workingDaysBetween } from '@commitea/core'
import type { GanttData, GanttRow, GanttWeek } from '../../data/fixtures.js' import type { GanttData, GanttRow, GanttWeek } from '../../data/fixtures.js'
import { addWorkingDays, formatShort } from '../dates.js' import { addWorkingDays, formatShort } from '../dates.js'
@@ -26,14 +26,15 @@ function toSchedulable(issues: ProjectData['issues']) {
const roundUpToWeek = (n: number): number => Math.ceil(Math.max(n, 1) / 7) * 7 const roundUpToWeek = (n: number): number => Math.ceil(Math.max(n, 1) / 7) * 7
/** /**
* Real Gantt: the deterministic scheduler's serial layout over the open * Real Gantt: the capacity-aware scheduler's lane layout over the open backlog
* backlog (startDay/endDay/critical), plus a handful of recently-closed * (startDay/endDay/critical/worker) — the SAME layout the Monte Carlo forecast
* issues shown as already-done bars. No per-issue Monte Carlo exists at this * runs each trial on, so the bars you see match what's forecast. Plus a handful
* grain, so `p80` is a rough single-issue buffer (endDay padded by half its * of recently-closed issues shown as already-done bars. No per-issue Monte Carlo
* own duration) rather than a simulated percentile — flagged below. * exists at this grain, so `p80` is a rough single-issue buffer (endDay padded by
* half its own duration) rather than a simulated percentile — flagged below.
*/ */
export function ganttView(d: ProjectData): GanttData { export function ganttView(d: ProjectData): GanttData {
const plan = schedule(toSchedulable(d.issues), d.deps) const plan = scheduleWithCapacity(toSchedulable(d.issues), d.deps, d.workers)
const issueByNumber = new Map(d.issues.map((i) => [i.number, i])) const issueByNumber = new Map(d.issues.map((i) => [i.number, i]))
const scheduledRows: GanttRow[] = plan.items.map((item) => { const scheduledRows: GanttRow[] = plan.items.map((item) => {
@@ -44,7 +45,9 @@ export function ganttView(d: ProjectData): GanttData {
return { return {
id: item.number, id: item.number,
title: item.title, title: item.title,
who: initials(issue?.assignee ?? ''), // The lane it actually landed on (capacity-aware) — falls back to the
// assignee, then blank, so an unassigned issue shows the lane doing it.
who: initials(item.worker ?? issue?.assignee ?? ''),
start: item.startDay, start: item.startDay,
end: item.endDay, end: item.endDay,
crit: item.critical, crit: item.critical,

View File

@@ -261,6 +261,7 @@ __metadata:
react: "npm:^18.3.1" react: "npm:^18.3.1"
react-dom: "npm:^18.3.1" react-dom: "npm:^18.3.1"
tailwindcss: "npm:^3.4.17" tailwindcss: "npm:^3.4.17"
tsx: "npm:^4"
typescript: "npm:^5.7.3" typescript: "npm:^5.7.3"
vite: "npm:^6.1.0" vite: "npm:^6.1.0"
languageName: unknown languageName: unknown
@@ -2687,7 +2688,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"esbuild@npm:^0.27.0 || ^0.28.0": "esbuild@npm:^0.27.0 || ^0.28.0, esbuild@npm:~0.28.0":
version: 0.28.1 version: 0.28.1
resolution: "esbuild@npm:0.28.1" resolution: "esbuild@npm:0.28.1"
dependencies: dependencies:
@@ -5330,6 +5331,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tsx@npm:^4":
version: 4.23.0
resolution: "tsx@npm:4.23.0"
dependencies:
esbuild: "npm:~0.28.0"
fsevents: "npm:~2.3.3"
dependenciesMeta:
fsevents:
optional: true
bin:
tsx: dist/cli.mjs
checksum: 10c0/e4fade6bf8a4447424652da3a68f5ab7b927d1cbe5f697dba876c626c5fb7bf7663c4f71777992c9cbbdc8148c63ee1ddcf15536ff9d1863f9fbea25247ee0a9
languageName: node
linkType: hard
"type-fest@npm:^0.13.1": "type-fest@npm:^0.13.1":
version: 0.13.1 version: 0.13.1
resolution: "type-fest@npm:0.13.1" resolution: "type-fest@npm:0.13.1"