diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md index c15243b..25d6748 100644 --- a/docs/ADOPTION.md +++ b/docs/ADOPTION.md @@ -168,5 +168,7 @@ sitting in one place, verified, with a receipt.) **What does it cost?** Detection scopes are mostly toolchain work (fast, cheap even on a local model). Each run makes one small change, so review time stays near zero. -**How do I show this to my team?** `qodex maintain-demo` (interactive page) · -`--markdown` (README-ready writeup) · `--pdf` (one-page PDF for the meeting). +**How do I show this to my team?** The *story*: `qodex maintain-demo` (interactive page) · +`--markdown` (README-ready writeup) · `--pdf` (one-page PDF for the meeting). The *numbers*: +`qodex maintain-report --markdown` (paste the live receipt-backed report into a PR or standup +thread) · `--pdf` (one-pager with the 8-week trend as a real bar chart). diff --git a/src/cli/maintain-report-export.ts b/src/cli/maintain-report-export.ts new file mode 100644 index 0000000..9e40796 --- /dev/null +++ b/src/cli/maintain-report-export.ts @@ -0,0 +1,84 @@ +/** + * Self-Improvement Report exporters — the REAL maintain numbers (not the demo story) as: + * - Markdown: paste-ready for a PR description / team update / docs. + * - PDF blocks: a shareable one-pager via pdf-lite, with the 8-week trend as a real bar chart + * (vector rects — sparkline glyphs aren't in the Latin-1 base fonts). + * + * PURE (data in, artifact out; caller supplies generatedAt) — both renderers share one data + * struct so the report can never disagree with itself across formats. + */ +import type { MaintainStats, MaintainWeekly, MaintainForecast } from './maintain-stats.js'; +import type { PdfBlock } from './pdf-lite.js'; + +export interface MaintainReportData { + generatedAt: string; // e.g. '2026-07-02' + project?: string; + stats: MaintainStats; + weekly: MaintainWeekly; + trend: number[]; // opened/week, oldest→newest + forecast: MaintainForecast; + projection: { cleanupsPerMonth: number; minutesPerMonth: number }; + next?: { scope: string; why: string } | null; +} + +const dirWord = (d: MaintainForecast['direction']): string => + d === 'rising' ? 'rising ↑' : d === 'falling' ? 'cooling ↓' : 'steady →'; + +function sparkline(trend: number[]): string { + const max = Math.max(1, ...trend); + const b = '▁▂▃▄▅▆▇█'; + return trend.map(n => b[Math.min(7, Math.round((n / max) * 7))]).join(''); +} + +/** Markdown report — paste into a PR / issue / team chat. PURE. */ +export function buildMaintainReportMarkdown(d: MaintainReportData): string { + const s = d.stats; + const lines = [ + `## 🔧 Self-Improvement Report${d.project ? ` — ${d.project}` : ''}`, + '', + `*Generated ${d.generatedAt} by [QodeX](https://github.com/QodeXcli/QodeX) \`maintain\` — every number below comes from verified-run receipts, not model claims.*`, + '', + '| | |', + '|---|---|', + `| **Cleanup PRs shipped** | ${s.opened} |`, + `| **Safely blocked** (guardrail declined) | ${s.blocked} |`, + `| **Files cleaned** | ${s.filesCleaned} |`, + `| **Est. minutes saved** | ~${s.estMinutesSaved} |`, + `| **Success rate** | ${Math.round(s.successRate * 100)}% of ${s.totalRuns} runs |`, + '', + `**This week:** ${d.weekly.opened} PR(s) · ${d.weekly.filesCleaned} files · ${d.weekly.openedDelta >= 0 ? '▲' : '▼'}${Math.abs(d.weekly.openedDelta)} vs last week`, + `**8-week trend:** \`${sparkline(d.trend)}\` (opened/week)`, + `**Forecast:** ${dirWord(d.forecast.direction)} · avg ~${d.forecast.weeklyAvg}/wk · next week ≈ ${d.forecast.nextWeek}`, + `**Projected:** ~${d.projection.cleanupsPerMonth} cleanups/mo · ~${d.projection.minutesPerMonth} min/mo`, + '', + `**By scope:** ${s.byScope.map(x => `\`${x.scope}\` ${x.opened}/${x.runs}`).join(' · ') || '—'}`, + ]; + if (d.next) lines.push('', `**Suggested next:** \`${d.next.scope}\` — ${d.next.why}`); + return lines.join('\n'); +} + +/** PDF report blocks (feed to pdf-lite buildPdf). PURE. */ +export function buildMaintainReportPdfBlocks(d: MaintainReportData): PdfBlock[] { + const s = d.stats; + const kv = (k: string, v: string): PdfBlock => ({ text: `${k}: ${v}`, size: 11, indent: 10 }); + const blocks: PdfBlock[] = [ + { text: `Self-Improvement Report${d.project ? ` — ${d.project}` : ''}`, size: 18, bold: true }, + { text: `Generated ${d.generatedAt} by QodeX maintain. Every number comes from verified-run receipts — filesChanged from real git diffs, verification from checkers that actually ran.`, size: 9, spaceBefore: 4 }, + { text: 'All time', size: 13, bold: true, spaceBefore: 12 }, + kv('Cleanup PRs shipped', String(s.opened)), + kv('Safely blocked (guardrail declined)', String(s.blocked)), + kv('Files cleaned', String(s.filesCleaned)), + kv('Est. minutes saved', `~${s.estMinutesSaved}`), + kv('Success rate', `${Math.round(s.successRate * 100)}% of ${s.totalRuns} runs`), + { text: 'This week', size: 13, bold: true, spaceBefore: 10 }, + kv('Opened', `${d.weekly.opened} PR(s) · ${d.weekly.filesCleaned} file(s) · ${d.weekly.openedDelta >= 0 ? '+' : '-'}${Math.abs(d.weekly.openedDelta)} vs last week`), + { text: '8-week trend (opened/week)', size: 13, bold: true, spaceBefore: 10 }, + { text: '', bars: d.trend, size: 9 }, + kv('Forecast', `${dirWord(d.forecast.direction)} - avg ~${d.forecast.weeklyAvg}/wk - next week ~ ${d.forecast.nextWeek}`), + kv('Projected', `~${d.projection.cleanupsPerMonth} cleanups/mo - ~${d.projection.minutesPerMonth} min/mo`), + { text: 'By scope', size: 13, bold: true, spaceBefore: 10 }, + ...(s.byScope.length ? s.byScope.map(x => kv(x.scope, `${x.opened} opened / ${x.runs} runs`)) : [{ text: 'no runs yet', size: 10, indent: 10 }]), + ]; + if (d.next) blocks.push({ text: 'Suggested next', size: 13, bold: true, spaceBefore: 10 }, kv(d.next.scope, d.next.why)); + return blocks; +} diff --git a/src/cli/pdf-lite.ts b/src/cli/pdf-lite.ts index 3163ae6..9b9b9e1 100644 --- a/src/cli/pdf-lite.ts +++ b/src/cli/pdf-lite.ts @@ -20,6 +20,10 @@ export interface PdfBlock { indent?: number; /** Extra space above the block, in points. */ spaceBefore?: number; + /** Render a mini bar chart (values normalized to the max) instead of text — real vector + * rectangles, since sparkline glyphs (▁▂▃…) aren't in the Latin-1 base fonts. `text` is + * used as the row label to the left of the bars. */ + bars?: number[]; } const PAGE_W = 595; // A4 portrait, points @@ -68,20 +72,29 @@ function wrap(text: string, size: number, mono: boolean, widthPts: number): stri * with `Buffer.from(pdf, 'latin1')`. PURE + deterministic (no clock, no randomness). */ export function buildPdf(blocks: PdfBlock[]): string { - // 1. Lay blocks out into pages of positioned lines. - interface Line { x: number; y: number; size: number; font: 'F1' | 'F2' | 'F3'; text: string } - const pages: Line[][] = [[]]; + // 1. Lay blocks out into pages of positioned elements (text lines + bar-chart rows). + interface Line { kind: 'text'; x: number; y: number; size: number; font: 'F1' | 'F2' | 'F3'; text: string } + interface BarsRow { kind: 'bars'; x: number; y: number; values: number[]; label: string; size: number } + type El = Line | BarsRow; + const BAR_H = 36; const BAR_W = 14; const BAR_GAP = 4; + const pages: El[][] = [[]]; let y = PAGE_H - MARGIN; for (const b of blocks) { const size = b.size ?? 11; - const gap = Math.round(size * 1.45); const x = MARGIN + (b.indent ?? 0); y -= b.spaceBefore ?? 0; + if (b.bars) { + if (y < MARGIN + BAR_H + size) { pages.push([]); y = PAGE_H - MARGIN; } + y -= BAR_H + Math.round(size * 0.6); + pages[pages.length - 1]!.push({ kind: 'bars', x, y, values: b.bars, label: pdfSanitize(b.text), size }); + continue; + } + const gap = Math.round(size * 1.45); const font = b.mono ? 'F3' : b.bold ? 'F2' : 'F1'; for (const lineText of wrap(pdfSanitize(b.text), size, !!b.mono, PAGE_W - x - MARGIN)) { if (y < MARGIN + size) { pages.push([]); y = PAGE_H - MARGIN; } y -= gap; - pages[pages.length - 1]!.push({ x, y, size, font, text: lineText }); + pages[pages.length - 1]!.push({ kind: 'text', x, y, size, font, text: lineText }); } } @@ -103,7 +116,18 @@ export function buildPdf(blocks: PdfBlock[]): string { `/Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents ${contentNum} 0 R >>\nendobj\n`, ); const stream = lines - .map(l => `BT /${l.font} ${l.size} Tf 1 0 0 1 ${l.x} ${l.y} Tm (${pdfEscape(l.text)}) Tj ET`) + .map(l => { + if (l.kind === 'text') return `BT /${l.font} ${l.size} Tf 1 0 0 1 ${l.x} ${l.y} Tm (${pdfEscape(l.text)}) Tj ET`; + // Bar row: label text, then normalized filled rects (q/Q so the gray fill never leaks into text). + const max = Math.max(1, ...l.values); + const label = l.label ? `BT /F1 ${l.size} Tf 1 0 0 1 ${l.x} ${l.y + 2} Tm (${pdfEscape(l.label)}) Tj ET\n` : ''; + const bx0 = l.x + (l.label ? Math.min(150, l.label.length * l.size * 0.55 + 12) : 0); + const rects = l.values.map((v, i) => { + const h = Math.max(1, Math.round((v / max) * BAR_H)); + return `${bx0 + i * (BAR_W + BAR_GAP)} ${l.y} ${BAR_W} ${h} re f`; + }).join(' '); + return `${label}q 0.45 g ${rects} Q`; + }) .join('\n'); objects.push(`${contentNum} 0 obj\n<< /Length ${stream.length} >>\nstream\n${stream}\nendstream\nendobj\n`); }); diff --git a/src/index.ts b/src/index.ts index ee5d373..2c5f28e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -727,8 +727,11 @@ program program .command('maintain-report') - .description('Self-Improvement Report — the maintain recipe\'s cleanups, files cleaned, and time saved') - .action(async () => { + .description('Self-Improvement Report — real receipt-backed numbers; --markdown for PRs, --pdf for a shareable one-pager') + .option('--markdown', 'emit the report as Markdown (paste into a PR / issue / team chat)') + .option('--pdf', 'write the report as a one-page PDF (real bar chart for the 8-week trend)') + .option('-o, --out ', 'with --markdown/--pdf, write to this file (PDF default: ~/.qodex/maintain-report.pdf)') + .action(async (opts: { markdown?: boolean; pdf?: boolean; out?: string }) => { const { getScheduleStore } = await import('./schedule/store.js'); const { parseMaintainScope, MAINTAIN_SCOPES } = await import('./schedule/recipes.js'); const { buildMaintainStats, weeklyReport, recommendNextScope, trendByWeek, projectMonthly, forecastTrend } = await import('./cli/maintain-stats.js'); @@ -747,13 +750,40 @@ program const wk = weeklyReport(runs, now); const next = recommendNextScope(runs, stats, MAINTAIN_SCOPES); const proj = projectMonthly(runs, now); - const spark = (() => { const t = trendByWeek(runs, now); const max = Math.max(1, ...t); const b = '▁▂▃▄▅▆▇█'; return t.map(n => b[Math.min(7, Math.round((n / max) * 7))]).join(''); })(); + const fc = forecastTrend(runs, now); + const trend = trendByWeek(runs, now); + + // Export paths: the SAME data through the PURE exporters — report can't disagree across formats. + if (opts.markdown || opts.pdf) { + const { buildMaintainReportMarkdown, buildMaintainReportPdfBlocks } = await import('./cli/maintain-report-export.js'); + const data = { + generatedAt: new Date(now).toISOString().slice(0, 10), + project: process.cwd().split(/[\\/]/).filter(Boolean).pop(), + stats, weekly: wk, trend, forecast: fc, projection: proj, next, + }; + if (opts.markdown) { + const md = buildMaintainReportMarkdown(data); + if (opts.out) { const { promises: fs } = await import('fs'); await fs.writeFile(opts.out, md); console.log(`\n📝 Report → ${opts.out}\n`); } + else console.log(md); + process.exit(0); + } + const { buildPdf } = await import('./cli/pdf-lite.js'); + const { QODEX_HOME } = await import('./config/defaults.js'); + const path = await import('path'); + const { promises: fs } = await import('fs'); + const out = opts.out ?? path.join(QODEX_HOME, 'maintain-report.pdf'); + await fs.mkdir(path.dirname(out), { recursive: true }).catch(() => {}); + await fs.writeFile(out, Buffer.from(buildPdf(buildMaintainReportPdfBlocks(data)), 'latin1')); + console.log(`\n📄 Report → ${out}\n`); + process.exit(0); + } + + const spark = (() => { const t = trend; const max = Math.max(1, ...t); const b = '▁▂▃▄▅▆▇█'; return t.map(n => b[Math.min(7, Math.round((n / max) * 7))]).join(''); })(); console.log('\n🔧 QodeX Self-Improvement Report\n'); if (stats.totalRuns === 0) { console.log(' No maintain runs yet. `qodex schedule add --recipe maintain --prompt "unused-imports"`.\n'); process.exit(0); } console.log(` All time: ${stats.opened} cleanup PR(s) · ${stats.blocked} safely blocked · ${stats.filesCleaned} files cleaned · ~${stats.estMinutesSaved} min saved`); console.log(` This week: ${wk.opened} PR(s) · ${wk.filesCleaned} files · ${wk.openedDelta >= 0 ? '▲' : '▼'}${Math.abs(wk.openedDelta)} vs last week`); console.log(` 8-wk trend: ${spark} (opened/week)`); - const fc = forecastTrend(runs, now); const arrow = fc.direction === 'rising' ? 'rising ↑' : fc.direction === 'falling' ? 'cooling ↓' : 'steady →'; console.log(` Forecast: ${arrow} · avg ~${fc.weeklyAvg}/wk · next week ≈ ${fc.nextWeek} cleanup(s)`); console.log(` Projected: ~${proj.cleanupsPerMonth} cleanups/mo · ~${proj.minutesPerMonth} min/mo at the current rate`); diff --git a/test/maintain-report-export.test.ts b/test/maintain-report-export.test.ts new file mode 100644 index 0000000..4017177 --- /dev/null +++ b/test/maintain-report-export.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { buildMaintainReportMarkdown, buildMaintainReportPdfBlocks, type MaintainReportData } from '../src/cli/maintain-report-export.ts'; +import { buildPdf } from '../src/cli/pdf-lite.ts'; + +const DATA: MaintainReportData = { + generatedAt: '2026-07-02', + project: 'qodex', + stats: { + totalRuns: 8, opened: 5, blocked: 2, failed: 1, successRate: 0.625, filesCleaned: 14, estMinutesSaved: 25, + byScope: [{ scope: 'unused-imports', runs: 4, opened: 3 }, { scope: 'unused-locals', runs: 4, opened: 2 }], + lastRun: { when: '2h ago', status: 'opened', scope: 'unused-imports' }, + }, + weekly: { opened: 2, blocked: 1, filesCleaned: 5, minutesSaved: 10, priorOpened: 1, openedDelta: 1 }, + trend: [0, 1, 0, 2, 1, 0, 1, 2], + forecast: { weeklyAvg: 0.88, slope: 0.12, direction: 'steady', nextWeek: 1, weeks: 8 }, + projection: { cleanupsPerMonth: 5, minutesPerMonth: 25 }, + next: { scope: 'extract-helper', why: 'never run here yet — try it' }, +}; + +describe('buildMaintainReportMarkdown', () => { + const md = buildMaintainReportMarkdown(DATA); + it('is a paste-ready PR block with the receipt-backed framing and all key numbers', () => { + expect(md.startsWith('## 🔧 Self-Improvement Report — qodex')).toBe(true); + expect(md).toContain('verified-run receipts, not model claims'); + expect(md).toContain('| **Cleanup PRs shipped** | 5 |'); + expect(md).toContain('| **Safely blocked** (guardrail declined) | 2 |'); + expect(md).toContain('63% of 8 runs'); + expect(md).toMatch(/`[▁▂▃▄▅▆▇█]{8}`/); // unicode sparkline is fine in Markdown + expect(md).toContain('`unused-imports` 3/4'); + expect(md).toContain('**Suggested next:** `extract-helper`'); + }); + it('omits the suggestion line when next is null', () => { + expect(buildMaintainReportMarkdown({ ...DATA, next: null })).not.toContain('Suggested next'); + }); +}); + +describe('buildMaintainReportPdfBlocks → buildPdf', () => { + const pdf = buildPdf(buildMaintainReportPdfBlocks(DATA)); + it('renders a structurally valid PDF with the real numbers', () => { + expect(pdf.startsWith('%PDF-1.4\n')).toBe(true); + expect(pdf).toContain('(Self-Improvement Report - qodex) Tj'); + expect(pdf).toContain('Cleanup PRs shipped: 5'); // pdfSanitize collapses double spaces + expect(pdf).toContain('63% of 8 runs'); + expect(pdf).toContain('extract-helper'); + }); + it('draws the 8-week trend as REAL vector bars (re f rects), normalized, gray-scoped with q/Q', () => { + expect(pdf).toMatch(/q 0\.45 g( \d+ \d+ 14 \d+ re f){8} Q/); // 8 bars, width 14 + const heights = [...pdf.matchAll(/(\d+) (\d+) 14 (\d+) re f/g)].map(m => Number(m[3])); + expect(Math.max(...heights)).toBe(36); // max value → full BAR_H + expect(Math.min(...heights)).toBe(1); // zero weeks → 1pt floor (visible baseline) + expect(pdf).not.toContain('▁'); // no sparkline glyphs leak into the PDF + }); + it('all-zero trend does not divide by zero (flat 1pt bars)', () => { + const flat = buildPdf(buildMaintainReportPdfBlocks({ ...DATA, trend: [0, 0, 0, 0] })); + const heights = [...flat.matchAll(/\d+ \d+ 14 (\d+) re f/g)].map(m => Number(m[1])); + expect(heights).toEqual([1, 1, 1, 1]); + }); +});