Compare commits

9 Commits

Author SHA1 Message Date
Croissant Le Doux
25cf0a6d39 feat: Reginald follows the model you load (auto-detect) + shows it in the header
Instead of a hardcoded model name (which forces LM Studio to JIT-swap your loaded
model out — and fails when a big model already fills memory), resolve the model
at request time: an explicit env override wins, else ask the server which model
is *loaded* (LM Studio's native /api/v0/models), else the first non-embedding
model, else a default. Reginald now uses whatever you load, no config churn.

- main/model.ts: resolveLoadedModel() drives both model:status and model:chat;
  COMMITEA_MODEL_SMALL still overrides.
- useChat exposes the resolved model id; the panel header shows it
  (google/gemma-4-26b-a4b-qat → "gemma-4-26b-a4b · local").
- live-reginald e2e: header assertion relaxed to the loaded model; timeouts
  raised for a slow big local model (~2 calls/turn + a reconcile).

Verified: 14 fixture e2e green; live e2e drives the app against the loaded
gemma-4-26b — "What now?" → "You should work on #2 … on the critical path,
unblocks #33 and #4" (the real scheduler pick), header shows the live model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:23:14 -04:00
Croissant Le Doux
3de887417c feat: Reginald is real — model router + agent loop + query_project (P4)
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>
2026-07-08 21:23:14 -04:00
e8bf71e970 Merge pull request 'P4-3: the write path — apply estimate/priority changes to gitea (apply_changes)' (#41) from p4/apply-changes into main
Reviewed-on: #41
2026-07-09 00:25:26 +00:00
Croissant Le Doux
6198f21d9a feat: the write path — apply estimate/priority changes to gitea (P4-3 apply_changes)
The first write path. Read, forecast, and calibration were all real; now you can
*manage* CommiTea from CommiTea. Estimates/priority are exclusive label axes, so
a change is a label swap — proposed, approved, then written. Nothing is assumed.

core (@commitea/core):
- planIssueChange(current, change): pure diff planner — swaps the est/*|p/* axis,
  clears on null, dedups a doubled axis; returns the resulting label set + a
  before/after diff + noop flag. describeChange() renders "est/2d → est/5d".
- request() seam extended for writes (method/body, JSON, 204). client gains
  listLabels() (name→id) and setIssueLabels() (PUT /issues/{n}/labels).

app:
- main bridge gitea:applyChange — resolves plan.labels → ids (cached, refetch on
  miss), PUTs, returns the plan + fresh issue. Token never leaves main.
- preload + global.d.ts expose applyChange; useBacklog returns a refetch so a
  write re-reconciles the board + forecast.
- Issue screen: an Adjust button (shown only when configured) opens a
  propose-approve Dialog — estimate/priority pickers, live "est/3d → est/8d"
  consequence, Apply/Cancel. AppShell wires it, reflects new labels on the open
  issue immediately, and refetches.

Verified: 83 core tests green (7 apply-changes + 2 client-write new), desktop
typecheck clean, 14 fixture e2e green. Live spec exercises propose + CANCEL (no
mutation); the real PUT was verified once manually (change #2 est/3d→est/8d→200,
reverted clean). Icon: pencil (no sliders-horizontal in the set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:14:47 -04:00
a901b7f855 Merge pull request 'Calibration → forecast flips off cold-start (#1); re-lands Monte Carlo (#10) + lifecycle (#5)' (#40) from p2/calibration into main
Reviewed-on: #40
2026-07-08 23:54:48 +00:00
Croissant Le Doux
7e26de1b6c feat: calibration from closed-issue actuals → forecast flips off cold-start (#1)
Close the D3 loop. The forecast now learns from the team's own estimate-vs-actual
history (the working time #5 infers from git events) instead of guessing forever.

core (@commitea/core/calibration-v0):
- fitCalibration(samples): lognormal fit on log(actual/estimate) — global +
  per-bucket (once a bucket clears the floor) + per-person bias. coldStart until
  n >= 20 closed-with-estimate issues.
- calibrationSamples(): pull those samples from the closed backlog via lifecycle
  inference (estimate label vs inferred actualWorkingDays).
- toDurationModel(): project the fit to the params forecast consumes.
- forecast() gains options.model: when past cold-start, fitted params drive the
  sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart
  now reflects the model. nearestBucket extracted + exported.

app:
- AppShell fits calibration once from the reconciled backlog, feeds the model into
  forecastBacklog (cone), and drives the Calibration screen + Runway header.
- Focus cone footer, Runway note, and Calibration screen now say cold-start (N/20)
  vs calibrated (on N closed) from real data; Calibration scatter / bucket bias /
  per-person all fitted, degrading honestly on a thin dataset.

Known refinement: same-day closes yield 0 working-day actuals (day-granular) and
are excluded, so a fast-moving repo can sit at n=0 — honest, but a fractional
(hours-based) actual would let those count. Per-person uses gitea login, not
display name, until the person map lands.

Note: also re-lands #10 (Monte Carlo) and #5 (lifecycle) which merged into their
stacked base branches but never propagated to main (stacked-merge trap); this
branch is cut from main and carries all three so main is whole again.

Verified: 74 core tests green (9 calibration + 2 forecast-switch added), desktop
typecheck clean, 14 fixture e2e green, live spec asserts the real cold-start
calibration surface (Runway note + screen badge fitted from actuals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:51:50 -04:00
Croissant Le Doux
9cedd8646e feat: lifecycle inference from the issue timeline (#5)
Fill the board's Steeping / In-review columns (and the calibration actuals)
from real gitea timeline events, replacing the three-column-only v0.

core (@commitea/core):
- inferLifecycle(issue, events, asOf): five-column inference — closed → done;
  open PR ref → review; commit ref → steeping; any triage signal → triage;
  else diagnosis. Earliest event of each kind fixes the stage timestamp.
- Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the
  calibration fit (D3) learns from — and steepingDays (first commit → now) for
  the board age badge.
- workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular.
- normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline
  (label/milestone → triage, commit_ref → commit, pull_ref → pull, close,
  reopen), drop the rest. Paginated.

app:
- reconcile now fetches every issue's timeline and returns it keyed by number;
  threaded through the bridge → useBacklog → board/focus.
- issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review
  columns, steeping-age `days` badge, focus-card steeping badge.

Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely
referenced in a PR body can read as In-review; distinguishing closing refs from
mentions needs the PR link's state (later). Re-opening multi-segment actuals
also deferred.

Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five
transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts
the board's Done column is populated from real events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:42:46 -04:00
Croissant Le Doux
be70c8607d feat: Monte Carlo forecast → real burn-up cone (#10)
Replace the demo cone on Morning service with a real, seeded Monte Carlo
forecast over the open backlog. The LLM never does this — it's plain,
reproducible code (evidence-based scheduling).

core (@commitea/core/forecast-v0):
- Code-resident lognormal cold-start priors per estimate bucket (D3):
  sampled actual = estimate * exp(N(mu, sigma)), mu > 0 (actuals run long),
  sigma shrinks as tickets grow. Replaced by the team's empirical fit at
  n >= 20 (#5 supplies the actuals).
- forecast(): seeded mulberry32 + Box-Muller over the scheduler's
  deterministic order (order is fixed from estimates/deps; only durations
  vary, so the cone stretches, never reorders). Returns p50/p80/p95 landing
  + a per-issue burn-up curve (p10/p50/p90). 12 unit tests; reproducible.

renderer:
- lib/dates.ts: working-day -> calendar mapper (skips weekends) + buildBurnUpData.
- BurnUpCone gains a data-driven twin; falls back byte-identical to the
  fixture cone when no forecast (demo mode unchanged).
- Focus card shows the real "80% of the open backlog lands by <range>",
  real scope count, and names the cold-start priors.

v0 scope (each a later slice): single serial worker (capacity is #8);
cold-start priors only (empirical fit is #5); no historical actual polyline
(needs lifecycle events, #5). Header chrome (reconcile time, ahead/behind
badge) stays fixture until milestone due dates land.

Verified: 51 core tests green, desktop typecheck clean, 14 fixture e2e green,
live spec asserts the real cone renders (25 open issues, "lands by Nov 11-27").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:42:46 -04:00
ca45f1e97c Merge pull request 'P2: deterministic scheduler → real Now/Next/Later' (#37) from p2/scheduler-focus into main
Reviewed-on: #37
2026-07-08 23:22:41 +00:00
35 changed files with 2245 additions and 101 deletions

View File

@@ -16,9 +16,13 @@ test.describe('live backlog', () => {
await win.waitForLoadState('domcontentloaded') await win.waitForLoadState('domcontentloaded')
const rail = win.getByRole('navigation', { name: 'Primary' }) const rail = win.getByRole('navigation', { name: 'Primary' })
// The pot — waiting here also lets the reconcile (issues + deps) complete // The pot — waiting here also lets the reconcile (issues + deps + timelines) complete
await rail.getByRole('button', { name: 'The pot' }).click() await rail.getByRole('button', { name: 'The pot' }).click()
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 }) await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
// Lifecycle inference (#5): columns come from the real event stream — merged
// work lands in Done, so that column is non-empty (proves timelines drove it,
// not the three-column fallback which would still show closed issues in Done).
await expect(win.getByText('Done', { exact: true })).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These // Back to Focus — the deterministic scheduler's real Now/Next/Later. These
@@ -30,6 +34,32 @@ test.describe('live backlog', () => {
await expect(win.getByText(/Cold-start priors/)).toBeVisible() await expect(win.getByText(/Cold-start priors/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' }) await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
// Runway → calibration surface, fitted from real closed-issue actuals (#1).
// With <20 estimated closes the repo is honestly cold-start; the note proves
// the fit ran on real data, not the fixture's "calibrated on 27".
await rail.getByRole('button', { name: 'Runway' }).click()
await expect(
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
).toBeVisible()
await win.getByRole('button', { name: 'Full report' }).click()
await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' })
// Write path (apply_changes): open the top scheduled issue, propose an
// estimate change, and confirm the propose-approve diff renders — then CANCEL
// so the live run never mutates the real repo (the PUT is unit-tested; a
// one-off change→revert verified it against gitea manually).
await rail.getByRole('button', { name: 'Morning service' }).click()
await win.getByRole('main').getByRole('link').first().click()
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
await win.getByRole('button', { name: 'Adjust' }).click()
await expect(win.getByText('Adjust estimate & priority')).toBeVisible()
await win.getByRole('combobox').first().selectOption('est/8d')
await expect(win.getByText('Proposed label change')).toBeVisible()
await expect(win.getByText(/est\/8d/).last()).toBeVisible()
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation
await app.close() await app.close()
}) })
}) })

View File

@@ -0,0 +1,33 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { _electron as electron, expect, test } from '@playwright/test'
const here = dirname(fileURLToPath(import.meta.url))
const MAIN = join(here, '..', 'out', 'main', 'index.js')
// Opt-in (GITEA_LIVE=1 + COMMITEA_MODEL_LIVE=1 + a local model on :1234). Launches
// WITHOUT COMMITEA_E2E so Reginald runs the real agent loop against the real repo.
test.describe('live Reginald', () => {
test('answers a question by consulting the real project', async () => {
test.skip(!process.env.GITEA_LIVE || !process.env.COMMITEA_MODEL_LIVE, 'live model test — opt-in')
test.setTimeout(300_000) // a big local model is slow: ~2 calls/turn + a reconcile
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
const win = await app.firstWindow()
await win.waitForLoadState('domcontentloaded')
// model configured → the live greeting + header (the loaded model, not the scripted demo)
await expect(win.getByText(/· local$/)).toBeVisible({ timeout: 20000 })
await expect(win.getByText(/I check the real board before I answer/)).toBeVisible()
const composer = win.getByPlaceholder(/Tell me what to do/)
await composer.fill('What should I work on right now?')
await composer.press('Enter')
// the agent loop ran end-to-end: it consulted the project, then answered
await expect(win.getByText(/consulted the project/)).toBeVisible({ timeout: 240_000 })
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-reginald.png'), fullPage: true, animations: 'disabled' })
await app.close()
})
})

View File

@@ -9,7 +9,16 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { createGiteaClient, type GiteaConfig } from '@commitea/core' import {
createGiteaClient,
type GiteaClient,
type GiteaConfig,
type GiteaLabel,
type IssueChange,
type LifecycleEvent,
planIssueChange,
type ProjectSnapshot,
} from '@commitea/core'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
@@ -43,27 +52,76 @@ function resolveConfig(): GiteaConfig | null {
} }
} }
export function registerGiteaIpc(): void { // Memoized client so both the gitea and model bridges share one instance.
const config = resolveConfig() let sharedClient: GiteaClient | null | undefined
const client = config ? createGiteaClient(config, fetch) : null export function getGiteaClient(): GiteaClient | null {
const repo = config ? `${config.owner}/${config.repo}` : null if (sharedClient === undefined) {
const config = resolveConfig()
sharedClient = config ? createGiteaClient(config, fetch) : null
}
return sharedClient
}
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo })) /** Full reconcile: issues + milestones + native deps + lifecycle timelines. */
export async function reconcileSnapshot(
client: GiteaClient,
): Promise<ProjectSnapshot & { milestones: Awaited<ReturnType<GiteaClient['listMilestones']>> }> {
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
// dependency edges among the open scope (the scheduler only plans what's left)
const open = issues.filter((i) => i.state === 'open')
const perIssue = await Promise.all(
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
)
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
// lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
const timelineEntries = await Promise.all(
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
)
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
return { issues, milestones, deps, timelines }
}
export function registerGiteaIpc(): void {
const client = getGiteaClient()
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))
ipcMain.handle('gitea:reconcile', async () => { ipcMain.handle('gitea:reconcile', async () => {
if (!client) return { configured: false, issues: [], milestones: [], deps: [] } if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) const snap = await reconcileSnapshot(client)
// dependency edges among the open scope (the scheduler only plans what's left) return { configured: true, ...snap }
const open = issues.filter((i) => i.state === 'open')
const perIssue = await Promise.all(
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
)
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
return { configured: true, issues, milestones, deps }
}) })
ipcMain.handle('gitea:getIssue', async (_event, index: number) => { ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
if (!client) return null if (!client) return null
return client.getIssue(index) return client.getIssue(index)
}) })
// Cached label list for name→id resolution; refreshed on demand if a name misses.
let labelCache: GiteaLabel[] | null = null
async function resolveLabelIds(names: string[]): Promise<number[]> {
if (!client) return []
const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
if (!labelCache) labelCache = await client.listLabels()
let byName = lookup()
if (names.some((n) => !byName.has(n))) {
labelCache = await client.listLabels() // a name we don't know — refetch once
byName = lookup()
}
return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
}
// The write path (apply_changes). Additive label swaps, applied only after the
// renderer's propose-approve. Returns the plan + the freshly-read issue.
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change)
if (plan.noop) return { ok: true as const, plan, issue: current }
const ids = await resolveLabelIds(plan.labels)
await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue)
return { ok: true as const, plan, issue }
})
} }

View File

@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { BrowserWindow, app, shell } from 'electron' import { BrowserWindow, app, shell } from 'electron'
import { registerGiteaIpc } from './gitea.js' import { registerGiteaIpc } from './gitea.js'
import { registerModelIpc } from './model.js'
function createWindow(): void { function createWindow(): void {
const win = new BrowserWindow({ const win = new BrowserWindow({
@@ -35,6 +36,7 @@ function createWindow(): void {
void app.whenReady().then(() => { void app.whenReady().then(() => {
registerGiteaIpc() registerGiteaIpc()
registerModelIpc()
createWindow() createWindow()
app.on('activate', () => { app.on('activate', () => {

View File

@@ -0,0 +1,103 @@
/**
* Main-process model bridge — Reginald's brain runs here. Model traffic (like
* gitea's) stays in main: the renderer is CSP-locked and never talks to the
* LLM directly. On `model:chat` it drives the agent loop against the configured
* OpenAI-compatible endpoint, executing `query_project` by reconciling the repo
* and building the requested view. v0 is read-only — writes still go through the
* propose-approve controls.
*/
import {
buildProjectView,
type ChatMessage,
createChatClient,
type ModelRouter,
type ProjectView,
type QueryFilters,
REGINALD_SYSTEM,
REGINALD_TOOLS,
runAgentTurn,
} from '@commitea/core'
import { ipcMain } from 'electron'
import { getGiteaClient, reconcileSnapshot } from './gitea.js'
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null {
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
if (!baseUrl) return null
return {
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? '' },
}
}
/**
* Resolve which model to actually ask for. An explicit env override wins;
* otherwise ask the server which model is *loaded* (LM Studio's native
* `/api/v0/models`) so Reginald follows whatever you load — no config churn on a
* model switch. Falls back to the first non-embedding model, then a sane default.
*/
async function resolveLoadedModel(baseUrl: string, override: string): Promise<string> {
if (override) return override
const root = baseUrl.replace(/\/v1\/?$/, '')
try {
const res = await fetch(`${root}/api/v0/models`)
if (res.ok) {
const data = (await res.json()) as { data?: { id: string; state?: string; type?: string }[] }
const loaded = (data.data ?? []).find((m) => m.state === 'loaded' && m.type !== 'embeddings')
if (loaded) return loaded.id
}
} catch {
// native API unavailable — fall through to the OpenAI listing
}
try {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
if (res.ok) {
const data = (await res.json()) as { data?: { id: string }[] }
const first = (data.data ?? []).find((m) => !/embed/i.test(m.id))
if (first) return first.id
}
} catch {
// ignore — use the default
}
return 'google/gemma-4-e4b'
}
export function registerModelIpc(): void {
const router = resolveModelRouter()
ipcMain.handle('model:status', async () => {
if (!router) return { configured: false, model: null }
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
return { configured: true, model }
})
ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => {
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
const client = getGiteaClient()
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
const chat = createChatClient({ ...router.small, model }, fetch)
const execute = async (name: string, args: unknown) => {
if (name !== 'query_project') return { error: `unknown tool: ${name}` }
if (!client) return { error: 'gitea is not configured' }
const snap = await reconcileSnapshot(client)
const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
return buildProjectView(a.view, a.filters, snap, new Date())
}
try {
const turn = await runAgentTurn({
complete: (m, t) => chat.complete(m, t),
messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
tools: REGINALD_TOOLS,
execute,
})
return { ok: true as const, content: turn.content, steps: turn.steps }
} catch (e) {
return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
}
})
}

View File

@@ -9,6 +9,14 @@ const api = {
reconcile: () => ipcRenderer.invoke('gitea:reconcile'), reconcile: () => ipcRenderer.invoke('gitea:reconcile'),
/** One issue by index, normalized (or null if unconfigured). */ /** One issue by index, normalized (or null if unconfigured). */
getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index), getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index),
/** Apply an estimate/priority change (the write path); resolves to the plan + fresh issue. */
applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change),
},
model: {
/** Whether a model endpoint is configured (else the UI keeps the scripted Reginald). */
status: () => ipcRenderer.invoke('model:status'),
/** One agent turn: messages in, Reginald's prose + the tools it consulted out. */
chat: (messages: unknown) => ipcRenderer.invoke('model:chat', messages),
}, },
} }

View File

@@ -1,11 +1,12 @@
import React from 'react' import React from 'react'
import { CALIBRATION } from '../../data/fixtures.js' import { CALIBRATION, type CalibrationData } from '../../data/fixtures.js'
import { Badge, Card, Icon } from '../ui/index.js' import { Badge, Card, Icon } from '../ui/index.js'
// Calibration report — estimate-vs-actual evidence behind the cones // Calibration report — estimate-vs-actual evidence behind the cones.
export function CalibrationScreen({ onBack }: { onBack: () => void }) { // `data` (real fit from closed-issue actuals) overrides the demo fixture.
const c = CALIBRATION export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) {
const c = data ?? CALIBRATION
// scatter chart geometry // scatter chart geometry
const W = 420, const W = 420,
@@ -68,7 +69,11 @@ export function CalibrationScreen({ onBack }: { onBack: () => void }) {
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Calibration</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Calibration</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p>
</div> </div>
<Badge tone="ok" dot>curve active · n 20</Badge> {c.active ? (
<Badge tone="ok" dot>curve active · n 20</Badge>
) : (
<Badge tone="warn" dot>cold-start · {c.n}/20</Badge>
)}
</header> </header>
</div> </div>
@@ -170,7 +175,9 @@ export function CalibrationScreen({ onBack }: { onBack: () => void }) {
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span> <span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span>
</div> </div>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with. {c.active
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
: 'Not enough closed history yet — Im forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
</p> </p>
</Card> </Card>
</div> </div>

View File

@@ -109,7 +109,9 @@ export function FocusScreen({
<BurnUpCone data={forecast?.cone} /> <BurnUpCone data={forecast?.cone} />
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}> <p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
{forecast {forecast
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — the cone tightens as the team closes work.` ? forecast.coldStart
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`
: 'The cone has narrowed since Friday. Im quietly pleased.'} : 'The cone has narrowed since Friday. Im quietly pleased.'}
</p> </p>
</Card> </Card>

View File

@@ -1,17 +1,33 @@
// Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right // Issue detail — human intent (gitea) on the left, machine-derived (pm-state) on the right
import React from 'react' import React, { useState } from 'react'
import {
describeChange,
type EstimateLabel,
ESTIMATE_LABELS,
type IssueChange,
planIssueChange,
type PriorityLabel,
PRIORITY_LABELS,
} from '@commitea/core'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
import { Badge, Button, Card, Icon, Tag } from '../ui/index.js' import { Badge, Button, Card, Dialog, Icon, Select, Tag } from '../ui/index.js'
const NONE = '—'
export function IssueScreen({ export function IssueScreen({
issue, issue,
onBack, onBack,
onOpenIssue, onOpenIssue,
canWrite = false,
onApplyChange,
}: { }: {
issue: IssueRef issue: IssueRef
onBack: () => void onBack: () => void
onOpenIssue: (issue: IssueRef) => void onOpenIssue: (issue: IssueRef) => void
canWrite?: boolean
onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }>
}) { }) {
const det: IssueDetail = ISSUE_DETAIL[issue.id] || { const det: IssueDetail = ISSUE_DETAIL[issue.id] || {
state: 'triage', state: 'triage',
@@ -40,6 +56,44 @@ export function IssueScreen({
} as Record<string, { tone: 'warn' | 'neutral' | 'info' | 'ok'; label: string }> } as Record<string, { tone: 'warn' | 'neutral' | 'info' | 'ok'; label: string }>
)[det.state] || { tone: 'neutral', label: det.state } )[det.state] || { tone: 'neutral', label: det.state }
// ---- propose-approve write path (apply_changes) ----
const labels = issue.labels ?? []
const curEstimate = labels.find((l) => l.startsWith('est/')) ?? ''
const curPriority = labels.find((l) => l.startsWith('p/')) ?? ''
const [adjustOpen, setAdjustOpen] = useState(false)
const [estimate, setEstimate] = useState(curEstimate)
const [priority, setPriority] = useState(curPriority)
const [applying, setApplying] = useState(false)
const openAdjust = () => {
setEstimate(curEstimate)
setPriority(curPriority)
setAdjustOpen(true)
}
// the concrete changes this dialog would apply, one per axis that differs
const pendingChanges: IssueChange[] = []
if (estimate !== curEstimate)
pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null })
if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null })
const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c)))
const apply = async () => {
if (!onApplyChange || pendingChanges.length === 0) return
setApplying(true)
try {
for (const c of pendingChanges) await onApplyChange(c)
setAdjustOpen(false)
} finally {
setApplying(false)
}
}
const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))]
const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))]
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* breadcrumb + header */} {/* breadcrumb + header */}
@@ -65,10 +119,69 @@ export function IssueScreen({
</span> </span>
</div> </div>
</div> </div>
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{canWrite ? (
<Button variant="secondary" icon="pencil" onClick={openAdjust}>
Adjust
</Button>
) : null}
<Button variant="secondary" icon="arrow-up-right">Open in Gitea</Button>
</div>
</header> </header>
</div> </div>
<Dialog
open={adjustOpen}
onClose={() => setAdjustOpen(false)}
title="Adjust estimate & priority"
footer={
<>
<Button variant="ghost" onClick={() => setAdjustOpen(false)}>
Cancel
</Button>
<Button onClick={apply} disabled={pendingChanges.length === 0 || applying}>
{applying ? 'Applying…' : 'Apply to gitea'}
</Button>
</>
}
>
<div style={{ display: 'flex', gap: 14 }}>
<Select
label="Estimate"
options={estOptions}
value={estimate}
onChange={(e) => setEstimate(e.target.value)}
/>
<Select
label="Priority"
options={prioOptions}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div style={{ marginTop: 14, minHeight: 40 }}>
{pendingChanges.length === 0 ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-3)', margin: 0 }}>
No change yet pick a different estimate or priority.
</p>
) : (
<>
<p style={{ font: 'var(--text-overline)', letterSpacing: 'var(--letter-spacing-wide)', textTransform: 'uppercase', color: 'var(--ink-3)', margin: '0 0 6px' }}>
Proposed label change
</p>
{diffs.map((d) => (
<div key={d} style={{ font: '500 13px var(--font-mono)', color: 'var(--ink-1)' }}>
{d}
</div>
))}
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '8px 0 0' }}>
Writes the label to gitea and re-runs the plan. Nothing else changes.
</p>
</>
)}
</div>
</Dialog>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16, alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16, alignItems: 'start' }}>
{/* left: human intent */} {/* left: human intent */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>

View File

@@ -8,15 +8,22 @@ import { RUNWAY, CAPACITY } from '../../data/fixtures.js'
export function RunwayScreen({ export function RunwayScreen({
onOpenCalibration, onOpenCalibration,
onOpenMilestone, onOpenMilestone,
calibration,
}: { }: {
onOpenCalibration: () => void onOpenCalibration: () => void
onOpenMilestone: () => void onOpenMilestone: () => void
calibration?: { n: number; coldStart: boolean }
}) { }) {
const calibNote = calibration
? calibration.coldStart
? `cold-start priors · ${calibration.n}/20 closed issues estimated`
: `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}`
: 'calibrated on 27 closed issues'
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}> <header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1> <h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Runway</h1>
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · calibrated on 27 closed issues</p> <p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>capacity vs milestone dates · {calibNote}</p>
</header> </header>
<Card overline="Milestones" flush> <Card overline="Milestones" flush>

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueChange } from '@commitea/core'
import type { IssueRef } from '../../data/fixtures.js' import type { IssueRef } from '../../data/fixtures.js'
import { forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' import { backlogCalibration, forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
import { useBacklog } from '../../lib/use-backlog.js' import { useBacklog } from '../../lib/use-backlog.js'
import { PrimitivesGallery } from '../gallery.js' import { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js' import { BoardScreen } from '../screens/board-screen.js'
@@ -85,11 +87,17 @@ export function AppShell() {
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
const [issue, setIssue] = useState<IssueRef | null>(null) const [issue, setIssue] = useState<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([]) const [readIds, setReadIds] = useState<number[]>([])
const backlog = useBacklog() const [backlog, refetchBacklog] = useBacklog()
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined const boardColumns =
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
const focus =
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
const calibration =
backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined
const forecast = const forecast =
backlog.status === 'ready' ? (forecastBacklog(backlog.issues, backlog.deps) ?? undefined) : undefined backlog.status === 'ready'
? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined)
: undefined
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
@@ -101,6 +109,17 @@ export function AppShell() {
setView('issue') setView('issue')
} }
// The write path: apply through the bridge, reflect the new labels on the open
// issue immediately, and re-reconcile so the board + forecast catch up.
const applyChange = async (change: IssueChange) => {
const res = await window.commitea.gitea.applyChange(change)
if (res.ok) {
setIssue((cur) => (cur && cur.id === res.issue.number ? { ...cur, labels: res.issue.labels } : cur))
refetchBacklog()
}
return res
}
const NAV: NavEntry[] = [ const NAV: NavEntry[] = [
{ id: 'standup', label: 'Standup', icon: 'sun' }, { id: 'standup', label: 'Standup', icon: 'sun' },
{ id: 'focus', label: 'Morning service', icon: 'coffee' }, { id: 'focus', label: 'Morning service', icon: 'coffee' },
@@ -176,10 +195,11 @@ export function AppShell() {
<RunwayScreen <RunwayScreen
onOpenCalibration={() => setView('calibration')} onOpenCalibration={() => setView('calibration')}
onOpenMilestone={() => setView('milestone')} onOpenMilestone={() => setView('milestone')}
calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined}
/> />
) )
case 'calibration': case 'calibration':
return <CalibrationScreen onBack={() => setView('runway')} /> return <CalibrationScreen onBack={() => setView('runway')} data={calibration?.data} />
case 'milestone': case 'milestone':
return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} /> return <MilestoneScreen onBack={() => setView('runway')} onOpenIssue={openIssue} />
case 'inbox': case 'inbox':
@@ -199,7 +219,13 @@ export function AppShell() {
return <SettingsScreen dark={dark} setDark={setDark} /> return <SettingsScreen dark={dark} setDark={setDark} />
case 'issue': case 'issue':
return issue ? ( return issue ? (
<IssueScreen issue={issue} onBack={() => setView(prevView)} onOpenIssue={openIssue} /> <IssueScreen
issue={issue}
onBack={() => setView(prevView)}
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
onApplyChange={applyChange}
/>
) : null ) : null
case 'states': case 'states':
return <StatesScreen onCapture={() => setView('capture')} /> return <StatesScreen onCapture={() => setView('capture')} />

View File

@@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../../data/fixtures.js' import { useChat } from '../../lib/use-chat.js'
import { Icon, IconButton } from '../ui/index.js' import { Icon, IconButton } from '../ui/index.js'
/** /**
* Reginald's panel — chat is the write-path (decisions.md D1). This is the P3-2 * Reginald's panel — chat is the write-path (decisions.md D1). Wired to the
* fixture shell: it echoes a canned reply so the layout + interactions are real, * model bridge via `useChat`: when a model is configured, sending drives a real
* but no model is wired. P4 replaces `send` with the model router + tools. * agent turn (query_project + prose); otherwise it echoes the scripted fixture
* reply so the layout stays real. Writes still go through propose-approve.
*/ */
export interface ChatPanelProps { export interface ChatPanelProps {
onOpenDirectives?: () => void onOpenDirectives?: () => void
@@ -14,9 +15,10 @@ export interface ChatPanelProps {
} }
export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) { export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const [msgs, setMsgs] = useState<ChatMessage[]>(CHAT) const { msgs, thinking, live, model, steps, send: sendChat } = useChat()
// shorten "google/gemma-4-26b-a4b-qat" → "gemma-4-26b" for the header chip
const modelLabel = model ? (model.split('/').pop() ?? model).replace(/-(qat|instruct|it|gguf)$/i, '') : 'gemma-4'
const [text, setText] = useState('') const [text, setText] = useState('')
const [thinking, setThinking] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -27,13 +29,8 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
const send = () => { const send = () => {
const t = text.trim() const t = text.trim()
if (!t) return if (!t) return
setMsgs((m) => [...m, { from: 'user', text: t }])
setText('') setText('')
setThinking(true) sendChat(t)
setTimeout(() => {
setThinking(false)
setMsgs((m) => [...m, { from: 'agent', text: CANNED_REPLY }])
}, 900)
} }
return ( return (
@@ -61,7 +58,7 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
<Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} /> <Icon name="sparkles" size={16} style={{ color: offline ? 'var(--ink-3)' : 'var(--jade)' }} />
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span> <span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>Reginald</span>
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}> <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', marginLeft: 'auto' }}>
{offline ? 'offline · queueing' : 'gemma-4b · local'} {offline ? 'offline · queueing' : live ? `${modelLabel} · local` : 'demo · scripted'}
</span> </span>
<IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} /> <IconButton icon="history" label="Directive log" size="sm" onClick={onOpenDirectives} />
</header> </header>
@@ -98,6 +95,11 @@ export function ChatPanel({ onOpenDirectives, offline }: ChatPanelProps) {
</div> </div>
) : null} ) : null}
{thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null} {thinking ? <div style={{ font: 'var(--text-agent)', color: 'var(--ink-3)' }}>considering</div> : null}
{!thinking && steps.length ? (
<div style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', display: 'flex', alignItems: 'center', gap: 5 }}>
<Icon name="eye" size={11} /> consulted {Array.from(new Set(steps.map((s) => s.replace('query_project', 'the project')))).join(', ')}
</div>
) : null}
</div> </div>
<div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}> <div style={{ padding: 14, borderTop: '1px solid var(--line-1)', flexShrink: 0 }}>

View File

@@ -1,4 +1,18 @@
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' import type {
AgentStep,
ChatMessage,
DependencyEdge,
GiteaIssue,
GiteaMilestone,
IssueChange,
LabelPlan,
LifecycleEvent,
} from '@commitea/core'
/** The result of a write through the bridge. */
export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue }
/** The gitea bridge exposed by the preload over IPC (main-process backed). */ /** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge { export interface GiteaBridge {
@@ -8,8 +22,22 @@ export interface GiteaBridge {
issues: GiteaIssue[] issues: GiteaIssue[]
milestones: GiteaMilestone[] milestones: GiteaMilestone[]
deps: DependencyEdge[] deps: DependencyEdge[]
/** Normalized lifecycle events keyed by issue number. */
timelines: Record<number, LifecycleEvent[]>
}> }>
getIssue(index: number): Promise<GiteaIssue | null> getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult>
}
/** One agent turn's result. */
export type ChatResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| { ok: true; content: string; steps: AgentStep[] }
/** The model bridge (Reginald) exposed by the preload over IPC. */
export interface ModelBridge {
status(): Promise<{ configured: boolean; model: string | null }>
chat(messages: ChatMessage[]): Promise<ChatResult>
} }
declare global { declare global {
@@ -17,6 +45,7 @@ declare global {
commitea: { commitea: {
platform: string platform: string
gitea: GiteaBridge gitea: GiteaBridge
model: ModelBridge
} }
} }
} }

View File

@@ -1,17 +1,33 @@
import { import {
type CalibrationModel,
type CalibrationSample,
calibrationSamples,
COLD_START_THRESHOLD,
type DependencyEdge, type DependencyEdge,
fitCalibration,
forecast, forecast,
type GiteaIssue, type GiteaIssue,
inferColumnV0, inferLifecycle,
type LifecycleColumn, type LifecycleColumn,
type LifecycleEvent,
type LifecycleInference,
PRIOR_BUCKETS,
schedule, schedule,
type ScheduledItem, type ScheduledItem,
selectFocus, selectFocus,
toDurationModel,
} from '@commitea/core' } from '@commitea/core'
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js' import { type BoardColumn, type BoardIssue, type CalibrationData, type FocusIssue } from '../data/fixtures.js'
import { type BurnUpData, buildBurnUpData } from './dates.js' import { type BurnUpData, buildBurnUpData } from './dates.js'
type Timelines = Record<number, LifecycleEvent[]>
/** Infer every issue's lifecycle once; callers index by issue number. */
function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map<number, LifecycleInference> {
return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)]))
}
const COLUMN_LABELS: Record<LifecycleColumn, string> = { const COLUMN_LABELS: Record<LifecycleColumn, string> = {
diagnosis: 'Diagnosis', diagnosis: 'Diagnosis',
triage: 'Triage', triage: 'Triage',
@@ -27,24 +43,32 @@ function initials(login: string): string {
} }
/** /**
* Shape real gitea issues into the Board's five columns via lifecycle-v0. * Shape real gitea issues into the Board's five columns via lifecycle inference
* `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr` * (#5). Steeping / In-review are now populated from the event stream (first
* are likewise event-derived and omitted here. * commit ref → steeping, first PR ref → review); the `days` badge is the
* steeping age in working days.
*/ */
export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] { export function issuesToBoardColumns(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): BoardColumn[] {
const inf = inferAll(issues, timelines, asOf)
return COLUMN_ORDER.map((key) => ({ return COLUMN_ORDER.map((key) => ({
id: key, id: key,
label: COLUMN_LABELS[key], label: COLUMN_LABELS[key],
issues: issues issues: issues
.filter((i) => inferColumnV0(i) === key) .filter((i) => inf.get(i.number)!.column === key)
.map( .map((i): BoardIssue => {
(i): BoardIssue => ({ const li = inf.get(i.number)!
return {
id: i.number, id: i.number,
title: i.title, title: i.title,
labels: i.labels, labels: i.labels,
who: i.assignee ? initials(i.assignee) : '·', who: i.assignee ? initials(i.assignee) : '·',
}), days: li.steepingDays != null ? `${li.steepingDays}d` : undefined,
), }
}),
})) }))
} }
@@ -54,9 +78,16 @@ export interface FocusView {
later: FocusIssue | null later: FocusIssue | null
} }
function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { function toFocusIssue(item: ScheduledItem | null, inf?: Map<number, LifecycleInference>): FocusIssue | null {
if (!item) return null if (!item) return null
return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale } const steepingDays = inf?.get(item.number)?.steepingDays ?? null
return {
id: item.number,
title: item.title,
labels: item.labels,
rationale: item.rationale,
steeping: steepingDays != null ? `${steepingDays}d` : undefined,
}
} }
function toSchedulable(issues: GiteaIssue[]) { function toSchedulable(issues: GiteaIssue[]) {
@@ -76,22 +107,109 @@ export interface ForecastView {
cone: BurnUpData cone: BurnUpData
p80Label: string p80Label: string
rangeLabel: string rangeLabel: string
/** true while the forecast still runs on code priors (calibration not yet trusted). */
coldStart: boolean
/** Closed-with-estimate issues feeding calibration so far. */
calibratedN: number
} }
/** /**
* Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored * Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored
* burn-up cone. Returns null when there's nothing to forecast (no open scope) — * burn-up cone. When a calibration model is supplied and past cold-start, its
* the UI then falls back to the demo cone. `today` is injectable for tests. * fitted params drive the sim. Returns null when there's nothing to forecast.
* `today` is injectable for tests.
*/ */
export function forecastBacklog( export function forecastBacklog(
issues: GiteaIssue[], issues: GiteaIssue[],
deps: DependencyEdge[], deps: DependencyEdge[],
today: Date = new Date(), today: Date = new Date(),
calibration?: CalibrationModel,
): ForecastView | null { ): ForecastView | null {
const f = forecast(toSchedulable(issues), deps) const model = calibration ? toDurationModel(calibration) : undefined
const f = forecast(toSchedulable(issues), deps, model ? { model } : {})
const cone = buildBurnUpData(f, today) const cone = buildBurnUpData(f, today)
if (!cone) return null if (!cone) return null
return { scope: f.scope, cone, p80Label: cone.p80Label, rangeLabel: cone.rangeLabel } return {
scope: f.scope,
cone,
p80Label: cone.p80Label,
rangeLabel: cone.rangeLabel,
coldStart: f.coldStart,
calibratedN: calibration?.n ?? 0,
}
}
/** Fit the calibration model from the closed backlog's inferred actuals (#1). */
export function calibrateBacklog(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): CalibrationModel {
return fitCalibration(calibrationSamples(issues, timelines, asOf))
}
/** Calibration model + its screen view in one pass over the closed backlog. */
export function backlogCalibration(
issues: GiteaIssue[],
timelines: Timelines = {},
asOf: Date = new Date(),
): { model: CalibrationModel; data: CalibrationData } {
const samples = calibrationSamples(issues, timelines, asOf)
const model = fitCalibration(samples)
return { model, data: calibrationData(model, samples, issues) }
}
const pctFromMu = (mu: number) => Math.round((Math.exp(mu) - 1) * 100)
/**
* Shape the calibration model + its samples into the screen's view. Buckets and
* people only earn a bias once their sample clears the fit floor; everything
* degrades honestly on a thin (cold-start) dataset.
*/
export function calibrationData(
model: CalibrationModel,
samples: CalibrationSample[],
openIssues: GiteaIssue[],
): CalibrationData {
const labels = PRIOR_BUCKETS.map((b) => {
const inBucket = samples.filter((s) => s.bucket === b)
const fit = model.byBucket[b]
const mu = fit ? fit.mu : model.global.mu
return {
label: `est/${b}d`,
n: fit ? fit.n : inBucket.length,
median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '—',
bias: fit ? pctFromMu(fit.mu) : null,
}
})
const people = Object.entries(model.byPerson).map(([who, pb]) => ({
who,
n: pb.n,
bias: pctFromMu(model.global.mu + pb.biasMu),
note: '',
}))
const openEst = openIssues
.filter((i) => i.state === 'open')
.reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0)
const effect = model.coldStart
? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '—' }
: {
raw: `${openEst}d estimated`,
banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`,
p50: `${Math.round(openEst * Math.exp(model.global.mu))}d`,
}
return {
n: model.n,
active: !model.coldStart,
labels,
people,
scatter: samples.map((s) => [s.estimateDays, s.actualWorkingDays]),
fit: Number(Math.exp(model.global.mu).toFixed(2)),
effect,
}
} }
/** /**
@@ -99,8 +217,18 @@ export function forecastBacklog(
* as Now/Next/Later. Estimates + priority come from label facts; dependency * as Now/Next/Later. Estimates + priority come from label facts; dependency
* edges come from gitea's native issue dependencies. * edges come from gitea's native issue dependencies.
*/ */
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView { export function scheduleFocus(
issues: GiteaIssue[],
deps: DependencyEdge[],
timelines: Timelines = {},
asOf: Date = new Date(),
): FocusView {
const plan = schedule(toSchedulable(issues), deps) const plan = schedule(toSchedulable(issues), deps)
const f = selectFocus(plan) const f = selectFocus(plan)
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) } const inf = inferAll(issues, timelines, asOf)
return {
now: toFocusIssue(f.now, inf),
next: toFocusIssue(f.next, inf),
later: toFocusIssue(f.later, inf),
}
} }

View File

@@ -1,20 +1,29 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
export type BacklogState = export type BacklogState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'unconfigured' } | { status: 'unconfigured' }
| { status: 'error'; message: string } | { status: 'error'; message: string }
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] } | {
status: 'ready'
issues: GiteaIssue[]
milestones: GiteaMilestone[]
deps: DependencyEdge[]
timelines: Record<number, LifecycleEvent[]>
}
/** /**
* Reconcile the managed repo once on mount, through the main-process bridge. * Reconcile the managed repo through the main-process bridge. Runs once on
* `unconfigured` means no token — the UI falls back to demo fixtures. Errors * mount; the returned `refetch` re-reconciles after a write so the board and
* (network, bad token) surface as `error`. * forecast reflect the change. `unconfigured` means no token — the UI falls
* back to demo fixtures. Errors (network, bad token) surface as `error`.
*/ */
export function useBacklog(): BacklogState { export function useBacklog(): [BacklogState, () => void] {
const [state, setState] = useState<BacklogState>({ status: 'loading' }) const [state, setState] = useState<BacklogState>({ status: 'loading' })
const [nonce, setNonce] = useState(0)
const refetch = useCallback(() => setNonce((n) => n + 1), [])
useEffect(() => { useEffect(() => {
let alive = true let alive = true
@@ -24,7 +33,13 @@ export function useBacklog(): BacklogState {
if (!alive) return if (!alive) return
setState( setState(
r.configured r.configured
? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps } ? {
status: 'ready',
issues: r.issues,
milestones: r.milestones,
deps: r.deps,
timelines: r.timelines,
}
: { status: 'unconfigured' }, : { status: 'unconfigured' },
) )
}) })
@@ -34,7 +49,7 @@ export function useBacklog(): BacklogState {
return () => { return () => {
alive = false alive = false
} }
}, []) }, [nonce])
return state return [state, refetch]
} }

View File

@@ -0,0 +1,105 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ChatMessage as WireMessage } from '@commitea/core'
import { CANNED_REPLY, CHAT, type ChatMessage } from '../data/fixtures.js'
const LIVE_GREETING: ChatMessage = {
from: 'agent',
text: 'Morning. Ask me anything about the project — I check the real board before I answer.',
}
export interface ChatState {
msgs: ChatMessage[]
thinking: boolean
/** true once a model endpoint is confirmed; otherwise the panel echoes the demo reply. */
live: boolean
/** The loaded model's id when live (for the header). */
model: string | null
/** Tools Reginald consulted on the last turn (for a subtle activity line). */
steps: string[]
send: (text: string) => void
}
/**
* Reginald's conversation. When a model is configured, `send` drives one agent
* turn through the main-process bridge (which runs the tool loop). Otherwise it
* echoes the scripted fixture reply, so the layout stays real with no model and
* fixture e2e is unaffected. The fixture greeting is display-only — only real
* turns (`convo`) are sent to the model as history.
*/
export function useChat(): ChatState {
const [seed, setSeed] = useState<ChatMessage[]>(CHAT)
const [convo, setConvo] = useState<ChatMessage[]>([])
const [thinking, setThinking] = useState(false)
const [live, setLive] = useState(false)
const [model, setModel] = useState<string | null>(null)
const [steps, setSteps] = useState<string[]>([])
const convoRef = useRef(convo)
convoRef.current = convo
useEffect(() => {
let alive = true
window.commitea.model
.status()
.then((s) => {
if (alive && s.configured) {
setLive(true)
setModel(s.model)
setSeed([LIVE_GREETING])
}
})
.catch(() => {})
return () => {
alive = false
}
}, [])
const send = useCallback(
(raw: string) => {
const text = raw.trim()
if (!text) return
const nextConvo: ChatMessage[] = [...convoRef.current, { from: 'user', text }]
setConvo(nextConvo)
setThinking(true)
setSteps([])
if (!live) {
window.setTimeout(() => {
setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: CANNED_REPLY }])
}, 900)
return
}
const wire: WireMessage[] = nextConvo.map((m) => ({
role: m.from === 'user' ? 'user' : 'assistant',
content: m.text,
}))
window.commitea.model
.chat(wire)
.then((res) => {
setThinking(false)
if (res.ok) {
setSteps(res.steps.map((s) => s.tool))
setConvo((c) => [...c, { from: 'agent', text: res.content || '…' }])
} else {
setConvo((c) => [
...c,
{
from: 'agent',
text: res.reason === 'error' ? `I hit a snag: ${res.message ?? 'unknown error'}` : 'No model is configured.',
},
])
}
})
.catch(() => {
setThinking(false)
setConvo((c) => [...c, { from: 'agent', text: 'I could not reach the model.' }])
})
},
[live],
)
return { msgs: [...seed, ...convo], thinking, live, model, steps, send }
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { FetchLike, GiteaIssue } from '../gitea/types.js'
import { runAgentTurn } from './agent-loop.js'
import { REGINALD_SYSTEM, REGINALD_TOOLS } from './agent-tools.js'
import { createChatClient } from './chat-client.js'
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
/**
* Opt-in (COMMITEA_MODEL_LIVE=1). Drives the real chat client + agent loop
* against a local OpenAI-compatible server (LM Studio on :1234 by default),
* proving the model calls query_project and narrates the real result.
*/
const LIVE = !!process.env.COMMITEA_MODEL_LIVE
const BASE = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
const MODEL = process.env.COMMITEA_MODEL_SMALL ?? 'google/gemma-4-e4b'
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1, title: '#1', body: '', state: 'open', labels, facts: extractLabelFacts(labels),
milestone: null, assignee: null, assignees: [], createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-05T09:00:00Z', closedAt: null, url: '', ...over,
}
}
const SNAP: ProjectSnapshot = {
issues: [
issue({ number: 2, title: 'ChangeSource interface + polling source', labels: ['est/3d', 'p/1'] }),
issue({ number: 3, title: 'SQLite cache bootstrap', labels: ['est/2d', 'p/1'] }),
],
timelines: {},
deps: [{ issue: 3, dependsOn: 2 }],
}
describe('agent loop (live model)', () => {
it.skipIf(!LIVE)(
'calls query_project and narrates the real focus',
async () => {
const client = createChatClient({ baseUrl: BASE, model: MODEL }, globalThis.fetch as unknown as FetchLike)
const turn = await runAgentTurn({
complete: (m, t) => client.complete(m, t),
messages: [
{ role: 'system', content: REGINALD_SYSTEM },
{ role: 'user', content: 'What should I work on right now?' },
],
tools: REGINALD_TOOLS,
execute: async (name, args) =>
name === 'query_project'
? buildProjectView((args as { view: any }).view, (args as any).filters, SNAP, new Date())
: { error: `unknown tool ${name}` },
})
// it consulted the project, then answered in prose about the real top item (#2)
expect(turn.steps.some((s) => s.tool === 'query_project')).toBe(true)
expect(turn.content.trim().length).toBeGreaterThan(0)
expect(turn.content).toMatch(/#?2\b|ChangeSource/i)
},
60_000,
)
})

View File

@@ -0,0 +1,69 @@
/**
* The agent turn loop. Given a model `complete` fn, the conversation, the tool
* declarations, and an `execute` that actually runs a tool, it drives the
* call→tool→result→call cycle until the model answers in prose (or a step
* budget is hit). Pure orchestration with injected I/O — the model and the tool
* executor are both stubbable, so the loop is fully unit-testable offline.
*/
import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js'
/** A tool the loop ran, with the raw args and its stringified result — for the UI's activity trail. */
export interface AgentStep {
tool: string
arguments: string
result: string
}
export type ToolExecutor = (name: string, args: unknown) => Promise<unknown>
export interface AgentTurn {
content: string
steps: AgentStep[]
/** The full conversation including this turn's assistant/tool messages. */
messages: ChatMessage[]
}
const DEFAULT_MAX_STEPS = 4
function stringify(result: unknown): string {
return typeof result === 'string' ? result : JSON.stringify(result)
}
export async function runAgentTurn(opts: {
complete: (messages: ChatMessage[], tools?: ToolDecl[]) => Promise<CompletionResult>
messages: ChatMessage[]
tools: ToolDecl[]
execute: ToolExecutor
maxSteps?: number
}): Promise<AgentTurn> {
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS
const convo: ChatMessage[] = [...opts.messages]
const steps: AgentStep[] = []
for (let step = 0; step < maxSteps; step++) {
const { content, toolCalls } = await opts.complete(convo, opts.tools)
if (toolCalls.length === 0) {
convo.push({ role: 'assistant', content })
return { content, steps, messages: convo }
}
convo.push({ role: 'assistant', content, toolCalls })
for (const tc of toolCalls) {
let result: unknown
try {
const args = tc.arguments ? JSON.parse(tc.arguments) : {}
result = await opts.execute(tc.name, args)
} catch (e) {
result = { error: e instanceof Error ? e.message : String(e) }
}
const resultStr = stringify(result)
steps.push({ tool: tc.name, arguments: tc.arguments, result: resultStr })
convo.push({ role: 'tool', toolCallId: tc.id, name: tc.name, content: resultStr })
}
}
// Out of tool budget — force a final prose answer with tools withheld.
const final = await opts.complete(convo, [])
convo.push({ role: 'assistant', content: final.content })
return { content: final.content, steps, messages: convo }
}

View File

@@ -0,0 +1,46 @@
/**
* Reginald's tool surface. v0 ships the one read tool (`query_project`); the
* three write tools (capture_work, apply_changes, record_directive) layer on
* later against the same loop. Few, fat tools so a small local model survives
* with one thing to reach for (docs/agent-tools.md).
*/
import type { ToolDecl } from './chat-client.js'
export const QUERY_PROJECT_TOOL: ToolDecl = {
name: 'query_project',
description:
'Read the current project state. A `view` selects the shape; deterministic code (scheduler, ' +
'lifecycle inference, calibration) backs every number — you report it, you never compute it.',
parameters: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['focus', 'board', 'calibration', 'issue', 'search'],
description:
'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' +
'issue = one issue (needs filters.issueId); search = issues matching filters.query.',
},
filters: {
type: 'object',
properties: {
issueId: { type: 'number' },
query: { type: 'string' },
limit: { type: 'number' },
},
},
},
required: ['view'],
},
}
export const REGINALD_TOOLS: ToolDecl[] = [QUERY_PROJECT_TOOL]
export const REGINALD_SYSTEM = [
'You are Reginald, the calm, dry project manager inside CommiTea — a tool that runs projects on Gitea.',
'Call query_project to ground every answer in the real project; never invent issues, numbers, or dates.',
'The scheduler and forecasts are deterministic code — report their output, do not recompute it.',
'Forecasts are ranges, never single dates. Refer to issues as #<number>.',
'Be brief and plain. A sentence or two is usually enough. No preamble, no bullet-point dumps.',
].join(' ')

View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { GiteaIssue } from '../gitea/types.js'
import type { FetchLike } from '../gitea/types.js'
import { type ChatMessage, type CompletionResult, createChatClient, type ToolDecl } from './chat-client.js'
import { runAgentTurn } from './agent-loop.js'
import { pickModel } from './model-router.js'
import { buildProjectView, type ProjectSnapshot } from './query-project.js'
// ---- chat client ----
describe('createChatClient', () => {
function stub(body: unknown) {
const calls: { url: string; body: unknown }[] = []
const fetch: FetchLike = (url, init) => {
calls.push({ url, body: init?.body ? JSON.parse(init.body) : undefined })
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(''),
})
}
return { fetch, calls }
}
it('POSTs to /chat/completions and parses content', async () => {
const { fetch, calls } = stub({ choices: [{ message: { content: 'the focus is #2' } }] })
const client = createChatClient({ baseUrl: 'http://localhost:1234/v1', model: 'gemma' }, fetch)
const res = await client.complete([{ role: 'user', content: 'what now?' }])
expect(res.content).toBe('the focus is #2')
expect(res.toolCalls).toEqual([])
expect(calls[0].url).toBe('http://localhost:1234/v1/chat/completions')
expect((calls[0].body as { model: string }).model).toBe('gemma')
})
it('parses tool calls from the wire shape', async () => {
const { fetch } = stub({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'c1', function: { name: 'query_project', arguments: '{"view":"focus"}' } }],
},
},
],
})
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const res = await client.complete([{ role: 'user', content: 'x' }], [{ name: 'query_project', description: '', parameters: {} }])
expect(res.toolCalls).toEqual([{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }])
})
it('serializes assistant tool_calls + tool results in the request', async () => {
const { fetch, calls } = stub({ choices: [{ message: { content: 'ok' } }] })
const client = createChatClient({ baseUrl: 'http://x/v1', model: 'm' }, fetch)
const convo: ChatMessage[] = [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
{ role: 'tool', toolCallId: 'c1', name: 'query_project', content: '{"now":null}' },
]
await client.complete(convo)
const sent = (calls[0].body as { messages: Record<string, unknown>[] }).messages
expect(sent[1].tool_calls).toBeDefined()
expect(sent[2]).toEqual({ role: 'tool', tool_call_id: 'c1', content: '{"now":null}' })
})
})
// ---- model router ----
describe('pickModel', () => {
const router = { small: { baseUrl: 'u', model: 'small' }, big: { baseUrl: 'u', model: 'big' } }
it('routes plan → big, everything else → small', () => {
expect(pickModel(router, 'plan').model).toBe('big')
expect(pickModel(router, 'ritual').model).toBe('small')
})
})
// ---- agent loop ----
describe('runAgentTurn', () => {
const tools: ToolDecl[] = [{ name: 'query_project', description: '', parameters: {} }]
it('runs a tool then returns the model prose, recording the step', async () => {
// first completion asks for a tool; second answers in prose
const scripted: CompletionResult[] = [
{ content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{"view":"focus"}' }] },
{ content: 'Right now: #2.', toolCalls: [] },
]
let i = 0
const executed: { name: string; args: unknown }[] = []
const turn = await runAgentTurn({
complete: async () => scripted[i++],
messages: [{ role: 'user', content: 'what now?' }],
tools,
execute: async (name, args) => {
executed.push({ name, args })
return { now: { issue: 2 } }
},
})
expect(turn.content).toBe('Right now: #2.')
expect(turn.steps).toEqual([
{ tool: 'query_project', arguments: '{"view":"focus"}', result: '{"now":{"issue":2}}' },
])
expect(executed).toEqual([{ name: 'query_project', args: { view: 'focus' } }])
// conversation carries user → assistant(tool) → tool → assistant(prose)
expect(turn.messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant'])
})
it('answers directly when the model needs no tool', async () => {
const turn = await runAgentTurn({
complete: async () => ({ content: 'Hello.', toolCalls: [] }),
messages: [{ role: 'user', content: 'hi' }],
tools,
execute: async () => ({}),
})
expect(turn.content).toBe('Hello.')
expect(turn.steps).toEqual([])
})
it('captures a tool executor error as the tool result instead of throwing', async () => {
const scripted: CompletionResult[] = [
{ content: '', toolCalls: [{ id: 'c1', name: 'query_project', arguments: '{}' }] },
{ content: 'Something went wrong reading that.', toolCalls: [] },
]
let i = 0
const turn = await runAgentTurn({
complete: async () => scripted[i++],
messages: [{ role: 'user', content: 'x' }],
tools,
execute: async () => {
throw new Error('boom')
},
})
expect(turn.steps[0].result).toContain('boom')
expect(turn.content).toBe('Something went wrong reading that.')
})
it('forces a final prose answer when the step budget is exhausted', async () => {
// always asks for a tool; the loop must still terminate with prose
let toolRounds = 0
const turn = await runAgentTurn({
complete: async (_m, tls) => {
if (tls && tls.length) {
toolRounds++
return { content: '', toolCalls: [{ id: `c${toolRounds}`, name: 'query_project', arguments: '{}' }] }
}
return { content: 'final answer', toolCalls: [] } // called with tools withheld
},
messages: [{ role: 'user', content: 'x' }],
tools,
execute: async () => ({}),
maxSteps: 2,
})
expect(toolRounds).toBe(2)
expect(turn.content).toBe('final answer')
})
})
// ---- query_project view builder ----
describe('buildProjectView', () => {
const asOf = new Date('2026-02-01T00:00:00Z')
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1,
title: '#1',
body: '',
state: 'open',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: null,
assignees: [],
createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-05T09:00:00Z',
closedAt: null,
url: '',
...over,
}
}
const snap: ProjectSnapshot = {
issues: [
issue({ number: 2, title: 'ChangeSource', labels: ['est/3d', 'p/1'] }),
issue({ number: 3, title: 'SQLite cache', labels: ['est/2d', 'p/1'] }),
issue({ number: 9, title: 'Done thing', state: 'closed', labels: ['est/2d'], closedAt: '2026-01-12T09:00:00Z' }),
],
timelines: { 9: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }] },
deps: [{ issue: 3, dependsOn: 2 }],
}
it('focus returns Now/Next/Later grounded in the scheduler', () => {
const v = buildProjectView('focus', undefined, snap, asOf) as {
now: { issue: number } | null
openCount: number
}
expect(v.now?.issue).toBe(2) // #3 depends on #2, so #2 comes first
expect(v.openCount).toBe(2)
})
it('board groups issues by lifecycle column', () => {
const v = buildProjectView('board', undefined, snap, asOf) as {
columns: { column: string; count: number }[]
}
const done = v.columns.find((c) => c.column === 'done')
expect(done?.count).toBe(1) // #9
const triage = v.columns.find((c) => c.column === 'triage')
expect(triage?.count).toBe(2) // #2, #3 labelled, no commits yet
})
it('calibration reports n + cold-start honestly', () => {
const v = buildProjectView('calibration', undefined, snap, asOf) as { n: number; coldStart: boolean }
expect(v.n).toBe(1) // one closed, estimated, with an actual
expect(v.coldStart).toBe(true)
})
it('issue returns intent + derived for a specific id', () => {
const v = buildProjectView('issue', { issueId: 3 }, snap, asOf) as {
issue: number
blockedBy: number[]
}
expect(v.issue).toBe(3)
expect(v.blockedBy).toEqual([2])
})
it('search matches title/labels', () => {
const v = buildProjectView('search', { query: 'sqlite' }, snap, asOf) as { results: { issue: number }[] }
expect(v.results.map((r) => r.issue)).toEqual([3])
})
it('unbuilt views return a notImplemented marker, not fabricated data', () => {
expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' })
})
})

View File

@@ -0,0 +1,123 @@
/**
* Reginald's model client — a thin OpenAI-wire chat-completions client over an
* injected fetch (the same seam the gitea client uses). `@commitea/core` stays
* pure: no network, no globals. The desktop main process passes the real
* `fetch`; tests pass a stub. Points at any OpenAI-compatible endpoint (LM
* Studio, Ollama, the OpenAI API) — the model router picks which.
*/
import type { FetchLike } from '../gitea/types.js'
/** Where a model lives + which model to ask for. */
export interface ModelConfig {
/** OpenAI-compatible base, including the version segment, e.g. `http://localhost:1234/v1`. */
baseUrl: string
model: string
/** Bearer key; omitted for local servers that don't check it. */
apiKey?: string
}
/** A model's request to run a tool. `arguments` is a raw JSON string (OpenAI shape). */
export interface ToolCall {
id: string
name: string
arguments: string
}
/** One turn in the conversation, in our normalized shape. */
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string
/** assistant turns that requested tools. */
toolCalls?: ToolCall[]
/** tool turns: which call they answer. */
toolCallId?: string
/** tool turns: the function name. */
name?: string
}
/** A tool the model may call — name, description, and a JSON-Schema parameter spec. */
export interface ToolDecl {
name: string
description: string
parameters: object
}
export interface CompletionResult {
content: string
toolCalls: ToolCall[]
}
export interface ChatClient {
complete(messages: ChatMessage[], tools?: ToolDecl[]): Promise<CompletionResult>
}
/** Map our message shape to the OpenAI wire shape. */
function toWireMessage(m: ChatMessage): Record<string, unknown> {
if (m.role === 'assistant' && m.toolCalls?.length) {
return {
role: 'assistant',
content: m.content || null,
tool_calls: m.toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.arguments },
})),
}
}
if (m.role === 'tool') {
return { role: 'tool', tool_call_id: m.toolCallId, content: m.content }
}
return { role: m.role, content: m.content }
}
function toWireTool(t: ToolDecl): Record<string, unknown> {
return { type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } }
}
/** Shape of the one choice we read back. */
interface RawChoiceMessage {
content: string | null
tool_calls?: { id: string; function: { name: string; arguments: string } }[]
}
export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): ChatClient {
const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`
return {
async complete(messages, tools) {
const body: Record<string, unknown> = {
model: config.model,
messages: messages.map(toWireMessage),
temperature: 0,
}
if (tools?.length) {
body.tools = tools.map(toWireTool)
body.tool_choice = 'auto'
}
const res = await fetchImpl(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`)
}
const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] }
const msg = json.choices?.[0]?.message
return {
content: msg?.content ?? '',
toolCalls: (msg?.tool_calls ?? []).map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: tc.function.arguments,
})),
}
},
}
}

View File

@@ -0,0 +1,22 @@
/**
* Model router (per PLAN.md / decisions.md): prose + the read tool run on a
* small local model; decomposition/negotiation earns the big one. Reginald v0
* only exercises the small model (read + prose); the `big` slot is declared so
* `capture_work` / `record_directive` can route to it without a rewrite.
*/
import type { ModelConfig } from './chat-client.js'
export type TaskKind = 'ritual' | 'plan'
export interface ModelRouter {
/** Small local model — standups, focus prose, the read tool. */
small: ModelConfig
/** Big model — capture decomposition, directive negotiation. */
big: ModelConfig
}
/** `plan` → the big model; everything else → the small one. */
export function pickModel(router: ModelRouter, kind: TaskKind): ModelConfig {
return kind === 'plan' ? router.big : router.small
}

View File

@@ -0,0 +1,146 @@
/**
* `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 }
}
}

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { extractLabelFacts } from '../labels/label-schema.js'
import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
import {
CALIBRATION_BUCKET_FLOOR,
calibrationSamples,
type CalibrationSample,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration-v0.js'
function sample(over: Partial<CalibrationSample> = {}): CalibrationSample {
return { issue: 1, estimateDays: 2, actualWorkingDays: 2, bucket: 2, person: null, ...over }
}
describe('fitCalibration', () => {
it('is cold-start below the threshold and reports the honest n', () => {
const m = fitCalibration([sample(), sample({ actualWorkingDays: 4 })])
expect(m.n).toBe(2)
expect(m.coldStart).toBe(true)
})
it('flips off cold-start at the threshold', () => {
const many = Array.from({ length: COLD_START_THRESHOLD }, (_, i) =>
sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }),
)
const m = fitCalibration(many)
expect(m.n).toBe(COLD_START_THRESHOLD)
expect(m.coldStart).toBe(false)
})
it('recovers the global median ratio (mu = mean log-ratio)', () => {
// every actual is exactly 2x its estimate → mu = ln 2
const m = fitCalibration(Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 4 })))
expect(m.global.mu).toBeCloseTo(Math.log(2), 6)
})
it('fits a bucket only once it clears the floor', () => {
const twos = Array.from({ length: CALIBRATION_BUCKET_FLOOR }, (_, i) =>
sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }),
)
const oneThin = [sample({ issue: 99, estimateDays: 5, actualWorkingDays: 9, bucket: 5 })]
const m = fitCalibration([...twos, ...oneThin])
expect(m.byBucket[2]?.n).toBe(CALIBRATION_BUCKET_FLOOR)
expect(m.byBucket[5]).toBeUndefined() // only 1 sample, below floor
})
it('drops non-positive estimates/actuals', () => {
const m = fitCalibration([sample({ actualWorkingDays: 0 }), sample({ estimateDays: 0 }), sample()])
expect(m.n).toBe(1)
})
it('derives a per-person bias relative to global', () => {
// one person consistently runs longer than the mean
const base = Array.from({ length: 20 }, (_, i) => sample({ issue: i, actualWorkingDays: 2, person: 'ak' }))
const slow = Array.from({ length: 3 }, (_, i) => sample({ issue: 100 + i, actualWorkingDays: 6, person: 'sm' }))
const m = fitCalibration([...base, ...slow])
expect(m.byPerson['sm'].biasMu).toBeGreaterThan(0)
expect(m.byPerson['ak'].biasMu).toBeLessThan(0)
})
})
describe('toDurationModel', () => {
it('projects the fit down to the params forecast needs', () => {
const m = fitCalibration(
Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 })),
)
const dm = toDurationModel(m)
expect(dm.coldStart).toBe(false)
expect(dm.byBucket[2].mu).toBeCloseTo(m.byBucket[2].mu, 6)
expect(dm.global.mu).toBeCloseTo(m.global.mu, 6)
})
})
describe('calibrationSamples', () => {
const asOf = new Date('2026-02-01T00:00:00Z')
function issue(over: Partial<GiteaIssue>): GiteaIssue {
const labels = over.labels ?? []
return {
number: 1,
title: '#1',
body: '',
state: 'closed',
labels,
facts: extractLabelFacts(labels),
milestone: null,
assignee: null,
assignees: [],
createdAt: '2026-01-05T09:00:00Z',
updatedAt: '2026-01-12T09:00:00Z',
closedAt: '2026-01-12T09:00:00Z',
url: '',
...over,
}
}
const events = (i: number): Record<number, LifecycleEvent[]> => ({
[i]: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }],
})
it('samples closed, estimated issues with a resolvable actual', () => {
const i = issue({ number: 7, labels: ['est/2d'], assignee: 'sm', closedAt: '2026-01-12T09:00:00Z' })
const [s] = calibrationSamples([i], events(7), asOf)
expect(s.issue).toBe(7)
expect(s.estimateDays).toBe(2)
expect(s.bucket).toBe(2)
expect(s.person).toBe('sm')
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
expect(s.actualWorkingDays).toBe(3)
})
it('skips open issues and closed ones without an estimate', () => {
const open = issue({ number: 8, state: 'open', labels: ['est/2d'], closedAt: null })
const noEst = issue({ number: 9, labels: [] })
expect(calibrationSamples([open, noEst], { ...events(8), ...events(9) }, asOf)).toEqual([])
})
})

View File

@@ -0,0 +1,127 @@
/**
* Calibration, v0 — fit the team's own estimate-vs-actual history so the
* forecast stops guessing (D3). The "actual" is the working time lifecycle
* inference derives from git events (#5), never manual tracking. Fit a
* lognormal on log(actual / estimate) globally and per estimate bucket; until
* the sample clears the cold-start threshold, the forecast keeps using the
* code-resident priors and this model just reports progress toward it.
*/
import { type DurationModel, type LognormalPrior, nearestBucket } from '../forecast/forecast-v0.js'
import { inferLifecycle, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js'
import type { GiteaIssue } from '../gitea/types.js'
/** Global sample size at which the fit takes over from the cold-start priors. */
export const COLD_START_THRESHOLD = 20
/** Minimum per-bucket sample before that bucket earns its own fit. */
export const CALIBRATION_BUCKET_FLOOR = 3
/** Fallback spread when a group is too small to estimate one. */
const DEFAULT_SIGMA = 0.4
/** One closed issue's estimate vs its inferred actual. */
export interface CalibrationSample {
issue: number
estimateDays: number
actualWorkingDays: number
bucket: number
person: string | null
}
export interface BucketFit extends LognormalPrior {
n: number
}
export interface PersonBias {
/** Additive to global mu (log space). */
biasMu: number
n: number
}
export interface CalibrationModel {
/** Closed issues with an estimate + a resolvable actual. */
n: number
/** true while n < COLD_START_THRESHOLD — forecast keeps the code priors. */
coldStart: boolean
global: LognormalPrior
byBucket: Record<number, BucketFit>
byPerson: Record<string, PersonBias>
}
function mean(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0) / xs.length
}
/** Sample standard deviation; falls back to DEFAULT_SIGMA below 2 points. */
function stddev(xs: number[], mu: number): number {
if (xs.length < 2) return DEFAULT_SIGMA
const variance = xs.reduce((a, x) => a + (x - mu) ** 2, 0) / (xs.length - 1)
return Math.sqrt(variance) || DEFAULT_SIGMA
}
/** Fit a calibration model from estimate-vs-actual samples. Pure. */
export function fitCalibration(samples: CalibrationSample[]): CalibrationModel {
const usable = samples.filter((s) => s.estimateDays > 0 && s.actualWorkingDays > 0)
const n = usable.length
const coldStart = n < COLD_START_THRESHOLD
const logRatios = usable.map((s) => Math.log(s.actualWorkingDays / s.estimateDays))
const globalMu = n ? mean(logRatios) : 0
const global: LognormalPrior = { mu: globalMu, sigma: n ? stddev(logRatios, globalMu) : DEFAULT_SIGMA }
const byBucket: Record<number, BucketFit> = {}
const byPerson: Record<string, PersonBias> = {}
const groups = new Map<number, number[]>()
const people = new Map<string, number[]>()
for (const s of usable) {
const lr = Math.log(s.actualWorkingDays / s.estimateDays)
;(groups.get(s.bucket) ?? groups.set(s.bucket, []).get(s.bucket)!).push(lr)
if (s.person) (people.get(s.person) ?? people.set(s.person, []).get(s.person)!).push(lr)
}
for (const [bucket, lrs] of groups) {
if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue
const mu = mean(lrs)
byBucket[bucket] = { mu, sigma: stddev(lrs, mu), n: lrs.length }
}
for (const [person, lrs] of people) {
if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue
byPerson[person] = { biasMu: mean(lrs) - globalMu, n: lrs.length }
}
return { n, coldStart, global, byBucket, byPerson }
}
/** The subset of a model `forecast` consumes. */
export function toDurationModel(model: CalibrationModel): DurationModel {
const byBucket: Record<number, LognormalPrior> = {}
for (const [bucket, fit] of Object.entries(model.byBucket)) {
byBucket[Number(bucket)] = { mu: fit.mu, sigma: fit.sigma }
}
return { coldStart: model.coldStart, global: model.global, byBucket }
}
/**
* Extract calibration samples from the closed backlog: each closed issue that
* carries an estimate and yields an inferred actual working duration.
*/
export function calibrationSamples(
issues: GiteaIssue[],
timelines: Record<number, LifecycleEvent[]>,
asOf: Date,
): CalibrationSample[] {
const out: CalibrationSample[] = []
for (const issue of issues) {
if (issue.state !== 'closed') continue
const estimateDays = issue.facts.estimateDays
if (estimateDays == null) continue
const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf)
if (inf.actualWorkingDays == null || inf.actualWorkingDays <= 0) continue
out.push({
issue: issue.number,
estimateDays,
actualWorkingDays: inf.actualWorkingDays,
bucket: nearestBucket(estimateDays),
person: issue.assignee,
})
}
return out
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange } from './apply-changes-v0.js'
describe('planIssueChange', () => {
it('swaps the estimate label, keeping non-axis labels', () => {
const plan = planIssueChange(['est/2d', 'p/1', 'backend'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' })
expect(plan.removed).toEqual(['est/2d'])
expect(plan.added).toEqual(['est/5d'])
expect(plan.labels).toEqual(['p/1', 'backend', 'est/5d'])
expect(plan.noop).toBe(false)
})
it('adds an estimate when none was set', () => {
const plan = planIssueChange(['p/2'], { kind: 'reestimate', issue: 1, estimate: 'est/1d' })
expect(plan.removed).toEqual([])
expect(plan.added).toEqual(['est/1d'])
expect(plan.labels).toEqual(['p/2', 'est/1d'])
})
it('clears the axis when the target is null', () => {
const plan = planIssueChange(['est/3d', 'p/1'], { kind: 'reprioritize', issue: 1, priority: null })
expect(plan.removed).toEqual(['p/1'])
expect(plan.added).toEqual([])
expect(plan.labels).toEqual(['est/3d'])
})
it('is a noop when the target already holds the axis alone', () => {
const plan = planIssueChange(['est/2d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/2d' })
expect(plan.noop).toBe(true)
expect(plan.labels).toEqual(['p/1', 'est/2d'])
})
it('cleans up a duplicated axis down to the target', () => {
// two est/* labels — the change collapses to one
const plan = planIssueChange(['est/2d', 'est/5d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' })
expect(plan.removed).toEqual(['est/2d'])
expect(plan.added).toEqual([]) // est/5d already present
expect(plan.labels).toEqual(['p/1', 'est/5d'])
expect(plan.noop).toBe(false)
})
it('reprioritize only touches the priority axis', () => {
const plan = planIssueChange(['est/2d', 'p/3'], { kind: 'reprioritize', issue: 1, priority: 'p/1' })
expect(plan.labels).toEqual(['est/2d', 'p/1'])
})
it('describeChange renders the diff', () => {
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
expect(describeChange(planIssueChange(['est/2d'], change))).toBe('est/2d → est/5d')
expect(describeChange(planIssueChange(['p/1'], { kind: 'reprioritize', issue: 1, priority: null }))).toBe('p/1 → ∅')
expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change')
})
})

View File

@@ -0,0 +1,59 @@
/**
* Apply-changes, v0 — the write path's planning half. Estimates and priority
* live as exclusive label axes (`est/*`, `p/*`); a change swaps the axis label.
* This computes the resulting label set + a human-readable diff *purely*, so the
* UI can show a propose-approve consequence before the write and tests can pin
* the semantics. The actual PUT (name→id resolution + network) is the bridge's
* job — additive here, never assumed.
*/
import {
type EstimateLabel,
ESTIMATE_LABELS,
type PriorityLabel,
PRIORITY_LABELS,
} from '../labels/label-schema.js'
export type IssueChange =
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null }
export interface LabelPlan {
/** The full resulting label-name set (order: kept labels, then the new axis label). */
labels: string[]
/** Axis labels being added (0 or 1). */
added: string[]
/** Axis labels being removed (includes clearing a duplicated axis). */
removed: string[]
/** true when the change would leave the labels unchanged. */
noop: boolean
}
function axisFor(change: IssueChange): { labels: readonly string[]; target: string | null } {
return change.kind === 'reestimate'
? { labels: ESTIMATE_LABELS, target: change.estimate }
: { labels: PRIORITY_LABELS, target: change.priority }
}
/**
* Plan the label mutation for a single change. Removes every label on the
* change's axis except the target, adds the target if absent. Setting the axis
* to null clears it. Cleans up a duplicated axis (two `est/*`) as a side effect.
*/
export function planIssueChange(current: string[], change: IssueChange): LabelPlan {
const { labels: axis, target } = axisFor(change)
const onAxis = current.filter((l) => axis.includes(l))
const removed = onAxis.filter((l) => l !== target)
const added = target && !current.includes(target) ? [target] : []
const kept = current.filter((l) => !axis.includes(l))
const labels = target ? [...kept, target] : kept
return { labels, added, removed, noop: added.length === 0 && removed.length === 0 }
}
/** A short "est/2d → est/3d" (or "+p/1" / "est/5d") summary for the confirm UI. */
export function describeChange(plan: LabelPlan): string {
if (plan.noop) return 'no change'
const from = plan.removed.length ? plan.removed.join(', ') : '∅'
const to = plan.added.length ? plan.added.join(', ') : '∅'
return `${from}${to}`
}

View File

@@ -97,4 +97,27 @@ describe('forecast', () => {
expect(f.scope).toBe(2) expect(f.scope).toBe(2)
expect(f.p50Day).toBeGreaterThan(0) expect(f.p50Day).toBeGreaterThan(0)
}) })
it('a cold-start model changes nothing — the code priors still drive it', () => {
const priors = forecast(scope, [], { trials: 1000, seed: 7 })
const cold = forecast(scope, [], {
trials: 1000,
seed: 7,
model: { coldStart: true, global: { mu: 5, sigma: 0.1 }, byBucket: {} },
})
expect(cold.coldStart).toBe(true)
expect(cold.p50Day).toBeCloseTo(priors.p50Day, 6)
})
it('a fitted model drives the sim once past cold-start', () => {
// an optimistic fit (mu < 0, tight sigma) should land the scope sooner than the pessimistic priors
const priors = forecast(scope, [], { trials: 2000, seed: 7 })
const fitted = forecast(scope, [], {
trials: 2000,
seed: 7,
model: { coldStart: false, global: { mu: -0.2, sigma: 0.1 }, byBucket: {} },
})
expect(fitted.coldStart).toBe(false)
expect(fitted.p50Day).toBeLessThan(priors.p50Day)
})
}) })

View File

@@ -41,15 +41,29 @@ export const COLD_START_PRIORS: Record<number, LognormalPrior> = {
8: { mu: 0.16, sigma: 0.36 }, 8: { mu: 0.16, sigma: 0.36 },
} }
const PRIOR_BUCKETS = [1, 2, 3, 5, 8] export const PRIOR_BUCKETS = [1, 2, 3, 5, 8]
/** Nearest estimate bucket (ties resolve to the smaller bucket). */ /** Nearest estimate bucket (ties resolve to the smaller bucket). */
export function priorForEstimate(days: number): LognormalPrior { export function nearestBucket(days: number): number {
let best = PRIOR_BUCKETS[0] let best = PRIOR_BUCKETS[0]
for (const b of PRIOR_BUCKETS) { for (const b of PRIOR_BUCKETS) {
if (Math.abs(b - days) < Math.abs(best - days)) best = b if (Math.abs(b - days) < Math.abs(best - days)) best = b
} }
return COLD_START_PRIORS[best] return best
}
/** The cold-start prior for the bucket nearest to `days`. */
export function priorForEstimate(days: number): LognormalPrior {
return COLD_START_PRIORS[nearestBucket(days)]
}
/** The lognormal parameters `forecast` needs, per estimate bucket. */
export interface DurationModel {
coldStart: boolean
/** Fallback params (used when a bucket lacks its own fit). */
global: LognormalPrior
/** Per-bucket fitted params; missing buckets fall back to `global`. */
byBucket: Record<number, LognormalPrior>
} }
export interface ForecastOptions { export interface ForecastOptions {
@@ -57,6 +71,19 @@ export interface ForecastOptions {
trials?: number trials?: number
/** PRNG seed. Fixed by default so a forecast is reproducible. */ /** PRNG seed. Fixed by default so a forecast is reproducible. */
seed?: number seed?: number
/**
* Fitted duration model. When present and not cold-start, its params drive
* the sim; otherwise the code-resident cold-start priors do.
*/
model?: DurationModel
}
/** Resolve the lognormal params for an estimate, preferring a fitted model. */
export function durationParams(days: number, model?: DurationModel): LognormalPrior {
if (model && !model.coldStart) {
return model.byBucket[nearestBucket(days)] ?? model.global
}
return priorForEstimate(days)
} }
export interface BurnUpPoint { export interface BurnUpPoint {
@@ -121,13 +148,14 @@ export function forecast(
): Forecast { ): Forecast {
const trials = options.trials ?? DEFAULT_TRIALS const trials = options.trials ?? DEFAULT_TRIALS
const seed = options.seed ?? DEFAULT_SEED const seed = options.seed ?? DEFAULT_SEED
const coldStart = options.model ? options.model.coldStart : true
const order = schedule(issues, edges).items // empty when a dependency cycle exists const order = schedule(issues, edges).items // empty when a dependency cycle exists
const n = order.length const n = order.length
if (n === 0) { if (n === 0) {
return { scope: 0, trials, coldStart: true, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] } return { scope: 0, trials, coldStart, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] }
} }
const priors = order.map((it) => priorForEstimate(it.durationDays)) const priors = order.map((it) => durationParams(it.durationDays, options.model))
const rng = mulberry32(seed) const rng = mulberry32(seed)
// endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t. // endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
@@ -156,7 +184,7 @@ export function forecast(
return { return {
scope: n, scope: n,
trials, trials,
coldStart: true, coldStart,
p50Day: percentile(total, 0.5), p50Day: percentile(total, 0.5),
p80Day: percentile(total, 0.8), p80Day: percentile(total, 0.8),
p95Day: percentile(total, 0.95), p95Day: percentile(total, 0.95),

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { createGiteaClient, normalizeIssue } from './client.js' import { createGiteaClient, normalizeIssue, normalizeTimeline } from './client.js'
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js' import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
const CONFIG: GiteaConfig = { const CONFIG: GiteaConfig = {
@@ -44,6 +44,31 @@ function stubFetch(body: unknown, status = 200): {
return { fetch, calls } return { fetch, calls }
} }
describe('normalizeTimeline', () => {
it('maps known gitea event types to lifecycle signals and drops the rest', () => {
const events = normalizeTimeline([
{ type: 'label', created_at: '2026-01-06T09:00:00Z' },
{ type: 'milestone', created_at: '2026-01-06T09:05:00Z' },
{ type: 'commit_ref', created_at: '2026-01-07T12:00:00Z' },
{ type: 'pull_ref', created_at: '2026-01-08T12:00:00Z' },
{ type: 'comment', created_at: '2026-01-08T13:00:00Z' }, // dropped
{ type: 'add_dependency', created_at: '2026-01-08T14:00:00Z' }, // dropped
{ type: 'close', created_at: '2026-01-12T09:00:00Z' },
])
expect(events).toEqual([
{ type: 'triage', at: '2026-01-06T09:00:00Z' },
{ type: 'triage', at: '2026-01-06T09:05:00Z' },
{ type: 'commit', at: '2026-01-07T12:00:00Z' },
{ type: 'pull', at: '2026-01-08T12:00:00Z' },
{ type: 'close', at: '2026-01-12T09:00:00Z' },
])
})
it('skips events missing a timestamp', () => {
expect(normalizeTimeline([{ type: 'commit_ref', created_at: '' }])).toEqual([])
})
})
describe('createGiteaClient.getIssue', () => { describe('createGiteaClient.getIssue', () => {
it('returns a normalized, typed issue from canned JSON', async () => { it('returns a normalized, typed issue from canned JSON', async () => {
const { fetch } = stubFetch(RAW_ISSUE) const { fetch } = stubFetch(RAW_ISSUE)
@@ -98,6 +123,29 @@ describe('createGiteaClient.getIssue', () => {
) )
}) })
it('setIssueLabels PUTs the label ids to the issue labels endpoint', async () => {
const { fetch, calls } = stubFetch(null, 204)
await createGiteaClient(CONFIG, fetch).setIssueLabels(9, [3, 7])
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://gitea.stephenmann.io/api/v1/repos/christian/commitea/issues/9/labels')
expect(calls[0].init?.method).toBe('PUT')
expect(calls[0].init?.headers?.['Content-Type']).toBe('application/json')
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ labels: [3, 7] })
})
it('listLabels returns id+name pairs', async () => {
const { fetch } = stubFetch([
{ id: 3, name: 'est/2d', color: 'fff' },
{ id: 7, name: 'p/1', color: '000' },
])
const labels = await createGiteaClient(CONFIG, fetch).listLabels()
expect(labels).toEqual([
{ id: 3, name: 'est/2d' },
{ id: 7, name: 'p/1' },
])
})
it('throws GiteaApiError carrying status + body on a non-2xx response', async () => { it('throws GiteaApiError carrying status + body on a non-2xx response', async () => {
const { fetch } = stubFetch('not found', 404) const { fetch } = stubFetch('not found', 404)
const client = createGiteaClient(CONFIG, fetch) const client = createGiteaClient(CONFIG, fetch)

View File

@@ -6,12 +6,14 @@
* get the full set. * get the full set.
*/ */
import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js'
import { extractLabelFacts } from '../labels/label-schema.js' import { extractLabelFacts } from '../labels/label-schema.js'
import { import {
GiteaApiError, GiteaApiError,
type FetchLike, type FetchLike,
type GiteaConfig, type GiteaConfig,
type GiteaIssue, type GiteaIssue,
type GiteaLabel,
type GiteaMilestone, type GiteaMilestone,
type GiteaMilestoneRef, type GiteaMilestoneRef,
} from './types.js' } from './types.js'
@@ -54,6 +56,33 @@ interface RawMilestoneFull {
closed_issues: number closed_issues: number
} }
/** The subset of a gitea timeline comment we read. */
interface RawTimelineComment {
type: string
created_at: string
}
/** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */
const TIMELINE_TYPE_MAP: Record<string, LifecycleEventType> = {
label: 'triage',
milestone: 'triage',
assignees: 'triage',
commit_ref: 'commit',
pull_ref: 'pull',
close: 'close',
reopen: 'reopen',
}
/** Map a raw gitea timeline to normalized lifecycle events. Pure. */
export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] {
const out: LifecycleEvent[] = []
for (const c of raw) {
const type = TIMELINE_TYPE_MAP[c.type]
if (type && c.created_at) out.push({ type, at: c.created_at })
}
return out
}
export interface ListIssuesOptions { export interface ListIssuesOptions {
/** @default 'all' */ /** @default 'all' */
state?: 'open' | 'closed' | 'all' state?: 'open' | 'closed' | 'all'
@@ -68,6 +97,12 @@ export interface GiteaClient {
listMilestones(): Promise<GiteaMilestone[]> listMilestones(): Promise<GiteaMilestone[]>
/** The issue indices this issue depends on (its blockers). */ /** The issue indices this issue depends on (its blockers). */
getIssueDependencies(index: number): Promise<number[]> getIssueDependencies(index: number): Promise<number[]>
/** Normalized lifecycle events for one issue (all pages of its timeline). */
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): Promise<GiteaLabel[]>
/** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void>
} }
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */ /** Map raw gitea issue JSON to the normalized domain shape. Pure. */
@@ -114,18 +149,24 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
async function request(path: string): Promise<unknown> { async function request(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
const method = init?.method ?? 'GET'
const hasBody = init?.body !== undefined
const res = await fetchImpl(`${repoBase}${path}`, { const res = await fetchImpl(`${repoBase}${path}`, {
method,
headers: { headers: {
Authorization: `token ${config.token}`, Authorization: `token ${config.token}`,
Accept: 'application/json', Accept: 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
}, },
body: hasBody ? JSON.stringify(init!.body) : undefined,
}) })
if (!res.ok) { if (!res.ok) {
const body = await res.text().catch(() => '') const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body) throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body)
} }
return res.json() // writes may reply 204 No Content
return res.status === 204 ? null : res.json()
} }
/** Follow gitea's page-limit pagination until a short page is returned. */ /** Follow gitea's page-limit pagination until a short page is returned. */
@@ -163,5 +204,23 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[] const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
return raw.map((d) => d.number) return raw.map((d) => d.number)
}, },
async getIssueTimeline(index) {
const raw = await requestAll<RawTimelineComment>(
(page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`,
)
return normalizeTimeline(raw)
},
async listLabels() {
const raw = await requestAll<{ id: number; name: string }>(
(page) => `/labels?page=${page}&limit=${PAGE_LIMIT}`,
)
return raw.map((l) => ({ id: l.id, name: l.name }))
},
async setIssueLabels(index, labelIds) {
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
},
} }
} }

View File

@@ -35,6 +35,12 @@ export interface GiteaHttpResponse {
export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse> export type FetchLike = (url: string, init?: GiteaRequestInit) => Promise<GiteaHttpResponse>
/** A repo label — just the id + name we need for name→id resolution. */
export interface GiteaLabel {
id: number
name: string
}
/** Milestone as referenced from an issue (not the full milestone resource). */ /** Milestone as referenced from an issue (not the full milestone resource). */
export interface GiteaMilestoneRef { export interface GiteaMilestoneRef {
id: number id: number

View File

@@ -9,7 +9,7 @@ export {
} from './labels/label-schema.js' } from './labels/label-schema.js'
export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js' export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js'
export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js' export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js' export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
export { GiteaApiError } from './gitea/types.js' export { GiteaApiError } from './gitea/types.js'
export type { export type {
@@ -17,13 +17,28 @@ export type {
GiteaConfig, GiteaConfig,
GiteaHttpResponse, GiteaHttpResponse,
GiteaIssue, GiteaIssue,
GiteaLabel,
GiteaMilestone, GiteaMilestone,
GiteaMilestoneRef, GiteaMilestoneRef,
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js' export { describeChange, planIssueChange } from './changes/apply-changes-v0.js'
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js' export type { IssueChange, LabelPlan } from './changes/apply-changes-v0.js'
export {
inferColumnV0,
inferLifecycle,
LIFECYCLE_COLUMNS,
workingDaysBetween,
} from './lifecycle/lifecycle-v0.js'
export type {
LifecycleColumn,
LifecycleEvent,
LifecycleEventType,
LifecycleInference,
LifecycleStages,
} from './lifecycle/lifecycle-v0.js'
export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js' export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
export type { export type {
@@ -34,5 +49,42 @@ export type {
SchedulePlan, SchedulePlan,
} from './scheduler/scheduler-v0.js' } from './scheduler/scheduler-v0.js'
export { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast/forecast-v0.js' export {
export type { BurnUpPoint, Forecast, ForecastOptions, LognormalPrior } from './forecast/forecast-v0.js' COLD_START_PRIORS,
durationParams,
forecast,
nearestBucket,
PRIOR_BUCKETS,
priorForEstimate,
} from './forecast/forecast-v0.js'
export type {
BurnUpPoint,
DurationModel,
Forecast,
ForecastOptions,
LognormalPrior,
} from './forecast/forecast-v0.js'
export {
CALIBRATION_BUCKET_FLOOR,
calibrationSamples,
COLD_START_THRESHOLD,
fitCalibration,
toDurationModel,
} from './calibration/calibration-v0.js'
export type {
BucketFit,
CalibrationModel,
CalibrationSample,
PersonBias,
} from './calibration/calibration-v0.js'
export { createChatClient } from './agent/chat-client.js'
export type { ChatClient, ChatMessage, CompletionResult, ModelConfig, ToolCall, ToolDecl } from './agent/chat-client.js'
export { pickModel } from './agent/model-router.js'
export type { ModelRouter, TaskKind } from './agent/model-router.js'
export { runAgentTurn } from './agent/agent-loop.js'
export type { AgentStep, AgentTurn, ToolExecutor } from './agent/agent-loop.js'
export { QUERY_PROJECT_TOOL, REGINALD_SYSTEM, REGINALD_TOOLS } from './agent/agent-tools.js'
export { buildProjectView } from './agent/query-project.js'
export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js'

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { inferColumnV0 } from './lifecycle-v0.js' import {
inferColumnV0,
inferLifecycle,
type LifecycleEvent,
workingDaysBetween,
} from './lifecycle-v0.js'
const base = { state: 'open' as const, labels: [] as string[], milestone: null } const base = { state: 'open' as const, labels: [] as string[], milestone: null }
@@ -27,3 +32,93 @@ describe('inferColumnV0', () => {
expect(['steeping', 'review']).not.toContain(col) expect(['steeping', 'review']).not.toContain(col)
}) })
}) })
describe('workingDaysBetween', () => {
it('counts weekdays in [start, end), excluding weekends', () => {
// Mon 2026-01-05 → Mon 2026-01-12 spans a full week = 5 working days
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-12'))).toBe(5)
})
it('is 0 for same day or reversed', () => {
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-05'))).toBe(0)
expect(workingDaysBetween(new Date('2026-01-12'), new Date('2026-01-05'))).toBe(0)
})
it('skips a weekend inside the interval', () => {
// Fri 2026-01-09 → Mon 2026-01-12: only Fri counts (Sat/Sun excluded) = 1
expect(workingDaysBetween(new Date('2026-01-09'), new Date('2026-01-12'))).toBe(1)
})
})
describe('inferLifecycle', () => {
const openBase = { ...base, createdAt: '2026-01-05T09:00:00Z', closedAt: null }
const asOf = new Date('2026-01-14T09:00:00Z')
const ev = (type: LifecycleEvent['type'], at: string): LifecycleEvent => ({ type, at })
it('bare open issue with no events is diagnosis', () => {
const inf = inferLifecycle(openBase, [], asOf)
expect(inf.column).toBe('diagnosis')
expect(inf.stages.opened).toBe(openBase.createdAt)
expect(inf.actualWorkingDays).toBeNull()
expect(inf.steepingDays).toBeNull()
})
it('a triage event (or a current label) moves it to triage', () => {
expect(inferLifecycle(openBase, [ev('triage', '2026-01-06T09:00:00Z')], asOf).column).toBe('triage')
expect(inferLifecycle({ ...openBase, labels: ['p/1'] }, [], asOf).column).toBe('triage')
})
it('a commit ref moves an open issue to steeping and counts its working age', () => {
const inf = inferLifecycle(
{ ...openBase, labels: ['est/2d'] },
[ev('triage', '2026-01-05T10:00:00Z'), ev('commit', '2026-01-07T12:00:00Z')],
asOf,
)
expect(inf.column).toBe('steeping')
expect(inf.stages.steeping).toBe('2026-01-07T12:00:00Z')
// Wed 2026-01-07 → Wed 2026-01-14 = 5 working days
expect(inf.steepingDays).toBe(5)
})
it('a pull ref outranks a commit ref → review', () => {
const inf = inferLifecycle(
openBase,
[ev('commit', '2026-01-07T12:00:00Z'), ev('pull', '2026-01-08T12:00:00Z')],
asOf,
)
expect(inf.column).toBe('review')
expect(inf.steepingDays).toBeNull() // only steeping issues carry an age
})
it('closed → done, with actual working time from work-start to close', () => {
const inf = inferLifecycle(
{ ...openBase, state: 'closed', closedAt: '2026-01-12T09:00:00Z' },
[ev('commit', '2026-01-07T09:00:00Z'), ev('pull', '2026-01-08T09:00:00Z'), ev('close', '2026-01-12T09:00:00Z')],
asOf,
)
expect(inf.column).toBe('done')
expect(inf.stages.done).toBe('2026-01-12T09:00:00Z')
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
expect(inf.actualWorkingDays).toBe(3)
})
it('takes the earliest event of each kind for stage timestamps', () => {
const inf = inferLifecycle(
openBase,
[ev('commit', '2026-01-09T09:00:00Z'), ev('commit', '2026-01-07T09:00:00Z')],
asOf,
)
expect(inf.stages.steeping).toBe('2026-01-07T09:00:00Z')
})
it('falls back to opened when a closed issue has no work-start signal', () => {
const inf = inferLifecycle(
{ ...openBase, state: 'closed', closedAt: '2026-01-08T09:00:00Z' },
[ev('close', '2026-01-08T09:00:00Z')],
asOf,
)
// Mon 2026-01-05 → Thu 2026-01-08 = Mon,Tue,Wed = 3 working days
expect(inf.actualWorkingDays).toBe(3)
})
})

View File

@@ -1,12 +1,10 @@
/** /**
* Lifecycle inference, v0 — the coarse column an issue sits in, derived from * Lifecycle inference.
* *only* what a single issues-list read gives us (state, labels, milestone).
* *
* The real five-column inference (P1-5) needs the issue timeline: first * `inferColumnV0` places the coarse three columns from a single issues-list
* branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy. * read (state/labels/milestone). `inferLifecycle` (#5) adds the issue timeline:
* Until that lands, v0 can only place three columns honestly; `steeping` and * first commit ref → Steeping, first PR ref → In review, close → Done — and
* `review` stay empty rather than guess. Board renders all five columns and * derives the estimate-vs-actual working time that feeds calibration (D3).
* fills the middle two once the event stream is available.
*/ */
import type { GiteaIssue } from '../gitea/types.js' import type { GiteaIssue } from '../gitea/types.js'
@@ -23,10 +21,109 @@ export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [
/** /**
* Closed → done. Open with any human intent applied (a label or a milestone) * Closed → done. Open with any human intent applied (a label or a milestone)
* → triage. Open and bare → diagnosis. Never returns steeping/review in v0. * → triage. Open and bare → diagnosis. Never returns steeping/review — that's
* `inferLifecycle`, which reads the event stream.
*/ */
export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn { export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
if (issue.state === 'closed') return 'done' if (issue.state === 'closed') return 'done'
const hasIntent = issue.labels.length > 0 || issue.milestone !== null const hasIntent = issue.labels.length > 0 || issue.milestone !== null
return hasIntent ? 'triage' : 'diagnosis' return hasIntent ? 'triage' : 'diagnosis'
} }
/** A timeline signal, normalized from gitea's raw event stream. */
export type LifecycleEventType = 'triage' | 'commit' | 'pull' | 'close' | 'reopen'
export interface LifecycleEvent {
type: LifecycleEventType
/** ISO timestamp. */
at: string
}
/** Timestamps for the stages an issue has reached (absent = not yet reached). */
export interface LifecycleStages {
opened: string
/** First label / milestone / assignment. */
triaged?: string
/** First commit referencing the issue — work began. */
steeping?: string
/** First PR referencing the issue — in review. */
review?: string
/** Closed. */
done?: string
}
export interface LifecycleInference {
column: LifecycleColumn
stages: LifecycleStages
/**
* Working days from work-start (steeping → triaged → opened, first available)
* to done. Only for closed issues — this is the "actual" calibration learns
* from. null while open.
*/
actualWorkingDays: number | null
/** Working days the issue has been steeping (first commit → asOf); null unless currently steeping. */
steepingDays: number | null
}
const DAY_MS = 86_400_000
/**
* Whole working days (MonFri) in the half-open interval [start, end). Same day
* or reversed → 0. Day-granular by design — estimates are in days.
*/
export function workingDaysBetween(start: Date, end: Date): number {
const s = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())
const e = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate())
if (e <= s) return 0
let count = 0
for (let t = s; t < e; t += DAY_MS) {
const dow = new Date(t).getUTCDay()
if (dow !== 0 && dow !== 6) count += 1
}
return count
}
/**
* The five-column inference. Column reflects the furthest stage still in play:
* closed → done; else an open PR ref → review; else a commit ref → steeping;
* else any triage signal (or a current label/milestone) → triage; else
* diagnosis. Stage timestamps are the earliest event of each kind.
*/
export function inferLifecycle(
issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone' | 'createdAt' | 'closedAt'>,
events: LifecycleEvent[],
asOf: Date,
): LifecycleInference {
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at))
const firstOf = (type: LifecycleEventType) => sorted.find((e) => e.type === type)?.at
const stages: LifecycleStages = { opened: issue.createdAt }
const triagedAt = firstOf('triage')
const steepingAt = firstOf('commit')
const reviewAt = firstOf('pull')
if (triagedAt) stages.triaged = triagedAt
if (steepingAt) stages.steeping = steepingAt
if (reviewAt) stages.review = reviewAt
const doneAt = issue.state === 'closed' ? (issue.closedAt ?? firstOf('close')) : undefined
if (doneAt) stages.done = doneAt
let column: LifecycleColumn
if (issue.state === 'closed') column = 'done'
else if (reviewAt) column = 'review'
else if (steepingAt) column = 'steeping'
else if (triagedAt || issue.labels.length > 0 || issue.milestone !== null) column = 'triage'
else column = 'diagnosis'
let actualWorkingDays: number | null = null
if (stages.done) {
const start = stages.steeping ?? stages.triaged ?? stages.opened
actualWorkingDays = workingDaysBetween(new Date(start), new Date(stages.done))
}
let steepingDays: number | null = null
if (column === 'steeping' && stages.steeping) {
steepingDays = workingDaysBetween(new Date(stages.steeping), asOf)
}
return { column, stages, actualWorkingDays, steepingDays }
}