/** * Capacity model (#8). A member's throughput is `focusFactor × allocation` = * ideal person-days of project work delivered per calendar working day. The * scheduler treats each member as a lane whose task durations are scaled by that * rate (a 0.5-capacity person takes twice as long on an est/2d task). Config * lives in the pm-state repo (`capacity/members.yaml/json`, D4); estimates are * in ideal person-days (pm-state.md). */ export interface CapacityMember { /** gitea login. */ person: string /** Productive fraction of a working day (0..1). */ focusFactor: number /** Fraction of that allocated to this project (0..1). */ allocation: number } /** Ideal person-days delivered per calendar working day. */ export function capacityPerWorkday(m: CapacityMember): number { return m.focusFactor * m.allocation } function clamp01(n: unknown, fallback: number): number { return typeof n === 'number' && Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback } /** * Validate a raw capacity config (`{ members: [...] }`) into members. Unknown * shapes degrade to `[]` (→ the scheduler falls back to a single worker), never * throw. focusFactor/allocation clamp to [0,1]; a member without a person is dropped. */ export function parseCapacityConfig(raw: unknown): CapacityMember[] { const list = (raw as { members?: unknown })?.members if (!Array.isArray(list)) return [] const out: CapacityMember[] = [] for (const m of list) { const r = (m ?? {}) as Record const person = typeof r.person === 'string' ? r.person.trim() : '' if (!person) continue const member: CapacityMember = { person, focusFactor: clamp01(r.focusFactor, 0.8), allocation: clamp01(r.allocation, 1), } if (capacityPerWorkday(member) > 0) out.push(member) } return out }