From 3f6ec0ef85e29d331bc281c88cd03aaa7664df4e Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Thu, 12 Feb 2026 23:16:12 +0000 Subject: [PATCH 1/2] feat: add onProgress callback for liberate/init progress feedback (GIT-64) Add ILiberateProgress type and optional onProgress callback to ILiberateOptions. LiberateService emits progress at triage, enriching, and complete phases. Both init and liberate CLI commands use the callback to write in-place progress to stderr. Co-Authored-By: Claude Opus 4.6 --- .../interfaces/ILiberateService.ts | 20 +++++++++++++++++++ src/application/services/LiberateService.ts | 8 ++++++++ src/commands/init.ts | 14 +++++++++++++ src/commands/liberate.ts | 14 +++++++++++++ 4 files changed, 56 insertions(+) diff --git a/src/application/interfaces/ILiberateService.ts b/src/application/interfaces/ILiberateService.ts index 6c56efc5..a4143798 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' | 'enriching' | 'complete'; + /** Current commit index (1-based). */ + 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..892dd124 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: 'enriching', 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..75e2f30b 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 type { ILiberateProgress } from '../application/interfaces/ILiberateService'; interface IInitCommandOptions { yes?: boolean; @@ -195,9 +196,22 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger const container = createContainer({ logger, scope: 'init', enrich: true }); const { liberateService } = container.cradle; + const onProgress = (p: ILiberateProgress): void => { + if (p.phase === 'triage') { + process.stderr.write(`Found ${p.total} high-interest commits to analyze.\n`); + } else if (p.phase === 'enriching') { + const sha = p.sha.slice(0, 7); + const subject = p.subject.length > 60 ? p.subject.slice(0, 57) + '...' : p.subject; + process.stderr.write(`\r [${p.current}/${p.total}] ${sha} ${subject} (${p.factsExtracted} facts)`); + } else if (p.phase === 'complete') { + process.stderr.write('\n'); + } + }; + const result = await liberateService.liberate({ maxCommits: commitCount, enrich: true, + onProgress, }); console.log( diff --git a/src/commands/liberate.ts b/src/commands/liberate.ts index a5991b5c..2f0faef3 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 type { ILiberateProgress } from '../application/interfaces/ILiberateService'; interface ILiberateCommandOptions { since?: string; @@ -48,12 +49,25 @@ export async function liberateCommand(options: ILiberateCommandOptions, logger?: console.log('Dry run — no notes will be written.\n'); } + const onProgress = (p: ILiberateProgress): void => { + if (p.phase === 'triage') { + process.stderr.write(`Found ${p.total} high-interest commits to analyze.\n`); + } else if (p.phase === 'enriching') { + const sha = p.sha.slice(0, 7); + const subject = p.subject.length > 60 ? p.subject.slice(0, 57) + '...' : p.subject; + process.stderr.write(`\r [${p.current}/${p.total}] ${sha} ${subject} (${p.factsExtracted} facts)`); + } else if (p.phase === 'complete') { + process.stderr.write('\n'); + } + }; + const result = await liberateService.liberate({ since, maxCommits, dryRun: options.dryRun, threshold, enrich: options.enrich, + onProgress, }); console.log(`Commits scanned: ${result.commitsScanned}`); From 682d9e7fb9c7995035aba9dcc4b988721dfe4bc1 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Thu, 12 Feb 2026 23:22:08 +0000 Subject: [PATCH 2/2] fix: address review feedback on progress callback (GIT-64) - Extract shared createStderrProgressHandler() into src/commands/progress.ts - Add liberateWithProgress() try/finally wrapper for terminal cleanup - Gate \r updates behind process.stderr.isTTY; use newlines in non-TTY - Rename phase 'enriching' to 'processing' (accurate for heuristic-only) - Fix doc: current is 0 for triage/complete phases, 1-based for processing - Add 2 unit tests verifying onProgress contract and zero-commit edge case Co-Authored-By: Claude Opus 4.6 --- .../interfaces/ILiberateService.ts | 4 +- src/application/services/LiberateService.ts | 2 +- src/commands/init.ts | 27 ++++----- src/commands/liberate.ts | 31 ++++------ src/commands/progress.ts | 58 +++++++++++++++++++ .../services/LiberateService.test.ts | 48 +++++++++++++++ 6 files changed, 131 insertions(+), 39 deletions(-) create mode 100644 src/commands/progress.ts diff --git a/src/application/interfaces/ILiberateService.ts b/src/application/interfaces/ILiberateService.ts index a4143798..4a491ea8 100644 --- a/src/application/interfaces/ILiberateService.ts +++ b/src/application/interfaces/ILiberateService.ts @@ -10,8 +10,8 @@ */ export interface ILiberateProgress { /** Current phase of processing. */ - readonly phase: 'triage' | 'enriching' | 'complete'; - /** Current commit index (1-based). */ + 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; diff --git a/src/application/services/LiberateService.ts b/src/application/services/LiberateService.ts index 892dd124..1b4d429f 100644 --- a/src/application/services/LiberateService.ts +++ b/src/application/services/LiberateService.ts @@ -84,7 +84,7 @@ export class LiberateService implements ILiberateService { let commitIndex = 0; for (const scored of triageResult.highInterest) { commitIndex++; - options?.onProgress?.({ phase: 'enriching', current: commitIndex, total: highInterestTotal, sha: scored.commit.sha, subject: scored.commit.subject, factsExtracted: totalFactsExtracted }); + 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); diff --git a/src/commands/init.ts b/src/commands/init.ts index 75e2f30b..2a47f7bd 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -19,7 +19,7 @@ import { } from './init-hooks'; import { buildMcpConfig } from './init-mcp'; import { createContainer } from '../infrastructure/di'; -import type { ILiberateProgress } from '../application/interfaces/ILiberateService'; +import { createStderrProgressHandler, liberateWithProgress } from './progress'; interface IInitCommandOptions { yes?: boolean; @@ -196,23 +196,16 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger const container = createContainer({ logger, scope: 'init', enrich: true }); const { liberateService } = container.cradle; - const onProgress = (p: ILiberateProgress): void => { - if (p.phase === 'triage') { - process.stderr.write(`Found ${p.total} high-interest commits to analyze.\n`); - } else if (p.phase === 'enriching') { - const sha = p.sha.slice(0, 7); - const subject = p.subject.length > 60 ? p.subject.slice(0, 57) + '...' : p.subject; - process.stderr.write(`\r [${p.current}/${p.total}] ${sha} ${subject} (${p.factsExtracted} facts)`); - } else if (p.phase === 'complete') { - process.stderr.write('\n'); - } - }; - - 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 2f0faef3..62f56084 100644 --- a/src/commands/liberate.ts +++ b/src/commands/liberate.ts @@ -4,7 +4,7 @@ import { createContainer } from '../infrastructure/di'; import type { ILogger } from '../domain/interfaces/ILogger'; -import type { ILiberateProgress } from '../application/interfaces/ILiberateService'; +import { createStderrProgressHandler, liberateWithProgress } from './progress'; interface ILiberateCommandOptions { since?: string; @@ -49,26 +49,19 @@ export async function liberateCommand(options: ILiberateCommandOptions, logger?: console.log('Dry run — no notes will be written.\n'); } - const onProgress = (p: ILiberateProgress): void => { - if (p.phase === 'triage') { - process.stderr.write(`Found ${p.total} high-interest commits to analyze.\n`); - } else if (p.phase === 'enriching') { - const sha = p.sha.slice(0, 7); - const subject = p.subject.length > 60 ? p.subject.slice(0, 57) + '...' : p.subject; - process.stderr.write(`\r [${p.current}/${p.total}] ${sha} ${subject} (${p.factsExtracted} facts)`); - } else if (p.phase === 'complete') { - process.stderr.write('\n'); - } - }; + const onProgress = createStderrProgressHandler(); - const result = await liberateService.liberate({ - since, - maxCommits, - dryRun: options.dryRun, - threshold, - enrich: options.enrich, + 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); + }); }); });