/** * `query_project` — Reginald's single read tool. It selects a compact, * model-friendly view over the reconciled backlog. Every number comes from * deterministic code (scheduler, lifecycle inference, calibration); the model * only requests a shape and narrates it — it never computes (decisions.md). * * Serves focus / board / calibration / issue / search / standup. The remaining * views (milestone / runway) return a `notImplemented` marker so the model * degrades honestly instead of inventing data. */ import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js' import type { GiteaIssue } from '../gitea/types.js' import { inferLifecycle, type LifecycleColumn, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js' import { type DependencyEdge, schedule, selectFocus } from '../scheduler/scheduler-v0.js' export type ProjectView = | 'focus' | 'board' | 'calibration' | 'issue' | 'milestone' | 'runway' | 'standup' | 'search' export interface QueryFilters { issueId?: number query?: string limit?: number } /** The reconciled inputs a view is built from. */ export interface ProjectSnapshot { issues: GiteaIssue[] timelines: Record deps: DependencyEdge[] } const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done'] 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, })) } function focusView(snap: ProjectSnapshot) { const plan = schedule(toSchedulable(snap.issues), snap.deps) const f = selectFocus(plan) const slot = (item: (typeof plan.items)[number] | null) => item ? { issue: item.number, title: item.title, rationale: item.rationale } : null return { now: slot(f.now), next: slot(f.next), later: slot(f.later), openCount: plan.items.length } } function boardView(snap: ProjectSnapshot, asOf: Date) { const columns: Record = {} for (const key of COLUMN_ORDER) columns[key] = [] for (const issue of snap.issues) { const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf) columns[inf.column].push({ issue: issue.number, title: issue.title, labels: issue.labels }) } return { columns: COLUMN_ORDER.map((key) => ({ column: key, count: columns[key].length, issues: columns[key] })), } } function calibrationView(snap: ProjectSnapshot, asOf: Date) { const model = fitCalibration(calibrationSamples(snap.issues, snap.timelines, asOf)) return { n: model.n, coldStart: model.coldStart, globalMultiplier: Number(Math.exp(model.global.mu).toFixed(2)), byBucket: Object.entries(model.byBucket).map(([bucket, fit]) => ({ estimate: `est/${bucket}d`, n: fit.n, multiplier: Number(Math.exp(fit.mu).toFixed(2)), })), } } function issueView(snap: ProjectSnapshot, filters: QueryFilters, asOf: Date) { const issue = snap.issues.find((i) => i.number === filters.issueId) if (!issue) return { notFound: filters.issueId ?? null } const inf = inferLifecycle(issue, snap.timelines[issue.number] ?? [], asOf) const blockedBy = snap.deps.filter((d) => d.issue === issue.number).map((d) => d.dependsOn) const blocks = snap.deps.filter((d) => d.dependsOn === issue.number).map((d) => d.issue) return { issue: issue.number, title: issue.title, state: issue.state, column: inf.column, labels: issue.labels, assignee: issue.assignee, milestone: issue.milestone?.title ?? null, estimateDays: issue.facts.estimateDays, priority: issue.facts.priority, steepingDays: inf.steepingDays, blockedBy, blocks, } } function searchView(snap: ProjectSnapshot, filters: QueryFilters) { const q = (filters.query ?? '').toLowerCase().trim() const limit = Math.min(filters.limit ?? 20, 100) const hits = q ? snap.issues.filter( (i) => i.title.toLowerCase().includes(q) || i.labels.some((l) => l.toLowerCase().includes(q)), ) : [] return { query: filters.query ?? '', results: hits.slice(0, limit).map((i) => ({ issue: i.number, title: i.title, state: i.state, labels: i.labels })), } } /** * Standup — the morning ritual as a compact, model-narratable payload: today's * plan (the scheduler's earliest pick per person), overnight drift (real * anomalies — issues sitting in review or steeping past their estimate), and the * single stalest blocker to nag about. All from deterministic code; the model * narrates it. Satisfies #28's "surfaces schedule drift + at least one stale blocker". */ function standupView(snap: ProjectSnapshot, asOf: Date) { const plan = schedule(toSchedulable(snap.issues), snap.deps) const open = snap.issues.filter((i) => i.state === 'open') const infOf = (i: GiteaIssue) => inferLifecycle(i, snap.timelines[i.number] ?? [], asOf) // plan: the earliest scheduled pick per assignee (dependency + priority order) const seen = new Set() const planPicks: { who: string; issue: number; title: string; why: string }[] = [] for (const item of plan.items) { const who = open.find((i) => i.number === item.number)?.assignee ?? 'unassigned' if (seen.has(who)) continue seen.add(who) planPicks.push({ who, issue: item.number, title: item.title, why: item.critical ? 'on the critical path' : item.blocks.length ? `blocks ${item.blocks.map((b) => `#${b}`).join(', ')}` : 'next by dependency + priority', }) } // drift: real overnight anomalies (review-sitting, steeping past estimate) const drift: { issue: number; note: string }[] = [] for (const i of open) { const inf = infOf(i) if (inf.column === 'review') drift.push({ issue: i.number, note: `#${i.number} is sitting in review` }) else if (inf.steepingDays != null && inf.steepingDays > (i.facts.estimateDays ?? 2)) drift.push({ issue: i.number, note: `#${i.number} has steeped ${inf.steepingDays}d past its ${i.facts.estimateDays ?? 2}d estimate`, }) } // nag: the single longest-steeping open issue + what it blocks let nag: { issue: number; steepingDays: number; blocks: number[] } | null = null for (const i of open) { const inf = infOf(i) if (inf.steepingDays == null) continue if (!nag || inf.steepingDays > nag.steepingDays) { nag = { issue: i.number, steepingDays: inf.steepingDays, blocks: plan.items.find((it) => it.number === i.number)?.blocks ?? [] } } } return { date: asOf.toISOString().slice(0, 10), plan: planPicks.slice(0, 5), drift: drift.slice(0, 5), nag } } /** Build the compact payload for one view. Unknown/unbuilt views return a marker. */ export function buildProjectView( view: ProjectView, filters: QueryFilters | undefined, snap: ProjectSnapshot, asOf: Date, ): unknown { const f = filters ?? {} switch (view) { case 'focus': return focusView(snap) case 'board': return boardView(snap, asOf) case 'calibration': return calibrationView(snap, asOf) case 'issue': return issueView(snap, f, asOf) case 'search': return searchView(snap, f) case 'standup': return standupView(snap, asOf) default: return { notImplemented: view } } }