From 913e6bd03e32915cd38a4f2ff1a282463ea73a9b Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:57:21 -0400 Subject: [PATCH 1/7] refactor: extract pure helpers from cooldown-session orchestration Extract isSyncableBet, collectBridgeRunIds, hasObservations, shouldSyncOutcomes, and hasMethod into cooldown-session.helpers.ts to make them testable independent of side-effecting orchestration. Wire the helpers back into cooldown-session.ts methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cooldown-session.helpers.test.ts | 99 +++++++++++++++++++ .../cooldown-session.helpers.ts | 48 +++++++++ .../cycle-management/cooldown-session.ts | 30 +++--- 3 files changed, 162 insertions(+), 15 deletions(-) diff --git a/src/features/cycle-management/cooldown-session.helpers.test.ts b/src/features/cycle-management/cooldown-session.helpers.test.ts index 085fda2..4ca60a9 100644 --- a/src/features/cycle-management/cooldown-session.helpers.test.ts +++ b/src/features/cycle-management/cooldown-session.helpers.test.ts @@ -9,9 +9,13 @@ import { buildDojoSessionBuildRequest, buildSynthesisInputRecord, clampConfidenceWithDelta, + collectBridgeRunIds, filterExecutionHistoryForCycle, hasFailedCaptures, + hasMethod, + hasObservations, isJsonFile, + isSyncableBet, isSynthesisPendingFile, listCompletedBetDescriptions, mapBridgeRunStatusToIncompleteStatus, @@ -19,6 +23,7 @@ import { resolveAppliedProposalIds, selectEffectiveBetOutcomes, shouldRecordBetOutcomes, + shouldSyncOutcomes, shouldWarnOnIncompleteRuns, shouldWriteDojoDiary, shouldWriteDojoSession, @@ -414,4 +419,98 @@ describe('cooldown-session helpers', () => { 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); + }); + + it('returns false when outcome is not pending', () => { + expect(isSyncableBet({ outcome: 'complete', runId: 'run-1' })).toBe(false); + expect(isSyncableBet({ outcome: 'partial', runId: 'run-1' })).toBe(false); + expect(isSyncableBet({ outcome: 'abandoned', runId: 'run-1' })).toBe(false); + }); + + it('returns false when runId is missing or undefined', () => { + expect(isSyncableBet({ outcome: 'pending' })).toBe(false); + expect(isSyncableBet({ outcome: 'pending', runId: undefined })).toBe(false); + }); + + it('returns false when runId is empty string', () => { + expect(isSyncableBet({ outcome: 'pending', runId: '' })).toBe(false); + }); + }); + + describe('collectBridgeRunIds', () => { + it('collects betId→runId pairs for matching cycleId', () => { + const result = collectBridgeRunIds([ + { cycleId: 'c1', betId: 'b1', runId: 'r1' }, + { cycleId: 'c2', betId: 'b2', runId: 'r2' }, + { cycleId: 'c1', betId: 'b3', runId: 'r3' }, + ], 'c1'); + + expect(result.size).toBe(2); + expect(result.get('b1')).toBe('r1'); + expect(result.get('b3')).toBe('r3'); + }); + + it('skips records with missing betId or runId', () => { + const result = collectBridgeRunIds([ + { cycleId: 'c1', betId: 'b1' }, + { cycleId: 'c1', runId: 'r2' }, + { cycleId: 'c1' }, + ], 'c1'); + + expect(result.size).toBe(0); + }); + + it('returns empty map when no records match', () => { + const result = collectBridgeRunIds([ + { cycleId: 'c2', betId: 'b1', runId: 'r1' }, + ], 'c1'); + + expect(result.size).toBe(0); + }); + + it('returns empty map for empty input', () => { + expect(collectBridgeRunIds([], 'c1').size).toBe(0); + }); + }); + + 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 3a8ae9e..22cd0b0 100644 --- a/src/features/cycle-management/cooldown-session.helpers.ts +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -302,3 +302,51 @@ export function isSynthesisPendingFile(filename: string): boolean { 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). + */ +export function isSyncableBet(bet: { outcome: string; runId?: string }): boolean { + return bet.outcome === 'pending' && Boolean(bet.runId); +} + +/** + * Build a betId → runId mapping from an array of bridge-run metadata records. + * Only includes records that match the target cycleId and have both betId and runId. + */ +export function collectBridgeRunIds( + metas: ReadonlyArray<{ cycleId?: string; betId?: string; runId?: string }>, + targetCycleId: string, +): Map { + const result = new Map(); + for (const meta of metas) { + if (meta.cycleId === targetCycleId && meta.betId && meta.runId) { + result.set(meta.betId, meta.runId); + } + } + 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 fa923a3..447b4a4 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -56,6 +56,11 @@ import { isJsonFile, isSynthesisPendingFile, hasFailedCaptures, + isSyncableBet, + collectBridgeRunIds, + hasObservations, + shouldSyncOutcomes, + hasMethod, } from './cooldown-session.helpers.js'; /** @@ -805,7 +810,7 @@ export class CooldownSession { if (!runId) continue; const runObs = this.readObservationsForRun(runId, bet.id); - if (runObs.length > 0) { + if (hasObservations(runObs)) { observations.push(...runObs); } } @@ -873,16 +878,11 @@ export class CooldownSession { * Returns an empty Map when bridgeRunsDir is missing or unreadable. */ private loadBridgeRunIdsByBetId(cycleId: string, bridgeRunsDir: string): Map { - const result = new Map(); - - for (const file of this.listJsonFiles(bridgeRunsDir)) { - const meta = this.readBridgeRunMeta(join(bridgeRunsDir, file)); - if (meta?.cycleId === cycleId && meta.betId && meta.runId) { - result.set(meta.betId, meta.runId); - } - } - - return result; + const files = this.listJsonFiles(bridgeRunsDir); + const metas = files + .map((file) => this.readBridgeRunMeta(join(bridgeRunsDir, file))) + .filter((meta): meta is NonNullable => meta !== undefined); + return collectBridgeRunIds(metas, cycleId); } private listJsonFiles(dir: string): string[] { @@ -919,15 +919,15 @@ export class CooldownSession { const toSync: BetOutcomeRecord[] = []; for (const bet of cycle.bets) { - if (bet.outcome !== 'pending' || !bet.runId) continue; + if (!isSyncableBet(bet)) continue; - const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId); + const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId!); if (outcome) { toSync.push({ betId: bet.id, outcome }); } } - if (toSync.length > 0) { + if (shouldSyncOutcomes(toSync)) { this.recordBetOutcomes(cycleId, toSync); } @@ -1201,7 +1201,7 @@ export class CooldownSession { */ private runExpiryCheck(): void { try { - if (typeof this.deps.knowledgeStore.checkExpiry !== 'function') return; + if (!hasMethod(this.deps.knowledgeStore, 'checkExpiry')) return; const result = this.deps.knowledgeStore.checkExpiry(); for (const message of buildExpiryCheckMessages(result)) { logger.debug(message); From 361ec1427efee3869cd364905732cb26fe5511bb Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 07:01:58 -0400 Subject: [PATCH 2/7] refactor: extract pure helpers from session-bridge orchestration Extract findEarliestTimestamp, matchesCycleRef, resolveAgentId, computeBudgetPercent, extractHistoryTokenTotal, sumTokenTotals, countJsonlContent, and mapBridgeRunStatus into session-bridge.helpers.ts. Replace inline .endsWith('.json') with shared isJsonFile filter. Simplify tautological in-progress status mapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../execution/session-bridge.helpers.test.ts | 141 ++++++++++++++++++ .../execution/session-bridge.helpers.ts | 80 ++++++++++ .../execution/session-bridge.ts | 37 ++--- 3 files changed, 237 insertions(+), 21 deletions(-) diff --git a/src/infrastructure/execution/session-bridge.helpers.test.ts b/src/infrastructure/execution/session-bridge.helpers.test.ts index 20d041e..a6120b2 100644 --- a/src/infrastructure/execution/session-bridge.helpers.test.ts +++ b/src/infrastructure/execution/session-bridge.helpers.test.ts @@ -1,7 +1,15 @@ import { canTransitionCycleState, + computeBudgetPercent, + countJsonlContent, + extractHistoryTokenTotal, + findEarliestTimestamp, hasBridgeRunMetadataChanged, isJsonFile, + mapBridgeRunStatus, + matchesCycleRef, + resolveAgentId, + sumTokenTotals, } from './session-bridge.helpers.js'; describe('session-bridge helpers', () => { @@ -72,4 +80,137 @@ describe('session-bridge helpers', () => { expect(isJsonFile('readme.md')).toBe(false); }); }); + + 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([ + '2026-03-16T12:00:00.000Z', + '2026-03-15T08:00:00.000Z', + '2026-03-16T06:00:00.000Z', + ])).toBe('2026-03-15T08:00:00.000Z'); + }); + + it('returns undefined for empty input', () => { + expect(findEarliestTimestamp([])).toBeUndefined(); + }); + + it('returns the only element for single-item arrays', () => { + expect(findEarliestTimestamp(['2026-03-16T12:00:00.000Z'])).toBe('2026-03-16T12:00:00.000Z'); + }); + }); + + describe('matchesCycleRef', () => { + it('matches by id', () => { + expect(matchesCycleRef({ id: 'c1', name: 'Keiko 1' }, 'c1')).toBe(true); + }); + + it('matches by name', () => { + expect(matchesCycleRef({ id: 'c1', name: 'Keiko 1' }, 'Keiko 1')).toBe(true); + }); + + it('returns false when neither id nor name matches', () => { + expect(matchesCycleRef({ id: 'c1', name: 'Keiko 1' }, 'c2')).toBe(false); + }); + + it('handles undefined name', () => { + expect(matchesCycleRef({ id: 'c1' }, 'c1')).toBe(true); + expect(matchesCycleRef({ id: 'c1' }, 'something')).toBe(false); + }); + }); + + describe('resolveAgentId', () => { + it('returns agentId when present', () => { + expect(resolveAgentId('agent-1', 'kataka-1')).toBe('agent-1'); + }); + + it('falls back to katakaId when agentId is undefined', () => { + expect(resolveAgentId(undefined, 'kataka-1')).toBe('kataka-1'); + }); + + it('returns undefined when both are undefined', () => { + expect(resolveAgentId(undefined, undefined)).toBeUndefined(); + }); + }); + + describe('computeBudgetPercent', () => { + it('returns null when tokenBudget is 0 or undefined', () => { + expect(computeBudgetPercent(500, undefined)).toBeNull(); + expect(computeBudgetPercent(500, 0)).toBeNull(); + }); + + it('computes percent and returns token estimate', () => { + expect(computeBudgetPercent(500, 1000)).toEqual({ percent: 50, tokenEstimate: 500 }); + expect(computeBudgetPercent(1500, 1000)).toEqual({ percent: 150, tokenEstimate: 1500 }); + }); + + it('rounds percent to nearest integer', () => { + expect(computeBudgetPercent(333, 1000)).toEqual({ percent: 33, tokenEstimate: 333 }); + }); + }); + + describe('extractHistoryTokenTotal', () => { + it('returns token total for matching cycle', () => { + expect(extractHistoryTokenTotal( + { cycleId: 'c1', tokenUsage: { total: 500 } }, + 'c1', + )).toBe(500); + }); + + it('returns null for non-matching cycle', () => { + expect(extractHistoryTokenTotal( + { cycleId: 'c2', tokenUsage: { total: 500 } }, + 'c1', + )).toBeNull(); + }); + + it('returns null when tokenUsage is missing', () => { + expect(extractHistoryTokenTotal({ cycleId: 'c1' }, 'c1')).toBeNull(); + }); + + it('returns null when tokenUsage.total is undefined', () => { + expect(extractHistoryTokenTotal( + { cycleId: 'c1', tokenUsage: {} }, + 'c1', + )).toBeNull(); + }); + }); + + 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); + expect(countJsonlContent('{"a":1}')).toBe(1); + }); + + it('returns 0 for empty or whitespace-only content', () => { + expect(countJsonlContent('')).toBe(0); + expect(countJsonlContent(' ')).toBe(0); + expect(countJsonlContent('\n')).toBe(0); + }); + + it('handles trailing newlines correctly', () => { + expect(countJsonlContent('{"a":1}\n{"b":2}\n')).toBe(2); + }); + }); }); diff --git a/src/infrastructure/execution/session-bridge.helpers.ts b/src/infrastructure/execution/session-bridge.helpers.ts index 659c49d..81c8c9a 100644 --- a/src/infrastructure/execution/session-bridge.helpers.ts +++ b/src/infrastructure/execution/session-bridge.helpers.ts @@ -24,3 +24,83 @@ export function hasBridgeRunMetadataChanged( ): boolean { 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. + */ +export function findEarliestTimestamp(timestamps: readonly string[]): string | undefined { + if (timestamps.length === 0) return undefined; + return [...timestamps].sort()[0]; +} + +/** + * Match a cycle by ID or name. Used by loadCycle. + */ +export function matchesCycleRef( + cycle: { id: string; name?: string }, + ref: string, +): boolean { + return cycle.id === ref || cycle.name === ref; +} + +/** + * Resolve the agent ID from primary and legacy fields. + */ +export function resolveAgentId( + agentId: string | undefined, + katakaId: string | undefined, +): string | undefined { + return agentId ?? katakaId; +} + +/** + * Compute budget percent from tokens used and budget total. + * Returns null when no budget is configured. + */ +export function computeBudgetPercent( + tokensUsed: number, + tokenBudget: number | undefined, +): { percent: number; tokenEstimate: number } | null { + if (!tokenBudget) return null; + return { + percent: Math.round((tokensUsed / tokenBudget) * 100), + tokenEstimate: tokensUsed, + }; +} + +/** + * Extract token total from a raw history entry for a given cycle. + * Returns null if the entry does not belong to the cycle. + */ +export function extractHistoryTokenTotal( + entry: { cycleId?: string; tokenUsage?: { total?: number } }, + targetCycleId: string, +): number | null { + if (entry.cycleId !== targetCycleId) return null; + 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. + */ +export function countJsonlContent(content: string): number { + const trimmed = content.trim(); + return trimmed ? trimmed.split('\n').length : 0; +} diff --git a/src/infrastructure/execution/session-bridge.ts b/src/infrastructure/execution/session-bridge.ts index cf52fd7..cb987e6 100644 --- a/src/infrastructure/execution/session-bridge.ts +++ b/src/infrastructure/execution/session-bridge.ts @@ -20,8 +20,14 @@ import { z } from 'zod/v4'; import { JsonStore } from '@infra/persistence/json-store.js'; import { canTransitionCycleState, + computeBudgetPercent, + countJsonlContent, + extractHistoryTokenTotal, + findEarliestTimestamp, hasBridgeRunMetadataChanged, isJsonFile, + matchesCycleRef, + resolveAgentId, } from './session-bridge.helpers.js'; import { createRunTree, readRun, writeRun, runPaths } from '@infra/persistence/run-store.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; @@ -357,7 +363,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { betId: meta.betId, betName: meta.betName, runId: meta.runId, - status: meta.status === 'in-progress' ? 'in-progress' : meta.status, + status: meta.status, kansatsuCount: counts.observations, artifactCount: counts.artifacts, decisionCount: counts.decisions, @@ -381,9 +387,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { } private formatElapsedDuration(bridgeRuns: BridgeRunMeta[]): string { - const earliestStart = bridgeRuns - .map((meta) => meta.startedAt) - .sort()[0]; + const earliestStart = findEarliestTimestamp(bridgeRuns.map((meta) => meta.startedAt)); return earliestStart ? this.formatDuration(Date.now() - new Date(earliestStart).getTime()) @@ -447,7 +451,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { for (const file of files) { try { const cycle = JsonStore.read(join(cyclesDir, file), CycleSchema); - if (cycle.id === cycleId || cycle.name === cycleId) { + if (matchesCycleRef(cycle, cycleId)) { return cycle; } } catch { @@ -692,8 +696,8 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { stages: meta.stages, isolation: meta.isolation, startedAt: meta.startedAt, - agentId: meta.agentId ?? meta.katakaId, - katakaId: meta.agentId ?? meta.katakaId, + agentId: resolveAgentId(meta.agentId, meta.katakaId), + katakaId: resolveAgentId(meta.agentId, meta.katakaId), }; } @@ -848,7 +852,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { if (!existsSync(dir)) return []; return readdirSync(dir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .map((f) => { try { const meta = BridgeRunMetaSchema.parse(JSON.parse(readFileSync(join(dir, f), 'utf-8'))); @@ -897,8 +901,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { private countJsonlLines(filePath: string): number { if (!existsSync(filePath)) return 0; try { - const content = readFileSync(filePath, 'utf-8').trim(); - return content ? content.split('\n').length : 0; + return countJsonlContent(readFileSync(filePath, 'utf-8')); } catch { return 0; } @@ -924,17 +927,13 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { if (!existsSync(historyDir)) return { percent: 0, tokenEstimate: 0 }; const totalTokens = this.sumCycleHistoryTokens(historyDir, cycle.id); - - return { - percent: Math.round((totalTokens / cycle.budget.tokenBudget) * 100), - tokenEstimate: totalTokens, - }; + return computeBudgetPercent(totalTokens, cycle.budget.tokenBudget); } private sumCycleHistoryTokens(historyDir: string, cycleId: string): number { let totalTokens = 0; - for (const file of readdirSync(historyDir).filter((entry) => entry.endsWith('.json'))) { + for (const file of readdirSync(historyDir).filter(isJsonFile)) { const entryTotal = this.readHistoryTokenTotal(join(historyDir, file), cycleId); totalTokens += entryTotal ?? 0; } @@ -945,11 +944,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { private readHistoryTokenTotal(filePath: string, cycleId: string): number | null { try { const entry = JSON.parse(readFileSync(filePath, 'utf-8')); - if (entry.cycleId !== cycleId) { - return null; - } - - return entry.tokenUsage?.total ?? null; + return extractHistoryTokenTotal(entry, cycleId); } catch { return null; } From 12002048ae8757da4957bfbd208878641e000636 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 07:05:30 -0400 Subject: [PATCH 3/7] refactor: extract pure helpers from execute.ts orchestration Extract hasNoGapsToBridge, hasBlockedGaps, hasBridgedGaps, formatConfidencePercent, and hasPipelineLearnings into execute.helpers.ts with targeted tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/execute.helpers.test.ts | 62 ++++++++++++++++++++++++ src/cli/commands/execute.helpers.ts | 35 +++++++++++++ src/cli/commands/execute.ts | 17 ++++--- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index b651af7..69e2c06 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -3,9 +3,14 @@ import { betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, + formatConfidencePercent, formatDurationMs, formatAgentLoadError, formatExplain, + hasBlockedGaps, + hasBridgedGaps, + hasNoGapsToBridge, + hasPipelineLearnings, mergePinnedFlavors, parseBetOption, parseCompletedRunArtifacts, @@ -477,4 +482,61 @@ describe('execute helpers', () => { expect(resolveCompletionStatus(undefined)).toBe('complete'); }); }); + + 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%'); + expect(formatConfidencePercent(1)).toBe('100%'); + expect(formatConfidencePercent(0)).toBe('0%'); + }); + + it('rounds to nearest integer', () => { + expect(formatConfidencePercent(0.333)).toBe('33%'); + expect(formatConfidencePercent(0.667)).toBe('67%'); + }); + }); + + 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 47304a6..68fadcc 100644 --- a/src/cli/commands/execute.helpers.ts +++ b/src/cli/commands/execute.helpers.ts @@ -342,3 +342,38 @@ export function formatDurationMs(ms: number): string { if (minutes > 0) return `${minutes}m ${seconds % 60}s`; 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 4f6a3ea..560b37f 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -26,9 +26,14 @@ import { betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, + formatConfidencePercent, formatDurationMs, formatAgentLoadError, formatExplain, + hasBlockedGaps, + hasBridgedGaps, + hasNoGapsToBridge, + hasPipelineLearnings, mergePinnedFlavors, parseBetOption, parseCompletedRunArtifacts, @@ -657,20 +662,20 @@ function bridgeExecutionGaps(input: { suggestedFlavors: string[]; }>; }): boolean { - if (!input.gaps || input.gaps.length === 0) return true; + if (hasNoGapsToBridge(input.gaps)) return true; const store = new KnowledgeStore(kataDirPath(input.kataDir, 'knowledge')); const bridger = new GapBridger({ knowledgeStore: store }); - const { blocked, bridged } = bridger.bridge(input.gaps); + const { blocked, bridged } = bridger.bridge(input.gaps!); - if (blocked.length > 0) { + if (hasBlockedGaps(blocked)) { 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 (bridged.length > 0) { + if (hasBridgedGaps(bridged)) { console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`); ProjectStateUpdater.incrementGapsClosed(input.projectStateFile, bridged.length); } @@ -695,7 +700,7 @@ function printSingleCategoryResult(result: StageRunResult, isJson: boolean, opts console.log(''); console.log('Decisions:'); for (const decision of result.decisions) { - console.log(` ${decision.decisionType}: ${decision.selection} (confidence: ${(decision.confidence * 100).toFixed(0)}%)`); + console.log(` ${decision.decisionType}: ${decision.selection} (confidence: ${formatConfidencePercent(decision.confidence)})`); } console.log(''); console.log(`Stage artifact: ${result.stageArtifact.name}`); @@ -735,7 +740,7 @@ function printPipelineResult( console.log(` Artifact: ${stageResult.stageArtifact.name}`); } - if (result.pipelineReflection.learnings.length > 0) { + if (hasPipelineLearnings(result.pipelineReflection.learnings)) { console.log(''); console.log('Learnings:'); for (const learning of result.pipelineReflection.learnings) { From a208395ac867708e83a75d0480f07040cd02b2bd Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 07:08:30 -0400 Subject: [PATCH 4/7] test: add targeted cooldown unit tests for branch coverage Exercise diary writing guards, force bypass, depth override, nextKeiko no-op without runsDir, and empty incompleteRuns path. These kill ConditionalExpression and BooleanLiteral mutants in cooldown-session.ts private orchestration methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cooldown-session.unit.test.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/src/features/cycle-management/cooldown-session.unit.test.ts b/src/features/cycle-management/cooldown-session.unit.test.ts index 96b769a..835337e 100644 --- a/src/features/cycle-management/cooldown-session.unit.test.ts +++ b/src/features/cycle-management/cooldown-session.unit.test.ts @@ -494,6 +494,175 @@ describe('CooldownSession unit seams', () => { } }); + it('writeRunDiary skips diary when dojoDir is not configured', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 1_000 }, 'No Diary'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Skip diary bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + + const dojoSessionBuilder = { build: vi.fn() }; + const session = new CooldownSession({ + ...fixture.baseDeps, + // dojoDir deliberately NOT set + dojoSessionBuilder, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.run(cycle.id); + + // Diary and session should NOT be written when dojoDir is absent + expect(dojoSessionBuilder.build).not.toHaveBeenCalled(); + expect(result.report).toBeDefined(); + } finally { + fixture.cleanup(); + } + }); + + it('complete writes diary and session when dojoDir and dojoSessionBuilder are configured', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Complete Diary'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Diary bet', + appetite: 40, + outcome: 'complete', + issueRefs: [], + }); + + const dojoSessionBuilder = { build: vi.fn() }; + const session = new CooldownSession({ + ...fixture.baseDeps, + dojoDir: fixture.dojoDir, + dojoSessionBuilder, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + await session.complete(cycle.id); + + // Both diary and session should be written + expect(dojoSessionBuilder.build).toHaveBeenCalledTimes(1); + } finally { + fixture.cleanup(); + } + }); + + it('run with force=true parameter exercises warning bypass', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 2_000 }, 'Force Bypass'); + const updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Force bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + const bet = updated.bets[0]!; + const run = makeRun(cycle.id, bet.id, 'running'); + createRunTree(fixture.runsDir, run); + fixture.cycleManager.setRunId(cycle.id, bet.id, run.id); + + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + // Should not warn when force=true + await session.run(cycle.id, [], { force: true }); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('run(s) are still in progress')); + } finally { + vi.restoreAllMocks(); + fixture.cleanup(); + } + }); + + it('prepare with depth parameter overrides default synthesis depth', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Depth Override'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Depth bet', + appetite: 30, + outcome: 'complete', + issueRefs: [], + }); + + const session = new CooldownSession({ + ...fixture.baseDeps, + synthesisDepth: 'minimal', + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.prepare(cycle.id, [], 'thorough'); + const synthesisInput = JSON.parse(readFileSync(result.synthesisInputPath, 'utf-8')); + expect(synthesisInput.depth).toBe('thorough'); + } finally { + fixture.cleanup(); + } + }); + + it('run skips nextKeiko when runsDir is not configured', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 1_000 }, 'No Runs'); + const nextKeikoGen = { generate: vi.fn(() => ({ text: 'test', observationCounts: { friction: 0, gap: 0, insight: 0, total: 0 }, milestoneIssueCount: 0 })) }; + + const session = new CooldownSession({ + cycleManager: fixture.cycleManager, + knowledgeStore: fixture.knowledgeStore, + persistence: JsonStore, + pipelineDir: fixture.pipelineDir, + historyDir: fixture.historyDir, + // runsDir deliberately NOT set + proposalGenerator: { generate: vi.fn(() => []) }, + nextKeikoProposalGenerator: nextKeikoGen, + }); + + const result = await session.run(cycle.id); + + // nextKeiko should NOT be called without runsDir + expect(nextKeikoGen.generate).not.toHaveBeenCalled(); + expect(result.nextKeikoResult).toBeUndefined(); + expect(result.incompleteRuns).toBeUndefined(); + } finally { + fixture.cleanup(); + } + }); + + it('run returns empty incompleteRuns when all bets are complete', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 2_000 }, 'All Complete'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Done bet', + appetite: 30, + outcome: 'complete', + issueRefs: [], + }); + + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.run(cycle.id); + expect(result.incompleteRuns).toEqual([]); + } finally { + fixture.cleanup(); + } + }); + it('checkIncompleteRuns prefers bridge metadata and falls back to run.json status', () => { const fixture = createFixture(); From 2b0df1216fdd45073e9eb1b6be53fcadc662312a Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 07:20:14 -0400 Subject: [PATCH 5/7] refactor: replace inline endsWith filters with shared isJsonFile Use the shared isJsonFile helper from file-filters.ts in workflow-runner.ts and execute.ts instead of inline .endsWith('.json') callbacks. Kills MethodExpression mutants. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/execute.ts | 3 ++- src/features/execute/workflow-runner.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 560b37f..20a3ad5 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -17,6 +17,7 @@ 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'; @@ -810,7 +811,7 @@ function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCat const dir = katasDir(kataDir); if (!existsSync(dir)) return []; return readdirSync(dir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .map((f) => { try { const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8')); diff --git a/src/features/execute/workflow-runner.ts b/src/features/execute/workflow-runner.ts index 9df9e86..42a2bf1 100644 --- a/src/features/execute/workflow-runner.ts +++ b/src/features/execute/workflow-runner.ts @@ -16,6 +16,7 @@ import { ExecutionHistoryEntrySchema } from '@domain/types/history.js'; import { createStageOrchestrator } from '@domain/services/orchestrators/index.js'; import { MetaOrchestrator } from '@domain/services/meta-orchestrator.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; +import { isJsonFile } from '@shared/lib/file-filters.js'; import { logger } from '@shared/lib/logger.js'; import type { UsageAnalytics } from '@infra/tracking/usage-analytics.js'; @@ -307,7 +308,7 @@ export class WorkflowRunner { if (!existsSync(artifactsDir)) return []; return readdirSync(artifactsDir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .map((f) => f.replace('.json', '')); } @@ -348,7 +349,7 @@ export function listRecentArtifacts(kataDir: string): ArtifactEntry[] { if (!existsSync(artifactsDir)) return []; const files = readdirSync(artifactsDir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .sort() .reverse(); From fc55e8cf0107627429e459393a55c1e5f5bfef27 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 07:32:56 -0400 Subject: [PATCH 6/7] test: add terminal-state getAgentContext tests for bridge Exercise the ConditionalExpression and EqualityOperator mutants in getAgentContext by asserting that completed and failed runs reject dispatch, and non-existent runs throw descriptive errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../execution/session-bridge.unit.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/infrastructure/execution/session-bridge.unit.test.ts b/src/infrastructure/execution/session-bridge.unit.test.ts index 8e0faad..bc47a60 100644 --- a/src/infrastructure/execution/session-bridge.unit.test.ts +++ b/src/infrastructure/execution/session-bridge.unit.test.ts @@ -106,6 +106,38 @@ describe('SessionExecutionBridge unit coverage', () => { expect(context).toContain(`- **Run ID**: ${prepared.runId}`); }); + it('getAgentContext rejects a completed run in terminal state', () => { + const cycle = createCycle(kataDir); + const bridge = new SessionExecutionBridge(kataDir); + const prepared = bridge.prepare(cycle.bets[0]!.id); + + bridge.complete(prepared.runId, { success: true }); + + expect(() => bridge.getAgentContext(prepared.runId)).toThrow( + `Run "${prepared.runId}" is in terminal state "complete" and cannot be dispatched.`, + ); + }); + + it('getAgentContext rejects a failed run in terminal state', () => { + const cycle = createCycle(kataDir); + const bridge = new SessionExecutionBridge(kataDir); + const prepared = bridge.prepare(cycle.bets[0]!.id); + + bridge.complete(prepared.runId, { success: false }); + + expect(() => bridge.getAgentContext(prepared.runId)).toThrow( + `Run "${prepared.runId}" is in terminal state "failed" and cannot be dispatched.`, + ); + }); + + it('getAgentContext throws for non-existent run', () => { + const bridge = new SessionExecutionBridge(kataDir); + + expect(() => bridge.getAgentContext('nonexistent-run')).toThrow( + 'No bridge run found for run ID "nonexistent-run".', + ); + }); + it('resolves stages for ad-hoc, named, and missing named kata assignments', () => { mkdirSync(join(kataDir, 'katas'), { recursive: true }); writeFileSync( From be00787ad73372b413d9f6416c749fd26cefb158 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 07:35:13 -0400 Subject: [PATCH 7/7] test: add saved kata CRUD tests for mutation hardening Export listSavedKatas, loadSavedKata, saveSavedKata, and deleteSavedKata from execute.ts and add direct tests covering existsSync guards, JSON parsing errors, schema validation, and file deletion paths. These kill ConditionalExpression, BlockStatement, and ObjectLiteral mutants. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/execute.test.ts | 114 ++++++++++++++++++++++++++++++- src/cli/commands/execute.ts | 8 +-- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/execute.test.ts b/src/cli/commands/execute.test.ts index 1c1adf0..6ba1361 100644 --- a/src/cli/commands/execute.test.ts +++ b/src/cli/commands/execute.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, readdirSync import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { Command } from 'commander'; -import { registerExecuteCommands } from './execute.js'; +import { registerExecuteCommands, listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from './execute.js'; import { CycleManager } from '@domain/services/cycle-manager.js'; import { JsonStore } from '@infra/persistence/json-store.js'; import { SessionExecutionBridge } from '@infra/execution/session-bridge.js'; @@ -1705,3 +1705,115 @@ describe('registerExecuteCommands', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Saved kata CRUD — direct function tests for mutation hardening +// --------------------------------------------------------------------------- + +describe('saved kata CRUD functions', () => { + let tmpBase: string; + + beforeEach(() => { + tmpBase = join(tmpdir(), `kata-crud-${randomUUID()}`); + mkdirSync(tmpBase, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + describe('listSavedKatas', () => { + it('returns empty when katas dir does not exist', () => { + expect(listSavedKatas(tmpBase)).toEqual([]); + }); + + it('returns valid katas and skips non-json files', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'valid.json'), JSON.stringify({ name: 'valid', stages: ['build'] })); + writeFileSync(join(katasDir, 'notes.txt'), 'not json'); + writeFileSync(join(katasDir, 'broken.json'), '{ broken }'); + + const result = listSavedKatas(tmpBase); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('valid'); + expect(result[0]!.stages).toEqual(['build']); + }); + + it('skips files with invalid schema data', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'bad-schema.json'), JSON.stringify({ name: 123 })); + + expect(listSavedKatas(tmpBase)).toEqual([]); + }); + }); + + describe('loadSavedKata', () => { + it('loads a valid saved kata', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'my-kata.json'), JSON.stringify({ + name: 'my-kata', stages: ['research', 'build'], + })); + + const result = loadSavedKata(tmpBase, 'my-kata'); + expect(result.stages).toEqual(['research', 'build']); + }); + + it('throws when kata does not exist', () => { + expect(() => loadSavedKata(tmpBase, 'missing')).toThrow( + 'Kata "missing" not found.', + ); + }); + + it('throws for invalid JSON content', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'broken.json'), '{ broken }'); + + expect(() => loadSavedKata(tmpBase, 'broken')).toThrow( + 'Kata "broken" has invalid JSON:', + ); + }); + + it('throws for valid JSON with invalid schema', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'bad.json'), JSON.stringify({ name: 123 })); + + expect(() => loadSavedKata(tmpBase, 'bad')).toThrow( + 'Kata "bad" has invalid structure.', + ); + }); + }); + + describe('saveSavedKata', () => { + it('creates the katas directory and writes the file', () => { + saveSavedKata(tmpBase, 'new-kata', ['build', 'review']); + + const filePath = join(tmpBase, 'katas', 'new-kata.json'); + expect(existsSync(filePath)).toBe(true); + const data = JSON.parse(readFileSync(filePath, 'utf-8')); + expect(data.name).toBe('new-kata'); + expect(data.stages).toEqual(['build', 'review']); + }); + }); + + describe('deleteSavedKata', () => { + it('deletes an existing saved kata', () => { + saveSavedKata(tmpBase, 'del-kata', ['build']); + const filePath = join(tmpBase, 'katas', 'del-kata.json'); + expect(existsSync(filePath)).toBe(true); + + deleteSavedKata(tmpBase, 'del-kata'); + expect(existsSync(filePath)).toBe(false); + }); + + it('throws when kata does not exist', () => { + expect(() => deleteSavedKata(tmpBase, 'nonexistent')).toThrow( + 'Kata "nonexistent" not found.', + ); + }); + }); +}); diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 20a3ad5..b2aa18d 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -807,7 +807,7 @@ function katasDir(kataDir: string): string { return join(kataDir, KATA_DIRS.katas); } -function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> { +export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> { const dir = katasDir(kataDir); if (!existsSync(dir)) return []; return readdirSync(dir) @@ -827,7 +827,7 @@ function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCat .filter((k): k is NonNullable => k !== null); } -function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record } { +export function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record } { assertValidKataName(name); const filePath = join(katasDir(kataDir), `${name}.json`); if (!existsSync(filePath)) { @@ -852,7 +852,7 @@ function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[] } } -function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record): void { +export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record): void { assertValidKataName(name); const dir = katasDir(kataDir); mkdirSync(dir, { recursive: true }); @@ -860,7 +860,7 @@ function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], f writeFileSync(join(dir, `${name}.json`), JSON.stringify(kata, null, 2), 'utf-8'); } -function deleteSavedKata(kataDir: string, name: string): void { +export function deleteSavedKata(kataDir: string, name: string): void { assertValidKataName(name); const filePath = join(katasDir(kataDir), `${name}.json`); if (!existsSync(filePath)) {