diff --git a/src/application/interfaces/ILiberateService.ts b/src/application/interfaces/ILiberateService.ts index 6c56efc5..4a491ea8 100644 --- a/src/application/interfaces/ILiberateService.ts +++ b/src/application/interfaces/ILiberateService.ts @@ -5,6 +5,24 @@ * with structured memory notes. */ +/** + * Progress update emitted during liberate processing. + */ +export interface ILiberateProgress { + /** Current phase of processing. */ + readonly phase: 'triage' | 'processing' | 'complete'; + /** Current commit index (1-based during 'processing'; 0 for triage/complete). */ + readonly current: number; + /** Total high-interest commits to process. */ + readonly total: number; + /** Current commit SHA (empty for triage/complete). */ + readonly sha: string; + /** Current commit subject (empty for triage/complete). */ + readonly subject: string; + /** Running total of facts extracted so far. */ + readonly factsExtracted: number; +} + /** * Options for liberate operation. */ @@ -21,6 +39,8 @@ export interface ILiberateOptions { readonly cwd?: string; /** Enable LLM enrichment (requires API key). */ readonly enrich?: boolean; + /** Optional progress callback for UI feedback. */ + readonly onProgress?: (progress: ILiberateProgress) => void; } /** diff --git a/src/application/services/LiberateService.ts b/src/application/services/LiberateService.ts index 1b5f6a3e..1b4d429f 100644 --- a/src/application/services/LiberateService.ts +++ b/src/application/services/LiberateService.ts @@ -77,8 +77,14 @@ export class LiberateService implements ILiberateService { this.logger?.debug('Triage complete', { total: triageResult.totalCommits, highInterest: triageResult.highInterest.length }); + const highInterestTotal = triageResult.highInterest.length; + options?.onProgress?.({ phase: 'triage', current: 0, total: highInterestTotal, sha: '', subject: '', factsExtracted: 0 }); + // Process each high-interest commit + let commitIndex = 0; for (const scored of triageResult.highInterest) { + commitIndex++; + options?.onProgress?.({ phase: 'processing', current: commitIndex, total: highInterestTotal, sha: scored.commit.sha, subject: scored.commit.subject, factsExtracted: totalFactsExtracted }); const text = `${scored.commit.subject}\n${scored.commit.body}`.trim(); const heuristicMatches = extractPatternMatches(text); @@ -150,6 +156,8 @@ export class LiberateService implements ILiberateService { }); } + options?.onProgress?.({ phase: 'complete', current: highInterestTotal, total: highInterestTotal, sha: '', subject: '', factsExtracted: totalFactsExtracted }); + const result: ILiberateResult = { commitsScanned: triageResult.totalCommits, commitsAnnotated: annotations.length, diff --git a/src/commands/init.ts b/src/commands/init.ts index b8900a7f..2a47f7bd 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -19,6 +19,7 @@ import { } from './init-hooks'; import { buildMcpConfig } from './init-mcp'; import { createContainer } from '../infrastructure/di'; +import { createStderrProgressHandler, liberateWithProgress } from './progress'; interface IInitCommandOptions { yes?: boolean; @@ -195,10 +196,16 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger const container = createContainer({ logger, scope: 'init', enrich: true }); const { liberateService } = container.cradle; - const result = await liberateService.liberate({ - maxCommits: commitCount, - enrich: true, - }); + const onProgress = createStderrProgressHandler(); + + const result = await liberateWithProgress( + () => liberateService.liberate({ + maxCommits: commitCount, + enrich: true, + onProgress, + }), + onProgress, + ); console.log( `Commits scanned: ${result.commitsScanned} | ` + diff --git a/src/commands/liberate.ts b/src/commands/liberate.ts index a5991b5c..62f56084 100644 --- a/src/commands/liberate.ts +++ b/src/commands/liberate.ts @@ -4,6 +4,7 @@ import { createContainer } from '../infrastructure/di'; import type { ILogger } from '../domain/interfaces/ILogger'; +import { createStderrProgressHandler, liberateWithProgress } from './progress'; interface ILiberateCommandOptions { since?: string; @@ -48,13 +49,19 @@ export async function liberateCommand(options: ILiberateCommandOptions, logger?: console.log('Dry run — no notes will be written.\n'); } - const result = await liberateService.liberate({ - since, - maxCommits, - dryRun: options.dryRun, - threshold, - enrich: options.enrich, - }); + const onProgress = createStderrProgressHandler(); + + const result = await liberateWithProgress( + () => liberateService.liberate({ + since, + maxCommits, + dryRun: options.dryRun, + threshold, + enrich: options.enrich, + onProgress, + }), + onProgress, + ); console.log(`Commits scanned: ${result.commitsScanned}`); console.log(`Commits annotated: ${result.commitsAnnotated}`); diff --git a/src/commands/progress.ts b/src/commands/progress.ts new file mode 100644 index 00000000..cae38798 --- /dev/null +++ b/src/commands/progress.ts @@ -0,0 +1,58 @@ +/** + * Shared stderr progress handler for liberate operations. + */ + +import type { ILiberateProgress } from '../application/interfaces/ILiberateService'; + +/** + * Create a progress callback that writes liberate progress to stderr. + * Uses in-place `\r` updates when stderr is a TTY, newline-based otherwise. + */ +export function createStderrProgressHandler(): (p: ILiberateProgress) => void { + const isTTY = process.stderr.isTTY; + let lastLineLength = 0; + let inPlaceStarted = false; + + return (p: ILiberateProgress): void => { + if (p.phase === 'triage') { + process.stderr.write(`Found ${p.total} high-interest commits to analyze.\n`); + } else if (p.phase === 'processing') { + const sha = p.sha.slice(0, 7); + const subject = p.subject.length > 60 ? p.subject.slice(0, 57) + '...' : p.subject; + const line = ` [${p.current}/${p.total}] ${sha} ${subject} (${p.factsExtracted} facts)`; + + if (isTTY) { + const padded = line.padEnd(lastLineLength, ' '); + lastLineLength = line.length; + inPlaceStarted = true; + process.stderr.write(`\r${padded}`); + } else { + process.stderr.write(`${line}\n`); + } + } else if (p.phase === 'complete') { + if (isTTY && inPlaceStarted) { + process.stderr.write('\n'); + inPlaceStarted = false; + } + } + }; +} + +/** + * Wrap a liberate call so that if it throws after writing in-place progress, + * a trailing newline is still written to stderr. + */ +export async function liberateWithProgress( + fn: () => Promise, + onProgress: ReturnType, +): Promise { + try { + return await fn(); + } finally { + // Ensure terminal is clean if progress was mid-line when error occurred. + // The 'complete' phase handler already writes \n, but if liberate threw + // before emitting 'complete', we need a fallback. We rely on the handler's + // internal state via one final 'complete' call. + onProgress({ phase: 'complete', current: 0, total: 0, sha: '', subject: '', factsExtracted: 0 }); + } +} diff --git a/tests/unit/application/services/LiberateService.test.ts b/tests/unit/application/services/LiberateService.test.ts index 3fe6332d..2bc75d36 100644 --- a/tests/unit/application/services/LiberateService.test.ts +++ b/tests/unit/application/services/LiberateService.test.ts @@ -1,5 +1,6 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; +import type { ILiberateProgress } from '../../../../src/application/interfaces/ILiberateService'; import { execFileSync } from 'child_process'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; @@ -93,5 +94,52 @@ describe('LiberateService', () => { assert.equal(result.commitsAnnotated, 0); assert.equal(result.factsExtracted, 0); }); + + it('should call onProgress with triage, processing, and complete phases', async () => { + const events: ILiberateProgress[] = []; + + await service.liberate({ + cwd: repoDir, + dryRun: true, + threshold: 1, + onProgress: (p) => events.push({ ...p }), + }); + + assert.ok(events.length >= 2, 'should emit at least triage + complete'); + + // First event is triage + assert.equal(events[0].phase, 'triage'); + assert.equal(events[0].current, 0); + assert.ok(events[0].total >= 0); + + // Last event is complete + const last = events[events.length - 1]; + assert.equal(last.phase, 'complete'); + + // All middle events are processing + const processingEvents = events.filter(e => e.phase === 'processing'); + for (let i = 0; i < processingEvents.length; i++) { + assert.equal(processingEvents[i].current, i + 1, 'current should be 1-based'); + assert.ok(processingEvents[i].sha.length > 0, 'sha should be non-empty'); + assert.ok(processingEvents[i].subject.length > 0, 'subject should be non-empty'); + } + }); + + it('should emit triage and complete even with zero high-interest commits', async () => { + const events: ILiberateProgress[] = []; + + await service.liberate({ + cwd: repoDir, + dryRun: true, + threshold: 100, + onProgress: (p) => events.push({ ...p }), + }); + + assert.equal(events.length, 2); + assert.equal(events[0].phase, 'triage'); + assert.equal(events[0].total, 0); + assert.equal(events[1].phase, 'complete'); + assert.equal(events[1].factsExtracted, 0); + }); }); });