diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index feef276..f23eb10 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -1,7 +1,14 @@ import { + assertValidKataName, + buildPreparedCycleOutputLines, + buildPreparedRunOutputLines, formatDurationMs, + formatAgentLoadError, formatExplain, + mergePinnedFlavors, parseBetOption, + parseCompletedRunArtifacts, + parseCompletedRunTokenUsage, parseHintFlags, } from '@cli/commands/execute.helpers.js'; @@ -235,4 +242,183 @@ describe('execute helpers', () => { expect(formatDurationMs(7_440_000)).toBe('2h 4m'); }); }); + + describe('parseCompletedRunArtifacts', () => { + it('returns undefined when artifacts are omitted', () => { + expect(parseCompletedRunArtifacts(undefined)).toEqual({ ok: true, value: undefined }); + }); + + it('parses a valid artifact array', () => { + expect(parseCompletedRunArtifacts('[{"name":"report.md","path":"reports/report.md"}]')).toEqual({ + ok: true, + value: [{ name: 'report.md', path: 'reports/report.md' }], + }); + }); + + it('rejects non-array JSON payloads', () => { + expect(parseCompletedRunArtifacts('{"name":"report.md"}')).toEqual({ + ok: false, + error: 'Error: --artifacts must be a JSON array', + }); + }); + + it('rejects array items without a string name', () => { + expect(parseCompletedRunArtifacts('[null]')).toEqual({ + ok: false, + error: 'Error: each artifact must have a "name" string property', + }); + }); + + it('rejects invalid JSON', () => { + expect(parseCompletedRunArtifacts('{broken}')).toEqual({ + ok: false, + error: 'Error: --artifacts must be valid JSON', + }); + }); + }); + + describe('parseCompletedRunTokenUsage', () => { + it('returns no token usage when both values are omitted', () => { + expect(parseCompletedRunTokenUsage(undefined, undefined)).toEqual({ + ok: true, + value: { + hasTokens: false, + tokenUsage: undefined, + totalTokens: undefined, + }, + }); + }); + + it('sums partial token usage and defaults the missing side to zero', () => { + expect(parseCompletedRunTokenUsage(7, undefined)).toEqual({ + ok: true, + value: { + hasTokens: true, + tokenUsage: { inputTokens: 7, outputTokens: undefined, total: 7 }, + totalTokens: 7, + }, + }); + expect(parseCompletedRunTokenUsage(undefined, 3)).toEqual({ + ok: true, + value: { + hasTokens: true, + tokenUsage: { inputTokens: undefined, outputTokens: 3, total: 3 }, + totalTokens: 3, + }, + }); + }); + + it('preserves explicit zero counts', () => { + expect(parseCompletedRunTokenUsage(0, 0)).toEqual({ + ok: true, + value: { + hasTokens: true, + tokenUsage: { inputTokens: 0, outputTokens: 0, total: 0 }, + totalTokens: 0, + }, + }); + }); + + it('rejects negative or invalid values', () => { + expect(parseCompletedRunTokenUsage(-1, undefined)).toEqual({ + ok: false, + error: 'Error: --input-tokens must be a non-negative integer', + }); + expect(parseCompletedRunTokenUsage(undefined, Number.NaN)).toEqual({ + ok: false, + error: 'Error: --output-tokens must be a non-negative integer', + }); + }); + }); + + describe('assertValidKataName', () => { + it('accepts letters, digits, hyphens, and underscores', () => { + expect(() => assertValidKataName('my_kata-1')).not.toThrow(); + }); + + it('rejects names with traversal or path separators', () => { + expect(() => assertValidKataName('../evil')).toThrow('Invalid kata name "../evil"'); + expect(() => assertValidKataName('safe/')).toThrow('Invalid kata name "safe/"'); + }); + }); + + describe('formatAgentLoadError', () => { + it('maps missing agents to the not-found guidance', () => { + expect(formatAgentLoadError( + '1234', + 'Agent "1234" not found.', + )).toBe('Error: agent "1234" not found. Use "kata agent list" to see registered agents.'); + }); + + it('preserves wrapped registry load failures without duplicating the prefix', () => { + expect(formatAgentLoadError( + '1234', + 'Failed to load agent "1234": Invalid input: expected string, received undefined', + )).toBe('Error: Failed to load agent "1234": Invalid input: expected string, received undefined'); + }); + + it('wraps raw load failures with agent context', () => { + expect(formatAgentLoadError( + '1234', + 'Invalid agent ID: "1234"', + )).toBe('Error: Failed to load agent "1234": Invalid agent ID: "1234"'); + }); + }); + + describe('mergePinnedFlavors', () => { + it('returns undefined when neither flag provided any values', () => { + expect(mergePinnedFlavors(undefined, undefined)).toBeUndefined(); + expect(mergePinnedFlavors([], [])).toBeUndefined(); + }); + + it('merges primary and fallback pins in order', () => { + expect(mergePinnedFlavors(['typescript-tdd'], ['legacy-build'])).toEqual([ + 'typescript-tdd', + 'legacy-build', + ]); + }); + }); + + describe('buildPreparedCycleOutputLines', () => { + it('renders a readable summary for each prepared cycle run', () => { + expect(buildPreparedCycleOutputLines({ + cycleName: 'Dispatch Cycle', + preparedRuns: [ + { + betName: 'Bet A', + runId: 'run-1', + stages: ['build', 'review'], + isolation: 'worktree', + }, + ], + })).toEqual([ + 'Prepared 1 run(s) for cycle "Dispatch Cycle"', + ' Bet A', + ' Run ID: run-1', + ' Stages: build, review', + ' Isolation: worktree', + ]); + }); + }); + + describe('buildPreparedRunOutputLines', () => { + it('renders the plain-text prepare output with the agent context block', () => { + expect(buildPreparedRunOutputLines({ + betName: 'Prepared bet', + runId: 'run-1', + cycleName: 'Cycle A', + stages: ['build'], + isolation: 'worktree', + }, '**Run ID**: run-1')).toEqual([ + 'Prepared run for bet: "Prepared bet"', + ' Run ID: run-1', + ' Cycle: Cycle A', + ' Stages: build', + ' Isolation: worktree', + '', + 'Agent context block (use "kata kiai context " to fetch at dispatch time):', + '**Run ID**: run-1', + ]); + }); + }); }); diff --git a/src/cli/commands/execute.helpers.ts b/src/cli/commands/execute.helpers.ts index bc2e994..10986ac 100644 --- a/src/cli/commands/execute.helpers.ts +++ b/src/cli/commands/execute.helpers.ts @@ -30,6 +30,34 @@ export type ParseSuccess = z.infer; export type ParseFailure = z.infer; export type ParseResult = z.infer; export type ExplainMatchReport = z.infer; +export type CompletedRunArtifact = { name: string; path?: string }; +export type CompletedRunTokenUsage = { + hasTokens: boolean; + totalTokens?: number; + tokenUsage?: { + inputTokens?: number; + outputTokens?: number; + total: number; + }; +}; + +export interface PreparedCycleOutput { + cycleName: string; + preparedRuns: ReadonlyArray<{ + betName: string; + runId: string; + stages: readonly string[]; + isolation: string; + }>; +} + +export interface PreparedRunOutput { + betName: string; + runId: string; + cycleName: string; + stages: readonly string[]; + isolation: string; +} const _betOptionResultSchema = z.discriminatedUnion('ok', [ parseSuccessSchema.extend({ @@ -48,6 +76,23 @@ const _hintFlagResultSchema = z.discriminatedUnion('ok', [ type BetOptionResult = z.infer; type HintFlagResult = z.infer; +const _completedRunArtifactSchema = z.object({ + name: z.string(), + path: z.string().optional(), +}).passthrough(); + +const _completedRunArtifactsSchema = z.array(_completedRunArtifactSchema); + +const _completedRunTokenUsageSchema = z.object({ + hasTokens: z.boolean(), + totalTokens: z.number().nonnegative().optional(), + tokenUsage: z.object({ + inputTokens: z.number().nonnegative().optional(), + outputTokens: z.number().nonnegative().optional(), + total: z.number().nonnegative(), + }).optional(), +}); + export function formatExplain( stageCategory: string, selectedFlavors: readonly string[], @@ -158,6 +203,121 @@ export function parseHintFlags(hints: readonly string[] | undefined): HintFlagRe return { ok: true, value: result }; } +export function parseCompletedRunArtifacts(artifactsJson: string | undefined): ParseResult { + if (artifactsJson === undefined) { + return { ok: true, value: undefined }; + } + + try { + const parsed = JSON.parse(artifactsJson); + const artifacts = _completedRunArtifactsSchema.safeParse(parsed); + if (!artifacts.success) { + return { + ok: false, + error: Array.isArray(parsed) + ? 'Error: each artifact must have a "name" string property' + : 'Error: --artifacts must be a JSON array', + }; + } + + return { ok: true, value: artifacts.data as CompletedRunArtifact[] }; + } catch { + return { + ok: false, + error: 'Error: --artifacts must be valid JSON', + }; + } +} + +export function parseCompletedRunTokenUsage(inputTokens: number | undefined, outputTokens: number | undefined): ParseResult { + if (inputTokens !== undefined && (Number.isNaN(inputTokens) || inputTokens < 0)) { + return { + ok: false, + error: 'Error: --input-tokens must be a non-negative integer', + }; + } + + if (outputTokens !== undefined && (Number.isNaN(outputTokens) || outputTokens < 0)) { + return { + ok: false, + error: 'Error: --output-tokens must be a non-negative integer', + }; + } + + const hasTokens = inputTokens !== undefined || outputTokens !== undefined; + const totalTokens = hasTokens ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined; + + return { + ok: true, + value: _completedRunTokenUsageSchema.parse({ + hasTokens, + totalTokens, + tokenUsage: hasTokens + ? { + inputTokens, + outputTokens, + total: totalTokens, + } + : undefined, + }) as CompletedRunTokenUsage, + }; +} + +export function formatAgentLoadError(agentId: string, message: string): string { + const normalizedMessage = message.trim(); + + if (/^Agent\b.*\bnot found\.?$/i.test(normalizedMessage)) { + return `Error: agent "${agentId}" not found. Use "kata agent list" to see registered agents.`; + } + + const loadFailurePrefix = `Failed to load agent "${agentId}":`; + if (normalizedMessage.startsWith(loadFailurePrefix)) { + return `Error: ${normalizedMessage}`; + } + + return `Error: Failed to load agent "${agentId}": ${normalizedMessage}`; +} + +export function mergePinnedFlavors( + primaryPins: readonly string[] | undefined, + fallbackPins: readonly string[] | undefined, +): string[] | undefined { + const merged = [...(primaryPins ?? []), ...(fallbackPins ?? [])]; + return merged.length > 0 ? merged : undefined; +} + +export function buildPreparedCycleOutputLines(result: PreparedCycleOutput): string[] { + const lines = [`Prepared ${result.preparedRuns.length} run(s) for cycle "${result.cycleName}"`]; + for (const run of result.preparedRuns) { + lines.push(` ${run.betName}`); + lines.push(` Run ID: ${run.runId}`); + lines.push(` Stages: ${run.stages.join(', ')}`); + lines.push(` Isolation: ${run.isolation}`); + } + return lines; +} + +export function buildPreparedRunOutputLines(result: PreparedRunOutput, agentContextBlock: string): string[] { + return [ + `Prepared run for bet: "${result.betName}"`, + ` Run ID: ${result.runId}`, + ` Cycle: ${result.cycleName}`, + ` Stages: ${result.stages.join(', ')}`, + ` Isolation: ${result.isolation}`, + '', + 'Agent context block (use "kata kiai context " to fetch at dispatch time):', + agentContextBlock, + ]; +} + +export function assertValidKataName(name: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new Error( + `Invalid kata name "${name}": names must contain only letters, digits, hyphens, and underscores.`, + ); + } +} + export function formatDurationMs(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); diff --git a/src/cli/commands/execute.test.ts b/src/cli/commands/execute.test.ts index 5dd5d6a..1c1adf0 100644 --- a/src/cli/commands/execute.test.ts +++ b/src/cli/commands/execute.test.ts @@ -7,6 +7,7 @@ import { registerExecuteCommands } 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'; +import { ProjectStateUpdater } from '@features/belt/belt-calculator.js'; // --------------------------------------------------------------------------- // Hoist mock functions before modules are imported @@ -17,6 +18,10 @@ const { mockRunStage, mockRunPipeline } = vi.hoisted(() => ({ mockRunPipeline: vi.fn(), })); +const { mockBridgeGaps } = vi.hoisted(() => ({ + mockBridgeGaps: vi.fn(), +})); + // Mock WorkflowRunner as a class (required for Vitest to treat it as a constructor) vi.mock('@features/execute/workflow-runner.js', () => ({ WorkflowRunner: class MockWorkflowRunner { @@ -26,6 +31,12 @@ vi.mock('@features/execute/workflow-runner.js', () => ({ listRecentArtifacts: vi.fn().mockReturnValue([]), })); +vi.mock('@features/execute/gap-bridger.js', () => ({ + GapBridger: class MockGapBridger { + bridge = mockBridgeGaps; + }, +})); + // Stub status handlers so `execute status`/`execute stats` don't need real infra vi.mock('./status.js', () => ({ handleStatus: vi.fn(), @@ -87,6 +98,7 @@ describe('registerExecuteCommands', () => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockRunStage.mockResolvedValue(makeSingleResult()); mockRunPipeline.mockResolvedValue(makePipelineResult(['build', 'review'])); + mockBridgeGaps.mockReturnValue({ blocked: [], bridged: [] }); process.exitCode = undefined as unknown as number; }); @@ -119,6 +131,16 @@ describe('registerExecuteCommands', () => { ); } + function writeInvalidAgentRecord(id: string): void { + writeFileSync( + join(kataDir, 'kataka', `${id}.json`), + JSON.stringify({ + id, + active: true, + }, null, 2), + ); + } + function createCycleWithBets( name = 'CLI Cycle', bets: Array<{ description: string; appetite: number; outcome?: 'pending' | 'complete' | 'partial' | 'abandoned' }> = [ @@ -185,6 +207,33 @@ describe('registerExecuteCommands', () => { }); describe('cycle subcommand', () => { + it('registers execute, stats, status, and cycle command descriptions and options', () => { + const program = createProgram(); + const executeCmd = program.commands.find((c) => c.name() === 'execute'); + expect(executeCmd).toBeDefined(); + expect(executeCmd!.description()).toBe('Run stage orchestration — select and execute flavors (alias: kiai)'); + + const statusCmd = executeCmd!.commands.find((c) => c.name() === 'status'); + expect(statusCmd).toBeDefined(); + expect(statusCmd!.description()).toBe('Show project status (same as "kata status")'); + + const statsCmd = executeCmd!.commands.find((c) => c.name() === 'stats'); + expect(statsCmd).toBeDefined(); + expect(statsCmd!.description()).toBe('Show analytics (same as "kata stats")'); + expect(statsCmd!.options.find((o) => o.long === '--category')?.description).toBe('Filter stats by stage category'); + expect(statsCmd!.options.find((o) => o.long === '--gyo')?.description).toBe('Filter stats by stage category (alias)'); + + const cycleCmd = executeCmd!.commands.find((c) => c.name() === 'cycle'); + expect(cycleCmd).toBeDefined(); + expect(cycleCmd!.description()).toBe('Session bridge — prepare, monitor, or complete a cycle for in-session agent execution'); + expect(cycleCmd!.options.find((o) => o.long === '--prepare')?.description).toBe('Prepare all pending bets in the cycle for agent dispatch'); + expect(cycleCmd!.options.find((o) => o.long === '--status')?.description).toBe('Get aggregated status of all runs in the cycle'); + expect(cycleCmd!.options.find((o) => o.long === '--complete')?.description).toBe('Complete all in-progress runs in the cycle'); + expect(cycleCmd!.options.find((o) => o.long === '--agent')?.description).toBe('Agent ID to attribute all prepared runs to (only used with --prepare)'); + expect(cycleCmd!.options.find((o) => o.long === '--kataka')?.description).toBe('Alias for --agent '); + expect(cycleCmd!.options.find((o) => o.long === '--json')?.description).toBe('Output as JSON'); + }); + it('prepares all pending bets for session execution', async () => { const cycle = createCycleWithBets('Dispatch Cycle', [ { description: 'Bet A', appetite: 20 }, @@ -198,6 +247,9 @@ describe('registerExecuteCommands', () => { const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); expect(output).toContain('Prepared 2 run(s)'); + expect(output).toContain('Bet A'); + expect(output).toContain('Run ID:'); + expect(output).toContain('Isolation:'); const bridgeRunsDir = join(kataDir, 'bridge-runs'); const files = existsSync(bridgeRunsDir) @@ -287,6 +339,27 @@ describe('registerExecuteCommands', () => { expect(output).toContain('kansatsu: 2, maki: 1, kime: 1'); }); + it('renders pending, complete, and failed status markers for cycle bets', async () => { + const cycle = createCycleWithBets('Marker Cycle', [ + { description: 'Prepared success', appetite: 20 }, + { description: 'Prepared failure', appetite: 20 }, + { description: 'Still pending', appetite: 20 }, + ]); + const bridge = new SessionExecutionBridge(kataDir); + const successRun = bridge.prepare(cycle.bets[0]!.id); + const failedRun = bridge.prepare(cycle.bets[1]!.id); + bridge.complete(successRun.runId, { success: true }); + bridge.complete(failedRun.runId, { success: false }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', 'cycle', cycle.id, '--status']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('✓ Prepared success [complete]'); + expect(output).toContain('✗ Prepared failure [failed]'); + expect(output).toContain('· Still pending [pending]'); + }); + it('completes a prepared cycle and emits json when requested', async () => { const cycle = createCycleWithBets('Complete Cycle', [ { description: 'Bet A', appetite: 20 }, @@ -456,6 +529,120 @@ describe('registerExecuteCommands', () => { const parsed = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); expect(parsed.tokenUsage).toEqual({ inputTokens: 0, outputTokens: 0, total: 0 }); }); + + it('prints plain-text token usage when only one token side is provided', async () => { + const cycle = createCycleWithBets('Partial Token Cycle', [ + { description: 'One-sided token run', appetite: 20 }, + ]); + const prepared = prepareRunForBet(cycle.bets[0]!.id); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', '--cwd', baseDir, 'execute', 'complete', prepared.runId, + '--input-tokens', '7', + ]); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Run'); + expect(output).toContain('marked as complete'); + expect(output).toContain('tokens: 7 total, 7 in, 0 out'); + }); + + it('rejects null artifact entries', async () => { + const cycle = createCycleWithBets('Artifact Null Cycle', [ + { description: 'Bad artifact item', appetite: 20 }, + ]); + const prepared = prepareRunForBet(cycle.bets[0]!.id); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', '--cwd', baseDir, 'execute', 'complete', prepared.runId, + '--artifacts', '[null]', + ]); + + expect(process.exitCode).toBe(1); + expect(errorSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('"name" string property'); + }); + }); + + describe('prepare subcommand', () => { + it('renders prepared run details and the agent context block in plain text mode', async () => { + const cycle = createCycleWithBets('Prepare Text Cycle', [ + { description: 'Prepared bet', appetite: 20 }, + ]); + const previousKataDir = process.env['KATA_DIR']; + process.env['KATA_DIR'] = kataDir; + + try { + const program = createProgram(); + const executeCmd = program.commands.find((command) => command.name() === 'execute'); + const prepareCmd = executeCmd?.commands.find((command) => command.name() === 'prepare'); + await prepareCmd?.parseAsync(['node', 'test', '--bet', cycle.bets[0]!.id]); + } finally { + if (previousKataDir === undefined) { + delete process.env['KATA_DIR']; + } else { + process.env['KATA_DIR'] = previousKataDir; + } + } + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Prepared run for bet: "Prepared bet"'); + expect(output).toContain('Run ID:'); + expect(output).toContain('Agent context block'); + }); + + it('emits prepared run data as json when local --json is provided', async () => { + const cycle = createCycleWithBets('Prepare Json Cycle', [ + { description: 'Prepared bet', appetite: 20 }, + ]); + const previousKataDir = process.env['KATA_DIR']; + process.env['KATA_DIR'] = kataDir; + + try { + const program = createProgram(); + const executeCmd = program.commands.find((command) => command.name() === 'execute'); + const prepareCmd = executeCmd?.commands.find((command) => command.name() === 'prepare'); + await prepareCmd?.parseAsync(['node', 'test', '--bet', cycle.bets[0]!.id, '--json']); + } finally { + if (previousKataDir === undefined) { + delete process.env['KATA_DIR']; + } else { + process.env['KATA_DIR'] = previousKataDir; + } + } + + const parsed = JSON.parse(consoleSpy.mock.calls[0]?.[0] as string); + expect(parsed.betId).toBe(cycle.bets[0]!.id); + expect(parsed.runId).toBeTruthy(); + expect(parsed.stages).toContain('build'); + }); + + it('reports agent load failures separately from missing-agent errors', async () => { + const cycle = createCycleWithBets('Prepare Invalid Agent Cycle', [ + { description: 'Prepared bet', appetite: 20 }, + ]); + const invalidAgentId = '11111111-1111-4111-8111-111111111111'; + writeInvalidAgentRecord(invalidAgentId); + const previousKataDir = process.env['KATA_DIR']; + process.env['KATA_DIR'] = kataDir; + + try { + const program = createProgram(); + const executeCmd = program.commands.find((command) => command.name() === 'execute'); + const prepareCmd = executeCmd?.commands.find((command) => command.name() === 'prepare'); + await prepareCmd?.parseAsync(['node', 'test', '--bet', cycle.bets[0]!.id, '--agent', invalidAgentId]); + } finally { + if (previousKataDir === undefined) { + delete process.env['KATA_DIR']; + } else { + process.env['KATA_DIR'] = previousKataDir; + } + } + + expect(process.exitCode).toBe(1); + expect(errorSpy.mock.calls.map((c) => c[0]).join('\n')).toContain(`Failed to load agent "${invalidAgentId}"`); + }); }); describe('context subcommand', () => { @@ -491,6 +678,33 @@ describe('registerExecuteCommands', () => { expect(mockRunPipeline).toHaveBeenCalledWith(['build', 'review'], expect.anything()); }); + + it('merges --pin and --ryu flags for the hidden execute run alias', async () => { + const previousKataDir = process.env['KATA_DIR']; + process.env['KATA_DIR'] = kataDir; + + try { + const program = createProgram(); + const executeCmd = program.commands.find((command) => command.name() === 'execute'); + const runCmd = executeCmd?.commands.find((command) => command.name() === 'run'); + await runCmd?.parseAsync([ + 'node', 'test', 'build', + '--pin', 'legacy-build', + '--ryu', 'typescript-tdd', + ]); + } finally { + if (previousKataDir === undefined) { + delete process.env['KATA_DIR']; + } else { + process.env['KATA_DIR'] = previousKataDir; + } + } + + expect(mockRunStage).toHaveBeenCalledWith( + 'build', + expect.objectContaining({ pin: expect.arrayContaining(['legacy-build', 'typescript-tdd']) }), + ); + }); }); // ---- --list-katas ---- @@ -554,6 +768,19 @@ describe('registerExecuteCommands', () => { expect(output).toContain('good-kata'); }); + it('warns and skips kata files with invalid structure', async () => { + writeFileSync(join(kataDir, 'katas', 'broken-structure.json'), JSON.stringify({ name: 'broken-structure' }, null, 2)); + writeFileSync(join(kataDir, 'katas', 'good-kata.json'), JSON.stringify({ name: 'good-kata', stages: ['plan'] }, null, 2)); + + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', '--list-katas']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + const errors = errorSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('good-kata'); + expect(errors).toContain('skipping invalid kata file "broken-structure.json"'); + }); + it('includes description when kata has one', async () => { const kata = { name: 'described-kata', stages: ['build'], description: 'My description' }; writeFileSync(join(kataDir, 'katas', 'described-kata.json'), JSON.stringify(kata, null, 2)); @@ -698,6 +925,14 @@ describe('registerExecuteCommands', () => { await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', '--kata', 'my_kata-1']); expect(process.exitCode).not.toBe(1); }); + + it('reports an invalid kata-name error before any file lookup for trailing separators', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', '--delete-kata', 'safe/']); + + expect(process.exitCode).toBe(1); + expect(errorSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Invalid kata name "safe/"'); + }); }); // ---- --gyo ---- @@ -814,6 +1049,19 @@ describe('registerExecuteCommands', () => { expect.objectContaining({ agentId, katakaId: agentId }), ); }); + + it('reports invalid agent records as load failures', async () => { + const agentId = '22222222-2222-4222-8222-222222222222'; + writeInvalidAgentRecord(agentId); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'test', '--cwd', baseDir, 'execute', 'build', '--agent', agentId, + ]); + + expect(process.exitCode).toBe(1); + expect(errorSpy.mock.calls.map((c) => c[0]).join('\n')).toContain(`Failed to load agent "${agentId}"`); + }); }); // ---- parseBetOption edge cases (via --bet) ---- @@ -954,6 +1202,15 @@ describe('registerExecuteCommands', () => { expect(output).toContain('dry-run'); }); + it('shows dry-run notice in output for a pipeline', async () => { + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', 'build', 'review', '--dry-run']); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Pipeline: build -> review'); + expect(output).toContain('dry-run'); + }); + it('shows pipeline learnings in output', async () => { mockRunPipeline.mockResolvedValue({ ...makePipelineResult(['build', 'review']), @@ -978,6 +1235,75 @@ describe('registerExecuteCommands', () => { const bridgeGapsOpt = executeCmd!.options.find((o) => o.long === '--bridge-gaps'); expect(bridgeGapsOpt).toBeDefined(); }); + + it('captures bridged gaps for a single-stage run', async () => { + const incrementGapsClosed = vi.spyOn(ProjectStateUpdater, 'incrementGapsClosed').mockImplementation(() => {}); + mockRunStage.mockResolvedValue({ + ...makeSingleResult('build'), + gaps: [{ description: 'Missing validation', severity: 'medium' }], + }); + mockBridgeGaps.mockReturnValue({ + blocked: [], + bridged: [{ description: 'Missing validation', severity: 'medium' }], + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', 'build', '--bridge-gaps']); + + expect(mockBridgeGaps).toHaveBeenCalledWith([ + expect.objectContaining({ description: 'Missing validation' }), + ]); + expect(consoleSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Captured 1 gap(s) as step-tier learnings.'); + expect(incrementGapsClosed).toHaveBeenCalledWith(join(kataDir, 'project-state.json'), 1); + }); + + it('blocks pipeline execution when bridged gaps include high-severity blockers', async () => { + mockRunPipeline.mockResolvedValue({ + ...makePipelineResult(['build', 'review']), + stageResults: [ + { ...makeSingleResult('build'), gaps: [{ description: 'Prod access missing', severity: 'high' }] }, + { ...makeSingleResult('review'), gaps: [] }, + ], + }); + mockBridgeGaps.mockReturnValue({ + blocked: [{ description: 'Prod access missing', severity: 'high' }], + bridged: [], + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', 'build', 'review', '--bridge-gaps']); + + expect(errorSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Blocked by 1 high-severity gap(s)'); + expect(process.exitCode).toBe(1); + }); + + it('captures bridged gaps for a pipeline when no blockers remain', async () => { + const incrementGapsClosed = vi.spyOn(ProjectStateUpdater, 'incrementGapsClosed').mockImplementation(() => {}); + mockRunPipeline.mockResolvedValue({ + ...makePipelineResult(['build', 'review']), + stageResults: [ + { ...makeSingleResult('build'), gaps: [{ description: 'Document fallback', severity: 'medium' }] }, + { ...makeSingleResult('review'), gaps: [{ description: 'Clarify rollout', severity: 'low' }] }, + ], + }); + mockBridgeGaps.mockReturnValue({ + blocked: [], + bridged: [ + { description: 'Document fallback', severity: 'medium' }, + { description: 'Clarify rollout', severity: 'low' }, + ], + }); + + const program = createProgram(); + await program.parseAsync(['node', 'test', '--cwd', baseDir, 'execute', 'build', 'review', '--bridge-gaps']); + + expect(mockBridgeGaps).toHaveBeenCalledWith([ + expect.objectContaining({ description: 'Document fallback' }), + expect.objectContaining({ description: 'Clarify rollout' }), + ]); + expect(consoleSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Captured 2 gap(s) as step-tier learnings.'); + expect(incrementGapsClosed).toHaveBeenCalledWith(join(kataDir, 'project-state.json'), 2); + }); }); // ---- kiai alias ---- diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 79f5d8c..7e6e71e 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -21,7 +21,19 @@ import { KATA_DIRS } from '@shared/constants/paths.js'; import { ProjectStateUpdater } from '@features/belt/belt-calculator.js'; import { CycleManager } from '@domain/services/cycle-manager.js'; import { SessionExecutionBridge } from '@infra/execution/session-bridge.js'; -import { formatDurationMs, formatExplain, parseBetOption, parseHintFlags } from '@cli/commands/execute.helpers.js'; +import { + assertValidKataName, + buildPreparedCycleOutputLines, + buildPreparedRunOutputLines, + formatDurationMs, + formatAgentLoadError, + formatExplain, + mergePinnedFlavors, + parseBetOption, + parseCompletedRunArtifacts, + parseCompletedRunTokenUsage, + parseHintFlags, +} from '@cli/commands/execute.helpers.js'; import { resolveRef } from '@cli/resolve-ref.js'; import { handleStatus, handleStats, parseCategoryFilter } from './status.js'; @@ -106,11 +118,7 @@ export function registerExecuteCommands(program: Command): void { agentRegistry.get(agentId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('not found') || msg.includes('Agent')) { - console.error(`Error: agent "${agentId}" not found. Use "kata agent list" to see registered agents.`); - } else { - console.error(`Error: Failed to load agent "${agentId}": ${msg}`); - } + console.error(formatAgentLoadError(agentId, msg)); process.exitCode = 1; return; } @@ -120,12 +128,8 @@ export function registerExecuteCommands(program: Command): void { if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`Prepared ${result.preparedRuns.length} run(s) for cycle "${result.cycleName}"`); - for (const run of result.preparedRuns) { - console.log(` ${run.betName}`); - console.log(` Run ID: ${run.runId}`); - console.log(` Stages: ${run.stages.join(', ')}`); - console.log(` Isolation: ${run.isolation}`); + for (const line of buildPreparedCycleOutputLines(result)) { + console.log(line); } } } else if (localOpts.status) { @@ -188,69 +192,46 @@ export function registerExecuteCommands(program: Command): void { const isJson = !!(localOpts.json || ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); - let artifacts: Array<{ name: string; path?: string }> | undefined; - if (localOpts.artifacts) { - try { - const parsed: unknown = JSON.parse(localOpts.artifacts); - if (!Array.isArray(parsed)) { - console.error('Error: --artifacts must be a JSON array'); - process.exitCode = 1; - return; - } - for (const item of parsed) { - if (typeof item !== 'object' || item === null || typeof (item as Record).name !== 'string') { - console.error('Error: each artifact must have a "name" string property'); - process.exitCode = 1; - return; - } - } - artifacts = parsed as Array<{ name: string; path?: string }>; - } catch { - console.error('Error: --artifacts must be valid JSON'); - process.exitCode = 1; - return; - } - } - - // Validate token counts if provided - if (localOpts.inputTokens !== undefined && (isNaN(localOpts.inputTokens) || localOpts.inputTokens < 0)) { - console.error('Error: --input-tokens must be a non-negative integer'); + const parsedArtifacts = parseCompletedRunArtifacts(localOpts.artifacts); + if (!parsedArtifacts.ok) { + console.error(parsedArtifacts.error); process.exitCode = 1; return; } - if (localOpts.outputTokens !== undefined && (isNaN(localOpts.outputTokens) || localOpts.outputTokens < 0)) { - console.error('Error: --output-tokens must be a non-negative integer'); + const artifacts = parsedArtifacts.value as Array<{ name: string; path?: string }> | undefined; + + const parsedTokenUsage = parseCompletedRunTokenUsage(localOpts.inputTokens, localOpts.outputTokens); + if (!parsedTokenUsage.ok) { + console.error(parsedTokenUsage.error); process.exitCode = 1; return; } - - const inputTokens = localOpts.inputTokens; - const outputTokens = localOpts.outputTokens; - const hasTokens = inputTokens !== undefined || outputTokens !== undefined; - const totalTokens = hasTokens ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined; + const { hasTokens, totalTokens, tokenUsage } = parsedTokenUsage.value as { + hasTokens: boolean; + totalTokens?: number; + tokenUsage?: { + inputTokens?: number; + outputTokens?: number; + total: number; + }; + }; bridge.complete(runId, { success: !localOpts.failed, artifacts, notes: localOpts.notes, - ...(hasTokens ? { - tokenUsage: { - inputTokens, - outputTokens, - total: totalTokens, - }, - } : {}), + ...(tokenUsage ? { tokenUsage } : {}), }); if (isJson) { console.log(JSON.stringify({ runId, status: localOpts.failed ? 'failed' : 'complete', - ...(hasTokens ? { tokenUsage: { inputTokens, outputTokens, total: totalTokens } } : {}), + ...(tokenUsage ? { tokenUsage } : {}), })); } else { const tokenLine = hasTokens - ? ` (tokens: ${totalTokens ?? 0} total, ${inputTokens ?? 0} in, ${outputTokens ?? 0} out)` + ? ` (tokens: ${totalTokens ?? 0} total, ${tokenUsage?.inputTokens ?? 0} in, ${tokenUsage?.outputTokens ?? 0} out)` : ''; console.log(`Run ${runId} marked as ${localOpts.failed ? 'failed' : 'complete'}.${tokenLine}`); } @@ -297,11 +278,7 @@ export function registerExecuteCommands(program: Command): void { agentRegistry.get(agentId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('not found') || msg.includes('Agent')) { - console.error(`Error: agent "${agentId}" not found. Use "kata agent list" to see registered agents.`); - } else { - console.error(`Error: Failed to load agent "${agentId}": ${msg}`); - } + console.error(formatAgentLoadError(agentId, msg)); process.exitCode = 1; return; } @@ -311,17 +288,14 @@ export function registerExecuteCommands(program: Command): void { if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { - console.log(`Prepared run for bet: "${result.betName}"`); - console.log(` Run ID: ${result.runId}`); - console.log(` Cycle: ${result.cycleName}`); - console.log(` Stages: ${result.stages.join(', ')}`); - console.log(` Isolation: ${result.isolation}`); - console.log(''); - console.log('Agent context block (use "kata kiai context " to fetch at dispatch time):'); + let agentContextBlock: string; try { - console.log(bridge.getAgentContext(result.runId)); + agentContextBlock = bridge.getAgentContext(result.runId); } catch (err) { - console.log(`(context unavailable: ${err instanceof Error ? err.message : String(err)})`); + agentContextBlock = `(context unavailable: ${err instanceof Error ? err.message : String(err)})`; + } + for (const line of buildPreparedRunOutputLines(result, agentContextBlock)) { + console.log(line); } } })); @@ -339,7 +313,7 @@ export function registerExecuteCommands(program: Command): void { const localOpts = ctx.cmd.opts(); await runCategories(ctx, [category], { bet: localOpts.bet, - pin: [...(localOpts.pin ?? []), ...(localOpts.ryu ?? [])], + pin: mergePinnedFlavors(localOpts.pin ?? [], localOpts.ryu ?? []), dryRun: localOpts.dryRun, json: localOpts.json, }); @@ -476,7 +450,7 @@ export function registerExecuteCommands(program: Command): void { return; } - const pin = [...(localOpts.ryu ?? []), ...(localOpts.pin ?? [])]; + const pin = mergePinnedFlavors(localOpts.ryu ?? [], localOpts.pin ?? []); // Parse --hint flags into flavorHints map (merges with loaded kata hints) const cliHints = parseHintFlags(localOpts.hint as string[] | undefined); @@ -491,7 +465,7 @@ export function registerExecuteCommands(program: Command): void { await runCategories(ctx, resolvedCategories, { bet: betFromNext ?? (localOpts.bet as string | undefined), - pin: pin.length > 0 ? pin : undefined, + pin, dryRun: localOpts.dryRun, saveKata: localOpts.saveKata, agentId: (localOpts.agent ?? localOpts.kataka) as string | undefined, @@ -532,7 +506,6 @@ async function runCategories( rawCategories: string[], opts: RunOptions, ): Promise { - // Validate all categories const categories: StageCategory[] = []; for (const cat of rawCategories) { const parseResult = StageCategorySchema.safeParse(cat); @@ -554,147 +527,50 @@ async function runCategories( } const bet = parsedBet.value; const agentId = opts.agentId ?? opts.katakaId; - - // Fire-and-forget belt discovery hooks const projectStateFile = join(ctx.kataDir, 'project-state.json'); ProjectStateUpdater.markDiscovery(projectStateFile, 'ranFirstExecution'); if (opts.yolo) ProjectStateUpdater.markRanWithYolo(projectStateFile); - // Validate --agent/--kataka ID if provided if (agentId) { try { const agentRegistry = new KataAgentRegistry(join(ctx.kataDir, KATA_DIRS.kataka)); agentRegistry.get(agentId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - if (/not found/i.test(msg)) { - console.error(`Error: agent "${agentId}" not found. Use "kata agent list" to see registered agents.`); - } else { - console.error(`Error: Failed to load agent "${agentId}": ${msg}`); - } + console.error(formatAgentLoadError(agentId, msg)); process.exitCode = 1; return; } } - const isJson = ctx.globalOpts.json || opts.json; + const isJson = Boolean(ctx.globalOpts.json || opts.json); if (categories.length === 1) { - // Single stage - const result = await runner.runStage(categories[0]!, { + const shouldContinue = await runSingleCategoryMode({ + ctx, + runner, + category: categories[0]!, bet, - pin: opts.pin, - dryRun: opts.dryRun, agentId, - katakaId: agentId, - yolo: opts.yolo, - flavorHints: opts.flavorHints, + isJson, + opts, + projectStateFile, }); - - // --bridge-gaps: evaluate identified gaps - if (opts.bridgeGaps && result.gaps && result.gaps.length > 0) { - const store = new KnowledgeStore(kataDirPath(ctx.kataDir, 'knowledge')); - const bridger = new GapBridger({ knowledgeStore: store }); - const { blocked, bridged } = bridger.bridge(result.gaps); - if (blocked.length > 0) { - console.error(`[kata] Blocked by ${blocked.length} high-severity gap(s):`); - for (const g of blocked) console.error(` • ${g.description}`); - process.exitCode = 1; - return; - } - if (bridged.length > 0) { - console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`); - ProjectStateUpdater.incrementGapsClosed(projectStateFile, bridged.length); - } - } - - if (isJson) { - console.log(JSON.stringify(result, null, 2)); - } else { - if (opts.explain) { - console.log(formatExplain(result.stageCategory, result.selectedFlavors, result.matchReports)); - console.log(''); - } - console.log(`Stage: ${result.stageCategory}`); - console.log(`Execution mode: ${result.executionMode}`); - console.log(`Selected flavors: ${result.selectedFlavors.join(', ')}`); - 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(''); - console.log(`Stage artifact: ${result.stageArtifact.name}`); - if (opts.dryRun) { - console.log(''); - console.log('(dry-run — no artifacts persisted)'); - } - } + if (!shouldContinue) return; } else { - // Multi-stage pipeline - const result = await runner.runPipeline(categories, { + const shouldContinue = await runPipelineMode({ + ctx, + runner, + categories, bet, - dryRun: opts.dryRun, agentId, - katakaId: agentId, - yolo: opts.yolo, - flavorHints: opts.flavorHints, + isJson, + opts, + projectStateFile, }); - - // --bridge-gaps: evaluate identified gaps across all stages - if (opts.bridgeGaps) { - const allGaps = result.stageResults.flatMap((sr) => sr.gaps ?? []); - if (allGaps.length > 0) { - const store = new KnowledgeStore(kataDirPath(ctx.kataDir, 'knowledge')); - const bridger = new GapBridger({ knowledgeStore: store }); - const { blocked, bridged } = bridger.bridge(allGaps); - if (blocked.length > 0) { - console.error(`[kata] Blocked by ${blocked.length} high-severity gap(s):`); - for (const g of blocked) console.error(` • ${g.description}`); - process.exitCode = 1; - return; - } - if (bridged.length > 0) { - console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`); - ProjectStateUpdater.incrementGapsClosed(projectStateFile, bridged.length); - } - } - } - - if (isJson) { - console.log(JSON.stringify(result, null, 2)); - } else { - if (opts.explain) { - for (const stageResult of result.stageResults) { - console.log(formatExplain(stageResult.stageCategory, stageResult.selectedFlavors, stageResult.matchReports)); - console.log(''); - } - } - console.log(`Pipeline: ${categories.join(' -> ')}`); - console.log(`Stages completed: ${result.stageResults.length}`); - console.log(`Overall quality: ${result.pipelineReflection.overallQuality}`); - console.log(''); - for (const stageResult of result.stageResults) { - console.log(` ${stageResult.stageCategory}:`); - console.log(` Flavors: ${stageResult.selectedFlavors.join(', ')}`); - console.log(` Mode: ${stageResult.executionMode}`); - console.log(` Artifact: ${stageResult.stageArtifact.name}`); - } - if (result.pipelineReflection.learnings.length > 0) { - console.log(''); - console.log('Learnings:'); - for (const learning of result.pipelineReflection.learnings) { - console.log(` - ${learning}`); - } - } - if (opts.dryRun) { - console.log(''); - console.log('(dry-run — no artifacts persisted)'); - } - } + if (!shouldContinue) return; } - // Save kata if requested if (opts.saveKata && !opts.dryRun) { saveSavedKata(ctx.kataDir, opts.saveKata, categories, opts.flavorHints); ProjectStateUpdater.markDiscovery(projectStateFile, 'savedKataSequence'); @@ -702,6 +578,174 @@ async function runCategories( } } +type RunContext = { kataDir: string; globalOpts: { json?: boolean }; cmd: { opts(): Record } }; +type StageRunResult = Awaited>; +type PipelineRunResult = Awaited>; + +async function runSingleCategoryMode(input: { + ctx: RunContext; + runner: WorkflowRunner; + category: StageCategory; + bet: Record | undefined; + agentId?: string; + isJson: boolean; + opts: RunOptions; + projectStateFile: string; +}): Promise { + const result = await input.runner.runStage(input.category, { + bet: input.bet, + pin: input.opts.pin, + dryRun: input.opts.dryRun, + agentId: input.agentId, + katakaId: input.agentId, + yolo: input.opts.yolo, + flavorHints: input.opts.flavorHints, + }); + + const shouldContinue = bridgeExecutionGaps({ + kataDir: input.ctx.kataDir, + projectStateFile: input.projectStateFile, + gaps: input.opts.bridgeGaps ? result.gaps : undefined, + }); + if (!shouldContinue) return false; + + printSingleCategoryResult(result, input.isJson, input.opts); + return true; +} + +async function runPipelineMode(input: { + ctx: RunContext; + runner: WorkflowRunner; + categories: StageCategory[]; + bet: Record | undefined; + agentId?: string; + isJson: boolean; + opts: RunOptions; + projectStateFile: string; +}): Promise { + const result = await input.runner.runPipeline(input.categories, { + bet: input.bet, + dryRun: input.opts.dryRun, + agentId: input.agentId, + katakaId: input.agentId, + yolo: input.opts.yolo, + flavorHints: input.opts.flavorHints, + }); + + const shouldContinue = bridgeExecutionGaps({ + kataDir: input.ctx.kataDir, + projectStateFile: input.projectStateFile, + gaps: input.opts.bridgeGaps + ? result.stageResults.flatMap((stageResult) => stageResult.gaps ?? []) + : undefined, + }); + if (!shouldContinue) return false; + + printPipelineResult(result, input.categories, input.isJson, input.opts); + return true; +} + +function bridgeExecutionGaps(input: { + kataDir: string; + projectStateFile: string; + gaps?: Array<{ + description: string; + severity: 'low' | 'medium' | 'high'; + suggestedFlavors: string[]; + }>; +}): boolean { + if (!input.gaps || input.gaps.length === 0) return true; + + const store = new KnowledgeStore(kataDirPath(input.kataDir, 'knowledge')); + const bridger = new GapBridger({ knowledgeStore: store }); + const { blocked, bridged } = bridger.bridge(input.gaps); + + if (blocked.length > 0) { + console.error(`[kata] Blocked by ${blocked.length} high-severity gap(s):`); + for (const gap of blocked) console.error(` • ${gap.description}`); + process.exitCode = 1; + return false; + } + + if (bridged.length > 0) { + console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`); + ProjectStateUpdater.incrementGapsClosed(input.projectStateFile, bridged.length); + } + + return true; +} + +function printSingleCategoryResult(result: StageRunResult, isJson: boolean, opts: RunOptions): void { + if (isJson) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (opts.explain) { + console.log(formatExplain(result.stageCategory, result.selectedFlavors, result.matchReports)); + console.log(''); + } + + console.log(`Stage: ${result.stageCategory}`); + console.log(`Execution mode: ${result.executionMode}`); + console.log(`Selected flavors: ${result.selectedFlavors.join(', ')}`); + 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(''); + console.log(`Stage artifact: ${result.stageArtifact.name}`); + + if (opts.dryRun) { + console.log(''); + console.log('(dry-run — no artifacts persisted)'); + } +} + +function printPipelineResult( + result: PipelineRunResult, + categories: StageCategory[], + isJson: boolean, + opts: RunOptions, +): void { + if (isJson) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (opts.explain) { + for (const stageResult of result.stageResults) { + console.log(formatExplain(stageResult.stageCategory, stageResult.selectedFlavors, stageResult.matchReports)); + console.log(''); + } + } + + console.log(`Pipeline: ${categories.join(' -> ')}`); + console.log(`Stages completed: ${result.stageResults.length}`); + console.log(`Overall quality: ${result.pipelineReflection.overallQuality}`); + console.log(''); + for (const stageResult of result.stageResults) { + console.log(` ${stageResult.stageCategory}:`); + console.log(` Flavors: ${stageResult.selectedFlavors.join(', ')}`); + console.log(` Mode: ${stageResult.executionMode}`); + console.log(` Artifact: ${stageResult.stageArtifact.name}`); + } + + if (result.pipelineReflection.learnings.length > 0) { + console.log(''); + console.log('Learnings:'); + for (const learning of result.pipelineReflection.learnings) { + console.log(` - ${learning}`); + } + } + + if (opts.dryRun) { + console.log(''); + console.log('(dry-run — no artifacts persisted)'); + } +} + // --------------------------------------------------------------------------- // Runner builder + helpers // --------------------------------------------------------------------------- @@ -750,15 +794,6 @@ function collect(value: string, previous: string[]): string[] { // Saved kata helpers // --------------------------------------------------------------------------- -/** Prevent path traversal via kata names. Only alphanumeric, hyphens, and underscores allowed. */ -function assertValidKataName(name: string): void { - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - throw new Error( - `Invalid kata name "${name}": names must contain only letters, digits, hyphens, and underscores.`, - ); - } -} - function katasDir(kataDir: string): string { return join(kataDir, KATA_DIRS.katas); } diff --git a/src/features/cycle-management/cooldown-session-prepare.test.ts b/src/features/cycle-management/cooldown-session-prepare.test.ts index 7d71dcb..7ea3e68 100644 --- a/src/features/cycle-management/cooldown-session-prepare.test.ts +++ b/src/features/cycle-management/cooldown-session-prepare.test.ts @@ -9,6 +9,7 @@ import { SynthesisInputSchema } from '@domain/types/synthesis.js'; import { appendObservation, createRunTree } from '@infra/persistence/run-store.js'; import type { Run } from '@domain/types/run-state.js'; import type { Observation } from '@domain/types/observation.js'; +import { logger } from '@shared/lib/logger.js'; import { CooldownSession, type CooldownSessionDeps, @@ -221,6 +222,34 @@ describe('CooldownSession.prepare()', () => { expect(cycleManager.get(cycle.id).state).toBe('active'); }); + it('logs an error when prepare() rollback also fails', async () => { + const cycle = cycleManager.create({ tokenBudget: 50000 }); + cycleManager.updateState(cycle.id, 'active'); + + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const originalUpdateState = cycleManager.updateState.bind(cycleManager); + vi.spyOn(cycleManager, 'generateCooldown').mockImplementation(() => { + throw new Error('Simulated failure'); + }); + vi.spyOn(cycleManager, 'updateState').mockImplementation((cycleId, state) => { + if (state === 'active') { + throw new Error('Rollback exploded'); + } + originalUpdateState(cycleId, state); + }); + + try { + await expect(session.prepare(cycle.id)).rejects.toThrow('Simulated failure'); + expect(errorSpy).toHaveBeenCalledWith( + `Failed to roll back cycle "${cycle.id}" from cooldown to "active". Manual intervention may be required.`, + { rollbackError: 'Rollback exploded' }, + ); + expect(cycleManager.get(cycle.id).state).toBe('cooldown'); + } finally { + errorSpy.mockRestore(); + } + }); + it('works without synthesisDir (returns empty path)', async () => { const sessionNoSynthesis = new CooldownSession(makeDeps({ synthesisDir: undefined })); const cycle = cycleManager.create({ tokenBudget: 50000 }); @@ -294,6 +323,51 @@ describe('CooldownSession.complete()', () => { expect(result.synthesisProposals).toBeUndefined(); }); + it('logs belt advancement during complete() only when the belt levels up', async () => { + const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const projectStateFile = join(baseDir, 'project-state-complete.json'); + + const leveledCycle = cycleManager.create({ tokenBudget: 50000 }, 'Leveled Complete Cycle'); + const leveledSession = new CooldownSession(makeDeps({ + projectStateFile, + beltCalculator: { + computeAndStore: vi.fn(() => ({ + belt: 'san-kyu', + previous: 'yon-kyu', + leveledUp: true, + })), + }, + })); + await leveledSession.prepare(leveledCycle.id); + + const steadyCycle = cycleManager.create({ tokenBudget: 50000 }, 'Steady Complete Cycle'); + const steadySession = new CooldownSession(makeDeps({ + projectStateFile, + beltCalculator: { + computeAndStore: vi.fn(() => ({ + belt: 'san-kyu', + previous: 'san-kyu', + leveledUp: false, + })), + }, + })); + await steadySession.prepare(steadyCycle.id); + + try { + const leveledResult = await leveledSession.complete(leveledCycle.id); + expect(leveledResult.beltResult?.leveledUp).toBe(true); + expect(infoSpy).toHaveBeenCalledWith('Belt advanced: yon-kyu → san-kyu'); + + infoSpy.mockClear(); + + const steadyResult = await steadySession.complete(steadyCycle.id); + expect(steadyResult.beltResult?.leveledUp).toBe(false); + expect(infoSpy).not.toHaveBeenCalled(); + } finally { + infoSpy.mockRestore(); + } + }); + it('applies new-learning proposals from synthesis result', async () => { const session = new CooldownSession(makeDeps()); const cycle = cycleManager.create({ tokenBudget: 50000 }); @@ -375,6 +449,48 @@ describe('CooldownSession.complete()', () => { expect(updated.archived).toBe(true); }); + it('applies update-learning proposals and clamps confidence into the valid range', async () => { + const session = new CooldownSession(makeDeps()); + const learning = knowledgeStore.capture({ + tier: 'stage', + category: 'testing', + content: 'Initial learning', + confidence: 0.95, + }); + const cycle = cycleManager.create({ tokenBudget: 50000 }); + await session.prepare(cycle.id); + + const proposalId = crypto.randomUUID(); + const synthesisInputId = crypto.randomUUID(); + const synthesisResult = { + inputId: synthesisInputId, + proposals: [ + { + id: proposalId, + type: 'update-learning', + confidence: 0.9, + confidenceDelta: 0.2, + citations: [crypto.randomUUID(), crypto.randomUUID()], + reasoning: 'Repeated evidence increased confidence', + createdAt: new Date().toISOString(), + targetLearningId: learning.id, + proposedContent: 'Updated learning content', + }, + ], + }; + JsonStore.write( + join(synthesisDir, `result-${synthesisInputId}.json`), + synthesisResult, + (await import('@domain/types/synthesis.js')).SynthesisResultSchema, + ); + + await session.complete(cycle.id, synthesisInputId, [proposalId]); + + const updated = knowledgeStore.get(learning.id); + expect(updated.content).toBe('Updated learning content'); + expect(updated.confidence).toBe(1); + }); + it('skips proposals not in acceptedProposalIds', async () => { const session = new CooldownSession(makeDeps()); const cycle = cycleManager.create({ tokenBudget: 50000 }); @@ -439,6 +555,62 @@ describe('CooldownSession.complete()', () => { expect(cycleManager.get(cycle.id).state).toBe('complete'); expect(result.synthesisProposals).toBeUndefined(); }); + + it('writes complete() diary entries with only resolved bets and synthesized agent perspective', async () => { + const dojoDir = join(baseDir, 'dojo-complete-diary'); + const diaryDir = join(dojoDir, 'diary'); + mkdirSync(diaryDir, { recursive: true }); + + const session = new CooldownSession(makeDeps({ dojoDir })); + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Diary Complete Cycle'); + cycleManager.addBet(cycle.id, { + description: 'Done bet', + appetite: 20, + outcome: 'complete', + issueRefs: [], + }); + cycleManager.addBet(cycle.id, { + description: 'Pending bet', + appetite: 20, + outcome: 'pending', + issueRefs: [], + }); + await session.prepare(cycle.id); + + const proposalId = crypto.randomUUID(); + const synthesisInputId = crypto.randomUUID(); + const synthesisResult = { + inputId: synthesisInputId, + proposals: [ + { + id: proposalId, + type: 'new-learning', + confidence: 0.85, + citations: [crypto.randomUUID(), crypto.randomUUID()], + reasoning: 'Synthesis identified a reusable pattern', + createdAt: new Date().toISOString(), + proposedContent: 'Capture resolved bets in cooldown diary entries.', + proposedTier: 'stage', + proposedCategory: 'cycle-management', + }, + ], + }; + JsonStore.write( + join(synthesisDir, `result-${synthesisInputId}.json`), + synthesisResult, + (await import('@domain/types/synthesis.js')).SynthesisResultSchema, + ); + + await session.complete(cycle.id, synthesisInputId, [proposalId]); + + const { DiaryStore } = await import('@infra/dojo/diary-store.js'); + const entry = new DiaryStore(diaryDir).readByCycleId(cycle.id); + expect(entry).not.toBeNull(); + expect(entry?.rawDataSummary).toContain('Done bet'); + expect(entry?.rawDataSummary).not.toContain('Pending bet'); + expect(entry?.agentPerspective).toContain('Agent Perspective (Synthesis)'); + expect(entry?.agentPerspective).toContain('Capture resolved bets in cooldown diary entries.'); + }); }); // --------------------------------------------------------------------------- @@ -559,6 +731,33 @@ describe('CooldownSession.prepare() — observation wiring', () => { expect(input.observations[0]!.content).toBe('Discovered via bridge-run lookup'); }); + it('ignores invalid, non-json, and foreign-cycle bridge-run files during fallback lookup', async () => { + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Filtered Bridge Lookup'); + const withBet = cycleManager.addBet(cycle.id, { description: 'Target bet', appetite: 40, outcome: 'pending', issueRefs: [] }); + const bet = withBet.bets[0]!; + + const matchingRun = makeRun(cycle.id, bet.id); + const foreignRun = makeRun(randomUUID(), randomUUID()); + createRunTree(runsDir, matchingRun); + createRunTree(runsDir, foreignRun); + + writeFileSync(join(bridgeRunsDir, 'notes.txt'), 'ignore me'); + writeFileSync(join(bridgeRunsDir, 'broken.json'), '{not-json'); + writeBridgeRun(foreignRun.id, foreignRun.betId, foreignRun.cycleId); + writeBridgeRun(matchingRun.id, bet.id, cycle.id); + + appendObservation(runsDir, matchingRun.id, makeObservation('Matching bridge-run observation'), { level: 'run' }); + appendObservation(runsDir, foreignRun.id, makeObservation('Foreign bridge-run observation'), { level: 'run' }); + + const session = new CooldownSession(makeDeps()); + const result = await session.prepare(cycle.id); + + const raw = readFileSync(result.synthesisInputPath, 'utf-8'); + const input = SynthesisInputSchema.parse(JSON.parse(raw)); + expect(input.observations).toHaveLength(1); + expect(input.observations[0]!.content).toBe('Matching bridge-run observation'); + }); + it('collects stage-level observations in addition to run-level (#335)', async () => { const cycle = cycleManager.create({ tokenBudget: 50000 }, 'All Levels Test'); const withBet = cycleManager.addBet(cycle.id, { description: 'Multi-level bet', appetite: 30, outcome: 'pending', issueRefs: [] }); diff --git a/src/features/cycle-management/cooldown-session.helpers.test.ts b/src/features/cycle-management/cooldown-session.helpers.test.ts new file mode 100644 index 0000000..4442725 --- /dev/null +++ b/src/features/cycle-management/cooldown-session.helpers.test.ts @@ -0,0 +1,376 @@ +import { join } from 'node:path'; +import { + buildBeltAdvancementMessage, + buildAgentPerspectiveFromProposals, + buildCooldownBudgetUsage, + buildExpiryCheckMessages, + buildCooldownLearningDrafts, + buildDiaryBetOutcomesFromCycleBets, + buildDojoSessionBuildRequest, + buildSynthesisInputRecord, + clampConfidenceWithDelta, + filterExecutionHistoryForCycle, + listCompletedBetDescriptions, + mapBridgeRunStatusToIncompleteStatus, + mapBridgeRunStatusToSyncedOutcome, + resolveAppliedProposalIds, + selectEffectiveBetOutcomes, + shouldRecordBetOutcomes, + shouldWarnOnIncompleteRuns, + shouldWriteDojoDiary, + shouldWriteDojoSession, +} from './cooldown-session.helpers.js'; + +describe('cooldown-session helpers', () => { + describe('shouldWarnOnIncompleteRuns', () => { + it('warns only when there are incomplete runs and force is false', () => { + expect(shouldWarnOnIncompleteRuns(0, false)).toBe(false); + expect(shouldWarnOnIncompleteRuns(1, true)).toBe(false); + expect(shouldWarnOnIncompleteRuns(2, false)).toBe(true); + }); + }); + + describe('shouldRecordBetOutcomes', () => { + it('returns true only for non-empty explicit outcomes', () => { + expect(shouldRecordBetOutcomes([])).toBe(false); + expect(shouldRecordBetOutcomes([{ betId: 'bet-1', outcome: 'complete' }])).toBe(true); + }); + }); + + describe('selectEffectiveBetOutcomes', () => { + it('prefers explicit outcomes over auto-synced outcomes', () => { + expect(selectEffectiveBetOutcomes( + [{ betId: 'bet-1', outcome: 'abandoned' }], + [{ betId: 'bet-1', outcome: 'complete' }], + )).toEqual([{ betId: 'bet-1', outcome: 'abandoned' }]); + }); + + it('falls back to synced outcomes when no explicit outcomes were provided', () => { + expect(selectEffectiveBetOutcomes( + [], + [{ betId: 'bet-1', outcome: 'partial', notes: 'auto-synced' }], + )).toEqual([{ betId: 'bet-1', outcome: 'partial', notes: 'auto-synced' }]); + }); + }); + + describe('buildDiaryBetOutcomesFromCycleBets', () => { + it('filters pending bets and maps descriptions into diary outcomes', () => { + expect(buildDiaryBetOutcomesFromCycleBets([ + { id: 'bet-1', outcome: 'pending', description: 'Pending bet' }, + { id: 'bet-2', outcome: 'complete', outcomeNotes: 'done', description: 'Done bet' }, + { id: 'bet-3', outcome: 'partial', description: 'Partial bet' }, + ])).toEqual([ + { betId: 'bet-2', outcome: 'complete', notes: 'done', betDescription: 'Done bet' }, + { betId: 'bet-3', outcome: 'partial', notes: undefined, betDescription: 'Partial bet' }, + ]); + }); + }); + + describe('dojo helpers', () => { + it('requires dojoDir to write diary and session outputs', () => { + expect(shouldWriteDojoDiary(undefined)).toBe(false); + expect(shouldWriteDojoDiary('/tmp/dojo')).toBe(true); + expect(shouldWriteDojoSession(undefined, { build: vi.fn() })).toBe(false); + expect(shouldWriteDojoSession('/tmp/dojo', undefined)).toBe(false); + expect(shouldWriteDojoSession('/tmp/dojo', { build: vi.fn() })).toBe(true); + }); + }); + + describe('clampConfidenceWithDelta', () => { + it('clamps confidence updates to the valid 0..1 range', () => { + expect(clampConfidenceWithDelta(0.9, 0.2)).toBe(1); + expect(clampConfidenceWithDelta(0.1, -0.3)).toBe(0); + expect(clampConfidenceWithDelta(0.4, 0.2)).toBeCloseTo(0.6); + }); + }); + + describe('buildCooldownBudgetUsage', () => { + it('reports zero utilization when token budget is missing or zero', () => { + expect(buildCooldownBudgetUsage(undefined, 500, 'info')).toEqual({ + utilizationPercent: 0, + alertLevel: 'info', + }); + expect(buildCooldownBudgetUsage(0, 500, undefined)).toEqual({ + utilizationPercent: 0, + alertLevel: undefined, + }); + }); + + it('derives alert levels from utilization thresholds', () => { + expect(buildCooldownBudgetUsage(100, 110, undefined).utilizationPercent).toBeCloseTo(110); + expect(buildCooldownBudgetUsage(100, 110, undefined).alertLevel).toBe('critical'); + expect(buildCooldownBudgetUsage(100, 90, undefined).utilizationPercent).toBeCloseTo(90); + expect(buildCooldownBudgetUsage(100, 90, undefined).alertLevel).toBe('warning'); + expect(buildCooldownBudgetUsage(100, 75, undefined).utilizationPercent).toBeCloseTo(75); + expect(buildCooldownBudgetUsage(100, 75, undefined).alertLevel).toBe('info'); + expect(buildCooldownBudgetUsage(100, 50, 'warning')).toEqual({ + utilizationPercent: 50, + alertLevel: undefined, + }); + }); + }); + + describe('bridge-run status mapping', () => { + it('maps terminal bridge statuses to cooldown outcomes only when they are actionable', () => { + expect(mapBridgeRunStatusToSyncedOutcome('complete')).toBe('complete'); + expect(mapBridgeRunStatusToSyncedOutcome('failed')).toBe('partial'); + expect(mapBridgeRunStatusToSyncedOutcome('in-progress')).toBeUndefined(); + expect(mapBridgeRunStatusToSyncedOutcome(undefined)).toBeUndefined(); + }); + + it('maps only in-progress bridge statuses to incomplete-run warnings', () => { + expect(mapBridgeRunStatusToIncompleteStatus('in-progress')).toBe('running'); + expect(mapBridgeRunStatusToIncompleteStatus('complete')).toBeUndefined(); + expect(mapBridgeRunStatusToIncompleteStatus('failed')).toBeUndefined(); + }); + }); + + describe('filterExecutionHistoryForCycle', () => { + it('keeps only entries for the requested cycle', () => { + expect(filterExecutionHistoryForCycle([ + { id: 'one', cycleId: 'cycle-a' }, + { id: 'two', cycleId: 'cycle-b' }, + { id: 'three', cycleId: 'cycle-a' }, + ] as Parameters[0], 'cycle-a').map((entry) => entry.id)).toEqual(['one', 'three']); + }); + }); + + describe('buildCooldownLearningDrafts', () => { + it('builds an exact low-completion learning draft with the cycle name when completion is below 50%', () => { + expect(buildCooldownLearningDrafts({ + cycleId: 'cycle-123', + cycleName: 'Hard Cycle', + completionRate: 25, + betCount: 4, + tokenBudget: 1000, + utilizationPercent: 25, + tokensUsed: 250, + })).toContainEqual({ + category: 'cycle-management', + content: 'Cycle "Hard Cycle" had low completion rate (25.0%). Consider reducing scope or breaking bets into smaller chunks.', + confidence: 0.6, + observation: '4 bets, 25.0% completion', + }); + }); + + it('omits boundary drafts at exactly 50% completion and 30% utilization', () => { + expect(buildCooldownLearningDrafts({ + cycleId: 'cycle-123', + cycleName: 'Boundary Cycle', + completionRate: 50, + betCount: 2, + tokenBudget: 1000, + utilizationPercent: 30, + tokensUsed: 300, + })).toEqual([]); + }); + + it('builds over-budget and under-utilization drafts with exact budget wording', () => { + expect(buildCooldownLearningDrafts({ + cycleId: 'cycle-123', + completionRate: 100, + betCount: 1, + tokenBudget: 1000, + utilizationPercent: 150, + tokensUsed: 1500, + })).toContainEqual({ + category: 'budget-management', + content: 'Cycle "cycle-123" exceeded token budget (150.0% utilization). Consider more conservative estimates.', + confidence: 0.7, + observation: '1500 tokens used of 1000 budget', + }); + + expect(buildCooldownLearningDrafts({ + cycleId: 'cycle-999', + cycleName: 'Under Cycle', + completionRate: 100, + betCount: 3, + tokenBudget: 1000, + utilizationPercent: 20, + tokensUsed: 200, + })).toContainEqual({ + category: 'budget-management', + content: 'Cycle "Under Cycle" significantly under-utilized token budget (20.0%). Could have taken on more work.', + confidence: 0.5, + observation: '200 tokens used of 1000 budget', + }); + }); + }); + + describe('buildExpiryCheckMessages', () => { + it('returns only the non-zero expiry summary lines', () => { + expect(buildExpiryCheckMessages({ + archived: { length: 2 }, + flaggedStale: { length: 1 }, + })).toEqual([ + 'Expiry check: auto-archived 2 expired operational learnings', + 'Expiry check: flagged 1 stale strategic learnings for review', + ]); + + expect(buildExpiryCheckMessages({ + archived: { length: 0 }, + flaggedStale: { length: 0 }, + })).toEqual([]); + }); + }); + + describe('buildBeltAdvancementMessage', () => { + it('formats the advancement message only when the belt leveled up', () => { + expect(buildBeltAdvancementMessage({ + previous: 'go-kyu', + belt: 'yon-kyu', + leveledUp: true, + })).toBe('Belt advanced: go-kyu → yon-kyu'); + + expect(buildBeltAdvancementMessage({ + previous: 'go-kyu', + belt: 'go-kyu', + leveledUp: false, + })).toBeUndefined(); + }); + }); + + describe('buildDojoSessionBuildRequest', () => { + it('uses the provided runsDir and cycle name when available', () => { + expect(buildDojoSessionBuildRequest({ + dojoDir: '/tmp/dojo', + cycleId: 'cycle-12345678', + cycleName: 'Session Cycle', + runsDir: '/tmp/runs', + })).toEqual({ + diaryDir: join('/tmp/dojo', 'diary'), + runsDir: '/tmp/runs', + title: 'Cooldown — Session Cycle', + }); + }); + + it('falls back to the dojo-adjacent runsDir and truncated cycle id title', () => { + expect(buildDojoSessionBuildRequest({ + dojoDir: '/tmp/dojo', + cycleId: 'abcdef1234567890', + })).toEqual({ + diaryDir: join('/tmp/dojo', 'diary'), + runsDir: join('/tmp/dojo', '..', 'runs'), + title: 'Cooldown — abcdef12', + }); + }); + }); + + describe('buildSynthesisInputRecord', () => { + it('preserves the provided synthesis payload exactly', () => { + expect(buildSynthesisInputRecord({ + id: 'synth-1', + cycleId: 'cycle-1', + createdAt: '2026-03-16T12:00:00.000Z', + depth: 'standard', + observations: [], + learnings: [], + cycleName: 'Synthesis Cycle', + tokenBudget: 4000, + tokensUsed: 1200, + })).toEqual({ + id: 'synth-1', + cycleId: 'cycle-1', + createdAt: '2026-03-16T12:00:00.000Z', + depth: 'standard', + observations: [], + learnings: [], + cycleName: 'Synthesis Cycle', + tokenBudget: 4000, + tokensUsed: 1200, + }); + }); + }); + + describe('buildAgentPerspectiveFromProposals', () => { + it('returns undefined when there are no accepted proposals', () => { + expect(buildAgentPerspectiveFromProposals([])).toBeUndefined(); + }); + + it('formats positive and negative confidence deltas distinctly', () => { + const perspective = buildAgentPerspectiveFromProposals([ + { + id: 'positive', + type: 'update-learning', + confidenceDelta: 0.15, + proposedContent: 'Increase confidence', + }, + { + id: 'negative', + type: 'update-learning', + confidenceDelta: -0.2, + proposedContent: 'Decrease confidence', + }, + ] as Parameters[0]); + + expect(perspective).toContain('+0.15'); + expect(perspective).toContain('-0.20'); + }); + + it('formats each synthesis proposal variant with its expected wording', () => { + const perspective = buildAgentPerspectiveFromProposals([ + { + id: 'new-learning', + type: 'new-learning', + proposedTier: 'operational', + proposedCategory: 'cadence', + confidence: 0.82, + proposedContent: 'Keep cooldown reports short and explicit.', + basedOnObservations: [], + rationale: 'Short reports travel better.', + }, + { + id: 'promote', + type: 'promote', + learningId: 'learning-1', + toTier: 'strategic', + rationale: 'Shows up every cycle.', + }, + { + id: 'archive', + type: 'archive', + learningId: 'learning-2', + reason: 'Superseded by new process', + }, + { + id: 'method', + type: 'methodology-recommendation', + area: 'testing', + recommendation: 'Keep extracting pure helpers before adding orchestration tests.', + rationale: 'Preserves logical boundaries.', + }, + ] as Parameters[0]); + + expect(perspective).toContain('## Agent Perspective (Synthesis)'); + expect(perspective).toContain('**New learning** [operational/cadence] (confidence: 0.82):'); + expect(perspective).toContain('Keep cooldown reports short and explicit.'); + expect(perspective).toContain('**Promoted learning** to strategic tier.'); + expect(perspective).toContain('**Archived learning**: Superseded by new process'); + expect(perspective).toContain('**Methodology recommendation** (testing):'); + expect(perspective).toContain('Keep extracting pure helpers before adding orchestration tests.'); + }); + }); + + describe('resolveAppliedProposalIds', () => { + it('uses explicit accepted ids when provided and otherwise falls back to every proposal id', () => { + expect(resolveAppliedProposalIds( + [{ id: 'p-1' }, { id: 'p-2' }], + ['p-2'], + )).toEqual(new Set(['p-2'])); + + expect(resolveAppliedProposalIds( + [{ id: 'p-1' }, { id: 'p-2' }], + )).toEqual(new Set(['p-1', 'p-2'])); + }); + }); + + describe('listCompletedBetDescriptions', () => { + it('includes only complete and partial bets', () => { + expect(listCompletedBetDescriptions([ + { outcome: 'complete', description: 'Complete bet' }, + { outcome: 'partial', description: 'Partial bet' }, + { outcome: 'abandoned', description: 'Abandoned bet' }, + ])).toEqual(['Complete bet', 'Partial bet']); + }); + }); +}); diff --git a/src/features/cycle-management/cooldown-session.helpers.ts b/src/features/cycle-management/cooldown-session.helpers.ts new file mode 100644 index 0000000..4ce0201 --- /dev/null +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -0,0 +1,294 @@ +import { join } from 'node:path'; +import type { BudgetAlertLevel } from '@domain/types/cycle.js'; +import type { ExecutionHistoryEntry } from '@domain/types/history.js'; +import type { Learning } from '@domain/types/learning.js'; +import type { Observation } from '@domain/types/observation.js'; +import type { SynthesisInput, SynthesisProposal } from '@domain/types/synthesis.js'; +import type { BeltComputeResult } from '@features/belt/belt-calculator.js'; + +export interface CooldownHelperBetOutcome { + betId: string; + outcome: 'complete' | 'partial' | 'abandoned'; + notes?: string; + betDescription?: string; +} + +export interface CooldownDiaryBetSource { + id: string; + outcome: string; + outcomeNotes?: string; + description: string; +} + +export interface CooldownBudgetUsage { + utilizationPercent: number; + alertLevel?: BudgetAlertLevel; +} + +export interface CooldownLearningContext { + cycleId: string; + cycleName?: string; + completionRate: number; + betCount: number; + tokenBudget?: number; + utilizationPercent: number; + tokensUsed: number; +} + +export interface CooldownLearningDraft { + category: 'cycle-management' | 'budget-management'; + content: string; + confidence: number; + observation: string; +} + +export interface DojoSessionBuildRequest { + diaryDir: string; + runsDir: string; + title: string; +} + +export function shouldWarnOnIncompleteRuns(incompleteRunsCount: number, force: boolean): boolean { + return incompleteRunsCount > 0 && !force; +} + +export function shouldRecordBetOutcomes(outcomes: readonly CooldownHelperBetOutcome[]): boolean { + return outcomes.length > 0; +} + +export function selectEffectiveBetOutcomes( + explicitBetOutcomes: readonly CooldownHelperBetOutcome[], + syncedBetOutcomes: readonly CooldownHelperBetOutcome[], +): CooldownHelperBetOutcome[] { + return explicitBetOutcomes.length > 0 + ? [...explicitBetOutcomes] + : [...syncedBetOutcomes]; +} + +export function buildDiaryBetOutcomesFromCycleBets(bets: readonly CooldownDiaryBetSource[]): CooldownHelperBetOutcome[] { + return bets + .filter((bet) => bet.outcome !== 'pending') + .map((bet) => ({ + betId: bet.id, + outcome: bet.outcome as CooldownHelperBetOutcome['outcome'], + notes: bet.outcomeNotes, + betDescription: bet.description, + })); +} + +export function shouldWriteDojoDiary(dojoDir: string | undefined): boolean { + return Boolean(dojoDir); +} + +export function shouldWriteDojoSession(dojoDir: string | undefined, dojoSessionBuilder: unknown): boolean { + return Boolean(dojoDir && dojoSessionBuilder); +} + +export function clampConfidenceWithDelta(existingConfidence: number, confidenceDelta: number): number { + return Math.min(1, Math.max(0, existingConfidence + confidenceDelta)); +} + +export function buildCooldownBudgetUsage( + tokenBudget: number | undefined, + tokensUsed: number, + currentAlertLevel: BudgetAlertLevel | undefined, +): CooldownBudgetUsage { + const utilizationPercent = tokenBudget && tokenBudget > 0 + ? (tokensUsed / tokenBudget) * 100 + : 0; + + let alertLevel = currentAlertLevel; + if (tokenBudget) { + if (utilizationPercent >= 100) { + alertLevel = 'critical'; + } else if (utilizationPercent >= 90) { + alertLevel = 'warning'; + } else if (utilizationPercent >= 75) { + alertLevel = 'info'; + } else { + alertLevel = undefined; + } + } + + return { + utilizationPercent, + alertLevel, + }; +} + +export function mapBridgeRunStatusToSyncedOutcome( + status: string | undefined, +): CooldownHelperBetOutcome['outcome'] | undefined { + if (status === 'complete') return 'complete'; + if (status === 'failed') return 'partial'; + return undefined; +} + +export function mapBridgeRunStatusToIncompleteStatus( + status: string | undefined, +): 'running' | undefined { + return status === 'in-progress' ? 'running' : undefined; +} + +export function filterExecutionHistoryForCycle( + entries: readonly ExecutionHistoryEntry[], + cycleId: string, +): ExecutionHistoryEntry[] { + return entries.filter((entry) => entry.cycleId === cycleId); +} + +export function buildCooldownLearningDrafts(context: CooldownLearningContext): CooldownLearningDraft[] { + const drafts: CooldownLearningDraft[] = []; + const cycleLabel = context.cycleName ?? context.cycleId; + + if (context.betCount > 0 && context.completionRate < 50) { + drafts.push({ + category: 'cycle-management', + content: `Cycle "${cycleLabel}" had low completion rate (${context.completionRate.toFixed(1)}%). Consider reducing scope or breaking bets into smaller chunks.`, + confidence: 0.6, + observation: `${context.betCount} bets, ${context.completionRate.toFixed(1)}% completion`, + }); + } + + if (context.tokenBudget) { + if (context.utilizationPercent > 100) { + drafts.push({ + category: 'budget-management', + content: `Cycle "${cycleLabel}" exceeded token budget (${context.utilizationPercent.toFixed(1)}% utilization). Consider more conservative estimates.`, + confidence: 0.7, + observation: `${context.tokensUsed} tokens used of ${context.tokenBudget} budget`, + }); + } else if (context.utilizationPercent < 30 && context.betCount > 0) { + drafts.push({ + category: 'budget-management', + content: `Cycle "${cycleLabel}" significantly under-utilized token budget (${context.utilizationPercent.toFixed(1)}%). Could have taken on more work.`, + confidence: 0.5, + observation: `${context.tokensUsed} tokens used of ${context.tokenBudget} budget`, + }); + } + } + + return drafts; +} + +export function buildExpiryCheckMessages(input: { + archived: { length: number }; + flaggedStale: { length: number }; +}): string[] { + const messages: string[] = []; + + if (input.archived.length > 0) { + messages.push(`Expiry check: auto-archived ${input.archived.length} expired operational learnings`); + } + + if (input.flaggedStale.length > 0) { + messages.push(`Expiry check: flagged ${input.flaggedStale.length} stale strategic learnings for review`); + } + + return messages; +} + +export function buildBeltAdvancementMessage( + beltResult: Pick | undefined, +): string | undefined { + if (!beltResult?.leveledUp) { + return undefined; + } + + return `Belt advanced: ${beltResult.previous} → ${beltResult.belt}`; +} + +export function buildDojoSessionBuildRequest(input: { + dojoDir: string; + cycleId: string; + cycleName?: string; + runsDir?: string; +}): DojoSessionBuildRequest { + return { + diaryDir: join(input.dojoDir, 'diary'), + runsDir: input.runsDir ?? join(input.dojoDir, '..', 'runs'), + title: input.cycleName + ? `Cooldown — ${input.cycleName}` + : `Cooldown — ${input.cycleId.slice(0, 8)}`, + }; +} + +export function buildSynthesisInputRecord(input: { + id: string; + cycleId: string; + createdAt: string; + depth: import('@domain/types/synthesis.js').SynthesisDepth; + observations: Observation[]; + learnings: Learning[]; + cycleName?: string; + tokenBudget?: number; + tokensUsed: number; +}): SynthesisInput { + return { + id: input.id, + cycleId: input.cycleId, + createdAt: input.createdAt, + depth: input.depth, + observations: input.observations, + learnings: input.learnings, + cycleName: input.cycleName, + tokenBudget: input.tokenBudget, + tokensUsed: input.tokensUsed, + }; +} + +export function resolveAppliedProposalIds( + proposals: ReadonlyArray<{ id: string }>, + acceptedProposalIds?: readonly string[], +): Set { + return acceptedProposalIds + ? new Set(acceptedProposalIds) + : new Set(proposals.map((proposal) => proposal.id)); +} + +export function buildAgentPerspectiveFromProposals(proposals: readonly SynthesisProposal[]): string | undefined { + if (proposals.length === 0) return undefined; + + return [ + '## Agent Perspective (Synthesis)', + '', + ...proposals.flatMap((proposal) => [...formatAgentPerspectiveProposal(proposal), '']), + ].join('\n').trimEnd(); +} + +function formatAgentPerspectiveProposal(proposal: SynthesisProposal): string[] { + switch (proposal.type) { + case 'new-learning': + return [ + `**New learning** [${proposal.proposedTier}/${proposal.proposedCategory}] (confidence: ${proposal.confidence.toFixed(2)}):`, + ` ${proposal.proposedContent}`, + ]; + case 'update-learning': + return formatUpdateLearningPerspective(proposal.confidenceDelta, proposal.proposedContent); + case 'promote': + return [`**Promoted learning** to ${proposal.toTier} tier.`]; + case 'archive': + return [`**Archived learning**: ${proposal.reason}`]; + case 'methodology-recommendation': + return [ + `**Methodology recommendation** (${proposal.area}):`, + ` ${proposal.recommendation}`, + ]; + } +} + +function formatUpdateLearningPerspective(confidenceDelta: number, proposedContent: string): string[] { + const prefix = confidenceDelta > 0 ? '+' : ''; + return [ + `**Updated learning** (confidence delta: ${prefix}${confidenceDelta.toFixed(2)}):`, + ` ${proposedContent}`, + ]; +} + +export function listCompletedBetDescriptions( + bets: ReadonlyArray<{ outcome: string; description: string }>, +): string[] { + return bets + .filter((bet) => bet.outcome === 'complete' || bet.outcome === 'partial') + .map((bet) => bet.description); +} diff --git a/src/features/cycle-management/cooldown-session.test.ts b/src/features/cycle-management/cooldown-session.test.ts index e35a84c..ba237b9 100644 --- a/src/features/cycle-management/cooldown-session.test.ts +++ b/src/features/cycle-management/cooldown-session.test.ts @@ -112,6 +112,40 @@ describe('CooldownSession', () => { expect(cycleManager.get(cycle.id).state).toBe('complete'); }); + it('logs expiry-check summaries when archived or stale learnings are found', async () => { + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}); + const checkExpiry = vi.spyOn(knowledgeStore, 'checkExpiry').mockReturnValue({ + archived: [{ id: 'archived-learning' }], + flaggedStale: [{ id: 'stale-learning' }], + } as ReturnType); + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Expiry Check Cycle'); + + await session.run(cycle.id); + + expect(checkExpiry).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledWith('Expiry check: auto-archived 1 expired operational learnings'); + expect(debugSpy).toHaveBeenCalledWith('Expiry check: flagged 1 stale strategic learnings for review'); + }); + + it('does not log expiry-check summaries when nothing was archived or flagged', async () => { + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}); + const checkExpiry = vi.spyOn(knowledgeStore, 'checkExpiry').mockReturnValue({ + archived: [], + flaggedStale: [], + } as ReturnType); + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Quiet Expiry Check'); + + try { + await session.run(cycle.id); + + expect(checkExpiry).toHaveBeenCalledTimes(1); + expect(debugSpy).not.toHaveBeenCalledWith('Expiry check: auto-archived 0 expired operational learnings'); + expect(debugSpy).not.toHaveBeenCalledWith('Expiry check: flagged 0 stale strategic learnings for review'); + } finally { + debugSpy.mockRestore(); + } + }); + it('enriches report with token usage from cycle history', async () => { const cycle = cycleManager.create({ tokenBudget: 100000 }, 'Token Cycle'); cycleManager.addBet(cycle.id, { @@ -251,6 +285,66 @@ describe('CooldownSession', () => { expect(result.learningsCaptured).toBeGreaterThanOrEqual(1); }); + it('warns when some cooldown learnings fail to capture', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const cycle = cycleManager.create({ tokenBudget: 10000 }, 'Partial Learning Failure'); + cycleManager.addBet(cycle.id, { + description: 'Bet 1', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + const updatedCycle = cycleManager.addBet(cycle.id, { + description: 'Bet 2', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + + const originalCapture = knowledgeStore.capture.bind(knowledgeStore); + vi.spyOn(knowledgeStore, 'capture') + .mockImplementationOnce(() => { + throw new Error('capture exploded'); + }) + .mockImplementation((params) => { + originalCapture(params); + }); + + const historyEntry = { + id: crypto.randomUUID(), + pipelineId: crypto.randomUUID(), + stageType: 'build', + stageIndex: 0, + adapter: 'manual', + tokenUsage: { + inputTokens: 8000, + outputTokens: 7000, + cacheCreationTokens: 0, + cacheReadTokens: 0, + total: 15000, + }, + artifactNames: [], + learningIds: [], + cycleId: cycle.id, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }; + JsonStore.write( + join(historyDir, `${historyEntry.id}.json`), + historyEntry, + ExecutionHistoryEntrySchema, + ); + + const result = await session.run(cycle.id, [ + { betId: updatedCycle.bets[0]!.id, outcome: 'abandoned' }, + { betId: updatedCycle.bets[1]!.id, outcome: 'abandoned' }, + ]); + + expect(result.learningsCaptured).toBe(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to capture cooldown learning: capture exploded')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1 of 2 cooldown learnings failed to capture')); + }); + it('does not capture an over-budget learning at exactly 100% utilization', async () => { const cycle = cycleManager.create({ tokenBudget: 10000 }, 'Exact Budget'); cycleManager.addBet(cycle.id, { @@ -378,6 +472,34 @@ describe('CooldownSession', () => { expect(cycleManager.get(cycle.id).state).toBe('active'); }); + it('logs an error when rollback also fails after run() throws', async () => { + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Rollback Failure Test'); + cycleManager.updateState(cycle.id, 'active'); + + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const originalUpdateState = cycleManager.updateState.bind(cycleManager); + vi.spyOn(cycleManager, 'generateCooldown').mockImplementation(() => { + throw new Error('Simulated failure'); + }); + vi.spyOn(cycleManager, 'updateState').mockImplementation((cycleId, state) => { + if (state === 'active') { + throw new Error('Rollback exploded'); + } + originalUpdateState(cycleId, state); + }); + + try { + await expect(session.run(cycle.id)).rejects.toThrow('Simulated failure'); + expect(errorSpy).toHaveBeenCalledWith( + `Failed to roll back cycle "${cycle.id}" from cooldown to "active". Manual intervention may be required.`, + { rollbackError: 'Rollback exploded' }, + ); + expect(cycleManager.get(cycle.id).state).toBe('cooldown'); + } finally { + errorSpy.mockRestore(); + } + }); + it('handles empty cycle with no bets', async () => { const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Empty Cycle'); @@ -459,6 +581,61 @@ describe('CooldownSession', () => { }); }); + describe('belt advancement integration', () => { + it('logs belt advancement during run() only when the belt levels up', async () => { + const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const projectStateFile = join(baseDir, 'project-state-run.json'); + + const leveledCycle = cycleManager.create({ tokenBudget: 50000 }, 'Leveled Run Cycle'); + const leveledSession = new CooldownSession({ + cycleManager, + knowledgeStore, + persistence: JsonStore, + pipelineDir, + historyDir, + projectStateFile, + beltCalculator: { + computeAndStore: vi.fn(() => ({ + belt: 'yon-kyu', + previous: 'go-kyu', + leveledUp: true, + })), + }, + }); + + const steadyCycle = cycleManager.create({ tokenBudget: 50000 }, 'Steady Run Cycle'); + const steadySession = new CooldownSession({ + cycleManager, + knowledgeStore, + persistence: JsonStore, + pipelineDir, + historyDir, + projectStateFile, + beltCalculator: { + computeAndStore: vi.fn(() => ({ + belt: 'go-kyu', + previous: 'go-kyu', + leveledUp: false, + })), + }, + }); + + try { + const leveledResult = await leveledSession.run(leveledCycle.id); + expect(leveledResult.beltResult?.leveledUp).toBe(true); + expect(infoSpy).toHaveBeenCalledWith('Belt advanced: go-kyu → yon-kyu'); + + infoSpy.mockClear(); + + const steadyResult = await steadySession.run(steadyCycle.id); + expect(steadyResult.beltResult?.leveledUp).toBe(false); + expect(infoSpy).not.toHaveBeenCalled(); + } finally { + infoSpy.mockRestore(); + } + }); + }); + describe('next-keiko integration', () => { it('passes completed and partial bets to the next-keiko generator', async () => { const runsDir = join(baseDir, 'runs-next-keiko'); @@ -582,6 +759,44 @@ describe('CooldownSession', () => { const reloaded = cycleManager.get(cycle.id); expect(reloaded.bets[0]!.outcome).toBe('pending'); // Unchanged }); + + it('logs unmatched bet IDs returned by the cycle manager', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const cycle = cycleManager.create({ tokenBudget: 50000 }); + + try { + session.recordBetOutcomes(cycle.id, [ + { betId: 'nonexistent-id', outcome: 'complete' }, + ]); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`Bet outcome(s) for cycle "${cycle.id}" referenced nonexistent bet IDs: nonexistent-id`), + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('does not warn when every bet outcome matches a real bet', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const cycle = cycleManager.create({ tokenBudget: 50000 }); + const updated = cycleManager.addBet(cycle.id, { + description: 'Known bet', + appetite: 20, + outcome: 'pending', + issueRefs: [], + }); + + try { + session.recordBetOutcomes(cycle.id, [ + { betId: updated.bets[0]!.id, outcome: 'complete' }, + ]); + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('referenced nonexistent bet IDs')); + } finally { + warnSpy.mockRestore(); + } + }); }); describe('enrichReportWithTokens', () => { @@ -629,6 +844,39 @@ describe('CooldownSession', () => { expect(enriched.utilizationPercent).toBe(18); }); + it('requests history with warnOnInvalid=false and ignores entries for other cycles', () => { + const cycle = cycleManager.create({ tokenBudget: 100000 }, 'Filtered History'); + const list = vi.fn(() => ([ + { + id: 'entry-a', + cycleId: cycle.id, + tokenUsage: { total: 1800 }, + }, + { + id: 'entry-b', + cycleId: 'other-cycle', + tokenUsage: { total: 9000 }, + }, + { + id: 'entry-c', + cycleId: cycle.id, + }, + ])); + const sessionWithPersistenceSpy = new CooldownSession({ + cycleManager, + knowledgeStore, + persistence: { list } as unknown as typeof JsonStore, + pipelineDir, + historyDir, + }); + + const enriched = sessionWithPersistenceSpy.enrichReportWithTokens(cycleManager.generateCooldown(cycle.id), cycle.id); + + expect(list).toHaveBeenCalledWith(historyDir, ExecutionHistoryEntrySchema, { warnOnInvalid: false }); + expect(enriched.tokensUsed).toBe(1800); + expect(enriched.utilizationPercent).toBeCloseTo(1.8, 0); + }); + it('sets critical alert when over budget', () => { const cycle = cycleManager.create({ tokenBudget: 10000 }, 'Over Budget'); @@ -1127,6 +1375,24 @@ describe('CooldownSession', () => { expect(sessions.length).toBe(0); }); + it('does not generate a dojo session when dojoDir is omitted even if a builder exists', async () => { + const dojoSessionBuilder = { build: vi.fn() }; + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Builder Only'); + + const sessionWithoutDojoDir = new CooldownSession({ + cycleManager, + knowledgeStore, + persistence: JsonStore, + pipelineDir, + historyDir, + dojoSessionBuilder, + }); + + await sessionWithoutDojoDir.run(cycle.id); + + expect(dojoSessionBuilder.build).not.toHaveBeenCalled(); + }); + it('does not abort cooldown when dojo session generation fails', async () => { const failingBuilder = { build: () => { throw new Error('SessionBuilder exploded'); }, @@ -1188,6 +1454,25 @@ describe('CooldownSession', () => { expect(sessions.length).toBeGreaterThanOrEqual(1); expect(sessions[0]!.title).toContain('Complete Session Cycle'); }); + + it('does not generate a dojo session on complete() when dojoDir is omitted', async () => { + const dojoSessionBuilder = { build: vi.fn() }; + const cycle = cycleManager.create({ tokenBudget: 50000 }, 'Complete Without Dojo Dir'); + const sessionForComplete = new CooldownSession({ + cycleManager, + knowledgeStore, + persistence: JsonStore, + pipelineDir, + historyDir, + synthesisDir, + dojoSessionBuilder, + }); + + await sessionForComplete.prepare(cycle.id); + await sessionForComplete.complete(cycle.id); + + expect(dojoSessionBuilder.build).not.toHaveBeenCalled(); + }); }); describe('run with ruleRegistry (ruleSuggestions)', () => { @@ -1583,6 +1868,35 @@ describe('CooldownSession', () => { expect(result.report.completionRate).toBe(0); // partial doesn't count as complete }); + it('does not record bet outcomes when bridge-run status is still in progress', async () => { + const cycle = cycleManager.create({ tokenBudget: 50000 }); + const withBet = cycleManager.addBet(cycle.id, { description: 'Running bet', appetite: 80, outcome: 'pending', issueRefs: [] }); + const bet = withBet.bets[0]!; + const runId = randomUUID(); + cycleManager.setRunId(cycle.id, bet.id, runId); + + writeFileSync(join(bridgeRunsDir, `${runId}.json`), JSON.stringify({ + runId, betId: bet.id, cycleId: cycle.id, cycleName: 'Test', betName: 'Running bet', + stages: ['build'], isolation: 'shared', startedAt: new Date().toISOString(), status: 'in-progress', + })); + + const sessionWithBridge = new CooldownSession({ + cycleManager, knowledgeStore, persistence: JsonStore, pipelineDir, historyDir, bridgeRunsDir, + }); + const recordSpy = vi.spyOn(sessionWithBridge, 'recordBetOutcomes'); + + try { + cycleManager.updateState(cycle.id, 'active'); + const result = await sessionWithBridge.run(cycle.id, [], { force: true }); + + expect(recordSpy).not.toHaveBeenCalled(); + expect(result.betOutcomes).toEqual([]); + expect(result.report.bets[0]!.outcome).toBe('pending'); + } finally { + recordSpy.mockRestore(); + } + }); + it('explicit bet outcome passed to run() takes precedence over bridge-run auto-sync', async () => { const cycle = cycleManager.create({ tokenBudget: 50000 }); // Bet starts as pending — auto-sync would set it to 'complete' from bridge-run @@ -1725,6 +2039,18 @@ describe('CooldownSession', () => { } }); + it('does not log the incomplete-runs warning when no incomplete runs exist', async () => { + const cycle = cycleManager.create({ tokenBudget: 50000 }); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + try { + await session.run(cycle.id, [], { force: false }); + expect(warnSpy.mock.calls.some((args) => String(args[0]).includes('still in progress'))).toBe(false); + } finally { + warnSpy.mockRestore(); + } + }); + it('cooldown proceeds normally (no block) even with incomplete runs — just warns', async () => { const cycle = cycleManager.create({ tokenBudget: 50000 }); const withBet = cycleManager.addBet(cycle.id, { description: 'Stale bet', appetite: 30, outcome: 'pending', issueRefs: [] }); @@ -1849,5 +2175,68 @@ describe('CooldownSession', () => { warnSpy.mockRestore(); } }); + + it('prepare does not log the incomplete-runs warning when every run is complete', async () => { + const cycle = cycleManager.create({ tokenBudget: 50000 }); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + try { + await session.prepare(cycle.id, [], 'quick', { force: false }); + expect(warnSpy.mock.calls.some((args) => String(args[0]).includes('still in progress'))).toBe(false); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe('buildAgentPerspectiveFromProposals', () => { + it('formats supported proposal types for diary agent perspective text', () => { + const perspective = CooldownSession.buildAgentPerspectiveFromProposals([ + { + id: 'new-learning', + type: 'new-learning', + proposedTier: 'stage', + proposedCategory: 'testing', + proposedContent: 'Write mutation-focused tests for hot seams.', + confidence: 0.82, + }, + { + id: 'update-learning', + type: 'update-learning', + confidenceDelta: 0.15, + proposedContent: 'Prefer smaller cooldown scopes when mutation debt is high.', + }, + { + id: 'promote', + type: 'promote', + toTier: 'category', + }, + { + id: 'archive', + type: 'archive', + reason: 'Superseded by recent cooldown evidence.', + }, + { + id: 'methodology', + type: 'methodology-recommendation', + area: 'planning', + recommendation: 'Split large orchestration seams before adding more CLI wiring.', + }, + ] as Parameters[0]); + + expect(perspective).toContain('New learning'); + expect(perspective).toContain('[stage/testing]'); + expect(perspective).toContain('Updated learning'); + expect(perspective).toContain('+0.15'); + expect(perspective).toContain('Promoted learning'); + expect(perspective).toContain('Archived learning'); + expect(perspective).toContain('Methodology recommendation'); + expect(perspective).toContain('(planning)'); + expect(perspective).toContain('Split large orchestration seams before adding more CLI wiring.'); + }); + + it('returns undefined when there are no proposals', () => { + expect(CooldownSession.buildAgentPerspectiveFromProposals([])).toBeUndefined(); + }); }); }); diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index 702bb4b..c983bd2 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -5,7 +5,7 @@ import type { IKnowledgeStore } from '@domain/ports/knowledge-store.js'; import type { IPersistence } from '@domain/ports/persistence.js'; import type { IStageRuleRegistry } from '@domain/ports/rule-registry.js'; import type { ExecutionHistoryEntry } from '@domain/types/history.js'; -import type { BudgetAlertLevel, Cycle } from '@domain/types/cycle.js'; +import type { Cycle } from '@domain/types/cycle.js'; import type { RuleSuggestion } from '@domain/types/rule.js'; import { ExecutionHistoryEntrySchema } from '@domain/types/history.js'; import { DiaryWriter } from '@features/dojo/diary-writer.js'; @@ -27,13 +27,33 @@ import type { Observation } from '@domain/types/observation.js'; import { SynthesisInputSchema, SynthesisResultSchema, - type SynthesisInput, type SynthesisProposal, } from '@domain/types/synthesis.js'; import type { BeltCalculator } from '@features/belt/belt-calculator.js'; import { loadProjectState, type BeltComputeResult } from '@features/belt/belt-calculator.js'; import type { KataAgentConfidenceCalculator } from '@features/kata-agent/kata-agent-confidence-calculator.js'; import { KataAgentRegistry } from '@infra/registries/kata-agent-registry.js'; +import { + buildAgentPerspectiveFromProposals, + buildBeltAdvancementMessage, + buildCooldownBudgetUsage, + buildExpiryCheckMessages, + buildCooldownLearningDrafts, + buildDiaryBetOutcomesFromCycleBets, + buildDojoSessionBuildRequest, + buildSynthesisInputRecord, + clampConfidenceWithDelta, + filterExecutionHistoryForCycle, + listCompletedBetDescriptions, + mapBridgeRunStatusToIncompleteStatus, + mapBridgeRunStatusToSyncedOutcome, + resolveAppliedProposalIds, + selectEffectiveBetOutcomes, + shouldRecordBetOutcomes, + shouldWarnOnIncompleteRuns, + shouldWriteDojoDiary, + shouldWriteDojoSession, +} from './cooldown-session.helpers.js'; /** * Dependencies injected into CooldownSession for testability. @@ -254,177 +274,326 @@ export class CooldownSession { constructor(deps: CooldownSessionDeps) { this.deps = deps; + this.proposalGenerator = this.resolveProposalGenerator(deps); + this.predictionMatcher = this.resolvePredictionMatcher(deps); + this.calibrationDetector = this.resolveCalibrationDetector(deps); + this.hierarchicalPromoter = this.resolveHierarchicalPromoter(deps); + this.frictionAnalyzer = this.resolveFrictionAnalyzer(deps); + this._nextKeikoProposalGenerator = this.resolveNextKeikoProposalGenerator(deps); + } + + private resolveProposalGenerator(deps: CooldownSessionDeps): Pick { + if (deps.proposalGenerator) return deps.proposalGenerator; + const generatorDeps: ProposalGeneratorDeps = { cycleManager: deps.cycleManager, knowledgeStore: deps.knowledgeStore, persistence: deps.persistence, pipelineDir: deps.pipelineDir, }; - this.proposalGenerator = deps.proposalGenerator ?? new ProposalGenerator(generatorDeps); - this.predictionMatcher = deps.predictionMatcher ?? (deps.runsDir ? new PredictionMatcher(deps.runsDir) : null); - this.calibrationDetector = deps.calibrationDetector ?? (deps.runsDir ? new CalibrationDetector(deps.runsDir) : null); - this.hierarchicalPromoter = deps.hierarchicalPromoter ?? new HierarchicalPromoter(deps.knowledgeStore); - this.frictionAnalyzer = deps.frictionAnalyzer ?? (deps.runsDir ? new FrictionAnalyzer(deps.runsDir, deps.knowledgeStore) : null); - // NextKeikoProposalGenerator is opt-in: only activated when explicitly provided - // via nextKeikoProposalGenerator dep OR when nextKeikoGeneratorDeps is provided. - // Auto-construction from runsDir alone would cause all existing tests to call - // `claude --print` unexpectedly. - this._nextKeikoProposalGenerator = deps.nextKeikoProposalGenerator - ?? (deps.nextKeikoGeneratorDeps ? new NextKeikoProposalGenerator(deps.nextKeikoGeneratorDeps) : null); + return new ProposalGenerator(generatorDeps); } - /** - * Run the full cooldown session. - * - * 1. Check for incomplete runs (pending/running) — warn if found; --force bypasses - * 2. Transition cycle state to 'cooldown' - * 3. Record per-bet outcomes (data collection, not interactive -- CLI handles prompts) - * 4. Generate the CooldownReport via CycleManager - * 5. Enrich report with actual token usage from TokenTracker - * 6. Load run summaries when runsDir provided - * 7. Generate next-cycle proposals via ProposalGenerator - * 8. Load pending rule suggestions when ruleRegistry provided - * 9. Capture any learnings from the cooldown analysis - * 10. Transition cycle state to 'complete' - * 11. Return the full session result - * - * @param force When true, skips the incomplete-run guard and proceeds even if runs are in-progress. - */ - async run(cycleId: string, betOutcomes: BetOutcomeRecord[] = [], { force = false, humanPerspective }: { force?: boolean; humanPerspective?: string } = {}): Promise { - // 0. Check for incomplete runs before any state mutation - const incompleteRuns = this.checkIncompleteRuns(cycleId); - if (incompleteRuns.length > 0 && !force) { - logger.warn( - `Warning: ${incompleteRuns.length} run(s) are still in progress. Cooldown data may be incomplete. Proceeding — use --force to suppress this warning.`, - ); - } + private resolvePredictionMatcher(deps: CooldownSessionDeps): Pick | null { + if (deps.predictionMatcher) return deps.predictionMatcher; + return deps.runsDir ? new PredictionMatcher(deps.runsDir) : null; + } - // Save previous state for rollback on failure - const previousState = this.deps.cycleManager.get(cycleId).state; + private resolveCalibrationDetector(deps: CooldownSessionDeps): Pick | null { + if (deps.calibrationDetector) return deps.calibrationDetector; + return deps.runsDir ? new CalibrationDetector(deps.runsDir) : null; + } + + private resolveHierarchicalPromoter( + deps: CooldownSessionDeps, + ): Pick { + return deps.hierarchicalPromoter ?? new HierarchicalPromoter(deps.knowledgeStore); + } + + private resolveFrictionAnalyzer(deps: CooldownSessionDeps): Pick | null { + if (deps.frictionAnalyzer) return deps.frictionAnalyzer; + return deps.runsDir ? new FrictionAnalyzer(deps.runsDir, deps.knowledgeStore) : null; + } + + private resolveNextKeikoProposalGenerator( + deps: CooldownSessionDeps, + ): Pick | null { + if (deps.nextKeikoProposalGenerator) return deps.nextKeikoProposalGenerator; + return deps.nextKeikoGeneratorDeps ? new NextKeikoProposalGenerator(deps.nextKeikoGeneratorDeps) : null; + } - // 1. Transition to cooldown state + private warnOnIncompleteRuns(incompleteRuns: IncompleteRunInfo[], force: boolean): void { + if (!shouldWarnOnIncompleteRuns(incompleteRuns.length, force)) return; + logger.warn( + `Warning: ${incompleteRuns.length} run(s) are still in progress. Cooldown data may be incomplete. Proceeding — use --force to suppress this warning.`, + ); + } + + private beginCooldown(cycleId: string): Cycle['state'] { + const previousState = this.deps.cycleManager.get(cycleId).state; this.deps.cycleManager.updateState(cycleId, 'cooldown'); + return previousState; + } + private rollbackCycleState(cycleId: string, previousState: Cycle['state']): void { try { - // 2. Auto-sync pending bet outcomes from bridge-run metadata (non-critical) - const syncedOutcomes = this.autoSyncBetOutcomesFromBridgeRuns(cycleId); + this.deps.cycleManager.updateState(cycleId, previousState); + } catch (rollbackError) { + logger.error(`Failed to roll back cycle "${cycleId}" from cooldown to "${previousState}". Manual intervention may be required.`, { + rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + } + } - // 3. Record bet outcomes if provided (explicit outcomes override auto-sync) - if (betOutcomes.length > 0) { - this.recordBetOutcomes(cycleId, betOutcomes); - } + private buildCooldownPhase(cycleId: string, betOutcomes: BetOutcomeRecord[]): { + cycle: Cycle; + report: CooldownReport; + runSummaries?: RunSummary[]; + proposals: CycleProposal[]; + ruleSuggestions?: RuleSuggestion[]; + learningsCaptured: number; + effectiveBetOutcomes: BetOutcomeRecord[]; + } { + const syncedOutcomes = this.autoSyncBetOutcomesFromBridgeRuns(cycleId); + this.recordExplicitBetOutcomes(cycleId, betOutcomes); + const cycle = this.deps.cycleManager.get(cycleId); + const report = this.buildCooldownReport(cycleId); + const runSummaries = this.maybeLoadRunSummaries(cycle); + + return { + cycle, + report, + runSummaries, + proposals: this.proposalGenerator.generate(cycleId, runSummaries), + ruleSuggestions: this.loadRuleSuggestions(), + learningsCaptured: this.captureCooldownLearnings(report), + effectiveBetOutcomes: selectEffectiveBetOutcomes(betOutcomes, syncedOutcomes) as BetOutcomeRecord[], + }; + } - // Effective outcomes for the result: explicit > auto-synced - const effectiveBetOutcomes: BetOutcomeRecord[] = betOutcomes.length > 0 ? betOutcomes : syncedOutcomes; + private buildCooldownReport(cycleId: string): CooldownReport { + const report = this.deps.cycleManager.generateCooldown(cycleId); + return this.enrichReportWithTokens(report, cycleId); + } + + private maybeLoadRunSummaries(cycle: Cycle): RunSummary[] | undefined { + return this.deps.runsDir ? this.loadRunSummaries(cycle) : undefined; + } - // 4. Generate the base cooldown report - let report = this.deps.cycleManager.generateCooldown(cycleId); + private loadRuleSuggestions(): RuleSuggestion[] | undefined { + if (!this.deps.ruleRegistry) return undefined; + + try { + return this.deps.ruleRegistry.getPendingSuggestions(); + } catch (err) { + logger.warn(`Failed to load rule suggestions: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } - // 5. Enrich with actual token usage - report = this.enrichReportWithTokens(report, cycleId); + private recordExplicitBetOutcomes(cycleId: string, betOutcomes: BetOutcomeRecord[]): void { + if (!shouldRecordBetOutcomes(betOutcomes)) return; + this.recordBetOutcomes(cycleId, betOutcomes); + } - // 6. Load run summaries when runsDir provided (enrich proposals) - const cycle = this.deps.cycleManager.get(cycleId); - const runSummaries = this.deps.runsDir - ? this.loadRunSummaries(cycle) - : undefined; + private runCooldownFollowUps(cycle: Cycle): void { + this.runPredictionMatching(cycle); + this.runCalibrationDetection(cycle); + this.runHierarchicalPromotion(); + this.runExpiryCheck(); + this.runFrictionAnalysis(cycle); + } - // 6. Generate next-cycle proposals (enriched with run data when available) - const proposals = this.proposalGenerator.generate(cycleId, runSummaries); + private computeOptionalBeltResult(): BeltComputeResult | undefined { + if (!this.deps.beltCalculator || !this.deps.projectStateFile) return undefined; - // 7. Load pending rule suggestions (non-critical — errors must not abort a completed cooldown) - let ruleSuggestions: RuleSuggestion[] | undefined; - if (this.deps.ruleRegistry) { - try { - ruleSuggestions = this.deps.ruleRegistry.getPendingSuggestions(); - } catch (err) { - logger.warn(`Failed to load rule suggestions: ${err instanceof Error ? err.message : String(err)}`); - } + try { + const state = loadProjectState(this.deps.projectStateFile); + const beltResult = this.deps.beltCalculator.computeAndStore(this.deps.projectStateFile, state); + const beltAdvanceMessage = buildBeltAdvancementMessage(beltResult); + if (beltAdvanceMessage) { + logger.info(beltAdvanceMessage); } + return beltResult; + } catch (err) { + logger.warn(`Belt computation failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } - // 8. Capture cooldown learnings (non-critical — errors should not abort) - const learningsCaptured = this.captureCooldownLearnings(report); + private computeOptionalAgentConfidence(): void { + const agentConfidenceCalculator = this.deps.agentConfidenceCalculator ?? this.deps.katakaConfidenceCalculator; + const agentDir = this.deps.agentDir ?? this.deps.katakaDir; + if (!agentConfidenceCalculator || !agentDir) return; - // 8a. Match cycle predictions to outcomes (non-critical) - this.runPredictionMatching(cycle); + try { + const registry = new KataAgentRegistry(agentDir); + for (const agent of registry.list()) { + agentConfidenceCalculator.compute(agent.id, agent.name); + } + } catch (err) { + logger.warn(`Agent confidence computation failed: ${err instanceof Error ? err.message : String(err)}`); + } + } - // 8a.5. Detect systematic prediction biases (non-critical, runs after prediction matching) - this.runCalibrationDetection(cycle); + private writeRunDiary(input: { + cycleId: string; + cycleName?: string; + cycle: Cycle; + betOutcomes: BetOutcomeRecord[]; + proposals: CycleProposal[]; + runSummaries?: RunSummary[]; + learningsCaptured: number; + ruleSuggestions?: RuleSuggestion[]; + humanPerspective?: string; + }): void { + if (!shouldWriteDojoDiary(this.deps.dojoDir)) return; + + this.writeDiaryEntry({ + cycleId: input.cycleId, + cycleName: input.cycleName, + betOutcomes: this.enrichBetOutcomesWithDescriptions(input.cycle, input.betOutcomes), + proposals: input.proposals, + runSummaries: input.runSummaries, + learningsCaptured: input.learningsCaptured, + ruleSuggestions: input.ruleSuggestions, + humanPerspective: input.humanPerspective, + }); + } - // 8b. Bubble step-tier learnings up the hierarchy (non-critical) - this.runHierarchicalPromotion(); + private enrichBetOutcomesWithDescriptions(cycle: Cycle, betOutcomes: BetOutcomeRecord[]): BetOutcomeRecord[] { + const betDescriptionMap = new Map(cycle.bets.map((bet) => [bet.id, bet.description])); + return betOutcomes.map((betOutcome) => ({ + ...betOutcome, + betDescription: betOutcome.betDescription ?? betDescriptionMap.get(betOutcome.betId), + })); + } - // 8c. Scan for expired/stale learnings (non-critical) - this.runExpiryCheck(); + private writeCompleteDiary(input: { + cycleId: string; + cycleName?: string; + cycle: Cycle; + proposals: CycleProposal[]; + runSummaries?: RunSummary[]; + ruleSuggestions?: RuleSuggestion[]; + synthesisProposals?: SynthesisProposal[]; + }): void { + if (!shouldWriteDojoDiary(this.deps.dojoDir)) return; + + this.writeDiaryEntry({ + cycleId: input.cycleId, + cycleName: input.cycleName, + betOutcomes: buildDiaryBetOutcomesFromCycleBets(input.cycle.bets) as BetOutcomeRecord[], + proposals: input.proposals, + runSummaries: input.runSummaries, + learningsCaptured: 0, + ruleSuggestions: input.ruleSuggestions, + agentPerspective: buildAgentPerspectiveFromProposals(input.synthesisProposals ?? []), + }); + } - // 8d. Analyze friction observations and resolve contradictions (non-critical) - this.runFrictionAnalysis(cycle); + private writeOptionalDojoSession(cycleId: string, cycleName?: string): void { + if (!shouldWriteDojoSession(this.deps.dojoDir, this.deps.dojoSessionBuilder)) return; + this.writeDojoSession(cycleId, cycleName); + } - // 8e. Compute belt advancement (non-critical) - let beltResult: BeltComputeResult | undefined; - if (this.deps.beltCalculator && this.deps.projectStateFile) { - try { - const state = loadProjectState(this.deps.projectStateFile); - beltResult = this.deps.beltCalculator.computeAndStore(this.deps.projectStateFile, state); - if (beltResult.leveledUp) { - logger.info(`Belt advanced: ${beltResult.previous} → ${beltResult.belt}`); - } - } catch (err) { - logger.warn(`Belt computation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } + private readAppliedSynthesisProposals( + synthesisInputId?: string, + acceptedProposalIds?: readonly string[], + ): SynthesisProposal[] | undefined { + const resultPath = this.resolveSynthesisResultPath(synthesisInputId); + if (!resultPath || !existsSync(resultPath)) return undefined; - // 8f. Compute per-agent confidence profiles (non-critical) - const agentConfidenceCalculator = this.deps.agentConfidenceCalculator ?? this.deps.katakaConfidenceCalculator; - const agentDir = this.deps.agentDir ?? this.deps.katakaDir; - if (agentConfidenceCalculator && agentDir) { - try { - const registry = new KataAgentRegistry(agentDir); - for (const agent of registry.list()) { - agentConfidenceCalculator.compute(agent.id, agent.name); - } - } catch (err) { - logger.warn(`Agent confidence computation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } + try { + const synthesisResult = JsonStore.read(resultPath, SynthesisResultSchema); + return this.applyAcceptedSynthesisProposals(synthesisResult.proposals, acceptedProposalIds); + } catch (err) { + logger.warn(`Failed to read synthesis result for input ${synthesisInputId}: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } - // 8.5. Write dojo diary entry (non-critical — failure never aborts cooldown) - if (this.deps.dojoDir) { - const betDescriptionMap = new Map(cycle.bets.map((b) => [b.id, b.description])); - const enrichedBetOutcomes = effectiveBetOutcomes.map((b) => ({ - ...b, - betDescription: b.betDescription ?? betDescriptionMap.get(b.betId), - })); - this.writeDiaryEntry({ - cycleId, - cycleName: cycle.name, - betOutcomes: enrichedBetOutcomes, - proposals, - runSummaries, - learningsCaptured, - ruleSuggestions, - humanPerspective, - }); - } + private resolveSynthesisResultPath(synthesisInputId?: string): string | undefined { + if (!synthesisInputId || !this.deps.synthesisDir) return undefined; + return join(this.deps.synthesisDir, `result-${synthesisInputId}.json`); + } - // 8.6. Generate dojo session (non-critical — satisfies belt criterion dojoSessionsGenerated) - if (this.deps.dojoDir && this.deps.dojoSessionBuilder) { - this.writeDojoSession(cycleId, cycle.name); + private applyAcceptedSynthesisProposals( + proposals: readonly SynthesisProposal[], + acceptedProposalIds?: readonly string[], + ): SynthesisProposal[] { + const idsToApply = resolveAppliedProposalIds(proposals, acceptedProposalIds); + const appliedProposals: SynthesisProposal[] = []; + + for (const proposal of proposals) { + if (!idsToApply.has(proposal.id)) continue; + if (this.tryApplyProposal(proposal)) { + appliedProposals.push(proposal); } + } - // 8g. Generate LLM-driven next-keiko proposals (non-critical) - const nextKeikoResult = this.runNextKeikoProposals(cycle); + return appliedProposals; + } + + private tryApplyProposal(proposal: SynthesisProposal): boolean { + try { + this.applyProposal(proposal); + return true; + } catch (err) { + logger.warn(`Failed to apply synthesis proposal ${proposal.id} (${proposal.type}): ${err instanceof Error ? err.message : String(err)}`); + return false; + } + } - // 9. Transition to complete + /** + * Run the full cooldown session. + * + * 1. Check for incomplete runs (pending/running) — warn if found; --force bypasses + * 2. Transition cycle state to 'cooldown' + * 3. Record per-bet outcomes (data collection, not interactive -- CLI handles prompts) + * 4. Generate the CooldownReport via CycleManager + * 5. Enrich report with actual token usage from TokenTracker + * 6. Load run summaries when runsDir provided + * 7. Generate next-cycle proposals via ProposalGenerator + * 8. Load pending rule suggestions when ruleRegistry provided + * 9. Capture any learnings from the cooldown analysis + * 10. Transition cycle state to 'complete' + * 11. Return the full session result + * + * @param force When true, skips the incomplete-run guard and proceeds even if runs are in-progress. + */ + async run(cycleId: string, betOutcomes: BetOutcomeRecord[] = [], { force = false, humanPerspective }: { force?: boolean; humanPerspective?: string } = {}): Promise { + const incompleteRuns = this.checkIncompleteRuns(cycleId); + this.warnOnIncompleteRuns(incompleteRuns, force); + const previousState = this.beginCooldown(cycleId); + + try { + const phase = this.buildCooldownPhase(cycleId, betOutcomes); + this.runCooldownFollowUps(phase.cycle); + const beltResult = this.computeOptionalBeltResult(); + this.computeOptionalAgentConfidence(); + this.writeRunDiary({ + cycleId, + cycleName: phase.cycle.name, + cycle: phase.cycle, + betOutcomes: phase.effectiveBetOutcomes, + proposals: phase.proposals, + runSummaries: phase.runSummaries, + learningsCaptured: phase.learningsCaptured, + ruleSuggestions: phase.ruleSuggestions, + humanPerspective, + }); + this.writeOptionalDojoSession(cycleId, phase.cycle.name); + const nextKeikoResult = this.runNextKeikoProposals(phase.cycle); this.deps.cycleManager.updateState(cycleId, 'complete'); return { - report, - betOutcomes: effectiveBetOutcomes, - proposals, - learningsCaptured, - runSummaries, - ruleSuggestions, + report: phase.report, + betOutcomes: phase.effectiveBetOutcomes, + proposals: phase.proposals, + learningsCaptured: phase.learningsCaptured, + runSummaries: phase.runSummaries, + ruleSuggestions: phase.ruleSuggestions, synthesisInputId: undefined, synthesisInputPath: undefined, synthesisProposals: undefined, @@ -433,14 +602,7 @@ export class CooldownSession { nextKeikoResult, }; } catch (error) { - // Attempt to roll back to previous state so the user can retry - try { - this.deps.cycleManager.updateState(cycleId, previousState); - } catch (rollbackError) { - logger.error(`Failed to roll back cycle "${cycleId}" from cooldown to "${previousState}". Manual intervention may be required.`, { - rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - }); - } + this.rollbackCycleState(cycleId, previousState); throw error; } } @@ -457,100 +619,34 @@ export class CooldownSession { * @param force When true, skips the incomplete-run guard and proceeds even if runs are in-progress. */ async prepare(cycleId: string, betOutcomes: BetOutcomeRecord[] = [], depth?: import('@domain/types/synthesis.js').SynthesisDepth, { force = false }: { force?: boolean } = {}): Promise { - // Check for incomplete runs before any state mutation const incompleteRuns = this.checkIncompleteRuns(cycleId); - if (incompleteRuns.length > 0 && !force) { - logger.warn( - `Warning: ${incompleteRuns.length} run(s) are still in progress. Cooldown data may be incomplete. Proceeding — use --force to suppress this warning.`, - ); - } - - const previousState = this.deps.cycleManager.get(cycleId).state; - - // 1. Transition to cooldown state - this.deps.cycleManager.updateState(cycleId, 'cooldown'); + this.warnOnIncompleteRuns(incompleteRuns, force); + const previousState = this.beginCooldown(cycleId); try { - // 2. Auto-sync pending bet outcomes from bridge-run metadata (non-critical) - this.autoSyncBetOutcomesFromBridgeRuns(cycleId); - - // 3. Record explicit bet outcomes (override auto-sync) - if (betOutcomes.length > 0) { - this.recordBetOutcomes(cycleId, betOutcomes); - } - - // 4. Generate the base cooldown report - let report = this.deps.cycleManager.generateCooldown(cycleId); - - // 5. Enrich with actual token usage - report = this.enrichReportWithTokens(report, cycleId); - - // 6. Load run summaries when runsDir provided - const cycle = this.deps.cycleManager.get(cycleId); - const runSummaries = this.deps.runsDir - ? this.loadRunSummaries(cycle) - : undefined; - - // 7. Generate next-cycle proposals - const proposals = this.proposalGenerator.generate(cycleId, runSummaries); - - // 7. Load pending rule suggestions - let ruleSuggestions: RuleSuggestion[] | undefined; - if (this.deps.ruleRegistry) { - try { - ruleSuggestions = this.deps.ruleRegistry.getPendingSuggestions(); - } catch (err) { - logger.warn(`Failed to load rule suggestions: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 8. Capture cooldown learnings - const learningsCaptured = this.captureCooldownLearnings(report); - - // 8a. Match cycle predictions to outcomes (non-critical) - this.runPredictionMatching(cycle); - - // 8a.5. Detect systematic prediction biases (non-critical, runs after prediction matching) - this.runCalibrationDetection(cycle); - - // 8b. Bubble step-tier learnings up the hierarchy (non-critical) - this.runHierarchicalPromotion(); - - // 8c. Scan for expired/stale learnings (non-critical) - this.runExpiryCheck(); - - // 8d. Analyze friction observations and resolve contradictions (non-critical) - this.runFrictionAnalysis(cycle); - - // 9. Read ALL observations from .kata/runs/ for this cycle and write SynthesisInput + const phase = this.buildCooldownPhase(cycleId, betOutcomes); + this.runCooldownFollowUps(phase.cycle); const effectiveDepth = depth ?? this.deps.synthesisDepth ?? 'standard'; const { synthesisInputId, synthesisInputPath } = this.writeSynthesisInput( cycleId, - cycle, - report, + phase.cycle, + phase.report, effectiveDepth, ); return { - report, + report: phase.report, betOutcomes, - proposals, - learningsCaptured, - runSummaries, - ruleSuggestions, + proposals: phase.proposals, + learningsCaptured: phase.learningsCaptured, + runSummaries: phase.runSummaries, + ruleSuggestions: phase.ruleSuggestions, synthesisInputId, synthesisInputPath, incompleteRuns: this.deps.runsDir ? incompleteRuns : undefined, }; } catch (error) { - // Attempt to roll back to previous state so the user can retry - try { - this.deps.cycleManager.updateState(cycleId, previousState); - } catch (rollbackError) { - logger.error(`Failed to roll back cycle "${cycleId}" from cooldown to "${previousState}". Manual intervention may be required.`, { - rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - }); - } + this.rollbackCycleState(cycleId, previousState); throw error; } } @@ -570,107 +666,25 @@ export class CooldownSession { acceptedProposalIds?: string[], ): Promise { const cycle = this.deps.cycleManager.get(cycleId); - - // Reconstruct basic result data (report may have already been prepared) - let report = this.deps.cycleManager.generateCooldown(cycleId); - report = this.enrichReportWithTokens(report, cycleId); - - const runSummaries = this.deps.runsDir ? this.loadRunSummaries(cycle) : undefined; + const report = this.buildCooldownReport(cycleId); + const runSummaries = this.maybeLoadRunSummaries(cycle); const proposals = this.proposalGenerator.generate(cycleId, runSummaries); - - let ruleSuggestions: RuleSuggestion[] | undefined; - if (this.deps.ruleRegistry) { - try { - ruleSuggestions = this.deps.ruleRegistry.getPendingSuggestions(); - } catch (err) { - logger.warn(`Failed to load rule suggestions: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // Read and apply synthesis proposals if synthesisInputId provided - let synthesisProposals: SynthesisProposal[] | undefined; - if (synthesisInputId && this.deps.synthesisDir) { - const resultPath = join(this.deps.synthesisDir, `result-${synthesisInputId}.json`); - if (existsSync(resultPath)) { - try { - const synthesisResult = JsonStore.read(resultPath, SynthesisResultSchema); - const idsToApply = acceptedProposalIds - ? new Set(acceptedProposalIds) - : new Set(synthesisResult.proposals.map((p) => p.id)); - - const appliedProposals: SynthesisProposal[] = []; - for (const proposal of synthesisResult.proposals) { - if (!idsToApply.has(proposal.id)) continue; - try { - this.applyProposal(proposal); - appliedProposals.push(proposal); - } catch (err) { - logger.warn(`Failed to apply synthesis proposal ${proposal.id} (${proposal.type}): ${err instanceof Error ? err.message : String(err)}`); - } - } - synthesisProposals = appliedProposals; - } catch (err) { - logger.warn(`Failed to read synthesis result for input ${synthesisInputId}: ${err instanceof Error ? err.message : String(err)}`); - } - } - } - - // Write dojo diary entry (non-critical) - if (this.deps.dojoDir) { - const effectiveBetOutcomes: BetOutcomeRecord[] = cycle.bets - .filter((b) => b.outcome !== 'pending') - .map((b) => ({ betId: b.id, outcome: b.outcome as BetOutcomeRecord['outcome'], notes: b.outcomeNotes, betDescription: b.description })); - this.writeDiaryEntry({ - cycleId, - cycleName: cycle.name, - betOutcomes: effectiveBetOutcomes, - proposals, - runSummaries, - learningsCaptured: 0, - ruleSuggestions, - agentPerspective: synthesisProposals && synthesisProposals.length > 0 - ? CooldownSession.buildAgentPerspectiveFromProposals(synthesisProposals) - : undefined, - }); - } - - // Generate dojo session (non-critical — satisfies belt criterion dojoSessionsGenerated) - if (this.deps.dojoDir && this.deps.dojoSessionBuilder) { - this.writeDojoSession(cycleId, cycle.name); - } - - // 8e. Compute belt advancement (non-critical) - let beltResult: BeltComputeResult | undefined; - if (this.deps.beltCalculator && this.deps.projectStateFile) { - try { - const state = loadProjectState(this.deps.projectStateFile); - beltResult = this.deps.beltCalculator.computeAndStore(this.deps.projectStateFile, state); - if (beltResult.leveledUp) { - logger.info(`Belt advanced: ${beltResult.previous} → ${beltResult.belt}`); - } - } catch (err) { - logger.warn(`Belt computation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 8f. Compute per-agent confidence profiles (non-critical) - const agentConfidenceCalculator = this.deps.agentConfidenceCalculator ?? this.deps.katakaConfidenceCalculator; - const agentDir = this.deps.agentDir ?? this.deps.katakaDir; - if (agentConfidenceCalculator && agentDir) { - try { - const registry = new KataAgentRegistry(agentDir); - for (const agent of registry.list()) { - agentConfidenceCalculator.compute(agent.id, agent.name); - } - } catch (err) { - logger.warn(`Agent confidence computation failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // 8g. Generate LLM-driven next-keiko proposals (non-critical) + const ruleSuggestions = this.loadRuleSuggestions(); + const synthesisProposals = this.readAppliedSynthesisProposals(synthesisInputId, acceptedProposalIds); + this.writeCompleteDiary({ + cycleId, + cycleName: cycle.name, + cycle, + proposals, + runSummaries, + ruleSuggestions, + synthesisProposals, + }); + this.writeOptionalDojoSession(cycleId, cycle.name); + const beltResult = this.computeOptionalBeltResult(); + this.computeOptionalAgentConfidence(); const nextKeikoResult = this.runNextKeikoProposals(cycle); - // Transition to complete this.deps.cycleManager.updateState(cycleId, 'complete'); return { @@ -704,7 +718,7 @@ export class CooldownSession { case 'update-learning': { const existing = this.deps.knowledgeStore.get(proposal.targetLearningId); - const newConfidence = Math.min(1, Math.max(0, existing.confidence + proposal.confidenceDelta)); + const newConfidence = clampConfidenceWithDelta(existing.confidence, proposal.confidenceDelta); this.deps.knowledgeStore.update(proposal.targetLearningId, { content: proposal.proposedContent, confidence: newConfidence, @@ -745,90 +759,105 @@ export class CooldownSession { report: CooldownReport, depth: import('@domain/types/synthesis.js').SynthesisDepth, ): { synthesisInputId: string; synthesisInputPath: string } { - const synthesisDir = this.deps.synthesisDir; - if (!synthesisDir) { - // No synthesisDir configured — skip writing, return empty sentinel - const id = crypto.randomUUID(); - return { synthesisInputId: id, synthesisInputPath: '' }; + const target = this.createSynthesisTarget(); + if (!target.synthesisDir) { + return { synthesisInputId: target.id, synthesisInputPath: '' }; } + const synthesisInput = buildSynthesisInputRecord({ + id: target.id, + cycleId, + createdAt: new Date().toISOString(), + depth, + observations: this.collectSynthesisObservations(cycleId, cycle), + learnings: this.loadSynthesisLearnings(), + cycleName: cycle.name, + tokenBudget: report.budget.tokenBudget, + tokensUsed: report.tokensUsed, + }); + + this.cleanupStaleSynthesisInputs(target.synthesisDir, cycleId); + JsonStore.write(target.filePath, synthesisInput, SynthesisInputSchema); + + return { synthesisInputId: target.id, synthesisInputPath: target.filePath }; + } + + private createSynthesisTarget(): { id: string; synthesisDir?: string; filePath: string } { const id = crypto.randomUUID(); + const synthesisDir = this.deps.synthesisDir; + const filePath = synthesisDir ? join(synthesisDir, `pending-${id}.json`) : ''; + return { id, synthesisDir, filePath }; + } + + private collectSynthesisObservations(cycleId: string, cycle: Cycle): Observation[] { const observations: Observation[] = []; + if (!this.deps.runsDir) return observations; - // Build a betId → runId map from bridge-run files (fallback for missing bet.runId). - // Only loaded when bridgeRunsDir is configured — lazy, O(n bridge-runs) at most once. const bridgeRunIdByBetId = this.deps.bridgeRunsDir ? this.loadBridgeRunIdsByBetId(cycleId, this.deps.bridgeRunsDir) : new Map(); - // Collect all observations across every level for each bet - if (this.deps.runsDir) { - for (const bet of cycle.bets) { - // Prefer bet.runId (forward link set by prepare); fall back to bridge-run lookup - const runId = bet.runId ?? bridgeRunIdByBetId.get(bet.id); - if (!runId) continue; - try { - // Read run.json to get stageSequence for full-tree observation scan - let stageSequence: import('@domain/types/stage.js').StageCategory[] = []; - try { - const run = readRun(this.deps.runsDir, runId); - stageSequence = run.stageSequence; - } catch { - // run.json not found — fall back to run-level only - } - const runObs = readAllObservationsForRun(this.deps.runsDir, runId, stageSequence); - observations.push(...runObs); - } catch (err) { - logger.warn(`Failed to read observations for run ${runId} (bet ${bet.id}): ${err instanceof Error ? err.message : String(err)}`); - } + for (const bet of cycle.bets) { + const runId = bet.runId ?? bridgeRunIdByBetId.get(bet.id); + if (!runId) continue; + + const runObs = this.readObservationsForRun(runId, bet.id); + if (runObs.length > 0) { + observations.push(...runObs); } } - // Load current active learnings from KnowledgeStore - let learnings: import('@domain/types/learning.js').Learning[] = []; + return observations; + } + + private readObservationsForRun(runId: string, betId: string): Observation[] { try { - learnings = this.deps.knowledgeStore.query({}); + const stageSequence = this.readStageSequence(runId); + return readAllObservationsForRun(this.deps.runsDir!, runId, stageSequence); } catch (err) { - logger.warn(`Failed to query learnings for synthesis input: ${err instanceof Error ? err.message : String(err)}`); + logger.warn(`Failed to read observations for run ${runId} (bet ${betId}): ${err instanceof Error ? err.message : String(err)}`); + return []; } + } - const synthesisInput: SynthesisInput = { - id, - cycleId, - createdAt: new Date().toISOString(), - depth, - observations, - learnings, - cycleName: cycle.name, - tokenBudget: report.budget.tokenBudget, - tokensUsed: report.tokensUsed, - }; + private readStageSequence(runId: string): import('@domain/types/stage.js').StageCategory[] { + try { + return readRun(this.deps.runsDir!, runId).stageSequence; + } catch { + return []; + } + } + + private loadSynthesisLearnings(): import('@domain/types/learning.js').Learning[] { + try { + return this.deps.knowledgeStore.query({}); + } catch (err) { + logger.warn(`Failed to query learnings for synthesis input: ${err instanceof Error ? err.message : String(err)}`); + return []; + } + } - // Clean up stale pending synthesis files for the same cycleId (#330). - // Running --prepare multiple times would otherwise accumulate files and - // confuse `cooldown complete` about which is current. + private cleanupStaleSynthesisInputs(synthesisDir: string, cycleId: string): void { try { - const existing = readdirSync(synthesisDir).filter((f) => f.startsWith('pending-') && f.endsWith('.json')); + const existing = readdirSync(synthesisDir).filter((file) => file.startsWith('pending-') && file.endsWith('.json')); for (const file of existing) { - try { - const raw = readFileSync(join(synthesisDir, file), 'utf-8'); - const meta = JSON.parse(raw) as { cycleId?: string }; - if (meta.cycleId === cycleId) { - unlinkSync(join(synthesisDir, file)); - logger.debug(`Removed stale synthesis input file: ${file}`); - } - } catch { - // Skip unreadable / already-deleted files - } + this.removeStaleSynthesisInputFile(synthesisDir, file, cycleId); } } catch { // Non-critical — if cleanup fails, still write the new file } + } - const filePath = join(synthesisDir, `pending-${id}.json`); - JsonStore.write(filePath, synthesisInput, SynthesisInputSchema); - - return { synthesisInputId: id, synthesisInputPath: filePath }; + private removeStaleSynthesisInputFile(synthesisDir: string, file: string, cycleId: string): void { + try { + const raw = readFileSync(join(synthesisDir, file), 'utf-8'); + const meta = JSON.parse(raw) as { cycleId?: string }; + if (meta.cycleId !== cycleId) return; + unlinkSync(join(synthesisDir, file)); + logger.debug(`Removed stale synthesis input file: ${file}`); + } catch { + // Skip unreadable / already-deleted files + } } /** @@ -844,26 +873,30 @@ export class CooldownSession { const result = new Map(); if (!existsSync(bridgeRunsDir)) return result; - let files: string[]; + 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; + } + + private listJsonFiles(dir: string): string[] { try { - files = readdirSync(bridgeRunsDir).filter((f) => f.endsWith('.json')); + return readdirSync(dir).filter((file) => file.endsWith('.json')); } catch { - return result; + return []; } + } - for (const file of files) { - try { - const raw = readFileSync(join(bridgeRunsDir, file), 'utf-8'); - const meta = JSON.parse(raw) as { cycleId?: string; betId?: string; runId?: string }; - if (meta.cycleId === cycleId && meta.betId && meta.runId) { - result.set(meta.betId, meta.runId); - } - } catch { - // Skip unreadable / invalid bridge-run files - } + private readBridgeRunMeta(filePath: string): { cycleId?: string; betId?: string; runId?: string; status?: string } | undefined { + try { + return JSON.parse(readFileSync(filePath, 'utf-8')) as { cycleId?: string; betId?: string; runId?: string; status?: string }; + } catch { + return undefined; } - - return result; } /** @@ -881,23 +914,14 @@ export class CooldownSession { if (!bridgeRunsDir) return []; const cycle = this.deps.cycleManager.get(cycleId); - const toSync: Array<{ betId: string; outcome: 'complete' | 'partial'; notes?: string }> = []; + const toSync: BetOutcomeRecord[] = []; for (const bet of cycle.bets) { - if (bet.outcome !== 'pending') continue; // already resolved — don't overwrite - if (!bet.runId) continue; + if (bet.outcome !== 'pending' || !bet.runId) continue; - const bridgeRunPath = join(bridgeRunsDir, `${bet.runId}.json`); - try { - if (!existsSync(bridgeRunPath)) continue; - const raw = JSON.parse(readFileSync(bridgeRunPath, 'utf-8')) as { status?: string }; - if (raw.status === 'complete') { - toSync.push({ betId: bet.id, outcome: 'complete' }); - } else if (raw.status === 'failed') { - toSync.push({ betId: bet.id, outcome: 'partial' }); - } - } catch { - // Non-critical — skip this bet silently + const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId); + if (outcome) { + toSync.push({ betId: bet.id, outcome }); } } @@ -908,6 +932,16 @@ export class CooldownSession { return toSync; } + private readBridgeRunOutcome( + bridgeRunsDir: string, + 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); + } + /** * Apply bet outcomes to the cycle via CycleManager. * Logs a warning for any unmatched bet IDs. @@ -934,24 +968,11 @@ export class CooldownSession { const tokensUsed = cycleTokens; // Recalculate utilization - const tokenBudget = report.budget.tokenBudget; - const utilizationPercent = tokenBudget && tokenBudget > 0 - ? (tokensUsed / tokenBudget) * 100 - : 0; - - // Determine alert level - let alertLevel: BudgetAlertLevel | undefined = report.alertLevel; - if (tokenBudget) { - if (utilizationPercent >= 100) { - alertLevel = 'critical'; - } else if (utilizationPercent >= 90) { - alertLevel = 'warning'; - } else if (utilizationPercent >= 75) { - alertLevel = 'info'; - } else { - alertLevel = undefined; - } - } + const { utilizationPercent, alertLevel } = buildCooldownBudgetUsage( + report.budget.tokenBudget, + tokensUsed, + report.alertLevel, + ); return { ...report, @@ -966,73 +987,50 @@ export class CooldownSession { * Creates a learning if the cycle had interesting patterns. */ private captureCooldownLearnings(report: CooldownReport): number { + const attempts = this.captureCooldownLearningDrafts(report); + if (attempts.failed > 0) { + logger.warn(`${attempts.failed} of ${attempts.captured + attempts.failed} cooldown learnings failed to capture. Check previous warnings for details.`); + } + + return attempts.captured; + } + + private captureCooldownLearningDrafts(report: CooldownReport): { captured: number; failed: number } { let captured = 0; let failed = 0; + const recordedAt = new Date().toISOString(); + const drafts = buildCooldownLearningDrafts({ + cycleId: report.cycleId, + cycleName: report.cycleName, + completionRate: report.completionRate, + betCount: report.bets.length, + tokenBudget: report.budget.tokenBudget, + utilizationPercent: report.utilizationPercent, + tokensUsed: report.tokensUsed, + }); - // Capture a learning if completion rate is notably low - if (report.bets.length > 0 && report.completionRate < 50) { - if (this.safeCaptureLearning({ + for (const draft of drafts) { + const capturedDraft = this.safeCaptureLearning({ tier: 'category', - category: 'cycle-management', - content: `Cycle "${report.cycleName ?? report.cycleId}" had low completion rate (${report.completionRate.toFixed(1)}%). Consider reducing scope or breaking bets into smaller chunks.`, - confidence: 0.6, + category: draft.category, + content: draft.content, + confidence: draft.confidence, evidence: [{ pipelineId: report.cycleId, stageType: 'cooldown', - observation: `${report.bets.length} bets, ${report.completionRate.toFixed(1)}% completion`, - recordedAt: new Date().toISOString(), + observation: draft.observation, + recordedAt, }], - })) { + }); + + if (capturedDraft) { captured++; } else { failed++; } } - // Capture a learning if budget was significantly over/under-utilized - if (report.budget.tokenBudget) { - if (report.utilizationPercent > 100) { - if (this.safeCaptureLearning({ - tier: 'category', - category: 'budget-management', - content: `Cycle "${report.cycleName ?? report.cycleId}" exceeded token budget (${report.utilizationPercent.toFixed(1)}% utilization). Consider more conservative estimates.`, - confidence: 0.7, - evidence: [{ - pipelineId: report.cycleId, - stageType: 'cooldown', - observation: `${report.tokensUsed} tokens used of ${report.budget.tokenBudget} budget`, - recordedAt: new Date().toISOString(), - }], - })) { - captured++; - } else { - failed++; - } - } else if (report.utilizationPercent < 30 && report.bets.length > 0) { - if (this.safeCaptureLearning({ - tier: 'category', - category: 'budget-management', - content: `Cycle "${report.cycleName ?? report.cycleId}" significantly under-utilized token budget (${report.utilizationPercent.toFixed(1)}%). Could have taken on more work.`, - confidence: 0.5, - evidence: [{ - pipelineId: report.cycleId, - stageType: 'cooldown', - observation: `${report.tokensUsed} tokens used of ${report.budget.tokenBudget} budget`, - recordedAt: new Date().toISOString(), - }], - })) { - captured++; - } else { - failed++; - } - } - } - - if (failed > 0) { - logger.warn(`${failed} of ${captured + failed} cooldown learnings failed to capture. Check previous warnings for details.`); - } - - return captured; + return { captured, failed }; } private safeCaptureLearning(params: Parameters[0]): boolean { @@ -1059,40 +1057,48 @@ export class CooldownSession { for (const bet of cycle.bets) { if (!bet.runId) continue; - - // Prefer bridge-run metadata — it's updated by execute complete / kiai complete, unlike run.json - if (this.deps.bridgeRunsDir) { - const bridgeRunPath = join(this.deps.bridgeRunsDir, `${bet.runId}.json`); - try { - if (existsSync(bridgeRunPath)) { - const raw = JSON.parse(readFileSync(bridgeRunPath, 'utf-8')) as { status?: string }; - // 'in-progress' = still running; 'complete'/'failed' = done - if (raw.status === 'in-progress') { - incomplete.push({ runId: bet.runId, betId: bet.id, status: 'running' }); - } - continue; // bridge-run file found — don't fall through to run.json - } - } catch { - // fall through to run.json check - } - } - - // Fall back to run.json only when bridge-run file is absent - if (this.deps.runsDir) { - try { - const run = readRun(this.deps.runsDir, bet.runId); - if (run.status === 'pending' || run.status === 'running') { - incomplete.push({ runId: bet.runId, betId: bet.id, status: run.status }); - } - } catch { - // Run file missing or invalid — skip silently (run may not have started yet) - } + const status = this.resolveIncompleteRunStatus(bet.id, bet.runId); + if (status) { + incomplete.push({ runId: bet.runId, betId: bet.id, status }); } } return incomplete; } + private resolveIncompleteRunStatus( + betId: string, + runId: string, + ): IncompleteRunInfo['status'] | undefined { + const bridgeStatus = this.readIncompleteBridgeRunStatus(runId); + if (bridgeStatus !== undefined) return bridgeStatus ?? undefined; + return this.readIncompleteRunFileStatus(betId, runId); + } + + private readIncompleteBridgeRunStatus(runId: string): IncompleteRunInfo['status'] | null | undefined { + 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; + } + + private readIncompleteRunFileStatus( + _betId: string, + runId: string, + ): IncompleteRunInfo['status'] | undefined { + if (!this.deps.runsDir) return undefined; + + try { + const run = readRun(this.deps.runsDir, runId); + return run.status === 'pending' || run.status === 'running' ? run.status : undefined; + } catch { + return undefined; + } + } + /** * Load per-bet run summaries from .kata/runs/ state files. * Bets without a runId are skipped silently. @@ -1126,7 +1132,7 @@ export class CooldownSession { ExecutionHistoryEntrySchema, { warnOnInvalid: false }, ); - return allEntries.filter((entry) => entry.cycleId === cycleId); + return filterExecutionHistoryForCycle(allEntries, cycleId); } /** @@ -1136,15 +1142,7 @@ export class CooldownSession { */ private runPredictionMatching(cycle: Cycle): void { if (!this.predictionMatcher) return; - - for (const bet of cycle.bets) { - if (!bet.runId) continue; - try { - this.predictionMatcher.match(bet.runId); - } catch (err) { - logger.warn(`Prediction matching failed for run ${bet.runId}: ${err instanceof Error ? err.message : String(err)}`); - } - } + this.runForEachBetRun(cycle, (runId) => this.predictionMatcher!.match(runId), 'Prediction matching'); } /** @@ -1155,14 +1153,29 @@ export class CooldownSession { */ private runCalibrationDetection(cycle: Cycle): void { if (!this.calibrationDetector) return; + this.runForEachBetRun(cycle, (runId) => this.calibrationDetector!.detect(runId), 'Calibration detection'); + } + private runForEachBetRun( + cycle: Cycle, + runner: (runId: string) => void, + label: string, + ): void { for (const bet of cycle.bets) { if (!bet.runId) continue; - try { - this.calibrationDetector.detect(bet.runId); - } catch (err) { - logger.warn(`Calibration detection failed for run ${bet.runId}: ${err instanceof Error ? err.message : String(err)}`); - } + this.runForBetRun(bet.runId, runner, label); + } + } + + private runForBetRun( + runId: string, + runner: (runId: string) => void, + label: string, + ): void { + try { + runner(runId); + } catch (err) { + logger.warn(`${label} failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}`); } } @@ -1188,12 +1201,9 @@ export class CooldownSession { private runExpiryCheck(): void { try { if (typeof this.deps.knowledgeStore.checkExpiry !== 'function') return; - const { archived, flaggedStale } = this.deps.knowledgeStore.checkExpiry(); - if (archived.length > 0) { - logger.debug(`Expiry check: auto-archived ${archived.length} expired operational learnings`); - } - if (flaggedStale.length > 0) { - logger.debug(`Expiry check: flagged ${flaggedStale.length} stale strategic learnings for review`); + const result = this.deps.knowledgeStore.checkExpiry(); + for (const message of buildExpiryCheckMessages(result)) { + logger.debug(message); } } catch (err) { logger.warn(`Learning expiry check failed: ${err instanceof Error ? err.message : String(err)}`); @@ -1207,15 +1217,7 @@ export class CooldownSession { */ private runFrictionAnalysis(cycle: Cycle): void { if (!this.frictionAnalyzer) return; - - for (const bet of cycle.bets) { - if (!bet.runId) continue; - try { - this.frictionAnalyzer.analyze(bet.runId); - } catch (err) { - logger.warn(`Friction analysis failed for run ${bet.runId}: ${err instanceof Error ? err.message : String(err)}`); - } - } + this.runForEachBetRun(cycle, (runId) => this.frictionAnalyzer!.analyze(runId), 'Friction analysis'); } private writeDiaryEntry(input: { @@ -1256,64 +1258,45 @@ export class CooldownSession { */ private writeDojoSession(cycleId: string, cycleName?: string): void { try { - const dojoDir = this.deps.dojoDir!; - const diaryDir = join(dojoDir, 'diary'); - const diaryStore = new DiaryStore(diaryDir); - - const aggregator = new DataAggregator({ - knowledgeStore: this.deps.knowledgeStore as import('@features/dojo/data-aggregator.js').IDojoKnowledgeStore, - diaryStore, - cycleManager: this.deps.cycleManager, - runsDir: this.deps.runsDir ?? join(dojoDir, '..', 'runs'), - }); - - const data = aggregator.gather({ maxDiaries: 5 }); - - const title = cycleName - ? `Cooldown — ${cycleName}` - : `Cooldown — ${cycleId.slice(0, 8)}`; - - this.deps.dojoSessionBuilder!.build(data, { title }); + const request = this.buildDojoSessionRequest(cycleId, cycleName); + const data = this.gatherDojoSessionData(request); + this.deps.dojoSessionBuilder!.build(data, { title: request.title }); } catch (err) { logger.warn(`Failed to generate dojo session: ${err instanceof Error ? err.message : String(err)}`); } } + private buildDojoSessionRequest(cycleId: string, cycleName?: string): { + diaryDir: string; + runsDir: string; + title: string; + } { + return buildDojoSessionBuildRequest({ + dojoDir: this.deps.dojoDir!, + cycleId, + cycleName, + runsDir: this.deps.runsDir, + }); + } + + private gatherDojoSessionData(request: { diaryDir: string; runsDir: string }): ReturnType { + const diaryStore = new DiaryStore(request.diaryDir); + const aggregator = new DataAggregator({ + knowledgeStore: this.deps.knowledgeStore as import('@features/dojo/data-aggregator.js').IDojoKnowledgeStore, + diaryStore, + cycleManager: this.deps.cycleManager, + runsDir: request.runsDir, + }); + + return aggregator.gather({ maxDiaries: 5 }); + } + /** * Build a text summary of synthesis proposals for use as the diary's agentPerspective. * Returns undefined when there are no proposals. */ static buildAgentPerspectiveFromProposals(proposals: SynthesisProposal[]): string | undefined { - if (proposals.length === 0) return undefined; - - const lines: string[] = ['## Agent Perspective (Synthesis)']; - lines.push(''); - - for (const p of proposals) { - switch (p.type) { - case 'new-learning': - lines.push(`**New learning** [${p.proposedTier}/${p.proposedCategory}] (confidence: ${p.confidence.toFixed(2)}):`); - lines.push(` ${p.proposedContent}`); - break; - case 'update-learning': - lines.push(`**Updated learning** (confidence delta: ${p.confidenceDelta > 0 ? '+' : ''}${p.confidenceDelta.toFixed(2)}):`); - lines.push(` ${p.proposedContent}`); - break; - case 'promote': - lines.push(`**Promoted learning** to ${p.toTier} tier.`); - break; - case 'archive': - lines.push(`**Archived learning**: ${p.reason}`); - break; - case 'methodology-recommendation': - lines.push(`**Methodology recommendation** (${p.area}):`); - lines.push(` ${p.recommendation}`); - break; - } - lines.push(''); - } - - return lines.join('\n').trimEnd(); + return buildAgentPerspectiveFromProposals(proposals); } /** @@ -1325,17 +1308,7 @@ export class CooldownSession { if (!this._nextKeikoProposalGenerator || !this.deps.runsDir) return undefined; try { - const completedBets = cycle.bets - .filter((b) => b.outcome === 'complete' || b.outcome === 'partial') - .map((b) => b.description); - - return this._nextKeikoProposalGenerator.generate({ - cycle, - runsDir: this.deps.runsDir, - bridgeRunsDir: this.deps.bridgeRunsDir, - milestoneName: this.deps.nextKeikoMilestoneName, - completedBets, - }); + return this.generateNextKeikoProposals(cycle); } catch (err) { logger.warn( `Next-keiko proposal generation failed: ${err instanceof Error ? err.message : String(err)}`, @@ -1343,4 +1316,14 @@ export class CooldownSession { return undefined; } } + + private generateNextKeikoProposals(cycle: Cycle): NextKeikoResult { + return this._nextKeikoProposalGenerator!.generate({ + cycle, + runsDir: this.deps.runsDir!, + bridgeRunsDir: this.deps.bridgeRunsDir, + milestoneName: this.deps.nextKeikoMilestoneName, + completedBets: listCompletedBetDescriptions(cycle.bets), + }); + } } diff --git a/src/features/cycle-management/cooldown-session.unit.test.ts b/src/features/cycle-management/cooldown-session.unit.test.ts new file mode 100644 index 0000000..96b769a --- /dev/null +++ b/src/features/cycle-management/cooldown-session.unit.test.ts @@ -0,0 +1,540 @@ +import { join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { CycleManager } from '@domain/services/cycle-manager.js'; +import { ProjectStateSchema } from '@domain/types/belt.js'; +import type { Run, StageState } from '@domain/types/run-state.js'; +import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; +import { JsonStore } from '@infra/persistence/json-store.js'; +import { appendObservation, createRunTree, writeStageState } from '@infra/persistence/run-store.js'; +import { KataAgentRegistry } from '@infra/registries/kata-agent-registry.js'; +import { SynthesisResultSchema } from '@domain/types/synthesis.js'; +import { logger } from '@shared/lib/logger.js'; +import { + CooldownSession, + type CooldownSessionDeps, +} from './cooldown-session.js'; + +function createFixture() { + const baseDir = join(tmpdir(), `kata-cooldown-unit-${randomUUID()}`); + const cyclesDir = join(baseDir, 'cycles'); + const knowledgeDir = join(baseDir, 'knowledge'); + const pipelineDir = join(baseDir, 'pipelines'); + const historyDir = join(baseDir, 'history'); + const runsDir = join(baseDir, 'runs'); + const bridgeRunsDir = join(baseDir, 'bridge-runs'); + const synthesisDir = join(baseDir, 'synthesis'); + const dojoDir = join(baseDir, 'dojo'); + const agentDir = join(baseDir, 'agents'); + const projectStateFile = join(baseDir, 'project-state.json'); + + for (const dir of [cyclesDir, knowledgeDir, pipelineDir, historyDir, runsDir, bridgeRunsDir, synthesisDir, dojoDir, agentDir]) { + mkdirSync(dir, { recursive: true }); + } + + const cycleManager = new CycleManager(cyclesDir, JsonStore); + const knowledgeStore = new KnowledgeStore(knowledgeDir); + + const baseDeps: CooldownSessionDeps = { + cycleManager, + knowledgeStore, + persistence: JsonStore, + pipelineDir, + historyDir, + runsDir, + bridgeRunsDir, + synthesisDir, + }; + + return { + baseDir, + cycleManager, + knowledgeStore, + pipelineDir, + historyDir, + runsDir, + bridgeRunsDir, + synthesisDir, + dojoDir, + agentDir, + projectStateFile, + baseDeps, + cleanup() { + rmSync(baseDir, { recursive: true, force: true }); + }, + }; +} + +function makeRun(cycleId: string, betId: string, status: Run['status'] = 'completed'): Run { + return { + id: randomUUID(), + cycleId, + betId, + betPrompt: 'Unit test bet', + stageSequence: ['build'], + currentStage: null, + status, + startedAt: new Date().toISOString(), + }; +} + +function makeStageState(overrides: Partial = {}): StageState { + return { + category: 'build', + status: 'completed', + selectedFlavors: [], + gaps: [], + decisions: [], + approvedGates: [], + ...overrides, + }; +} + +function writeBridgeRun( + bridgeRunsDir: string, + runId: string, + data: { cycleId?: string; betId?: string; status?: string }, +): void { + writeFileSync(join(bridgeRunsDir, `${runId}.json`), JSON.stringify({ + runId, + ...data, + })); +} + +function writeProjectState(projectStateFile: string): void { + JsonStore.write(projectStateFile, { + currentBelt: 'mukyu', + synthesisAppliedCount: 0, + gapsClosedCount: 0, + ranWithYolo: false, + discovery: { + ranFirstExecution: false, + completedFirstCycleCooldown: false, + savedKataSequence: false, + createdCustomStepOrFlavor: false, + launchedConfig: false, + launchedWatch: false, + launchedDojo: false, + }, + checkHistory: [], + }, ProjectStateSchema); +} + +describe('CooldownSession unit seams', () => { + it('runs bounded optional cooldown workflows and records low-completion learnings', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 2_000 }, 'Bounded Run'); + let updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Bridge-backed bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Still pending bet', + appetite: 20, + outcome: 'pending', + issueRefs: [], + }); + updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Another pending bet', + appetite: 20, + outcome: 'pending', + issueRefs: [], + }); + + const bridgeBet = updated.bets[0]!; + const run = makeRun(cycle.id, bridgeBet.id); + createRunTree(fixture.runsDir, run); + writeStageState(fixture.runsDir, run.id, makeStageState()); + fixture.cycleManager.setRunId(cycle.id, bridgeBet.id, run.id); + writeBridgeRun(fixture.bridgeRunsDir, run.id, { + cycleId: cycle.id, + betId: bridgeBet.id, + status: 'failed', + }); + + const proposalGenerator = { generate: vi.fn(() => []) }; + const predictionMatcher = { match: vi.fn() }; + const calibrationDetector = { detect: vi.fn() }; + const frictionAnalyzer = { analyze: vi.fn() }; + const hierarchicalPromoter = { + promoteStepToFlavor: vi.fn(() => ({ learnings: [] })), + promoteFlavorToStage: vi.fn(() => ({ learnings: [] })), + promoteStageToCategory: vi.fn(), + }; + const beltCalculator = { + computeAndStore: vi.fn(() => ({ + belt: 'go-kyu' as const, + previous: 'mukyu' as const, + leveledUp: true, + snapshot: {} as never, + })), + }; + const agentConfidenceCalculator = { compute: vi.fn() }; + const dojoSessionBuilder = { build: vi.fn() }; + const nextKeikoProposalGenerator = { + generate: vi.fn(() => ({ + text: 'next keiko', + observationCounts: { friction: 0, gap: 0, insight: 0, total: 0 }, + milestoneIssueCount: 0, + })), + }; + + writeProjectState(fixture.projectStateFile); + const registry = new KataAgentRegistry(fixture.agentDir); + registry.register({ + id: randomUUID(), + name: 'Unit Agent', + role: 'executor', + skills: ['testing'], + createdAt: new Date().toISOString(), + active: true, + }); + + const session = new CooldownSession({ + ...fixture.baseDeps, + dojoDir: fixture.dojoDir, + agentDir: fixture.agentDir, + projectStateFile: fixture.projectStateFile, + proposalGenerator, + predictionMatcher, + calibrationDetector, + frictionAnalyzer, + hierarchicalPromoter, + beltCalculator, + agentConfidenceCalculator, + dojoSessionBuilder, + nextKeikoProposalGenerator, + ruleRegistry: { getPendingSuggestions: vi.fn(() => []) }, + }); + + const result = await session.run(cycle.id); + + expect(result.betOutcomes).toEqual([ + { betId: bridgeBet.id, outcome: 'partial' }, + ]); + expect(result.learningsCaptured).toBeGreaterThanOrEqual(1); + expect(predictionMatcher.match).toHaveBeenCalledWith(run.id); + expect(calibrationDetector.detect).toHaveBeenCalledWith(run.id); + expect(frictionAnalyzer.analyze).toHaveBeenCalledWith(run.id); + expect(hierarchicalPromoter.promoteStepToFlavor).toHaveBeenCalled(); + expect(beltCalculator.computeAndStore).toHaveBeenCalledWith( + fixture.projectStateFile, + expect.objectContaining({ currentBelt: 'mukyu' }), + ); + expect(agentConfidenceCalculator.compute).toHaveBeenCalledTimes(1); + expect(dojoSessionBuilder.build).toHaveBeenCalledTimes(1); + expect(nextKeikoProposalGenerator.generate).toHaveBeenCalledTimes(1); + expect(fixture.cycleManager.get(cycle.id).state).toBe('complete'); + } finally { + fixture.cleanup(); + } + }); + + it('prepare writes synthesis input from bridge-run fallback data and removes stale pending files', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Prepare Fallback'); + const withBet = fixture.cycleManager.addBet(cycle.id, { + description: 'Bridge fallback bet', + appetite: 30, + outcome: 'complete', + issueRefs: [], + }); + const bet = withBet.bets[0]!; + const run = makeRun(cycle.id, bet.id); + + createRunTree(fixture.runsDir, run); + writeStageState(fixture.runsDir, run.id, makeStageState()); + appendObservation(fixture.runsDir, run.id, { + id: randomUUID(), + timestamp: new Date().toISOString(), + type: 'insight', + content: 'Observation collected through the bridge fallback', + }, { level: 'run' }); + writeBridgeRun(fixture.bridgeRunsDir, run.id, { + cycleId: cycle.id, + betId: bet.id, + status: 'complete', + }); + + const stalePath = join(fixture.synthesisDir, `pending-${randomUUID()}.json`); + const otherPath = join(fixture.synthesisDir, `pending-${randomUUID()}.json`); + writeFileSync(stalePath, JSON.stringify({ cycleId: cycle.id })); + writeFileSync(otherPath, JSON.stringify({ cycleId: randomUUID() })); + + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.prepare(cycle.id); + const synthesisInput = JSON.parse(readFileSync(result.synthesisInputPath, 'utf-8')); + + expect(synthesisInput.observations).toHaveLength(1); + expect(synthesisInput.observations[0]!.content).toContain('bridge fallback'); + expect(existsSync(stalePath)).toBe(false); + expect(existsSync(otherPath)).toBe(true); + } finally { + fixture.cleanup(); + } + }); + + it('complete applies only the accepted synthesis proposals', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Complete Apply'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Completed bet', + appetite: 40, + outcome: 'complete', + issueRefs: [], + }); + + const inputId = randomUUID(); + const acceptedProposalId = randomUUID(); + JsonStore.write(join(fixture.synthesisDir, `result-${inputId}.json`), { + inputId, + proposals: [ + { + id: acceptedProposalId, + type: 'new-learning', + confidence: 0.8, + citations: [randomUUID(), randomUUID()], + reasoning: 'High-signal synthesis', + createdAt: new Date().toISOString(), + proposedContent: 'Prefer bounded helpers for cooldown orchestration', + proposedTier: 'category', + proposedCategory: 'cycle-management', + }, + { + id: randomUUID(), + type: 'methodology-recommendation', + confidence: 0.6, + citations: [randomUUID(), randomUUID()], + reasoning: 'Not accepted', + createdAt: new Date().toISOString(), + recommendation: 'Keep the session thin', + area: 'cooldown', + }, + ], + }, SynthesisResultSchema); + + const session = new CooldownSession({ + ...fixture.baseDeps, + dojoDir: fixture.dojoDir, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.complete(cycle.id, inputId, [acceptedProposalId]); + + expect(result.synthesisProposals).toHaveLength(1); + expect(result.synthesisProposals![0]!.id).toBe(acceptedProposalId); + expect(fixture.knowledgeStore.query({}).some((learning) => + learning.content.includes('bounded helpers for cooldown orchestration'), + )).toBe(true); + expect(fixture.cycleManager.get(cycle.id).state).toBe('complete'); + } finally { + fixture.cleanup(); + } + }); + + it('complete covers update, promote, archive, and methodology synthesis proposals', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Complete Variants'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Completed bet', + appetite: 40, + outcome: 'complete', + issueRefs: [], + }); + + const toUpdate = fixture.knowledgeStore.capture({ + tier: 'category', + category: 'cycle-management', + content: 'Old content', + confidence: 0.4, + source: 'user', + }); + const toPromote = fixture.knowledgeStore.capture({ + tier: 'step', + stageType: 'build', + category: 'testing', + content: 'Promote me', + confidence: 0.6, + source: 'user', + }); + const toArchive = fixture.knowledgeStore.capture({ + tier: 'category', + category: 'testing', + content: 'Archive me', + confidence: 0.5, + source: 'user', + }); + + const inputId = randomUUID(); + const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + JsonStore.write(join(fixture.synthesisDir, `result-${inputId}.json`), { + inputId, + proposals: [ + { + id: randomUUID(), + type: 'update-learning', + confidence: 0.8, + citations: [randomUUID(), randomUUID()], + reasoning: 'Update confidence and wording', + createdAt: new Date().toISOString(), + targetLearningId: toUpdate.id, + proposedContent: 'Updated content', + confidenceDelta: 0.3, + }, + { + id: randomUUID(), + type: 'promote', + confidence: 0.7, + citations: [randomUUID(), randomUUID()], + reasoning: 'Promote proven learning', + createdAt: new Date().toISOString(), + targetLearningId: toPromote.id, + fromTier: 'step', + toTier: 'flavor', + }, + { + id: randomUUID(), + type: 'archive', + confidence: 0.6, + citations: [randomUUID(), randomUUID()], + reasoning: 'Archive obsolete learning', + createdAt: new Date().toISOString(), + targetLearningId: toArchive.id, + reason: 'Superseded', + }, + { + id: randomUUID(), + type: 'methodology-recommendation', + confidence: 0.5, + citations: [randomUUID(), randomUUID()], + reasoning: 'Document process guidance', + createdAt: new Date().toISOString(), + recommendation: 'Keep cooldown wrappers thin', + area: 'cooldown', + }, + ], + }, SynthesisResultSchema); + + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.complete(cycle.id, inputId); + + expect(result.synthesisProposals).toHaveLength(4); + expect(fixture.knowledgeStore.get(toUpdate.id)).toEqual(expect.objectContaining({ + content: 'Updated content', + confidence: 0.7, + })); + expect(fixture.knowledgeStore.get(toPromote.id).tier).toBe('flavor'); + expect(fixture.knowledgeStore.query({ includeArchived: true }).find((learning) => learning.id === toArchive.id)?.archived).toBe(true); + expect(infoSpy).toHaveBeenCalledWith('Methodology recommendation (area: cooldown): Keep cooldown wrappers thin'); + } finally { + vi.restoreAllMocks(); + fixture.cleanup(); + } + }); + + it('rolls cycle state back when run fails after cooldown begins', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 1_000 }, 'Rollback Run'); + fixture.cycleManager.updateState(cycle.id, 'active'); + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => { throw new Error('planned failure'); }) }, + }); + + await expect(session.run(cycle.id)).rejects.toThrow('planned failure'); + expect(fixture.cycleManager.get(cycle.id).state).toBe('active'); + } finally { + fixture.cleanup(); + } + }); + + it('warns and continues when agent confidence loading fails', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 1_000 }, 'Confidence Warning'); + const brokenAgentPath = join(fixture.baseDir, 'not-a-directory.json'); + writeFileSync(brokenAgentPath, '{}'); + + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const session = new CooldownSession({ + ...fixture.baseDeps, + agentDir: brokenAgentPath, + agentConfidenceCalculator: { compute: vi.fn() }, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + await session.run(cycle.id); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Agent confidence computation failed:')); + } finally { + vi.restoreAllMocks(); + fixture.cleanup(); + } + }); + + it('checkIncompleteRuns prefers bridge metadata and falls back to run.json status', () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 3_000 }, 'Incomplete Check'); + let updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Bridge running bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Run file pending bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + + const bridgeBet = updated.bets[0]!; + const runBet = updated.bets[1]!; + + const bridgeRun = makeRun(cycle.id, bridgeBet.id, 'completed'); + const pendingRun = makeRun(cycle.id, runBet.id, 'pending'); + createRunTree(fixture.runsDir, bridgeRun); + createRunTree(fixture.runsDir, pendingRun); + fixture.cycleManager.setRunId(cycle.id, bridgeBet.id, bridgeRun.id); + fixture.cycleManager.setRunId(cycle.id, runBet.id, pendingRun.id); + writeBridgeRun(fixture.bridgeRunsDir, bridgeRun.id, { + cycleId: cycle.id, + betId: bridgeBet.id, + status: 'in-progress', + }); + + const session = new CooldownSession(fixture.baseDeps); + + expect(session.checkIncompleteRuns(cycle.id)).toEqual([ + { runId: bridgeRun.id, betId: bridgeBet.id, status: 'running' }, + { runId: pendingRun.id, betId: runBet.id, status: 'pending' }, + ]); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/src/features/execute/workflow-runner.test.ts b/src/features/execute/workflow-runner.test.ts index c29fb30..515ceb7 100644 --- a/src/features/execute/workflow-runner.test.ts +++ b/src/features/execute/workflow-runner.test.ts @@ -14,6 +14,7 @@ import { FlavorNotFoundError, OrchestratorError } from '@shared/lib/errors.js'; import { UsageAnalytics } from '@infra/tracking/usage-analytics.js'; import { MetaOrchestrator } from '@domain/services/meta-orchestrator.js'; import { ExecutionHistoryEntrySchema } from '@domain/types/history.js'; +import { logger } from '@shared/lib/logger.js'; import { WorkflowRunner, type WorkflowRunnerDeps, listRecentArtifacts } from './workflow-runner.js'; // --------------------------------------------------------------------------- @@ -427,15 +428,24 @@ describe('WorkflowRunner', () => { }); it('falls back to filename and unknown when artifact JSON is malformed', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); writeFileSync(join(baseDir, 'artifacts', 'broken.json'), '{ broken json '); - const [artifact] = listRecentArtifacts(baseDir); + try { + const [artifact] = listRecentArtifacts(baseDir); - expect(artifact).toEqual({ - name: 'broken', - timestamp: 'unknown', - file: 'broken.json', - }); + expect(artifact).toEqual({ + name: 'broken', + timestamp: 'unknown', + file: 'broken.json', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'Could not parse artifact file "broken.json" — showing partial info.', + { file: 'broken.json', error: expect.any(String) }, + ); + } finally { + warnSpy.mockRestore(); + } }); }); @@ -622,6 +632,7 @@ describe('WorkflowRunner', () => { }); it('does not crash when analytics fails', async () => { + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}); const analytics = { recordEvent: vi.fn(() => { throw new Error('Disk full'); }), getEvents: vi.fn(() => []), @@ -629,9 +640,109 @@ describe('WorkflowRunner', () => { } as unknown as UsageAnalytics; const deps = makeDeps({ kataDir: baseDir, analytics }); const runner = new WorkflowRunner(deps); - // Should not throw despite analytics failure - const result = await runner.runStage('build'); - expect(result.stageCategory).toBe('build'); + + try { + const result = await runner.runStage('build'); + expect(result.stageCategory).toBe('build'); + expect(debugSpy).toHaveBeenCalledWith( + 'Analytics recordEvent failed — non-fatal, continuing.', + { error: 'Disk full' }, + ); + } finally { + debugSpy.mockRestore(); + } + }); + + it('logs analytics failures for each stage during runPipeline without crashing', async () => { + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}); + const analytics = { + recordEvent: vi.fn(() => { throw new Error('Disk full'); }), + getEvents: vi.fn(() => []), + getStats: vi.fn(), + } as unknown as UsageAnalytics; + const flavors = [ + makeFlavor('research-standard', 'research'), + makeFlavor('build-standard', 'build'), + ]; + const deps: WorkflowRunnerDeps = { + flavorRegistry: makeFlavorRegistry(flavors), + decisionRegistry: makeDecisionRegistry(), + executor: makeExecutor(), + kataDir: baseDir, + analytics, + }; + const runner = new WorkflowRunner(deps); + + try { + const result = await runner.runPipeline(['research', 'build']); + expect(result.stageResults).toHaveLength(2); + expect(debugSpy).toHaveBeenCalledTimes(2); + expect(debugSpy).toHaveBeenNthCalledWith( + 1, + 'Analytics recordEvent failed — non-fatal, continuing.', + { error: 'Disk full' }, + ); + expect(debugSpy).toHaveBeenNthCalledWith( + 2, + 'Analytics recordEvent failed — non-fatal, continuing.', + { error: 'Disk full' }, + ); + } finally { + debugSpy.mockRestore(); + } + }); + + it('logs a warning when stage artifact persistence fails but still returns a stage result', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const deps = makeDeps({ kataDir: baseDir }); + const runner = new WorkflowRunner(deps); + vi.spyOn(runner as never, 'persistArtifact').mockImplementation(() => { + throw new Error('Artifact write exploded'); + }); + + try { + const result = await runner.runStage('build'); + expect(result.stageCategory).toBe('build'); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to persist stage artifact — result is still valid.', + { stageCategory: 'build', error: 'Artifact write exploded' }, + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('logs a warning when pipeline artifact persistence fails but still returns stage results', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const flavors = [ + makeFlavor('research-standard', 'research'), + makeFlavor('build-standard', 'build'), + ]; + const deps: WorkflowRunnerDeps = { + flavorRegistry: makeFlavorRegistry(flavors), + decisionRegistry: makeDecisionRegistry(), + executor: makeExecutor(), + kataDir: baseDir, + }; + const runner = new WorkflowRunner(deps); + vi.spyOn(runner as never, 'persistArtifact').mockImplementation(() => { + throw new Error('Artifact write exploded'); + }); + + try { + const result = await runner.runPipeline(['research', 'build']); + expect(result.stageResults).toHaveLength(2); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to persist stage artifact — result is still valid.', + { stageCategory: 'research', error: 'Artifact write exploded' }, + ); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to persist stage artifact — result is still valid.', + { stageCategory: 'build', error: 'Artifact write exploded' }, + ); + } finally { + warnSpy.mockRestore(); + } }); }); diff --git a/src/infrastructure/execution/session-bridge.test.ts b/src/infrastructure/execution/session-bridge.test.ts index 78cd094..c5795a6 100644 --- a/src/infrastructure/execution/session-bridge.test.ts +++ b/src/infrastructure/execution/session-bridge.test.ts @@ -600,6 +600,28 @@ describe('SessionExecutionBridge', () => { expect(() => bridge.getAgentContext(randomUUID())).toThrow(/No bridge run found/); }); + it.each([ + { success: true as const, terminalState: 'complete' }, + { success: false as const, terminalState: 'failed' }, + ])('should reject dispatch for %s bridge runs', ({ success, terminalState }) => { + const cycle = createCycle(kataDir, { + bets: [{ + id: randomUUID(), + description: 'Terminal bet', + appetite: 15, + outcome: 'pending', + }], + }); + const bridge = new SessionExecutionBridge(kataDir); + const prepared = bridge.prepare(cycle.bets[0]!.id); + + bridge.complete(prepared.runId, { success }); + + expect(() => bridge.getAgentContext(prepared.runId)).toThrow( + `Run "${prepared.runId}" is in terminal state "${terminalState}" and cannot be dispatched.`, + ); + }); + it('agentContext should NOT be present on PreparedRun returned by prepare() (#243)', () => { const cycle = createCycle(kataDir); const bridge = new SessionExecutionBridge(kataDir); @@ -1033,6 +1055,48 @@ describe('SessionExecutionBridge', () => { expect(bridgeRunFiles).toHaveLength(cycle.bets.length); }); + it('should reuse an in-progress bridge run by betId when the cycle bet lost its stored runId', () => { + const cycle = createCycle(kataDir, { state: 'planning' }); + const bridge = new SessionExecutionBridge(kataDir); + + const first = bridge.prepareCycle(cycle.id); + const cyclePath = join(kataDir, 'cycles', `${cycle.id}.json`); + const persistedCycle = CycleSchema.parse(JSON.parse(readFileSync(cyclePath, 'utf-8'))); + delete persistedCycle.bets[0]!.runId; + writeFileSync(cyclePath, JSON.stringify(persistedCycle, null, 2)); + + const second = bridge.prepareCycle(cycle.id); + + expect(second.preparedRuns[0]!.betId).toBe(first.preparedRuns[0]!.betId); + expect(second.preparedRuns[0]!.runId).toBe(first.preparedRuns[0]!.runId); + }); + + it('should create a fresh run when a pending bet only has terminal bridge-run metadata', () => { + const cycle = createCycle(kataDir, { + state: 'planning', + bets: [{ + id: randomUUID(), + description: 'Retryable bet', + appetite: 20, + outcome: 'pending', + }], + }); + const bridge = new SessionExecutionBridge(kataDir); + + const first = bridge.prepareCycle(cycle.id); + bridge.complete(first.preparedRuns[0]!.runId, { success: false, notes: 'retry this one' }); + + const cyclePath = join(kataDir, 'cycles', `${cycle.id}.json`); + const persistedCycle = CycleSchema.parse(JSON.parse(readFileSync(cyclePath, 'utf-8'))); + persistedCycle.bets[0]!.outcome = 'pending'; + persistedCycle.updatedAt = new Date().toISOString(); + writeFileSync(cyclePath, JSON.stringify(persistedCycle, null, 2)); + + const second = bridge.prepareCycle(cycle.id); + + expect(second.preparedRuns[0]!.runId).not.toBe(first.preparedRuns[0]!.runId); + }); + it('should update cycle and bridge metadata names when an active cycle is re-prepared with a new name', () => { const cycle = createCycle(kataDir, { state: 'planning', name: 'Original Name' }); const bridge = new SessionExecutionBridge(kataDir); @@ -1088,6 +1152,7 @@ describe('SessionExecutionBridge', () => { expect(status.bets.length).toBe(2); expect(status.bets[0]!.status).toBe('pending'); expect(status.bets[1]!.status).toBe('pending'); + expect(status.bets.every((bet) => bet.runId === '')).toBe(true); expect(status.budgetUsed).toEqual({ percent: 0, tokenEstimate: 0 }); }); @@ -1377,8 +1442,8 @@ describe('SessionExecutionBridge', () => { const firstMeta = JSON.parse(readFileSync(firstMetaPath, 'utf-8')); const secondMeta = JSON.parse(readFileSync(secondMetaPath, 'utf-8')); - firstMeta.startedAt = new Date(now.getTime() - (2 * 60 * 60_000)).toISOString(); - secondMeta.startedAt = new Date(now.getTime() - (5 * 60_000)).toISOString(); + firstMeta.startedAt = new Date(now.getTime() - (5 * 60_000)).toISOString(); + secondMeta.startedAt = new Date(now.getTime() - (2 * 60 * 60_000)).toISOString(); writeFileSync(firstMetaPath, JSON.stringify(firstMeta, null, 2)); writeFileSync(secondMetaPath, JSON.stringify(secondMeta, null, 2)); @@ -1491,6 +1556,25 @@ describe('SessionExecutionBridge', () => { expect(summary.tokenUsage).toEqual({ inputTokens: 10, outputTokens: 5, total: 15 }); }); + it('should only write new history entries for runs that are still in progress', () => { + const cycle = createCycle(kataDir); + const bridge = new SessionExecutionBridge(kataDir); + const prepared = bridge.prepareCycle(cycle.id); + + bridge.complete(prepared.preparedRuns[0]!.runId, { success: true, notes: 'already done' }); + + const historyDir = join(kataDir, 'history'); + const historyCountBefore = readdirSync(historyDir).filter((file) => file.endsWith('.json')).length; + + bridge.completeCycle(cycle.id, { + [prepared.preparedRuns[1]!.runId]: { success: true, notes: 'complete remaining run' }, + }); + + const historyCountAfter = readdirSync(historyDir).filter((file) => file.endsWith('.json')).length; + expect(historyCountBefore).toBe(1); + expect(historyCountAfter).toBe(2); + }); + it('should update all bet outcomes in cycle JSON after completeCycle() (#216)', () => { // Regression test for #216: kata cooldown showed 0% completion because // bet outcomes were never written to the cycle JSON. diff --git a/src/infrastructure/execution/session-bridge.unit.test.ts b/src/infrastructure/execution/session-bridge.unit.test.ts index b63f279..8b8ba26 100644 --- a/src/infrastructure/execution/session-bridge.unit.test.ts +++ b/src/infrastructure/execution/session-bridge.unit.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CycleSchema } from '@domain/types/cycle.js'; 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'; function createTestDir(): string { @@ -170,6 +171,279 @@ describe('SessionExecutionBridge unit coverage', () => { expect(runJson.katakaId).toBe(agentId); }); + it('warns and preserves cycle state for invalid state transitions', () => { + const cycle = createCycle(kataDir, { state: 'planning' }); + const bridge = new SessionExecutionBridge(kataDir); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + try { + (bridge as unknown as { updateCycleState: (cycleId: string, state: 'complete') => void }).updateCycleState(cycle.id, 'complete'); + + const persisted = CycleSchema.parse(JSON.parse(readFileSync(join(kataDir, 'cycles', `${cycle.id}.json`), 'utf-8'))); + expect(persisted.state).toBe('planning'); + expect(warnSpy).toHaveBeenCalledWith(`Cannot transition cycle "${cycle.id}" from "planning" to "complete".`); + } finally { + warnSpy.mockRestore(); + } + }); + + 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); + expect(canTransitionCycleState('planning', 'complete')).toBe(false); + expect(canTransitionCycleState('active', 'complete')).toBe(false); + expect(canTransitionCycleState('complete', 'planning')).toBe(false); + }); + + it('warns when updateBetOutcomeInCycle cannot find the cycle file', () => { + const bridge = new SessionExecutionBridge(kataDir); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + try { + (bridge as unknown as { + updateBetOutcomeInCycle: (cycleId: string, betId: string, outcome: 'complete') => void; + }).updateBetOutcomeInCycle('missing-cycle', 'bet-1', 'complete'); + + expect(warnSpy).toHaveBeenCalledWith('Cannot update bet outcome: cycle file not found for cycle "missing-cycle".'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('warns when backfillRunIdInCycle cannot find the target bet', () => { + const cycle = createCycle(kataDir); + const bridge = new SessionExecutionBridge(kataDir); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + try { + (bridge as unknown as { + backfillRunIdInCycle: (cycleId: string, betId: string, runId: string) => void; + }).backfillRunIdInCycle(cycle.id, 'missing-bet', 'run-123'); + + expect(warnSpy).toHaveBeenCalledWith(`Cannot backfill bet.runId: bet "missing-bet" not found in cycle "${cycle.id}".`); + } finally { + warnSpy.mockRestore(); + } + }); + + it('refreshes prepared-run metadata only when bet or cycle names change', () => { + const cycle = createCycle(kataDir, { name: 'Renamed Cycle' }); + const bridge = new SessionExecutionBridge(kataDir); + const writeBridgeRunMeta = vi.spyOn(bridge as never, 'writeBridgeRunMeta').mockImplementation(() => {}); + const updateRunJsonAgentAttribution = vi.spyOn(bridge as never, 'updateRunJsonAgentAttribution').mockImplementation(() => {}); + const meta = { + runId: 'run-1', + betId: cycle.bets[0]!.id, + betName: 'Old Bet Name', + cycleId: cycle.id, + cycleName: 'Old Cycle Name', + stages: ['research', 'build'], + isolation: 'shared', + startedAt: '2026-03-15T10:00:00.000Z', + status: 'in-progress', + }; + + const updated = (bridge as unknown as { + refreshPreparedRunMeta: (meta: typeof meta, bet: typeof cycle.bets[number], cycle: typeof cycle, agentId?: string) => typeof meta; + }).refreshPreparedRunMeta(meta, { ...cycle.bets[0]!, description: 'New Bet Name' }, cycle); + + expect(updated.betName).toBe('New Bet Name'); + expect(updated.cycleName).toBe('Renamed Cycle'); + expect(writeBridgeRunMeta).toHaveBeenCalledWith(expect.objectContaining({ + runId: 'run-1', + betName: 'New Bet Name', + cycleName: 'Renamed Cycle', + })); + expect(updateRunJsonAgentAttribution).not.toHaveBeenCalled(); + }); + + it('does not rewrite prepared-run metadata when nothing changed and no agent was added', () => { + const cycle = createCycle(kataDir, { name: 'Stable Cycle' }); + const bridge = new SessionExecutionBridge(kataDir); + const writeBridgeRunMeta = vi.spyOn(bridge as never, 'writeBridgeRunMeta').mockImplementation(() => {}); + const meta = { + runId: 'run-1', + betId: cycle.bets[0]!.id, + betName: cycle.bets[0]!.description, + cycleId: cycle.id, + cycleName: 'Stable Cycle', + stages: ['research', 'build'], + isolation: 'shared', + startedAt: '2026-03-15T10:00:00.000Z', + status: 'in-progress', + }; + + const updated = (bridge as unknown as { + refreshPreparedRunMeta: (meta: typeof meta, bet: typeof cycle.bets[number], cycle: typeof cycle, agentId?: string) => typeof meta; + }).refreshPreparedRunMeta(meta, cycle.bets[0]!, cycle); + + expect(updated).toEqual(meta); + expect(writeBridgeRunMeta).not.toHaveBeenCalled(); + }); + + it('rebuilds prepared runs with manifest metadata and canonical agent attribution fallback', () => { + const bridge = new SessionExecutionBridge(kataDir); + const meta = { + runId: 'run-1', + betId: 'bet-1', + betName: 'Bridge Bet', + cycleId: 'cycle-1', + cycleName: 'Bridge Cycle', + stages: ['research', 'build'], + isolation: 'shared', + startedAt: '2026-03-15T10:00:00.000Z', + status: 'in-progress', + katakaId: 'agent-123', + }; + + const prepared = (bridge as unknown as { + rebuildPreparedRun: (meta: typeof meta) => ReturnType; + }).rebuildPreparedRun(meta); + + expect(prepared.manifest.stageType).toBe('research,build'); + expect(prepared.manifest.prompt).toBe('Execute the bet: "Bridge Bet"'); + expect(prepared.manifest.context.metadata).toMatchObject({ + betId: 'bet-1', + cycleId: 'cycle-1', + cycleName: 'Bridge Cycle', + runId: 'run-1', + adapter: 'claude-native', + }); + expect(prepared.agentId).toBe('agent-123'); + expect(prepared.katakaId).toBe('agent-123'); + }); + + it('lists bridge runs for a cycle while ignoring invalid and non-json files', () => { + const bridgeRunsDir = join(kataDir, 'bridge-runs'); + mkdirSync(bridgeRunsDir, { recursive: true }); + writeFileSync(join(bridgeRunsDir, 'notes.txt'), JSON.stringify({ cycleId: 'cycle-1', runId: 'txt-run' })); + writeFileSync(join(bridgeRunsDir, 'broken.json'), '{ broken json '); + writeFileSync(join(bridgeRunsDir, 'other.json'), JSON.stringify({ + runId: 'run-other', + betId: 'bet-other', + betName: 'Other Bet', + cycleId: 'other-cycle', + cycleName: 'Other Cycle', + stages: ['build'], + isolation: 'shared', + startedAt: '2026-03-15T10:00:00.000Z', + status: 'in-progress', + })); + writeFileSync(join(bridgeRunsDir, 'match.json'), JSON.stringify({ + runId: 'run-match', + betId: 'bet-match', + betName: 'Matching Bet', + cycleId: 'cycle-1', + cycleName: 'Matching Cycle', + stages: ['build'], + isolation: 'shared', + startedAt: '2026-03-15T10:00:00.000Z', + status: 'in-progress', + })); + + const bridge = new SessionExecutionBridge(kataDir); + const metas = (bridge as unknown as { + listBridgeRunsForCycle: (cycleId: string) => Array<{ runId: string; cycleId: string }>; + }).listBridgeRunsForCycle('cycle-1'); + + expect(metas).toHaveLength(1); + expect(metas[0]).toMatchObject({ runId: 'run-match', cycleId: 'cycle-1' }); + }); + + it('ignores cycle-shaped non-json files when finding the cycle for a bet', () => { + const realCycle = createCycle(kataDir); + const fakeBetId = randomUUID(); + const now = new Date().toISOString(); + writeFileSync(join(kataDir, 'cycles', 'ignored.txt'), JSON.stringify({ + id: 'txt-cycle', + name: 'Ignored Text Cycle', + budget: { tokenBudget: 100000 }, + bets: [{ + id: fakeBetId, + description: 'Text-backed bet', + appetite: 10, + outcome: 'pending', + }], + state: 'active', + createdAt: now, + updatedAt: now, + }, null, 2)); + + const bridge = new SessionExecutionBridge(kataDir); + const findCycleForBet = (bridge as unknown as { + findCycleForBet: (betId: string) => ReturnType; + }).findCycleForBet.bind(bridge); + + expect(findCycleForBet(realCycle.bets[0]!.id).id).toBe(realCycle.id); + expect(() => findCycleForBet(fakeBetId)).toThrow(`No cycle found containing bet "${fakeBetId}".`); + }); + + it('ignores cycle-shaped non-json files when loading a cycle by id or name', () => { + const realCycle = createCycle(kataDir); + const now = new Date().toISOString(); + writeFileSync(join(kataDir, 'cycles', 'shadow.txt'), JSON.stringify({ + id: 'shadow-cycle', + name: 'Shadow Cycle', + budget: { tokenBudget: 100000 }, + bets: [], + state: 'active', + createdAt: now, + updatedAt: now, + }, null, 2)); + + const bridge = new SessionExecutionBridge(kataDir); + const loadCycle = (bridge as unknown as { + loadCycle: (cycleId: string) => ReturnType; + }).loadCycle.bind(bridge); + + expect(loadCycle(realCycle.id).id).toBe(realCycle.id); + expect(() => loadCycle('shadow-cycle')).toThrow('Cycle "shadow-cycle" not found.'); + expect(() => loadCycle('Shadow Cycle')).toThrow('Cycle "Shadow Cycle" not found.'); + }); + + it('counts zero run data when jsonl files and stage directories are absent', () => { + const bridge = new SessionExecutionBridge(kataDir); + const runDir = join(kataDir, 'runs', 'run-1'); + mkdirSync(runDir, { recursive: true }); + + const counts = (bridge as unknown as { + countRunData: (runId: string) => { observations: number; artifacts: number; decisions: number; lastTimestamp: string | null }; + }).countRunData('run-1'); + + expect(counts).toEqual({ + observations: 0, + artifacts: 0, + decisions: 0, + lastTimestamp: null, + }); + }); + + it('sums cycle history tokens while ignoring non-json and missing-token entries', () => { + const bridge = new SessionExecutionBridge(kataDir); + const historyDir = join(kataDir, 'history'); + mkdirSync(historyDir, { recursive: true }); + + writeFileSync(join(historyDir, 'first.json'), JSON.stringify({ cycleId: 'cycle-1', tokenUsage: { total: 1200 } })); + writeFileSync(join(historyDir, 'missing.json'), JSON.stringify({ cycleId: 'cycle-1' })); + writeFileSync(join(historyDir, 'other.json'), JSON.stringify({ cycleId: 'other-cycle', tokenUsage: { total: 9999 } })); + writeFileSync(join(historyDir, 'notes.txt'), JSON.stringify({ cycleId: 'cycle-1', tokenUsage: { total: 5000 } })); + + const total = (bridge as unknown as { + sumCycleHistoryTokens: (historyDir: string, cycleId: string) => number; + }).sumCycleHistoryTokens(historyDir, 'cycle-1'); + + expect(total).toBe(1200); + }); + it('reports status counts from run data and estimates budget from matching history entries', () => { const cycle = createCycle(kataDir); const bridge = new SessionExecutionBridge(kataDir); diff --git a/stryker.config.mjs b/stryker.config.mjs index 8e80e2a..4f2b85f 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -24,9 +24,9 @@ export default { }, reporters: ['clear-text', 'progress', 'html'], thresholds: { - high: 80, - low: 70, - break: 60, + high: 90, + low: 80, + break: 70, }, concurrency: 2, incremental: true,