feat: deterministic scheduler → real Now/Next/Later (P2 thin slice)

CommiTea now recommends its own next unit of work from the live backlog.

- @commitea/core: `schedule()` — dependency topo-sort with priority +
  estimate tie-breaks, single serial capacity, cycle detection, and
  critical-path marking; `selectFocus()` takes the top three. Pure,
  deterministic; the LLM does none of this. +11 tests (39 in core).
  Client gains `getIssueDependencies`.
- main: reconcile also fetches native issue dependencies for the open
  scope and returns edges.
- renderer: `scheduleFocus()` maps real issues+deps→Now/Next/Later;
  Focus renders scheduler output (fixture fallback when unconfigured).

v0 scope (each a later slice): single serial worker (per-person
capacity #8), point durations (Monte Carlo cone #10), estimate-only
(calibration #5). Verified: 14 e2e green (fixtures) + gated live spec —
the board shows the real 25 open + 9 closed, and Focus picks #2
ChangeSource (critical path) as Now. Screenshots confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 17:12:22 -04:00
parent 94199639f2
commit 68a93de098
11 changed files with 387 additions and 26 deletions

View File

@@ -0,0 +1,182 @@
/**
* Deterministic scheduler, v0. Orders the open backlog by dependency topology,
* then priority, then estimate; lays it out on a single serial capacity and
* marks the critical path. The LLM never does this — it's plain code.
*
* v0 simplifications (each is a later slice, not a hack):
* - single serial worker; per-person capacity is #8.
* - point durations from estimate labels; Monte Carlo cones are #10.
* - a null estimate defaults to DEFAULT_ESTIMATE_DAYS.
* Dependencies on out-of-scope issues (closed/done, or not passed in) are
* treated as satisfied and dropped — you schedule what's left to do.
*/
export const DEFAULT_ESTIMATE_DAYS = 2
export interface SchedulableIssue {
number: number
title: string
labels: string[]
/** From label facts; null when unestimated. */
estimateDays: number | null
/** 1 (most urgent) … 4; null when unset. */
priority: number | null
}
export interface DependencyEdge {
issue: number
dependsOn: number
}
export interface ScheduledItem {
number: number
title: string
labels: string[]
/** 0-based position in the plan. */
order: number
/** Working-day offsets from now. */
startDay: number
endDay: number
durationDays: number
/** In-scope dependencies (all still open, so all still blocking). */
blockedBy: number[]
/** Issues that depend on this one. */
blocks: number[]
/** On a longest-duration dependency chain. */
critical: boolean
rationale: string
}
export interface SchedulePlan {
items: ScheduledItem[]
/** A dependency cycle (issue numbers) if one was found — the plan is then empty. */
cycle: number[] | null
}
function durationOf(issue: SchedulableIssue): number {
return issue.estimateDays ?? DEFAULT_ESTIMATE_DAYS
}
/** Best-first comparison among ready nodes: priority (1 first), then larger estimate, then number. */
function readyRank(a: SchedulableIssue, b: SchedulableIssue): number {
const pa = a.priority ?? 99
const pb = b.priority ?? 99
if (pa !== pb) return pa - pb
const da = durationOf(a)
const db = durationOf(b)
if (da !== db) return db - da
return a.number - b.number
}
function rationaleFor(item: {
critical: boolean
blockedBy: number[]
blocks: number[]
priority: number | null
}): string {
const blocksNote = item.blocks.length ? `unblocks ${item.blocks.map((n) => `#${n}`).join(', ')}` : ''
if (item.critical) return ['on the critical path', blocksNote].filter(Boolean).join(' · ')
if (item.blockedBy.length) return `waits on ${item.blockedBy.map((n) => `#${n}`).join(', ')}`
if (blocksNote) return blocksNote
if (item.priority != null) return `p/${item.priority} · ready`
return 'ready'
}
export function schedule(issues: SchedulableIssue[], edges: DependencyEdge[]): SchedulePlan {
const byNumber = new Map(issues.map((i) => [i.number, i]))
// keep only edges whose endpoints are both in scope
const scoped = edges.filter((e) => byNumber.has(e.issue) && byNumber.has(e.dependsOn))
const deps = new Map<number, number[]>() // issue → its dependencies
const dependents = new Map<number, number[]>() // issue → who depends on it
for (const i of issues) {
deps.set(i.number, [])
dependents.set(i.number, [])
}
for (const e of scoped) {
deps.get(e.issue)!.push(e.dependsOn)
dependents.get(e.dependsOn)!.push(e.issue)
}
// Kahn topo with priority tie-break
const indegree = new Map(issues.map((i) => [i.number, deps.get(i.number)!.length]))
const order: number[] = []
const ready = issues.filter((i) => indegree.get(i.number) === 0)
while (ready.length) {
ready.sort(readyRank)
const next = ready.shift()!
order.push(next.number)
for (const dep of dependents.get(next.number)!) {
const d = indegree.get(dep)! - 1
indegree.set(dep, d)
if (d === 0) ready.push(byNumber.get(dep)!)
}
}
if (order.length < issues.length) {
// cycle: report the nodes that never became ready
const stuck = issues.filter((i) => indegree.get(i.number)! > 0).map((i) => i.number)
return { items: [], cycle: stuck }
}
// longest-path (by duration) for critical-path marking
const longestThrough = new Map<number, number>() // node → longest chain duration passing through it
const before = new Map<number, number>() // longest duration of chain ending at node's start
for (const n of order) {
const depMax = Math.max(0, ...deps.get(n)!.map((d) => before.get(d)! + durationOf(byNumber.get(d)!)))
before.set(n, depMax)
}
const after = new Map<number, number>()
for (const n of [...order].reverse()) {
const depMax = Math.max(0, ...dependents.get(n)!.map((d) => after.get(d)! + durationOf(byNumber.get(d)!)))
after.set(n, depMax)
}
let globalMax = 0
for (const n of order) {
const total = before.get(n)! + durationOf(byNumber.get(n)!) + after.get(n)!
longestThrough.set(n, total)
globalMax = Math.max(globalMax, total)
}
// serial layout
let cursor = 0
const items: ScheduledItem[] = order.map((n, idx) => {
const issue = byNumber.get(n)!
const dur = durationOf(issue)
const start = cursor
cursor += dur
const blockedBy = deps.get(n)!
const blocks = dependents.get(n)!
const critical = globalMax > 0 && longestThrough.get(n) === globalMax
return {
number: n,
title: issue.title,
labels: issue.labels,
order: idx,
startDay: start,
endDay: cursor,
durationDays: dur,
blockedBy,
blocks,
critical,
rationale: rationaleFor({ critical, blockedBy, blocks, priority: issue.priority }),
}
})
return { items, cycle: null }
}
export interface Focus {
now: ScheduledItem | null
next: ScheduledItem | null
later: ScheduledItem | null
}
/** Now/Next/Later = the first three of the plan (already dependency- + priority-ordered). */
export function selectFocus(plan: SchedulePlan): Focus {
return {
now: plan.items[0] ?? null,
next: plan.items[1] ?? null,
later: plan.items[2] ?? null,
}
}