The fixture chat panel is now a working agent. Ask Reginald a question and it consults the real project through a tool loop, then answers in grounded prose. Read-only v0 — writes still go through the propose-approve controls. core (@commitea/core/agent): - chat-client: OpenAI-wire chat completions over an injected fetch (same seam as gitea). Points at any OpenAI-compatible endpoint (LM Studio/Ollama/OpenAI). - model-router: small model for prose + the read tool; big model reserved for later decomposition (pickModel). - agent-loop: runAgentTurn drives call→tool→result→call until prose (or a step budget), recording each tool step. Injected complete + execute → fully testable. - query-project: the single read tool's engine — compact focus/board/calibration/ issue/search views built from scheduler + lifecycle + calibration; unbuilt views return a notImplemented marker (never fabricated). The model reports, never computes. - agent-tools: query_project declaration + Reginald's system prompt. app: - main model bridge (model:status, model:chat) runs the loop; query_project reconciles the repo and builds the view. Model traffic stays in main (token/CSP). gitea.ts refactored to share getGiteaClient + reconcileSnapshot. - preload + global.d.ts expose the model bridge; useChat drives the panel — real agent turn when a model is configured, scripted fixture reply otherwise (so fixture e2e is unchanged). A subtle "consulted the project" activity line. Model config (env, defaults to LM Studio on :1234): COMMITEA_MODEL_URL / _SMALL (google/gemma-4-e4b) / _BIG (qwen/qwen3.6-35b-a3b). COMMITEA_E2E=1 keeps it unconfigured so the panel stays scripted. Verified: 88 core tests green (14 agent: client parse, loop tool/error/budget, all views) + a gated live integration test. Desktop typecheck clean, 14 fixture e2e green. Gated live e2e drives the real app against gitea + gemma-4-e4b: asked "what now?", Reginald called query_project and answered "focus is on issue #2" (the real scheduler pick). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
4.9 KiB
TypeScript
147 lines
4.9 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).
|
|
*
|
|
* v0 serves focus / board / calibration / issue. The remaining views
|
|
* (milestone / runway / standup / search) 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 })),
|
|
}
|
|
}
|
|
|
|
/** 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)
|
|
default:
|
|
return { notImplemented: view }
|
|
}
|
|
}
|