Repository: vercel/next.js
Pull request: Improve performance and token usage of `/ci-failures` command

Improve performance and token usage of `/ci-failures` command

include type in failure table, provide more instructions

use subagents

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the PR.
- Read the Docs Contribution Guide to ensure your contribution follows the docs guidelines: https://nextjs.org/docs/community/contribution-guide

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See: https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. (A discussion must be opened, see https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added (https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md


## For Maintainers

- Minimal description (aim for explaining to someone not on the team to understand the PR)
- When linking to a Slack thread, you might want to share details of the conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->


Changed files:
filename: .claude/commands/ci-failures.md
patch: @@ -1,91 +1,76 @@
 # Check CI Failures
 
-Analyze failing tests from PR CI runs with parallel subagent log analysis.
+Analyze failing tests from PR CI runs.
 
 ## Usage
 
 ```
-/ci-failures [pr-number]
+/ci-failures
 ```
 
-If no PR number provided, detect from current branch.
+Automatically detects PR from current branch.
 
 ## Instructions
 
-1. Get the PR number from argument or current branch:
+1. Run the script to fetch CI failure data:
 
    ```bash
-   gh pr view --json number,headRefName --jq '"\(.number) \(.headRefName)"'
+   node scripts/ci-failures.js
    ```
 
-2. **CRITICAL: Always fetch fresh run IDs** - never trust cached IDs from conversation summaries:
+   This fetches workflow runs, failed jobs, and logs, then generates markdown files.
+
+2. Read the generated index file for a summary:
 
    ```bash
-   gh api "repos/vercel/next.js/actions/runs?branch={branch}&per_page=10" \
-     --jq '.workflow_runs[] | select(.name == "build-and-test") | "\(.id) attempts:\(.run_attempt) status:\(.status) conclusion:\(.conclusion)"'
+   # Read scripts/ci-failures/index.md
    ```
 
-3. **Prioritize the MOST RECENT run, even if in-progress:**
-   - If the latest run is `in_progress` or `queued`, check it FIRST - it has the most relevant failures
-   - Individual jobs complete before the overall run - analyze them as they finish
-   - Only fall back to older completed runs if the current run has no completed jobs yet
+   The index shows all failed jobs with links to details.
 
-4. Get all failed jobs from the run (works for in-progress runs too):
+3. Spawn parallel haiku subagents to analyze the failing jobs (limit to 3-4 to avoid rate limits)
+   - **Agent prompt template** (copy-paste for each agent):
 
-   ```bash
-   gh api "repos/vercel/next.js/actions/runs/{run_id}/jobs?per_page=100" \
-     --jq '.jobs[] | select(.conclusion == "failure") | "\(.id) \(.name)"'
    ```
+   Analyze CI results for these jobs: scripts/ci-failures/job-{id1}.md scripts/ci-failures/job-{id2}.md
+   For each failing test, extract:
+   1. TEST FILE: (full path, e.g., test/production/required-server-files-ssr-404/test/index.test.ts)
+   2. TEST NAME: (the specific test case name)1
+   3. JOB TYPE: (the kind of the job, e.g. turbopack production, webpack dev, rust check)
+   4. EXPECTED: (exact expected value from assertion)
+   5. RECEIVED: (exact received value from assertion)
+   6. CATEGORY: (assertion|timeout|routing|source-map|build|cli-output)
+   7. ROOT CAUSE: (one sentence hypothesis)
+   8. LOG FILE: (analysed log file that led to conclusion)
+   Return structured findings grouped by TEST FILE, not by job.
+
+   Also extract other failures that are not related to tests.
+   Identify if they are likely transient.
 
-   **Note:** For runs with >100 jobs, paginate:
-
-   ```bash
-   gh api "repos/vercel/next.js/actions/runs/{run_id}/jobs?per_page=100&page=2"
    ```
 
-5. Spawn parallel haiku subagents to analyze logs (limit to 3-4 to avoid rate limits):
-   - **CRITICAL: Use the API endpoint for logs, NOT `gh run view`**
-   - `gh run view --job --log` FAILS when run is in-progress
-   - **Do NOT group by job name** (e.g., "test dev", "turbopack") - group by failure pattern instead
-   - Agent prompt should extract structured data using:
-     ```bash
-     # Extract assertion failures with context:
-     gh api "repos/vercel/next.js/actions/jobs/{job_id}/logs" 2>&1 | \
-       grep -B3 -A10 "expect.*\(toBe\|toContain\|toEqual\|toStartWith\|toMatch\)" | head -100
-     # Also check for test file paths:
-     gh api "repos/vercel/next.js/actions/jobs/{job_id}/logs" 2>&1 | \
-       grep -E "^\s+at Object\.|FAIL\s+test/" | head -20
-     ```
-   - **Agent prompt template** (copy-paste for each agent):
-     ```
-     Analyze CI logs for these jobs: {job_ids}
-     For each failing test, extract:
-     1. TEST FILE: (full path, e.g., test/production/required-server-files-ssr-404/test/index.test.ts)
-     2. TEST NAME: (the specific test case name)
-     3. EXPECTED: (exact expected value from assertion)
-     4. RECEIVED: (exact received value from assertion)
-     5. CATEGORY: (assertion|timeout|routing|source-map|build|cli-output)
-     6. ROOT CAUSE: (one sentence hypothesis)
-     Return structured findings grouped by TEST FILE, not by job.
-     ```
-
-6. **Deduplicate by test file** before summarizing:
+4. **Deduplicate by test file** before summarizing:
    - Group all failures by TEST FILE path, not by CI job name
    - If multiple jobs fail the same test file, count them but report once
    - Identify systemic issues (same test failing across many jobs)
 
-7. Create summary table **grouped by test file**:
-   | Test File | Issue (Expected vs Received) | Jobs | Priority |
-   |-----------|------------------------------|------|----------|
-   | `test/production/required-server-files-ssr-404/...` | `"second"` vs `"[slug]"` (routing) | 3 | HIGH |
-   | `test/integration/server-side-dev-errors/...` | source map paths wrong | 5 | HIGH |
-   | `test/e2e/app-dir/disable-logging-route/...` | "Compiling" appearing when disabled | 2 | MEDIUM |
+5. Analyze failures and create a summary **grouped by test file**:
 
-8. Recommend fixes:
+   | Test File                                           | Type.          | Issue (Expected vs Received)        | Jobs | Priority |
+   | --------------------------------------------------- | -------------- | ----------------------------------- | ---- | -------- |
+   | `test/production/required-server-files-ssr-404/...` | Turbopack prod | `"second"` vs `"[slug]"` (routing)  | 3    | HIGH     |
+   | `test/integration/server-side-dev-errors/...`       | webpack dev    | source map paths wrong              | 5    | HIGH     |
+   | `test/e2e/app-dir/disable-logging-route/...`        | prod           | "Compiling" appearing when disabled | 2    | MEDIUM   |
+   | N/A                                                 | rust check     | Formatting incorrect                | 2    | MEDIUM   |
+
+6. Recommend fixes:
    - **HIGH priority**: Show specific expected vs actual values, include test file path
    - **MEDIUM priority**: Identify root cause pattern
    - **LOW priority**: Mark as likely flaky/transient
 
+- Do not try to fix these failures.
+- If failures would require complex analysis and there are multiple problems, only do some basic analysis and point out that further investigation is needed and could be performant when requested.
+
 ## Failure Categories
 
 - **Infrastructure/Transient**: Network errors, 503s, timeouts unrelated to code
@@ -96,66 +81,3 @@ If no PR number provided, detect from current branch.
 - **Routing/SSR**: Dynamic params not resolved, wrong status codes, JSON parse errors
 - **Source Maps**: `webpack-internal://` paths, wrong line numbers, missing code frames
 - **CLI Output**: Missing warnings, wrong log order, "Ready" printed before errors
-
-## Failure Extraction Patterns
-
-Use these grep patterns to identify specific failure types:
-
-```bash
-# Assertion failures (most common)
-grep -B3 -A10 "expect.*\(toBe\|toContain\|toEqual\|toStartWith\)" | head -100
-
-# Routing issues (dynamic params, status codes)
-grep -E "Expected.*Received|\[slug\]|x-matched-path|Expected: [0-9]+" | head -50
-
-# Source map issues
-grep -E "webpack-internal://|at .* \(webpack" | head -30
-
-# CLI output issues (missing warnings)
-grep -E "Ready in|deprecated|Both middleware|Compiling" | head -30
-
-# Timeout issues
-grep -E "TIMEOUT|TimeoutError|exceeded|Exceeded timeout" | head -20
-
-# Test file paths (to identify which test is failing)
-grep -E "FAIL test/|at Object\.<anonymous> \(" | head -20
-```
-
-## Common Gotchas
-
-### In-Progress Runs
-
-- `gh run view {run_id} --job {job_id} --log` **FAILS** when run is in-progress
-- `gh api "repos/.../actions/jobs/{job_id}/logs"` **WORKS** for any completed job
-- Always use the API endpoint for reliability
-
-### Pagination
-
-- GitHub API paginates at 100 jobs per page
-- Next.js CI has ~120+ jobs - always check page 2:
-  ```bash
-  gh api ".../jobs?per_page=100&page=1" --jq '[.jobs[] | select(.conclusion == "failure")] | length'
-  gh api ".../jobs?per_page=100&page=2" --jq '[.jobs[] | select(.conclusion == "failure")] | length'
-  ```
-
-### Multiple Attempts
-
-- CI runs can have multiple attempts (retries)
-- Check attempt count: `.run_attempt` field
-- Query specific attempt: `.../runs/{id}/attempts/{n}/jobs`
-- 404 on attempt endpoint means that attempt doesn't exist
-
-## Quick Reference
-
-```bash
-# Get failed jobs (works for in-progress runs)
-gh api "repos/vercel/next.js/actions/runs/{run_id}/jobs?per_page=100" \
-  --jq '.jobs[] | select(.conclusion == "failure") | "\(.id) \(.name)"'
-
-# Get logs for a specific job (works for in-progress runs)
-gh api "repos/vercel/next.js/actions/jobs/{job_id}/logs" 2>&1 | head -500
-
-# Search logs for errors
-gh api "repos/vercel/next.js/actions/jobs/{job_id}/logs" 2>&1 | \
-  grep -E "FAIL|Error|error:|✕|Expected|Received" | head -50
-```

filename: scripts/.gitignore
patch: @@ -0,0 +1 @@
+ci-failures/*
\ No newline at end of file

filename: scripts/ci-failures.js
patch: @@ -0,0 +1,590 @@
+const { execSync } = require('child_process')
+const fs = require('fs/promises')
+const path = require('path')
+
+const OUTPUT_DIR = path.join(__dirname, 'ci-failures')
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function exec(cmd) {
+  try {
+    return execSync(cmd, {
+      encoding: 'utf8',
+      maxBuffer: 50 * 1024 * 1024, // 50MB for large logs
+    }).trim()
+  } catch (error) {
+    console.error(`Command failed: ${cmd}`)
+    console.error(error.stderr || error.message)
+    throw error
+  }
+}
+
+function execJson(cmd) {
+  const output = exec(cmd)
+  return JSON.parse(output)
+}
+
+function formatDuration(startedAt, completedAt) {
+  if (!startedAt || !completedAt) return 'N/A'
+  const start = new Date(startedAt)
+  const end = new Date(completedAt)
+
+  // Validate that both dates are valid (not Invalid Date objects)
+  if (isNaN(start.getTime()) || isNaN(end.getTime())) return 'N/A'
+
+  const seconds = Math.floor((end - start) / 1000)
+
+  if (seconds < 60) return `${seconds}s`
+  const minutes = Math.floor(seconds / 60)
+  const remainingSeconds = seconds % 60
+  return `${minutes}m ${remainingSeconds}s`
+}
+
+function sanitizeFilename(name) {
+  return name
+    .replace(/[^a-zA-Z0-9._-]/g, '-')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+    .substring(0, 100)
+}
+
+function escapeMarkdownTableCell(text) {
+  if (!text) return ''
+  // Escape pipe characters and newlines for markdown table cells
+  return String(text)
+    .replace(/\|/g, '\\|')
+    .replace(/\n/g, ' ')
+    .replace(/\r/g, '')
+}
+
+function stripTimestamps(logContent) {
+  // Remove GitHub Actions timestamp prefixes like "2026-01-23T10:11:12.8077557Z "
+  return logContent.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s/gm, '')
+}
+
+// ============================================================================
+// Data Fetching Functions
+// ============================================================================
+
+function getBranchInfo() {
+  try {
+    const output = exec(`gh pr view --json number,headRefName`)
+    const data = JSON.parse(output)
+    if (data.number && data.headRefName) {
+      return { prNumber: String(data.number), branchName: data.headRefName }
+    }
+  } catch {
+    // Fallback to git if not in PR context
+  }
+  const branchName = exec('git rev-parse --abbrev-ref HEAD')
+  return { prNumber: null, branchName }
+}
+
+function getWorkflowRuns(branch) {
+  const encodedBranch = encodeURIComponent(branch)
+  const jqQuery =
+    '.workflow_runs[] | select(.name == "build-and-test") | {id, run_attempt, status, conclusion}'
+  const output = exec(
+    `gh api "repos/vercel/next.js/actions/runs?branch=${encodedBranch}&per_page=10" --jq '${jqQuery}'`
+  )
+
+  if (!output.trim()) return []
+
+  return output
+    .split('\n')
+    .filter((line) => line.trim())
+    .map((line) => JSON.parse(line))
+}
+
+function getRunMetadata(runId) {
+  return execJson(
+    `gh api "repos/vercel/next.js/actions/runs/${runId}" --jq '{id, name, status, conclusion, run_attempt, html_url, head_sha, created_at, updated_at}'`
+  )
+}
+
+function getFailedJobs(runId) {
+  const failedJobs = []
+  let page = 1
+
+  while (true) {
+    const jqQuery = '.jobs[] | select(.conclusion == "failure") | {id, name}'
+    let output
+    try {
+      output = exec(
+        `gh api "repos/vercel/next.js/actions/runs/${runId}/jobs?per_page=100&page=${page}" --jq '${jqQuery}'`
+      )
+    } catch {
+      break
+    }
+
+    if (!output.trim()) break
+
+    const jobs = output
+      .split('\n')
+      .filter((line) => line.trim())
+      .map((line) => JSON.parse(line))
+
+    failedJobs.push(...jobs)
+
+    if (jobs.length < 100) break
+    page++
+  }
+
+  return failedJobs
+}
+
+function getJobMetadata(jobId) {
+  return execJson(
+    `gh api "repos/vercel/next.js/actions/jobs/${jobId}" --jq '{id, name, status, conclusion, started_at, completed_at, html_url}'`
+  )
+}
+
+function getJobLogs(jobId) {
+  try {
+    return exec(`gh api "repos/vercel/next.js/actions/jobs/${jobId}/logs"`)
+  } catch {
+    return 'Logs not available'
+  }
+}
+
+// ============================================================================
+// Log Parsing Functions
+// ============================================================================
+
+function extractTestOutputJson(logContent) {
+  // Extract all --test output start-- {JSON} --test output end-- blocks
+  const results = []
+  const regex = /--test output start--\s*(\{[\s\S]*?\})\s*--test output end--/g
+  let match = regex.exec(logContent)
+
+  while (match !== null) {
+    try {
+      const json = JSON.parse(match[1])
+      results.push(json)
+    } catch {
+      // Skip invalid JSON
+    }
+    match = regex.exec(logContent)
+  }
+
+  return results
+}
+
+function extractTestCaseGroups(logContent) {
+  // Extract ##[group]❌ test/... ##[endgroup] blocks
+  // Combine multiple retries of the same test into one entry
+  const groupsByPath = new Map()
+  const regex =
+    /##\[group\]❌\s*(test\/[^\s]+)\s+output([\s\S]*?)##\[endgroup\]/g
+  let match = regex.exec(logContent)
+
+  while (match !== null) {
+    const testPath = match[1]
+    const content = stripTimestamps(match[2].trim())
+
+    if (groupsByPath.has(testPath)) {
+      // Append retry content with a separator
+      const existing = groupsByPath.get(testPath)
+      groupsByPath.set(testPath, `${existing}\n\n--- RETRY ---\n\n${content}`)
+    } else {
+      groupsByPath.set(testPath, content)
+    }
+    match = regex.exec(logContent)
+  }
+
+  const groups = []
+  for (const [testPath, content] of groupsByPath) {
+    groups.push({ testPath, content })
+  }
+  return groups
+}
+
+function extractSections(logContent) {
+  // Split the log into sections at ##[group] and ##[endgroup] boundaries
+  const sections = []
+  const lines = logContent.split('\n')
+
+  let currentSection = { name: null, startLine: 0 }
+
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]
+
+    // Check for group start
+    const groupMatch = line.match(/##\[group\](.*)/)
+    if (groupMatch) {
+      // End current section
+      const lineCount = i - currentSection.startLine
+      if (lineCount > 0 || sections.length === 0) {
+        const rawContent = lines.slice(currentSection.startLine, i).join('\n')
+        const hasError = rawContent.includes('##[error]')
+        const content = stripTimestamps(rawContent.trim())
+        sections.push({
+          name: currentSection.name,
+          lineCount: lineCount,
+          content: content,
+          hasError: hasError,
+        })
+      }
+      // Start new section with group name
+      currentSection = { name: groupMatch[1].trim() || null, startLine: i + 1 }
+      continue
+    }
+
+    // Check for group end
+    if (line.includes('##[endgroup]')) {
+      // End current section
+      const lineCount = i - currentSection.startLine
+      const rawContent = lines.slice(currentSection.startLine, i).join('\n')
+      const hasError = rawContent.includes('##[error]')
+      const content = stripTimestamps(rawContent.trim())
+      sections.push({
+        name: currentSection.name,
+        lineCount: lineCount,
+        content: content,
+        hasError: hasError,
+      })
+      // Start new section with no name
+      currentSection = { name: null, startLine: i + 1 }
+      continue
+    }
+  }
+
+  // Add final section if there are remaining lines
+  const finalLineCount = lines.length - currentSection.startLine
+  if (finalLineCount > 0) {
+    const rawContent = lines.slice(currentSection.startLine).join('\n')
+    const hasError = rawContent.includes('##[error]')
+    const content = stripTimestamps(rawContent.trim())
+    sections.push({
+      name: currentSection.name,
+      lineCount: finalLineCount,
+      content: content,
+      hasError: hasError,
+    })
+  }
+
+  return sections
+}
+
+// ============================================================================
+// Markdown Generation Functions
+// ============================================================================
+
+function generateIndexMd(branchInfo, runMetadata, failedJobs, jobTestCounts) {
+  const lines = ['# CI Failures Report', '', `Branch: ${branchInfo.branchName}`]
+
+  if (branchInfo.prNumber) {
+    lines.push(`PR: #${branchInfo.prNumber}`)
+  }
+
+  lines.push(
+    `Run: ${runMetadata.id} (attempt ${runMetadata.run_attempt})`,
+    `Status: ${runMetadata.conclusion}`,
+    `Time: ${runMetadata.created_at} - ${runMetadata.updated_at}`,
+    `URL: ${runMetadata.html_url}`,
+    '',
+    `## Failed Jobs (${failedJobs.length})`,
+    '',
+    '| Job | Name | Duration | Tests | File |',
+    '|-----|------|----------|-------|------|'
+  )
+
+  for (const job of failedJobs) {
+    const duration = formatDuration(job.started_at, job.completed_at)
+    const testCount = jobTestCounts[job.id]
+    const testsStr = testCount
+      ? `${testCount.failed}/${testCount.total}`
+      : 'N/A'
+    lines.push(
+      `| ${job.id} | ${escapeMarkdownTableCell(job.name)} | ${duration} | ${testsStr} | [Details](job-${job.id}.md) |`
+    )
+  }
+
+  return lines.join('\n')
+}
+
+function generateJobMd(jobMetadata, testResults, testGroups, sections) {
+  const duration = formatDuration(
+    jobMetadata.started_at,
+    jobMetadata.completed_at
+  )
+
+  const lines = [
+    `# Job: ${jobMetadata.name}`,
+    '',
+    `ID: ${jobMetadata.id}`,
+    `Status: ${jobMetadata.conclusion}`,
+    `Started: ${jobMetadata.started_at}`,
+    `Completed: ${jobMetadata.completed_at}`,
+    `Duration: ${duration}`,
+    `URL: ${jobMetadata.html_url}`,
+    `Full Log: [job-${jobMetadata.id}-full-log.txt](job-${jobMetadata.id}-full-log.txt)`,
+    '',
+  ]
+
+  // Add sections list with line counts and links to section files
+  if (sections.length > 0) {
+    lines.push('## Sections', '')
+
+    for (let i = 0; i < sections.length; i++) {
+      const section = sections[i]
+      const sectionNum = i + 1
+      const filename = `job-${jobMetadata.id}-section-${sectionNum}.txt`
+      const errorPrefix = section.hasError ? '[error] ' : ''
+
+      if (section.name) {
+        lines.push(
+          `- ${errorPrefix}[${section.name} (${section.lineCount} lines)](${filename})`
+        )
+      } else {
+        lines.push(`- ${errorPrefix}[${section.lineCount} lines](${filename})`)
+      }
+    }
+    lines.push('')
+  }
+
+  // Aggregate test results from all test output JSONs
+  let totalFailed = 0
+  let totalPassed = 0
+  let totalTests = 0
+  const allFailedTests = []
+
+  for (const result of testResults) {
+    totalFailed += result.numFailedTests || 0
+    totalPassed += result.numPassedTests || 0
+    totalTests += result.numTotalTests || 0
+
+    if (result.testResults) {
+      for (const testResult of result.testResults) {
+        if (testResult.assertionResults) {
+          for (const assertion of testResult.assertionResults) {
+            if (assertion.status === 'failed') {
+              allFailedTests.push({
+                testFile: testResult.name,
+                testName: assertion.fullName || assertion.title,
+                error:
+                  assertion.failureMessages?.[0]?.substring(0, 100) ||
+                  'Unknown',
+              })
+            }
+          }
+        }
+      }
+    }
+  }
+
+  if (totalTests > 0) {
+    lines.push(
+      '## Test Results',
+      '',
+      `Failed: ${totalFailed}`,
+      `Passed: ${totalPassed}`,
+      `Total: ${totalTests}`,
+      ''
+    )
+
+    if (allFailedTests.length > 0) {
+      lines.push(
+        '## Failed Tests',
+        '',
+        '| Test File | Test Name | Error |',
+        '|-----------|-----------|-------|'
+      )
+
+      for (const test of allFailedTests) {
+        const shortFile = test.testFile.replace(/.*\/next\.js\/next\.js\//, '')
+        const shortError = test.error
+          .replace(/\n/g, ' ')
+          .substring(0, 60)
+          .replace(/\|/g, '\\|')
+        lines.push(
+          `| ${escapeMarkdownTableCell(shortFile)} | ${escapeMarkdownTableCell(test.testName)} | ${shortError}... |`
+        )
+      }
+      lines.push('')
+    }
+  }
+
+  if (testGroups.length > 0) {
+    lines.push('## Individual Test Files', '')
+    const seenPaths = new Set()
+    for (const group of testGroups) {
+      if (seenPaths.has(group.testPath)) continue
+      seenPaths.add(group.testPath)
+      const sanitizedName = sanitizeFilename(group.testPath)
+      lines.push(
+        `- [${group.testPath}](job-${jobMetadata.id}-test-${sanitizedName}.md)`
+      )
+    }
+  }
+
+  return lines.join('\n')
+}
+
+function generateTestMd(jobMetadata, testPath, content, testResultJson) {
+  const lines = [
+    `# Test: ${testPath}`,
+    '',
+    `Job: [${jobMetadata.name}](job-${jobMetadata.id}.md)`,
+    '',
+    '## Output',
+    '',
+    '```',
+    content,
+    '```',
+  ]
+
+  if (testResultJson) {
+    lines.push(
+      '',
+      '## Test Results JSON',
+      '',
+      '```json',
+      JSON.stringify(testResultJson, null, 2),
+      '```'
+    )
+  }
+
+  return lines.join('\n')
+}
+
+// ============================================================================
+// Main Function
+// ============================================================================
+
+async function main() {
+  // Step 1: Delete and recreate output directory
+  console.log('Cleaning output directory...')
+  await fs.rm(OUTPUT_DIR, { recursive: true, force: true })
+  await fs.mkdir(OUTPUT_DIR, { recursive: true })
+
+  // Step 2: Get branch info
+  console.log('Getting branch info...')
+  const branchInfo = getBranchInfo()
+  console.log(
+    `Branch: ${branchInfo.branchName}, PR: ${branchInfo.prNumber || 'N/A'}`
+  )
+
+  // Step 3: Get workflow runs
+  console.log('Fetching workflow runs...')
+  const runs = getWorkflowRuns(branchInfo.branchName)
+
+  if (runs.length === 0) {
+    console.log('No workflow runs found for this branch.')
+    process.exit(0)
+  }
+
+  // Find the most recent run (first in list)
+  const latestRun = runs[0]
+  console.log(
+    `Latest run: ${latestRun.id} (${latestRun.status}/${latestRun.conclusion})`
+  )
+
+  // Step 4: Get run metadata
+  console.log('Fetching run metadata...')
+  const runMetadata = getRunMetadata(latestRun.id)
+
+  // Step 5: Get failed jobs
+  console.log('Fetching failed jobs...')
+  const failedJobIds = getFailedJobs(latestRun.id)
+  console.log(`Found ${failedJobIds.length} failed jobs`)
+
+  if (failedJobIds.length === 0) {
+    console.log('No failed jobs found.')
+    await fs.writeFile(
+      path.join(OUTPUT_DIR, 'index.md'),
+      generateIndexMd(branchInfo, runMetadata, [], {})
+    )
+    process.exit(0)
+  }
+
+  // Step 6: Fetch details for each failed job
+  const failedJobs = []
+  const jobTestCounts = {}
+
+  for (const { id, name } of failedJobIds) {
+    console.log(`Processing job ${id}: ${name}...`)
+
+    // Get job metadata
+    const jobMetadata = getJobMetadata(id)
+    failedJobs.push(jobMetadata)
+
+    // Get job logs
+    const logs = getJobLogs(id)
+
+    // Write full log
+    await fs.writeFile(path.join(OUTPUT_DIR, `job-${id}-full-log.txt`), logs)
+
+    // Extract test output JSON
+    const testResults = extractTestOutputJson(logs)
+
+    // Calculate test counts for index
+    let failed = 0
+    let total = 0
+    for (const result of testResults) {
+      failed += result.numFailedTests || 0
+      total += result.numTotalTests || 0
+    }
+    if (total > 0) {
+      jobTestCounts[id] = { failed, total }
+    }
+
+    // Extract sections from the log
+    const sections = extractSections(logs)
+
+    // Write individual section files
+    for (let i = 0; i < sections.length; i++) {
+      const section = sections[i]
+      const sectionNum = i + 1
+      await fs.writeFile(
+        path.join(OUTPUT_DIR, `job-${id}-section-${sectionNum}.txt`),
+        section.content
+      )
+    }
+
+    // Extract test case groups
+    const testGroups = extractTestCaseGroups(logs)
+
+    // Write individual test files
+    for (const group of testGroups) {
+      const sanitizedName = sanitizeFilename(group.testPath)
+      // Find matching test result JSON for this test
+      const matchingResult = testResults.find((r) =>
+        r.testResults?.some((tr) => tr.name?.includes(group.testPath))
+      )
+      const testMd = generateTestMd(
+        jobMetadata,
+        group.testPath,
+        group.content,
+        matchingResult
+      )
+      await fs.writeFile(
+        path.join(OUTPUT_DIR, `job-${id}-test-${sanitizedName}.md`),
+        testMd
+      )
+    }
+
+    // Generate job markdown
+    const jobMd = generateJobMd(jobMetadata, testResults, testGroups, sections)
+    await fs.writeFile(path.join(OUTPUT_DIR, `job-${id}.md`), jobMd)
+  }
+
+  // Step 7: Generate index.md
+  console.log('Generating index.md...')
+  const indexMd = generateIndexMd(
+    branchInfo,
+    runMetadata,
+    failedJobs,
+    jobTestCounts
+  )
+  await fs.writeFile(path.join(OUTPUT_DIR, 'index.md'), indexMd)
+
+  console.log(`\nDone! Output written to ${OUTPUT_DIR}/index.md`)
+}
+
+main().catch((err) => {
+  console.error(err)
+  process.exit(1)
+})