Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/cli/commands/execute.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import {
betStatusSymbol,
buildPreparedCycleOutputLines,
buildPreparedRunOutputLines,
formatConfidencePercent,
formatDurationMs,
formatAgentLoadError,
formatExplain,
hasBlockedGaps,
hasBridgedGaps,
hasNoGapsToBridge,
hasPipelineLearnings,
mergePinnedFlavors,
parseBetOption,
parseCompletedRunArtifacts,
Expand Down Expand Up @@ -477,4 +482,61 @@ describe('execute helpers', () => {
expect(resolveCompletionStatus(undefined)).toBe('complete');
});
});

describe('hasNoGapsToBridge', () => {
it('returns true when gaps is undefined', () => {
expect(hasNoGapsToBridge(undefined)).toBe(true);
});

it('returns true when gaps is empty', () => {
expect(hasNoGapsToBridge([])).toBe(true);
});

it('returns false when there are gaps', () => {
expect(hasNoGapsToBridge([{ description: 'gap' }])).toBe(false);
});
});

describe('hasBridgedGaps', () => {
it('returns true for non-empty bridged array', () => {
expect(hasBridgedGaps([{ id: '1' }])).toBe(true);
});

it('returns false for empty array', () => {
expect(hasBridgedGaps([])).toBe(false);
});
});

describe('hasBlockedGaps', () => {
it('returns true for non-empty blocked array', () => {
expect(hasBlockedGaps([{ id: '1' }])).toBe(true);
});

it('returns false for empty array', () => {
expect(hasBlockedGaps([])).toBe(false);
});
});

describe('formatConfidencePercent', () => {
it('converts decimal confidence to percent string', () => {
expect(formatConfidencePercent(0.75)).toBe('75%');
expect(formatConfidencePercent(1)).toBe('100%');
expect(formatConfidencePercent(0)).toBe('0%');
});

it('rounds to nearest integer', () => {
expect(formatConfidencePercent(0.333)).toBe('33%');
expect(formatConfidencePercent(0.667)).toBe('67%');
});
});

describe('hasPipelineLearnings', () => {
it('returns true for non-empty learnings', () => {
expect(hasPipelineLearnings(['learning 1'])).toBe(true);
});

it('returns false for empty learnings', () => {
expect(hasPipelineLearnings([])).toBe(false);
});
});
});
35 changes: 35 additions & 0 deletions src/cli/commands/execute.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,38 @@ export function formatDurationMs(ms: number): string {
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}

/**
* Pure predicate: returns true when there are no gaps to bridge.
*/
export function hasNoGapsToBridge(gaps: readonly unknown[] | undefined): boolean {
return !gaps || gaps.length === 0;
}

/**
* Pure predicate: returns true when bridged gaps should be reported.
*/
export function hasBridgedGaps(bridged: readonly unknown[]): boolean {
return bridged.length > 0;
}

/**
* Pure predicate: returns true when blocked gaps should halt execution.
*/
export function hasBlockedGaps(blocked: readonly unknown[]): boolean {
return blocked.length > 0;
}

/**
* Format confidence as a percentage string (0-100, no decimal).
*/
export function formatConfidencePercent(confidence: number): string {
return `${(confidence * 100).toFixed(0)}%`;
}

/**
* Pure predicate: returns true when pipeline learnings should be printed.
*/
export function hasPipelineLearnings(learnings: readonly string[]): boolean {
return learnings.length > 0;
}
114 changes: 113 additions & 1 deletion src/cli/commands/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, readdirSync
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { Command } from 'commander';
import { registerExecuteCommands } from './execute.js';
import { registerExecuteCommands, listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from './execute.js';
import { CycleManager } from '@domain/services/cycle-manager.js';
import { JsonStore } from '@infra/persistence/json-store.js';
import { SessionExecutionBridge } from '@infra/execution/session-bridge.js';
Expand Down Expand Up @@ -1705,3 +1705,115 @@ describe('registerExecuteCommands', () => {
});
});
});

// ---------------------------------------------------------------------------
// Saved kata CRUD — direct function tests for mutation hardening
// ---------------------------------------------------------------------------

describe('saved kata CRUD functions', () => {
let tmpBase: string;

beforeEach(() => {
tmpBase = join(tmpdir(), `kata-crud-${randomUUID()}`);
mkdirSync(tmpBase, { recursive: true });
});

afterEach(() => {
rmSync(tmpBase, { recursive: true, force: true });
});

describe('listSavedKatas', () => {
it('returns empty when katas dir does not exist', () => {
expect(listSavedKatas(tmpBase)).toEqual([]);
});

it('returns valid katas and skips non-json files', () => {
const katasDir = join(tmpBase, 'katas');
mkdirSync(katasDir, { recursive: true });
writeFileSync(join(katasDir, 'valid.json'), JSON.stringify({ name: 'valid', stages: ['build'] }));
writeFileSync(join(katasDir, 'notes.txt'), 'not json');
writeFileSync(join(katasDir, 'broken.json'), '{ broken }');

const result = listSavedKatas(tmpBase);
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('valid');
expect(result[0]!.stages).toEqual(['build']);
});

it('skips files with invalid schema data', () => {
const katasDir = join(tmpBase, 'katas');
mkdirSync(katasDir, { recursive: true });
writeFileSync(join(katasDir, 'bad-schema.json'), JSON.stringify({ name: 123 }));

expect(listSavedKatas(tmpBase)).toEqual([]);
});
});

describe('loadSavedKata', () => {
it('loads a valid saved kata', () => {
const katasDir = join(tmpBase, 'katas');
mkdirSync(katasDir, { recursive: true });
writeFileSync(join(katasDir, 'my-kata.json'), JSON.stringify({
name: 'my-kata', stages: ['research', 'build'],
}));

const result = loadSavedKata(tmpBase, 'my-kata');
expect(result.stages).toEqual(['research', 'build']);
});

it('throws when kata does not exist', () => {
expect(() => loadSavedKata(tmpBase, 'missing')).toThrow(
'Kata "missing" not found.',
);
});

it('throws for invalid JSON content', () => {
const katasDir = join(tmpBase, 'katas');
mkdirSync(katasDir, { recursive: true });
writeFileSync(join(katasDir, 'broken.json'), '{ broken }');

expect(() => loadSavedKata(tmpBase, 'broken')).toThrow(
'Kata "broken" has invalid JSON:',
);
});

it('throws for valid JSON with invalid schema', () => {
const katasDir = join(tmpBase, 'katas');
mkdirSync(katasDir, { recursive: true });
writeFileSync(join(katasDir, 'bad.json'), JSON.stringify({ name: 123 }));

expect(() => loadSavedKata(tmpBase, 'bad')).toThrow(
'Kata "bad" has invalid structure.',
);
});
});

describe('saveSavedKata', () => {
it('creates the katas directory and writes the file', () => {
saveSavedKata(tmpBase, 'new-kata', ['build', 'review']);

const filePath = join(tmpBase, 'katas', 'new-kata.json');
expect(existsSync(filePath)).toBe(true);
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(data.name).toBe('new-kata');
expect(data.stages).toEqual(['build', 'review']);
});
});

describe('deleteSavedKata', () => {
it('deletes an existing saved kata', () => {
saveSavedKata(tmpBase, 'del-kata', ['build']);
const filePath = join(tmpBase, 'katas', 'del-kata.json');
expect(existsSync(filePath)).toBe(true);

deleteSavedKata(tmpBase, 'del-kata');
expect(existsSync(filePath)).toBe(false);
});

it('throws when kata does not exist', () => {
expect(() => deleteSavedKata(tmpBase, 'nonexistent')).toThrow(
'Kata "nonexistent" not found.',
);
});
});
});
28 changes: 17 additions & 11 deletions src/cli/commands/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { WorkflowRunner } from '@features/execute/workflow-runner.js';
import { GapBridger } from '@features/execute/gap-bridger.js';
import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js';
import { UsageAnalytics } from '@infra/tracking/usage-analytics.js';
import { isJsonFile } from '@shared/lib/file-filters.js';
import { KATA_DIRS } from '@shared/constants/paths.js';
import { ProjectStateUpdater } from '@features/belt/belt-calculator.js';
import { CycleManager } from '@domain/services/cycle-manager.js';
Expand All @@ -26,9 +27,14 @@ import {
betStatusSymbol,
buildPreparedCycleOutputLines,
buildPreparedRunOutputLines,
formatConfidencePercent,
formatDurationMs,
formatAgentLoadError,
formatExplain,
hasBlockedGaps,
hasBridgedGaps,
hasNoGapsToBridge,
hasPipelineLearnings,
mergePinnedFlavors,
parseBetOption,
parseCompletedRunArtifacts,
Expand Down Expand Up @@ -657,20 +663,20 @@ function bridgeExecutionGaps(input: {
suggestedFlavors: string[];
}>;
}): boolean {
if (!input.gaps || input.gaps.length === 0) return true;
if (hasNoGapsToBridge(input.gaps)) return true;

const store = new KnowledgeStore(kataDirPath(input.kataDir, 'knowledge'));
const bridger = new GapBridger({ knowledgeStore: store });
const { blocked, bridged } = bridger.bridge(input.gaps);
const { blocked, bridged } = bridger.bridge(input.gaps!);

if (blocked.length > 0) {
if (hasBlockedGaps(blocked)) {
console.error(`[kata] Blocked by ${blocked.length} high-severity gap(s):`);
for (const gap of blocked) console.error(` • ${gap.description}`);
process.exitCode = 1;
return false;
}

if (bridged.length > 0) {
if (hasBridgedGaps(bridged)) {
console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`);
ProjectStateUpdater.incrementGapsClosed(input.projectStateFile, bridged.length);
}
Expand All @@ -695,7 +701,7 @@ function printSingleCategoryResult(result: StageRunResult, isJson: boolean, opts
console.log('');
console.log('Decisions:');
for (const decision of result.decisions) {
console.log(` ${decision.decisionType}: ${decision.selection} (confidence: ${(decision.confidence * 100).toFixed(0)}%)`);
console.log(` ${decision.decisionType}: ${decision.selection} (confidence: ${formatConfidencePercent(decision.confidence)})`);
}
console.log('');
console.log(`Stage artifact: ${result.stageArtifact.name}`);
Expand Down Expand Up @@ -735,7 +741,7 @@ function printPipelineResult(
console.log(` Artifact: ${stageResult.stageArtifact.name}`);
}

if (result.pipelineReflection.learnings.length > 0) {
if (hasPipelineLearnings(result.pipelineReflection.learnings)) {
console.log('');
console.log('Learnings:');
for (const learning of result.pipelineReflection.learnings) {
Expand Down Expand Up @@ -801,11 +807,11 @@ function katasDir(kataDir: string): string {
return join(kataDir, KATA_DIRS.katas);
}

function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> {
export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> {
const dir = katasDir(kataDir);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith('.json'))
.filter(isJsonFile)
.map((f) => {
try {
const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
Expand All @@ -821,7 +827,7 @@ function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCat
.filter((k): k is NonNullable<typeof k> => k !== null);
}

function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record<string, FlavorHint> } {
export function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record<string, FlavorHint> } {
assertValidKataName(name);
const filePath = join(katasDir(kataDir), `${name}.json`);
if (!existsSync(filePath)) {
Expand All @@ -846,15 +852,15 @@ function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]
}
}

function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record<string, FlavorHint>): void {
export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record<string, FlavorHint>): void {
assertValidKataName(name);
const dir = katasDir(kataDir);
mkdirSync(dir, { recursive: true });
const kata = SavedKataSchema.parse({ name, stages, flavorHints });
writeFileSync(join(dir, `${name}.json`), JSON.stringify(kata, null, 2), 'utf-8');
}

function deleteSavedKata(kataDir: string, name: string): void {
export function deleteSavedKata(kataDir: string, name: string): void {
assertValidKataName(name);
const filePath = join(katasDir(kataDir), `${name}.json`);
if (!existsSync(filePath)) {
Expand Down
Loading
Loading