diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index 69e2c06..3198c83 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -7,10 +7,6 @@ import { formatDurationMs, formatAgentLoadError, formatExplain, - hasBlockedGaps, - hasBridgedGaps, - hasNoGapsToBridge, - hasPipelineLearnings, mergePinnedFlavors, parseBetOption, parseCompletedRunArtifacts, @@ -483,40 +479,6 @@ describe('execute helpers', () => { }); }); - describe('hasNoGapsToBridge', () => { - it('returns true when gaps is undefined', () => { - expect(hasNoGapsToBridge(undefined)).toBe(true); - }); - - it('returns true when gaps is empty', () => { - expect(hasNoGapsToBridge([])).toBe(true); - }); - - it('returns false when there are gaps', () => { - expect(hasNoGapsToBridge([{ description: 'gap' }])).toBe(false); - }); - }); - - describe('hasBridgedGaps', () => { - it('returns true for non-empty bridged array', () => { - expect(hasBridgedGaps([{ id: '1' }])).toBe(true); - }); - - it('returns false for empty array', () => { - expect(hasBridgedGaps([])).toBe(false); - }); - }); - - describe('hasBlockedGaps', () => { - it('returns true for non-empty blocked array', () => { - expect(hasBlockedGaps([{ id: '1' }])).toBe(true); - }); - - it('returns false for empty array', () => { - expect(hasBlockedGaps([])).toBe(false); - }); - }); - describe('formatConfidencePercent', () => { it('converts decimal confidence to percent string', () => { expect(formatConfidencePercent(0.75)).toBe('75%'); @@ -530,13 +492,4 @@ describe('execute helpers', () => { }); }); - describe('hasPipelineLearnings', () => { - it('returns true for non-empty learnings', () => { - expect(hasPipelineLearnings(['learning 1'])).toBe(true); - }); - - it('returns false for empty learnings', () => { - expect(hasPipelineLearnings([])).toBe(false); - }); - }); }); diff --git a/src/cli/commands/execute.helpers.ts b/src/cli/commands/execute.helpers.ts index 68fadcc..2b4cb46 100644 --- a/src/cli/commands/execute.helpers.ts +++ b/src/cli/commands/execute.helpers.ts @@ -343,37 +343,9 @@ export function formatDurationMs(ms: number): string { return `${seconds}s`; } -/** - * Pure predicate: returns true when there are no gaps to bridge. - */ -export function hasNoGapsToBridge(gaps: readonly unknown[] | undefined): boolean { - return !gaps || gaps.length === 0; -} - -/** - * Pure predicate: returns true when bridged gaps should be reported. - */ -export function hasBridgedGaps(bridged: readonly unknown[]): boolean { - return bridged.length > 0; -} - -/** - * Pure predicate: returns true when blocked gaps should halt execution. - */ -export function hasBlockedGaps(blocked: readonly unknown[]): boolean { - return blocked.length > 0; -} - /** * Format confidence as a percentage string (0-100, no decimal). */ export function formatConfidencePercent(confidence: number): string { return `${(confidence * 100).toFixed(0)}%`; } - -/** - * Pure predicate: returns true when pipeline learnings should be printed. - */ -export function hasPipelineLearnings(learnings: readonly string[]): boolean { - return learnings.length > 0; -} diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 0c989ff..3d12f74 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -1,10 +1,9 @@ import { join } from 'node:path'; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import type { Command } from 'commander'; import { withCommandContext, kataDirPath } from '@cli/utils.js'; import { getLexicon } from '@cli/lexicon.js'; import { StageCategorySchema, type StageCategory } from '@domain/types/stage.js'; -import { SavedKataSchema, type FlavorHint } from '@domain/types/saved-kata.js'; +import type { FlavorHint } from '@domain/types/saved-kata.js'; import { KataConfigSchema } from '@domain/types/config.js'; import { StepRegistry } from '@infra/registries/step-registry.js'; import { FlavorRegistry } from '@infra/registries/flavor-registry.js'; @@ -17,13 +16,11 @@ import { WorkflowRunner } from '@features/execute/workflow-runner.js'; import { GapBridger } from '@features/execute/gap-bridger.js'; import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; import { UsageAnalytics } from '@infra/tracking/usage-analytics.js'; -import { isJsonFile } from '@shared/lib/file-filters.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; import { ProjectStateUpdater } from '@features/belt/belt-calculator.js'; import { CycleManager } from '@domain/services/cycle-manager.js'; import { SessionExecutionBridge } from '@infra/execution/session-bridge.js'; import { - assertValidKataName, betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, @@ -31,10 +28,6 @@ import { formatDurationMs, formatAgentLoadError, formatExplain, - hasBlockedGaps, - hasBridgedGaps, - hasNoGapsToBridge, - hasPipelineLearnings, mergePinnedFlavors, parseBetOption, parseCompletedRunArtifacts, @@ -43,6 +36,7 @@ import { resolveCompletionStatus, resolveJsonFlag, } from '@cli/commands/execute.helpers.js'; +import { listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from '@cli/commands/saved-kata-store.js'; import { resolveRef } from '@cli/resolve-ref.js'; import { handleStatus, handleStats, parseCategoryFilter } from './status.js'; @@ -679,20 +673,20 @@ function bridgeExecutionGaps(input: { suggestedFlavors: string[]; }>; }): boolean { - if (hasNoGapsToBridge(input.gaps)) return true; + if (!input.gaps || input.gaps.length === 0) return true; const store = new KnowledgeStore(kataDirPath(input.kataDir, 'knowledge')); const bridger = new GapBridger({ knowledgeStore: store }); const { blocked, bridged } = bridger.bridge(input.gaps!); - if (hasBlockedGaps(blocked)) { + if (blocked.length > 0) { console.error(`[kata] Blocked by ${blocked.length} high-severity gap(s):`); for (const gap of blocked) console.error(` • ${gap.description}`); process.exitCode = 1; return false; } - if (hasBridgedGaps(bridged)) { + if (bridged.length > 0) { console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`); ProjectStateUpdater.incrementGapsClosed(input.projectStateFile, bridged.length); } @@ -760,7 +754,7 @@ function printPipelineResult( console.log(` Artifact: ${stageResult.stageArtifact.name}`); } - if (hasPipelineLearnings(result.pipelineReflection.learnings)) { + if (result.pipelineReflection.learnings.length > 0) { console.log(''); console.log('Learnings:'); for (const learning of result.pipelineReflection.learnings) { @@ -821,79 +815,5 @@ function collect(value: string, previous: string[]): string[] { return previous.concat([value]); } -// --------------------------------------------------------------------------- -// Saved kata helpers -// --------------------------------------------------------------------------- - -function katasDir(kataDir: string): string { - return join(kataDir, KATA_DIRS.katas); -} - -export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> { - const dir = katasDir(kataDir); - if (!existsSync(dir)) return []; - return readdirSync(dir) - .filter(isJsonFile) - .map((f) => { - try { - const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8')); - return SavedKataSchema.parse(raw); - } catch (e) { - if (e instanceof SyntaxError || (e instanceof Error && e.constructor.name === 'ZodError')) { - console.error(`Warning: skipping invalid kata file "${f}": ${e.message}`); - return null; - } - throw e; - } - }) - .filter((k): k is NonNullable => k !== null); -} - -export function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record } { - assertValidKataName(name); - const filePath = join(katasDir(kataDir), `${name}.json`); - if (!existsSync(filePath)) { - throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`); - } - let raw: unknown; - try { - raw = JSON.parse(readFileSync(filePath, 'utf-8')); - } catch (e) { - throw new Error( - `Kata "${name}" has invalid JSON: ${e instanceof Error ? e.message : String(e)}`, - { cause: e }, - ); - } - try { - return SavedKataSchema.parse(raw); - } catch (e) { - throw new Error( - `Kata "${name}" has invalid structure. Ensure it has "name" (string) and "stages" (array of categories).`, - { cause: e }, - ); - } -} - -export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record): void { - assertValidKataName(name); - const dir = katasDir(kataDir); - mkdirSync(dir, { recursive: true }); - const kata = SavedKataSchema.parse({ name, stages, flavorHints }); - writeFileSync(join(dir, `${name}.json`), JSON.stringify(kata, null, 2), 'utf-8'); -} - -export function deleteSavedKata(kataDir: string, name: string): void { - assertValidKataName(name); - const filePath = join(katasDir(kataDir), `${name}.json`); - if (!existsSync(filePath)) { - throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`); - } - try { - unlinkSync(filePath); - } catch (e) { - throw new Error( - `Could not delete kata "${name}": ${e instanceof Error ? e.message : String(e)}`, - { cause: e }, - ); - } -} +// Re-export saved-kata CRUD for backward compatibility +export { listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from '@cli/commands/saved-kata-store.js'; diff --git a/src/cli/commands/saved-kata-store.ts b/src/cli/commands/saved-kata-store.ts new file mode 100644 index 0000000..489c6b5 --- /dev/null +++ b/src/cli/commands/saved-kata-store.ts @@ -0,0 +1,80 @@ +import { join } from 'node:path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import type { StageCategory } from '@domain/types/stage.js'; +import { SavedKataSchema, type FlavorHint } from '@domain/types/saved-kata.js'; +import { isJsonFile } from '@shared/lib/file-filters.js'; +import { KATA_DIRS } from '@shared/constants/paths.js'; +import { assertValidKataName } from '@cli/commands/execute.helpers.js'; + +function katasDir(kataDir: string): string { + return join(kataDir, KATA_DIRS.katas); +} + +export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> { + const dir = katasDir(kataDir); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter(isJsonFile) + .map((f) => { + try { + const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8')); + return SavedKataSchema.parse(raw); + } catch (e) { + if (e instanceof SyntaxError || (e instanceof Error && e.constructor.name === 'ZodError')) { + console.error(`Warning: skipping invalid kata file "${f}": ${e.message}`); + return null; + } + throw e; + } + }) + .filter((k): k is NonNullable => k !== null); +} + +export function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record } { + assertValidKataName(name); + const filePath = join(katasDir(kataDir), `${name}.json`); + if (!existsSync(filePath)) { + throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`); + } + let raw: unknown; + try { + raw = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch (e) { + throw new Error( + `Kata "${name}" has invalid JSON: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } + try { + return SavedKataSchema.parse(raw); + } catch (e) { + throw new Error( + `Kata "${name}" has invalid structure. Ensure it has "name" (string) and "stages" (array of categories).`, + { cause: e }, + ); + } +} + +export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record): void { + assertValidKataName(name); + const dir = katasDir(kataDir); + mkdirSync(dir, { recursive: true }); + const kata = SavedKataSchema.parse({ name, stages, flavorHints }); + writeFileSync(join(dir, `${name}.json`), JSON.stringify(kata, null, 2), 'utf-8'); +} + +export function deleteSavedKata(kataDir: string, name: string): void { + assertValidKataName(name); + const filePath = join(katasDir(kataDir), `${name}.json`); + if (!existsSync(filePath)) { + throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`); + } + try { + unlinkSync(filePath); + } catch (e) { + throw new Error( + `Could not delete kata "${name}": ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, + ); + } +} diff --git a/src/features/cycle-management/cooldown-session.helpers.test.ts b/src/features/cycle-management/cooldown-session.helpers.test.ts index 4ca60a9..9a1d49b 100644 --- a/src/features/cycle-management/cooldown-session.helpers.test.ts +++ b/src/features/cycle-management/cooldown-session.helpers.test.ts @@ -11,9 +11,6 @@ import { clampConfidenceWithDelta, collectBridgeRunIds, filterExecutionHistoryForCycle, - hasFailedCaptures, - hasMethod, - hasObservations, isJsonFile, isSyncableBet, isSynthesisPendingFile, @@ -23,7 +20,6 @@ import { resolveAppliedProposalIds, selectEffectiveBetOutcomes, shouldRecordBetOutcomes, - shouldSyncOutcomes, shouldWarnOnIncompleteRuns, shouldWriteDojoDiary, shouldWriteDojoSession, @@ -409,17 +405,6 @@ describe('cooldown-session helpers', () => { }); }); - describe('hasFailedCaptures', () => { - it('returns true when failed count is positive', () => { - expect(hasFailedCaptures(1)).toBe(true); - expect(hasFailedCaptures(5)).toBe(true); - }); - - it('returns false when failed count is zero', () => { - expect(hasFailedCaptures(0)).toBe(false); - }); - }); - describe('isSyncableBet', () => { it('returns true only when outcome is pending AND runId is present', () => { expect(isSyncableBet({ outcome: 'pending', runId: 'run-1' })).toBe(true); @@ -477,40 +462,4 @@ describe('cooldown-session helpers', () => { }); }); - describe('hasObservations', () => { - it('returns true for non-empty arrays', () => { - expect(hasObservations([{ id: '1' }])).toBe(true); - expect(hasObservations([1, 2, 3])).toBe(true); - }); - - it('returns false for empty arrays', () => { - expect(hasObservations([])).toBe(false); - }); - }); - - describe('shouldSyncOutcomes', () => { - it('returns true when there are outcomes to sync', () => { - expect(shouldSyncOutcomes([{ betId: 'b1', outcome: 'complete' }])).toBe(true); - }); - - it('returns false when there are no outcomes to sync', () => { - expect(shouldSyncOutcomes([])).toBe(false); - }); - }); - - describe('hasMethod', () => { - it('returns true when the target has the named method', () => { - expect(hasMethod({ checkExpiry: () => {} }, 'checkExpiry')).toBe(true); - }); - - it('returns false when the target does not have the named method', () => { - expect(hasMethod({}, 'checkExpiry')).toBe(false); - expect(hasMethod({ checkExpiry: 42 }, 'checkExpiry')).toBe(false); - }); - - it('returns false for null and undefined targets', () => { - expect(hasMethod(null, 'checkExpiry')).toBe(false); - expect(hasMethod(undefined, 'checkExpiry')).toBe(false); - }); - }); }); diff --git a/src/features/cycle-management/cooldown-session.helpers.ts b/src/features/cycle-management/cooldown-session.helpers.ts index 22cd0b0..98e681e 100644 --- a/src/features/cycle-management/cooldown-session.helpers.ts +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -299,10 +299,6 @@ export function isSynthesisPendingFile(filename: string): boolean { return filename.startsWith('pending-') && filename.endsWith('.json'); } -export function hasFailedCaptures(failed: number): boolean { - return failed > 0; -} - /** * Pure filter: returns true for bets that are eligible for auto-sync * (outcome is still 'pending' AND the bet has a runId assigned). @@ -328,25 +324,3 @@ export function collectBridgeRunIds( return result; } -/** - * Pure predicate: returns true when the non-empty observations array - * should be appended to the aggregate (length > 0). - */ -export function hasObservations(observations: readonly unknown[]): boolean { - return observations.length > 0; -} - -/** - * Determine whether auto-synced outcomes should be recorded to the cycle. - */ -export function shouldSyncOutcomes(syncedOutcomes: readonly unknown[]): boolean { - return syncedOutcomes.length > 0; -} - -/** - * Pure predicate: returns true when a typeof check confirms the target - * has a given method (used to guard optional checkExpiry in expiry check). - */ -export function hasMethod(target: unknown, methodName: string): boolean { - return typeof (target as Record)?.[methodName] === 'function'; -} diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index 38be287..9bdeb7a 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -55,12 +55,8 @@ import { shouldWriteDojoSession, isJsonFile, isSynthesisPendingFile, - hasFailedCaptures, isSyncableBet, collectBridgeRunIds, - hasObservations, - shouldSyncOutcomes, - hasMethod, } from './cooldown-session.helpers.js'; /** @@ -819,7 +815,7 @@ export class CooldownSession { const runObs = this.readObservationsForRun(runId, bet.id); // Stryker disable next-line ConditionalExpression: push(...[]) is a no-op — guard is equivalent - if (hasObservations(runObs)) { + if (runObs.length > 0) { observations.push(...runObs); } } @@ -942,7 +938,7 @@ export class CooldownSession { } } - if (shouldSyncOutcomes(toSync)) { + if (toSync.length > 0) { this.recordBetOutcomes(cycleId, toSync); } @@ -1006,7 +1002,7 @@ export class CooldownSession { private captureCooldownLearnings(report: CooldownReport): number { const attempts = this.captureCooldownLearningDrafts(report); // Stryker disable next-line ConditionalExpression: gates a warning message — presentation logic - if (hasFailedCaptures(attempts.failed)) { + if (attempts.failed > 0) { logger.warn(`${attempts.failed} of ${attempts.captured + attempts.failed} cooldown learnings failed to capture. Check previous warnings for details.`); } @@ -1228,7 +1224,7 @@ export class CooldownSession { private runExpiryCheck(): void { try { // Stryker disable next-line ConditionalExpression: guard redundant with catch — checkExpiry absence is swallowed - if (!hasMethod(this.deps.knowledgeStore, 'checkExpiry')) return; + if (typeof (this.deps.knowledgeStore as unknown as Record)?.['checkExpiry'] !== 'function') return; const result = this.deps.knowledgeStore.checkExpiry(); for (const message of buildExpiryCheckMessages(result)) { logger.debug(message); diff --git a/src/infrastructure/execution/session-bridge.helpers.test.ts b/src/infrastructure/execution/session-bridge.helpers.test.ts index a6120b2..7385b80 100644 --- a/src/infrastructure/execution/session-bridge.helpers.test.ts +++ b/src/infrastructure/execution/session-bridge.helpers.test.ts @@ -6,10 +6,9 @@ import { findEarliestTimestamp, hasBridgeRunMetadataChanged, isJsonFile, - mapBridgeRunStatus, + matchesCycleRef, resolveAgentId, - sumTokenTotals, } from './session-bridge.helpers.js'; describe('session-bridge helpers', () => { @@ -81,14 +80,6 @@ describe('session-bridge helpers', () => { }); }); - describe('mapBridgeRunStatus', () => { - it('passes through all status values unchanged', () => { - expect(mapBridgeRunStatus('in-progress')).toBe('in-progress'); - expect(mapBridgeRunStatus('complete')).toBe('complete'); - expect(mapBridgeRunStatus('failed')).toBe('failed'); - }); - }); - describe('findEarliestTimestamp', () => { it('returns the earliest ISO timestamp from the list', () => { expect(findEarliestTimestamp([ @@ -183,20 +174,6 @@ describe('session-bridge helpers', () => { }); }); - describe('sumTokenTotals', () => { - it('sums numeric values treating null as 0', () => { - expect(sumTokenTotals([100, null, 200, null, 300])).toBe(600); - }); - - it('returns 0 for empty input', () => { - expect(sumTokenTotals([])).toBe(0); - }); - - it('returns 0 for all-null input', () => { - expect(sumTokenTotals([null, null])).toBe(0); - }); - }); - describe('countJsonlContent', () => { it('counts lines in non-empty JSONL content', () => { expect(countJsonlContent('{"a":1}\n{"b":2}\n{"c":3}')).toBe(3); diff --git a/src/infrastructure/execution/session-bridge.helpers.ts b/src/infrastructure/execution/session-bridge.helpers.ts index 81c8c9a..c69f264 100644 --- a/src/infrastructure/execution/session-bridge.helpers.ts +++ b/src/infrastructure/execution/session-bridge.helpers.ts @@ -25,14 +25,6 @@ export function hasBridgeRunMetadataChanged( return refreshed.betName !== current.betName || refreshed.cycleName !== current.cycleName; } -/** - * Map a bridge-run status to the display status used by getCycleStatus. - * 'in-progress' maps to 'in-progress', everything else passes through. - */ -export function mapBridgeRunStatus(status: T): T { - return status; -} - /** * Find the earliest timestamp from a list of ISO strings. * Returns undefined if the array is empty. @@ -89,13 +81,6 @@ export function extractHistoryTokenTotal( return entry.tokenUsage?.total ?? null; } -/** - * Sum token totals from multiple entries, treating null as 0. - */ -export function sumTokenTotals(totals: readonly (number | null)[]): number { - return totals.reduce((sum, t) => sum + (t ?? 0), 0); -} - /** * Count non-empty lines in a JSONL-format content string. * Returns 0 for empty or whitespace-only content. diff --git a/src/infrastructure/execution/session-bridge.ts b/src/infrastructure/execution/session-bridge.ts index 5964033..139fcd3 100644 --- a/src/infrastructure/execution/session-bridge.ts +++ b/src/infrastructure/execution/session-bridge.ts @@ -540,7 +540,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { this.writeCycleNameIfChanged(cyclePath, cycle, name); return; } - if (!this.canTransition(cycle.state, state)) { + if (!canTransitionCycleState(cycle.state, state)) { logger.warn(`Cannot transition cycle "${cycleId}" from "${cycle.state}" to "${state}".`); return; } @@ -551,11 +551,6 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { } } - // Delegates to the extracted pure helper for testability. - private canTransition(from: CycleState, to: CycleState): boolean { - return canTransitionCycleState(from, to); - } - private writeCycleNameIfChanged(cyclePath: string, cycle: Cycle, name?: string): void { if (name === undefined || cycle.name === name) { return;