diff --git a/.env.example b/.env.example index 6aafdbd..34a562e 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ TELEMETRY_LLM_PREVIEW_CHARS=800 # Comma-separated list of allowed origins for the API (set in production). CORS_ALLOWED_ORIGINS=http://localhost:8000 +# ───────────────────────────────────────────────── +# Data +# ───────────────────────────────────────────────── +# Limit number of epics loaded (useful for fast test runs). +# EPICS_LIMIT=1 + # ───────────────────────────────────────────────── # Generation Settings # ───────────────────────────────────────────────── diff --git a/.github/workflows/deploy-deno.yml b/.github/workflows/deploy-deno.yml index 0983ca3..ffdc608 100644 --- a/.github/workflows/deploy-deno.yml +++ b/.github/workflows/deploy-deno.yml @@ -40,6 +40,7 @@ jobs: deployctl deploy \ --project="${{ vars.DENO_DEPLOY_PROJECT }}" \ --prod \ + --include=src/ui/dist \ --exclude=node_modules \ --exclude=.git \ --exclude=.github \ diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 85b2707..2174fe0 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -40,6 +40,7 @@ jobs: deployctl deploy \ --project="${{ vars.DENO_DEPLOY_PROJECT_STAGING }}" \ --prod \ + --include=src/ui/dist \ --exclude=node_modules \ --exclude=.git \ --exclude=.github \ diff --git a/deno.json b/deno.json index 074e3b7..57af022 100644 --- a/deno.json +++ b/deno.json @@ -2,6 +2,7 @@ "imports": { "@std/assert": "jsr:@std/assert@^1.0.16", "@std/expect": "jsr:@std/expect@^1.0.17", + "@std/dotenv": "jsr:@std/dotenv@0.225.6", "@std/dotenv/load": "jsr:@std/dotenv@0.225.6/load", "@std/http/file-server": "jsr:@std/http@^1.0.23/file-server", "@std/path": "jsr:@std/path@^1.1.4", diff --git a/playwright.config.ts b/playwright.config.ts index d5793f2..aa99fdc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,34 @@ import { defineConfig, devices } from "@playwright/test"; +import { parse } from "@std/dotenv"; +const readEnvFile = (): Record => { + try { + const raw = Deno.readTextFileSync(".env"); + return parse(raw); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + return {}; + } + throw e; + } +}; + +const envFromFile = readEnvFile(); +const getEnv = (key: string, fallback?: string) => + Deno.env.get(key) ?? envFromFile[key] ?? fallback ?? ""; + +const llmBaseUrl = getEnv("LLM_BASE_URL", "https://openrouter.ai/api/v1"); +const llmApiKey = getEnv("LLM_API_KEY"); +const llmModel = getEnv("LLM_MODEL", "openai/gpt-4o-mini"); + +// Warn when targeting a remote LLM provider without an API key +const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/.test(llmBaseUrl); +if (!llmApiKey && !isLocalhost) { + console.warn( + `[playwright] LLM_API_KEY is empty while LLM_BASE_URL points to a remote provider (${llmBaseUrl}). ` + + "LLM calls will likely fail with 401. Set LLM_API_KEY in .env or environment.", + ); +} export default defineConfig({ testDir: "./tests/e2e", timeout: 60_000, @@ -18,10 +47,16 @@ export default defineConfig({ reuseExistingServer: !Deno.env.get("CI"), timeout: 120_000, env: { - LMSTUDIO_BASE_URL: "http://127.0.0.1:1234/v1", - LMSTUDIO_API_KEY: "lm-studio", - LMSTUDIO_MODEL: "gpt-oss-120b", - LMSTUDIO_JUDGE_MODEL: "gpt-oss-120b", + ...Deno.env.toObject(), + LLM_BASE_URL: llmBaseUrl, + LLM_API_KEY: llmApiKey, + LLM_MODEL: llmModel, + POLL_ENABLED: "false", + EVAL_REPLICATES: "1", + OPT_CONCURRENCY: "3", + OPT_ITERATIONS: "1", + OPT_PATCH_CANDIDATES: "1", + EPICS_LIMIT: "1", }, }, projects: [ diff --git a/prompts/champion.base.md b/prompts/champion.base.md index af16aec..548723b 100644 --- a/prompts/champion.base.md +++ b/prompts/champion.base.md @@ -8,13 +8,14 @@ Rules: 3. Each story MUST include: - title (short, action-oriented) - asA / iWant / soThat - - acceptanceCriteria: >= 2 items, objectively testable + - acceptanceCriteria: >= 1 item, objectively testable 4. Prefer acceptance criteria in Given/When/Then style. -5. Do NOT invent requirements. If something is unclear, put it in assumptions or +5. Keep output compact; omit optional fields unless needed. +6. Do NOT invent requirements. If something is unclear, put it in assumptions or followUps. -6. Reflect constraints/nonFunctional/outOfScope from the Epic. +7. Reflect constraints/nonFunctional/outOfScope from the Epic. -Azure DevOps mapping: +Azure DevOps mapping (optional if requested): - System.Title: story title - System.Description: include As a / I want / So that in readable Markdown diff --git a/prompts/champion.md b/prompts/champion.md index af16aec..548723b 100644 --- a/prompts/champion.md +++ b/prompts/champion.md @@ -8,13 +8,14 @@ Rules: 3. Each story MUST include: - title (short, action-oriented) - asA / iWant / soThat - - acceptanceCriteria: >= 2 items, objectively testable + - acceptanceCriteria: >= 1 item, objectively testable 4. Prefer acceptance criteria in Given/When/Then style. -5. Do NOT invent requirements. If something is unclear, put it in assumptions or +5. Keep output compact; omit optional fields unless needed. +6. Do NOT invent requirements. If something is unclear, put it in assumptions or followUps. -6. Reflect constraints/nonFunctional/outOfScope from the Epic. +7. Reflect constraints/nonFunctional/outOfScope from the Epic. -Azure DevOps mapping: +Azure DevOps mapping (optional if requested): - System.Title: story title - System.Description: include As a / I want / So that in readable Markdown diff --git a/src/config.ts b/src/config.ts index 5a54ad6..819d18a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -102,6 +102,12 @@ const EnvSchema = z.object({ // ───────────────────────────────────────────────── CORS_ALLOWED_ORIGINS: z.string().default(""), + // ───────────────────────────────────────────────── + // Data + // ───────────────────────────────────────────────── + /** Limit number of epics loaded (useful for fast test runs) */ + EPICS_LIMIT: z.coerce.number().int().min(1).max(100).optional(), + // ───────────────────────────────────────────────── // Generation Settings // ───────────────────────────────────────────────── diff --git a/src/fpf/poll.ts b/src/fpf/poll.ts index a120e07..95bf011 100644 --- a/src/fpf/poll.ts +++ b/src/fpf/poll.ts @@ -2,7 +2,7 @@ * PoLL - Panel of LLM Evaluators * * Implements FPF B.3 Trust & Assurance Calculus with: - * - 3 diverse judges (different temperatures for diversity) + * - N diverse judges (env-configured count and temperatures) * - Per-criterion evaluation (decomposed INVEST + GWT) * - WLNK aggregation: R_eff = max(0, min(R_i) - Φ(CL_min)) * - Full SCR audit trail @@ -38,26 +38,23 @@ import { // CONFIGURATION // ═══════════════════════════════════════════════════════════════ -const DEFAULT_JUDGES: JudgeConfig[] = [ - { - id: "judge-1", - model: "gpt-4o-mini", - temperature: 0.3, - provider: "lmstudio", - }, - { - id: "judge-2", - model: "gpt-4o-mini", - temperature: 0.5, - provider: "lmstudio", - }, - { - id: "judge-3", - model: "gpt-4o-mini", - temperature: 0.7, +const clampTemp = (value: number) => Math.max(0, Math.min(2, value)); + +const buildDefaultJudges = (): JudgeConfig[] => { + const model = env.LMSTUDIO_JUDGE_MODEL ?? env.LMSTUDIO_MODEL; + const baseTemp = env.POLL_TEMP_BASE; + const spread = env.POLL_TEMP_SPREAD; + const count = env.POLL_NUM_JUDGES; + + return Array.from({ length: count }, (_, index) => ({ + id: `judge-${index + 1}`, + model, + temperature: clampTemp(baseTemp + index * spread), provider: "lmstudio", - }, -]; + })); +}; + +const DEFAULT_JUDGES = buildDefaultJudges(); const CRITERIA_WEIGHTS: Record = { [EvaluationCriterion.CORRECTNESS]: 0.2, diff --git a/src/generator.ts b/src/generator.ts index c2a85a5..8c2ff1f 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -9,7 +9,8 @@ import { parseAcceptanceCriteria } from "./utils/acceptanceCriteria.ts"; export const baseStoryAgent = new Agent({ id: "story-generator", name: "Story Generator", - instructions: "You generate Azure DevOps user stories from epics.", + instructions: + "You generate Azure DevOps user stories from epics. Always return JSON that matches the provided schema and keep the response compact.", model: makeGeneratorModel(), }); @@ -37,6 +38,14 @@ type ValidationFailure = { issues: string[]; }; +const SCHEMA_GUARDRAILS = [ + "Output MUST match the provided JSON schema.", + "Keep the response compact; omit optional fields unless needed.", + "Include `acceptanceCriteria` with at least 1 item per story.", + "If you include `ado.fields`, keep each value brief.", + "Do not omit or rename required schema fields.", +].join("\n"); + /** * Helper to build provider-specific options including seed. * LM Studio accepts `seed` in the OpenAI-compatible API. @@ -324,6 +333,8 @@ export async function generateStoryPack( }, ]; + const guardedInstructions = `${candidatePrompt}\n\nSchema guardrails:\n${SCHEMA_GUARDRAILS}`; + let storyPack: StoryPack | null = null; let rawText = ""; let error: string | undefined; @@ -339,7 +350,7 @@ export async function generateStoryPack( { name: "story-generator", model: env.LMSTUDIO_MODEL }, () => baseStoryAgent.generate(messages, { - instructions: candidatePrompt, + instructions: guardedInstructions, structuredOutput: { schema: storyPackSchema, jsonPromptInjection: true, @@ -441,7 +452,7 @@ export async function generateStoryPack( return { storyPack, rawText, - instructions: candidatePrompt, + instructions: guardedInstructions, trace, gammaTime: startedAt, seed, diff --git a/src/orchestrator/state/kv-store.ts b/src/orchestrator/state/kv-store.ts index 1c7e812..23afdda 100644 --- a/src/orchestrator/state/kv-store.ts +++ b/src/orchestrator/state/kv-store.ts @@ -1,18 +1,13 @@ +// deno-lint-ignore-file require-await /** - * Deno KV State Store + * In-memory State Store * - * Persistent storage for tasks and session checkpoints. - * Replaces in-memory Maps for production reliability. - * - * Key schema: - * - ["tasks", taskId] → TaskRecord - * - ["tasks:by-status", status, taskId] → taskId (index) - * - ["optimization-tasks", taskId] → OptimizationTask - * - ["checkpoints", checkpointId] → SessionCheckpoint - * - ["checkpoints:by-session", sessionId, checkpointId] → checkpointId (index) + * Simple, non-persistent storage for tasks and session checkpoints. + * This replaces the Deno KV-backed store to avoid runtime dependencies. */ import type { OptimizationState } from "../types.ts"; + import type { OptimizationTask } from "../optimization-progress.ts"; // ───────────────────────────────────────────────── @@ -45,75 +40,85 @@ export interface SessionCheckpoint { } // ───────────────────────────────────────────────── -// KV Store Singleton +// In-memory state // ───────────────────────────────────────────────── -let _kv: Deno.Kv | null = null; +const tasks = new Map(); +const tasksByStatus = new Map>(); +const optimizationTasks = new Map(); +const checkpoints = new Map(); +const checkpointsBySession = new Map>(); + +const ensureStatusSet = (status: TaskStatus) => { + const existing = tasksByStatus.get(status); + if (existing) return existing; + const created = new Set(); + tasksByStatus.set(status, created); + return created; +}; -/** - * Get or create the KV store instance. - * Uses lazy initialization to avoid top-level await issues. - */ -async function getKv(): Promise { - if (!_kv) { - _kv = await Deno.openKv(); +const ensureSessionSet = (sessionId: string) => { + const existing = checkpointsBySession.get(sessionId); + if (existing) return existing; + const created = new Set(); + checkpointsBySession.set(sessionId, created); + return created; +}; + +const indexTask = (task: TaskRecord) => { + ensureStatusSet(task.status).add(task.id); +}; + +const deindexTask = (task: TaskRecord) => { + const set = tasksByStatus.get(task.status); + if (!set) return; + set.delete(task.id); + if (set.size === 0) { + tasksByStatus.delete(task.status); } - return _kv; -} +}; // ───────────────────────────────────────────────── // Task Management // ───────────────────────────────────────────────── -/** - * Save a task to the store. - */ export async function saveTask(task: TaskRecord): Promise { - const kv = await getKv(); - await kv.atomic() - .set(["tasks", task.id], task) - .set(["tasks:by-status", task.status, task.id], task.id) - .commit(); + const existing = tasks.get(task.id); + if (existing) { + deindexTask(existing); + } + tasks.set(task.id, task); + indexTask(task); } -/** - * Get a task by ID. - */ export async function getTask(taskId: string): Promise { - const kv = await getKv(); - const result = await kv.get(["tasks", taskId]); - return result.value; + const task = tasks.get(taskId); + return task ? { ...task } : null; } -/** - * Update task progress (optimized for frequent updates). - */ export async function updateTaskProgress( taskId: string, progress: { completed: number; total: number }, ): Promise { - const kv = await getKv(); - const task = await getTask(taskId); - if (task) { - task.progress = progress; - await kv.set(["tasks", taskId], task); - } + const task = tasks.get(taskId); + if (!task) return; + task.progress = progress; + tasks.set(taskId, task); } -/** - * Update task status with optional result. - */ export async function updateTaskStatus( taskId: string, status: TaskStatus, options?: { result?: unknown; error?: string }, ): Promise { - const kv = await getKv(); - const task = await getTask(taskId); + const task = tasks.get(taskId); if (!task) return; const oldStatus = task.status; - task.status = status; + if (oldStatus !== status) { + deindexTask(task); + task.status = status; + } if (options?.result !== undefined) { task.result = options.result; @@ -125,17 +130,10 @@ export async function updateTaskStatus( task.completedAt = new Date().toISOString(); } - // Atomic update: remove from old index, add to new index - await kv.atomic() - .delete(["tasks:by-status", oldStatus, taskId]) - .set(["tasks", taskId], task) - .set(["tasks:by-status", status, taskId], taskId) - .commit(); + tasks.set(taskId, task); + indexTask(task); } -/** - * Complete a task with result. - */ export async function completeTask( taskId: string, result: unknown, @@ -143,34 +141,20 @@ export async function completeTask( await updateTaskStatus(taskId, "completed", { result }); } -/** - * Fail a task with error. - */ export async function failTask(taskId: string, error: string): Promise { await updateTaskStatus(taskId, "failed", { error }); } -/** - * List tasks by status. - */ export async function listTasksByStatus( status: TaskStatus, ): Promise { - const kv = await getKv(); - const tasks: TaskRecord[] = []; - - const entries = kv.list({ prefix: ["tasks:by-status", status] }); - for await (const entry of entries) { - const task = await getTask(entry.value); - if (task) tasks.push(task); - } - - return tasks; + const ids = tasksByStatus.get(status); + if (!ids) return []; + return Array.from(ids) + .map((id) => tasks.get(id)) + .filter((task): task is TaskRecord => Boolean(task)); } -/** - * Create a new task. - */ export async function createTask( type: TaskType, options?: { totalProgress?: number }, @@ -186,39 +170,27 @@ export async function createTask( return task; } -/** - * Save a streaming optimization task. - */ export async function saveOptimizationTask( task: OptimizationTask, ): Promise { - const kv = await getKv(); - await kv.set(["optimization-tasks", task.id], task); + optimizationTasks.set(task.id, task); } -/** - * Get a streaming optimization task by ID. - */ export async function getOptimizationTask( taskId: string, ): Promise { - const kv = await getKv(); - const result = await kv.get(["optimization-tasks", taskId]); - return result.value; + const task = optimizationTasks.get(taskId); + return task ? { ...task } : null; } // ───────────────────────────────────────────────── // Checkpoint Management // ───────────────────────────────────────────────── -/** - * Save a checkpoint for recovery. - */ export async function saveCheckpoint( sessionId: string, state: OptimizationState, ): Promise { - const kv = await getKv(); const checkpointId = crypto.randomUUID(); const checkpoint: SessionCheckpoint = { id: checkpointId, @@ -227,111 +199,75 @@ export async function saveCheckpoint( createdAt: new Date().toISOString(), }; - await kv.atomic() - .set(["checkpoints", checkpointId], checkpoint) - .set(["checkpoints:by-session", sessionId, checkpointId], checkpointId) - .commit(); + checkpoints.set(checkpointId, checkpoint); + ensureSessionSet(sessionId).add(checkpointId); return checkpointId; } -/** - * Get a checkpoint by ID. - */ export async function getCheckpoint( checkpointId: string, ): Promise { - const kv = await getKv(); - const result = await kv.get(["checkpoints", checkpointId]); - return result.value; + const cp = checkpoints.get(checkpointId); + return cp ? { ...cp } : null; } -/** - * Get the latest checkpoint for a session. - */ export async function getLatestCheckpoint( sessionId: string, ): Promise { - const kv = await getKv(); - const entries = kv.list({ - prefix: ["checkpoints:by-session", sessionId], - }); - - // Collect all checkpoint IDs and get the latest by timestamp - const checkpointIds: string[] = []; - for await (const entry of entries) { - checkpointIds.push(entry.value); - } + const ids = checkpointsBySession.get(sessionId); + if (!ids || ids.size === 0) return null; - if (checkpointIds.length === 0) return null; - - // Get all checkpoints and find the newest - const checkpoints: SessionCheckpoint[] = []; - for (const id of checkpointIds) { - const cp = await getCheckpoint(id); - if (cp) checkpoints.push(cp); - } + const list = Array.from(ids) + .map((id) => checkpoints.get(id)) + .filter((checkpoint): checkpoint is SessionCheckpoint => + Boolean(checkpoint) + ); - if (checkpoints.length === 0) return null; + if (list.length === 0) return null; - // Sort by creation time (newest first) - checkpoints.sort((a, b) => + list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - return checkpoints[0] ?? null; + const latest = list[0]; + return latest ? { ...latest } : null; } -/** - * List all checkpoints for a session (newest first). - */ export async function listCheckpoints( sessionId: string, ): Promise { - const kv = await getKv(); - const checkpoints: SessionCheckpoint[] = []; + const ids = checkpointsBySession.get(sessionId); + if (!ids || ids.size === 0) return []; - const entries = kv.list({ - prefix: ["checkpoints:by-session", sessionId], - }); - - for await (const entry of entries) { - const checkpoint = await getCheckpoint(entry.value); - if (checkpoint) checkpoints.push(checkpoint); - } + const list = Array.from(ids) + .map((id) => checkpoints.get(id)) + .filter((checkpoint): checkpoint is SessionCheckpoint => + Boolean(checkpoint) + ); - // Sort by creation time (newest first) - checkpoints.sort((a, b) => + list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - return checkpoints; + return list; } // ───────────────────────────────────────────────── // Cleanup // ───────────────────────────────────────────────── -/** - * Clean up old completed/failed tasks. - * @param olderThanMs Delete tasks older than this many milliseconds - * @returns Number of tasks deleted - */ export async function cleanupOldTasks(olderThanMs: number): Promise { - const kv = await getKv(); const cutoff = Date.now() - olderThanMs; let deleted = 0; - for await (const entry of kv.list({ prefix: ["tasks"] })) { - const task = entry.value; + for (const [taskId, task] of tasks.entries()) { if ( task.completedAt && new Date(task.completedAt).getTime() < cutoff ) { - await kv.atomic() - .delete(["tasks", task.id]) - .delete(["tasks:by-status", task.status, task.id]) - .commit(); + tasks.delete(taskId); + deindexTask(task); deleted++; } } @@ -339,53 +275,31 @@ export async function cleanupOldTasks(olderThanMs: number): Promise { return deleted; } -/** - * Clean up old checkpoints, keeping only the N most recent per session. - * @param keepPerSession Number of checkpoints to keep per session - * @returns Number of checkpoints deleted - */ export async function cleanupOldCheckpoints( keepPerSession: number, ): Promise { - const kv = await getKv(); let deleted = 0; - // Group checkpoints by session - const sessions = new Map(); - for await ( - const entry of kv.list({ prefix: ["checkpoints"] }) - ) { - if (entry.key.length === 2 && entry.key[0] === "checkpoints") { - const checkpoint = entry.value; - const existing = sessions.get(checkpoint.sessionId) ?? []; - existing.push(checkpoint.id); - sessions.set(checkpoint.sessionId, existing); - } - } - - // Delete old checkpoints for each session - for (const [sessionId, checkpointIds] of sessions) { - if (checkpointIds.length > keepPerSession) { - // Get all checkpoints with timestamps - const checkpoints: SessionCheckpoint[] = []; - for (const id of checkpointIds) { - const cp = await getCheckpoint(id); - if (cp) checkpoints.push(cp); - } - - // Sort by creation time (newest first) - checkpoints.sort((a, b) => + for (const [sessionId, ids] of checkpointsBySession.entries()) { + const list = Array.from(ids) + .map((id) => checkpoints.get(id)) + .filter((checkpoint): checkpoint is SessionCheckpoint => + Boolean(checkpoint) + ) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - // Delete all but the newest N - for (const cp of checkpoints.slice(keepPerSession)) { - await kv.atomic() - .delete(["checkpoints", cp.id]) - .delete(["checkpoints:by-session", sessionId, cp.id]) - .commit(); - deleted++; - } + if (list.length <= keepPerSession) continue; + + for (const checkpoint of list.slice(keepPerSession)) { + checkpoints.delete(checkpoint.id); + ids.delete(checkpoint.id); + deleted++; + } + + if (ids.size === 0) { + checkpointsBySession.delete(sessionId); } } @@ -397,7 +311,6 @@ export async function cleanupOldCheckpoints( // ───────────────────────────────────────────────── export const kvStore = { - // Task management saveTask, getTask, updateTaskProgress, @@ -409,13 +322,11 @@ export const kvStore = { saveOptimizationTask, getOptimizationTask, - // Checkpoint management saveCheckpoint, getCheckpoint, getLatestCheckpoint, listCheckpoints, - // Cleanup cleanupOldTasks, cleanupOldCheckpoints, }; diff --git a/src/schema.ts b/src/schema.ts index b27d51b..5a55d30 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -23,18 +23,20 @@ export type Epic = z.infer; // Azure DevOps Work Item Fields // ───────────────────────────────────────────────── -export const adoFieldsSchema = z.object({ - "System.Title": z.string().min(5), - "System.Description": z.string().min(10), - "Microsoft.VSTS.Common.AcceptanceCriteria": z.string().min(10), - "Microsoft.VSTS.Scheduling.StoryPoints": z - .number() - .int() - .min(0) - .max(21) - .optional(), - "System.Tags": z.string().optional(), // semicolon-separated -}); +export const adoFieldsSchema = z + .object({ + "System.Title": z.string().min(5), + "System.Description": z.string().min(10), + "Microsoft.VSTS.Common.AcceptanceCriteria": z.string().min(10), + "Microsoft.VSTS.Scheduling.StoryPoints": z + .number() + .int() + .min(0) + .max(21) + .optional(), + "System.Tags": z.string().optional(), // semicolon-separated + }) + .partial(); export type AdoFields = z.infer; @@ -47,10 +49,12 @@ export const userStorySchema = z.object({ asA: z.string(), iWant: z.string(), soThat: z.string(), - acceptanceCriteria: z.array(z.string()).min(2), - ado: z.object({ - fields: adoFieldsSchema, - }), + acceptanceCriteria: z.array(z.string()).min(1), + ado: z + .object({ + fields: adoFieldsSchema, + }) + .optional(), }); export type UserStory = z.infer; diff --git a/src/scorer.ts b/src/scorer.ts index ff25b46..af70700 100644 --- a/src/scorer.ts +++ b/src/scorer.ts @@ -184,6 +184,9 @@ export function createStoryDecompositionScorer() { if (!p.isValid) return 0; const a = results.analyzeStepResult; + const investScore = a?.invest ?? 0; + const criteriaScore = a?.acceptanceCriteria ?? 0; + const duplicationScore = a?.duplication ?? 0; // Determine gate decision from either PoLL or single-judge FPF const pollInfo = isPoLLMetricInfo(p.pollResult?.info) @@ -206,9 +209,9 @@ export function createStoryDecompositionScorer() { // Weighted composite score const heuristicScore = HEURISTIC_WEIGHTS.coverage * p.coverageScore + - HEURISTIC_WEIGHTS.invest * a.invest + - HEURISTIC_WEIGHTS.acceptanceCriteria * a.acceptanceCriteria + - HEURISTIC_WEIGHTS.duplication * a.duplication + + HEURISTIC_WEIGHTS.invest * investScore + + HEURISTIC_WEIGHTS.acceptanceCriteria * criteriaScore + + HEURISTIC_WEIGHTS.duplication * duplicationScore + HEURISTIC_WEIGHTS.count * countScore; // Use PoLL R_eff (WLNK-aggregated) when available, else single-judge FPF @@ -234,6 +237,9 @@ export function createStoryDecompositionScorer() { if (!p.isValid) return `Score=${score}. Schema validation failed.`; const a = results.analyzeStepResult; + const investLabel = a?.invest?.toFixed(3) ?? "n/a"; + const criteriaLabel = a?.acceptanceCriteria?.toFixed(3) ?? "n/a"; + const duplicationLabel = a?.duplication?.toFixed(3) ?? "n/a"; // Extract PoLL info if available const pollInfo = isPoLLMetricInfo(p.pollResult?.info) @@ -254,13 +260,17 @@ export function createStoryDecompositionScorer() { const reasonParts = [ `Score=${score.toFixed(3)}`, `coverage=${p.coverageScore.toFixed(3)}`, - `invest=${a.invest.toFixed(3)}`, - `criteria=${a.acceptanceCriteria.toFixed(3)}`, - `dup=${a.duplication.toFixed(3)}`, + `invest=${investLabel}`, + `criteria=${criteriaLabel}`, + `dup=${duplicationLabel}`, `stories=${p.storyCount}`, `gate=${gateDecision}`, ]; + if (!a) { + reasonParts.push("analysis=unavailable"); + } + // Add PoLL-specific info when available (takes precedence) if (pollInfo) { reasonParts.push( @@ -299,7 +309,8 @@ export function createStoryDecompositionScorer() { if (p.pollError) reasonParts.push(`pollError=${p.pollError}`); if (p.fpfJudgeError) reasonParts.push(`fpfError=${p.fpfJudgeError}`); - reasonParts.push(`notes=${a.notes}`); + const notesLabel = a?.notes ?? "n/a"; + reasonParts.push(`notes=${notesLabel}`); return reasonParts.filter(Boolean).join(" | "); }); diff --git a/src/server/handler.ts b/src/server/handler.ts index 8c262ce..5201066 100644 --- a/src/server/handler.ts +++ b/src/server/handler.ts @@ -223,7 +223,9 @@ async function loadEpics(): Promise { if (cachedEpics) return cachedEpics; try { const text = await Deno.readTextFile(`${DATA_ROOT}/epics.eval.json`); - cachedEpics = JSON.parse(text); + const parsed = JSON.parse(text); + const limit = env.EPICS_LIMIT; + cachedEpics = limit ? parsed.slice(0, limit) : parsed; return cachedEpics!; } catch { return []; diff --git a/src/ui/package.json b/src/ui/package.json index aa22b23..f2c842d 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -16,11 +16,13 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", + "@fontsource/noto-sans": "^5.2.10", "ai": "^5.0.121", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.562.0", + "motion": "^12.27.0", "nanoid": "^5.1.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -33,6 +35,7 @@ "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", + "shadcn": "^3.7.0", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index 1b609ca..05a8901 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -1,4 +1,13 @@ import { useEffect, useMemo, useState } from "react"; +import { + CircleGaugeIcon, + PenLineIcon, + RepeatIcon, + SearchIcon, + TargetIcon, + TrophyIcon, + WandSparklesIcon, +} from "lucide-react"; import type { ChampionPrompt, Epic, @@ -50,11 +59,24 @@ type PlaygroundResponse = { scorerResult?: ScorerResult; }; +type OptimizationStepKey = + | "initializing" + | "evaluating_champion" + | "mining_pairs" + | "generating_patches" + | "tournament" + | "promotion" + | "meta_evolution" + | "checkpointing" + | "completed" + | "failed"; + type OptimizationTask = { taskId: string; status: "pending" | "running" | "completed" | "failed"; config?: Record; progress?: Record & { + step?: OptimizationStepKey; stepLabel?: string; iteration?: number; maxIterations?: number; @@ -72,6 +94,238 @@ type OptimizationTask = { completedAt?: string; }; +type OptimizationConfig = { + maxIterations: number; + replicates: number; + patchCandidates: number; + metaEvolutionEnabled: boolean; +}; + +type FlowStepIcon = (props: { className?: string }) => JSX.Element; + +type FlowStepDetail = { + label: string; + value: string; +}; + +type FlowStep = { + title: string; + description?: string; + meta?: string; + icon?: FlowStepIcon; + details?: FlowStepDetail[]; +}; + +type FlowDiagram = { + id: "playground" | "optimization"; + kicker: string; + title: string; + description?: string; + steps: FlowStep[]; + outcome: string; + layout?: "linear" | "cycle"; + loopLabel?: string; + inputs?: string[]; + explanation?: FlowExplanation; +}; + +type FlowExplanation = { + title: string; + summary: string; + items: Array<{ label: string; icon: FlowStepIcon }>; + note?: string; +}; + +const PLAYGROUND_STEPS: FlowStep[] = [ + { + title: "Select epic", + description: "Choose the epic to decompose.", + icon: TargetIcon, + details: [ + { label: "Input", value: "Epic list" }, + { label: "Output", value: "Chosen epic" }, + ], + }, + { + title: "Assemble prompt", + description: "Champion prompt + override.", + icon: PenLineIcon, + details: [ + { label: "Input", value: "Champion + override" }, + { label: "Output", value: "Final prompt" }, + ], + }, + { + title: "Generate stories", + description: "Model drafts the story pack.", + icon: WandSparklesIcon, + details: [ + { label: "Input", value: "Epic + prompt" }, + { label: "Output", value: "Story pack" }, + ], + }, + { + title: "Score and review", + description: "Scorer gates and returns output.", + icon: CircleGaugeIcon, + details: [ + { label: "Input", value: "Story pack" }, + { label: "Output", value: "Score + gate" }, + ], + }, +]; + +const OPTIMIZATION_STEPS: FlowStep[] = [ + { + title: "Score baseline", + description: "Run all epics to set baseline.", + icon: CircleGaugeIcon, + details: [ + { label: "Input", value: "Current prompt" }, + { label: "Output", value: "Baseline score" }, + ], + }, + { + title: "Compare outputs", + description: "Find strong vs weak examples.", + icon: SearchIcon, + details: [ + { label: "Input", value: "Scored runs" }, + { label: "Output", value: "Strengths/weaknesses" }, + ], + }, + { + title: "Try tweaks", + description: "Generate patch candidates.", + icon: PenLineIcon, + details: [ + { label: "Input", value: "Contrast pairs" }, + { label: "Output", value: "Patch candidates" }, + ], + }, + { + title: "Keep winner", + description: "Re-test and promote best.", + icon: TrophyIcon, + details: [ + { label: "Input", value: "Candidate scores" }, + { label: "Output", value: "New champion" }, + ], + }, +]; + +const OPTIMIZATION_STAGE_ORDER = [ + "score", + "compare", + "tweak", + "promote", +] as const; + +type OptimizationStageKey = typeof OPTIMIZATION_STAGE_ORDER[number]; + +const resolveOptimizationStage = ( + step?: OptimizationStepKey, +): OptimizationStageKey | null => { + switch (step) { + case "evaluating_champion": + return "score"; + case "mining_pairs": + return "compare"; + case "generating_patches": + case "meta_evolution": + return "tweak"; + case "tournament": + case "promotion": + case "checkpointing": + return "promote"; + case "completed": + case "failed": + return "promote"; + default: + return null; + } +}; + +const OPTIMIZATION_STEP_ACTIVITY: Record< + OptimizationStepKey, + { action: string; waiting: string } +> = { + initializing: { + action: "Booting optimizer and loading inputs.", + waiting: "Config + baseline prompt.", + }, + evaluating_champion: { + action: "Scoring the current champion across epics.", + waiting: "Scoring results.", + }, + mining_pairs: { + action: "Comparing strong vs weak outputs.", + waiting: "Contrast analysis.", + }, + generating_patches: { + action: "Drafting patch candidates.", + waiting: "Patch proposals.", + }, + tournament: { + action: "Testing candidate patches.", + waiting: "Candidate scores.", + }, + promotion: { + action: "Promoting the best patch.", + waiting: "Champion update.", + }, + meta_evolution: { + action: "Exploring meta-evo variations.", + waiting: "Meta patches.", + }, + checkpointing: { + action: "Saving the latest winner.", + waiting: "Checkpoint write.", + }, + completed: { + action: "Optimization complete.", + waiting: "Final summary.", + }, + failed: { + action: "Optimization failed.", + waiting: "Error details.", + }, +}; + +const getOptimizationActivity = (step?: OptimizationStepKey) => { + if (!step) { + return { + action: "Waiting for the first status update.", + waiting: "Server initialization.", + }; + } + return OPTIMIZATION_STEP_ACTIVITY[step] ?? { + action: "Running optimization.", + waiting: "Status update.", + }; +}; + +const FLOW_DIAGRAMS: FlowDiagram[] = [ + { + id: "playground", + kicker: "Playground flow", + title: "Single epic, end-to-end", + description: "Pick, run, and review in one sweep.", + steps: PLAYGROUND_STEPS, + outcome: "Results and raw output show below.", + }, + { + id: "optimization", + kicker: "Optimization flow", + title: "Champion search loop", + description: "Simple loop to keep the best prompt.", + layout: "cycle", + loopLabel: "Repeat until it stops improving.", + steps: OPTIMIZATION_STEPS, + outcome: "Best prompt stays active.", + }, +]; + const formatNumber = (value?: number, digits = 2) => { if (typeof value !== "number" || Number.isNaN(value)) return "n/a"; return value.toFixed(digits); @@ -92,6 +346,8 @@ const wrapCodeBlock = (value: string) => `~~~markdown\n${value}\n~~~`; type ThemeMode = "light" | "dark"; const THEME_STORAGE_KEY = "promptagent-theme"; +const TELEMETRY_POLL_MS = 30000; +const OPTIMIZATION_POLL_MS = 10000; const getStoredTheme = (): ThemeMode | null => { if (!("localStorage" in globalThis)) return null; @@ -129,37 +385,417 @@ const gateTone = (decision: string) => { const StoryCard = ( { story, index }: { story: StoryPack["userStories"][number]; index: number }, -) => ( - - -
- - {index + 1}. {story.title} - - - As a {story.asA}, I want {story.iWant} so that {story.soThat}. - +) => { + const storyPoints = + story.ado?.fields?.["Microsoft.VSTS.Scheduling.StoryPoints"]; + const criteria = Array.isArray(story.acceptanceCriteria) + ? story.acceptanceCriteria + : []; + + return ( + + +
+ + {index + 1}. {story.title} + + + As a {story.asA}, I want {story.iWant} so that {story.soThat}. + +
+ {typeof storyPoints === "number" && ( + + {storyPoints} pts + + )} +
+ +

+ Acceptance criteria +

+ {criteria.length ? ( +
    + {criteria.map((item, itemIndex) => ( +
  • {item}
  • + ))} +
+ ) : ( +

+ No acceptance criteria provided. +

+ )} +
+
+ ); +}; + +const FlowStepCard = ( + { + step, + index, + size = "md", + className, + statusLabel, + statusTone, + }: { + step: FlowStep; + index: number; + size?: "sm" | "md"; + className?: string; + statusLabel?: string; + statusTone?: "active" | "done" | "pending"; + }, +) => { + const StepIcon = step.icon; + const isSmall = size === "sm"; + const badgeClass = isSmall + ? "h-6 w-6 text-[0.65rem]" + : "h-7 w-7 text-xs"; + const titleClass = isSmall ? "text-xs" : "text-sm"; + const descClass = isSmall ? "text-[0.65rem]" : "text-xs"; + const metaClass = isSmall ? "text-[0.55rem]" : "text-[0.6rem]"; + const detailLabelClass = isSmall ? "text-[0.55rem]" : "text-[0.6rem]"; + const detailValueClass = isSmall ? "text-[0.65rem]" : "text-[0.7rem]"; + const iconClass = isSmall ? "h-3.5 w-3.5" : "h-4 w-4"; + const statusToneClass = statusTone === "active" + ? "border-primary/30 bg-primary/10 text-primary" + : statusTone === "done" + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700" + : "border-border/60 bg-muted/40 text-muted-foreground"; + + return ( +
+
+ + {index + 1} + + {StepIcon && }
- {typeof story.ado.fields["Microsoft.VSTS.Scheduling.StoryPoints"] === - "number" && ( - - {story.ado.fields["Microsoft.VSTS.Scheduling.StoryPoints"]} pts + {statusLabel && ( + + {statusLabel} + + )} +

+ {step.title} +

+ {step.description && ( +

+ {step.description} +

+ )} + {step.details && step.details.length > 0 && ( +
+ {step.details.map((detail) => ( +
+ + {detail.label} + + + {detail.value} + +
+ ))} +
+ )} + {step.meta && ( + + {step.meta} )} - - -

- Acceptance criteria +

+ ); +}; + +const FlowDiagramCard = ({ diagram }: { diagram: FlowDiagram }) => ( +
+
+

+ {diagram.kicker}

-
    - {story.acceptanceCriteria.map((item, itemIndex) => ( -
  • {item}
  • +

    + {diagram.title} +

    + {diagram.description && ( +

    {diagram.description}

    + )} +
+ {diagram.inputs && diagram.inputs.length > 0 && ( +
+ {diagram.inputs.map((input) => ( + + {input} + ))} - - - +
+ )} + + {diagram.layout === "cycle" + ? ( +
+
    + {diagram.steps.map((step, index) => ( +
  1. + + {index < diagram.steps.length - 1 && ( + <> + + + + )} +
  2. + ))} +
+
+ + + + + Loop + + + {diagram.loopLabel ?? "Repeat until the iteration cap is reached."} + +
+
+ ) + : ( +
    + {diagram.steps.map((step, index) => ( +
  1. + + {index < diagram.steps.length - 1 && ( + <> + + + + )} +
  2. + ))} +
+ )} +

+ Outcome: {diagram.outcome} +

+
); +const OptimizationKickoff = ( + { + currentStep, + currentStepLabel, + progress, + config, + }: { + currentStep?: OptimizationStepKey; + currentStepLabel?: string; + progress?: OptimizationTask["progress"]; + config: OptimizationConfig; + }, +) => { + const activeStage = resolveOptimizationStage(currentStep); + const activeIndex = activeStage + ? OPTIMIZATION_STAGE_ORDER.indexOf(activeStage) + : -1; + const activeStep = activeIndex >= 0 ? OPTIMIZATION_STEPS[activeIndex] : null; + const nextStep = activeIndex === -1 + ? OPTIMIZATION_STEPS[0] + : OPTIMIZATION_STEPS[activeIndex + 1]; + const iteration = progress?.iteration ?? 0; + const maxIterations = progress?.maxIterations ?? config.maxIterations; + const iterationPct = maxIterations > 0 + ? Math.min(1, iteration / maxIterations) + : 0; + const focusTitle = activeStep?.title ?? "Preparing run"; + const focusDescription = activeStep?.description ?? + "Warming up the run and loading inputs."; + const focusDetails = activeStep?.details ?? [ + { label: "Input", value: "Run config" }, + { label: "Output", value: "Baseline ready" }, + ]; + const activity = getOptimizationActivity(currentStep); + + return ( +
+
+
+

+ During optimization +

+

+ {focusTitle} +

+

+ {focusDescription} +

+
+ {focusDetails.map((detail) => ( + + {detail.label}: {detail.value} + + ))} +
+
+
+

System step

+

+ {currentStepLabel ?? "Initializing"} +

+ {nextStep && ( +

+ Next:{" "} + + {nextStep.title} + +

+ )} +
+
+ +
+
+
+ Iteration + + {iteration} / {maxIterations} + +
+
+
+
+
+ + {config.replicates} reruns + + + {config.patchCandidates} variations + + {config.metaEvolutionEnabled && ( + + meta-evo on + + )} +
+
+
+

Run signals

+
+
+ Elapsed + + {formatMs(progress?.totalElapsed)} + +
+
+ Champion score + + {formatNumber(progress?.championObjective, 3)} + +
+
+
+
+

Server activity

+
+
+ + Doing + +

+ {activity.action} +

+
+
+ + Waiting on + +

+ {activity.waiting} +

+
+
+
+
+ +

+ The optimizer runs this loop automatically: +

+
+ {OPTIMIZATION_STEPS.map((step, index) => { + const isActive = activeIndex === index; + const isDone = activeIndex !== -1 && index < activeIndex; + const isNext = activeIndex !== -1 && index === activeIndex + 1; + const toneClass = isActive + ? "border-primary/40 bg-primary/10" + : isDone + ? "border-emerald-500/30 bg-emerald-500/5" + : "border-border bg-background"; + const statusLabel = isActive + ? "Now" + : isDone + ? "Done" + : isNext + ? "Next" + : undefined; + const statusTone = isActive + ? "active" + : isDone + ? "done" + : "pending"; + + return ( + + ); + })} +
+
+ ); +}; + const PlaygroundResultView = ({ result }: { result: PlaygroundResponse }) => { const score = formatNumber(result.scorerResult?.score, 3); const gateDecision = result.scorerResult?.gateDecision ?? "n/a"; @@ -248,7 +884,7 @@ export default function App() { const [playgroundLoading, setPlaygroundLoading] = useState(false); const [playgroundError, setPlaygroundError] = useState(""); - const [optConfig, setOptConfig] = useState({ + const [optConfig, setOptConfig] = useState({ maxIterations: 4, replicates: 3, patchCandidates: 4, @@ -399,6 +1035,7 @@ export default function App() { useEffect(() => { let cancelled = false; + let intervalId: number | null = null; const poll = async () => { try { const res = await fetch("/telemetry"); @@ -412,12 +1049,33 @@ export default function App() { } }; - poll(); - const intervalId = globalThis.setInterval(poll, 5000); + const start = () => { + if (intervalId) return; + poll(); + intervalId = globalThis.setInterval(poll, TELEMETRY_POLL_MS); + }; + + const stop = () => { + if (!intervalId) return; + clearInterval(intervalId); + intervalId = null; + }; + + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + stop(); + } else { + start(); + } + }; + + start(); + document.addEventListener("visibilitychange", handleVisibility); return () => { cancelled = true; - clearInterval(intervalId); + stop(); + document.removeEventListener("visibilitychange", handleVisibility); }; }, []); @@ -499,8 +1157,12 @@ export default function App() { let cancelled = false; let intervalId: number | null = null; + let inFlight = false; + let done = false; const poll = async () => { + if (inFlight || done) return; + inFlight = true; try { const res = await fetch(`/v3/optimize/${optimizationTask.taskId}`); const data = await readJson(res); @@ -514,7 +1176,8 @@ export default function App() { } if (data.status === "completed" || data.status === "failed") { - if (intervalId) clearInterval(intervalId); + done = true; + stop(); } } catch (err) { if (!cancelled) { @@ -522,16 +1185,40 @@ export default function App() { err instanceof Error ? err.message : String(err), ); } - if (intervalId) clearInterval(intervalId); + done = true; + stop(); + } finally { + inFlight = false; + } + }; + + const start = () => { + if (intervalId || done) return; + poll(); + intervalId = globalThis.setInterval(poll, OPTIMIZATION_POLL_MS); + }; + + const stop = () => { + if (!intervalId) return; + clearInterval(intervalId); + intervalId = null; + }; + + const handleVisibility = () => { + if (document.visibilityState === "hidden") { + stop(); + } else { + start(); } }; - poll(); - intervalId = globalThis.setInterval(poll, 2500); + start(); + document.addEventListener("visibilitychange", handleVisibility); return () => { cancelled = true; - if (intervalId) clearInterval(intervalId); + stop(); + document.removeEventListener("visibilitychange", handleVisibility); }; }, [optimizationTask?.taskId]); @@ -609,6 +1296,14 @@ export default function App() {
+
+ {FLOW_DIAGRAMS.filter((diagram) => diagram.id === "playground").map( + (diagram) => ( + + ), + )} +
+
)} + {(optimizationLoading || + optimizationTask?.status === "running") && ( + + )}
+ +
+ {FLOW_DIAGRAMS.filter( + (diagram) => diagram.id === "optimization", + ).map((diagram) => ( + + ))} +
diff --git a/src/ui/src/types.ts b/src/ui/src/types.ts index 653104d..c4100e6 100644 --- a/src/ui/src/types.ts +++ b/src/ui/src/types.ts @@ -12,9 +12,9 @@ export type Epic = { }; export type AdoFields = { - "System.Title": string; - "System.Description": string; - "Microsoft.VSTS.Common.AcceptanceCriteria": string; + "System.Title"?: string; + "System.Description"?: string; + "Microsoft.VSTS.Common.AcceptanceCriteria"?: string; "Microsoft.VSTS.Scheduling.StoryPoints"?: number; "System.Tags"?: string; }; @@ -25,7 +25,7 @@ export type UserStory = { iWant: string; soThat: string; acceptanceCriteria: string[]; - ado: { + ado?: { fields: AdoFields; }; }; diff --git a/tests/e2e/optimization-flow.spec.ts b/tests/e2e/optimization-flow.spec.ts new file mode 100644 index 0000000..efdcc93 --- /dev/null +++ b/tests/e2e/optimization-flow.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test("optimization flow completes from UI", async ({ page }) => { + test.setTimeout(360_000); + + await page.goto("/"); + + await page.getByLabel("Iterations").fill("1"); + await page.getByLabel("Replicates").fill("1"); + await page.getByLabel("Patch candidates").fill("1"); + + const startButton = page.getByRole("button", { name: /Start optimization/i }); + await expect(startButton).toBeVisible(); + await startButton.click(); + + await expect(page.getByText(/Optimization running/i)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("System step")).toBeVisible(); + + await expect( + page.getByText("completed", { exact: true }).first(), + ).toBeVisible({ timeout: 360_000 }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts index 836548e..b9572af 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -129,13 +129,13 @@ Deno.test("userStorySchema - validates complete user story", () => { assert(result.success, "Valid user story should pass"); }); -Deno.test("userStorySchema - rejects story with < 2 acceptance criteria", () => { +Deno.test("userStorySchema - rejects story with empty acceptance criteria", () => { const story = { title: "Add login functionality", asA: "user", iWant: "to login", soThat: "I can access stuff", - acceptanceCriteria: ["Only one criterion"], // Invalid: min 2 + acceptanceCriteria: [], // Invalid: min 1 ado: { fields: { "System.Title": "Add login functionality", @@ -148,7 +148,7 @@ Deno.test("userStorySchema - rejects story with < 2 acceptance criteria", () => const result = userStorySchema.safeParse(story); assert( !result.success, - "Story with < 2 acceptance criteria should be invalid", + "Story with empty acceptance criteria should be invalid", ); }); @@ -229,3 +229,68 @@ Deno.test("storyPackSchema - provides defaults for optional arrays", () => { assertEquals(result.data.risks, []); assertEquals(result.data.followUps, []); }); + +// ───────────────────────────────────────────────── +// Schema Relaxation Boundary Tests +// ───────────────────────────────────────────────── + +Deno.test("userStorySchema - accepts story with exactly 1 acceptance criterion", () => { + const story = { + title: "Single AC story", + asA: "user", + iWant: "to do something", + soThat: "I get value", + acceptanceCriteria: ["Given X, When Y, Then Z"], + }; + + const result = userStorySchema.safeParse(story); + assert(result.success, "Story with 1 acceptance criterion should be valid"); +}); + +Deno.test("userStorySchema - accepts story without ado field", () => { + const story = { + title: "No ADO story", + asA: "user", + iWant: "to do something", + soThat: "I get value", + acceptanceCriteria: ["Given X, When Y, Then Z"], + }; + + const result = userStorySchema.safeParse(story); + assert(result.success, "Story without ado should be valid"); + assertEquals(result.data?.ado, undefined); +}); + +Deno.test("userStorySchema - accepts story with partial ado fields", () => { + const story = { + title: "Partial ADO story", + asA: "user", + iWant: "to do something", + soThat: "I get value", + acceptanceCriteria: ["Given X, When Y, Then Z"], + ado: { + fields: { + "System.Title": "Partial ADO story", + }, + }, + }; + + const result = userStorySchema.safeParse(story); + assert(result.success, "Story with partial ado fields should be valid"); +}); + +Deno.test("userStorySchema - accepts story with empty ado fields", () => { + const story = { + title: "Empty ADO fields story", + asA: "user", + iWant: "to do something", + soThat: "I get value", + acceptanceCriteria: ["Given X, When Y, Then Z"], + ado: { + fields: {}, + }, + }; + + const result = userStorySchema.safeParse(story); + assert(result.success, "Story with empty ado fields should be valid"); +});