Files
commitea/packages/core/src/agent/query-project.ts
Croissant Le Doux 19e83ff8ba query_project: implement the standup view (#28)
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>
2026-07-09 15:33:20 -04:00

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 }
}
}