feat(insights): workflow intelligence (corrections, time-to-first-edit, file churn, coverage) + dash wire-up (#756)

* feat(insights): workflow intelligence (corrections, time-to-first-edit, file churn, coverage) + dash wire-up

* menubar: propagate workflow-intelligence fields onto the cache-backed headline

The carry-forward headline (#755) serves totals from the durable day set
and merges only session-derived fields from the scan. The new workflow,
topReworkedFiles, and pricingCoverage fields were computed by the scan
but never propagated, so they silently defaulted on the all-provider
path (pricingCoverage rendered a dishonest 1.0). Propagate them like the
other scan-only fields and pin it with a carried-headline regression
test.
This commit is contained in:
Resham Joshi 2026-07-20 05:47:46 -07:00 committed by GitHub
parent e99ee4bf37
commit f32c00d2b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 677 additions and 8 deletions

View file

@ -83,9 +83,6 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
const toolBars: BarItem[] = c
? Object.entries(c.providers).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
: []
const modelBars: BarItem[] = c
? c.topModels.filter((m) => m.cost > 0).slice(0, 8).map((m) => ({ name: m.name, value: m.cost, display: usd(m.cost) }))
: []
const activityBars: BarItem[] = c
? c.topActivities.filter((a) => a.cost > 0).map((a) => ({ name: a.name, value: a.cost, display: usd(a.cost) }))
: []
@ -136,7 +133,37 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
<BarList items={toolBars} total={c?.cost} />
</Panel>
<Panel title="Top models">
<BarList items={modelBars} total={c?.cost} />
<DataTable
columns={[
{ key: 'name', label: 'Model' },
{ key: 'cost', label: 'Cost', num: true },
{ key: 'calls', label: 'Calls', num: true },
{ key: 'savings', label: 'Savings', num: true },
]}
rows={(c?.topModels ?? []).filter((m) => m.cost > 0).slice(0, 8).map((m) => ({
name: m.name,
cost: usd(m.cost),
calls: fmtNum(m.calls),
savings: usd(m.savingsUSD),
}))}
/>
</Panel>
</div>
<div className="mb-3">
<Panel title="Model efficiency">
<DataTable
columns={[
{ key: 'name', label: 'Model' },
{ key: 'costPerEdit', label: 'Cost/edit', num: true },
{ key: 'oneShot', label: 'One-shot', num: true },
]}
rows={(c?.modelEfficiency ?? []).slice(0, 10).map((m) => ({
name: m.name,
costPerEdit: usd(m.costPerEdit),
oneShot: `${Math.round(m.oneShotRate * 100)}%`,
}))}
/>
</Panel>
</div>
@ -152,11 +179,13 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
{ key: 'name', label: 'Project' },
{ key: 'cost', label: 'Cost', num: true },
{ key: 'sessions', label: 'Sessions', num: true },
{ key: 'avgCost', label: 'Avg/session', num: true },
]}
rows={(c?.topProjects ?? []).slice(0, 10).map((p) => ({
name: p.name,
cost: usd(p.cost),
sessions: fmtNum(p.sessions),
avgCost: usd(p.avgCostPerSession),
}))}
/>
)}

View file

@ -32,6 +32,16 @@ export type PeriodData = {
projects?: Array<{ name: string; cost: number; savingsUSD: number; sessions: number; sessionDetails?: Array<{ cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number; savingsUSD: number }> }> }>
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
topSessions?: Array<{ project: string; cost: number; savingsUSD: number; calls: number; date: string }>
/// Workflow-intelligence rollups (issue: workflow intelligence). Optional so
/// the day-aggregator PeriodData path (which has no per-turn data) can omit
/// them; the fresh-parse payload path always sets them.
workflow?: { corrections: number; correctionRate: number | null; medianTimeToFirstEditMs: number | null }
/// Files most reworked by edit-family calls, relative to project root, ranked
/// by distinct sessions then edits. Full (top 15) list; the payload basenames
/// and trims it.
topReworkedFiles?: ReworkedFile[]
/// Share (0-1) of cost-bearing calls that resolved a price.
pricingCoverage?: number
}
export type ProviderCost = {
@ -44,6 +54,7 @@ export type ProviderCost = {
import type { OptimizeResult } from './optimize.js'
import { getCurrency } from './currency.js'
import type { GranularHistory } from './granular-history.js'
import type { ReworkedFile } from './workflow-insights.js'
const TOP_ACTIVITIES_LIMIT = 20
const TOP_MODELS_LIMIT = 20
@ -53,6 +64,7 @@ const SYNTHETIC_MODEL_NAME = '<synthetic>'
const TOP_PROJECTS_LIMIT = 5
const TOP_SESSIONS_LIMIT = 3
const MODEL_EFFICIENCY_LIMIT = 5
const TOP_REWORKED_FILES_LIMIT = 8
export type DailyModelBreakdown = {
name: string
@ -215,6 +227,21 @@ export type MenubarPayload = {
calls: number
date: string
}>
/// Workflow-intelligence rollup for the period. `unansweredSessions` is
/// add-only and optional: sessions that ended with no assistant reply are
/// not computable from the session cache (the parser drops a trailing
/// unanswered user turn), so it stays unset until a parse-time capture lands.
workflow: {
corrections: number
correctionRate: number | null
medianTimeToFirstEditMs: number | null
unansweredSessions?: number
}
/// Files most reworked by edit-family calls (top 8). Path is basename-only
/// for privacy; distinct sessions and total edit calls per file.
topReworkedFiles: Array<{ path: string; sessions: number; edits: number }>
/// Share (0-1) of cost-bearing calls that resolved a price.
pricingCoverage: number
retryTax: {
totalUSD: number
retries: number
@ -377,6 +404,22 @@ function buildModelEfficiency(models: PeriodData['modelEfficiency']): MenubarPay
.map(m => ({ name: m.name, costPerEdit: m.costPerEdit, oneShotRate: m.oneShotRate }))
}
function buildWorkflow(workflow: PeriodData['workflow']): MenubarPayload['current']['workflow'] {
return {
corrections: workflow?.corrections ?? 0,
correctionRate: workflow?.correctionRate ?? null,
medianTimeToFirstEditMs: workflow?.medianTimeToFirstEditMs ?? null,
}
}
function buildTopReworkedFiles(files: PeriodData['topReworkedFiles']): MenubarPayload['current']['topReworkedFiles'] {
return (files ?? [])
.slice(0, TOP_REWORKED_FILES_LIMIT)
// Basename only: the menubar/web payload can leave the machine, so drop the
// directory path and keep just the file name.
.map(f => ({ path: f.path.split('/').pop() || f.path, sessions: f.sessions, edits: f.edits }))
}
function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['current']['topSessions'] {
return (sessions ?? [])
.sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD))
@ -432,6 +475,9 @@ export function buildMenubarPayload(
topProjects: buildTopProjects(current.projects ?? []),
modelEfficiency: buildModelEfficiency(current.modelEfficiency ?? []),
topSessions: buildTopSessions(current.topSessions ?? []),
workflow: buildWorkflow(current.workflow),
topReworkedFiles: buildTopReworkedFiles(current.topReworkedFiles),
pricingCoverage: current.pricingCoverage ?? 1,
retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: breakdowns?.tools ?? [],

View file

@ -11,6 +11,7 @@ import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
import { formatCost } from './currency.js'
import { formatTokens } from './format.js'
import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js'
import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
// ============================================================================
// Display constants
@ -339,6 +340,10 @@ export type OptimizeJsonReport = {
estimatedSavingsUSD: number
fix: WasteAction
}>
/// Files most reworked by edit-family calls, relative to project root (top 15).
topReworkedFiles: ReworkedFile[]
/// 1-3 templated one-liners keyed on the strongest workflow signals.
coachingNotes: string[]
modelRecommendations?: Array<ModelDefaultRecommendation>
}
@ -3130,6 +3135,28 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
return lines
}
function renderWorkflowSection(reworkedFiles: ReworkedFile[], coachingNotes: string[]): string[] {
const lines: string[] = []
if (reworkedFiles.length > 0) {
lines.push(chalk.bold.hex(ORANGE)(' Top reworked files'))
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
for (const f of reworkedFiles) {
const meta = chalk.dim(`${f.sessions} session${f.sessions === 1 ? '' : 's'}, ${f.edits} edit${f.edits === 1 ? '' : 's'}`)
lines.push(` ${f.path} ${meta}`)
}
lines.push('')
}
if (coachingNotes.length > 0) {
lines.push(chalk.bold.hex(ORANGE)(' Coaching notes'))
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
for (const note of coachingNotes) {
lines.push(wrap('- ' + note, PANEL_WIDTH, ' '))
}
lines.push('')
}
return lines
}
function renderOptimize(
findings: WasteFinding[],
costRate: number,
@ -3139,6 +3166,8 @@ function renderOptimize(
callCount: number,
healthScore: number,
healthGrade: HealthGrade,
reworkedFiles: ReworkedFile[],
coachingNotes: string[],
appliedHeader?: string,
previouslyApplied?: Record<string, string>,
modelRecommendations?: ModelDefaultRecommendation[],
@ -3165,6 +3194,7 @@ function renderOptimize(
lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused'))
lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.'))
lines.push('')
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
return lines.join('\n')
}
@ -3187,7 +3217,9 @@ function renderOptimize(
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
lines.push(chalk.dim(' Estimates only.'))
lines.push('')
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
if (modelRecommendations && modelRecommendations.length > 0) {
lines.push(chalk.bold.hex(ORANGE)(' Model defaults recommendation'))
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
@ -3203,6 +3235,22 @@ function renderOptimize(
return lines.join('\n')
}
// File churn + coaching notes for the workflow-intelligence section. Both are
// derived from the same parsed projects the detectors already ran on, so this
// adds no extra file I/O.
function buildWorkflowReport(projects: ProjectSummary[]): { topReworkedFiles: ReworkedFile[]; coachingNotes: string[] } {
const topReworkedFiles = aggregateFileChurn(projects)
const corrections = scanUserCorrections(projects)
const coachingNotes = buildCoachingNotes({
worstOneShot: worstOneShotCategory(projects),
corrections: corrections.corrections,
correctionRate: corrections.correctionRate,
topReworkedFile: topReworkedFiles[0] ?? null,
medianTimeToFirstEditMs: medianTimeToFirstEditMs(projects),
})
return { topReworkedFiles, coachingNotes }
}
export async function runOptimize(
projects: ProjectSummary[],
periodLabel: string,
@ -3230,7 +3278,8 @@ export async function runOptimize(
return
}
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects)
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
console.log(output)
}
@ -3277,6 +3326,7 @@ export function buildOptimizeJsonReport(
estimatedSavingsUSD: f.tokensSaved * result.costRate,
fix: f.fix,
})),
...buildWorkflowReport(projects),
modelRecommendations: result.modelRecommendations,
}
}

View file

@ -9,6 +9,7 @@ import { stat } from 'node:fs/promises'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { aggregateModels } from './models-report.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { scanAndDetect } from './optimize.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'
@ -42,6 +43,13 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
}
}
const unpricedModels = findUnpricedModels(Object.entries(modelTotals)
.map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens })))
const costBearingCalls = Object.entries(modelTotals)
.reduce((s, [model, d]) => s + (model === '<synthetic>' ? 0 : d.calls), 0)
const unpricedCalls = unpricedModels.reduce((s, m) => s + m.calls, 0)
const corrections = scanUserCorrections(projects)
return {
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
@ -56,8 +64,14 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri
models: Object.entries(modelTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD, estimatedCostUSD: d.estimatedCostUSD })),
unpricedModels: findUnpricedModels(Object.entries(modelTotals)
.map(([model, d]) => ({ model, calls: d.calls, cost: d.cost, tokens: d.tokens }))),
unpricedModels,
workflow: {
corrections: corrections.corrections,
correctionRate: corrections.correctionRate,
medianTimeToFirstEditMs: medianTimeToFirstEditMs(projects),
},
topReworkedFiles: aggregateFileChurn(projects),
pricingCoverage: computePricingCoverage(costBearingCalls, unpricedCalls),
}
}
@ -305,6 +319,14 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
// detection, both derivable solely from surviving sessions.
currentData.estimatedCostUSD = scanData.estimatedCostUSD
currentData.unpricedModels = scanData.unpricedModels
// Workflow-intelligence metrics are session-derived by nature (they need
// turn text, timestamps, and tool sequences that day entries don't
// carry), so like the fields above they come from the scan. Note
// pricingCoverage therefore describes surviving-session calls, a smaller
// population than the carried headline cost.
currentData.workflow = scanData.workflow
currentData.topReworkedFiles = scanData.topReworkedFiles
currentData.pricingCoverage = scanData.pricingCoverage
// Sessions: the cache buckets a session on its START day, the scan
// counts it on any day it was ACTIVE. Each undercounts differently
// (day-filtered views miss midnight-spanners; the scan misses expired

225
src/workflow-insights.ts Normal file
View file

@ -0,0 +1,225 @@
import { homedir } from 'os'
import { EDIT_TOOLS } from './classifier.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
// User-side mirror of compare-stats.ts scanSelfCorrections (which scans the
// assistant's own apologies). These match a *user* follow-up telling the
// assistant it got something wrong. Deliberately conservative: bare "wrong" or
// "undo" are excluded because they show up in ordinary task requests ("fix the
// wrong output", "undo the migration"). Every pattern requires a correction
// context so praise like "you were right" or "that's right" never trips it.
export const USER_CORRECTION_PATTERNS: RegExp[] = [
/\bthat'?s (?:not|n'?t) (?:what|right|correct|it)\b/i,
/\bthat'?s (?:wrong|incorrect)\b/i,
/\bthat is (?:wrong|incorrect|not right)\b/i,
/\bnot what I (?:meant|wanted|asked|said)\b/i,
/\bno,? I (?:meant|wanted|said|asked for)\b/i,
/\byou (?:missed|forgot|misunderstood|broke)\b/i,
/\brevert (?:that|it|this|the|your)\b/i,
/\bundo (?:that|it|this|your|the last|the change)\b/i,
/\bwrong (?:file|approach|place|method|function|answer|way|direction)\b/i,
/\bstill (?:wrong|broken|failing|not working)\b/i,
]
function matchesCorrection(text: string): boolean {
return USER_CORRECTION_PATTERNS.some(p => p.test(text))
}
export type UserCorrectionStats = {
corrections: number
/// Turns carrying a real user prompt (non-empty userMessage). The denominator
/// for correctionRate — continuation turns with no fresh prompt are excluded.
userTurns: number
correctionRate: number | null
}
export function scanUserCorrections(projects: ProjectSummary[]): UserCorrectionStats {
let corrections = 0
let userTurns = 0
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
const msg = turn.userMessage
if (!msg || !msg.trim()) continue
userTurns++
if (matchesCorrection(msg)) corrections++
}
}
}
return { corrections, userTurns, correctionRate: userTurns > 0 ? corrections / userTurns : null }
}
function callHasEditTools(tools: string[]): boolean {
return tools.some(t => EDIT_TOOLS.has(t))
}
/// Per-session ms from the session's first turn to the first assistant call
/// that used an edit-family tool. Returns null for sessions that never edited,
/// so those are excluded from the median rather than counted as zero.
export function sessionTimeToFirstEditMs(session: ProjectSummary['sessions'][number]): number | null {
const startMs = Date.parse(session.turns[0]?.timestamp ?? '')
if (Number.isNaN(startMs)) return null
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
if (!callHasEditTools(call.tools)) continue
const editMs = Date.parse(call.timestamp)
if (Number.isNaN(editMs)) continue
// Clamp: out-of-order timestamps across resumed transcripts would
// otherwise pull the median negative.
return Math.max(0, editMs - startMs)
}
}
return null
}
export function medianTimeToFirstEditMs(projects: ProjectSummary[]): number | null {
const samples: number[] = []
for (const project of projects) {
for (const session of project.sessions) {
const ms = sessionTimeToFirstEditMs(session)
if (ms !== null) samples.push(ms)
}
}
if (samples.length === 0) return null
samples.sort((a, b) => a - b)
const mid = Math.floor(samples.length / 2)
return samples.length % 2 === 0 ? (samples[mid - 1]! + samples[mid]!) / 2 : samples[mid]!
}
export type ReworkedFile = {
path: string
sessions: number
edits: number
}
function relativizePath(absPath: string, projectPath: string): string {
if (projectPath && (absPath === projectPath || absPath.startsWith(projectPath + '/'))) {
return absPath.slice(projectPath.length + 1) || absPath
}
const home = homedir()
if (absPath === home || absPath.startsWith(home + '/')) return '~' + absPath.slice(home.length)
return absPath
}
/// Ranks the files touched most by edit-family tool calls. File paths come from
/// each call's toolSequence, which the parser/cache retain per edit tool_use
/// (see parser.ts and session-cache CachedCall.toolSequence). Rank by distinct
/// sessions first (a file reworked across many sessions is the real churn
/// signal), then total edit calls.
export function aggregateFileChurn(projects: ProjectSummary[], limit = 15): ReworkedFile[] {
type Acc = { path: string; sessions: Set<string>; edits: number }
const byPath = new Map<string, Acc>()
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
for (const call of turn.assistantCalls) {
if (!call.toolSequence) continue
for (const step of call.toolSequence) {
for (const tc of step) {
if (!EDIT_TOOLS.has(tc.tool) || !tc.file) continue
let acc = byPath.get(tc.file)
if (!acc) {
acc = { path: relativizePath(tc.file, project.projectPath), sessions: new Set(), edits: 0 }
byPath.set(tc.file, acc)
}
acc.sessions.add(session.sessionId)
acc.edits++
}
}
}
}
}
}
return [...byPath.values()]
.map(a => ({ path: a.path, sessions: a.sessions.size, edits: a.edits }))
.sort((a, b) => b.sessions - a.sessions || b.edits - a.edits || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
.slice(0, limit)
}
/// Share (0-1) of cost-bearing calls that resolved a price. `unpricedCalls` are
/// the calls of models with usage but no pricing table entry (findUnpricedModels).
/// Returns 1 when there is nothing to price (no coverage gap to report).
export function computePricingCoverage(totalCostBearingCalls: number, unpricedCalls: number): number {
if (totalCostBearingCalls <= 0) return 1
const priced = Math.max(0, totalCostBearingCalls - unpricedCalls)
return priced / totalCostBearingCalls
}
export type CategoryOneShot = { category: string; rate: number; editTurns: number }
/// The task category with the weakest one-shot rate (over enough edit turns to
/// trust), used by the coaching notes. Rate is a percentage (0-100), matching
/// model-efficiency and the report's category one-shot figures.
export function worstOneShotCategory(projects: ProjectSummary[], minEditTurns = 5): CategoryOneShot | null {
const acc = new Map<string, { editTurns: number; oneShotTurns: number }>()
for (const project of projects) {
for (const session of project.sessions) {
for (const [cat, d] of Object.entries(session.categoryBreakdown)) {
const e = acc.get(cat) ?? { editTurns: 0, oneShotTurns: 0 }
e.editTurns += d.editTurns
e.oneShotTurns += d.oneShotTurns
acc.set(cat, e)
}
}
}
let worst: CategoryOneShot | null = null
for (const [cat, d] of acc) {
if (d.editTurns < minEditTurns) continue
const rate = (d.oneShotTurns / d.editTurns) * 100
if (!worst || rate < worst.rate) {
worst = { category: CATEGORY_LABELS[cat as TaskCategory] ?? cat, rate, editTurns: d.editTurns }
}
}
return worst
}
// Coaching-note thresholds. Kept conservative so a note only fires on a signal
// strong enough to act on.
const ONE_SHOT_LOW_PERCENT = 60
const CORRECTION_HIGH_RATE = 0.15
const CORRECTION_MIN_COUNT = 3
const CHURN_MIN_SESSIONS = 3
const TTFE_SLOW_MS = 5 * 60 * 1000
export type CoachingInput = {
worstOneShot?: CategoryOneShot | null
corrections?: number
correctionRate?: number | null
topReworkedFile?: ReworkedFile | null
medianTimeToFirstEditMs?: number | null
}
function formatDurationShort(ms: number): string {
if (ms >= 60_000) return `${Math.round(ms / 60_000)}m`
return `${Math.round(ms / 1000)}s`
}
/// 1-3 templated one-liners keyed on the strongest workflow signals. Pure
/// templating on already-computed numbers. Copy is dry and specific and uses no
/// em-dashes (UI copy house style).
export function buildCoachingNotes(input: CoachingInput): string[] {
const notes: string[] = []
const ws = input.worstOneShot
if (ws && ws.editTurns >= 5 && ws.rate < ONE_SHOT_LOW_PERCENT) {
notes.push(`One-shot rate on ${ws.category} is ${Math.round(ws.rate)}% over ${ws.editTurns} edit turns. Add the constraints up front or split the work into smaller edits.`)
}
if (input.correctionRate != null && input.correctionRate >= CORRECTION_HIGH_RATE && (input.corrections ?? 0) >= CORRECTION_MIN_COUNT) {
notes.push(`You corrected the assistant on ${Math.round(input.correctionRate * 100)}% of prompts (${input.corrections} times). State the requirements in the first message to cut the back and forth.`)
}
const cf = input.topReworkedFile
if (cf && cf.sessions >= CHURN_MIN_SESSIONS) {
notes.push(`${cf.path} was reworked across ${cf.sessions} sessions (${cf.edits} edits). A focused pass on it may cost less than the repeated churn.`)
}
if (input.medianTimeToFirstEditMs != null && input.medianTimeToFirstEditMs >= TTFE_SLOW_MS) {
notes.push(`Median time to first edit is ${formatDurationShort(input.medianTimeToFirstEditMs)}. Point the assistant at the target file to cut the exploration before it starts editing.`)
}
return notes.slice(0, 3)
}

View file

@ -103,6 +103,15 @@ describe('carried history reaches the user-visible headline', () => {
expect(payload.current.topModels.some(m => m.name === 'Opus 4.8' && m.cost === 100)).toBe(true)
const projects = payload.current.topProjects
expect(projects.some(p => p.name === 'proj-x' && p.cost === 100 && p.sessions === 3)).toBe(true)
// Scan-derived workflow-intelligence fields must be PROPAGATED onto the
// cache-authoritative headline, not silently dropped by the selective
// merge (the regression that made them dead on the default path). With no
// surviving sessions they are the empty-scan values, but they must exist.
expect(payload.current.workflow).toBeDefined()
expect(payload.current.workflow?.corrections).toBe(0)
expect(Array.isArray(payload.current.topReworkedFiles)).toBe(true)
expect(typeof payload.current.pricingCoverage).toBe('number')
})
it('merges live today data on top of carried history without double counting', async () => {

View file

@ -0,0 +1,288 @@
import { describe, expect, it } from 'vitest'
import {
scanUserCorrections,
medianTimeToFirstEditMs,
aggregateFileChurn,
computePricingCoverage,
worstOneShotCategory,
buildCoachingNotes,
USER_CORRECTION_PATTERNS,
} from '../src/workflow-insights.js'
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TaskCategory, ToolCall } from '../src/types.js'
function call(opts: { tools?: string[]; timestamp?: string; toolSequence?: ToolCall[][]; model?: string; costUSD?: number } = {}): ParsedApiCall {
return {
provider: 'claude',
model: opts.model ?? 'claude-sonnet-4-5',
usage: {
inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0,
cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
},
costUSD: opts.costUSD ?? 0,
tools: opts.tools ?? [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: opts.timestamp ?? '2026-05-05T00:00:00.000Z',
bashCommands: [],
deduplicationKey: 'k',
...(opts.toolSequence ? { toolSequence: opts.toolSequence } : {}),
}
}
function turn(opts: { userMessage?: string; calls?: ParsedApiCall[]; timestamp?: string; sessionId?: string } = {}): ClassifiedTurn {
return {
userMessage: opts.userMessage ?? '',
assistantCalls: opts.calls ?? [],
timestamp: opts.timestamp ?? '2026-05-05T00:00:00.000Z',
sessionId: opts.sessionId ?? 's1',
category: 'coding',
retries: 0,
hasEdits: (opts.calls ?? []).some(c => c.tools.some(t => t === 'Edit' || t === 'Write')),
}
}
function session(sessionId: string, turns: ClassifiedTurn[], categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown']): SessionSummary {
return {
sessionId,
project: 'app',
firstTimestamp: turns[0]?.timestamp ?? '2026-05-05T00:00:00.000Z',
lastTimestamp: '2026-05-05T01:00:00.000Z',
totalCostUSD: 0, totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0,
totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalSavingsUSD: 0,
apiCalls: turns.reduce((s, t) => s + t.assistantCalls.length, 0),
turns,
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown,
skillBreakdown: {}, subagentBreakdown: {},
}
}
function project(sessions: SessionSummary[], projectPath = '/home/u/app'): ProjectSummary {
return {
project: 'app', projectPath, sessions,
totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0,
}
}
function cat(editTurns: number, oneShotTurns: number): SessionSummary['categoryBreakdown'][TaskCategory] {
return { turns: editTurns, costUSD: 0, savingsUSD: 0, retries: 0, editTurns, oneShotTurns }
}
describe('scanUserCorrections', () => {
it('counts turns whose user message signals a correction', () => {
const p = project([session('s1', [
turn({ userMessage: "no, I meant the other file" }),
turn({ userMessage: "that's not what I asked for" }),
turn({ userMessage: "you missed the edge case" }),
turn({ userMessage: "revert that change" }),
turn({ userMessage: "add a new feature please" }),
])])
const r = scanUserCorrections([p])
expect(r.corrections).toBe(4)
expect(r.userTurns).toBe(5)
expect(r.correctionRate).toBeCloseTo(0.8)
})
it('does not flag praise or ordinary requests (false-positive guards)', () => {
const phrases = [
'you were right about that',
"you're right, thanks",
"that's right, ship it",
'looks correct to me',
'undo the migration when done',
'the build is failing, can you fix it',
'what went wrong here',
]
const p = project([session('s1', phrases.map(m => turn({ userMessage: m })))])
const r = scanUserCorrections([p])
expect(r.corrections).toBe(0)
})
it('ignores continuation turns with no fresh prompt', () => {
const p = project([session('s1', [
turn({ userMessage: '' }),
turn({ userMessage: ' ' }),
turn({ userMessage: 'that is wrong' }),
])])
const r = scanUserCorrections([p])
expect(r.userTurns).toBe(1)
expect(r.corrections).toBe(1)
expect(r.correctionRate).toBe(1)
})
it('returns a null rate for empty input', () => {
const r = scanUserCorrections([])
expect(r).toEqual({ corrections: 0, userTurns: 0, correctionRate: null })
})
it('every pattern is case-insensitive', () => {
for (const re of USER_CORRECTION_PATTERNS) expect(re.flags).toContain('i')
})
})
describe('medianTimeToFirstEditMs', () => {
const edit = (ts: string) => call({ tools: ['Edit'], timestamp: ts })
it('measures from the first turn to the first edit call', () => {
const s = session('s1', [
turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [call({ tools: ['Read'], timestamp: '2026-05-05T00:00:10.000Z' })] }),
turn({ timestamp: '2026-05-05T00:01:00.000Z', calls: [edit('2026-05-05T00:00:30.000Z')] }),
])
expect(medianTimeToFirstEditMs([project([s])])).toBe(30_000)
})
it('excludes sessions with no edits (not counted as zero)', () => {
const withEdit = session('s1', [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [edit('2026-05-05T00:00:20.000Z')] })])
const noEdit = session('s2', [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [call({ tools: ['Read'], timestamp: '2026-05-05T00:05:00.000Z' })] })])
expect(medianTimeToFirstEditMs([project([withEdit, noEdit])])).toBe(20_000)
})
it('takes the median across sessions (even count averages the middle two)', () => {
const mk = (id: string, secs: number) => session(id, [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [edit(new Date(Date.parse('2026-05-05T00:00:00.000Z') + secs * 1000).toISOString())] })])
// deltas: 10, 20, 30, 40 -> median (20+30)/2 = 25s
expect(medianTimeToFirstEditMs([project([mk('a', 10), mk('b', 20), mk('c', 30), mk('d', 40)])])).toBe(25_000)
})
it('clamps out-of-order timestamps to zero', () => {
const s = session('s1', [turn({ timestamp: '2026-05-05T00:01:00.000Z', calls: [edit('2026-05-05T00:00:00.000Z')] })])
expect(medianTimeToFirstEditMs([project([s])])).toBe(0)
})
it('returns null when no session has an edit', () => {
const s = session('s1', [turn({ timestamp: '2026-05-05T00:00:00.000Z', calls: [call({ tools: ['Read'] })] })])
expect(medianTimeToFirstEditMs([project([s])])).toBeNull()
})
})
describe('aggregateFileChurn', () => {
const editSeq = (file: string): ToolCall[][] => [[{ tool: 'Edit', file }]]
it('ranks by distinct sessions then total edits, and relativizes paths', () => {
const s1 = session('s1', [
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/src/a.ts') })] }),
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/src/a.ts') })] }),
turn({ sessionId: 's1', calls: [call({ tools: ['Write'], toolSequence: [[{ tool: 'Write', file: '/home/u/app/src/b.ts' }]] })] }),
])
const s2 = session('s2', [
turn({ sessionId: 's2', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/src/a.ts') })] }),
])
const files = aggregateFileChurn([project([s1, s2])])
expect(files[0]).toEqual({ path: 'src/a.ts', sessions: 2, edits: 3 })
expect(files[1]).toEqual({ path: 'src/b.ts', sessions: 1, edits: 1 })
})
it('breaks a session-count tie by edits then path', () => {
const s = session('s1', [
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/z.ts') })] }),
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/z.ts') })] }),
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq('/home/u/app/a.ts') })] }),
])
const files = aggregateFileChurn([project([s])])
// both touched by 1 session; z.ts has more edits so it ranks first
expect(files.map(f => f.path)).toEqual(['z.ts', 'a.ts'])
})
it('ignores non-edit tools and calls without a file path', () => {
const s = session('s1', [
turn({ sessionId: 's1', calls: [call({ tools: ['Read'], toolSequence: [[{ tool: 'Read', file: '/home/u/app/r.ts' }]] })] }),
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: [[{ tool: 'Edit' }]] })] }),
])
expect(aggregateFileChurn([project([s])])).toEqual([])
})
it('respects the limit', () => {
const turns = Array.from({ length: 5 }, (_, i) =>
turn({ sessionId: 's1', calls: [call({ tools: ['Edit'], toolSequence: editSeq(`/home/u/app/f${i}.ts`) })] }))
const files = aggregateFileChurn([project([session('s1', turns)])], 3)
expect(files).toHaveLength(3)
})
})
describe('computePricingCoverage', () => {
it('is 1 when everything is priced', () => {
expect(computePricingCoverage(100, 0)).toBe(1)
})
it('is the priced share when some calls are unpriced', () => {
expect(computePricingCoverage(100, 25)).toBe(0.75)
})
it('is 1 when there are no cost-bearing calls', () => {
expect(computePricingCoverage(0, 0)).toBe(1)
})
it('clamps to 0 if unpriced somehow exceeds the total', () => {
expect(computePricingCoverage(10, 20)).toBe(0)
})
})
describe('worstOneShotCategory', () => {
it('picks the lowest one-shot rate above the edit-turn floor', () => {
const s = session('s1', [], {
feature: cat(10, 3), // 30%
debugging: cat(8, 6), // 75%
} as SessionSummary['categoryBreakdown'])
const worst = worstOneShotCategory([project([s])])
expect(worst?.category).toBe('Feature Dev')
expect(worst?.rate).toBe(30)
expect(worst?.editTurns).toBe(10)
})
it('excludes categories below the edit-turn floor', () => {
const s = session('s1', [], {
feature: cat(2, 0), // 0% but only 2 edit turns
debugging: cat(10, 8), // 80%
} as SessionSummary['categoryBreakdown'])
const worst = worstOneShotCategory([project([s])])
expect(worst?.category).toBe('Debugging')
})
it('returns null when nothing qualifies', () => {
const s = session('s1', [], { feature: cat(1, 0) } as SessionSummary['categoryBreakdown'])
expect(worstOneShotCategory([project([s])])).toBeNull()
})
})
describe('buildCoachingNotes', () => {
it('emits a note per strong signal and never uses em-dashes', () => {
const notes = buildCoachingNotes({
worstOneShot: { category: 'Feature Dev', rate: 40, editTurns: 12 },
corrections: 6,
correctionRate: 0.2,
topReworkedFile: { path: 'src/a.ts', sessions: 4, edits: 20 },
medianTimeToFirstEditMs: 6 * 60 * 1000,
})
expect(notes.length).toBeGreaterThan(0)
expect(notes.length).toBeLessThanOrEqual(3)
for (const n of notes) expect(n).not.toContain('—')
})
it('caps at 3 notes even when all four signals fire', () => {
const notes = buildCoachingNotes({
worstOneShot: { category: 'Feature Dev', rate: 40, editTurns: 12 },
corrections: 6,
correctionRate: 0.2,
topReworkedFile: { path: 'src/a.ts', sessions: 4, edits: 20 },
medianTimeToFirstEditMs: 6 * 60 * 1000,
})
expect(notes).toHaveLength(3)
})
it('stays silent when every signal is below threshold', () => {
const notes = buildCoachingNotes({
worstOneShot: { category: 'Feature Dev', rate: 90, editTurns: 12 },
corrections: 1,
correctionRate: 0.02,
topReworkedFile: { path: 'src/a.ts', sessions: 1, edits: 2 },
medianTimeToFirstEditMs: 10 * 1000,
})
expect(notes).toEqual([])
})
it('requires both a high rate and a minimum count for the correction note', () => {
const highRateLowCount = buildCoachingNotes({ correctionRate: 0.5, corrections: 1 })
expect(highRateLowCount).toEqual([])
})
})