Browse Source

Port CI analysis Copilot skill and default it to aspnetcore (#65464)

* Initial plan

* Add ci-analysis skill from runtime with aspnetcore defaults

Co-authored-by: wtgodbe <[email protected]>

* Update ci-analysis references for aspnetcore review feedback

Co-authored-by: wtgodbe <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: wtgodbe <[email protected]>
Copilot 3 weeks ago
parent
commit
74725c1b3a

+ 259 - 0
.github/skills/ci-analysis/SKILL.md

@@ -0,0 +1,259 @@
+---
+name: ci-analysis
+description: Analyze CI build and test status from Azure DevOps and Helix for dotnet repository PRs. Use when checking CI status, investigating failures, determining if a PR is ready to merge, or given URLs containing dev.azure.com or helix.dot.net. Also use when asked "why is CI red", "test failures", "retry CI", "rerun tests", "is CI green", "build failed", "checks failing", or "flaky tests".
+---
+
+# Azure DevOps and Helix CI Analysis
+
+Analyze CI build status and test failures in Azure DevOps and Helix for dotnet repositories (aspnetcore, runtime, sdk, roslyn, and more).
+
+> 🚨 **NEVER** use `gh pr review --approve` or `--request-changes`. Only `--comment` is allowed. Approval and blocking are human-only actions.
+
+**Workflow**: Gather PR context (Step 0) → run the script → read the human-readable output + `[CI_ANALYSIS_SUMMARY]` JSON → synthesize recommendations yourself. The script collects data; you generate the advice. For supplementary investigation beyond the script, MCP tools (AzDO, Helix, GitHub) provide structured access when available; the script and `gh` CLI work independently when they're not.
+
+## When to Use This Skill
+
+Use this skill when:
+- Checking CI status on a PR ("is CI passing?", "what's the build status?", "why is CI red?")
+- Investigating CI failures or checking why a PR's tests are failing
+- Determining if a PR is ready to merge based on CI results
+- Debugging Helix test issues or analyzing build errors
+- Given URLs containing `dev.azure.com`, `helix.dot.net`, or GitHub PR links with failing checks
+- Asked questions like "why is this PR failing", "analyze the CI", "is CI green", "retry CI", "rerun tests", or "test failures"
+- Investigating canceled or timed-out jobs for recoverable results
+
+## Script Limitations
+
+The `Get-CIStatus.ps1` script targets **Azure DevOps + Helix** infrastructure specifically. It won't help with:
+- **GitHub Actions** workflows (different API, different log format)
+- Repos not using **Helix** for test distribution (no Helix work items to query)
+- Pure **build performance** questions (use MSBuild binlog analysis instead)
+
+However, the analysis patterns in this skill (interpreting failures, correlating with PR changes, distinguishing infrastructure vs. code issues) apply broadly even outside AzDO/Helix.
+
+## Quick Start
+
+```powershell
+# Analyze PR failures (most common) - defaults to dotnet/aspnetcore
+./scripts/Get-CIStatus.ps1 -PRNumber 123445 -ShowLogs
+
+# Analyze by build ID
+./scripts/Get-CIStatus.ps1 -BuildId 1276327 -ShowLogs
+
+# Query specific Helix work item
+./scripts/Get-CIStatus.ps1 -HelixJob "4b24b2c2-..." -WorkItem "System.Net.Http.Tests"
+
+# Other dotnet repositories
+./scripts/Get-CIStatus.ps1 -PRNumber 12345 -Repository "dotnet/runtime"
+./scripts/Get-CIStatus.ps1 -PRNumber 67890 -Repository "dotnet/sdk"
+./scripts/Get-CIStatus.ps1 -PRNumber 11111 -Repository "dotnet/roslyn"
+```
+
+## Key Parameters
+
+| Parameter | Description |
+|-----------|-------------|
+| `-PRNumber` | GitHub PR number to analyze |
+| `-BuildId` | Azure DevOps build ID |
+| `-ShowLogs` | Fetch and display Helix console logs |
+| `-Repository` | Target repo (default: dotnet/aspnetcore) |
+| `-MaxJobs` | Max failed jobs to show (default: 5) |
+| `-SearchMihuBot` | Search MihuBot for related issues |
+
+## Three Modes
+
+The script operates in three distinct modes depending on what information you have:
+
+| You have... | Use | What you get |
+|-------------|-----|-------------|
+| A GitHub PR number | `-PRNumber 12345` | Full analysis: all builds, failures, known issues, structured JSON summary |
+| An AzDO build ID | `-BuildId 1276327` | Single build analysis: timeline, failures, Helix results |
+| A Helix job ID (optionally a specific work item) | `-HelixJob "..." [-WorkItem "..."]` | Deep dive: list work items for the job, or with `-WorkItem`, focus on a single work item's console logs, artifacts, and test results |
+
+> ❌ **Don't guess the mode.** If the user gives a PR URL, use `-PRNumber`. If they paste an AzDO build link, extract the build ID. If they reference a specific Helix job, use `-HelixJob`.
+
+## What the Script Does
+
+### PR Analysis Mode (`-PRNumber`)
+1. Discovers AzDO builds associated with the PR (from GitHub check status; for full build history, query AzDO builds on `refs/pull/{PR}/merge` branch)
+2. Fetches Build Analysis for known issues
+3. Gets failed jobs from Azure DevOps timeline
+4. **Separates canceled jobs from failed jobs** (canceled may be dependency-canceled or timeout-canceled)
+5. Extracts Helix work item failures from each failed job
+6. Fetches console logs (with `-ShowLogs`)
+7. Searches for known issues with "Known Build Error" label
+8. Correlates failures with PR file changes
+9. **Emits structured summary** — `[CI_ANALYSIS_SUMMARY]` JSON block with all key facts for the agent to reason over
+
+> **After the script runs**, you (the agent) generate recommendations. The script collects data; you synthesize the advice. See [Generating Recommendations](#generating-recommendations) below.
+
+### Build ID Mode (`-BuildId`)
+1. Fetches the build timeline directly (skips PR discovery)
+2. Performs steps 3–7 from PR Analysis Mode, but does **not** fetch Build Analysis known issues or correlate failures with PR file changes (those require a PR number). Still emits `[CI_ANALYSIS_SUMMARY]` JSON.
+
+### Helix Job Mode (`-HelixJob` [and optional `-WorkItem`])
+1. With `-HelixJob` alone: enumerates work items for the job and summarizes their status
+2. With `-HelixJob` and `-WorkItem`: queries the specific work item for status and artifacts
+3. Fetches console logs and file listings, displays detailed failure information
+
+## Interpreting Results
+
+**Known Issues section**: Failures matching existing GitHub issues - these are tracked and being investigated.
+
+**Build Analysis check status**: The "Build Analysis" GitHub check is **green** only when *every* failure is matched to a known issue. If it's **red**, at least one failure is unaccounted for — do NOT claim "all failures are known issues" just because some known issues were found. You must verify each failing job is covered by a specific known issue before calling it safe to retry.
+
+**Canceled/timed-out jobs**: Jobs canceled due to earlier stage failures or AzDO timeouts. Dependency-canceled jobs don't need investigation. **Timeout-canceled jobs may have all-passing Helix results** — the "failure" is just the AzDO job wrapper timing out, not actual test failures. To verify: use `hlx_status` on each Helix job in the timed-out build (include passed work items). If all work items passed, the build effectively passed.
+
+> ❌ **Don't dismiss timed-out builds.** A build marked "failed" due to a 3-hour AzDO timeout can have 100% passing Helix work items. Check before concluding it failed.
+
+**PR Change Correlation**: Files changed by PR appearing in failures - likely PR-related.
+
+**Build errors**: Compilation failures need code fixes.
+
+**Helix failures**: Test failures on distributed infrastructure.
+
+**Local test failures**: Some repos (e.g., dotnet/sdk) run tests directly on build agents. These can also match known issues - search for the test name with the "Known Build Error" label.
+
+**Per-failure details** (`failedJobDetails` in JSON): Each failed job includes `errorCategory`, `errorSnippet`, and `helixWorkItems`. Use these for per-job classification instead of applying a single `recommendationHint` to all failures.
+
+Error categories: `test-failure`, `build-error`, `test-timeout`, `crash` (exit codes 139/134/-4), `tests-passed-reporter-failed` (all tests passed but reporter crashed — genuinely infrastructure), `unclassified` (investigate manually).
+
+> ⚠️ **`crash` does NOT always mean tests failed.** Exit code -4 often means the Helix work item wrapper timed out *after* tests completed. Always check `testResults.xml` before concluding a crash is a real failure. See [Recovering Results from Crashed/Canceled Jobs](#recovering-results-from-crashedcanceled-jobs).
+
+> ⚠️ **Be cautious labeling failures as "infrastructure."** Only conclude infrastructure with strong evidence: Build Analysis match, identical failure on target branch, or confirmed outage. Exception: `tests-passed-reporter-failed` is genuinely infrastructure.
+
+> ❌ **Missing packages on flow PRs ≠ infrastructure.** Flow PRs can cause builds to request *different* packages. Check *which* package and *why* before assuming feed delay.
+
+### Recovering Results from Crashed/Canceled Jobs
+
+When an AzDO job is canceled (timeout) or Helix work items show `Crash` (exit code -4), the tests may have actually passed. Follow this procedure:
+
+1. **Find the Helix job IDs** — Read the AzDO "Send to Helix" step log and search for lines containing `Sent Helix Job`. Extract the job GUIDs.
+
+2. **Check Helix job status** — Get pass/fail summary for each job. Look at `failedCount` vs `passedCount`.
+
+3. **For work items marked Crash/Failed** — Check if tests actually passed despite the crash. Try structured test results first (TRX parsing), then search for pass/fail counts in result files without downloading, then download as last resort:
+   - Parse the XML: `total`, `passed`, `failed` attributes on the `<assembly>` element
+   - If `failed=0` and `passed > 0`, the tests passed — the "crash" is the wrapper timing out after test completion
+
+4. **Verdict**:
+   - All work items passed or crash-with-passing-results → **Tests effectively passed.** The failure is infrastructure (wrapper timeout).
+   - Some work items have `failed > 0` in testResults.xml → **Real test failures.** Investigate those specific tests.
+   - No testResults.xml uploaded → Tests may not have run at all. Check console logs for errors.
+
+> This pattern is common with long-running test suites (e.g., WasmBuildTests) where tests complete but the Helix work item wrapper exceeds its timeout during result upload or cleanup.
+
+## Generating Recommendations
+
+After the script outputs the `[CI_ANALYSIS_SUMMARY]` JSON block, **you** synthesize recommendations. Do not parrot the JSON — reason over it.
+
+### Decision logic
+
+Read `recommendationHint` as a starting point, then layer in context:
+
+| Hint | Action |
+|------|--------|
+| `BUILD_SUCCESSFUL` | No failures. Confirm CI is green. |
+| `KNOWN_ISSUES_DETECTED` | Known tracked issues found — but this does NOT mean all failures are covered. Check the Build Analysis check status: if it's red, some failures are unmatched. Only recommend retry for failures that specifically match a known issue; investigate the rest. |
+| `LIKELY_PR_RELATED` | Failures correlate with PR changes. Lead with "fix these before retrying" and list `correlatedFiles`. |
+| `POSSIBLY_TRANSIENT` | Failures could not be automatically classified — does NOT mean they are transient. Use `failedJobDetails` to investigate each failure individually. |
+| `REVIEW_REQUIRED` | Could not auto-determine cause. Review failures manually. |
+| `MERGE_CONFLICTS` | PR has merge conflicts — CI won't run. Tell the user to resolve conflicts. Offer to analyze a previous build by ID. |
+| `NO_BUILDS` | No AzDO builds found (CI not triggered). Offer to check if CI needs to be triggered or analyze a previous build. |
+
+Then layer in nuance the heuristic can't capture:
+
+- **Mixed signals**: Some failures match known issues AND some correlate with PR changes → separate them. Known issues = safe to retry; correlated = fix first.
+- **Canceled jobs with recoverable results**: If `canceledJobNames` is non-empty, mention that canceled jobs may have passing Helix results (see "Recovering Results from Crashed/Canceled Jobs").
+- **Build still in progress**: If `lastBuildJobSummary.pending > 0`, note that more failures may appear.
+- **Multiple builds**: If `builds` has >1 entry, `lastBuildJobSummary` reflects only the last build — use `totalFailedJobs` for the aggregate count.
+- **BuildId mode**: `knownIssues` and `prCorrelation` won't be populated. Say "Build Analysis and PR correlation not available in BuildId mode."
+
+### How to Retry
+
+- **AzDO builds**: Comment `/azp run {pipeline-name}` on the PR (e.g., `/azp run dotnet-sdk-public`)
+- **All pipelines**: Comment `/azp run` to retry all failing pipelines
+- **Helix work items**: Cannot be individually retried — must re-run the entire AzDO build
+
+### Tone and output format
+
+Be direct. Lead with the most important finding. Structure your response as:
+1. **Summary verdict** (1-2 sentences) — Is CI green? Failures PR-related? Known issues?
+2. **Failure details** (2-4 bullets) — what failed, why, evidence
+3. **Recommended actions** (numbered) — retry, fix, investigate. Include `/azp run` commands.
+
+Synthesize from: JSON summary (structured facts) + human-readable output (details/logs) + Step 0 context (PR type, author intent).
+
+## Analysis Workflow
+
+### Step 0: Gather Context (before running anything)
+
+Before running the script, read the PR to understand what you're analyzing. Context changes how you interpret every failure.
+
+1. **Read PR metadata** — title, description, author, labels, linked issues
+2. **Classify the PR type** — this determines your interpretation framework:
+
+| PR Type | How to detect | Interpretation shift |
+|---------|--------------|---------------------|
+| **Code PR** | Human author, code changes | Failures likely relate to the changes |
+| **Flow/Codeflow PR** | Author is `dotnet-maestro[bot]`, title mentions "Update dependencies" | Missing packages may be behavioral, not infrastructure (see anti-pattern below) |
+| **Backport** | Title mentions "backport", targets a release branch | Failures may be branch-specific; check if test exists on target branch |
+| **Merge PR** | Merging between branches (e.g., release → main) | Conflicts and merge artifacts cause failures, not the individual changes |
+| **Dependency update** | Bumps package versions, global.json changes | Build failures often trace to the dependency, not the PR's own code |
+
+3. **Check existing comments** — has someone already diagnosed the failures? Is there a retry pending?
+4. **Note the changed files** — you'll use these to evaluate correlation after the script runs
+
+> ❌ **Don't skip Step 0.** Running the script without PR context leads to misdiagnosis — especially for flow PRs where "package not found" looks like infrastructure but is actually a code issue.
+
+### Step 1: Run the script
+
+Run with `-ShowLogs` for detailed failure info.
+
+### Step 2: Analyze results
+
+1. **Check Build Analysis** — If the Build Analysis GitHub check is **green**, all failures matched known issues and it's safe to retry. If it's **red**, some failures are unaccounted for — you must identify which failing jobs are covered by known issues and which are not. For 3+ failures, use SQL tracking to avoid missed matches (see [references/sql-tracking.md](references/sql-tracking.md)).
+2. **Correlate with PR changes** — Same files failing = likely PR-related
+3. **Compare with baseline** — If a test passes on the target branch but fails on the PR, compare Helix binlogs. See [references/binlog-comparison.md](references/binlog-comparison.md) — **delegate binlog download/extraction to subagents** to avoid burning context on mechanical work.
+4. **Check build progression** — If the PR has multiple builds (multiple pushes), check whether earlier builds passed. A failure that appeared after a specific push narrows the investigation to those commits. See [references/build-progression-analysis.md](references/build-progression-analysis.md). Present findings as facts, not fix recommendations.
+5. **Interpret patterns** (but don't jump to conclusions):
+   - Same error across many jobs → Real code issue
+   - Build Analysis flags a known issue → That *specific failure* is safe to retry (but others may not be)
+   - Failure is **not** in Build Analysis → Investigate further before assuming transient
+   - Device failures, Docker pulls, network timeouts → *Could* be infrastructure, but verify against the target branch first
+   - Test timeout but tests passed → Executor issue, not test failure
+6. **Check for mismatch with user's question** — The script only reports builds for the current head SHA. If the user asks about a job, error, or cancellation that doesn't appear in the results, **ask** if they're referring to a prior build. Common triggers:
+   - User mentions a canceled job but `canceledJobNames` is empty
+   - User says "CI is failing" but the latest build is green
+   - User references a specific job name not in the current results
+   Offer to re-run with `-BuildId` if the user can provide the earlier build ID from AzDO.
+
+### Step 3: Verify before claiming
+
+Before stating a failure's cause, verify your claim:
+
+- **"Infrastructure failure"** → Did Build Analysis flag it? Does the same test pass on the target branch? If neither, don't call it infrastructure.
+- **"Transient/flaky"** → Has it failed before? Is there a known issue? A single non-reproducing failure isn't enough to call it flaky.
+- **"PR-related"** → Do the changed files actually relate to the failing test? Correlation in the script output is heuristic, not proof.
+- **"Safe to retry"** → Are ALL failures accounted for (known issues or infrastructure), or are you ignoring some? Check the Build Analysis check status — if it's red, not all failures are matched. Map each failing job to a specific known issue before concluding "safe to retry."
+- **"Not related to this PR"** → Have you checked if the test passes on the target branch? Don't assume — verify.
+
+## References
+
+- **Helix artifacts & binlogs**: See [references/helix-artifacts.md](references/helix-artifacts.md)
+- **Binlog comparison (passing vs failing)**: See [references/binlog-comparison.md](references/binlog-comparison.md)
+- **Build progression (commit-to-build correlation)**: See [references/build-progression-analysis.md](references/build-progression-analysis.md)
+- **Subagent delegation patterns**: See [references/delegation-patterns.md](references/delegation-patterns.md)
+- **Azure CLI deep investigation**: See [references/azure-cli.md](references/azure-cli.md)
+- **Manual investigation steps**: See [references/manual-investigation.md](references/manual-investigation.md)
+- **SQL tracking for investigations**: See [references/sql-tracking.md](references/sql-tracking.md)
+- **AzDO/Helix details**: See [references/azdo-helix-reference.md](references/azdo-helix-reference.md)
+
+## Tips
+
+1. Check if same test fails on the target branch before assuming transient
+2. Look for `[SkipOnHelix]` and `[QuarantinedTest]` attributes for known skipped or quarantined tests
+3. Use `-SearchMihuBot` for semantic search of related issues
+4. Use binlog analysis tools to search binlogs for Helix job IDs, build errors, and properties
+5. `gh pr checks --json` valid fields: `bucket`, `completedAt`, `description`, `event`, `link`, `name`, `startedAt`, `state`, `workflow` — no `conclusion` field, `state` has `SUCCESS`/`FAILURE` directly
+6. "Canceled" ≠ "Failed" — canceled jobs may have recoverable Helix results. Check artifacts before concluding results are lost.

+ 92 - 0
.github/skills/ci-analysis/references/azdo-helix-reference.md

@@ -0,0 +1,92 @@
+# Azure DevOps and Helix Reference
+
+## Supported Repositories
+
+The script works with any dotnet repository that uses Azure DevOps and Helix:
+
+| Repository | Common Pipelines |
+|------------|-----------------|
+| `dotnet/runtime` | runtime, runtime-dev-innerloop, dotnet-linker-tests |
+| `dotnet/sdk` | dotnet-sdk (mix of local and Helix tests) |
+| `dotnet/aspnetcore` | aspnetcore-ci |
+| `dotnet/roslyn` | roslyn-CI |
+| `dotnet/maui` | maui-public |
+
+Use `-Repository` to specify the target:
+```powershell
+./scripts/Get-CIStatus.ps1 -PRNumber 12345 -Repository "dotnet/aspnetcore"
+```
+
+## Build Definition IDs (Example: dotnet/aspnetcore)
+
+Each repository has its own build definition IDs. Here are common ones for dotnet/aspnetcore:
+
+| Definition ID | Name | Description |
+|---------------|------|-------------|
+| `83` | aspnetcore-ci | Main PR validation build |
+| `86` | aspnetcore-quarantined-pr | Flaky tests quarantined into their own pipeline |
+| `87` | aspnetcore-components-e2e | Components end-to-end tests |
+| `318` | aspnetcore-template-tests-pr | Template tests |
+
+**Note:** The script auto-discovers builds for a PR, so you rarely need to know definition IDs.
+
+## Azure DevOps Organizations
+
+**Public builds (default):**
+- Organization: `dnceng-public`
+- Project: `cbb18261-c48f-4abb-8651-8cdcb5474649`
+
+**Internal/private builds:**
+- Organization: `dnceng`
+- Project GUID: Varies by pipeline
+
+Override with:
+```powershell
+./scripts/Get-CIStatus.ps1 -BuildId 1276327 -Organization "dnceng" -Project "internal-project-guid"
+```
+
+## Common Pipeline Names (Example: dotnet/aspnetcore)
+
+| Pipeline | Description |
+|----------|-------------|
+| `aspnetcore-ci` | Main PR validation build |
+| `aspnetcore-quarantined-pr` | Flaky tests quarantined into their own pipeline |
+| `aspnetcore-components-e2e` | Components end-to-end tests |
+| `aspnetcore-template-tests-pr` | Template tests |
+
+Other repos have different pipelines - the script discovers them automatically from the PR.
+
+## Useful Links
+
+- [Helix Portal](https://helix.dot.net/): View Helix jobs and work items (all repos)
+- [Helix API Documentation](https://helix.dot.net/swagger/): Swagger docs for Helix REST API
+- [Build Analysis](https://github.com/dotnet/arcade/blob/main/Documentation/Projects/Build%20Analysis/LandingPage.md): Known issues tracking (arcade infrastructure)
+- [dnceng-public AzDO](https://dev.azure.com/dnceng-public/public/_build): Public builds for all dotnet repos
+
+### Repository-specific docs:
+- [aspnetcore: Area Owners](https://github.com/dotnet/aspnetcore/blob/main/docs/area-owners.md)
+
+## Test Execution Types
+
+### Helix Tests
+Tests run on Helix distributed test infrastructure. The script extracts console log URLs and can fetch detailed failure info with `-ShowLogs`.
+
+### Local Tests (Non-Helix)
+Some repositories (e.g., dotnet/sdk) run tests directly on the build agent. The script detects these and extracts Azure DevOps Test Run URLs.
+
+## Known Issue Labels
+
+- `Known Build Error` - Used by Build Analysis across all dotnet repositories
+- Search syntax: `repo:<owner>/<repo> is:issue is:open label:"Known Build Error" <test-name>`
+
+Example searches (use `search_issues` when GitHub MCP is available, `gh` CLI otherwise):
+```bash
+# Search in runtime
+gh issue list --repo dotnet/runtime --label "Known Build Error" --search "FileSystemWatcher"
+
+# Search in aspnetcore
+gh issue list --repo dotnet/aspnetcore --label "Known Build Error" --search "Blazor"
+
+# Search in sdk
+gh issue list --repo dotnet/sdk --label "Known Build Error" --search "template"
+```

+ 96 - 0
.github/skills/ci-analysis/references/azure-cli.md

@@ -0,0 +1,96 @@
+# Deep Investigation with Azure CLI
+
+The AzDO MCP tools handle most pipeline queries directly. This reference covers the Azure CLI fallback for cases where MCP tools are unavailable or the endpoint isn't exposed (e.g., downloading artifacts, inspecting pipeline definitions).
+
+When the CI script and GitHub APIs aren't enough (e.g., investigating internal pipeline definitions or downloading build artifacts), use the Azure CLI with the `azure-devops` extension.
+
+> 💡 **Prefer `az pipelines` / `az devops` commands over raw REST API calls.** The CLI handles authentication, pagination, and JSON output formatting. Only fall back to manual `Invoke-RestMethod` calls when the CLI doesn't expose the endpoint you need (e.g., build timelines). The CLI's `--query` (JMESPath) and `-o table` flags are powerful for filtering without extra scripting.
+
+## Checking Authentication
+
+Before making AzDO API calls, verify the CLI is installed and authenticated:
+
+```powershell
+# Ensure az is on PATH (Windows may need a refresh after install)
+$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
+
+# Check if az CLI is available
+az --version 2>$null | Select-Object -First 1
+
+# Check if logged in and get current account
+az account show --query "{name:name, user:user.name}" -o table 2>$null
+
+# If not logged in, prompt the user to authenticate:
+#   az login                              # Interactive browser login
+#   az login --use-device-code            # Device code flow (for remote/headless)
+
+# Get an AAD access token for AzDO REST API calls (only needed for raw REST)
+$accessToken = (az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
+$headers = @{ "Authorization" = "Bearer $accessToken" }
+```
+
+> ⚠️ If `az` is not installed, use `winget install -e --id Microsoft.AzureCLI` (Windows). The `azure-devops` extension is also required — install or verify it with `az extension add --name azure-devops` (safe to run if already installed). Ask the user to authenticate if needed.
+
+> ⚠️ **Do NOT use `az devops configure --defaults`** — it sets user-wide defaults that may not match the organization/project needed for dotnet repositories. Always pass `--org` and `--project` (or `-p`) explicitly on each command.
+
+## Querying Pipeline Definitions and Builds
+
+```powershell
+$org = "https://dev.azure.com/dnceng"
+$project = "internal"
+
+# Find a pipeline definition by name
+az pipelines list --name "dotnet-unified-build" --org $org -p $project --query "[].{id:id, name:name, path:path}" -o table
+
+# Get pipeline definition details (shows YAML path, triggers, etc.)
+az pipelines show --id 1330 --org $org -p $project --query "{id:id, name:name, yamlPath:process.yamlFilename, repo:repository.name}" -o table
+
+# List recent builds for a pipeline (replace {TARGET_BRANCH} with the PR's base branch, e.g., main or release/9.0)
+az pipelines runs list --pipeline-ids 1330 --branch "refs/heads/{TARGET_BRANCH}" --top 5 --org $org -p $project --query "[].{id:id, result:result, finish:finishTime}" -o table
+
+# Get a specific build's details
+az pipelines runs show --id $buildId --org $org -p $project --query "{id:id, result:result, sourceBranch:sourceBranch}" -o table
+
+# List build artifacts
+az pipelines runs artifact list --run-id $buildId --org $org -p $project --query "[].{name:name, type:resource.type}" -o table
+
+# Download a build artifact
+az pipelines runs artifact download --run-id $buildId --artifact-name "TestBuild_linux_x64" --path "$env:TEMP\artifact" --org $org -p $project
+```
+
+## REST API Fallback
+
+Fall back to REST API only when the CLI doesn't expose what you need:
+
+```powershell
+# Get build timeline (stages, jobs, tasks with results and durations) — no CLI equivalent
+$accessToken = (az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
+$headers = @{ "Authorization" = "Bearer $accessToken" }
+$timelineUrl = "https://dev.azure.com/dnceng/internal/_apis/build/builds/$buildId/timeline?api-version=7.1"
+$timeline = (Invoke-RestMethod -Uri $timelineUrl -Headers $headers)
+$timeline.records | Where-Object { $_.result -eq "failed" -and $_.type -eq "Job" }
+```
+
+## Examining Pipeline YAML
+
+All dotnet repos that use arcade put their pipeline definitions under `eng/pipelines/`. Use `az pipelines show` to find the YAML file path, then fetch it:
+
+```powershell
+# Find the YAML path for a pipeline
+az pipelines show --id 1330 --org $org -p $project --query "{yamlPath:process.yamlFilename, repo:repository.name}" -o table
+
+# Fetch the YAML from the repo (example: dotnet/aspnetcore's ci-public pipeline)
+#   Read the pipeline YAML from the repo to understand build stages and conditions
+#   e.g., .azure/pipelines/ci-public.yml in dotnet/aspnetcore
+
+# For VMR unified builds, the YAML is in dotnet/dotnet:
+#   eng/pipelines/unified-build.yml
+
+# Templates are usually in eng/pipelines/common/ or eng/pipelines/templates/
+```
+
+This is especially useful when:
+- A job name doesn't clearly indicate what it builds
+- You need to understand stage dependencies (why a job was canceled)
+- You want to find which template defines a specific step
+- Investigating whether a pipeline change caused new failures

+ 119 - 0
.github/skills/ci-analysis/references/binlog-comparison.md

@@ -0,0 +1,119 @@
+# Deep Investigation: Binlog Comparison
+
+When a test **passes on the target branch but fails on a PR**, comparing MSBuild binlogs from both runs reveals the exact difference in task parameters without guessing.
+
+## When to Use This Pattern
+
+- Test assertion compares "expected vs actual" build outputs (e.g., CSC args, reference lists)
+- A build succeeds on one branch but fails on another with different MSBuild behavior
+- You need to find which MSBuild property/item change caused a specific task to behave differently
+
+## The Pattern: Delegate to Subagents
+
+> ⚠️ **Do NOT download, load, and parse binlogs in the main conversation context.** This burns 10+ turns on mechanical work. Delegate to subagents instead.
+
+### Step 1: Identify the two work items to compare
+
+Use `Get-CIStatus.ps1` to find the failing Helix job + work item, then find a corresponding passing build (recent PR merged to the target branch, or a CI run on that branch).
+
+**Finding Helix job IDs from build artifacts (binlogs to find binlogs):**
+When the failing work item's Helix job ID isn't visible (e.g., canceled jobs, or finding a matching job from a passing build), the IDs are inside the build's `SendToHelix.binlog`:
+
+1. Download the build artifact with `az`:
+   ```
+   az pipelines runs artifact list --run-id $buildId --org "https://dev.azure.com/dnceng-public" -p public --query "[].name" -o tsv
+   az pipelines runs artifact download --run-id $buildId --artifact-name "TestBuild_linux_x64" --path "$env:TEMP\artifact" --org "https://dev.azure.com/dnceng-public" -p public
+   ```
+2. Load the `SendToHelix.binlog` and search for `Sent Helix Job` to find the GUIDs.
+3. Query each Helix job GUID with the CI script:
+   ```
+   ./scripts/Get-CIStatus.ps1 -HelixJob "{GUID}" -FindBinlogs
+   ```
+
+**For Helix work item binlogs (the common case):**
+The CI script shows binlog URLs directly when you query a specific work item:
+```
+./scripts/Get-CIStatus.ps1 -HelixJob "{JOB_ID}" -WorkItem "{WORK_ITEM}"
+# Output includes: 🔬 msbuild.binlog: https://helix...blob.core.windows.net/...
+```
+
+### Step 2: Dispatch parallel subagents for extraction
+
+Launch two `task` subagents (can run in parallel), each with a prompt like:
+
+```
+Download the msbuild.binlog from Helix job {JOB_ID} work item {WORK_ITEM}.
+Use the CI skill script to get the artifact URL:
+  ./scripts/Get-CIStatus.ps1 -HelixJob "{JOB_ID}" -WorkItem "{WORK_ITEM}"
+Download the binlog, load it, find the {TASK_NAME} task, and extract CommandLineArguments.
+Normalize paths (see table below) and sort args.
+Parse into individual args using regex: (?:"[^"]+"|/[^\s]+|[^\s]+)
+Report the total arg count prominently.
+```
+
+**Important:** When diffing, look for **extra or missing args** (different count), not value differences in existing args. A Debug/Release difference in `/define:` is expected noise — an extra `/analyzerconfig:` or `/reference:` arg is the real signal.
+
+### Step 3: Diff the results
+
+With two normalized arg lists, `Compare-Object` instantly reveals the difference.
+
+## Common Binlog Search Patterns
+
+When investigating binlogs, these search query patterns are most useful:
+
+- Search for a property: `analysislevel`
+- Search within a target: `under($target AddGlobalAnalyzerConfigForPackage_MicrosoftCodeAnalysisNetAnalyzers)`
+- Find all properties matching a pattern: `GlobalAnalyzerConfig`
+
+## Path Normalization
+
+Helix work items run on different machines with different paths. Normalize before comparing:
+
+| Pattern | Replacement | Example |
+|---------|-------------|---------|
+| `/datadisks/disk1/work/[A-F0-9]{8}` | `{W}` | Helix work directory (Linux) |
+| `C:\h\w\[A-F0-9]{8}` | `{W}` | Helix work directory (Windows) |
+| `Program-[a-f0-9]{64}` | `Program-{H}` | Runfile content hash |
+| `dotnetSdkTests\.[a-zA-Z0-9]+` | `dotnetSdkTests.{T}` | Temp test directory |
+
+### After normalizing paths, focus on structural differences
+
+> ⚠️ **Ignore value-only differences in existing args** (e.g., Debug vs Release in `/define:`, different hash paths). These are expected configuration differences. Focus on **extra or missing args** — a different arg count indicates a real build behavior change.
+
+## Example: CscArguments Investigation
+
+A merge PR (release/10.0.3xx → main) had 208 CSC args vs 207 on main. The diff:
+
+```
+FAIL-ONLY: /analyzerconfig:{W}/p/d/sdk/11.0.100-ci/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_11_default.globalconfig
+```
+
+### What the binlog properties showed
+
+Both builds had identical property resolution:
+- `EffectiveAnalysisLevel = 11.0`
+- `_GlobalAnalyzerConfigFileName = analysislevel_11_default.globalconfig`
+- `_GlobalAnalyzerConfigFile = .../config/analysislevel_11_default.globalconfig`
+
+### The actual root cause
+
+The `AddGlobalAnalyzerConfigForPackage` target has an `Exists()` condition:
+```xml
+<ItemGroup Condition="Exists('$(_GlobalAnalyzerConfigFile_...)')">
+  <EditorConfigFiles Include="$(_GlobalAnalyzerConfigFile_...)" />
+</ItemGroup>
+```
+
+The merge's SDK layout **shipped** `analysislevel_11_default.globalconfig` on disk (from a newer roslyn-analyzers that flowed from 10.0.3xx), while main's SDK didn't have that file yet. Same property values, different files on disk = different build behavior.
+
+### Lesson learned
+
+Same MSBuild property resolution + different files on disk = different build behavior. Always check what's actually in the SDK layout, not just what the targets compute.
+
+## Anti-Patterns
+
+> ❌ **Don't manually split/parse CSC command lines in the main conversation.** CSC args have quoted paths, spaces, and complex structure. Regex parsing in PowerShell is fragile and burns turns on trial-and-error. Use a subagent.
+
+> ❌ **Don't assume the MSBuild property diff explains the behavior diff.** Two branches can compute identical property values but produce different outputs because of different files on disk, different NuGet packages, or different task assemblies. Compare the actual task invocation.
+
+> ❌ **Don't load large binlogs and browse them interactively in main context.** Use targeted searches rather than browsing interactively. Get in, get the data, get out.

+ 219 - 0
.github/skills/ci-analysis/references/build-progression-analysis.md

@@ -0,0 +1,219 @@
+# Deep Investigation: Build Progression Analysis
+
+When the current build is failing, the PR's build history can reveal whether the failure existed from the start or appeared after specific changes. This is a fact-gathering technique — like target-branch comparison — that provides context for understanding the current failure.
+
+## When to Use This Pattern
+
+- Standard analysis (script + logs) hasn't identified the root cause of the current failure
+- The PR has multiple pushes and you want to know whether earlier builds passed or failed
+- You need to understand whether a failure is inherent to the PR's approach or was introduced by a later change
+
+## The Pattern
+
+### Step 0: Start with the recent builds
+
+Don't try to analyze the full build history upfront — especially on large PRs with many pushes. Start with the most recent N builds (5-8), present the progression table, and let the user decide whether to dig deeper into earlier builds.
+
+On large PRs, the user is usually iterating toward a solution. The recent builds are the most relevant. Offer: "Here are the last N builds — the pass→fail transition was between X and Y. Want me to look at earlier builds?"
+
+### Step 1: List builds for the PR
+
+`gh pr checks` only shows checks for the current HEAD SHA. To see the full build history, use AzDO or CLI:
+
+**With AzDO (preferred):**
+
+Query AzDO for builds on `refs/pull/{PR}/merge` branch, sorted by queue time descending, top 20, in the `public` project. The response includes `triggerInfo` with `pr.sourceSha` — the PR's HEAD commit for each build.
+
+> 💡 Key parameters: `branchName: "refs/pull/{PR}/merge"`, `queryOrder: "QueueTimeDescending"`, `top: 20`, project `public` (for dnceng-public org).
+
+**Without MCP (fallback):**
+```powershell
+$org = "https://dev.azure.com/dnceng-public"
+$project = "public"
+az pipelines runs list --branch "refs/pull/{PR}/merge" --top 20 --org $org -p $project -o json
+```
+
+### Step 2: Map builds to the PR's head commit
+
+Each build's `triggerInfo` contains `pr.sourceSha` — the PR's HEAD commit when the build was triggered. Extract it from the build response or CLI output.
+
+> ⚠️ **`sourceVersion` is the merge commit**, not the PR's head commit. Use `triggerInfo.'pr.sourceSha'` instead.
+
+> ⚠️ **Target branch moves between builds.** Each build merges `pr.sourceSha` into the target branch HEAD *at the time the build starts*. If `main` received new commits between build N and N+1, the two builds merged against different baselines — even if `pr.sourceSha` is the same. Always extract the target branch HEAD to detect baseline shifts.
+
+### Step 2b: Extract the target branch HEAD
+
+**Shortcut for the latest build — use the GitHub merge commit:**
+
+For the current/latest build, the merge ref (`refs/pull/{PR}/merge`) is available via the GitHub API. The merge commit's first parent is the target branch HEAD at the time GitHub computed the merge:
+
+Look up the merge commit's parents — the first parent is the target branch HEAD. Use the GitHub API or MCP (`get_commit` with the `sourceVersion` SHA) to get the commit details. The `sourceVersion` from the AzDO build is the merge commit SHA (not `pr.sourceSha`). Example:
+
+```
+gh api repos/{owner}/{repo}/git/commits/{sourceVersion} --jq '.parents[0].sha'
+```
+
+This is simpler than parsing checkout logs.
+
+> ⚠️ **This only works for the latest build.** GitHub recomputes `refs/pull/{PR}/merge` on each push, so the merge commit changes. For historical builds in a progression analysis, the merge ref no longer reflects what was built — use the checkout log method below.
+
+**For historical builds — extract from checkout logs:**
+
+The AzDO build API doesn't expose the target branch SHA. Extract it from the checkout task log.
+
+**With AzDO (preferred):**
+
+Fetch the checkout task log for the build — typically **log ID 5**, starting around **line 500+** (skip the early git-fetch output). Search the output for the merge line:
+```
+HEAD is now at {mergeCommit} Merge {prSourceSha} into {targetBranchHead}
+```
+
+> 💡 `logId: 5` is the first checkout task in most dotnet pipelines. If it doesn't contain the merge line, check the build timeline for "Checkout" tasks to find the correct log ID.
+
+**Without MCP (fallback):**
+```powershell
+$token = az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv
+$headers = @{ Authorization = "Bearer $token" }
+$logUrl = "https://dev.azure.com/{org}/{project}/_apis/build/builds/{BUILD_ID}/logs/5"
+$log = Invoke-RestMethod -Uri $logUrl -Headers $headers
+```
+
+> Note: log ID 5 is the first checkout task in most pipelines. The merge line is typically around line 500-650. If log 5 doesn't contain it, check the build timeline for "Checkout" tasks.
+
+Note: a PR may have more unique `pr.sourceSha` values than commits visible on GitHub, because force-pushes replace the commit history. Each force-push triggers a new build with a new merge commit and a new `pr.sourceSha`.
+
+### Step 3: Store progression in SQL
+
+Use the SQL tool to track builds as you discover them. This avoids losing context and enables queries across the full history:
+
+```sql
+CREATE TABLE IF NOT EXISTS build_progression (
+  build_id INT PRIMARY KEY,
+  pr_sha TEXT,
+  target_sha TEXT,
+  result TEXT,       -- passed, failed, canceled
+  queued_at TEXT,
+  failed_jobs TEXT,  -- comma-separated job names
+  notes TEXT
+);
+```
+
+Insert rows as you extract data from each build:
+
+```sql
+INSERT INTO build_progression VALUES
+  (1283986, '7af79ad', '2d638dc', 'failed', '2026-02-08T10:00:00Z', 'WasmBuildTests', 'Initial commits'),
+  (1284169, '28ec8a0', '0b691ba', 'failed', '2026-02-08T14:00:00Z', 'WasmBuildTests', 'Iteration 2'),
+  (1284433, '39dc0a6', '18a3069', 'passed', '2026-02-09T09:00:00Z', NULL, 'Iteration 3');
+```
+
+Then query to find the pass→fail transition:
+
+```sql
+-- Find where it went from passing to failing
+SELECT * FROM build_progression ORDER BY queued_at;
+
+-- Did the target branch move between pass and fail?
+SELECT pr_sha, target_sha, result FROM build_progression
+WHERE result IN ('passed', 'failed') ORDER BY queued_at;
+
+-- Which builds share the same PR SHA? (force-push detection)
+SELECT pr_sha, COUNT(*) as builds, GROUP_CONCAT(result) as results
+FROM build_progression GROUP BY pr_sha HAVING builds > 1;
+```
+
+Present the table to the user:
+
+| PR HEAD | Target HEAD | Builds | Result | Notes |
+|---------|-------------|--------|--------|-------|
+| 7af79ad | 2d638dc | 1283986 | ❌ | Initial commits |
+| 28ec8a0 | 0b691ba | 1284169 | ❌ | Iteration 2 |
+| 39dc0a6 | 18a3069 | 1284433 | ✅ | Iteration 3 |
+| f186b93 | 5709f35 | 1286087 | ❌ | Added commit C; target moved ~35 commits |
+| 2e74845 | 482d8f9 | 1286967 | ❌ | Modified commit C |
+
+When both `pr.sourceSha` AND `Target HEAD` change between a pass→fail transition, either could be the cause. Analyze the failure content to determine which. If only the target moved (same `pr.sourceSha`), the failure came from the new baseline.
+
+#### Tracking individual test failures across builds
+
+For deeper analysis, track which tests failed in each build:
+
+```sql
+CREATE TABLE IF NOT EXISTS build_failures (
+  build_id INT,
+  job_name TEXT,
+  test_name TEXT,
+  error_snippet TEXT,
+  helix_job TEXT,
+  work_item TEXT,
+  PRIMARY KEY (build_id, job_name, test_name)
+);
+```
+
+Insert failures as you investigate each build, then query for patterns:
+
+```sql
+-- Tests that fail in every build (persistent, not flaky)
+SELECT test_name, COUNT(DISTINCT build_id) as fail_count, GROUP_CONCAT(build_id) as builds
+FROM build_failures GROUP BY test_name HAVING fail_count > 1;
+
+-- New failures in the latest build (what changed?)
+SELECT f.* FROM build_failures f
+LEFT JOIN build_failures prev ON f.test_name = prev.test_name AND prev.build_id = {PREV_BUILD_ID}
+WHERE f.build_id = {LATEST_BUILD_ID} AND prev.test_name IS NULL;
+
+-- Flaky tests: fail in some builds, pass in others
+SELECT test_name FROM build_failures GROUP BY test_name
+HAVING COUNT(DISTINCT build_id) < (SELECT COUNT(*) FROM build_progression WHERE result = 'failed');
+```
+
+### Step 4: Present findings, not conclusions
+
+Report what the progression shows:
+- Which builds passed and which failed
+- What commits were added between the last passing and first failing build
+- Whether the failing commits were added in response to review feedback (check review threads)
+
+> 💡 **Stop when you have the progression table and the pass→fail transition identified.** The table + transition commits + error category is enough for the user to act. Don't investigate further (e.g., comparing individual commits, checking passing builds, exploring main branch history) unless the user asks.
+
+**Do not** make fix recommendations based solely on build progression. The progression narrows the investigation — it doesn't determine the right fix. The human may have context about why changes were made, what constraints exist, or what the reviewer intended.
+
+## Checking review context
+
+When the progression shows that a failure appeared after new commits, check whether those commits were review-requested:
+
+```powershell
+# Get review comments with timestamps
+gh api "repos/{OWNER}/{REPO}/pulls/{PR}/comments" `
+    --jq '.[] | {author: .user.login, body: .body, created: .created_at}'
+```
+
+Present this as additional context: "Commit C was pushed after reviewer X commented requesting Y." Let the author decide how to proceed.
+
+## Combining with Binlog Comparison
+
+Build progression identifies **which change** correlates with the current failure. Binlog comparison (see [binlog-comparison.md](binlog-comparison.md)) shows **what's different** in the build between a passing and failing state. Together they provide a complete picture:
+
+1. Progression → "The current failure first appeared in build N+1, which added commit C"
+2. Binlog comparison → "In the current (failing) build, task X receives parameter Y=Z, whereas in the passing build it received Y=W"
+
+## Relationship to Target-Branch Comparison
+
+Both techniques compare a failing build against a passing one:
+
+| Technique | Passing build from | Answers |
+|-----------|-------------------|---------|
+| **Target-branch comparison** | Recent build on the base branch (e.g., main) | "Does this test pass without the PR's changes at all?" |
+| **Build progression** | Earlier build on the same PR | "Did this test pass with the PR's *earlier* changes?" |
+
+Use target-branch comparison first to confirm the failure is PR-related. Use build progression to narrow down *which part* of the PR introduced it. If build progression shows a pass→fail transition with the same `pr.sourceSha`, the target branch is the more likely culprit — use target-branch comparison to confirm.
+
+## Anti-Patterns
+
+> ❌ **Don't treat build history as a substitute for analyzing the current build.** The current build determines CI status. Build history is context for understanding and investigating the current failure.
+
+> ❌ **Don't make fix recommendations from progression alone.** "Build N passed and build N+1 failed after adding commit C" is a fact worth reporting. "Therefore revert commit C" is a judgment that requires more context than the agent has — the commit may be addressing a critical review concern, fixing a different bug, or partially correct.
+
+> ❌ **Don't assume earlier passing builds prove the original approach was complete.** A build may pass because it didn't change enough to trigger the failing test scenario. The reviewer who requested additional changes may have identified a real gap.
+
+> ❌ **Don't assume MSBuild changes only affect the platform you're looking at.** MSBuild properties, conditions, and targets are shared infrastructure. A commit that changes a condition, moves a property, or modifies a restore flag can impact any platform that evaluates the same code path. When a commit touches MSBuild files, verify its impact across all platforms — don't assume it's scoped to the one you're investigating.

+ 124 - 0
.github/skills/ci-analysis/references/delegation-patterns.md

@@ -0,0 +1,124 @@
+# Subagent Delegation Patterns
+
+CI investigations involve repetitive, mechanical work that burns main conversation context. Delegate data gathering to subagents; keep interpretation in the main agent.
+
+## Pattern 1: Scanning Multiple Console Logs
+
+**When:** Multiple failing work items across several jobs.
+
+**Delegate:**
+```
+Extract all unique test failures from these Helix work items:
+
+Job: {JOB_ID_1}, Work items: {ITEM_1}, {ITEM_2}
+Job: {JOB_ID_2}, Work items: {ITEM_3}
+
+For each, search console logs for lines ending with [FAIL] (xUnit format).
+If hlx MCP is not available, fall back to:
+  ./scripts/Get-CIStatus.ps1 -HelixJob "{JOB}" -WorkItem "{ITEM}"
+
+Extract lines ending with [FAIL] (xUnit format). Ignore [OUTPUT] and [PASS] lines.
+
+Return JSON: { "failures": [{ "test": "Namespace.Class.Method", "workItems": ["item1", "item2"] }] }
+```
+
+## Pattern 2: Finding a Baseline Build
+
+**When:** A test fails on a PR — need to confirm it passes on the target branch.
+
+**Delegate:**
+```
+Find a recent passing build on {TARGET_BRANCH} of dotnet/{REPO} that ran the same test leg.
+
+Failing build: {BUILD_ID}, job: {JOB_NAME}, work item: {WORK_ITEM}
+
+Steps:
+1. Search for recently merged PRs:
+   Search for recently merged PRs on {TARGET_BRANCH}
+2. Run: ./scripts/Get-CIStatus.ps1 -PRNumber {MERGED_PR} -Repository "dotnet/{REPO}"
+3. Find the build with same job name that passed
+4. Locate the Helix job ID (may need artifact download — see [azure-cli.md](azure-cli.md))
+
+Return JSON: { "found": true, "buildId": N, "helixJob": "...", "workItem": "...", "result": "Pass" }
+Or: { "found": false, "reason": "no passing build in last 5 merged PRs" }
+
+If authentication fails or API returns errors, STOP and return the error — don't troubleshoot.
+```
+
+## Pattern 3: Extracting Merge PR Changed Files
+
+**When:** A large merge PR (hundreds of files) has test failures — need the file list for the main agent to analyze.
+
+**Delegate:**
+```
+List all changed files on merge PR #{PR_NUMBER} in dotnet/{REPO}.
+
+Get the list of changed files for PR #{PR_NUMBER} in dotnet/{REPO}
+
+For each file, note: path, change type (added/modified/deleted), lines changed.
+
+Return JSON: { "totalFiles": N, "files": [{ "path": "...", "changeType": "modified", "linesChanged": N }] }
+```
+
+> The main agent decides which files are relevant to the specific failures — don't filter in the subagent.
+
+## Pattern 4: Parallel Artifact Extraction
+
+**When:** Multiple builds or artifacts need independent analysis — binlog comparison, canceled job recovery, multi-build progression.
+
+**Key insight:** Launch one subagent per build/artifact in parallel. Each does its mechanical extraction independently. The main agent synthesizes results across all of them.
+
+**Delegate (per build, for binlog analysis):**
+```
+Download and analyze binlog from AzDO build {BUILD_ID}, artifact {ARTIFACT_NAME}.
+
+Steps:
+1. Download the artifact (see [azure-cli.md](azure-cli.md))
+2. Load the binlog, find the {TASK_NAME} task invocations, get full task details including CommandLineArguments.
+
+Return JSON: { "buildId": N, "project": "...", "args": ["..."] }
+```
+
+**Delegate (per build, for canceled job recovery):**
+```
+Check if canceled job "{JOB_NAME}" from build {BUILD_ID} has recoverable Helix results.
+
+Steps:
+1. Check if TRX test results are available for the work item. Parse them for pass/fail counts.
+2. If no structured results, check for testResults.xml
+3. Parse the XML for pass/fail counts on the <assembly> element
+
+Return JSON: { "jobName": "...", "hasResults": true, "passed": N, "failed": N }
+Or: { "jobName": "...", "hasResults": false, "reason": "no testResults.xml uploaded" }
+```
+
+This pattern scales to any number of builds — launch N subagents for N builds, collect results, compare.
+
+## Pattern 5: Build Progression with Target HEAD Extraction
+
+**When:** PR has multiple builds and you need the full progression table with target branch HEADs.
+
+**Delegate (one subagent per build):**
+```
+Extract the target branch HEAD from AzDO build {BUILD_ID}.
+
+Fetch the checkout task log (typically LOG ID 5, starting around LINE 500+ to skip git-fetch output)
+
+Search for: "HEAD is now at {mergeCommit} Merge {prSourceSha} into {targetBranchHead}"
+
+Return JSON: { "buildId": N, "targetHead": "abc1234", "mergeCommit": "def5678" }
+Or: { "buildId": N, "targetHead": null, "error": "merge line not found in log 5" }
+```
+
+Launch one per build in parallel. The main agent combines with the build list to build the full progression table.
+
+## General Guidelines
+
+- **Use `general-purpose` agent type** — it has shell + MCP access for Helix, AzDO, binlog, and GitHub queries
+- **Run independent tasks in parallel** — the whole point of delegation
+- **Include script paths** — subagents don't inherit skill context
+- **Require structured JSON output** — enables comparison across subagents
+- **Don't delegate interpretation** — subagents return facts, main agent reasons
+- **STOP on errors** — subagents should return error details immediately, not troubleshoot auth/environment issues
+- **Use SQL for many results** — when launching 5+ subagents or doing multi-phase delegation, store results in a SQL table (`CREATE TABLE results (agent_id TEXT, build_id INT, data TEXT, status TEXT)`) so you can query across all results instead of holding them in context
+- **Specify `model: "claude-sonnet-4"` for MCP-heavy tasks** — default model may time out on multi-step MCP tool chains

+ 285 - 0
.github/skills/ci-analysis/references/helix-artifacts.md

@@ -0,0 +1,285 @@
+# Helix Work Item Artifacts
+
+Guide to finding and analyzing artifacts from Helix test runs.
+
+## Accessing Artifacts
+
+### Via the Script
+
+Query a specific work item to see its artifacts:
+
+```powershell
+./scripts/Get-CIStatus.ps1 -HelixJob "4b24b2c2-..." -WorkItem "Microsoft.NET.Sdk.Tests.dll.1" -ShowLogs
+```
+
+### Via API
+
+```bash
+# Get work item details including Files array
+curl -s "https://helix.dot.net/api/2019-06-17/jobs/{jobId}/workitems/{workItemName}"
+```
+
+The `Files` array contains artifacts with `FileName` and `Uri` properties.
+
+## Artifact Availability Varies
+
+**Not all test types produce the same artifacts.** What you see depends on the repo, test type, and configuration:
+
+- **Build/publish tests** (SDK, WASM) → Multiple binlogs
+- **AOT compilation tests** (iOS/Android) → `AOTBuild.binlog` plus device logs
+- **Standard unit tests** → Console logs only, no binlogs
+- **Crash failures** (exit code 134) → Core dumps may be present
+
+Always query the specific work item to see what's available rather than assuming a fixed structure.
+
+## Common Artifact Patterns
+
+| File Pattern | Purpose | When Useful |
+|--------------|---------|-------------|
+| `*.binlog` | MSBuild binary logs | AOT/build failures, MSB4018 errors |
+| `console.*.log` | Console output | Always available, general output |
+| `run-*.log` | XHarness execution logs | Mobile test failures |
+| `device-*.log` | Device-specific logs | iOS/Android device issues |
+| `dotnetTestLog.*.log` | dotnet test output | Test framework issues |
+| `vstest.*.log` | VSTest output | aspnetcore/SDK test issues |
+| `core.*`, `*.dmp` | Core dumps | Crashes, hangs |
+| `testResults.xml` | Test results | Detailed pass/fail info |
+
+Artifacts may be at the root level or nested in subdirectories like `xharness-output/logs/`.
+
+> **Note:** The Helix work item Details API has a known bug ([dotnet/dnceng#6072](https://github.com/dotnet/dnceng/issues/6072)) where
+> file URIs for subdirectory files are incorrect, and unicode characters in filenames are rejected.
+> The script works around this by using the separate `ListFiles` endpoint (`GET .../workitems/{workItemName}/files`)
+> which returns direct blob storage URIs that work for all filenames regardless of subdirectories or unicode.
+
+## Binlog Files
+
+Binlogs are **only present for tests that invoke MSBuild** (build/publish tests, AOT compilation). Standard unit tests don't produce binlogs.
+
+### Common Names
+
+| File | Description |
+|------|-------------|
+| `build.msbuild.binlog` | Build phase |
+| `publish.msbuild.binlog` | Publish phase |
+| `AOTBuild.binlog` | AOT compilation |
+| `msbuild.binlog` | General MSBuild operations |
+| `msbuild0.binlog`, `msbuild1.binlog` | Per-test-run logs (numbered) |
+
+### Analyzing Binlogs
+
+**Online viewer (no download):**
+1. Copy the binlog URI from the script output
+2. Go to https://live.msbuildlog.com/
+3. Paste the URL to load and analyze
+
+**Download and view locally:**
+```bash
+curl -o build.binlog "https://helix.dot.net/api/jobs/{jobId}/workitems/{workItem}/files/build.msbuild.binlog?api-version=2019-06-17"
+# Open with MSBuild Structured Log Viewer
+```
+
+**AI-assisted analysis:**
+Use the MSBuild MCP server to analyze binlogs for errors and warnings.
+
+## Core Dumps
+
+Core dumps appear when tests crash (typically exit code 134 on Linux/macOS):
+
+```
+core.1000.34   # Format: core.{uid}.{pid}
+```
+
+## Mobile Test Artifacts (iOS/Android)
+
+Mobile device tests typically include XHarness orchestration logs:
+
+- `run-ios-device.log` / `run-android.log` - Execution log
+- `device-{machine}-*.log` - Device output
+- `list-ios-device-*.log` - Device discovery
+- `AOTBuild.binlog` - AOT compilation (when applicable)
+- `*.crash` - iOS crash reports
+
+## Finding the Right Work Item
+
+1. Run the script with `-ShowLogs` to see Helix job/work item info
+2. Look for lines like:
+   ```
+   Helix Job: 4b24b2c2-ad5a-4c46-8a84-844be03b1d51
+   Work Item: Microsoft.NET.Sdk.Tests.dll.1
+   ```
+3. Query that specific work item for full artifact list
+
+## AzDO Build Artifacts (Pre-Helix)
+
+Helix work items contain artifacts from **test execution**. But there's another source of binlogs: **AzDO build artifacts** from the build phase before tests are sent to Helix.
+
+### When to Use Build Artifacts
+
+- Failed work item has no binlogs (unit tests don't produce them)
+- You need to see how tests were **built**, not how they **executed**
+- Investigating build/restore issues that happen before Helix
+
+### Listing Build Artifacts
+
+```powershell
+# List all artifacts for a build
+$org = "dnceng-public"
+$project = "public"
+$buildId = 1280125
+
+$url = "https://dev.azure.com/$org/$project/_apis/build/builds/$buildId/artifacts?api-version=5.0"
+$artifacts = (Invoke-RestMethod -Uri $url).value
+
+# Show artifacts with sizes
+$artifacts | ForEach-Object {
+    $sizeMB = [math]::Round($_.resource.properties.artifactsize / 1MB, 2)
+    Write-Host "$($_.name) - $sizeMB MB"
+}
+```
+
+### Common Build Artifacts
+
+| Artifact Pattern | Contents | Size |
+|------------------|----------|------|
+| `TestBuild_*` | Test build outputs + binlogs | 30-100 MB |
+| `BuildConfiguration` | Build config metadata | <1 MB |
+| `TemplateEngine_*` | Template engine outputs | ~40 MB |
+| `AoT_*` | AOT compilation outputs | ~3 MB |
+| `FullFramework_*` | .NET Framework test outputs | ~40 MB |
+
+### Downloading and Finding Binlogs
+
+```powershell
+# Download a specific artifact
+$artifactName = "TestBuild_linux_x64"
+$downloadUrl = "https://dev.azure.com/$org/$project/_apis/build/builds/$buildId/artifacts?artifactName=$artifactName&api-version=5.0&`$format=zip"
+$zipPath = "$env:TEMP\$artifactName.zip"
+$extractPath = "$env:TEMP\$artifactName"
+
+Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath
+Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
+
+# Find binlogs
+Get-ChildItem -Path $extractPath -Filter "*.binlog" -Recurse | ForEach-Object {
+    $sizeMB = [math]::Round($_.Length / 1MB, 2)
+    Write-Host "$($_.Name) ($sizeMB MB) - $($_.FullName)"
+}
+```
+
+### Typical Binlogs in Build Artifacts
+
+| File | Description |
+|------|-------------|
+| `log/Release/Build.binlog` | Main build log |
+| `log/Release/TestBuildTests.binlog` | Test build verification |
+| `log/Release/ToolsetRestore.binlog` | Toolset restore |
+
+### Build vs Helix Binlogs
+
+| Source | When Generated | What It Shows |
+|--------|----------------|---------------|
+| AzDO build artifacts | During CI build phase | How tests were compiled/packaged |
+| Helix work item artifacts | During test execution | What happened when tests ran `dotnet build` etc. |
+
+If a test runs `dotnet build` internally (like SDK end-to-end tests), both sources may have relevant binlogs.
+
+## Downloaded Artifact Layout
+
+When you download artifacts via MCP tools or manually, the directory structure can be confusing. Here's what to expect.
+
+### Helix Work Item Downloads
+
+MCP tools for downloading Helix artifacts:
+- **`hlx_download`** — downloads multiple files from a work item. Returns local file paths.
+- **`hlx_download_url`** — downloads a single file by direct URI (from `hlx_files` output). Use when you know exactly which file you need.
+
+> 💡 **Prefer remote investigation first**: search file contents, parse test results, and search logs remotely before downloading. Only download when you need to load binlogs or do offline analysis.
+
+`hlx_download` saves files to a temp directory. The structure is **flat** — all files from the work item land in one directory:
+
+```
+C:\...\Temp\helix-{hash}\
+├── console.d991a56d.log          # Console output
+├── testResults.xml               # Test pass/fail details
+├── msbuild.binlog                # Only if test invoked MSBuild
+├── publish.msbuild.binlog        # Only if test did a publish
+├── msbuild0.binlog               # Numbered: first test's build
+├── msbuild1.binlog               # Numbered: second test's build
+└── core.1000.34                  # Only on crash
+```
+
+**Key confusion point:** Numbered binlogs (`msbuild0.binlog`, `msbuild1.binlog`) correspond to individual test cases within the work item, not to build phases. A work item like `Microsoft.NET.Build.Tests.dll.18` runs dozens of tests, each invoking MSBuild separately. To map a binlog to a specific test:
+1. Load it with the binlog analysis tools
+2. Check the project paths inside — they usually contain the test name
+3. Or check `testResults.xml` to correlate test execution order with binlog numbering
+
+### AzDO Build Artifact Downloads
+
+AzDO artifacts download as **ZIP files** with nested directory structures:
+
+```
+$env:TEMP\TestBuild_linux_x64\
+└── TestBuild_linux_x64\          # Artifact name repeated as subfolder
+    └── log\Release\
+        ├── Build.binlog           # Main build
+        ├── TestBuildTests.binlog   # Test build verification
+        ├── ToolsetRestore.binlog   # Toolset restore
+        └── SendToHelix.binlog     # Contains Helix job GUIDs
+```
+
+**Key confusion point:** The artifact name appears twice in the path (extract folder + subfolder inside the ZIP). Use the full nested path when loading binlogs.
+
+### Mapping Binlogs to Failures
+
+This table shows the **typical** source for each binlog type. The boundaries aren't absolute — some repos run tests on the build agent (producing test binlogs in AzDO artifacts), and Helix work items for SDK/Blazor tests invoke `dotnet build` internally (producing build binlogs as Helix artifacts).
+
+| You want to investigate... | Look here first | But also check... |
+|---------------------------|-----------------|-------------------|
+| Why a test's internal `dotnet build` failed | Helix work item (`msbuild{N}.binlog`) | AzDO artifact if tests ran on agent |
+| Why the CI build itself failed to compile | AzDO build artifact (`Build.binlog`) | — |
+| Which Helix jobs were dispatched | AzDO build artifact (`SendToHelix.binlog`) | — |
+| AOT compilation failure | Helix work item (`AOTBuild.binlog`) | — |
+| Test build/publish behavior | Helix work item (`publish.msbuild.binlog`) | AzDO artifact (`TestBuildTests.binlog`) |
+
+> **Rule of thumb:** If the failing job name contains "Helix" or "Send to Helix", the test binlogs are in Helix. If the job runs tests directly (common in dotnet/sdk), check AzDO artifacts.
+
+### Tracking Downloaded Artifacts with SQL
+
+When downloading from multiple work items (e.g., binlog comparison between passing and failing builds), use SQL to avoid losing track of what's where:
+
+```sql
+CREATE TABLE IF NOT EXISTS downloaded_artifacts (
+  local_path TEXT PRIMARY KEY,
+  helix_job TEXT,
+  work_item TEXT,
+  build_id INT,
+  artifact_source TEXT,  -- 'helix' or 'azdo'
+  file_type TEXT,        -- 'binlog', 'testResults', 'console', 'crash'
+  notes TEXT             -- e.g., 'passing baseline', 'failing PR build'
+);
+```
+
+Key queries:
+```sql
+-- Find the pair of binlogs for comparison
+SELECT local_path, notes FROM downloaded_artifacts
+WHERE file_type = 'binlog' ORDER BY notes;
+
+-- What have I downloaded from a specific work item?
+SELECT local_path, file_type FROM downloaded_artifacts
+WHERE work_item = 'Microsoft.NET.Build.Tests.dll.18';
+```
+
+Use this whenever you're juggling artifacts from 2+ Helix jobs (especially during the binlog comparison pattern in [binlog-comparison.md](binlog-comparison.md)).
+
+### Tips
+
+- **Multiple binlogs ≠ multiple builds.** A single work item can produce several binlogs if the test suite runs multiple `dotnet build`/`dotnet publish` commands.
+- **Helix and AzDO binlogs can overlap.** Helix binlogs are *usually* from test execution and AzDO binlogs from the build phase, but SDK/Blazor tests invoke MSBuild inside Helix (producing build-like binlogs), and some repos run tests directly on the build agent (producing test binlogs in AzDO). Check both sources if you can't find what you need.
+- **Not all work items have binlogs.** Standard unit tests only produce `testResults.xml` and console logs.
+- **Use `hlx_download` with `pattern:"*.binlog"`** to filter downloads and avoid pulling large console logs.
+
+## Artifact Retention
+
+Helix artifacts are retained for a limited time (typically 30 days). Download important artifacts promptly if needed for long-term analysis.

+ 98 - 0
.github/skills/ci-analysis/references/manual-investigation.md

@@ -0,0 +1,98 @@
+# Manual Investigation Guide
+
+If the script doesn't provide enough information, use these manual investigation steps.
+
+## Table of Contents
+- [Get Build Timeline](#get-build-timeline)
+- [Find Helix Tasks](#find-helix-tasks)
+- [Get Build Logs](#get-build-logs)
+- [Query Helix APIs](#query-helix-apis)
+- [Download Artifacts](#download-artifacts)
+- [Analyze Binlogs](#analyze-binlogs)
+- [Extract Environment Variables](#extract-environment-variables)
+
+## Get Build Timeline
+
+```powershell
+$buildId = 1276327
+$response = Invoke-RestMethod -Uri "https://dev.azure.com/dnceng-public/cbb18261-c48f-4abb-8651-8cdcb5474649/_apis/build/builds/$buildId/timeline?api-version=7.0"
+$failedJobs = $response.records | Where-Object { $_.type -eq "Job" -and $_.result -eq "failed" }
+$failedJobs | Select-Object id, name, result | Format-Table
+```
+
+## Find Helix Tasks
+
+```powershell
+$jobId = "90274d9a-fbd8-54f8-6a7d-8dfc4e2f6f3f"  # From timeline
+$helixTasks = $response.records | Where-Object { $_.parentId -eq $jobId -and $_.name -like "*Helix*" }
+$helixTasks | Select-Object id, name, result, log | Format-Table
+```
+
+## Get Build Logs
+
+```powershell
+$logId = 565  # From task.log.id
+$logContent = Invoke-RestMethod -Uri "https://dev.azure.com/dnceng-public/cbb18261-c48f-4abb-8651-8cdcb5474649/_apis/build/builds/$buildId/logs/${logId}?api-version=7.0"
+$logContent | Select-String -Pattern "error|FAIL" -Context 2,5
+```
+
+## Query Helix APIs
+
+> 💡 **Prefer MCP tools when available** — they handle most Helix queries without manual curl commands. Use the APIs below only as fallback.
+
+```bash
+# Get job details
+curl -s "https://helix.dot.net/api/2019-06-17/jobs/JOB_ID"
+
+# List work items
+curl -s "https://helix.dot.net/api/2019-06-17/jobs/JOB_ID/workitems"
+
+# Get work item details
+curl -s "https://helix.dot.net/api/2019-06-17/jobs/JOB_ID/workitems/WORK_ITEM_NAME"
+
+# Get console log
+curl -s "https://helix.dot.net/api/2019-06-17/jobs/JOB_ID/workitems/WORK_ITEM_NAME/console"
+```
+
+## Download Artifacts
+
+```powershell
+$workItem = Invoke-RestMethod -Uri "https://helix.dot.net/api/2019-06-17/jobs/$jobId/workitems/$workItemName"
+$workItem.Files | ForEach-Object { Write-Host "$($_.FileName): $($_.Uri)" }
+```
+
+Common artifacts:
+- `console.*.log` - Console output
+- `*.binlog` - MSBuild binary logs
+- `run-*.log` - XHarness/test runner logs
+- Core dumps and crash reports
+
+## Analyze Binlogs
+
+Binlogs contain detailed MSBuild execution traces for diagnosing:
+- AOT compilation failures
+- Static web asset issues
+- NuGet restore problems
+- Target execution order issues
+
+**Using MSBuild binlog MCP tools:**
+
+Load the binlog, then search for errors/diagnostics or specific queries. The binlog MCP tools handle loading, searching, and extracting task details.
+
+**Manual Analysis:**
+Use [MSBuild Structured Log Viewer](https://msbuildlog.com/) or https://live.msbuildlog.com/
+
+## Extract Environment Variables
+
+```bash
+curl -s "https://helix.dot.net/api/2019-06-17/jobs/JOB_ID/workitems/WORK_ITEM_NAME/console" | grep "DOTNET_"
+```
+
+Example output:
+```
+DOTNET_JitStress=1
+DOTNET_TieredCompilation=0
+DOTNET_GCStress=0xC
+```
+
+These are critical for reproducing failures locally.

+ 107 - 0
.github/skills/ci-analysis/references/sql-tracking.md

@@ -0,0 +1,107 @@
+# SQL Tracking for CI Investigations
+
+Use the SQL tool to track structured data during complex investigations. This avoids losing context across tool calls and enables queries that catch mistakes (like claiming "all failures known" when some are unmatched).
+
+## Failed Job Tracking
+
+Track each failure from the script output and map it to known issues as you verify them:
+
+```sql
+CREATE TABLE IF NOT EXISTS failed_jobs (
+  build_id INT,
+  job_name TEXT,
+  error_category TEXT,   -- from failedJobDetails: test-failure, build-error, crash, etc.
+  error_snippet TEXT,
+  known_issue_url TEXT,  -- NULL if unmatched
+  known_issue_title TEXT,
+  is_pr_correlated BOOLEAN DEFAULT FALSE,
+  recovery_status TEXT DEFAULT 'not-checked',  -- effectively-passed, real-failure, no-results
+  notes TEXT,
+  PRIMARY KEY (build_id, job_name)
+);
+```
+
+### Key queries
+
+```sql
+-- Unmatched failures (Build Analysis red = these exist)
+SELECT job_name, error_category, error_snippet FROM failed_jobs
+WHERE known_issue_url IS NULL;
+
+-- Are ALL failures accounted for?
+SELECT COUNT(*) as total,
+       SUM(CASE WHEN known_issue_url IS NOT NULL THEN 1 ELSE 0 END) as matched
+FROM failed_jobs;
+
+-- Which crash/canceled jobs need recovery verification?
+SELECT job_name, build_id FROM failed_jobs
+WHERE error_category IN ('crash', 'unclassified') AND recovery_status = 'not-checked';
+
+-- PR-correlated failures (fix before retrying)
+SELECT job_name, error_snippet FROM failed_jobs WHERE is_pr_correlated = TRUE;
+```
+
+### Workflow
+
+1. After the script runs, insert one row per failed job from `failedJobDetails` (each entry includes `buildId`)
+2. For each known issue from `knownIssues`, UPDATE matching rows with the issue URL
+3. Query for unmatched failures — these need investigation
+4. For crash/canceled jobs, update `recovery_status` after checking Helix results
+
+## Build Progression
+
+See [build-progression-analysis.md](build-progression-analysis.md) for the `build_progression` and `build_failures` tables that track pass/fail across multiple builds.
+
+> **`failed_jobs` vs `build_failures` — when to use each:**
+> - `failed_jobs` (above): **Job-level** — maps each failed AzDO job to a known issue. Use for single-build triage ("are all failures accounted for?").
+> - `build_failures` (build-progression-analysis.md): **Test-level** — tracks individual test names across builds. Use for progression analysis ("which tests started failing after commit X?").
+
+## PR Comment Tracking
+
+For deep-dive analysis — especially across a chain of related PRs (e.g., dependency flow failures, sequential merge PRs, or long-lived PRs with weeks of triage) — store PR comments so you can query them without re-fetching:
+
+```sql
+CREATE TABLE IF NOT EXISTS pr_comments (
+  pr_number INT,
+  repo TEXT DEFAULT 'dotnet/aspnetcore',
+  comment_id INT PRIMARY KEY,
+  author TEXT,
+  created_at TEXT,
+  body TEXT,
+  is_triage BOOLEAN DEFAULT FALSE  -- set TRUE if comment diagnoses a failure
+);
+```
+
+### Key queries
+
+```sql
+-- What has already been diagnosed? (avoid re-investigating)
+SELECT author, created_at, substr(body, 1, 200) FROM pr_comments
+WHERE is_triage = TRUE ORDER BY created_at;
+
+-- Cross-PR: same failure discussed in multiple PRs?
+SELECT pr_number, author, substr(body, 1, 150) FROM pr_comments
+WHERE body LIKE '%BlazorWasm%' ORDER BY created_at;
+
+-- Who was asked to investigate what?
+SELECT author, substr(body, 1, 200) FROM pr_comments
+WHERE body LIKE '%PTAL%' OR body LIKE '%could you%look%';
+```
+
+### When to use
+
+- Long-lived PRs (>1 week) with 10+ comments containing triage context
+- Analyzing a chain of related PRs where earlier PRs have relevant diagnosis
+- When the same failure appears across multiple merge/flow PRs and you need to know what was already tried
+
+## When to Use SQL vs. Not
+
+| Situation | Use SQL? |
+|-----------|----------|
+| 1-2 failed jobs, all match known issues | No — straightforward, hold in context |
+| 3+ failed jobs across multiple builds | Yes — prevents missed matches |
+| Build progression with 5+ builds | Yes — see [build-progression-analysis.md](build-progression-analysis.md) |
+| Crash recovery across multiple work items | Yes — cache testResults.xml findings |
+| Single build, single failure | No — overkill |
+| PR chain or long-lived PR with extensive triage comments | Yes — preserves diagnosis context across tool calls |
+| Downloading artifacts from 2+ Helix jobs (e.g., binlog comparison) | Yes — see [helix-artifacts.md](helix-artifacts.md) |

+ 2274 - 0
.github/skills/ci-analysis/scripts/Get-CIStatus.ps1

@@ -0,0 +1,2274 @@
+<#
+.SYNOPSIS
+    Retrieves test failures from Azure DevOps builds and Helix test runs.
+
+.DESCRIPTION
+    This script queries Azure DevOps for failed jobs in a build and retrieves
+    the corresponding Helix console logs to show detailed test failure information.
+    It can also directly query a specific Helix job and work item.
+
+.PARAMETER BuildId
+    The Azure DevOps build ID to query.
+
+.PARAMETER PRNumber
+    The GitHub PR number to find the associated build.
+
+.PARAMETER HelixJob
+    The Helix job ID (GUID) to query directly.
+
+.PARAMETER WorkItem
+    The Helix work item name to query (requires -HelixJob).
+
+.PARAMETER Repository
+    The GitHub repository (owner/repo format). Default: dotnet/aspnetcore
+
+.PARAMETER Organization
+    The Azure DevOps organization. Default: dnceng-public
+
+.PARAMETER Project
+    The Azure DevOps project GUID. Default: cbb18261-c48f-4abb-8651-8cdcb5474649
+
+.PARAMETER ShowLogs
+    If specified, fetches and displays the Helix console logs for failed tests.
+
+.PARAMETER MaxJobs
+    Maximum number of failed jobs to process. Default: 5
+
+.PARAMETER MaxFailureLines
+    Maximum number of lines to capture per test failure. Default: 50
+
+.PARAMETER TimeoutSec
+    Timeout in seconds for API calls. Default: 30
+
+.PARAMETER ContextLines
+    Number of context lines to show before errors. Default: 0
+
+.PARAMETER NoCache
+    Bypass cache and fetch fresh data for all API calls.
+
+.PARAMETER CacheTTLSeconds
+    Cache lifetime in seconds. Default: 30
+
+.PARAMETER ClearCache
+    Clear all cached files and exit.
+
+.PARAMETER ContinueOnError
+    Continue processing remaining jobs if an API call fails, showing partial results.
+
+.PARAMETER SearchMihuBot
+    Search MihuBot's semantic database for related issues and discussions.
+    Uses https://mihubot.xyz/mcp to find conceptually related issues across dotnet repositories.
+
+.PARAMETER FindBinlogs
+    Scan work items in a Helix job to find which ones contain MSBuild binlog files.
+    Useful when the failed work item doesn't have binlogs (e.g., unit tests) but you need
+    to find related build tests that do have binlogs for deeper analysis.
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -BuildId 1276327
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -PRNumber 123445 -ShowLogs
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -PRNumber 123445 -Repository dotnet/aspnetcore
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -HelixJob "4b24b2c2-ad5a-4c46-8a84-844be03b1d51" -WorkItem "iOS.Device.Aot.Test"
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -BuildId 1276327 -SearchMihuBot
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -HelixJob "4b24b2c2-ad5a-4c46-8a84-844be03b1d51" -FindBinlogs
+    # Scans work items to find which ones contain MSBuild binlog files
+
+.EXAMPLE
+    .\Get-CIStatus.ps1 -ClearCache
+#>
+
+[CmdletBinding(DefaultParameterSetName = 'BuildId')]
+param(
+    [Parameter(ParameterSetName = 'BuildId', Mandatory = $true)]
+    [int]$BuildId,
+
+    [Parameter(ParameterSetName = 'PRNumber', Mandatory = $true)]
+    [int]$PRNumber,
+
+    [Parameter(ParameterSetName = 'HelixJob', Mandatory = $true)]
+    [string]$HelixJob,
+
+    [Parameter(ParameterSetName = 'HelixJob')]
+    [string]$WorkItem,
+
+    [Parameter(ParameterSetName = 'ClearCache', Mandatory = $true)]
+    [switch]$ClearCache,
+
+    [string]$Repository = "dotnet/aspnetcore",
+    [string]$Organization = "dnceng-public",
+    [string]$Project = "cbb18261-c48f-4abb-8651-8cdcb5474649",
+    [switch]$ShowLogs,
+    [int]$MaxJobs = 5,
+    [int]$MaxFailureLines = 50,
+    [int]$TimeoutSec = 30,
+    [int]$ContextLines = 0,
+    [switch]$NoCache,
+    [int]$CacheTTLSeconds = 30,
+    [switch]$ContinueOnError,
+    [switch]$SearchMihuBot,
+    [switch]$FindBinlogs
+)
+
+$ErrorActionPreference = "Stop"
+
+#region Caching Functions
+
+# Cross-platform temp directory detection
+function Get-TempDirectory {
+    # Try common environment variables in order of preference
+    $tempPath = $env:TEMP
+    if (-not $tempPath) { $tempPath = $env:TMP }
+    if (-not $tempPath) { $tempPath = $env:TMPDIR }  # macOS
+    if (-not $tempPath -and $IsLinux) { $tempPath = "/tmp" }
+    if (-not $tempPath -and $IsMacOS) { $tempPath = "/tmp" }
+    if (-not $tempPath) {
+        # Fallback: use .cache in user's home directory
+        $home = $env:HOME
+        if (-not $home) { $home = $env:USERPROFILE }
+        if ($home) {
+            $tempPath = Join-Path $home ".cache"
+            if (-not (Test-Path $tempPath)) {
+                New-Item -ItemType Directory -Path $tempPath -Force | Out-Null
+            }
+        }
+    }
+    if (-not $tempPath) {
+        throw "Could not determine temp directory. Set TEMP, TMP, or TMPDIR environment variable."
+    }
+    return $tempPath
+}
+
+$script:TempDir = Get-TempDirectory
+
+# Handle -ClearCache parameter
+if ($ClearCache) {
+    $cacheDir = Join-Path $script:TempDir "ci-analysis-cache"
+    if (Test-Path $cacheDir) {
+        $files = Get-ChildItem -Path $cacheDir -File
+        $count = $files.Count
+        Remove-Item -Path $cacheDir -Recurse -Force
+        Write-Host "Cleared $count cached files from $cacheDir" -ForegroundColor Green
+    }
+    else {
+        Write-Host "Cache directory does not exist: $cacheDir" -ForegroundColor Yellow
+    }
+    exit 0
+}
+
+# Setup caching
+$script:CacheDir = Join-Path $script:TempDir "ci-analysis-cache"
+if (-not (Test-Path $script:CacheDir)) {
+    New-Item -ItemType Directory -Path $script:CacheDir -Force | Out-Null
+}
+
+# Clean up expired cache files on startup (files older than 2x TTL)
+function Clear-ExpiredCache {
+    param([int]$TTLSeconds = $CacheTTLSeconds)
+
+    $maxAge = $TTLSeconds * 2
+    $cutoff = (Get-Date).AddSeconds(-$maxAge)
+
+    Get-ChildItem -Path $script:CacheDir -File -ErrorAction SilentlyContinue | Where-Object {
+        $_.LastWriteTime -lt $cutoff
+    } | ForEach-Object {
+        Write-Verbose "Removing expired cache file: $($_.Name)"
+        try {
+            Remove-Item $_.FullName -Force -ErrorAction Stop
+        }
+        catch {
+            Write-Verbose "Failed to remove cache file '$($_.Name)': $($_.Exception.Message)"
+        }
+    }
+}
+
+# Run cache cleanup at startup (non-blocking)
+if (-not $NoCache) {
+    Clear-ExpiredCache -TTLSeconds $CacheTTLSeconds
+}
+
+function Get-UrlHash {
+    param([string]$Url)
+    
+    $sha256 = [System.Security.Cryptography.SHA256]::Create()
+    try {
+        return [System.BitConverter]::ToString(
+            $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Url))
+        ).Replace("-", "")
+    }
+    finally {
+        $sha256.Dispose()
+    }
+}
+
+function Get-CachedResponse {
+    param(
+        [string]$Url,
+        [int]$TTLSeconds = $CacheTTLSeconds
+    )
+
+    if ($NoCache) { return $null }
+
+    $hash = Get-UrlHash -Url $Url
+    $cacheFile = Join-Path $script:CacheDir "$hash.json"
+
+    if (Test-Path $cacheFile) {
+        $cacheInfo = Get-Item $cacheFile
+        $age = (Get-Date) - $cacheInfo.LastWriteTime
+
+        if ($age.TotalSeconds -lt $TTLSeconds) {
+            Write-Verbose "Cache hit for $Url (age: $([int]$age.TotalSeconds) sec)"
+            return Get-Content $cacheFile -Raw
+        }
+        else {
+            Write-Verbose "Cache expired for $Url"
+        }
+    }
+
+    return $null
+}
+
+function Set-CachedResponse {
+    param(
+        [string]$Url,
+        [string]$Content
+    )
+
+    if ($NoCache) { return }
+
+    $hash = Get-UrlHash -Url $Url
+    $cacheFile = Join-Path $script:CacheDir "$hash.json"
+    
+    # Use atomic write: write to temp file, then rename
+    $tempFile = Join-Path $script:CacheDir "$hash.tmp.$([System.Guid]::NewGuid().ToString('N'))"
+    try {
+        $Content | Set-Content -LiteralPath $tempFile -Force
+        Move-Item -LiteralPath $tempFile -Destination $cacheFile -Force
+        Write-Verbose "Cached response for $Url"
+    }
+    catch {
+        # Clean up temp file on failure
+        if (Test-Path $tempFile) {
+            Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
+        }
+        Write-Verbose "Failed to cache response: $_"
+    }
+}
+
+function Invoke-CachedRestMethod {
+    param(
+        [string]$Uri,
+        [int]$TimeoutSec = 30,
+        [switch]$AsJson,
+        [switch]$SkipCache,
+        [switch]$SkipCacheWrite
+    )
+
+    # Check cache first (unless skipping)
+    if (-not $SkipCache) {
+        $cached = Get-CachedResponse -Url $Uri
+        if ($cached) {
+            if ($AsJson) {
+                try {
+                    return $cached | ConvertFrom-Json -ErrorAction Stop
+                }
+                catch {
+                    Write-Verbose "Failed to parse cached response as JSON, treating as cache miss: $_"
+                }
+            }
+            else {
+                return $cached
+            }
+        }
+    }
+
+    # Make the actual request
+    Write-Verbose "GET $Uri"
+    $response = Invoke-RestMethod -Uri $Uri -Method Get -TimeoutSec $TimeoutSec
+
+    # Cache the response (unless skipping write)
+    if (-not $SkipCache -and -not $SkipCacheWrite) {
+        if ($AsJson -or $response -is [PSCustomObject]) {
+            $content = $response | ConvertTo-Json -Depth 100 -Compress
+            Set-CachedResponse -Url $Uri -Content $content
+        }
+        else {
+            Set-CachedResponse -Url $Uri -Content $response
+        }
+    }
+
+    return $response
+}
+
+#endregion Caching Functions
+
+#region Validation Functions
+
+function Test-RepositoryFormat {
+    param([string]$Repo)
+    
+    # Validate repository format to prevent command injection
+    $repoPattern = '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$'
+    if ($Repo -notmatch $repoPattern) {
+        throw "Invalid repository format '$Repo'. Expected 'owner/repo' (e.g., 'dotnet/aspnetcore')."
+    }
+    return $true
+}
+
+function Get-SafeSearchTerm {
+    param([string]$Term)
+    
+    # Sanitize search term to avoid passing unsafe characters to gh CLI
+    # Keep: alphanumeric, spaces, dots, hyphens, colons (for namespaces like System.Net),
+    # and slashes (for paths). These are safe for GitHub search and common in .NET names.
+    $safeTerm = $Term -replace '[^\w\s\-.:/]', ''
+    return $safeTerm.Trim()
+}
+
+#endregion Validation Functions
+
+#region Azure DevOps API Functions
+
+function Get-AzDOBuildIdFromPR {
+    param([int]$PR)
+
+    # Check for gh CLI dependency
+    if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
+        throw "GitHub CLI (gh) is required for PR lookup. Install from https://cli.github.com/ or use -BuildId instead."
+    }
+
+    # Validate repository format
+    Test-RepositoryFormat -Repo $Repository | Out-Null
+
+    Write-Host "Finding builds for PR #$PR in $Repository..." -ForegroundColor Cyan
+    Write-Verbose "Running: gh pr checks $PR --repo $Repository"
+
+    # Use gh cli to get the checks with splatted arguments
+    $checksOutput = & gh pr checks $PR --repo $Repository 2>&1
+    $ghExitCode = $LASTEXITCODE
+
+    if ($ghExitCode -ne 0 -and -not ($checksOutput | Select-String -Pattern "buildId=")) {
+        throw "Failed to fetch CI status for PR #$PR in $Repository - check PR number and permissions"
+    }
+
+    # Check if PR has merge conflicts (no CI runs when mergeable_state is dirty)
+    $prMergeState = $null
+    $prMergeStateOutput = & gh api "repos/$Repository/pulls/$PR" --jq '.mergeable_state' 2>$null
+    $ghMergeStateExitCode = $LASTEXITCODE
+    if ($ghMergeStateExitCode -eq 0 -and $prMergeStateOutput) {
+        $prMergeState = $prMergeStateOutput.Trim()
+    } else {
+        Write-Verbose "Could not determine PR merge state (gh exit code $ghMergeStateExitCode)."
+    }
+
+    # Find ALL failing Azure DevOps builds
+    $failingBuilds = @{}
+    foreach ($line in $checksOutput) {
+        if ($line -match 'fail.*buildId=(\d+)') {
+            $buildId = $Matches[1]
+            # Extract pipeline name (first column before 'fail')
+            $pipelineName = ($line -split '\s+fail')[0].Trim()
+            if (-not $failingBuilds.ContainsKey($buildId)) {
+                $failingBuilds[$buildId] = $pipelineName
+            }
+        }
+    }
+
+    if ($failingBuilds.Count -eq 0) {
+        # No failing builds - try to find any build
+        $anyBuild = $checksOutput | Select-String -Pattern "buildId=(\d+)" | Select-Object -First 1
+        if ($anyBuild) {
+            $anyBuildMatch = [regex]::Match($anyBuild.ToString(), "buildId=(\d+)")
+            if ($anyBuildMatch.Success) {
+                $buildIdStr = $anyBuildMatch.Groups[1].Value
+                $buildIdInt = 0
+                if ([int]::TryParse($buildIdStr, [ref]$buildIdInt)) {
+                    return @{ BuildIds = @($buildIdInt); Reason = $null; MergeState = $prMergeState }
+                }
+            }
+        }
+        if ($prMergeState -eq 'dirty') {
+            Write-Host "`nPR #$PR has merge conflicts (mergeable_state: dirty)" -ForegroundColor Red
+            Write-Host "CI will not run until conflicts are resolved." -ForegroundColor Yellow
+            Write-Host "Resolve conflicts and push to trigger CI, or use -BuildId to analyze a previous build." -ForegroundColor Gray
+            return @{ BuildIds = @(); Reason = "MERGE_CONFLICTS"; MergeState = $prMergeState }
+        }
+        Write-Host "`nNo CI build found for PR #$PR in $Repository" -ForegroundColor Red
+        Write-Host "The CI pipeline has not been triggered yet." -ForegroundColor Yellow
+        return @{ BuildIds = @(); Reason = "NO_BUILDS"; MergeState = $prMergeState }
+    }
+
+    # Return all unique failing build IDs
+    $buildIds = $failingBuilds.Keys | ForEach-Object { [int]$_ } | Sort-Object -Unique
+
+    if ($buildIds.Count -gt 1) {
+        Write-Host "Found $($buildIds.Count) failing builds:" -ForegroundColor Yellow
+        foreach ($id in $buildIds) {
+            Write-Host "  - Build $id ($($failingBuilds[$id.ToString()]))" -ForegroundColor Gray
+        }
+    }
+
+    return @{ BuildIds = $buildIds; Reason = $null; MergeState = $prMergeState }
+}
+
+function Get-BuildAnalysisKnownIssues {
+    param([int]$PR)
+
+    # Check for gh CLI dependency
+    if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
+        Write-Verbose "GitHub CLI (gh) not available for Build Analysis check"
+        return @()
+    }
+
+    Write-Verbose "Fetching Build Analysis check for PR #$PR..."
+
+    try {
+        # Get the head commit SHA for the PR
+        $headSha = gh pr view $PR --repo $Repository --json headRefOid --jq '.headRefOid' 2>&1
+        if ($LASTEXITCODE -ne 0) {
+            Write-Verbose "Failed to get PR head SHA: $headSha"
+            return @()
+        }
+
+        # Validate headSha is a valid git SHA (40 hex characters)
+        if ($headSha -notmatch '^[a-fA-F0-9]{40}$') {
+            Write-Verbose "Invalid head SHA format: $headSha"
+            return @()
+        }
+
+        # Get the Build Analysis check run
+        $checkRuns = gh api "repos/$Repository/commits/$headSha/check-runs" --jq '.check_runs[] | select(.name == "Build Analysis") | .output' 2>&1
+        if ($LASTEXITCODE -ne 0 -or -not $checkRuns) {
+            Write-Verbose "No Build Analysis check found"
+            return @()
+        }
+
+        $output = $checkRuns | ConvertFrom-Json -ErrorAction SilentlyContinue
+        if (-not $output -or -not $output.text) {
+            Write-Verbose "Build Analysis check has no output text"
+            return @()
+        }
+
+        # Parse known issues from the output text
+        # Format: <a href="https://github.com/dotnet/aspnetcore/issues/117164">Issue Title</a>
+        $knownIssues = @()
+        $issuePattern = '<a href="(https://github\.com/[^/]+/[^/]+/issues/(\d+))">([^<]+)</a>'
+        $matches = [regex]::Matches($output.text, $issuePattern)
+
+        foreach ($match in $matches) {
+            $issueUrl = $match.Groups[1].Value
+            $issueNumber = $match.Groups[2].Value
+            $issueTitle = $match.Groups[3].Value
+
+            # Avoid duplicates
+            if (-not ($knownIssues | Where-Object { $_.Number -eq $issueNumber })) {
+                $knownIssues += @{
+                    Number = $issueNumber
+                    Url = $issueUrl
+                    Title = $issueTitle
+                }
+            }
+        }
+
+        if ($knownIssues.Count -gt 0) {
+            Write-Host "`nBuild Analysis found $($knownIssues.Count) known issue(s):" -ForegroundColor Yellow
+            foreach ($issue in $knownIssues) {
+                Write-Host "  - #$($issue.Number): $($issue.Title)" -ForegroundColor Gray
+                Write-Host "    $($issue.Url)" -ForegroundColor DarkGray
+            }
+        }
+
+        return $knownIssues
+    }
+    catch {
+        Write-Verbose "Error fetching Build Analysis: $_"
+        return @()
+    }
+}
+
+function Get-PRChangedFiles {
+    param(
+        [int]$PR,
+        [int]$MaxFiles = 100
+    )
+
+    # Check for gh CLI dependency
+    if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
+        Write-Verbose "GitHub CLI (gh) not available for PR file lookup"
+        return @()
+    }
+
+    Write-Verbose "Fetching changed files for PR #$PR..."
+
+    try {
+        # Get the file count first to avoid fetching huge PRs
+        $fileCount = gh pr view $PR --repo $Repository --json files --jq '.files | length' 2>&1
+        if ($LASTEXITCODE -ne 0) {
+            Write-Verbose "Failed to get PR file count: $fileCount"
+            return @()
+        }
+
+        $count = [int]$fileCount
+        if ($count -gt $MaxFiles) {
+            Write-Verbose "PR has $count files (exceeds limit of $MaxFiles) - skipping correlation"
+            Write-Host "PR has $count changed files - skipping detailed correlation (limit: $MaxFiles)" -ForegroundColor Gray
+            return @()
+        }
+
+        # Get the list of changed files
+        $filesJson = gh pr view $PR --repo $Repository --json files --jq '.files[].path' 2>&1
+        if ($LASTEXITCODE -ne 0) {
+            Write-Verbose "Failed to get PR files: $filesJson"
+            return @()
+        }
+
+        $files = $filesJson -split "`n" | Where-Object { $_ }
+        return $files
+    }
+    catch {
+        Write-Verbose "Error fetching PR files: $_"
+        return @()
+    }
+}
+
+function Get-PRCorrelation {
+    param(
+        [array]$ChangedFiles,
+        [array]$AllFailures
+    )
+
+    $result = @{ CorrelatedFiles = @(); TestFiles = @() }
+    if ($ChangedFiles.Count -eq 0 -or $AllFailures.Count -eq 0) { return $result }
+
+    $failureText = ($AllFailures | ForEach-Object {
+        $_.TaskName
+        $_.JobName
+        $_.Errors -join "`n"
+        $_.HelixLogs -join "`n"
+        $_.FailedTests -join "`n"
+    }) -join "`n"
+
+    foreach ($file in $ChangedFiles) {
+        $fileName = [System.IO.Path]::GetFileNameWithoutExtension($file)
+        $fileNameWithExt = [System.IO.Path]::GetFileName($file)
+        $baseTestName = $fileName -replace '\.[^.]+$', ''
+
+        $isCorrelated = $false
+        if ($failureText -match [regex]::Escape($fileName) -or
+            $failureText -match [regex]::Escape($fileNameWithExt) -or
+            $failureText -match [regex]::Escape($file) -or
+            ($baseTestName -and $failureText -match [regex]::Escape($baseTestName))) {
+            $isCorrelated = $true
+        }
+
+        if ($isCorrelated) {
+            $isTestFile = $file -match '\.Tests?\.' -or $file -match '[/\\]tests?[/\\]' -or $file -match 'Test\.cs$' -or $file -match 'Tests\.cs$'
+            if ($isTestFile) { $result.TestFiles += $file } else { $result.CorrelatedFiles += $file }
+        }
+    }
+
+    $result.CorrelatedFiles = @($result.CorrelatedFiles | Select-Object -Unique)
+    $result.TestFiles = @($result.TestFiles | Select-Object -Unique)
+    return $result
+}
+
+function Show-PRCorrelationSummary {
+    param(
+        [array]$ChangedFiles,
+        [array]$AllFailures
+    )
+
+    if ($ChangedFiles.Count -eq 0) {
+        return
+    }
+
+    $correlation = Get-PRCorrelation -ChangedFiles $ChangedFiles -AllFailures $AllFailures
+    $correlatedFiles = $correlation.CorrelatedFiles
+    $testFiles = $correlation.TestFiles
+
+    # Show results
+    if ($correlatedFiles.Count -gt 0 -or $testFiles.Count -gt 0) {
+        Write-Host "`n=== PR Change Correlation ===" -ForegroundColor Magenta
+
+        if ($testFiles.Count -gt 0) {
+            Write-Host "⚠️  Test files changed by this PR are failing:" -ForegroundColor Yellow
+            $shown = 0
+            foreach ($file in $testFiles) {
+                if ($shown -ge 10) {
+                    Write-Host "    ... and $($testFiles.Count - 10) more test files" -ForegroundColor Gray
+                    break
+                }
+                Write-Host "    $file" -ForegroundColor Red
+                $shown++
+            }
+        }
+
+        if ($correlatedFiles.Count -gt 0) {
+            Write-Host "⚠️  Files changed by this PR appear in failures:" -ForegroundColor Yellow
+            $shown = 0
+            foreach ($file in $correlatedFiles) {
+                if ($shown -ge 10) {
+                    Write-Host "    ... and $($correlatedFiles.Count - 10) more files" -ForegroundColor Gray
+                    break
+                }
+                Write-Host "    $file" -ForegroundColor Red
+                $shown++
+            }
+        }
+
+        Write-Host "`nCorrelated files found — check JSON summary for details." -ForegroundColor Yellow
+    }
+}
+
+function Get-AzDOBuildStatus {
+    param([int]$Build)
+
+    $url = "https://dev.azure.com/$Organization/$Project/_apis/build/builds/${Build}?api-version=7.0"
+
+    try {
+        # First check cache to see if we have a completed status
+        $cached = Get-CachedResponse -Url $url
+        if ($cached) {
+            $cachedData = $cached | ConvertFrom-Json
+            # Only use cache if build was completed - in-progress status goes stale quickly
+            if ($cachedData.status -eq "completed") {
+                return @{
+                    Status = $cachedData.status
+                    Result = $cachedData.result
+                    StartTime = $cachedData.startTime
+                    FinishTime = $cachedData.finishTime
+                }
+            }
+            Write-Verbose "Skipping cached in-progress build status"
+        }
+
+        # Fetch fresh status
+        $response = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec -AsJson -SkipCache
+
+        # Only cache if completed
+        if ($response.status -eq "completed") {
+            $content = $response | ConvertTo-Json -Depth 10 -Compress
+            Set-CachedResponse -Url $url -Content $content
+        }
+
+        return @{
+            Status = $response.status        # notStarted, inProgress, completed
+            Result = $response.result        # succeeded, failed, canceled (only set when completed)
+            StartTime = $response.startTime
+            FinishTime = $response.finishTime
+        }
+    }
+    catch {
+        Write-Verbose "Failed to fetch build status: $_"
+        return $null
+    }
+}
+
+function Get-AzDOTimeline {
+    param(
+        [int]$Build,
+        [switch]$BuildInProgress
+    )
+
+    $url = "https://dev.azure.com/$Organization/$Project/_apis/build/builds/$Build/timeline?api-version=7.0"
+    Write-Host "Fetching build timeline..." -ForegroundColor Cyan
+
+    try {
+        # Don't cache timeline for in-progress builds - it changes as jobs complete
+        $response = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec -AsJson -SkipCacheWrite:$BuildInProgress
+        return $response
+    }
+    catch {
+        if ($ContinueOnError) {
+            Write-Warning "Failed to fetch build timeline: $_"
+            return $null
+        }
+        throw "Failed to fetch build timeline: $_"
+    }
+}
+
+function Get-FailedJobs {
+    param($Timeline)
+
+    if ($null -eq $Timeline -or $null -eq $Timeline.records) {
+        return @()
+    }
+
+    $failedJobs = $Timeline.records | Where-Object {
+        $_.type -eq "Job" -and $_.result -eq "failed"
+    }
+
+    return $failedJobs
+}
+
+function Get-CanceledJobs {
+    param($Timeline)
+
+    if ($null -eq $Timeline -or $null -eq $Timeline.records) {
+        return @()
+    }
+
+    $canceledJobs = $Timeline.records | Where-Object {
+        $_.type -eq "Job" -and $_.result -eq "canceled"
+    }
+
+    return $canceledJobs
+}
+
+function Get-HelixJobInfo {
+    param($Timeline, $JobId)
+
+    if ($null -eq $Timeline -or $null -eq $Timeline.records) {
+        return @()
+    }
+
+    # Find tasks in this job that mention Helix
+    $helixTasks = $Timeline.records | Where-Object {
+        $_.parentId -eq $JobId -and
+        $_.name -like "*Helix*" -and
+        $_.result -eq "failed"
+    }
+
+    return $helixTasks
+}
+
+function Get-BuildLog {
+    param([int]$Build, [int]$LogId)
+
+    $url = "https://dev.azure.com/$Organization/$Project/_apis/build/builds/$Build/logs/${LogId}?api-version=7.0"
+
+    try {
+        $response = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec
+        return $response
+    }
+    catch {
+        Write-Warning "Failed to fetch log ${LogId}: $_"
+        return $null
+    }
+}
+
+#endregion Azure DevOps API Functions
+
+#region Log Parsing Functions
+
+function Extract-HelixUrls {
+    param([string]$LogContent)
+
+    $urls = @()
+
+    # First, normalize the content by removing line breaks that might split URLs
+    $normalizedContent = $LogContent -replace "`r`n", "" -replace "`n", ""
+
+    # Match Helix console log URLs - workitem names can contain dots, dashes, and other chars
+    $urlMatches = [regex]::Matches($normalizedContent, 'https://helix\.dot\.net/api/[^/]+/jobs/[a-f0-9-]+/workitems/[^/\s]+/console')
+    foreach ($match in $urlMatches) {
+        $urls += $match.Value
+    }
+
+    Write-Verbose "Found $($urls.Count) Helix URLs"
+    return $urls | Select-Object -Unique
+}
+
+function Extract-TestFailures {
+    param([string]$LogContent)
+
+    $failures = @()
+
+    # Match test failure patterns from MSBuild output
+    $pattern = 'error\s*:\s*.*Test\s+(\S+)\s+has failed'
+    $failureMatches = [regex]::Matches($LogContent, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
+
+    foreach ($match in $failureMatches) {
+        $failures += @{
+            TestName = $match.Groups[1].Value
+            FullMatch = $match.Value
+        }
+    }
+
+    Write-Verbose "Found $($failures.Count) test failures"
+    return $failures
+}
+
+function Extract-BuildErrors {
+    param(
+        [string]$LogContent,
+        [int]$Context = 5
+    )
+
+    $errors = @()
+    $lines = $LogContent -split "`n"
+
+    # Patterns for common build errors - ordered from most specific to least specific
+    $errorPatterns = @(
+        'error\s+CS\d+:.*',                        # C# compiler errors
+        'error\s+MSB\d+:.*',                       # MSBuild errors
+        'error\s+NU\d+:.*',                        # NuGet errors
+        '\.pcm: No such file or directory',        # Clang module cache
+        'EXEC\s*:\s*error\s*:.*',                  # Exec task errors
+        'fatal error:.*',                          # Fatal errors (clang, etc)
+        ':\s*error:',                              # Clang/GCC errors (file.cpp:123: error:)
+        'undefined reference to',                  # Linker errors
+        'cannot find -l',                          # Linker missing library
+        'collect2: error:',                        # GCC linker wrapper errors
+        '##\[error\].*'                            # AzDO error annotations (last - catch-all)
+    )
+
+    $combinedPattern = ($errorPatterns -join '|')
+
+    # Track if we only found MSBuild wrapper errors
+    $foundRealErrors = $false
+    $msbWrapperLines = @()
+
+    for ($i = 0; $i -lt $lines.Count; $i++) {
+        if ($lines[$i] -match $combinedPattern) {
+            # Skip MSBuild wrapper "exited with code" if we find real errors
+            if ($lines[$i] -match 'exited with code \d+') {
+                $msbWrapperLines += $i
+                continue
+            }
+
+            # Skip duplicate MSBuild errors (they often repeat)
+            if ($lines[$i] -match 'error MSB3073.*exited with code') {
+                continue
+            }
+
+            $foundRealErrors = $true
+
+            # Clean up the line (remove timestamps, etc)
+            $cleanLine = $lines[$i] -replace '^\d{4}-\d{2}-\d{2}T[\d:\.]+Z\s*', ''
+            $cleanLine = $cleanLine -replace '##\[error\]', 'ERROR: '
+
+            # Add context lines if requested
+            if ($Context -gt 0) {
+                $contextStart = [Math]::Max(0, $i - $Context)
+                $contextLines = @()
+                for ($j = $contextStart; $j -lt $i; $j++) {
+                    $contextLines += "  " + $lines[$j].Trim()
+                }
+                if ($contextLines.Count -gt 0) {
+                    $errors += ($contextLines -join "`n")
+                }
+            }
+
+            $errors += $cleanLine.Trim()
+        }
+    }
+
+    # If we only found MSBuild wrapper errors, show context around them
+    if (-not $foundRealErrors -and $msbWrapperLines.Count -gt 0) {
+        $wrapperLine = $msbWrapperLines[0]
+        # Look for real errors in the 50 lines before the wrapper error
+        $searchStart = [Math]::Max(0, $wrapperLine - 50)
+        for ($i = $searchStart; $i -lt $wrapperLine; $i++) {
+            $line = $lines[$i]
+            # Look for C++/clang/gcc style errors
+            if ($line -match ':\s*error:' -or $line -match 'fatal error:' -or $line -match 'undefined reference') {
+                $cleanLine = $line -replace '^\d{4}-\d{2}-\d{2}T[\d:\.]+Z\s*', ''
+                $errors += $cleanLine.Trim()
+            }
+        }
+    }
+
+    return $errors | Select-Object -First 20 | Select-Object -Unique
+}
+
+function Extract-HelixLogUrls {
+    param([string]$LogContent)
+
+    $urls = @()
+
+    # Match Helix console log URLs from log content
+    # Pattern: https://helix.dot.net/api/2019-06-17/jobs/{jobId}/workitems/{workItemName}/console
+    $pattern = 'https://helix\.dot\.net/api/[^/]+/jobs/([a-f0-9-]+)/workitems/([^/\s]+)/console'
+    $urlMatches = [regex]::Matches($LogContent, $pattern)
+
+    foreach ($match in $urlMatches) {
+        $urls += @{
+            Url = $match.Value
+            JobId = $match.Groups[1].Value
+            WorkItem = $match.Groups[2].Value
+        }
+    }
+
+    # Deduplicate by URL
+    $uniqueUrls = @{}
+    foreach ($url in $urls) {
+        if (-not $uniqueUrls.ContainsKey($url.Url)) {
+            $uniqueUrls[$url.Url] = $url
+        }
+    }
+
+    return $uniqueUrls.Values
+}
+
+#endregion Log Parsing Functions
+
+#region Known Issues Search
+
+function Search-MihuBotIssues {
+    param(
+        [string[]]$SearchTerms,
+        [string]$ExtraContext = "",
+        [string]$Repository = "dotnet/aspnetcore",
+        [bool]$IncludeOpen = $true,
+        [bool]$IncludeClosed = $true,
+        [int]$TimeoutSec = 30
+    )
+
+    $results = @()
+
+    if (-not $SearchTerms -or $SearchTerms.Count -eq 0) {
+        return $results
+    }
+
+    try {
+        # MihuBot MCP endpoint - call as JSON-RPC style request
+        $mcpUrl = "https://mihubot.xyz/mcp"
+
+        # Build the request payload matching the MCP tool schema
+        $payload = @{
+            jsonrpc = "2.0"
+            method = "tools/call"
+            id = [guid]::NewGuid().ToString()
+            params = @{
+                name = "search_dotnet_repos"
+                arguments = @{
+                    repository = $Repository
+                    searchTerms = $SearchTerms
+                    extraSearchContext = $ExtraContext
+                    includeOpen = $IncludeOpen
+                    includeClosed = $IncludeClosed
+                    includeIssues = $true
+                    includePullRequests = $true
+                    includeComments = $false
+                }
+            }
+        } | ConvertTo-Json -Depth 10
+
+        Write-Verbose "Calling MihuBot MCP endpoint with terms: $($SearchTerms -join ', ')"
+
+        $response = Invoke-RestMethod -Uri $mcpUrl -Method Post -Body $payload -ContentType "application/json" -TimeoutSec $TimeoutSec
+
+        # Parse MCP response
+        if ($response.result -and $response.result.content) {
+            foreach ($content in $response.result.content) {
+                if ($content.type -eq "text" -and $content.text) {
+                    $issueData = $content.text | ConvertFrom-Json -ErrorAction SilentlyContinue
+                    if ($issueData) {
+                        foreach ($issue in $issueData) {
+                            $results += @{
+                                Number = $issue.Number
+                                Title = $issue.Title
+                                Url = $issue.Url
+                                Repository = $issue.Repository
+                                State = $issue.State
+                                Source = "MihuBot"
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        # Deduplicate by issue number and repo
+        $unique = @{}
+        foreach ($issue in $results) {
+            $key = "$($issue.Repository)#$($issue.Number)"
+            if (-not $unique.ContainsKey($key)) {
+                $unique[$key] = $issue
+            }
+        }
+
+        return $unique.Values | Select-Object -First 5
+    }
+    catch {
+        Write-Verbose "MihuBot search failed: $_"
+        return @()
+    }
+}
+
+function Search-KnownIssues {
+    param(
+        [string]$TestName,
+        [string]$ErrorMessage,
+        [string]$Repository = "dotnet/aspnetcore"
+    )
+
+    # Search for known issues using the "Known Build Error" label
+    # This label is used by Build Analysis across dotnet repositories
+
+    $knownIssues = @()
+
+    # Check if gh CLI is available
+    if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
+        Write-Verbose "GitHub CLI not available for searching known issues"
+        return $knownIssues
+    }
+
+    try {
+        # Extract search terms from test name and error message
+        $searchTerms = @()
+
+        # First priority: Look for [FAIL] test names in the error message
+        # Pattern: "TestName [FAIL]" - the test name comes BEFORE [FAIL]
+        if ($ErrorMessage -match '(\S+)\s+\[FAIL\]') {
+            $failedTest = $Matches[1]
+            # Extract just the method name (after last .)
+            if ($failedTest -match '\.([^.]+)$') {
+                $searchTerms += $Matches[1]
+            }
+            # Also add the full test name
+            $searchTerms += $failedTest
+        }
+
+        # Second priority: Extract test class/method from stack traces
+        if ($ErrorMessage -match 'at\s+(\w+\.\w+)\(' -and $searchTerms.Count -eq 0) {
+            $searchTerms += $Matches[1]
+        }
+
+        if ($TestName) {
+            # Try to get the test method name from the work item
+            if ($TestName -match '\.([^.]+)$') {
+                $methodName = $Matches[1]
+                # Only add if it looks like a test name (not just "Tests")
+                if ($methodName -ne "Tests" -and $methodName.Length -gt 5) {
+                    $searchTerms += $methodName
+                }
+            }
+            # Also try the full test name if it's not too long and looks specific
+            if ($TestName.Length -lt 100 -and $TestName -notmatch '^System\.\w+\.Tests$') {
+                $searchTerms += $TestName
+            }
+        }
+
+        # Third priority: Extract specific exception patterns (but not generic TimeoutException)
+        if ($ErrorMessage -and $searchTerms.Count -eq 0) {
+            # Look for specific exception types
+            if ($ErrorMessage -match '(System\.(?:InvalidOperation|ArgumentNull|Format)\w*Exception)') {
+                $searchTerms += $Matches[1]
+            }
+        }
+
+        # Deduplicate and limit search terms
+        $searchTerms = $searchTerms | Select-Object -Unique | Select-Object -First 3
+
+        foreach ($term in $searchTerms) {
+            if (-not $term) { continue }
+
+            # Sanitize the search term to avoid passing unsafe characters to gh CLI
+            $safeTerm = Get-SafeSearchTerm -Term $term
+            if (-not $safeTerm) { continue }
+
+            Write-Verbose "Searching for known issues with term: $safeTerm"
+
+            # Search for open issues with the "Known Build Error" label
+            $results = & gh issue list `
+                --repo $Repository `
+                --label "Known Build Error" `
+                --state open `
+                --search $safeTerm `
+                --limit 3 `
+                --json number,title,url 2>$null | ConvertFrom-Json
+
+            if ($results) {
+                foreach ($issue in $results) {
+                    # Check if the title actually contains our search term (avoid false positives)
+                    if ($issue.title -match [regex]::Escape($safeTerm)) {
+                        $knownIssues += @{
+                            Number = $issue.number
+                            Title = $issue.title
+                            Url = $issue.url
+                            SearchTerm = $safeTerm
+                        }
+                    }
+                }
+            }
+
+            # If we found issues, stop searching
+            if ($knownIssues.Count -gt 0) {
+                break
+            }
+        }
+
+        # Deduplicate by issue number
+        $unique = @{}
+        foreach ($issue in $knownIssues) {
+            if (-not $unique.ContainsKey($issue.Number)) {
+                $unique[$issue.Number] = $issue
+            }
+        }
+
+        return $unique.Values
+    }
+    catch {
+        Write-Verbose "Failed to search for known issues: $_"
+        return @()
+    }
+}
+
+function Show-KnownIssues {
+    param(
+        [string]$TestName = "",
+        [string]$ErrorMessage = "",
+        [string]$Repository = $script:Repository,
+        [switch]$IncludeMihuBot
+    )
+
+    # Search for known issues if we have a test name or error
+    if ($TestName -or $ErrorMessage) {
+        $knownIssues = Search-KnownIssues -TestName $TestName -ErrorMessage $ErrorMessage -Repository $Repository
+        if ($knownIssues -and $knownIssues.Count -gt 0) {
+            Write-Host "`n  Known Issues:" -ForegroundColor Magenta
+            foreach ($issue in $knownIssues) {
+                Write-Host "    #$($issue.Number): $($issue.Title)" -ForegroundColor Magenta
+                Write-Host "    $($issue.Url)" -ForegroundColor Gray
+            }
+        }
+
+        # Search MihuBot for related issues/discussions
+        if ($IncludeMihuBot) {
+            $searchTerms = @()
+
+            # Extract meaningful search terms
+            if ($ErrorMessage -match '(\S+)\s+\[FAIL\]') {
+                $failedTest = $Matches[1]
+                if ($failedTest -match '\.([^.]+)$') {
+                    $searchTerms += $Matches[1]
+                }
+            }
+
+            if ($TestName -and $TestName -match '\.([^.]+)$') {
+                $methodName = $Matches[1]
+                if ($methodName -ne "Tests" -and $methodName.Length -gt 5) {
+                    $searchTerms += $methodName
+                }
+            }
+
+            # Add test name as context
+            if ($TestName) {
+                $searchTerms += $TestName
+            }
+
+            $searchTerms = $searchTerms | Select-Object -Unique | Select-Object -First 3
+
+            if ($searchTerms.Count -gt 0) {
+                $mihuBotResults = Search-MihuBotIssues -SearchTerms $searchTerms -Repository $Repository -ExtraContext "test failure $TestName"
+                if ($mihuBotResults -and $mihuBotResults.Count -gt 0) {
+                    # Filter out issues already shown from Known Build Error search
+                    $knownNumbers = @()
+                    if ($knownIssues) {
+                        $knownNumbers = $knownIssues | ForEach-Object { $_.Number }
+                    }
+                    $newResults = $mihuBotResults | Where-Object { $_.Number -notin $knownNumbers }
+
+                    if ($newResults -and @($newResults).Count -gt 0) {
+                        Write-Host "`n  Related Issues (MihuBot):" -ForegroundColor Blue
+                        foreach ($issue in $newResults) {
+                            $stateIcon = if ($issue.State -eq "open") { "[open]" } else { "[closed]" }
+                            Write-Host "    #$($issue.Number): $($issue.Title) $stateIcon" -ForegroundColor Blue
+                            Write-Host "    $($issue.Url)" -ForegroundColor Gray
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+#endregion Known Issues Search
+
+#region Test Results Functions
+
+function Get-AzDOTestResults {
+    param(
+        [string]$RunId,
+        [string]$Org = "https://dev.azure.com/$Organization"
+    )
+
+    # Check if az devops CLI is available
+    if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
+        Write-Verbose "Azure CLI not available for fetching test results"
+        return $null
+    }
+
+    try {
+        Write-Verbose "Fetching test results for run $RunId via az devops CLI..."
+        $results = az devops invoke `
+            --org $Org `
+            --area test `
+            --resource Results `
+            --route-parameters project=$Project runId=$RunId `
+            --api-version 7.0 `
+            --query "value[?outcome=='Failed'].{name:testCaseTitle, outcome:outcome, error:errorMessage}" `
+            -o json 2>$null | ConvertFrom-Json
+
+        return $results
+    }
+    catch {
+        Write-Verbose "Failed to fetch test results via az devops: $_"
+        return $null
+    }
+}
+
+function Extract-TestRunUrls {
+    param([string]$LogContent)
+
+    $testRuns = @()
+
+    # Match Azure DevOps Test Run URLs
+    # Pattern: Published Test Run : https://dev.azure.com/dnceng-public/public/_TestManagement/Runs?runId=35626550&_a=runCharts
+    $pattern = 'Published Test Run\s*:\s*(https://dev\.azure\.com/[^/]+/[^/]+/_TestManagement/Runs\?runId=(\d+)[^\s]*)'
+    $matches = [regex]::Matches($LogContent, $pattern)
+
+    foreach ($match in $matches) {
+        $testRuns += @{
+            Url = $match.Groups[1].Value
+            RunId = $match.Groups[2].Value
+        }
+    }
+
+    Write-Verbose "Found $($testRuns.Count) test run URLs"
+    return $testRuns
+}
+
+function Get-LocalTestFailures {
+    param(
+        [object]$Timeline,
+        [int]$BuildId
+    )
+
+    $localFailures = @()
+
+    # Find failed test tasks (non-Helix)
+    # Look for tasks with "Test" in name that have issues but no Helix URLs
+    $testTasks = $Timeline.records | Where-Object {
+        ($_.name -match 'Test|xUnit' -or $_.type -eq 'Task') -and
+        $_.issues -and
+        $_.issues.Count -gt 0
+    }
+
+    foreach ($task in $testTasks) {
+        # Check if this task has test failures (XUnit errors)
+        $testErrors = $task.issues | Where-Object {
+            $_.message -match 'Tests failed:' -or
+            $_.message -match 'error\s*:.*Test.*failed'
+        }
+
+        if ($testErrors.Count -gt 0) {
+            # This is a local test failure - find the parent job for URL construction
+            $parentJob = $Timeline.records | Where-Object { $_.id -eq $task.parentId -and $_.type -eq "Job" } | Select-Object -First 1
+
+            $failure = @{
+                TaskName = $task.name
+                TaskId = $task.id
+                ParentJobId = if ($parentJob) { $parentJob.id } else { $task.parentId }
+                LogId = if ($task.log) { $task.log.id } else { $null }
+                Issues = $testErrors
+                TestRunUrls = @()
+            }
+
+            # Try to get test run URLs from the publish task
+            $publishTask = $Timeline.records | Where-Object {
+                $_.parentId -eq $task.parentId -and
+                $_.name -match 'Publish.*Test.*Results' -and
+                $_.log
+            } | Select-Object -First 1
+
+            if ($publishTask -and $publishTask.log) {
+                $logContent = Get-BuildLog -Build $BuildId -LogId $publishTask.log.id
+                if ($logContent) {
+                    $testRunUrls = Extract-TestRunUrls -LogContent $logContent
+                    $failure.TestRunUrls = $testRunUrls
+                }
+            }
+
+            $localFailures += $failure
+        }
+    }
+
+    return $localFailures
+}
+
+#endregion Test Results Functions
+
+#region Helix API Functions
+
+function Get-HelixJobDetails {
+    param([string]$JobId)
+
+    $url = "https://helix.dot.net/api/2019-06-17/jobs/$JobId"
+
+    try {
+        $response = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec -AsJson
+        return $response
+    }
+    catch {
+        Write-Warning "Failed to fetch Helix job ${JobId}: $_"
+        return $null
+    }
+}
+
+function Get-HelixWorkItems {
+    param([string]$JobId)
+
+    $url = "https://helix.dot.net/api/2019-06-17/jobs/$JobId/workitems"
+
+    try {
+        $response = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec -AsJson
+        return $response
+    }
+    catch {
+        Write-Warning "Failed to fetch work items for job ${JobId}: $_"
+        return $null
+    }
+}
+
+function Get-HelixWorkItemFiles {
+    <#
+    .SYNOPSIS
+        Fetches work item files via the ListFiles endpoint which returns direct blob storage URIs.
+    .DESCRIPTION
+        Workaround for https://github.com/dotnet/dnceng/issues/6072:
+        The Details endpoint returns incorrect permalink URIs for files in subdirectories
+        and rejects unicode characters in filenames. The ListFiles endpoint returns direct
+        blob storage URIs that always work, regardless of subdirectory depth or unicode.
+    #>
+    param([string]$JobId, [string]$WorkItemName)
+
+    $encodedWorkItem = [uri]::EscapeDataString($WorkItemName)
+    $url = "https://helix.dot.net/api/2019-06-17/jobs/$JobId/workitems/$encodedWorkItem/files"
+
+    try {
+        $files = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec -AsJson
+        return $files
+    }
+    catch {
+        Write-Warning "Failed to fetch files for work item ${WorkItemName}: $_"
+        return $null
+    }
+}
+
+function Get-HelixWorkItemDetails {
+    param([string]$JobId, [string]$WorkItemName)
+
+    $encodedWorkItem = [uri]::EscapeDataString($WorkItemName)
+    $url = "https://helix.dot.net/api/2019-06-17/jobs/$JobId/workitems/$encodedWorkItem"
+
+    try {
+        $response = Invoke-CachedRestMethod -Uri $url -TimeoutSec $TimeoutSec -AsJson
+
+        # Replace Files from the Details endpoint with results from ListFiles.
+        # The Details endpoint has broken URIs for subdirectory and unicode filenames
+        # (https://github.com/dotnet/dnceng/issues/6072). ListFiles returns direct
+        # blob storage URIs that always work.
+        $listFiles = Get-HelixWorkItemFiles -JobId $JobId -WorkItemName $WorkItemName
+        if ($null -ne $listFiles) {
+            $response.Files = @($listFiles | ForEach-Object {
+                [PSCustomObject]@{
+                    FileName = $_.Name
+                    Uri = $_.Link
+                }
+            })
+        }
+
+        return $response
+    }
+    catch {
+        Write-Warning "Failed to fetch work item ${WorkItemName}: $_"
+        return $null
+    }
+}
+
+function Get-HelixConsoleLog {
+    param([string]$Url)
+
+    try {
+        $response = Invoke-CachedRestMethod -Uri $Url -TimeoutSec $TimeoutSec
+        return $response
+    }
+    catch {
+        Write-Warning "Failed to fetch Helix log from ${Url}: $_"
+        return $null
+    }
+}
+
+function Find-WorkItemsWithBinlogs {
+    <#
+    .SYNOPSIS
+        Scans work items in a Helix job to find which ones contain binlog files.
+    .DESCRIPTION
+        Not all work items produce binlogs - only build/publish tests do.
+        This function helps locate work items that have binlogs for deeper analysis.
+    #>
+    param(
+        [Parameter(Mandatory)]
+        [string]$JobId,
+        [int]$MaxItems = 30,
+        [switch]$IncludeDetails
+    )
+
+    $workItems = Get-HelixWorkItems -JobId $JobId
+    if (-not $workItems) {
+        Write-Warning "No work items found for job $JobId"
+        return @()
+    }
+
+    Write-Host "Scanning up to $MaxItems work items for binlogs..." -ForegroundColor Gray
+
+    $results = @()
+    $scanned = 0
+
+    foreach ($wi in $workItems | Select-Object -First $MaxItems) {
+        $scanned++
+        $details = Get-HelixWorkItemDetails -JobId $JobId -WorkItemName $wi.Name
+        if ($details -and $details.Files) {
+            $binlogs = @($details.Files | Where-Object { $_.FileName -like "*.binlog" })
+            if ($binlogs.Count -gt 0) {
+                $result = @{
+                    Name = $wi.Name
+                    BinlogCount = $binlogs.Count
+                    Binlogs = $binlogs | ForEach-Object { $_.FileName }
+                    ExitCode = $details.ExitCode
+                    State = $details.State
+                }
+                if ($IncludeDetails) {
+                    $result.BinlogUris = $binlogs | ForEach-Object { $_.Uri }
+                }
+                $results += $result
+            }
+        }
+
+        # Progress indicator every 10 items
+        if ($scanned % 10 -eq 0) {
+            Write-Host "  Scanned $scanned/$MaxItems..." -ForegroundColor DarkGray
+        }
+    }
+
+    return $results
+}
+
+#endregion Helix API Functions
+
+#region Output Formatting
+
+function Format-TestFailure {
+    param(
+        [string]$LogContent,
+        [int]$MaxLines = $MaxFailureLines,
+        [int]$MaxFailures = 3
+    )
+
+    $lines = $LogContent -split "`n"
+    $allFailures = @()
+    $currentFailure = @()
+    $inFailure = $false
+    $emptyLineCount = 0
+    $failureCount = 0
+
+    # Expanded failure detection patterns
+    # CAUTION: These trigger "failure block" capture. Overly broad patterns (e.g. \w+Error:)
+    # will grab Python harness/reporter noise and swamp the real test failure.
+    $failureStartPatterns = @(
+        '\[FAIL\]',
+        'Assert\.\w+\(\)\s+Failure',
+        'Expected:.*but was:',
+        'BUG:',
+        'FAILED\s*$',
+        'END EXECUTION - FAILED',
+        'System\.\w+Exception:',
+        'Timed Out \(timeout'
+    )
+    $combinedPattern = ($failureStartPatterns -join '|')
+
+    foreach ($line in $lines) {
+        # Check for new failure start
+        if ($line -match $combinedPattern) {
+            # Save previous failure if exists
+            if ($currentFailure.Count -gt 0) {
+                $allFailures += ($currentFailure -join "`n")
+                $failureCount++
+                if ($failureCount -ge $MaxFailures) {
+                    break
+                }
+            }
+            # Start new failure
+            $currentFailure = @($line)
+            $inFailure = $true
+            $emptyLineCount = 0
+            continue
+        }
+
+        if ($inFailure) {
+            $currentFailure += $line
+
+            # Track consecutive empty lines to detect end of stack trace
+            if ($line -match '^\s*$') {
+                $emptyLineCount++
+            }
+            else {
+                $emptyLineCount = 0
+            }
+
+            # Stop this failure after stack trace ends (2+ consecutive empty lines) or max lines reached
+            if ($emptyLineCount -ge 2 -or $currentFailure.Count -ge $MaxLines) {
+                $allFailures += ($currentFailure -join "`n")
+                $currentFailure = @()
+                $inFailure = $false
+                $failureCount++
+                if ($failureCount -ge $MaxFailures) {
+                    break
+                }
+            }
+        }
+    }
+
+    # Don't forget last failure
+    if ($currentFailure.Count -gt 0 -and $failureCount -lt $MaxFailures) {
+        $allFailures += ($currentFailure -join "`n")
+    }
+
+    if ($allFailures.Count -eq 0) {
+        return $null
+    }
+
+    $result = $allFailures -join "`n`n--- Next Failure ---`n`n"
+
+    if ($failureCount -ge $MaxFailures) {
+        $result += "`n`n... (more failures exist, showing first $MaxFailures)"
+    }
+
+    return $result
+}
+
+# Helper to display test results from a test run
+function Show-TestRunResults {
+    param(
+        [object[]]$TestRunUrls,
+        [string]$Org = "https://dev.azure.com/$Organization"
+    )
+
+    if (-not $TestRunUrls -or $TestRunUrls.Count -eq 0) { return }
+
+    Write-Host "`n  Test Results:" -ForegroundColor Yellow
+    foreach ($testRun in $TestRunUrls) {
+        Write-Host "    Run $($testRun.RunId): $($testRun.Url)" -ForegroundColor Gray
+
+        $testResults = Get-AzDOTestResults -RunId $testRun.RunId -Org $Org
+        if ($testResults -and $testResults.Count -gt 0) {
+            Write-Host "`n    Failed tests ($($testResults.Count)):" -ForegroundColor Red
+            foreach ($result in $testResults | Select-Object -First 10) {
+                Write-Host "      - $($result.name)" -ForegroundColor White
+            }
+            if ($testResults.Count -gt 10) {
+                Write-Host "      ... and $($testResults.Count - 10) more" -ForegroundColor Gray
+            }
+        }
+    }
+}
+
+#endregion Output Formatting
+
+#region Main Execution
+
+# Main execution
+try {
+    # Handle direct Helix job query
+    if ($PSCmdlet.ParameterSetName -eq 'HelixJob') {
+        Write-Host "`n=== Helix Job $HelixJob ===" -ForegroundColor Yellow
+        Write-Host "URL: https://helix.dot.net/api/jobs/$HelixJob" -ForegroundColor Gray
+
+        # Get job details
+        $jobDetails = Get-HelixJobDetails -JobId $HelixJob
+        if ($jobDetails) {
+            Write-Host "`nQueue: $($jobDetails.QueueId)" -ForegroundColor Cyan
+            Write-Host "Source: $($jobDetails.Source)" -ForegroundColor Cyan
+        }
+
+        if ($WorkItem) {
+            # Query specific work item
+            Write-Host "`n--- Work Item: $WorkItem ---" -ForegroundColor Cyan
+
+            $workItemDetails = Get-HelixWorkItemDetails -JobId $HelixJob -WorkItemName $WorkItem
+            if ($workItemDetails) {
+                Write-Host "  State: $($workItemDetails.State)" -ForegroundColor $(if ($workItemDetails.State -eq 'Passed') { 'Green' } else { 'Red' })
+                Write-Host "  Exit Code: $($workItemDetails.ExitCode)" -ForegroundColor White
+                Write-Host "  Machine: $($workItemDetails.MachineName)" -ForegroundColor Gray
+                Write-Host "  Duration: $($workItemDetails.Duration)" -ForegroundColor Gray
+
+                # Show artifacts with binlogs highlighted
+                if ($workItemDetails.Files -and $workItemDetails.Files.Count -gt 0) {
+                    Write-Host "`n  Artifacts:" -ForegroundColor Yellow
+                    $binlogs = $workItemDetails.Files | Where-Object { $_.FileName -like "*.binlog" }
+                    $otherFiles = $workItemDetails.Files | Where-Object { $_.FileName -notlike "*.binlog" }
+
+                    # Show binlogs first with special formatting
+                    foreach ($file in $binlogs | Select-Object -Unique FileName, Uri) {
+                        Write-Host "    📋 $($file.FileName): $($file.Uri)" -ForegroundColor Cyan
+                    }
+                    if ($binlogs.Count -gt 0) {
+                        Write-Host "    (Tip: Use MSBuild MCP server or https://live.msbuildlog.com/ to analyze binlogs)" -ForegroundColor DarkGray
+                    }
+
+                    # Show other files
+                    foreach ($file in $otherFiles | Select-Object -Unique FileName, Uri | Select-Object -First 10) {
+                        Write-Host "    $($file.FileName): $($file.Uri)" -ForegroundColor Gray
+                    }
+                }
+
+                # Fetch console log
+                $consoleUrl = "https://helix.dot.net/api/2019-06-17/jobs/$HelixJob/workitems/$WorkItem/console"
+                Write-Host "`n  Console Log: $consoleUrl" -ForegroundColor Yellow
+
+                $consoleLog = Get-HelixConsoleLog -Url $consoleUrl
+                if ($consoleLog) {
+                    $failureInfo = Format-TestFailure -LogContent $consoleLog
+                    if ($failureInfo) {
+                        Write-Host $failureInfo -ForegroundColor White
+
+                        # Search for known issues
+                        Show-KnownIssues -TestName $WorkItem -ErrorMessage $failureInfo -IncludeMihuBot:$SearchMihuBot
+                    }
+                    else {
+                        # Show last 50 lines if no failure pattern detected
+                        $lines = $consoleLog -split "`n"
+                        $lastLines = $lines | Select-Object -Last 50
+                        Write-Host ($lastLines -join "`n") -ForegroundColor White
+                    }
+                }
+            }
+        }
+        else {
+            # List all work items in the job
+            Write-Host "`nWork Items:" -ForegroundColor Yellow
+            $workItems = Get-HelixWorkItems -JobId $HelixJob
+            if ($workItems) {
+                Write-Host "  Total: $($workItems.Count)" -ForegroundColor Cyan
+                Write-Host "  Checking for failures..." -ForegroundColor Gray
+
+                # Need to fetch details for each to find failures (list API only shows 'Finished')
+                $failedItems = @()
+                foreach ($wi in $workItems | Select-Object -First 20) {
+                    $details = Get-HelixWorkItemDetails -JobId $HelixJob -WorkItemName $wi.Name
+                    if ($details -and $null -ne $details.ExitCode -and $details.ExitCode -ne 0) {
+                        $failedItems += @{
+                            Name = $wi.Name
+                            ExitCode = $details.ExitCode
+                            State = $details.State
+                        }
+                    }
+                }
+
+                if ($failedItems.Count -gt 0) {
+                    Write-Host "`n  Failed Work Items:" -ForegroundColor Red
+                    foreach ($wi in $failedItems | Select-Object -First $MaxJobs) {
+                        Write-Host "    - $($wi.Name) (Exit: $($wi.ExitCode))" -ForegroundColor White
+                    }
+                    Write-Host "`n  Use -WorkItem '<name>' to see details" -ForegroundColor Gray
+                }
+                else {
+                    Write-Host "  No failures found in first 20 work items" -ForegroundColor Green
+                }
+
+                Write-Host "`n  All work items:" -ForegroundColor Yellow
+                foreach ($wi in $workItems | Select-Object -First 10) {
+                    Write-Host "    - $($wi.Name)" -ForegroundColor White
+                }
+                if ($workItems.Count -gt 10) {
+                    Write-Host "    ... and $($workItems.Count - 10) more" -ForegroundColor Gray
+                }
+
+                # Find work items with binlogs if requested
+                if ($FindBinlogs) {
+                    Write-Host "`n  === Binlog Search ===" -ForegroundColor Yellow
+                    $binlogResults = Find-WorkItemsWithBinlogs -JobId $HelixJob -MaxItems 30 -IncludeDetails
+
+                    if ($binlogResults.Count -gt 0) {
+                        Write-Host "`n  Work items with binlogs:" -ForegroundColor Cyan
+                        foreach ($result in $binlogResults) {
+                            $stateColor = if ($result.ExitCode -eq 0) { 'Green' } else { 'Red' }
+                            Write-Host "    $($result.Name)" -ForegroundColor $stateColor
+                            Write-Host "      Binlogs ($($result.BinlogCount)):" -ForegroundColor Gray
+                            foreach ($binlog in $result.Binlogs | Select-Object -First 5) {
+                                Write-Host "        - $binlog" -ForegroundColor White
+                            }
+                            if ($result.Binlogs.Count -gt 5) {
+                                Write-Host "        ... and $($result.Binlogs.Count - 5) more" -ForegroundColor DarkGray
+                            }
+                        }
+                        Write-Host "`n  Tip: Use -WorkItem '<name>' to get full binlog URIs" -ForegroundColor DarkGray
+                    }
+                    else {
+                        Write-Host "  No binlogs found in scanned work items." -ForegroundColor Yellow
+                        Write-Host "  This job may contain only unit tests (which don't produce binlogs)." -ForegroundColor Gray
+                    }
+                }
+            }
+        }
+
+        exit 0
+    }
+
+    # Get build ID(s) if using PR number
+    $buildIds = @()
+    $knownIssuesFromBuildAnalysis = @()
+    $prChangedFiles = @()
+    $noBuildReason = $null
+    if ($PSCmdlet.ParameterSetName -eq 'PRNumber') {
+        $buildResult = Get-AzDOBuildIdFromPR -PR $PRNumber
+        if ($buildResult.Reason) {
+            # No builds found — emit summary with reason and exit
+            $noBuildReason = $buildResult.Reason
+            $buildIds = @()
+            $summary = [ordered]@{
+                mode = "PRNumber"
+                repository = $Repository
+                prNumber = $PRNumber
+                builds = @()
+                totalFailedJobs = 0
+                totalLocalFailures = 0
+                lastBuildJobSummary = [ordered]@{
+                    total = 0; succeeded = 0; failed = 0; canceled = 0; pending = 0; warnings = 0; skipped = 0
+                }
+                failedJobNames = @()
+                failedJobDetails = @()
+                canceledJobNames = @()
+                knownIssues = @()
+                prCorrelation = [ordered]@{
+                    changedFileCount = 0
+                    hasCorrelation = $false
+                    correlatedFiles = @()
+                }
+                recommendationHint = if ($noBuildReason -eq "MERGE_CONFLICTS") { "MERGE_CONFLICTS" } else { "NO_BUILDS" }
+                noBuildReason = $noBuildReason
+                mergeState = $buildResult.MergeState
+            }
+            Write-Host ""
+            Write-Host "[CI_ANALYSIS_SUMMARY]"
+            Write-Host ($summary | ConvertTo-Json -Depth 5)
+            Write-Host "[/CI_ANALYSIS_SUMMARY]"
+            exit 0
+        }
+        $buildIds = @($buildResult.BuildIds)
+
+        # Check Build Analysis for known issues
+        $knownIssuesFromBuildAnalysis = @(Get-BuildAnalysisKnownIssues -PR $PRNumber)
+
+        # Get changed files for correlation
+        $prChangedFiles = @(Get-PRChangedFiles -PR $PRNumber)
+        if ($prChangedFiles.Count -gt 0) {
+            Write-Verbose "PR has $($prChangedFiles.Count) changed files"
+        }
+    }
+    else {
+        $buildIds = @($BuildId)
+    }
+
+    # Process each build
+    $totalFailedJobs = 0
+    $totalLocalFailures = 0
+    $allFailuresForCorrelation = @()
+    $allFailedJobNames = @()
+    $allCanceledJobNames = @()
+    $allFailedJobDetails = @()
+    $lastBuildJobSummary = $null
+
+    foreach ($currentBuildId in $buildIds) {
+        Write-Host "`n=== Azure DevOps Build $currentBuildId ===" -ForegroundColor Yellow
+        Write-Host "URL: https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId" -ForegroundColor Gray
+
+        # Get and display build status
+        $buildStatus = Get-AzDOBuildStatus -Build $currentBuildId
+        if ($buildStatus) {
+            $statusColor = switch ($buildStatus.Status) {
+                "inProgress" { "Cyan" }
+                "completed" { if ($buildStatus.Result -eq "succeeded") { "Green" } else { "Red" } }
+                default { "Gray" }
+            }
+            $statusText = $buildStatus.Status
+            if ($buildStatus.Status -eq "completed" -and $buildStatus.Result) {
+                $statusText = "$($buildStatus.Status) ($($buildStatus.Result))"
+            }
+            elseif ($buildStatus.Status -eq "inProgress") {
+                $statusText = "IN PROGRESS - showing failures so far"
+            }
+            Write-Host "Status: $statusText" -ForegroundColor $statusColor
+        }
+
+        # Get timeline
+        $isInProgress = $buildStatus -and $buildStatus.Status -eq "inProgress"
+        $timeline = Get-AzDOTimeline -Build $currentBuildId -BuildInProgress:$isInProgress
+
+        # Handle timeline fetch failure
+        if (-not $timeline) {
+            Write-Host "`nCould not fetch build timeline" -ForegroundColor Red
+            Write-Host "Build URL: https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId" -ForegroundColor Gray
+            continue
+        }
+
+        # Get failed jobs
+        $failedJobs = Get-FailedJobs -Timeline $timeline
+
+        # Get canceled jobs (different from failed - typically due to dependency failures)
+        $canceledJobs = Get-CanceledJobs -Timeline $timeline
+
+        # Also check for local test failures (non-Helix)
+        $localTestFailures = Get-LocalTestFailures -Timeline $timeline -BuildId $currentBuildId
+
+        # Accumulate totals and compute job summary BEFORE any continue branches
+        $totalFailedJobs += $failedJobs.Count
+        $totalLocalFailures += $localTestFailures.Count
+        $allFailedJobNames += @($failedJobs | ForEach-Object { $_.name })
+        $allCanceledJobNames += @($canceledJobs | ForEach-Object { $_.name })
+
+        $allJobs = @()
+        $succeededJobs = 0
+        $pendingJobs = 0
+        $canceledJobCount = 0
+        $skippedJobs = 0
+        $warningJobs = 0
+        if ($timeline -and $timeline.records) {
+            $allJobs = @($timeline.records | Where-Object { $_.type -eq "Job" })
+            $succeededJobs = @($allJobs | Where-Object { $_.result -eq "succeeded" }).Count
+            $warningJobs = @($allJobs | Where-Object { $_.result -eq "succeededWithIssues" }).Count
+            $pendingJobs = @($allJobs | Where-Object { -not $_.result -or $_.state -eq "pending" -or $_.state -eq "inProgress" }).Count
+            $canceledJobCount = @($allJobs | Where-Object { $_.result -eq "canceled" }).Count
+            $skippedJobs = @($allJobs | Where-Object { $_.result -eq "skipped" }).Count
+        }
+        $lastBuildJobSummary = [ordered]@{
+            total = $allJobs.Count
+            succeeded = $succeededJobs
+            failed = if ($failedJobs) { $failedJobs.Count } else { 0 }
+            canceled = $canceledJobCount
+            pending = $pendingJobs
+            warnings = $warningJobs
+            skipped = $skippedJobs
+        }
+
+        if ((-not $failedJobs -or $failedJobs.Count -eq 0) -and $localTestFailures.Count -eq 0) {
+            if ($buildStatus -and $buildStatus.Status -eq "inProgress") {
+                Write-Host "`nNo failures yet - build still in progress" -ForegroundColor Cyan
+                Write-Host "Run again later to check for failures, or use -NoCache to get fresh data" -ForegroundColor Gray
+            }
+            else {
+                Write-Host "`nNo failed jobs found in build $currentBuildId" -ForegroundColor Green
+            }
+            # Still show canceled jobs if any
+            if ($canceledJobs -and $canceledJobs.Count -gt 0) {
+                Write-Host "`nNote: $($canceledJobs.Count) job(s) were canceled (not failed):" -ForegroundColor DarkYellow
+                foreach ($job in $canceledJobs | Select-Object -First 5) {
+                    Write-Host "  - $($job.name)" -ForegroundColor DarkGray
+                }
+                if ($canceledJobs.Count -gt 5) {
+                    Write-Host "  ... and $($canceledJobs.Count - 5) more" -ForegroundColor DarkGray
+                }
+                Write-Host "  (Canceled jobs are typically due to earlier stage failures or timeouts)" -ForegroundColor DarkGray
+            }
+            continue
+        }
+
+        # Report local test failures first (these may exist even without failed jobs)
+        if ($localTestFailures.Count -gt 0) {
+            Write-Host "`n=== Local Test Failures (non-Helix) ===" -ForegroundColor Yellow
+            Write-Host "Build: https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId" -ForegroundColor Gray
+
+            foreach ($failure in $localTestFailures) {
+                Write-Host "`n--- $($failure.TaskName) ---" -ForegroundColor Cyan
+
+                # Collect issues for correlation
+                $issueMessages = $failure.Issues | ForEach-Object { $_.message }
+                $allFailuresForCorrelation += @{
+                    TaskName = $failure.TaskName
+                    JobName = "Local Test"
+                    Errors = $issueMessages
+                    HelixLogs = @()
+                    FailedTests = @()
+                }
+
+                # Show build and log links
+                $jobLogUrl = "https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId&view=logs&j=$($failure.ParentJobId)"
+                if ($failure.TaskId) {
+                    $jobLogUrl += "&t=$($failure.TaskId)"
+                }
+                Write-Host "  Log: $jobLogUrl" -ForegroundColor Gray
+
+                # Show issues
+                foreach ($issue in $failure.Issues) {
+                    Write-Host "  $($issue.message)" -ForegroundColor Red
+                }
+
+                # Show test run URLs if available
+                if ($failure.TestRunUrls.Count -gt 0) {
+                    Show-TestRunResults -TestRunUrls $failure.TestRunUrls -Org "https://dev.azure.com/$Organization"
+                }
+
+                # Try to get more details from the task log
+                if ($failure.LogId) {
+                    $logContent = Get-BuildLog -Build $currentBuildId -LogId $failure.LogId
+                    if ($logContent) {
+                        # Extract test run URLs from this log too
+                        $additionalRuns = Extract-TestRunUrls -LogContent $logContent
+                        if ($additionalRuns.Count -gt 0 -and $failure.TestRunUrls.Count -eq 0) {
+                            Show-TestRunResults -TestRunUrls $additionalRuns -Org "https://dev.azure.com/$Organization"
+                        }
+
+                        # Search for known issues based on build errors and task name
+                        $buildErrors = Extract-BuildErrors -LogContent $logContent
+                        if ($buildErrors.Count -gt 0) {
+                            Show-KnownIssues -ErrorMessage ($buildErrors -join "`n") -IncludeMihuBot:$SearchMihuBot
+                        }
+                        elseif ($failure.TaskName) {
+                            # If no specific errors, try searching by task name
+                            Show-KnownIssues -TestName $failure.TaskName -IncludeMihuBot:$SearchMihuBot
+                        }
+                    }
+                }
+            }
+        }
+
+        if (-not $failedJobs -or $failedJobs.Count -eq 0) {
+            Write-Host "`n=== Summary ===" -ForegroundColor Yellow
+            Write-Host "Local test failures: $($localTestFailures.Count)" -ForegroundColor Red
+            Write-Host "Build URL: https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId" -ForegroundColor Cyan
+            continue
+        }
+
+        Write-Host "`nFound $($failedJobs.Count) failed job(s):" -ForegroundColor Red
+
+        # Show canceled jobs if any (these are different from failed)
+        if ($canceledJobs -and $canceledJobs.Count -gt 0) {
+            Write-Host "Also $($canceledJobs.Count) job(s) were canceled (due to earlier failures/timeouts):" -ForegroundColor DarkYellow
+            foreach ($job in $canceledJobs | Select-Object -First 3) {
+                Write-Host "  - $($job.name)" -ForegroundColor DarkGray
+            }
+            if ($canceledJobs.Count -gt 3) {
+                Write-Host "  ... and $($canceledJobs.Count - 3) more" -ForegroundColor DarkGray
+            }
+        }
+
+        $processedJobs = 0
+        $errorCount = 0
+        foreach ($job in $failedJobs) {
+            if ($processedJobs -ge $MaxJobs) {
+                Write-Host "`n... and $($failedJobs.Count - $MaxJobs) more failed jobs (use -MaxJobs to see more)" -ForegroundColor Yellow
+                break
+            }
+
+            try {
+                Write-Host "`n--- $($job.name) ---" -ForegroundColor Cyan
+                Write-Host "  Build: https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId&view=logs&j=$($job.id)" -ForegroundColor Gray
+
+                # Track per-job failure details for JSON summary
+                $jobDetail = [ordered]@{
+                    jobName = $job.name
+                    buildId = $currentBuildId
+                    errorSnippet = ""
+                    helixWorkItems = @()
+                    errorCategory = "unclassified"
+                }
+
+                # Get Helix tasks for this job
+                $helixTasks = Get-HelixJobInfo -Timeline $timeline -JobId $job.id
+
+                if ($helixTasks) {
+                    foreach ($task in $helixTasks) {
+                        if ($task.log) {
+                            Write-Host "  Fetching Helix task log..." -ForegroundColor Gray
+                            $logContent = Get-BuildLog -Build $currentBuildId -LogId $task.log.id
+
+                            if ($logContent) {
+                                # Extract test failures
+                                $failures = Extract-TestFailures -LogContent $logContent
+
+                                if ($failures.Count -gt 0) {
+                                    Write-Host "  Failed tests:" -ForegroundColor Red
+                                    foreach ($failure in $failures) {
+                                        Write-Host "    - $($failure.TestName)" -ForegroundColor White
+                                    }
+
+                                    # Collect for PR correlation
+                                    $allFailuresForCorrelation += @{
+                                        TaskName = $task.name
+                                        JobName = $job.name
+                                        Errors = @()
+                                        HelixLogs = @()
+                                        FailedTests = $failures | ForEach-Object { $_.TestName }
+                                    }
+                                    $jobDetail.errorCategory = "test-failure"
+                                    $jobDetail.errorSnippet = ($failures | Select-Object -First 3 | ForEach-Object { $_.TestName }) -join "; "
+                                }
+
+                            # Extract and optionally fetch Helix URLs
+                            $helixUrls = Extract-HelixUrls -LogContent $logContent
+
+                            if ($helixUrls.Count -gt 0 -and $ShowLogs) {
+                                Write-Host "`n  Helix Console Logs:" -ForegroundColor Yellow
+
+                                foreach ($url in $helixUrls | Select-Object -First 3) {
+                                    Write-Host "`n  $url" -ForegroundColor Gray
+
+                                    # Extract work item name from URL for known issue search
+                                    $workItemName = ""
+                                    if ($url -match '/workitems/([^/]+)/console') {
+                                        $workItemName = $Matches[1]
+                                        $jobDetail.helixWorkItems += $workItemName
+                                    }
+
+                                    $helixLog = Get-HelixConsoleLog -Url $url
+                                    if ($helixLog) {
+                                        $failureInfo = Format-TestFailure -LogContent $helixLog
+                                        if ($failureInfo) {
+                                            Write-Host $failureInfo -ForegroundColor White
+
+                                            # Categorize failure from log content
+                                            if ($failureInfo -match 'Timed Out \(timeout') {
+                                                $jobDetail.errorCategory = "test-timeout"
+                                            } elseif ($failureInfo -match 'Exit Code:\s*(139|134|-4)' -or $failureInfo -match 'createdump') {
+                                                # Crash takes highest precedence — don't downgrade
+                                                if ($jobDetail.errorCategory -notin @("crash")) {
+                                                    $jobDetail.errorCategory = "crash"
+                                                }
+                                            } elseif ($failureInfo -match 'Traceback \(most recent call last\)' -and $helixLog -match 'Tests run:.*Failures:\s*0') {
+                                                # Work item failed (non-zero exit from reporter crash) but all tests passed.
+                                                # The Python traceback is from Helix infrastructure, not from the test itself.
+                                                if ($jobDetail.errorCategory -notin @("crash", "test-timeout")) {
+                                                    $jobDetail.errorCategory = "tests-passed-reporter-failed"
+                                                }
+                                            } elseif ($jobDetail.errorCategory -eq "unclassified") {
+                                                $jobDetail.errorCategory = "test-failure"
+                                            }
+                                            if (-not $jobDetail.errorSnippet) {
+                                                $jobDetail.errorSnippet = $failureInfo.Substring(0, [Math]::Min(200, $failureInfo.Length))
+                                            }
+
+                                            # Search for known issues
+                                            Show-KnownIssues -TestName $workItemName -ErrorMessage $failureInfo -IncludeMihuBot:$SearchMihuBot
+                                        }
+                                        else {
+                                            # No failure pattern matched — show tail of log
+                                            $lines = $helixLog -split "`n"
+                                            $lastLines = $lines | Select-Object -Last 20
+                                            $tailText = $lastLines -join "`n"
+                                            Write-Host $tailText -ForegroundColor White
+                                            if (-not $jobDetail.errorSnippet) {
+                                                $jobDetail.errorSnippet = $tailText.Substring(0, [Math]::Min(200, $tailText.Length))
+                                            }
+                                            Show-KnownIssues -TestName $workItemName -ErrorMessage $tailText -IncludeMihuBot:$SearchMihuBot
+                                        }
+                                    }
+                                }
+                            }
+                            elseif ($helixUrls.Count -gt 0) {
+                                Write-Host "`n  Helix logs available (use -ShowLogs to fetch):" -ForegroundColor Yellow
+                                foreach ($url in $helixUrls | Select-Object -First 3) {
+                                    Write-Host "    $url" -ForegroundColor Gray
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+                else {
+                    # No Helix tasks - this is a build failure, extract actual errors
+                    $buildTasks = $timeline.records | Where-Object {
+                        $_.parentId -eq $job.id -and $_.result -eq "failed"
+                    }
+
+                    foreach ($task in $buildTasks | Select-Object -First 3) {
+                        Write-Host "  Failed task: $($task.name)" -ForegroundColor Red
+
+                        # Fetch and parse the build log for actual errors
+                        if ($task.log) {
+                            $logUrl = "https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId&view=logs&j=$($job.id)&t=$($task.id)"
+                            Write-Host "  Log: $logUrl" -ForegroundColor Gray
+                            $logContent = Get-BuildLog -Build $currentBuildId -LogId $task.log.id
+
+                            if ($logContent) {
+                                $buildErrors = Extract-BuildErrors -LogContent $logContent
+
+                                if ($buildErrors.Count -gt 0) {
+                                    # Collect for PR correlation
+                                    $allFailuresForCorrelation += @{
+                                        TaskName = $task.name
+                                        JobName = $job.name
+                                        Errors = $buildErrors
+                                        HelixLogs = @()
+                                        FailedTests = @()
+                                    }
+                                    $jobDetail.errorCategory = "build-error"
+                                    if (-not $jobDetail.errorSnippet) {
+                                        $snippet = ($buildErrors | Select-Object -First 2) -join "; "
+                                        $jobDetail.errorSnippet = $snippet.Substring(0, [Math]::Min(200, $snippet.Length))
+                                    }
+
+                                    # Extract Helix log URLs from the full log content
+                                    $helixLogUrls = Extract-HelixLogUrls -LogContent $logContent
+
+                                    if ($helixLogUrls.Count -gt 0) {
+                                        Write-Host "  Helix failures ($($helixLogUrls.Count)):" -ForegroundColor Red
+                                        foreach ($helixLog in $helixLogUrls | Select-Object -First 5) {
+                                            Write-Host "    - $($helixLog.WorkItem)" -ForegroundColor White
+                                            Write-Host "      Log: $($helixLog.Url)" -ForegroundColor Gray
+                                        }
+                                        if ($helixLogUrls.Count -gt 5) {
+                                            Write-Host "    ... and $($helixLogUrls.Count - 5) more" -ForegroundColor Gray
+                                        }
+                                    }
+                                    else {
+                                        Write-Host "  Build errors:" -ForegroundColor Red
+                                        foreach ($err in $buildErrors | Select-Object -First 5) {
+                                            Write-Host "    $err" -ForegroundColor White
+                                        }
+                                        if ($buildErrors.Count -gt 5) {
+                                            Write-Host "    ... and $($buildErrors.Count - 5) more errors" -ForegroundColor Gray
+                                        }
+                                    }
+
+                                    # Search for known issues
+                                    Show-KnownIssues -ErrorMessage ($buildErrors -join "`n") -IncludeMihuBot:$SearchMihuBot
+                                }
+                                else {
+                                    Write-Host "  (No specific errors extracted from log)" -ForegroundColor Gray
+                                }
+                            }
+                        }
+                    }
+                }
+
+            $allFailedJobDetails += $jobDetail
+            $processedJobs++
+        }
+        catch {
+            $errorCount++
+            if ($ContinueOnError) {
+                Write-Warning "  Error processing job '$($job.name)': $_"
+            }
+            else {
+                throw [System.Exception]::new("Error processing job '$($job.name)': $($_.Exception.Message)", $_.Exception)
+            }
+        }
+    }
+
+    Write-Host "`n=== Build $currentBuildId Summary ===" -ForegroundColor Yellow
+    if ($allJobs.Count -gt 0) {
+        $parts = @()
+        if ($succeededJobs -gt 0) { $parts += "$succeededJobs passed" }
+        if ($warningJobs -gt 0) { $parts += "$warningJobs passed with warnings" }
+        if ($failedJobs.Count -gt 0) { $parts += "$($failedJobs.Count) failed" }
+        if ($canceledJobCount -gt 0) { $parts += "$canceledJobCount canceled" }
+        if ($skippedJobs -gt 0) { $parts += "$skippedJobs skipped" }
+        if ($pendingJobs -gt 0) { $parts += "$pendingJobs pending" }
+        $jobSummary = $parts -join ", "
+        $allSucceeded = ($failedJobs.Count -eq 0 -and $pendingJobs -eq 0 -and $canceledJobCount -eq 0 -and ($succeededJobs + $warningJobs + $skippedJobs) -eq $allJobs.Count)
+        $summaryColor = if ($allSucceeded) { "Green" } elseif ($failedJobs.Count -gt 0) { "Red" } else { "Cyan" }
+        Write-Host "Jobs: $($allJobs.Count) total ($jobSummary)" -ForegroundColor $summaryColor
+    }
+    else {
+        Write-Host "Failed jobs: $($failedJobs.Count)" -ForegroundColor Red
+    }
+    if ($localTestFailures.Count -gt 0) {
+        Write-Host "Local test failures: $($localTestFailures.Count)" -ForegroundColor Red
+    }
+    if ($errorCount -gt 0) {
+        Write-Host "API errors (partial results): $errorCount" -ForegroundColor Yellow
+    }
+    Write-Host "Build URL: https://dev.azure.com/$Organization/$Project/_build/results?buildId=$currentBuildId" -ForegroundColor Cyan
+}
+
+# Show PR change correlation if we have changed files
+if ($prChangedFiles.Count -gt 0 -and $allFailuresForCorrelation.Count -gt 0) {
+    Show-PRCorrelationSummary -ChangedFiles $prChangedFiles -AllFailures $allFailuresForCorrelation
+}
+
+# Overall summary if multiple builds
+if ($buildIds.Count -gt 1) {
+    Write-Host "`n=== Overall Summary ===" -ForegroundColor Magenta
+    Write-Host "Analyzed $($buildIds.Count) builds" -ForegroundColor White
+    Write-Host "Total failed jobs: $totalFailedJobs" -ForegroundColor Red
+    Write-Host "Total local test failures: $totalLocalFailures" -ForegroundColor Red
+
+    if ($knownIssuesFromBuildAnalysis.Count -gt 0) {
+        Write-Host "`nKnown Issues (from Build Analysis):" -ForegroundColor Yellow
+        foreach ($issue in $knownIssuesFromBuildAnalysis) {
+            Write-Host "  - #$($issue.Number): $($issue.Title)" -ForegroundColor Gray
+            Write-Host "    $($issue.Url)" -ForegroundColor DarkGray
+        }
+    }
+}
+
+# Build structured summary and emit as JSON
+$summary = [ordered]@{
+    mode = $PSCmdlet.ParameterSetName
+    repository = $Repository
+    prNumber = if ($PSCmdlet.ParameterSetName -eq 'PRNumber') { $PRNumber } else { $null }
+    builds = @($buildIds | ForEach-Object {
+        [ordered]@{
+            buildId = $_
+            url = "https://dev.azure.com/$Organization/$Project/_build/results?buildId=$_"
+        }
+    })
+    totalFailedJobs = $totalFailedJobs
+    totalLocalFailures = $totalLocalFailures
+    lastBuildJobSummary = if ($lastBuildJobSummary) { $lastBuildJobSummary } else { [ordered]@{
+        total = 0; succeeded = 0; failed = 0; canceled = 0; pending = 0; warnings = 0; skipped = 0
+    } }
+    failedJobNames = @($allFailedJobNames)
+    failedJobDetails = @($allFailedJobDetails)
+    failedJobDetailsTruncated = ($allFailedJobNames.Count -gt $allFailedJobDetails.Count)
+    canceledJobNames = @($allCanceledJobNames)
+    knownIssues = @($knownIssuesFromBuildAnalysis | ForEach-Object {
+        [ordered]@{ number = $_.Number; title = $_.Title; url = $_.Url }
+    })
+    prCorrelation = [ordered]@{
+        changedFileCount = $prChangedFiles.Count
+        hasCorrelation = $false
+        correlatedFiles = @()
+    }
+    recommendationHint = ""
+}
+
+# Compute PR correlation using shared helper
+if ($prChangedFiles.Count -gt 0 -and $allFailuresForCorrelation.Count -gt 0) {
+    $correlation = Get-PRCorrelation -ChangedFiles $prChangedFiles -AllFailures $allFailuresForCorrelation
+    $allCorrelated = @($correlation.CorrelatedFiles) + @($correlation.TestFiles) | Select-Object -Unique
+    $summary.prCorrelation.hasCorrelation = $allCorrelated.Count -gt 0
+    $summary.prCorrelation.correlatedFiles = @($allCorrelated)
+}
+
+# Compute recommendation hint
+# Priority: KNOWN_ISSUES wins over LIKELY_PR_RELATED intentionally.
+# When both exist, SKILL.md "Mixed signals" guidance tells the agent to separate them.
+if (-not $lastBuildJobSummary -and $buildIds.Count -gt 0) {
+    $summary.recommendationHint = "REVIEW_REQUIRED"
+} elseif ($knownIssuesFromBuildAnalysis.Count -gt 0) {
+    $summary.recommendationHint = "KNOWN_ISSUES_DETECTED"
+} elseif ($totalFailedJobs -eq 0 -and $totalLocalFailures -eq 0) {
+    $summary.recommendationHint = "BUILD_SUCCESSFUL"
+} elseif ($summary.prCorrelation.hasCorrelation) {
+    $summary.recommendationHint = "LIKELY_PR_RELATED"
+} elseif ($prChangedFiles.Count -gt 0 -and $allFailuresForCorrelation.Count -gt 0) {
+    $summary.recommendationHint = "POSSIBLY_TRANSIENT"
+} else {
+    $summary.recommendationHint = "REVIEW_REQUIRED"
+}
+
+Write-Host ""
+Write-Host "[CI_ANALYSIS_SUMMARY]"
+Write-Host ($summary | ConvertTo-Json -Depth 5)
+Write-Host "[/CI_ANALYSIS_SUMMARY]"
+
+}
+catch {
+    Write-Error "Error: $_"
+    exit 1
+}
+
+#endregion Main Execution