From f32c00d2b17bee2e0017ece8997cc2ef57c7d8f6 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:47:46 -0700 Subject: [PATCH] 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. --- dash/src/App.tsx | 37 +++- src/menubar-json.ts | 46 ++++ src/optimize.ts | 54 ++++- src/usage-aggregator.ts | 26 ++- src/workflow-insights.ts | 225 +++++++++++++++++++ tests/menubar-carried-headline.test.ts | 9 + tests/workflow-insights.test.ts | 288 +++++++++++++++++++++++++ 7 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 src/workflow-insights.ts create mode 100644 tests/workflow-insights.test.ts diff --git a/dash/src/App.tsx b/dash/src/App.tsx index 92fe1ae..6b1e5a7 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -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: - + m.cost > 0).slice(0, 8).map((m) => ({ + name: m.name, + cost: usd(m.cost), + calls: fmtNum(m.calls), + savings: usd(m.savingsUSD), + }))} + /> + + + +
+ + ({ + name: m.name, + costPerEdit: usd(m.costPerEdit), + oneShot: `${Math.round(m.oneShotRate * 100)}%`, + }))} + />
@@ -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), }))} /> )} diff --git a/src/menubar-json.ts b/src/menubar-json.ts index c59bc17..e696572 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -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 = '' 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 ?? [], diff --git a/src/optimize.ts b/src/optimize.ts index 7d281bd..7900998 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -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 } @@ -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, 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, } } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 13ad0f7..75fa6cc 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -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 === '' ? 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 diff --git a/src/workflow-insights.ts b/src/workflow-insights.ts new file mode 100644 index 0000000..370cec2 --- /dev/null +++ b/src/workflow-insights.ts @@ -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; edits: number } + const byPath = new Map() + + 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() + 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) +} diff --git a/tests/menubar-carried-headline.test.ts b/tests/menubar-carried-headline.test.ts index 4b8af48..93dc81d 100644 --- a/tests/menubar-carried-headline.test.ts +++ b/tests/menubar-carried-headline.test.ts @@ -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 () => { diff --git a/tests/workflow-insights.test.ts b/tests/workflow-insights.test.ts new file mode 100644 index 0000000..e566e69 --- /dev/null +++ b/tests/workflow-insights.test.ts @@ -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([]) + }) +})