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
47 changes: 0 additions & 47 deletions src/cli/commands/execute.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ import {
formatDurationMs,
formatAgentLoadError,
formatExplain,
hasBlockedGaps,
hasBridgedGaps,
hasNoGapsToBridge,
hasPipelineLearnings,
mergePinnedFlavors,
parseBetOption,
parseCompletedRunArtifacts,
Expand Down Expand Up @@ -483,40 +479,6 @@ describe('execute helpers', () => {
});
});

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

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

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

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

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

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

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

describe('formatConfidencePercent', () => {
it('converts decimal confidence to percent string', () => {
expect(formatConfidencePercent(0.75)).toBe('75%');
Expand All @@ -530,13 +492,4 @@ describe('execute helpers', () => {
});
});

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

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

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

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

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

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

/**
* Pure predicate: returns true when pipeline learnings should be printed.
*/
export function hasPipelineLearnings(learnings: readonly string[]): boolean {
return learnings.length > 0;
}
96 changes: 8 additions & 88 deletions src/cli/commands/execute.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { join } from 'node:path';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import type { Command } from 'commander';
import { withCommandContext, kataDirPath } from '@cli/utils.js';
import { getLexicon } from '@cli/lexicon.js';
import { StageCategorySchema, type StageCategory } from '@domain/types/stage.js';
import { SavedKataSchema, type FlavorHint } from '@domain/types/saved-kata.js';
import type { FlavorHint } from '@domain/types/saved-kata.js';
import { KataConfigSchema } from '@domain/types/config.js';
import { StepRegistry } from '@infra/registries/step-registry.js';
import { FlavorRegistry } from '@infra/registries/flavor-registry.js';
Expand All @@ -17,24 +16,18 @@ import { WorkflowRunner } from '@features/execute/workflow-runner.js';
import { GapBridger } from '@features/execute/gap-bridger.js';
import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js';
import { UsageAnalytics } from '@infra/tracking/usage-analytics.js';
import { isJsonFile } from '@shared/lib/file-filters.js';
import { KATA_DIRS } from '@shared/constants/paths.js';
import { ProjectStateUpdater } from '@features/belt/belt-calculator.js';
import { CycleManager } from '@domain/services/cycle-manager.js';
import { SessionExecutionBridge } from '@infra/execution/session-bridge.js';
import {
assertValidKataName,
betStatusSymbol,
buildPreparedCycleOutputLines,
buildPreparedRunOutputLines,
formatConfidencePercent,
formatDurationMs,
formatAgentLoadError,
formatExplain,
hasBlockedGaps,
hasBridgedGaps,
hasNoGapsToBridge,
hasPipelineLearnings,
mergePinnedFlavors,
parseBetOption,
parseCompletedRunArtifacts,
Expand All @@ -43,6 +36,7 @@ import {
resolveCompletionStatus,
resolveJsonFlag,
} from '@cli/commands/execute.helpers.js';
import { listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from '@cli/commands/saved-kata-store.js';
import { resolveRef } from '@cli/resolve-ref.js';
import { handleStatus, handleStats, parseCategoryFilter } from './status.js';

Expand Down Expand Up @@ -679,20 +673,20 @@ function bridgeExecutionGaps(input: {
suggestedFlavors: string[];
}>;
}): boolean {
if (hasNoGapsToBridge(input.gaps)) return true;
if (!input.gaps || input.gaps.length === 0) return true;

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

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

if (hasBridgedGaps(bridged)) {
if (bridged.length > 0) {
console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`);
ProjectStateUpdater.incrementGapsClosed(input.projectStateFile, bridged.length);
}
Expand Down Expand Up @@ -760,7 +754,7 @@ function printPipelineResult(
console.log(` Artifact: ${stageResult.stageArtifact.name}`);
}

if (hasPipelineLearnings(result.pipelineReflection.learnings)) {
if (result.pipelineReflection.learnings.length > 0) {
console.log('');
console.log('Learnings:');
for (const learning of result.pipelineReflection.learnings) {
Expand Down Expand Up @@ -821,79 +815,5 @@ function collect(value: string, previous: string[]): string[] {
return previous.concat([value]);
}

// ---------------------------------------------------------------------------
// Saved kata helpers
// ---------------------------------------------------------------------------

function katasDir(kataDir: string): string {
return join(kataDir, KATA_DIRS.katas);
}

export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> {
const dir = katasDir(kataDir);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter(isJsonFile)
.map((f) => {
try {
const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
return SavedKataSchema.parse(raw);
} catch (e) {
if (e instanceof SyntaxError || (e instanceof Error && e.constructor.name === 'ZodError')) {
console.error(`Warning: skipping invalid kata file "${f}": ${e.message}`);
return null;
}
throw e;
}
})
.filter((k): k is NonNullable<typeof k> => k !== null);
}

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)) {
throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`);
}
let raw: unknown;
try {
raw = JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (e) {
throw new Error(
`Kata "${name}" has invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
{ cause: e },
);
}
try {
return SavedKataSchema.parse(raw);
} catch (e) {
throw new Error(
`Kata "${name}" has invalid structure. Ensure it has "name" (string) and "stages" (array of categories).`,
{ cause: e },
);
}
}

export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record<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');
}

export function deleteSavedKata(kataDir: string, name: string): void {
assertValidKataName(name);
const filePath = join(katasDir(kataDir), `${name}.json`);
if (!existsSync(filePath)) {
throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`);
}
try {
unlinkSync(filePath);
} catch (e) {
throw new Error(
`Could not delete kata "${name}": ${e instanceof Error ? e.message : String(e)}`,
{ cause: e },
);
}
}
// Re-export saved-kata CRUD for backward compatibility
export { listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from '@cli/commands/saved-kata-store.js';
80 changes: 80 additions & 0 deletions src/cli/commands/saved-kata-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { join } from 'node:path';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import type { StageCategory } from '@domain/types/stage.js';
import { SavedKataSchema, type FlavorHint } from '@domain/types/saved-kata.js';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Zod v4, is ZodErrorexported from thezod/v4entrypoint and isinstanceof ZodError the recommended runtime check?

💡 Result:

Yes.

  • ZodError is exported from the zod/v4 entrypoint: zod/v4 re-exports Zod 4 “classic” (./classic/index.js), which re-exports ./errors.js, and that module exports ZodError (the constructor) as a public export. [1] [2] [3]
  • Recommended runtime check: Zod’s own docs/examples use err instanceof z.ZodError as the way to detect validation errors at runtime. [4] Also, in v4 specifically, the core error type ($ZodError) does not extend the built-in Error, so instanceof Error is not a reliable check for Zod validation errors. [5]

Sources:
[1] https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/index.ts
[2] https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/external.ts
[3] https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/errors.ts
[4] https://github.com/colinhacks/zod (README “Handling errors” example)
[5] https://zod.dev/packages/core (Errors section)


Import ZodError from zod/v4 and use instanceof for error type checking.

At line 23, replace the brittle constructor.name === 'ZodError' check with instanceof ZodError. This is the recommended runtime pattern in Zod v4, where ZodError is properly exported and type-safe, whereas the string comparison is fragile and can misclassify errors.

Proposed fix
 import { SavedKataSchema, type FlavorHint } from '@domain/types/saved-kata.js';
+import { ZodError } from 'zod/v4';
@@
-        if (e instanceof SyntaxError || (e instanceof Error && e.constructor.name === 'ZodError')) {
+        if (e instanceof SyntaxError || e instanceof ZodError) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/commands/saved-kata-store.ts` at line 4, Import ZodError from
'zod/v4' and replace the fragile runtime check that uses error.constructor.name
=== 'ZodError' with a proper instanceof check (e.g., error instanceof ZodError)
in the try/catch block where you validate using SavedKataSchema (the
validation/parse area referencing SavedKataSchema and FlavorHint); update the
top imports to include ZodError and change the conditional to use instanceof
ZodError so Zod validation errors are identified reliably.

import { isJsonFile } from '@shared/lib/file-filters.js';
import { KATA_DIRS } from '@shared/constants/paths.js';
import { assertValidKataName } from '@cli/commands/execute.helpers.js';

function katasDir(kataDir: string): string {
return join(kataDir, KATA_DIRS.katas);
}

export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> {
const dir = katasDir(kataDir);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter(isJsonFile)
.map((f) => {
try {
const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
return SavedKataSchema.parse(raw);
} catch (e) {
if (e instanceof SyntaxError || (e instanceof Error && e.constructor.name === 'ZodError')) {
console.error(`Warning: skipping invalid kata file "${f}": ${e.message}`);
return null;
}
throw e;
}
})
.filter((k): k is NonNullable<typeof k> => k !== null);
}
Comment on lines +13 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Prefer JsonStore typed read/write over raw fs+JSON parsing in this persistence module.

This new store re-implements JSON persistence behavior with manual readFileSync/writeFileSync + JSON.parse. Please align saved-kata CRUD with JsonStore to keep typed IO, error handling, and persistence behavior consistent across CLI persistence code.

Based on learnings: "Store persistent data as JSON files in .kata/ directory using JsonStore with typed read/write against Zod schemas".

Also applies to: 33-64


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)) {
throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`);
}
let raw: unknown;
try {
raw = JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (e) {
throw new Error(
`Kata "${name}" has invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
{ cause: e },
);
}
try {
return SavedKataSchema.parse(raw);
} catch (e) {
throw new Error(
`Kata "${name}" has invalid structure. Ensure it has "name" (string) and "stages" (array of categories).`,
{ cause: e },
);
}
}

export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record<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');
}

export function deleteSavedKata(kataDir: string, name: string): void {
assertValidKataName(name);
const filePath = join(katasDir(kataDir), `${name}.json`);
if (!existsSync(filePath)) {
throw new Error(`Kata "${name}" not found. Use --list-katas to see available katas.`);
}
try {
unlinkSync(filePath);
} catch (e) {
throw new Error(
`Could not delete kata "${name}": ${e instanceof Error ? e.message : String(e)}`,
{ cause: e },
);
}
}
Loading
Loading