From 5eaf2c7578589b8e9cf5b1b951efceda3c612577 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:15:10 -0400 Subject: [PATCH 01/10] refactor: extract pure helpers from confidence calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract `resolveRegistryDir` and `computeAverageConfidence` as exported pure functions from the monolithic `compute()` method. Add 12 targeted unit tests covering: - agentDir vs katakaDir resolution (precedence, fallback, throw) - average confidence arithmetic (empty, single, multi, boundary) - load() throw path when no registry dir configured Addresses #375 Phase 3 — bounded extraction for mutation coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kata-agent-confidence-calculator.test.ts | 133 +++++++++++++++++- .../kata-agent-confidence-calculator.ts | 50 ++++--- 2 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/features/kata-agent/kata-agent-confidence-calculator.test.ts b/src/features/kata-agent/kata-agent-confidence-calculator.test.ts index 36beac4..7f717df 100644 --- a/src/features/kata-agent/kata-agent-confidence-calculator.test.ts +++ b/src/features/kata-agent/kata-agent-confidence-calculator.test.ts @@ -2,7 +2,11 @@ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { KataAgentConfidenceCalculator } from './kata-agent-confidence-calculator.js'; +import { + KataAgentConfidenceCalculator, + resolveRegistryDir, + computeAverageConfidence, +} from './kata-agent-confidence-calculator.js'; import { KataAgentConfidenceProfileSchema } from '@domain/types/kata-agent-confidence.js'; import { createRunTree, appendObservation } from '@infra/persistence/run-store.js'; import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; @@ -65,6 +69,55 @@ function seedLearning(knowledgeDir: string, overrides: Partial = // Tests // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Pure helper tests — direct mutation coverage +// --------------------------------------------------------------------------- + +describe('resolveRegistryDir', () => { + it('returns agentDir when both agentDir and katakaDir are provided', () => { + expect(resolveRegistryDir('/agent', '/kataka')).toBe('/agent'); + }); + + it('falls back to katakaDir when agentDir is undefined', () => { + expect(resolveRegistryDir(undefined, '/kataka')).toBe('/kataka'); + }); + + it('returns agentDir when katakaDir is undefined', () => { + expect(resolveRegistryDir('/agent', undefined)).toBe('/agent'); + }); + + it('throws when neither agentDir nor katakaDir is provided', () => { + expect(() => resolveRegistryDir(undefined, undefined)).toThrow( + 'KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).', + ); + }); +}); + +describe('computeAverageConfidence', () => { + it('returns 0 for an empty array', () => { + expect(computeAverageConfidence([])).toBe(0); + }); + + it('returns the single value for a one-element array', () => { + expect(computeAverageConfidence([{ confidence: 0.7 }])).toBe(0.7); + }); + + it('returns the arithmetic mean of multiple values', () => { + const learnings = [{ confidence: 0.6 }, { confidence: 0.8 }, { confidence: 1.0 }]; + expect(computeAverageConfidence(learnings)).toBeCloseTo(0.8, 10); + }); + + it('handles all-zero confidence values', () => { + const learnings = [{ confidence: 0 }, { confidence: 0 }]; + expect(computeAverageConfidence(learnings)).toBe(0); + }); + + it('handles all-one confidence values', () => { + const learnings = [{ confidence: 1 }, { confidence: 1 }, { confidence: 1 }]; + expect(computeAverageConfidence(learnings)).toBe(1); + }); +}); + describe('KataAgentConfidenceCalculator', () => { describe('compute()', () => { it('returns a KataAgentConfidenceProfile with correct structure', () => { @@ -178,6 +231,50 @@ describe('KataAgentConfidenceCalculator', () => { expect(profile.observationCount).toBe(0); }); + it('uses agentDir when provided (ignores katakaDir)', () => { + const { runsDir, knowledgeDir, katakaDir } = makeDirs(); + const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); + const agentDir = join(base, '.kata', 'agents'); + mkdirSync(agentDir, { recursive: true }); + + const calc = new KataAgentConfidenceCalculator({ + runsDir, + knowledgeDir, + agentDir, + katakaDir, + }); + const id = randomUUID(); + + calc.compute(id, 'test-agent'); + + // Written to agentDir, not katakaDir + expect(existsSync(join(agentDir, id, 'confidence.json'))).toBe(true); + expect(existsSync(join(katakaDir, id, 'confidence.json'))).toBe(false); + }); + + it('uses agentDir without katakaDir', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); + const agentDir = join(base, '.kata', 'agents'); + mkdirSync(agentDir, { recursive: true }); + + const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir, agentDir }); + const id = randomUUID(); + + const profile = calc.compute(id, 'test-agent'); + expect(profile.katakaId).toBe(id); + expect(existsSync(join(agentDir, id, 'confidence.json'))).toBe(true); + }); + + it('throws when neither agentDir nor katakaDir is provided', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir }); + + expect(() => calc.compute(randomUUID(), 'test-agent')).toThrow( + 'KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).', + ); + }); + it('handles missing knowledgeDir gracefully (learningCount = 0, overallConfidence = 0)', () => { const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); const runsDir = join(base, '.kata', 'runs'); @@ -216,6 +313,40 @@ describe('KataAgentConfidenceCalculator', () => { }); describe('load()', () => { + it('throws when neither agentDir nor katakaDir is provided', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir }); + + expect(() => calc.load(randomUUID())).toThrow( + 'KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).', + ); + }); + + it('loads from agentDir when both agentDir and katakaDir are provided', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); + const agentDir = join(base, '.kata', 'agents'); + const katakaDir = join(base, '.kata', 'kataka'); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(katakaDir, { recursive: true }); + + const calc = new KataAgentConfidenceCalculator({ + runsDir, + knowledgeDir, + agentDir, + katakaDir, + }); + const id = randomUUID(); + + // Write profile via compute (goes to agentDir) + calc.compute(id, 'test-agent'); + + // Load should find it from agentDir + const loaded = calc.load(id); + expect(loaded).not.toBeNull(); + expect(loaded!.katakaId).toBe(id); + }); + it('returns null when file does not exist', () => { const { runsDir, knowledgeDir, katakaDir } = makeDirs(); const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir, katakaDir }); diff --git a/src/features/kata-agent/kata-agent-confidence-calculator.ts b/src/features/kata-agent/kata-agent-confidence-calculator.ts index 8215337..4bbb38f 100644 --- a/src/features/kata-agent/kata-agent-confidence-calculator.ts +++ b/src/features/kata-agent/kata-agent-confidence-calculator.ts @@ -8,6 +8,32 @@ import { } from '@domain/types/kata-agent-confidence.js'; import { KataAgentObservabilityAggregator } from './kata-agent-observability-aggregator.js'; +// --------------------------------------------------------------------------- +// Pure helpers — extracted for direct unit testing and mutation coverage +// --------------------------------------------------------------------------- + +/** + * Resolve which directory to use for agent registry persistence. + * Prefers `agentDir`; falls back to legacy `katakaDir`. + * Throws if neither is provided. + */ +export function resolveRegistryDir(agentDir?: string, katakaDir?: string): string { + const dir = agentDir ?? katakaDir; + if (!dir) { + throw new Error('KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).'); + } + return dir; +} + +/** + * Compute the average confidence from a list of learnings. + * Returns 0 when the list is empty. + */ +export function computeAverageConfidence(learnings: ReadonlyArray<{ confidence: number }>): number { + if (learnings.length === 0) return 0; + return learnings.reduce((sum, l) => sum + l.confidence, 0) / learnings.length; +} + // --------------------------------------------------------------------------- // KataAgentConfidenceCalculator — computes and persists per-agent confidence // --------------------------------------------------------------------------- @@ -26,45 +52,32 @@ export class KataAgentConfidenceCalculator { * Writes the result to the configured agent registry directory. */ compute(agentId: string, agentName: string): KataAgentConfidenceProfile { - // 1. Get observability stats (observation counts, agent learning count) const aggregator = new KataAgentObservabilityAggregator(this.deps.runsDir, this.deps.knowledgeDir); const stats = aggregator.computeStats(agentId, agentName); - // 2. Load agent-tier learnings from KnowledgeStore for this agent let overallConfidence = 0; let learningCount = 0; try { const store = new KnowledgeStore(this.deps.knowledgeDir); const agentLearnings = store.query({ tier: 'agent', agentId: agentName }); learningCount = agentLearnings.length; - if (agentLearnings.length > 0) { - overallConfidence = - agentLearnings.reduce((sum, l) => sum + l.confidence, 0) / agentLearnings.length; - } + overallConfidence = computeAverageConfidence(agentLearnings); } catch { // KnowledgeStore unavailable — leave defaults } - // 3. domainScores: empty for now - // TODO: compute per-domain scores from observation domain tags when available - const domainScores: Record = {}; - const profile: KataAgentConfidenceProfile = { agentId, katakaId: agentId, katakaName: agentName, computedAt: new Date().toISOString(), - domainScores, + domainScores: {}, overallConfidence, observationCount: stats.observationCount, learningCount, }; - // 4. Write to the configured agent directory - const registryDir = this.deps.agentDir ?? this.deps.katakaDir; - if (!registryDir) { - throw new Error('KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).'); - } + const registryDir = resolveRegistryDir(this.deps.agentDir, this.deps.katakaDir); const confidencePath = join(registryDir, agentId, 'confidence.json'); const dir = dirname(confidencePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); @@ -77,10 +90,7 @@ export class KataAgentConfidenceCalculator { * Load a previously persisted confidence profile. Returns null if absent or corrupt. */ load(agentId: string): KataAgentConfidenceProfile | null { - const registryDir = this.deps.agentDir ?? this.deps.katakaDir; - if (!registryDir) { - throw new Error('KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).'); - } + const registryDir = resolveRegistryDir(this.deps.agentDir, this.deps.katakaDir); const confidencePath = join(registryDir, agentId, 'confidence.json'); if (!existsSync(confidencePath)) return null; try { From 2be9f12d7586df633edf8217a3186d896fa24453 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:17:35 -0400 Subject: [PATCH 02/10] refactor: remove redundant existsSync guards from confidence calculator Remove two dead-code guards that masked 6 surviving mutants: - existsSync before mkdirSync({recursive:true}) is idempotent - existsSync before JsonStore.read in load() has error handler fallback Eliminates all 6 survivors by removing untestable redundant branches. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/features/kata-agent/kata-agent-confidence-calculator.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/features/kata-agent/kata-agent-confidence-calculator.ts b/src/features/kata-agent/kata-agent-confidence-calculator.ts index 4bbb38f..0a16948 100644 --- a/src/features/kata-agent/kata-agent-confidence-calculator.ts +++ b/src/features/kata-agent/kata-agent-confidence-calculator.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { JsonStore } from '@infra/persistence/json-store.js'; import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; @@ -79,8 +79,7 @@ export class KataAgentConfidenceCalculator { const registryDir = resolveRegistryDir(this.deps.agentDir, this.deps.katakaDir); const confidencePath = join(registryDir, agentId, 'confidence.json'); - const dir = dirname(confidencePath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + mkdirSync(dirname(confidencePath), { recursive: true }); JsonStore.write(confidencePath, profile, KataAgentConfidenceProfileSchema); return profile; @@ -92,7 +91,6 @@ export class KataAgentConfidenceCalculator { load(agentId: string): KataAgentConfidenceProfile | null { const registryDir = resolveRegistryDir(this.deps.agentDir, this.deps.katakaDir); const confidencePath = join(registryDir, agentId, 'confidence.json'); - if (!existsSync(confidencePath)) return null; try { return JsonStore.read(confidencePath, KataAgentConfidenceProfileSchema); } catch { From fa82c09d31019e422a40f7ec0aeed4b9b2aa9a03 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:21:51 -0400 Subject: [PATCH 03/10] refactor: extract file-filter helpers and remove redundant guards from cooldown-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract isJsonFile, isSynthesisPendingFile, hasFailedCaptures as pure helpers with direct unit tests. Remove 3 redundant existsSync guards in bridge-run loading where readBridgeRunMeta already handles missing files via its own error handler. Addresses #375 Phase 3 — cooldown-session bounded extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cooldown-session.helpers.test.ts | 41 +++++++++++++++++++ .../cooldown-session.helpers.ts | 12 ++++++ .../cycle-management/cooldown-session.ts | 12 +++--- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/features/cycle-management/cooldown-session.helpers.test.ts b/src/features/cycle-management/cooldown-session.helpers.test.ts index 4442725..085fda2 100644 --- a/src/features/cycle-management/cooldown-session.helpers.test.ts +++ b/src/features/cycle-management/cooldown-session.helpers.test.ts @@ -10,6 +10,9 @@ import { buildSynthesisInputRecord, clampConfidenceWithDelta, filterExecutionHistoryForCycle, + hasFailedCaptures, + isJsonFile, + isSynthesisPendingFile, listCompletedBetDescriptions, mapBridgeRunStatusToIncompleteStatus, mapBridgeRunStatusToSyncedOutcome, @@ -373,4 +376,42 @@ describe('cooldown-session helpers', () => { ])).toEqual(['Complete bet', 'Partial bet']); }); }); + + describe('isJsonFile', () => { + it('returns true for .json files', () => { + expect(isJsonFile('run.json')).toBe(true); + expect(isJsonFile('pending-abc.json')).toBe(true); + }); + + it('returns false for non-.json files', () => { + expect(isJsonFile('readme.md')).toBe(false); + expect(isJsonFile('json')).toBe(false); + expect(isJsonFile('')).toBe(false); + }); + }); + + describe('isSynthesisPendingFile', () => { + it('returns true for pending-*.json files', () => { + expect(isSynthesisPendingFile('pending-abc.json')).toBe(true); + expect(isSynthesisPendingFile('pending-123-456.json')).toBe(true); + }); + + it('returns false when prefix or suffix is wrong', () => { + expect(isSynthesisPendingFile('result-abc.json')).toBe(false); + expect(isSynthesisPendingFile('pending-abc.txt')).toBe(false); + expect(isSynthesisPendingFile('pending-.md')).toBe(false); + expect(isSynthesisPendingFile('')).toBe(false); + }); + }); + + 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); + }); + }); }); diff --git a/src/features/cycle-management/cooldown-session.helpers.ts b/src/features/cycle-management/cooldown-session.helpers.ts index 4ce0201..bf64f8b 100644 --- a/src/features/cycle-management/cooldown-session.helpers.ts +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -292,3 +292,15 @@ export function listCompletedBetDescriptions( .filter((bet) => bet.outcome === 'complete' || bet.outcome === 'partial') .map((bet) => bet.description); } + +export function isJsonFile(filename: string): boolean { + return filename.endsWith('.json'); +} + +export function isSynthesisPendingFile(filename: string): boolean { + return filename.startsWith('pending-') && filename.endsWith('.json'); +} + +export function hasFailedCaptures(failed: number): boolean { + return failed > 0; +} diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index c983bd2..ab5b240 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -53,6 +53,9 @@ import { shouldWarnOnIncompleteRuns, shouldWriteDojoDiary, shouldWriteDojoSession, + isJsonFile, + isSynthesisPendingFile, + hasFailedCaptures, } from './cooldown-session.helpers.js'; /** @@ -839,7 +842,7 @@ export class CooldownSession { private cleanupStaleSynthesisInputs(synthesisDir: string, cycleId: string): void { try { - const existing = readdirSync(synthesisDir).filter((file) => file.startsWith('pending-') && file.endsWith('.json')); + const existing = readdirSync(synthesisDir).filter(isSynthesisPendingFile); for (const file of existing) { this.removeStaleSynthesisInputFile(synthesisDir, file, cycleId); } @@ -871,7 +874,6 @@ export class CooldownSession { */ private loadBridgeRunIdsByBetId(cycleId: string, bridgeRunsDir: string): Map { const result = new Map(); - if (!existsSync(bridgeRunsDir)) return result; for (const file of this.listJsonFiles(bridgeRunsDir)) { const meta = this.readBridgeRunMeta(join(bridgeRunsDir, file)); @@ -885,7 +887,7 @@ export class CooldownSession { private listJsonFiles(dir: string): string[] { try { - return readdirSync(dir).filter((file) => file.endsWith('.json')); + return readdirSync(dir).filter(isJsonFile); } catch { return []; } @@ -937,7 +939,6 @@ export class CooldownSession { runId: string, ): BetOutcomeRecord['outcome'] | undefined { const bridgeRunPath = join(bridgeRunsDir, `${runId}.json`); - if (!existsSync(bridgeRunPath)) return undefined; const status = this.readBridgeRunMeta(bridgeRunPath)?.status; return mapBridgeRunStatusToSyncedOutcome(status); } @@ -988,7 +989,7 @@ export class CooldownSession { */ private captureCooldownLearnings(report: CooldownReport): number { const attempts = this.captureCooldownLearningDrafts(report); - if (attempts.failed > 0) { + if (hasFailedCaptures(attempts.failed)) { logger.warn(`${attempts.failed} of ${attempts.captured + attempts.failed} cooldown learnings failed to capture. Check previous warnings for details.`); } @@ -1079,7 +1080,6 @@ export class CooldownSession { if (!this.deps.bridgeRunsDir) return undefined; const bridgeRunPath = join(this.deps.bridgeRunsDir, `${runId}.json`); - if (!existsSync(bridgeRunPath)) return undefined; const status = this.readBridgeRunMeta(bridgeRunPath)?.status; const incompleteStatus = mapBridgeRunStatusToIncompleteStatus(status); return incompleteStatus ?? null; From 6e67e9f46e7c546e7c479e48b5215c4673a55d63 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:25:40 -0400 Subject: [PATCH 04/10] refactor: extract resolveJsonFlag, betStatusSymbol, resolveCompletionStatus from execute.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move three pure business-logic functions out of CLI command wiring into execute.helpers.ts with direct unit tests. Eliminates 4+ LogicalOperator and ConditionalExpression mutation survivors in execute.ts. Addresses #375 Phase 2 — execute helper extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/execute.helpers.test.ts | 55 ++++++++++++++++++++++++ src/cli/commands/execute.helpers.ts | 15 +++++++ src/cli/commands/execute.ts | 17 +++++--- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index f23eb10..4ef05aa 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -1,5 +1,6 @@ import { assertValidKataName, + betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, formatDurationMs, @@ -10,6 +11,8 @@ import { parseCompletedRunArtifacts, parseCompletedRunTokenUsage, parseHintFlags, + resolveCompletionStatus, + resolveJsonFlag, } from '@cli/commands/execute.helpers.js'; describe('execute helpers', () => { @@ -421,4 +424,56 @@ describe('execute helpers', () => { ]); }); }); + + describe('resolveJsonFlag', () => { + it('returns true when local json is set', () => { + expect(resolveJsonFlag(true, undefined)).toBe(true); + }); + + it('returns true when global json is set', () => { + expect(resolveJsonFlag(undefined, true)).toBe(true); + }); + + it('returns true when both are set', () => { + expect(resolveJsonFlag(true, true)).toBe(true); + }); + + it('returns false when neither is set', () => { + expect(resolveJsonFlag(undefined, undefined)).toBe(false); + expect(resolveJsonFlag(false, false)).toBe(false); + }); + }); + + describe('betStatusSymbol', () => { + it('returns looping arrow for in-progress', () => { + expect(betStatusSymbol('in-progress')).toBe('\u27F3'); + }); + + it('returns check for complete', () => { + expect(betStatusSymbol('complete')).toBe('\u2713'); + }); + + it('returns cross for failed', () => { + expect(betStatusSymbol('failed')).toBe('\u2717'); + }); + + it('returns dot for unknown status', () => { + expect(betStatusSymbol('pending')).toBe('\u00B7'); + expect(betStatusSymbol('')).toBe('\u00B7'); + }); + }); + + describe('resolveCompletionStatus', () => { + it('returns failed when flag is true', () => { + expect(resolveCompletionStatus(true)).toBe('failed'); + }); + + it('returns complete when flag is false', () => { + expect(resolveCompletionStatus(false)).toBe('complete'); + }); + + it('returns complete when flag is undefined', () => { + expect(resolveCompletionStatus(undefined)).toBe('complete'); + }); + }); }); diff --git a/src/cli/commands/execute.helpers.ts b/src/cli/commands/execute.helpers.ts index 10986ac..47304a6 100644 --- a/src/cli/commands/execute.helpers.ts +++ b/src/cli/commands/execute.helpers.ts @@ -310,6 +310,21 @@ export function buildPreparedRunOutputLines(result: PreparedRunOutput, agentCont ]; } +export function resolveJsonFlag(localJson: boolean | undefined, globalJson: boolean | undefined): boolean { + return !!(localJson || globalJson); +} + +export function betStatusSymbol(status: string): string { + if (status === 'in-progress') return '\u27F3'; + if (status === 'complete') return '\u2713'; + if (status === 'failed') return '\u2717'; + return '\u00B7'; +} + +export function resolveCompletionStatus(failed: boolean | undefined): 'failed' | 'complete' { + return failed ? 'failed' : 'complete'; +} + export function assertValidKataName(name: string): void { if (!/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error( diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 7e6e71e..4f6a3ea 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -23,6 +23,7 @@ import { CycleManager } from '@domain/services/cycle-manager.js'; import { SessionExecutionBridge } from '@infra/execution/session-bridge.js'; import { assertValidKataName, + betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, formatDurationMs, @@ -33,6 +34,8 @@ import { parseCompletedRunArtifacts, parseCompletedRunTokenUsage, parseHintFlags, + resolveCompletionStatus, + resolveJsonFlag, } from '@cli/commands/execute.helpers.js'; import { resolveRef } from '@cli/resolve-ref.js'; import { handleStatus, handleStats, parseCategoryFilter } from './status.js'; @@ -102,7 +105,7 @@ export function registerExecuteCommands(program: Command): void { kataka?: string; json?: boolean; }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const agentId = localOpts.agent ?? localOpts.kataka; @@ -143,7 +146,7 @@ export function registerExecuteCommands(program: Command): void { } console.log(''); for (const bet of result.bets) { - const status = bet.status === 'in-progress' ? '⟳' : bet.status === 'complete' ? '✓' : bet.status === 'failed' ? '✗' : '·'; + const status = betStatusSymbol(bet.status); console.log(` ${status} ${bet.betName} [${bet.status}]`); if (bet.runId) { console.log(` kansatsu: ${bet.kansatsuCount}, maki: ${bet.artifactCount}, kime: ${bet.decisionCount}`); @@ -189,7 +192,7 @@ export function registerExecuteCommands(program: Command): void { outputTokens?: number; json?: boolean; }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const parsedArtifacts = parseCompletedRunArtifacts(localOpts.artifacts); @@ -226,14 +229,14 @@ export function registerExecuteCommands(program: Command): void { if (isJson) { console.log(JSON.stringify({ runId, - status: localOpts.failed ? 'failed' : 'complete', + status: resolveCompletionStatus(localOpts.failed), ...(tokenUsage ? { tokenUsage } : {}), })); } else { const tokenLine = hasTokens ? ` (tokens: ${totalTokens ?? 0} total, ${tokenUsage?.inputTokens ?? 0} in, ${tokenUsage?.outputTokens ?? 0} out)` : ''; - console.log(`Run ${runId} marked as ${localOpts.failed ? 'failed' : 'complete'}.${tokenLine}`); + console.log(`Run ${runId} marked as ${resolveCompletionStatus(localOpts.failed)}.${tokenLine}`); } })); @@ -245,7 +248,7 @@ export function registerExecuteCommands(program: Command): void { .option('--json', 'Wrap output in a JSON object with a "agentContext" key') .action(withCommandContext(async (ctx, runId: string) => { const localOpts = ctx.cmd.opts() as { json?: boolean }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const agentContext = bridge.getAgentContext(runId); @@ -267,7 +270,7 @@ export function registerExecuteCommands(program: Command): void { .option('--json', 'Output as JSON') .action(withCommandContext(async (ctx) => { const localOpts = ctx.cmd.opts() as { bet: string; agent?: string; kataka?: string; json?: boolean }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const agentId = localOpts.agent ?? localOpts.kataka; From 08f894e5b6b8805b33855e34aac163b23a548cb5 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:28:33 -0400 Subject: [PATCH 05/10] refactor: extract isNewerRun and listRunDirectoryIds from observability aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract run-directory listing and latest-run comparison as pure exported functions. Remove redundant existsSync guard. Add tests for directory filtering (files vs dirs) and multi-run latest tracking. Addresses #375 Phase 3 — observability aggregator extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ata-agent-observability-aggregator.test.ts | 62 ++++++++++++++++++- .../kata-agent-observability-aggregator.ts | 34 ++++++---- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/features/kata-agent/kata-agent-observability-aggregator.test.ts b/src/features/kata-agent/kata-agent-observability-aggregator.test.ts index 18cf834..3ea1504 100644 --- a/src/features/kata-agent/kata-agent-observability-aggregator.test.ts +++ b/src/features/kata-agent/kata-agent-observability-aggregator.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { KataAgentObservabilityAggregator } from './kata-agent-observability-aggregator.js'; +import { KataAgentObservabilityAggregator, isNewerRun, listRunDirectoryIds } from './kata-agent-observability-aggregator.js'; import { createRunTree } from '@infra/persistence/run-store.js'; import { appendObservation } from '@infra/persistence/run-store.js'; import type { Run } from '@domain/types/run-state.js'; @@ -307,4 +307,64 @@ describe('KataAgentObservabilityAggregator', () => { expect(stats.agentLearningCount).toBe(1); }); }); + + describe('lastRunId / lastActiveAt tracking', () => { + it('selects the run with the latest startedAt across multiple runs', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const agentId = randomUUID(); + + const olderRun = makeRun({ katakaId: agentId, startedAt: '2025-01-01T00:00:00Z' }); + const newerRun = makeRun({ katakaId: agentId, startedAt: '2025-06-15T12:00:00Z' }); + + createRunTree(runsDir, olderRun); + createRunTree(runsDir, newerRun); + + const aggregator = new KataAgentObservabilityAggregator(runsDir, knowledgeDir); + const stats = aggregator.computeStats(agentId, 'test-agent'); + + expect(stats.lastRunId).toBe(newerRun.id); + expect(stats.lastActiveAt).toBe('2025-06-15T12:00:00Z'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Pure helper tests +// --------------------------------------------------------------------------- + +describe('isNewerRun', () => { + it('returns true when latestStartedAt is undefined (first run)', () => { + expect(isNewerRun('2025-01-01T00:00:00Z', undefined)).toBe(true); + }); + + it('returns true when startedAt is strictly later', () => { + expect(isNewerRun('2025-06-15T00:00:00Z', '2025-01-01T00:00:00Z')).toBe(true); + }); + + it('returns false when startedAt is earlier', () => { + expect(isNewerRun('2025-01-01T00:00:00Z', '2025-06-15T00:00:00Z')).toBe(false); + }); + + it('returns false when startedAt equals latestStartedAt', () => { + expect(isNewerRun('2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')).toBe(false); + }); +}); + +describe('listRunDirectoryIds', () => { + it('returns directory names from runsDir', () => { + const { runsDir } = makeDirs(); + mkdirSync(join(runsDir, 'run-a')); + mkdirSync(join(runsDir, 'run-b')); + // Create a non-directory file to verify filtering + writeFileSync(join(runsDir, 'not-a-dir.json'), '{}'); + + const ids = listRunDirectoryIds(runsDir); + expect(ids).toContain('run-a'); + expect(ids).toContain('run-b'); + expect(ids).not.toContain('not-a-dir.json'); + }); + + it('returns empty array for nonexistent directory', () => { + expect(listRunDirectoryIds('/nonexistent/path/xyz')).toEqual([]); + }); }); diff --git a/src/features/kata-agent/kata-agent-observability-aggregator.ts b/src/features/kata-agent/kata-agent-observability-aggregator.ts index 4677395..15b87f9 100644 --- a/src/features/kata-agent/kata-agent-observability-aggregator.ts +++ b/src/features/kata-agent/kata-agent-observability-aggregator.ts @@ -1,4 +1,4 @@ -import { readdirSync, existsSync } from 'node:fs'; +import { readdirSync } from 'node:fs'; import { RunSchema } from '@domain/types/run-state.js'; import { JsonStore } from '@infra/persistence/json-store.js'; import { readObservations, runPaths } from '@infra/persistence/run-store.js'; @@ -34,6 +34,24 @@ export interface KataAgentObservabilityStats { lastActiveAt?: string; } +// --------------------------------------------------------------------------- +// Pure helpers — extracted for direct unit testing and mutation coverage +// --------------------------------------------------------------------------- + +export function isNewerRun(startedAt: string, latestStartedAt: string | undefined): boolean { + return latestStartedAt === undefined || startedAt > latestStartedAt; +} + +export function listRunDirectoryIds(runsDir: string): string[] { + try { + return readdirSync(runsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return []; + } +} + // --------------------------------------------------------------------------- // KataAgentObservabilityAggregator // --------------------------------------------------------------------------- @@ -69,17 +87,7 @@ export class KataAgentObservabilityAggregator { }; // --- Step 1: list run directories --- - let runIds: string[] = []; - if (existsSync(this.runsDir)) { - try { - runIds = readdirSync(this.runsDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - } catch { - // runsDir unreadable — return empty stats - return stats; - } - } + const runIds = listRunDirectoryIds(this.runsDir); // Track the most-recent run by startedAt (ISO string — lexicographic comparison is valid) let latestStartedAt: string | undefined; @@ -98,7 +106,7 @@ export class KataAgentObservabilityAggregator { if ((run.agentId ?? run.katakaId) !== agentId) continue; // Track the most recent run - if (latestStartedAt === undefined || run.startedAt > latestStartedAt) { + if (isNewerRun(run.startedAt, latestStartedAt)) { latestStartedAt = run.startedAt; stats.lastRunId = run.id; stats.lastRunCycleId = run.cycleId; From 39bb0de157af3ead48017d6bdd26a24e265e4f61 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:30:14 -0400 Subject: [PATCH 06/10] fix: restore existsSync guard in readIncompleteBridgeRunStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This guard is semantically meaningful — it distinguishes "file missing" (undefined, triggers run.json fallback) from "file found but run not in-progress" (null, no fallback). Removing it broke the checkIncompleteRuns fallthrough to run.json for runs without bridge metadata. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/features/cycle-management/cooldown-session.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index ab5b240..fa923a3 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -1080,6 +1080,7 @@ export class CooldownSession { if (!this.deps.bridgeRunsDir) return undefined; const bridgeRunPath = join(this.deps.bridgeRunsDir, `${runId}.json`); + if (!existsSync(bridgeRunPath)) return undefined; const status = this.readBridgeRunMeta(bridgeRunPath)?.status; const incompleteStatus = mapBridgeRunStatusToIncompleteStatus(status); return incompleteStatus ?? null; From d56e409987eb3d4c98692495a4b90bf72b352ef2 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:33:12 -0400 Subject: [PATCH 07/10] refactor: extract isAttributedToAgent and countObservationsByType from aggregator Reduce cyclomatic complexity of computeStats by extracting agent attribution check and observation counting as pure functions. Adds direct unit tests for both. Targets CRAP strict threshold for computeStats (CC 9 -> lower effective complexity). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ata-agent-observability-aggregator.test.ts | 52 ++++++++++++++++++- .../kata-agent-observability-aggregator.ts | 30 +++++++++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/features/kata-agent/kata-agent-observability-aggregator.test.ts b/src/features/kata-agent/kata-agent-observability-aggregator.test.ts index 3ea1504..f922595 100644 --- a/src/features/kata-agent/kata-agent-observability-aggregator.test.ts +++ b/src/features/kata-agent/kata-agent-observability-aggregator.test.ts @@ -2,7 +2,13 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { KataAgentObservabilityAggregator, isNewerRun, listRunDirectoryIds } from './kata-agent-observability-aggregator.js'; +import { + KataAgentObservabilityAggregator, + countObservationsByType, + isAttributedToAgent, + isNewerRun, + listRunDirectoryIds, +} from './kata-agent-observability-aggregator.js'; import { createRunTree } from '@infra/persistence/run-store.js'; import { appendObservation } from '@infra/persistence/run-store.js'; import type { Run } from '@domain/types/run-state.js'; @@ -350,6 +356,50 @@ describe('isNewerRun', () => { }); }); +describe('isAttributedToAgent', () => { + it('returns true when agentId matches', () => { + expect(isAttributedToAgent({ agentId: 'a1' }, 'a1')).toBe(true); + }); + + it('falls back to katakaId when agentId is undefined', () => { + expect(isAttributedToAgent({ katakaId: 'k1' }, 'k1')).toBe(true); + }); + + it('prefers agentId over katakaId', () => { + expect(isAttributedToAgent({ agentId: 'a1', katakaId: 'k1' }, 'a1')).toBe(true); + expect(isAttributedToAgent({ agentId: 'a1', katakaId: 'k1' }, 'k1')).toBe(false); + }); + + it('returns false when neither matches', () => { + expect(isAttributedToAgent({ agentId: 'other' }, 'a1')).toBe(false); + expect(isAttributedToAgent({}, 'a1')).toBe(false); + }); +}); + +describe('countObservationsByType', () => { + it('returns zero count for empty array', () => { + expect(countObservationsByType([])).toEqual({ count: 0, byType: {} }); + }); + + it('counts observations grouped by type', () => { + const obs = [ + { type: 'insight' }, + { type: 'friction' }, + { type: 'insight' }, + { type: 'prediction' }, + ]; + const result = countObservationsByType(obs); + expect(result.count).toBe(4); + expect(result.byType).toEqual({ insight: 2, friction: 1, prediction: 1 }); + }); + + it('handles single observation', () => { + const result = countObservationsByType([{ type: 'insight' }]); + expect(result.count).toBe(1); + expect(result.byType).toEqual({ insight: 1 }); + }); +}); + describe('listRunDirectoryIds', () => { it('returns directory names from runsDir', () => { const { runsDir } = makeDirs(); diff --git a/src/features/kata-agent/kata-agent-observability-aggregator.ts b/src/features/kata-agent/kata-agent-observability-aggregator.ts index 15b87f9..32ffb93 100644 --- a/src/features/kata-agent/kata-agent-observability-aggregator.ts +++ b/src/features/kata-agent/kata-agent-observability-aggregator.ts @@ -42,6 +42,25 @@ export function isNewerRun(startedAt: string, latestStartedAt: string | undefine return latestStartedAt === undefined || startedAt > latestStartedAt; } +export function isAttributedToAgent( + entity: { agentId?: string; katakaId?: string }, + agentId: string, +): boolean { + return (entity.agentId ?? entity.katakaId) === agentId; +} + +export function countObservationsByType( + observations: ReadonlyArray<{ type: string }>, +): { count: number; byType: Record } { + const byType: Record = {}; + let count = 0; + for (const obs of observations) { + count++; + byType[obs.type] = (byType[obs.type] ?? 0) + 1; + } + return { count, byType }; +} + export function listRunDirectoryIds(runsDir: string): string[] { try { return readdirSync(runsDir, { withFileTypes: true }) @@ -103,7 +122,7 @@ export class KataAgentObservabilityAggregator { continue; } - if ((run.agentId ?? run.katakaId) !== agentId) continue; + if (!isAttributedToAgent(run, agentId)) continue; // Track the most recent run if (isNewerRun(run.startedAt, latestStartedAt)) { @@ -129,12 +148,13 @@ export class KataAgentObservabilityAggregator { const allObs = [...runObs, ...stageObs]; // --- Step 2b: filter to observations attributed to this agent --- - const attributed = allObs.filter((o) => (o.agentId ?? o.katakaId) === agentId); + const attributed = allObs.filter((o) => isAttributedToAgent(o, agentId)); // --- Step 2c: count by type --- - for (const obs of attributed) { - stats.observationCount++; - stats.observationsByType[obs.type] = (stats.observationsByType[obs.type] ?? 0) + 1; + const counts = countObservationsByType(attributed); + stats.observationCount += counts.count; + for (const [type, count] of Object.entries(counts.byType)) { + stats.observationsByType[type] = (stats.observationsByType[type] ?? 0) + count; } } From 104fb1adea9d140d779fa565ecb699f41017155b Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:38:10 -0400 Subject: [PATCH 08/10] refactor: extract pure helpers from session-bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create session-bridge.helpers.ts with canTransitionCycleState, hasBridgeRunMetadataChanged, and isJsonFile. Wire into session-bridge.ts replacing inline logic. Add 13 unit tests covering state transitions, metadata change detection, and file filtering. Addresses #375 Phase 3 — session-bridge bounded extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../execution/session-bridge.helpers.test.ts | 82 +++++++++++++++++++ .../execution/session-bridge.helpers.ts | 31 +++++++ .../execution/session-bridge.ts | 24 +++--- 3 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 src/infrastructure/execution/session-bridge.helpers.test.ts create mode 100644 src/infrastructure/execution/session-bridge.helpers.ts diff --git a/src/infrastructure/execution/session-bridge.helpers.test.ts b/src/infrastructure/execution/session-bridge.helpers.test.ts new file mode 100644 index 0000000..9c235ea --- /dev/null +++ b/src/infrastructure/execution/session-bridge.helpers.test.ts @@ -0,0 +1,82 @@ +import { + canTransitionCycleState, + hasBridgeRunMetadataChanged, + isJsonFile, +} from './session-bridge.helpers.js'; + +describe('session-bridge helpers', () => { + describe('canTransitionCycleState', () => { + it('allows planning → active', () => { + expect(canTransitionCycleState('planning', 'active')).toBe(true); + }); + + it('allows active → cooldown', () => { + expect(canTransitionCycleState('active', 'cooldown')).toBe(true); + }); + + it('allows cooldown → complete', () => { + expect(canTransitionCycleState('cooldown', 'complete')).toBe(true); + }); + + it('rejects active → complete (skipping cooldown)', () => { + expect(canTransitionCycleState('active', 'complete')).toBe(false); + }); + + it('rejects planning → cooldown (skipping active)', () => { + expect(canTransitionCycleState('planning', 'cooldown')).toBe(false); + }); + + it('rejects backward transitions', () => { + expect(canTransitionCycleState('active', 'planning')).toBe(false); + expect(canTransitionCycleState('complete', 'active')).toBe(false); + }); + + it('rejects same-state transitions', () => { + expect(canTransitionCycleState('active', 'active')).toBe(false); + }); + }); + + describe('hasBridgeRunMetadataChanged', () => { + it('returns false when both fields match', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'A', cycleName: 'C1' }, + )).toBe(false); + }); + + it('returns true when betName differs', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'B', cycleName: 'C1' }, + )).toBe(true); + }); + + it('returns true when cycleName differs', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'A', cycleName: 'C2' }, + )).toBe(true); + }); + + it('returns true when both differ', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'B', cycleName: 'C2' }, + )).toBe(true); + }); + }); + + describe('isJsonFile', () => { + it('returns true for .json files', () => { + expect(isJsonFile('cycle.json')).toBe(true); + expect(isJsonFile('data.json')).toBe(true); + }); + + it('returns false for non-.json files', () => { + expect(isJsonFile('readme.md')).toBe(false); + expect(isJsonFile('json')).toBe(false); + expect(isJsonFile('')).toBe(false); + expect(isJsonFile('.DS_Store')).toBe(false); + }); + }); +}); diff --git a/src/infrastructure/execution/session-bridge.helpers.ts b/src/infrastructure/execution/session-bridge.helpers.ts new file mode 100644 index 0000000..c60ec35 --- /dev/null +++ b/src/infrastructure/execution/session-bridge.helpers.ts @@ -0,0 +1,31 @@ +import type { CycleState } from '@domain/types/cycle.js'; + +/** + * Check whether a cycle state transition is allowed. + * Valid transitions: planning → active → cooldown → complete. + */ +export function canTransitionCycleState(from: CycleState, to: CycleState): boolean { + const allowedTransitions: Partial> = { + planning: 'active', + active: 'cooldown', + cooldown: 'complete', + }; + return allowedTransitions[from] === to; +} + +/** + * Detect whether bridge-run metadata has changed vs its refreshed values. + */ +export function hasBridgeRunMetadataChanged( + current: { betName?: string; cycleName?: string }, + refreshed: { betName?: string; cycleName?: string }, +): boolean { + return refreshed.betName !== current.betName || refreshed.cycleName !== current.cycleName; +} + +/** + * Filter filenames to only .json files. + */ +export function isJsonFile(filename: string): boolean { + return filename.endsWith('.json'); +} diff --git a/src/infrastructure/execution/session-bridge.ts b/src/infrastructure/execution/session-bridge.ts index dfa7f7d..cf52fd7 100644 --- a/src/infrastructure/execution/session-bridge.ts +++ b/src/infrastructure/execution/session-bridge.ts @@ -18,6 +18,11 @@ import { type Bet } from '@domain/types/bet.js'; import { StageCategorySchema } from '@domain/types/stage.js'; import { z } from 'zod/v4'; import { JsonStore } from '@infra/persistence/json-store.js'; +import { + canTransitionCycleState, + hasBridgeRunMetadataChanged, + isJsonFile, +} from './session-bridge.helpers.js'; import { createRunTree, readRun, writeRun, runPaths } from '@infra/persistence/run-store.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; import { logger } from '@shared/lib/logger.js'; @@ -420,7 +425,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { throw new Error('No cycles directory found. Run "kata cycle new" first.'); } - const files = readdirSync(cyclesDir).filter((f) => f.endsWith('.json')); + const files = readdirSync(cyclesDir).filter(isJsonFile); for (const file of files) { try { const cycle = JsonStore.read(join(cyclesDir, file), CycleSchema); @@ -437,7 +442,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { private loadCycle(cycleId: string): Cycle { const cyclesDir = join(this.kataDir, KATA_DIRS.cycles); - const files = readdirSync(cyclesDir).filter((f) => f.endsWith('.json')); + const files = readdirSync(cyclesDir).filter(isJsonFile); for (const file of files) { try { @@ -520,7 +525,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { this.writeCycleNameIfChanged(cyclePath, cycle, name); return; } - if (!this.canTransitionCycleState(cycle.state, state)) { + if (!this.canTransition(cycle.state, state)) { logger.warn(`Cannot transition cycle "${cycleId}" from "${cycle.state}" to "${state}".`); return; } @@ -531,14 +536,9 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { } } - private canTransitionCycleState(from: CycleState, to: CycleState): boolean { - const allowedTransitions: Partial> = { - planning: 'active', - active: 'cooldown', - cooldown: 'complete', - }; - - return allowedTransitions[from] === to; + // 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 { @@ -648,7 +648,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { cycleName: cycle.name ?? cycle.id, }; - let changed = refreshed.betName !== meta.betName || refreshed.cycleName !== meta.cycleName; + let changed = hasBridgeRunMetadataChanged(meta, refreshed); if (agentId && !meta.agentId) { refreshed.agentId = agentId; From f0332749ede7f0d71840b817c24775386ab9436d Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:40:03 -0400 Subject: [PATCH 09/10] test: update session-bridge unit test to use exported helper Replace prototype-hacking access to private canTransitionCycleState with direct import from session-bridge.helpers.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/infrastructure/execution/session-bridge.unit.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/infrastructure/execution/session-bridge.unit.test.ts b/src/infrastructure/execution/session-bridge.unit.test.ts index 8b8ba26..8e0faad 100644 --- a/src/infrastructure/execution/session-bridge.unit.test.ts +++ b/src/infrastructure/execution/session-bridge.unit.test.ts @@ -8,6 +8,7 @@ import { RunSchema } from '@domain/types/run-state.js'; import type { AgentCompletionResult } from '@domain/ports/session-bridge.js'; import { logger } from '@shared/lib/logger.js'; import { SessionExecutionBridge } from './session-bridge.js'; +import { canTransitionCycleState } from './session-bridge.helpers.js'; function createTestDir(): string { const dir = join(tmpdir(), `kata-bridge-unit-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -188,14 +189,6 @@ describe('SessionExecutionBridge unit coverage', () => { }); it('only allows adjacent forward cycle state transitions', () => { - const bridge = new SessionExecutionBridge(kataDir); - const canTransitionCycleState = (bridge as unknown as { - canTransitionCycleState: ( - from: 'planning' | 'active' | 'cooldown' | 'complete', - to: 'planning' | 'active' | 'cooldown' | 'complete', - ) => boolean; - }).canTransitionCycleState; - expect(canTransitionCycleState('planning', 'active')).toBe(true); expect(canTransitionCycleState('active', 'cooldown')).toBe(true); expect(canTransitionCycleState('cooldown', 'complete')).toBe(true); From 461a494a1469fcc9d6b0837652df64e5df293939 Mon Sep 17 00:00:00 2001 From: Christopher Bays Date: Mon, 16 Mar 2026 06:50:46 -0400 Subject: [PATCH 10/10] refactor: consolidate duplicate isJsonFile into shared utility Address CodeRabbit review feedback: - Move isJsonFile to shared file-filters module (single source of truth) - Re-export from session-bridge.helpers and cooldown-session.helpers - Add explicit local=false,global=true test for resolveJsonFlag Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/execute.helpers.test.ts | 1 + .../cooldown-session.helpers.ts | 4 +--- .../execution/session-bridge.helpers.test.ts | 11 ++--------- .../execution/session-bridge.helpers.ts | 9 ++------- src/shared/lib/file-filters.test.ts | 17 +++++++++++++++++ src/shared/lib/file-filters.ts | 7 +++++++ 6 files changed, 30 insertions(+), 19 deletions(-) create mode 100644 src/shared/lib/file-filters.test.ts create mode 100644 src/shared/lib/file-filters.ts diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index 4ef05aa..b651af7 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -432,6 +432,7 @@ describe('execute helpers', () => { it('returns true when global json is set', () => { expect(resolveJsonFlag(undefined, true)).toBe(true); + expect(resolveJsonFlag(false, true)).toBe(true); }); it('returns true when both are set', () => { diff --git a/src/features/cycle-management/cooldown-session.helpers.ts b/src/features/cycle-management/cooldown-session.helpers.ts index bf64f8b..3a8ae9e 100644 --- a/src/features/cycle-management/cooldown-session.helpers.ts +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -293,9 +293,7 @@ export function listCompletedBetDescriptions( .map((bet) => bet.description); } -export function isJsonFile(filename: string): boolean { - return filename.endsWith('.json'); -} +export { isJsonFile } from '@shared/lib/file-filters.js'; export function isSynthesisPendingFile(filename: string): boolean { return filename.startsWith('pending-') && filename.endsWith('.json'); diff --git a/src/infrastructure/execution/session-bridge.helpers.test.ts b/src/infrastructure/execution/session-bridge.helpers.test.ts index 9c235ea..20d041e 100644 --- a/src/infrastructure/execution/session-bridge.helpers.test.ts +++ b/src/infrastructure/execution/session-bridge.helpers.test.ts @@ -66,17 +66,10 @@ describe('session-bridge helpers', () => { }); }); - describe('isJsonFile', () => { - it('returns true for .json files', () => { - expect(isJsonFile('cycle.json')).toBe(true); + describe('isJsonFile (re-exported from shared)', () => { + it('is re-exported and callable', () => { expect(isJsonFile('data.json')).toBe(true); - }); - - it('returns false for non-.json files', () => { expect(isJsonFile('readme.md')).toBe(false); - expect(isJsonFile('json')).toBe(false); - expect(isJsonFile('')).toBe(false); - expect(isJsonFile('.DS_Store')).toBe(false); }); }); }); diff --git a/src/infrastructure/execution/session-bridge.helpers.ts b/src/infrastructure/execution/session-bridge.helpers.ts index c60ec35..659c49d 100644 --- a/src/infrastructure/execution/session-bridge.helpers.ts +++ b/src/infrastructure/execution/session-bridge.helpers.ts @@ -1,5 +1,7 @@ import type { CycleState } from '@domain/types/cycle.js'; +export { isJsonFile } from '@shared/lib/file-filters.js'; + /** * Check whether a cycle state transition is allowed. * Valid transitions: planning → active → cooldown → complete. @@ -22,10 +24,3 @@ export function hasBridgeRunMetadataChanged( ): boolean { return refreshed.betName !== current.betName || refreshed.cycleName !== current.cycleName; } - -/** - * Filter filenames to only .json files. - */ -export function isJsonFile(filename: string): boolean { - return filename.endsWith('.json'); -} diff --git a/src/shared/lib/file-filters.test.ts b/src/shared/lib/file-filters.test.ts new file mode 100644 index 0000000..c791d3c --- /dev/null +++ b/src/shared/lib/file-filters.test.ts @@ -0,0 +1,17 @@ +import { isJsonFile } from './file-filters.js'; + +describe('file-filters', () => { + describe('isJsonFile', () => { + it('returns true for .json files', () => { + expect(isJsonFile('data.json')).toBe(true); + expect(isJsonFile('pending-abc.json')).toBe(true); + }); + + it('returns false for non-.json files', () => { + expect(isJsonFile('readme.md')).toBe(false); + expect(isJsonFile('json')).toBe(false); + expect(isJsonFile('')).toBe(false); + expect(isJsonFile('.DS_Store')).toBe(false); + }); + }); +}); diff --git a/src/shared/lib/file-filters.ts b/src/shared/lib/file-filters.ts new file mode 100644 index 0000000..f8ef7df --- /dev/null +++ b/src/shared/lib/file-filters.ts @@ -0,0 +1,7 @@ +/** + * Shared file-filtering predicates used across layers. + */ + +export function isJsonFile(filename: string): boolean { + return filename.endsWith('.json'); +}