Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/ADOPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
84 changes: 84 additions & 0 deletions src/cli/maintain-report-export.ts
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 30 additions & 6 deletions src/cli/pdf-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
}

Expand All @@ -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`);
});
Expand Down
38 changes: 34 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>', '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');
Expand All @@ -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`);
Expand Down
58 changes: 58 additions & 0 deletions test/maintain-report-export.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});