From 8bfd983d82e06b2630d5ddce7ab8728376342e5d Mon Sep 17 00:00:00 2001 From: AskTinNguyen Date: Mon, 19 Jan 2026 19:49:27 +0700 Subject: [PATCH] feat(history): add historical log context for Ralph iterations Enable fresh Ralph iterations to optionally access historical logs from earlier runs to avoid repeating failed approaches. Features: - Three modes: off (default), smart (recommended), full - Configurable token budget (default 4000) - Parses existing run-*.md summary files - CLI flags: --with-history, --no-history, --history-mode Configuration in .agents/ralph/config.sh: - RALPH_HISTORY_MODE: off|smart|full - RALPH_HISTORY_TOKEN_BUDGET: max tokens for context - RALPH_HISTORY_MAX_RUNS: max historical runs to index Smart mode prioritizes: 1. Failures for current story 2. Successful approaches for current story 3. Recent failures from other stories Co-Authored-By: Claude Opus 4.5 --- .agents/ralph/PROMPT_build.md | 6 + .agents/ralph/config.sh | 28 +++ .agents/ralph/loop.sh | 20 ++ bin/ralph | 24 +++ lib/history/context-cli.js | 391 ++++++++++++++++++++++++++++++++++ lib/history/index.js | 36 ++++ lib/history/indexer.js | 316 +++++++++++++++++++++++++++ 7 files changed, 821 insertions(+) create mode 100644 lib/history/context-cli.js create mode 100644 lib/history/index.js create mode 100644 lib/history/indexer.js diff --git a/.agents/ralph/PROMPT_build.md b/.agents/ralph/PROMPT_build.md index a06aae3..3dabf5d 100644 --- a/.agents/ralph/PROMPT_build.md +++ b/.agents/ralph/PROMPT_build.md @@ -33,6 +33,12 @@ Story details: If the story details are empty or missing, STOP and report that the PRD story format could not be parsed. +## Historical Context (from previous runs) + +{{HISTORICAL_CONTEXT}} + +> If historical context shows previous failures for this story, review the approaches that failed and try different solutions. Learn from what didn't work to avoid repeating the same mistakes. + ## Rules (Non-Negotiable) - Implement **only** the work required to complete the selected story. diff --git a/.agents/ralph/config.sh b/.agents/ralph/config.sh index ff70dd8..3bc4e79 100644 --- a/.agents/ralph/config.sh +++ b/.agents/ralph/config.sh @@ -278,3 +278,31 @@ RALPH_MERGE_REQUIRE_CONFIRM=true # Note: Worktrees provide strongest isolation and are recommended when possible. # Sequential mode is for projects where worktrees are not feasible. # ───────────────────────────────────────────────────────────────────────────── + +# ───────────────────────────────────────────────────────────────────────────── +# Historical Log Context Configuration +# ───────────────────────────────────────────────────────────────────────────── +# Enable historical context from previous runs to help agents avoid repeating +# failed approaches. Context is generated from run-*.md summary files. +# +# History mode (choose one): +# off - No historical context (default, no overhead) +# smart - Focused context on failures and current story (recommended) +# full - Complete historical context for all indexed runs +# +# Default: off (no change to existing behavior) +RALPH_HISTORY_MODE=off +# +# Maximum tokens to allocate for historical context (default: 4000): +# Higher values provide more context but consume more of the prompt budget. +RALPH_HISTORY_TOKEN_BUDGET=4000 +# +# Maximum historical runs to consider when building index (default: 10): +# More runs = better context but slower indexing. +RALPH_HISTORY_MAX_RUNS=10 +# +# Override via CLI: +# ralph build 5 --with-history # Sets mode to "smart" +# ralph build 5 --no-history # Sets mode to "off" +# ralph build 5 --history-mode=full # Sets mode explicitly +# ───────────────────────────────────────────────────────────────────────────── diff --git a/.agents/ralph/loop.sh b/.agents/ralph/loop.sh index f7903d9..efacf73 100755 --- a/.agents/ralph/loop.sh +++ b/.agents/ralph/loop.sh @@ -978,6 +978,7 @@ render_prompt() { local run_meta="$8" python3 - "$src" "$dst" "$PRD_PATH" "$PLAN_PATH" "$AGENTS_PATH" "$PROGRESS_PATH" "$ROOT_DIR" "$GUARDRAILS_PATH" "$ERRORS_LOG_PATH" "$ACTIVITY_LOG_PATH" "$GUARDRAILS_REF" "$CONTEXT_REF" "$ACTIVITY_CMD" "$NO_COMMIT" "$story_meta" "$story_block" "$run_id" "$iter" "$run_log" "$run_meta" <<'PY' import sys +import os from pathlib import Path src = Path(sys.argv[1]).read_text() @@ -995,6 +996,8 @@ run_id = sys.argv[17] if len(sys.argv) > 17 else "" iteration = sys.argv[18] if len(sys.argv) > 18 else "" run_log = sys.argv[19] if len(sys.argv) > 19 else "" run_meta = sys.argv[20] if len(sys.argv) > 20 else "" +# Get historical context from environment (US-XXX Historical Log Context) +historical_context = os.environ.get("HISTORICAL_CONTEXT", "") repl = { "PRD_PATH": prd, "PLAN_PATH": plan, @@ -1012,6 +1015,7 @@ repl = { "ITERATION": iteration, "RUN_LOG_PATH": run_log, "RUN_META_PATH": run_meta, + "HISTORICAL_CONTEXT": historical_context, } story = {"id": "", "title": "", "block": ""} if meta_path: @@ -3545,6 +3549,22 @@ for i in $(seq $START_ITERATION "$MAX_ITERATIONS"); do PROMPT_RENDERED="$TMP_DIR/prompt-$RUN_TAG-$i.md" LOG_FILE="$RUNS_DIR/run-$RUN_TAG-iter-$i.log" RUN_META="$RUNS_DIR/run-$RUN_TAG-iter-$i.md" + + # Generate historical context if enabled (US-XXX Historical Log Context) + HISTORICAL_CONTEXT="" + if [[ "${RALPH_HISTORY_MODE:-off}" != "off" ]] && [ -n "${STORY_ID:-}" ]; then + PRD_FOLDER="$(dirname "$PRD_PATH")" + HISTORICAL_CONTEXT=$(node "$SCRIPT_DIR/../../lib/history/context-cli.js" \ + --prd-folder "$PRD_FOLDER" \ + --story-id "$STORY_ID" \ + --mode "${RALPH_HISTORY_MODE}" \ + --token-budget "${RALPH_HISTORY_TOKEN_BUDGET:-4000}" 2>/dev/null || echo "") + if [ -n "$HISTORICAL_CONTEXT" ]; then + log_activity "HISTORY_CONTEXT generated mode=${RALPH_HISTORY_MODE} story=$STORY_ID tokens=$(echo -n "$HISTORICAL_CONTEXT" | wc -c | tr -d ' ')" + fi + fi + export HISTORICAL_CONTEXT + render_prompt "$PROMPT_FILE" "$PROMPT_RENDERED" "$STORY_META" "$STORY_BLOCK" "$RUN_TAG" "$i" "$LOG_FILE" "$RUN_META" if [ "$MODE" = "build" ] && [ -n "${STORY_ID:-}" ]; then diff --git a/bin/ralph b/bin/ralph index 57b36fe..71c0b7d 100755 --- a/bin/ralph +++ b/bin/ralph @@ -77,6 +77,7 @@ const globalFlags = { uiOpenBrowser: false, watchBuildMode: false, estimatePricing: null, + historyMode: null, // Historical log context: off, smart, full }; // Parse global flags from rawArgs @@ -157,6 +158,8 @@ for (let i = 0; i < rawArgs.length; i++) { if (arg === "--no-retry") { globalFlags.noRetry = true; continue; } if (arg === "--build") { globalFlags.watchBuildMode = true; continue; } if (arg === "--skip-risk-check") { globalFlags.skipRiskCheck = true; continue; } + if (arg === "--with-history") { globalFlags.historyMode = "smart"; continue; } + if (arg === "--no-history") { globalFlags.historyMode = "off"; continue; } // --import-from flag if (arg.startsWith("--import-from=")) { @@ -198,6 +201,26 @@ for (let i = 0; i < rawArgs.length; i++) { continue; } + // --history-mode flag (Historical Log Context) + if (arg.startsWith("--history-mode=")) { + const mode = arg.split("=").slice(1).join("="); + if (!["off", "smart", "full"].includes(mode)) { + error(`Invalid --history-mode value: ${mode}. Must be: off, smart, or full`); + process.exit(1); + } + globalFlags.historyMode = mode; + continue; + } + if (arg === "--history-mode") { + const mode = rawArgs[++i]; + if (!["off", "smart", "full"].includes(mode)) { + error(`Invalid --history-mode value: ${mode}. Must be: off, smart, or full`); + process.exit(1); + } + globalFlags.historyMode = mode; + continue; + } + // --parallel flag if (arg.startsWith("--parallel=")) { const val = arg.split("=").slice(1).join("="); @@ -326,6 +349,7 @@ if (globalFlags.resumeMode) env.RALPH_RESUME = "1"; if (globalFlags.modelOverride) env.RALPH_MODEL_OVERRIDE = globalFlags.modelOverride; if (globalFlags.autoFixMode) env.AUTO_FIX = globalFlags.autoFixMode; if (globalFlags.skipRiskCheck) env.RALPH_SKIP_RISK = "1"; +if (globalFlags.historyMode) env.RALPH_HISTORY_MODE = globalFlags.historyMode; if (templateDir === globalDir) { env.PROMPT_PLAN = path.join(templateDir, "PROMPT_plan.md"); diff --git a/lib/history/context-cli.js b/lib/history/context-cli.js new file mode 100644 index 0000000..4db3997 --- /dev/null +++ b/lib/history/context-cli.js @@ -0,0 +1,391 @@ +#!/usr/bin/env node +/** + * Historical Context CLI + * + * Generates markdown context from historical run logs for injection into + * the build prompt. This helps agents learn from previous iterations and + * avoid repeating failed approaches. + * + * Usage: + * node context-cli.js --prd-folder --story-id --mode --token-budget + * + * Modes: + * - off: No historical context (returns empty string) + * - smart: Focused context on failures and current story (recommended) + * - full: Complete historical context for all runs + */ +const { buildIndex, getRunsForStory, getFailedRuns } = require("./indexer"); + +// Default configuration +const DEFAULT_TOKEN_BUDGET = 4000; +const DEFAULT_MAX_RUNS = 10; +const TOKENS_PER_CHAR = 0.25; // Rough estimate: 4 chars per token + +/** + * Estimate token count for a string + * @param {string} text - Text to estimate + * @returns {number} Estimated token count + */ +function estimateTokens(text) { + return Math.ceil(text.length * TOKENS_PER_CHAR); +} + +/** + * Score a run for relevance to the current context + * Higher scores = more relevant + * @param {Object} run - Run object from indexer + * @param {string} currentStoryId - The story being worked on + * @returns {number} Relevance score + */ +function scoreRun(run, currentStoryId) { + let score = 0; + + // Same story: highest priority + if (run.storyId && run.storyId === currentStoryId) { + score += 10; + } + + // Failures are more valuable for learning + if (run.status === "error" || run.status === "failure" || run.status === "failed") { + score += 8; + } + + // Recent runs are more relevant (decay based on iteration) + if (run.iteration) { + score += Math.max(0, 5 - Math.floor(run.iteration / 3)); + } + + // Runs with retries indicate difficulty + if (run.retryCount && run.retryCount > 0) { + score += 2; + } + + return score; +} + +/** + * Format a single run for context output + * @param {Object} run - Run object from indexer + * @param {boolean} verbose - Include full details + * @returns {string} Formatted markdown + */ +function formatRun(run, verbose = false) { + const lines = []; + + // Header with status indicator + const statusEmoji = run.status === "success" ? "✅" : "❌"; + lines.push(`### ${statusEmoji} Iteration ${run.iteration}: ${run.storyId || "Unknown"}`); + + // Basic info + lines.push(`- **Status**: ${run.status}`); + if (run.storyTitle) { + lines.push(`- **Story**: ${run.storyTitle}`); + } + if (run.duration) { + lines.push(`- **Duration**: ${run.duration}s`); + } + + // Commits (important for understanding what was attempted) + if (run.commits && run.commits.length > 0) { + lines.push("- **Commits**:"); + for (const commit of run.commits.slice(0, 3)) { + lines.push(` - \`${commit.hash}\` ${commit.message}`); + } + if (run.commits.length > 3) { + lines.push(` - ... and ${run.commits.length - 3} more`); + } + } + + // Changed files (helps understand scope) + if (run.changedFiles && run.changedFiles.length > 0 && verbose) { + lines.push("- **Files changed**:"); + for (const file of run.changedFiles.slice(0, 5)) { + lines.push(` - ${file}`); + } + if (run.changedFiles.length > 5) { + lines.push(` - ... and ${run.changedFiles.length - 5} more`); + } + } + + // Retry info (indicates difficulty) + if (run.retryCount && run.retryCount > 0) { + lines.push(`- **Retries**: ${run.retryCount}`); + } + + // Model/routing info (for context) + if (run.routingDecision && verbose) { + lines.push(`- **Model**: ${run.routingDecision.model} (complexity: ${run.routingDecision.complexityScore}/10)`); + } + + return lines.join("\n"); +} + +/** + * Generate smart context focused on failures and current story + * @param {string} prdFolder - Path to PRD folder + * @param {string} storyId - Current story ID + * @param {number} tokenBudget - Maximum tokens for context + * @returns {string} Markdown context + */ +function generateSmartContext(prdFolder, storyId, tokenBudget) { + const sections = []; + let usedTokens = 0; + + // Header + const header = `> Historical context from previous runs (mode: smart)\n`; + usedTokens += estimateTokens(header); + sections.push(header); + + // Get runs for this story (highest priority) + const storyRuns = getRunsForStory(prdFolder, storyId, { maxRuns: DEFAULT_MAX_RUNS }); + const storyFailures = storyRuns.filter((r) => + r.status === "error" || r.status === "failure" || r.status === "failed" + ); + + // Get all failures (for learning from other stories) + const allFailures = getFailedRuns(prdFolder, null, { maxRuns: DEFAULT_MAX_RUNS }); + const otherFailures = allFailures.filter((r) => r.storyId !== storyId); + + // Section 1: Failures for current story (most important) + if (storyFailures.length > 0) { + let sectionHeader = `\n## Previous Failures for ${storyId}\n\n`; + sectionHeader += `This story has failed ${storyFailures.length} time(s). Review these approaches to avoid repeating them:\n\n`; + usedTokens += estimateTokens(sectionHeader); + + if (usedTokens < tokenBudget) { + sections.push(sectionHeader); + + for (const run of storyFailures) { + const formatted = formatRun(run, true); + const runTokens = estimateTokens(formatted); + + if (usedTokens + runTokens > tokenBudget) break; + + sections.push(formatted + "\n"); + usedTokens += runTokens; + } + } + } + + // Section 2: Successful approaches for current story + const storySuccesses = storyRuns.filter((r) => r.status === "success"); + if (storySuccesses.length > 0 && usedTokens < tokenBudget * 0.8) { + const sectionHeader = `\n## Successful Approaches for ${storyId}\n\n`; + usedTokens += estimateTokens(sectionHeader); + + if (usedTokens < tokenBudget) { + sections.push(sectionHeader); + + // Only include the most recent success + const latestSuccess = storySuccesses[0]; + const formatted = formatRun(latestSuccess, false); + const runTokens = estimateTokens(formatted); + + if (usedTokens + runTokens <= tokenBudget) { + sections.push(formatted + "\n"); + usedTokens += runTokens; + } + } + } + + // Section 3: Failures from other stories (patterns to avoid) + if (otherFailures.length > 0 && usedTokens < tokenBudget * 0.9) { + let sectionHeader = `\n## Recent Failures from Other Stories\n\n`; + sectionHeader += `These failures may contain patterns or gotchas relevant to your work:\n\n`; + usedTokens += estimateTokens(sectionHeader); + + if (usedTokens < tokenBudget) { + sections.push(sectionHeader); + + // Include up to 3 other failures + for (const run of otherFailures.slice(0, 3)) { + const formatted = formatRun(run, false); + const runTokens = estimateTokens(formatted); + + if (usedTokens + runTokens > tokenBudget) break; + + sections.push(formatted + "\n"); + usedTokens += runTokens; + } + } + } + + // No historical context available + if (sections.length === 1) { + return ""; // Only header, no actual content + } + + return sections.join("\n"); +} + +/** + * Generate full context with all available history + * @param {string} prdFolder - Path to PRD folder + * @param {string} storyId - Current story ID + * @param {number} tokenBudget - Maximum tokens for context + * @returns {string} Markdown context + */ +function generateFullContext(prdFolder, storyId, tokenBudget) { + const sections = []; + let usedTokens = 0; + + // Header + const header = `> Historical context from previous runs (mode: full)\n`; + usedTokens += estimateTokens(header); + sections.push(header); + + // Build full index + const index = buildIndex(prdFolder, { maxRuns: DEFAULT_MAX_RUNS }); + + // Summary section + const summary = `\n## Run Summary\n\n` + + `- Total runs indexed: ${index.totalRuns}\n` + + `- Successful: ${index.successfulRuns}\n` + + `- Failed: ${index.failedRuns}\n` + + `- Stories covered: ${Object.keys(index.byStory).length}\n\n`; + usedTokens += estimateTokens(summary); + sections.push(summary); + + // Score and sort all runs + const scoredRuns = index.runs + .map((run) => ({ run, score: scoreRun(run, storyId) })) + .sort((a, b) => b.score - a.score); + + // Include runs by relevance score + const runsSection = `## Runs by Relevance\n\n`; + usedTokens += estimateTokens(runsSection); + sections.push(runsSection); + + for (const { run } of scoredRuns) { + const formatted = formatRun(run, true); + const runTokens = estimateTokens(formatted); + + if (usedTokens + runTokens > tokenBudget) break; + + sections.push(formatted + "\n"); + usedTokens += runTokens; + } + + return sections.join("\n"); +} + +/** + * Generate historical context based on mode + * @param {Object} options - Generation options + * @param {string} options.prdFolder - Path to PRD folder + * @param {string} options.storyId - Current story ID + * @param {string} options.mode - Context mode: off, smart, or full + * @param {number} options.tokenBudget - Maximum tokens for context + * @returns {string} Markdown context + */ +function generateContext(options) { + const { + prdFolder, + storyId, + mode = "smart", + tokenBudget = DEFAULT_TOKEN_BUDGET, + } = options; + + if (mode === "off") { + return ""; + } + + if (mode === "full") { + return generateFullContext(prdFolder, storyId, tokenBudget); + } + + // Default to smart mode + return generateSmartContext(prdFolder, storyId, tokenBudget); +} + +// CLI handling +if (require.main === module) { + const args = process.argv.slice(2); + + // Parse arguments + const options = { + prdFolder: null, + storyId: null, + mode: "smart", + tokenBudget: DEFAULT_TOKEN_BUDGET, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--prd-folder" && args[i + 1]) { + options.prdFolder = args[++i]; + } else if (arg.startsWith("--prd-folder=")) { + options.prdFolder = arg.split("=").slice(1).join("="); + } else if (arg === "--story-id" && args[i + 1]) { + options.storyId = args[++i]; + } else if (arg.startsWith("--story-id=")) { + options.storyId = arg.split("=").slice(1).join("="); + } else if (arg === "--mode" && args[i + 1]) { + options.mode = args[++i]; + } else if (arg.startsWith("--mode=")) { + options.mode = arg.split("=").slice(1).join("="); + } else if (arg === "--token-budget" && args[i + 1]) { + options.tokenBudget = parseInt(args[++i], 10); + } else if (arg.startsWith("--token-budget=")) { + options.tokenBudget = parseInt(arg.split("=").slice(1).join("="), 10); + } else if (arg === "--help" || arg === "-h") { + console.log(` +Historical Context Generator + +Usage: + node context-cli.js --prd-folder --story-id [options] + +Options: + --prd-folder Path to PRD-N folder (required) + --story-id Current story ID, e.g., US-001 (required) + --mode Context mode: off, smart (default), or full + --token-budget Maximum tokens for context (default: ${DEFAULT_TOKEN_BUDGET}) + --help, -h Show this help message + +Modes: + off No historical context (returns empty string) + smart Focused context on failures and current story (recommended) + full Complete historical context for all runs +`); + process.exit(0); + } + } + + // Validate required args + if (!options.prdFolder) { + console.error("Error: --prd-folder is required"); + process.exit(1); + } + + if (!options.storyId) { + console.error("Error: --story-id is required"); + process.exit(1); + } + + // Validate mode + if (!["off", "smart", "full"].includes(options.mode)) { + console.error(`Error: Invalid mode "${options.mode}". Must be: off, smart, or full`); + process.exit(1); + } + + // Generate and output context + try { + const context = generateContext(options); + process.stdout.write(context); + } catch (err) { + console.error(`Error generating context: ${err.message}`); + process.exit(1); + } +} + +module.exports = { + generateContext, + generateSmartContext, + generateFullContext, + scoreRun, + formatRun, + estimateTokens, + DEFAULT_TOKEN_BUDGET, + DEFAULT_MAX_RUNS, +}; diff --git a/lib/history/index.js b/lib/history/index.js new file mode 100644 index 0000000..57ebb1f --- /dev/null +++ b/lib/history/index.js @@ -0,0 +1,36 @@ +/** + * Historical Log Context Module + * + * Enables fresh Ralph iterations to optionally access historical logs from + * earlier runs to avoid repeating failed approaches. + * + * Three modes: + * - off (default): No historical context + * - smart (recommended): Focused context on failures and current story + * - full: Complete historical context for all runs + */ +const indexer = require("./indexer"); +const contextCli = require("./context-cli"); + +module.exports = { + // Indexer functions + indexRun: indexer.indexRun, + buildIndex: indexer.buildIndex, + getRunsForStory: indexer.getRunsForStory, + getFailedRuns: indexer.getFailedRuns, + getSuccessfulRuns: indexer.getSuccessfulRuns, + + // Context generation + generateContext: contextCli.generateContext, + generateSmartContext: contextCli.generateSmartContext, + generateFullContext: contextCli.generateFullContext, + + // Utilities + scoreRun: contextCli.scoreRun, + formatRun: contextCli.formatRun, + estimateTokens: contextCli.estimateTokens, + + // Constants + DEFAULT_TOKEN_BUDGET: contextCli.DEFAULT_TOKEN_BUDGET, + DEFAULT_MAX_RUNS: contextCli.DEFAULT_MAX_RUNS, +}; diff --git a/lib/history/indexer.js b/lib/history/indexer.js new file mode 100644 index 0000000..fc4397a --- /dev/null +++ b/lib/history/indexer.js @@ -0,0 +1,316 @@ +/** + * Historical Log Indexer + * + * Parses run-*.md summary files into structured data for historical context. + * These summaries are already created by the Ralph build loop and contain + * structured information about each iteration's results. + */ +const fs = require("fs"); +const path = require("path"); + +/** + * Parse a single run summary markdown file into structured data + * @param {string} runSummaryPath - Path to run-*.md file + * @returns {Object|null} Parsed run data or null if parsing fails + */ +function indexRun(runSummaryPath) { + try { + if (!fs.existsSync(runSummaryPath)) { + return null; + } + + const content = fs.readFileSync(runSummaryPath, "utf8"); + const run = { + path: runSummaryPath, + runId: null, + iteration: null, + mode: null, + storyId: null, + storyTitle: null, + started: null, + ended: null, + duration: null, + status: null, + logPath: null, + gitHeadBefore: null, + gitHeadAfter: null, + commits: [], + changedFiles: [], + uncommittedChanges: [], + tokens: { + input: null, + output: null, + total: null, + model: null, + }, + retryCount: 0, + routingDecision: null, + costEstimate: null, + costActual: null, + }; + + // Parse metadata section (- Key: Value format) + const metadataRegex = /^- (.+?):\s*(.+)$/gm; + let match; + while ((match = metadataRegex.exec(content)) !== null) { + const [, key, value] = match; + switch (key.toLowerCase()) { + case "run id": + run.runId = value.trim(); + break; + case "iteration": + run.iteration = parseInt(value.trim(), 10); + break; + case "mode": + run.mode = value.trim(); + break; + case "story": + // Format: "US-001: Story title" + const storyMatch = value.match(/^(US-\d+):\s*(.+)$/i); + if (storyMatch) { + run.storyId = storyMatch[1].toUpperCase(); + run.storyTitle = storyMatch[2].trim(); + } else { + run.storyTitle = value.trim(); + } + break; + case "started": + run.started = value.trim(); + break; + case "ended": + run.ended = value.trim(); + break; + case "duration": + run.duration = parseInt(value.replace(/s$/, "").trim(), 10); + break; + case "status": + run.status = value.trim().toLowerCase(); + break; + case "log": + run.logPath = value.trim(); + break; + case "head (before)": + run.gitHeadBefore = value.trim(); + break; + case "head (after)": + run.gitHeadAfter = value.trim(); + break; + case "input tokens": + run.tokens.input = parseInt(value.trim(), 10); + break; + case "output tokens": + run.tokens.output = parseInt(value.trim(), 10); + break; + case "total tokens": + run.tokens.total = parseInt(value.trim(), 10); + break; + case "model": + run.tokens.model = value.trim(); + break; + case "retry count": + run.retryCount = parseInt(value.trim(), 10) || 0; + break; + } + } + + // Parse commits section + const commitsSection = content.match(/### Commits\n([\s\S]*?)(?=\n###|$)/); + if (commitsSection) { + const commitRegex = /^- ([a-f0-9]+)\s+(.+)$/gm; + let commitMatch; + while ((commitMatch = commitRegex.exec(commitsSection[1])) !== null) { + if (commitMatch[1] !== "(none)") { + run.commits.push({ + hash: commitMatch[1], + message: commitMatch[2].trim(), + }); + } + } + } + + // Parse changed files section + const changedSection = content.match(/### Changed Files \(commits\)\n([\s\S]*?)(?=\n###|$)/); + if (changedSection) { + const fileRegex = /^- (.+)$/gm; + let fileMatch; + while ((fileMatch = fileRegex.exec(changedSection[1])) !== null) { + if (fileMatch[1] !== "(none)") { + run.changedFiles.push(fileMatch[1].trim()); + } + } + } + + // Parse uncommitted changes section + const uncommittedSection = content.match(/### Uncommitted Changes\n([\s\S]*?)(?=\n##|$)/); + if (uncommittedSection) { + const fileRegex = /^- (.+)$/gm; + let fileMatch; + while ((fileMatch = fileRegex.exec(uncommittedSection[1])) !== null) { + if (fileMatch[1] !== "(none)") { + run.uncommittedChanges.push(fileMatch[1].trim()); + } + } + } + + // Parse routing decision + const routingSection = content.match(/## Routing Decision\n([\s\S]*?)(?=\n##|$)/); + if (routingSection) { + const modelMatch = routingSection[1].match(/- Model:\s*(.+)/); + const scoreMatch = routingSection[1].match(/- Complexity score:\s*([0-9.]+)/); + const reasonMatch = routingSection[1].match(/- Reason:\s*(.+)/); + if (modelMatch || scoreMatch) { + run.routingDecision = { + model: modelMatch ? modelMatch[1].trim() : null, + complexityScore: scoreMatch ? parseFloat(scoreMatch[1]) : null, + reason: reasonMatch ? reasonMatch[1].trim() : null, + }; + } + } + + // Parse cost information + const costSection = content.match(/## Cost Estimate vs Actual\n([\s\S]*?)(?=\n##|$)/); + if (costSection) { + const estimatedMatch = costSection[1].match(/- Estimated cost:\s*\$([0-9.]+)/); + const actualMatch = costSection[1].match(/- Actual cost:\s*\$([0-9.]+)/); + if (estimatedMatch) { + run.costEstimate = parseFloat(estimatedMatch[1]); + } + if (actualMatch) { + run.costActual = parseFloat(actualMatch[1]); + } + } + + return run; + } catch (err) { + // Return null on any parsing error + return null; + } +} + +/** + * Build an index of all runs in a PRD folder + * @param {string} prdFolder - Path to PRD-N folder + * @param {Object} options - Options for indexing + * @param {number} options.maxRuns - Maximum number of runs to index (default: 50) + * @returns {Object} Index with runs array and byStory mapping + */ +function buildIndex(prdFolder, options = {}) { + const { maxRuns = 50 } = options; + + const index = { + prdFolder, + runs: [], + byStory: {}, + totalRuns: 0, + successfulRuns: 0, + failedRuns: 0, + lastUpdated: new Date().toISOString(), + }; + + try { + const runsDir = path.join(prdFolder, "runs"); + if (!fs.existsSync(runsDir)) { + return index; + } + + // Get all run summary files, sorted by modification time (newest first) + const runFiles = fs.readdirSync(runsDir) + .filter((f) => /^run-.*\.md$/.test(f)) + .map((f) => ({ + name: f, + path: path.join(runsDir, f), + mtime: fs.statSync(path.join(runsDir, f)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime) + .slice(0, maxRuns); + + for (const file of runFiles) { + const run = indexRun(file.path); + if (run) { + index.runs.push(run); + index.totalRuns++; + + if (run.status === "success") { + index.successfulRuns++; + } else if (run.status === "error" || run.status === "failure" || run.status === "failed") { + index.failedRuns++; + } + + // Index by story + if (run.storyId) { + if (!index.byStory[run.storyId]) { + index.byStory[run.storyId] = []; + } + index.byStory[run.storyId].push(run); + } + } + } + } catch (err) { + // Return partial index on error + index.error = err.message; + } + + return index; +} + +/** + * Get runs for a specific story + * @param {string} prdFolder - Path to PRD-N folder + * @param {string} storyId - Story ID (e.g., "US-001") + * @param {Object} options - Options + * @param {number} options.maxRuns - Maximum runs to return + * @returns {Array} Array of run objects for the story + */ +function getRunsForStory(prdFolder, storyId, options = {}) { + const index = buildIndex(prdFolder, options); + const normalizedStoryId = storyId.toUpperCase(); + return index.byStory[normalizedStoryId] || []; +} + +/** + * Get failed runs, optionally filtered by story + * @param {string} prdFolder - Path to PRD-N folder + * @param {string} storyId - Optional story ID to filter by + * @param {Object} options - Options + * @returns {Array} Array of failed run objects + */ +function getFailedRuns(prdFolder, storyId = null, options = {}) { + const index = buildIndex(prdFolder, options); + let runs = index.runs.filter((r) => + r.status === "error" || r.status === "failure" || r.status === "failed" + ); + + if (storyId) { + const normalizedStoryId = storyId.toUpperCase(); + runs = runs.filter((r) => r.storyId === normalizedStoryId); + } + + return runs; +} + +/** + * Get successful runs, optionally filtered by story + * @param {string} prdFolder - Path to PRD-N folder + * @param {string} storyId - Optional story ID to filter by + * @param {Object} options - Options + * @returns {Array} Array of successful run objects + */ +function getSuccessfulRuns(prdFolder, storyId = null, options = {}) { + const index = buildIndex(prdFolder, options); + let runs = index.runs.filter((r) => r.status === "success"); + + if (storyId) { + const normalizedStoryId = storyId.toUpperCase(); + runs = runs.filter((r) => r.storyId === normalizedStoryId); + } + + return runs; +} + +module.exports = { + indexRun, + buildIndex, + getRunsForStory, + getFailedRuns, + getSuccessfulRuns, +};