- {CAPACITY.map((p, i) => (
+ {capacityRows.map((p, i) => (
([])
const [milestoneId, setMilestoneId] = useState(null)
const [backlog, refetchBacklog] = useBacklog()
+ const capacityMembers = useCapacity()
+ const workers = capacityWorkers(capacityMembers)
const boardColumns =
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus =
@@ -104,10 +108,12 @@ export function AppShell() {
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast =
backlog.status === 'ready'
- ? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined)
+ ? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model, workers) ?? undefined)
: undefined
const runwayMilestones =
- backlog.status === 'ready' ? runwayView(backlog.issues, backlog.milestones, backlog.deps) : undefined
+ backlog.status === 'ready'
+ ? runwayView(backlog.issues, backlog.milestones, backlog.deps, new Date(), workers)
+ : undefined
const milestone =
backlog.status === 'ready' && milestoneId != null
? (milestoneView(
@@ -117,6 +123,8 @@ export function AppShell() {
backlog.deps,
backlog.timelines,
calibration?.model,
+ new Date(),
+ workers,
) ?? undefined)
: undefined
@@ -221,6 +229,7 @@ export function AppShell() {
}}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
milestones={runwayMilestones}
+ capacity={capacityMembers}
/>
)
case 'calibration':
diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts
index 93434ba..0ff0861 100644
--- a/apps/desktop/src/renderer/src/global.d.ts
+++ b/apps/desktop/src/renderer/src/global.d.ts
@@ -1,5 +1,6 @@
import type {
AgentStep,
+ CapacityMember,
ChangeProposal,
ChatMessage,
CaptureProposal,
@@ -77,9 +78,15 @@ export type DirectivesResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; directives: DirectiveRecord[] }
+/** The result of reading the capacity config. */
+export type CapacityResult =
+ | { ok: false; reason: 'unconfigured'; members: CapacityMember[] }
+ | { ok: true; members: CapacityMember[] }
+
/** The pm-state bridge (machine-derived state) exposed by the preload over IPC. */
export interface PmStateBridge {
directives(): Promise
+ capacity(): Promise
}
declare global {
diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts
index 6420fd5..e55bbea 100644
--- a/apps/desktop/src/renderer/src/lib/backlog.ts
+++ b/apps/desktop/src/renderer/src/lib/backlog.ts
@@ -2,10 +2,13 @@ import {
type CalibrationModel,
type CalibrationSample,
calibrationSamples,
+ type CapacityMember,
+ capacityPerWorkday,
COLD_START_THRESHOLD,
type DependencyEdge,
fitCalibration,
forecast,
+ type Worker,
type GiteaIssue,
type GiteaMilestone,
inferLifecycle,
@@ -107,9 +110,15 @@ function toSchedulable(issues: GiteaIssue[]) {
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
+ assignee: i.assignee,
}))
}
+/** gitea CapacityMembers → scheduler lanes (person + throughput). */
+export function capacityWorkers(members: CapacityMember[]): Worker[] {
+ return members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
+}
+
export interface ForecastView {
scope: number
cone: BurnUpData
@@ -132,9 +141,10 @@ export function forecastBacklog(
deps: DependencyEdge[],
today: Date = new Date(),
calibration?: CalibrationModel,
+ workers: Worker[] = [],
): ForecastView | null {
const model = calibration ? toDurationModel(calibration) : undefined
- const f = forecast(toSchedulable(issues), deps, model ? { model } : {})
+ const f = forecast(toSchedulable(issues), deps, { ...(model ? { model } : {}), workers })
const cone = buildBurnUpData(f, today)
if (!cone) return null
return {
@@ -252,6 +262,7 @@ export function runwayView(
milestones: GiteaMilestone[],
deps: DependencyEdge[],
today: Date = new Date(),
+ workers: Worker[] = [],
): RunwayMilestone[] {
const open = issues.filter((i) => i.state === 'open')
const rows = milestones
@@ -266,8 +277,10 @@ export function runwayView(
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
+ assignee: i.assignee,
})),
deps,
+ { workers },
)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
const dueDay = m.dueOn ? workingDaysBetween(today, new Date(m.dueOn)) : null
@@ -324,6 +337,7 @@ export function milestoneView(
timelines: Timelines = {},
calibration?: CalibrationModel,
today: Date = new Date(),
+ workers: Worker[] = [],
): MilestoneView | null {
const m = milestones.find((x) => x.id === id)
if (!m) return null
@@ -340,9 +354,10 @@ export function milestoneView(
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
+ assignee: i.assignee,
})),
deps,
- model ? { model } : {},
+ { ...(model ? { model } : {}), workers },
)
const cone = buildBurnUpData(f, today)
diff --git a/apps/desktop/src/renderer/src/lib/use-capacity.ts b/apps/desktop/src/renderer/src/lib/use-capacity.ts
new file mode 100644
index 0000000..40b6bc5
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/use-capacity.ts
@@ -0,0 +1,24 @@
+import { useEffect, useState } from 'react'
+
+import type { CapacityMember } from '@commitea/core'
+
+/**
+ * The team's capacity config from the pm-state repo. Empty when unconfigured —
+ * forecasts then fall back to a single serial worker. Read once on mount.
+ */
+export function useCapacity(): CapacityMember[] {
+ const [members, setMembers] = useState([])
+ useEffect(() => {
+ let alive = true
+ window.commitea.pmstate
+ .capacity()
+ .then((r) => {
+ if (alive) setMembers(r.members)
+ })
+ .catch(() => {})
+ return () => {
+ alive = false
+ }
+ }, [])
+ return members
+}
diff --git a/packages/core/src/capacity/capacity-v0.test.ts b/packages/core/src/capacity/capacity-v0.test.ts
new file mode 100644
index 0000000..f82863e
--- /dev/null
+++ b/packages/core/src/capacity/capacity-v0.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+
+import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
+import { makespan, scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js'
+import { capacityPerWorkday, parseCapacityConfig } from './capacity-v0.js'
+
+describe('capacity model', () => {
+ it('capacityPerWorkday = focusFactor × allocation', () => {
+ expect(capacityPerWorkday({ person: 'a', focusFactor: 0.8, allocation: 0.5 })).toBeCloseTo(0.4)
+ })
+
+ it('parses + clamps config, drops invalid members', () => {
+ const members = parseCapacityConfig({
+ members: [
+ { person: 'christian', focusFactor: 0.8, allocation: 1 },
+ { person: 'ak', focusFactor: 1.5, allocation: -1 }, // clamps to 1 / 0 → zero capacity → dropped
+ { focusFactor: 0.8 }, // no person → dropped
+ ],
+ })
+ expect(members).toEqual([{ person: 'christian', focusFactor: 0.8, allocation: 1 }])
+ })
+
+ it('returns [] for a non-array/absent members field', () => {
+ expect(parseCapacityConfig({})).toEqual([])
+ expect(parseCapacityConfig({ members: 'nope' })).toEqual([])
+ })
+})
+
+function issue(number: number, over: Partial = {}): SchedulableIssue {
+ return { number, title: `#${number}`, labels: [], estimateDays: 4, priority: 2, ...over }
+}
+
+describe('scheduleWithCapacity', () => {
+ it('with no workers, falls back to the single serial plan', () => {
+ const issues = [issue(1), issue(2)]
+ const plan = scheduleWithCapacity(issues, [], [])
+ expect(makespan(plan)).toBe(8) // 4 + 4 serial
+ })
+
+ it('parallelizes independent work across lanes (makespan shrinks)', () => {
+ const issues = [issue(1), issue(2), issue(3), issue(4)] // 4×4d = 16d serial
+ const one: Worker[] = [{ person: 'a', speed: 1 }]
+ const two: Worker[] = [
+ { person: 'a', speed: 1 },
+ { person: 'b', speed: 1 },
+ ]
+ expect(makespan(scheduleWithCapacity(issues, [], one))).toBe(16)
+ expect(makespan(scheduleWithCapacity(issues, [], two))).toBe(8) // two lanes → half
+ })
+
+ it('scales duration by a lane speed (slower lane takes longer)', () => {
+ const plan = scheduleWithCapacity([issue(1, { estimateDays: 4 })], [], [{ person: 'a', speed: 0.5 }])
+ expect(plan.items[0].durationDays).toBe(8) // 4 / 0.5
+ expect(plan.items[0].worker).toBe('a')
+ })
+
+ it('routes an issue to its assignee lane', () => {
+ const issues = [issue(1, { assignee: 'ak' }), issue(2, { assignee: 'sm' })]
+ const workers: Worker[] = [
+ { person: 'ak', speed: 1 },
+ { person: 'sm', speed: 1 },
+ ]
+ const plan = scheduleWithCapacity(issues, [], workers)
+ const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
+ expect(byN[1].worker).toBe('ak')
+ expect(byN[2].worker).toBe('sm')
+ // both start at 0 (different lanes) → parallel
+ expect(byN[1].startDay).toBe(0)
+ expect(byN[2].startDay).toBe(0)
+ })
+
+ it('adds a lane for an assignee not in the config (mean speed)', () => {
+ const plan = scheduleWithCapacity([issue(1, { assignee: 'newbie' })], [], [{ person: 'a', speed: 0.5 }])
+ expect(plan.items[0].worker).toBe('newbie')
+ expect(plan.items[0].durationDays).toBe(8) // mean speed 0.5 → 4/0.5
+ })
+
+ it('respects dependencies across lanes (a blocker finishes before its dependent starts)', () => {
+ const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
+ const workers: Worker[] = [
+ { person: 'a', speed: 1 },
+ { person: 'b', speed: 1 },
+ ]
+ const plan = scheduleWithCapacity([issue(1), issue(2)], edges, workers)
+ const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
+ expect(byN[2].startDay).toBeGreaterThanOrEqual(byN[1].endDay) // #2 waits for #1 even on another lane
+ })
+})
diff --git a/packages/core/src/capacity/capacity-v0.ts b/packages/core/src/capacity/capacity-v0.ts
new file mode 100644
index 0000000..9492069
--- /dev/null
+++ b/packages/core/src/capacity/capacity-v0.ts
@@ -0,0 +1,49 @@
+/**
+ * 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
+}
diff --git a/packages/core/src/forecast/forecast-v0.test.ts b/packages/core/src/forecast/forecast-v0.test.ts
index 17c9d14..d56d6b6 100644
--- a/packages/core/src/forecast/forecast-v0.test.ts
+++ b/packages/core/src/forecast/forecast-v0.test.ts
@@ -120,4 +120,20 @@ describe('forecast', () => {
expect(fitted.coldStart).toBe(false)
expect(fitted.p50Day).toBeLessThan(priors.p50Day)
})
+
+ it('capacity lanes parallelize the sim — the makespan shrinks with more workers', () => {
+ const independent = [issue(1), issue(2), issue(3), issue(4)] // no deps → fully parallelizable
+ const serial = forecast(independent, [], { trials: 2000, seed: 7 })
+ const twoLanes = forecast(independent, [], {
+ trials: 2000,
+ seed: 7,
+ workers: [
+ { person: 'a', speed: 1 },
+ { person: 'b', speed: 1 },
+ ],
+ })
+ // two equal lanes ≈ half the serial landing (independent work)
+ expect(twoLanes.p80Day).toBeLessThan(serial.p80Day)
+ expect(twoLanes.p80Day).toBeLessThan(serial.p80Day * 0.7)
+ })
})
diff --git a/packages/core/src/forecast/forecast-v0.ts b/packages/core/src/forecast/forecast-v0.ts
index 04134fb..f633d03 100644
--- a/packages/core/src/forecast/forecast-v0.ts
+++ b/packages/core/src/forecast/forecast-v0.ts
@@ -18,6 +18,7 @@ import {
schedule,
type SchedulableIssue,
} from '../scheduler/scheduler-v0.js'
+import { type LaneInputs, layoutOnLanes, resolveLanes, type Worker } from '../scheduler/scheduler-capacity-v0.js'
export interface LognormalPrior {
/** Median log-ratio: sampled median duration = estimate * e^mu. */
@@ -76,6 +77,12 @@ export interface ForecastOptions {
* the sim; otherwise the code-resident cold-start priors do.
*/
model?: DurationModel
+ /**
+ * Capacity lanes. When provided, each trial lays sampled durations across the
+ * lanes (parallel) instead of a single serial worker — the makespan shrinks
+ * toward the critical path. Empty/absent → single serial worker.
+ */
+ workers?: Worker[]
}
/** Resolve the lognormal params for an estimate, preferring a fitted model. */
@@ -158,15 +165,32 @@ export function forecast(
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.
+ // Capacity lanes, when configured — the per-trial layout goes parallel.
+ const laneInputs: LaneInputs = {
+ order: order.map((it) => it.number),
+ blockedBy: new Map(order.map((it) => [it.number, it.blockedBy])),
+ assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
+ }
+ const lanes =
+ options.workers && options.workers.length ? resolveLanes(options.workers, [...laneInputs.assignee.values()]) : null
+
+ // endByRank[k][t] = working day the (k+1)-th issue *to finish* completes on trial t.
const endByRank: number[][] = Array.from({ length: n }, () => new Array(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 sampled = order.map((it, k) => it.durationDays * Math.exp(priors[k].mu + priors[k].sigma * standardNormal(rng)))
+ if (lanes) {
+ // parallel: lay the sampled durations across lanes, then sort finish days
+ const byNumber = new Map(order.map((it, k) => [it.number, sampled[k]]))
+ const { finishAt } = layoutOnLanes(laneInputs, lanes, (num) => byNumber.get(num)!)
+ const finishes = order.map((it) => finishAt.get(it.number)!).sort((a, b) => a - b)
+ for (let k = 0; k < n; k++) endByRank[k][t] = finishes[k]
+ } else {
+ // single serial worker: cumulative sum (already sorted ascending)
+ let cursor = 0
+ for (let k = 0; k < n; k++) {
+ cursor += sampled[k]
+ endByRank[k][t] = cursor
+ }
}
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index a08ec87..f54a11d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -49,6 +49,12 @@ export type {
SchedulePlan,
} from './scheduler/scheduler-v0.js'
+export { makespan, scheduleWithCapacity } from './scheduler/scheduler-capacity-v0.js'
+export type { Worker } from './scheduler/scheduler-capacity-v0.js'
+
+export { capacityPerWorkday, parseCapacityConfig } from './capacity/capacity-v0.js'
+export type { CapacityMember } from './capacity/capacity-v0.js'
+
export {
COLD_START_PRIORS,
durationParams,
diff --git a/packages/core/src/scheduler/scheduler-capacity-v0.ts b/packages/core/src/scheduler/scheduler-capacity-v0.ts
new file mode 100644
index 0000000..a112507
--- /dev/null
+++ b/packages/core/src/scheduler/scheduler-capacity-v0.ts
@@ -0,0 +1,119 @@
+/**
+ * Capacity-aware scheduler (#8). Reuses the single-worker scheduler's topological
+ * order + critical-path marking, then re-lays the work across lanes: an issue
+ * runs on its assignee's lane (or the earliest-free lane when unassigned), its
+ * duration scaled by that lane's speed (ideal person-days/workday). Makespan
+ * shrinks toward the critical path as lanes are added. Deterministic; falls back
+ * to the single serial worker when no capacity is configured. The lane layout is
+ * factored so the Monte Carlo forecast reuses it per trial with sampled durations.
+ */
+
+import {
+ type DependencyEdge,
+ schedule,
+ type SchedulableIssue,
+ type SchedulePlan,
+} from './scheduler-v0.js'
+
+/** A scheduling lane: a person and their throughput (ideal person-days / workday). */
+export interface Worker {
+ person: string
+ speed: number
+}
+
+/** The topological order + per-issue relations the layout needs (from the base plan). */
+export interface LaneInputs {
+ order: number[]
+ blockedBy: Map
+ assignee: Map
+}
+
+/** Ensure a lane exists for every assignee; unconfigured assignees get the mean speed. */
+export function resolveLanes(workers: Worker[], assignees: (string | null)[]): Worker[] {
+ const lanes = [...workers]
+ const known = new Set(lanes.map((w) => w.person))
+ const meanSpeed = lanes.length ? lanes.reduce((s, w) => s + w.speed, 0) / lanes.length : 1
+ for (const a of assignees) {
+ if (a && !known.has(a)) {
+ lanes.push({ person: a, speed: meanSpeed })
+ known.add(a)
+ }
+ }
+ return lanes
+}
+
+function pickWorker(lanes: Worker[], freeAt: Map, assignee: string | null | undefined): Worker {
+ if (assignee) {
+ const own = lanes.find((w) => w.person === assignee)
+ if (own) return own
+ }
+ let best = lanes[0]
+ for (const w of lanes) if (freeAt.get(w.person)! < freeAt.get(best.person)!) best = w
+ return best
+}
+
+/**
+ * Lay a topologically-ordered set out across lanes. `duration(n)` supplies each
+ * issue's duration for this layout (estimate, or a sampled value in a MC trial).
+ * Returns each issue's finish day + the lane it ran on. Order guarantees a
+ * dependency is always laid out before its dependents.
+ */
+export function layoutOnLanes(
+ inputs: LaneInputs,
+ lanes: Worker[],
+ duration: (n: number) => number,
+): { finishAt: Map; startAt: Map; laneOf: Map } {
+ const freeAt = new Map(lanes.map((w) => [w.person, 0]))
+ const finishAt = new Map()
+ const startAt = new Map()
+ const laneOf = new Map()
+ for (const n of inputs.order) {
+ const worker = pickWorker(lanes, freeAt, inputs.assignee.get(n))
+ const blockers = inputs.blockedBy.get(n) ?? []
+ const depFinish = blockers.length ? Math.max(...blockers.map((d) => finishAt.get(d) ?? 0)) : 0
+ const start = Math.max(freeAt.get(worker.person)!, depFinish)
+ const end = start + duration(n) / worker.speed
+ startAt.set(n, start)
+ finishAt.set(n, end)
+ laneOf.set(n, worker.person)
+ freeAt.set(worker.person, end)
+ }
+ return { finishAt, startAt, laneOf }
+}
+
+/**
+ * Capacity-aware plan. An empty `workers` means no capacity is configured → the
+ * single-worker plan verbatim.
+ */
+export function scheduleWithCapacity(
+ issues: SchedulableIssue[],
+ edges: DependencyEdge[],
+ workers: Worker[],
+): SchedulePlan {
+ if (workers.length === 0) return schedule(issues, edges)
+ const base = schedule(issues, edges)
+ if (base.cycle || base.items.length === 0) return base
+
+ const inputs: LaneInputs = {
+ order: base.items.map((it) => it.number),
+ blockedBy: new Map(base.items.map((it) => [it.number, it.blockedBy])),
+ assignee: new Map(issues.map((i) => [i.number, i.assignee ?? null])),
+ }
+ const lanes = resolveLanes(workers, [...inputs.assignee.values()])
+ const durationOf = new Map(base.items.map((it) => [it.number, it.durationDays]))
+ const { finishAt, startAt, laneOf } = layoutOnLanes(inputs, lanes, (n) => durationOf.get(n)!)
+
+ const items = base.items.map((it) => ({
+ ...it,
+ startDay: startAt.get(it.number)!,
+ endDay: finishAt.get(it.number)!,
+ durationDays: finishAt.get(it.number)! - startAt.get(it.number)!,
+ worker: laneOf.get(it.number),
+ }))
+ return { items, cycle: null }
+}
+
+/** Makespan (last finish) of a plan — the project's landing day. */
+export function makespan(plan: SchedulePlan): number {
+ return plan.items.reduce((m, it) => Math.max(m, it.endDay), 0)
+}
diff --git a/packages/core/src/scheduler/scheduler-v0.ts b/packages/core/src/scheduler/scheduler-v0.ts
index 26192e4..01f8576 100644
--- a/packages/core/src/scheduler/scheduler-v0.ts
+++ b/packages/core/src/scheduler/scheduler-v0.ts
@@ -21,6 +21,8 @@ export interface SchedulableIssue {
estimateDays: number | null
/** 1 (most urgent) … 4; null when unset. */
priority: number | null
+ /** gitea assignee login, for capacity-aware lane routing; optional. */
+ assignee?: string | null
}
export interface DependencyEdge {
@@ -45,6 +47,8 @@ export interface ScheduledItem {
/** On a longest-duration dependency chain. */
critical: boolean
rationale: string
+ /** Lane it's scheduled on, when capacity-aware; absent for the single-worker plan. */
+ worker?: string
}
export interface SchedulePlan {