The StandupScreen already renders drift + plan + nag from real data (standup-view, #52), but the agent's query_project standup view was a `notImplemented` stub, so Reginald couldn't answer standup questions from deterministic data. Implement `standupView(snap, asOf)`: today's plan (the scheduler's earliest pick per person, with why — critical path / blocks / order), overnight drift (real anomalies: issues sitting in review, or steeping past their estimate), and the single stalest blocker to nag about (+ what it blocks). All deterministic; the model narrates. Added 'standup' to the query_project tool's view enum. Acceptance met: standup surfaces schedule drift + at least one stale blocker (and stays calm — no nag, empty drift — when nothing is steeping). +2 core tests; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
7.3 KiB
TypeScript
205 lines
7.3 KiB
TypeScript
/**
|
|
* `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<number, LifecycleEvent[]>
|
|
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<string, { issue: number; title: string; labels: string[] }[]> = {}
|
|
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<string>()
|
|
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 }
|
|
}
|
|
}
|