/extension.rs` for compiler integration
+3. extend `RuntimesConfig` in `src/compile/types.rs`
+4. register the runtime in extension collection
+5. add tests for pipeline generation, validation, and runtime-specific behavior
+
+Use a runtime when the feature installs or configures a language or execution environment before the agent runs.
+
+## Use the `CompilerExtension` trait
+
+The key abstraction for tools and runtimes is `CompilerExtension` in `src/compile/extensions/mod.rs`.
+
+Implement this trait when your feature needs to contribute things like:
+
+- required network hosts
+- required bash commands
+- prompt supplements
+- prepare or setup steps
+- MCP gateway server entries
+- allowed Copilot tools
+- compile-time validation
+- pipeline environment mappings
+- AWF mounts or PATH updates
+
+In practice, the workflow is:
+
+1. implement `CompilerExtension` for your runtime or tool
+2. return the pieces your feature needs
+3. let the compiler collect and merge those requirements
+
+This keeps new features composable instead of scattering special-case logic across the compiler.
+
+## Recommended extension workflow
+
+When adding a new feature:
+
+1. decide whether it belongs under `safe-outputs`, `tools`, `runtimes`, or target compilation
+2. add or update front-matter types in `src/compile/types.rs`
+3. implement the behavior in the colocated module
+4. register the feature in the compiler's collection and dispatch points
+5. add tests for parsing, validation, and generated YAML
+6. run `cargo test` and `cargo clippy`
+
+## Example decision guide
+
+Choose the extension point that matches the job:
+
+- **CLI command**: new end-user command
+- **compile target**: new output shape for generated pipelines
+- **front-matter field**: new author-facing configuration
+- **template marker**: new generated YAML insertion point
+- **safe output**: validated deferred write action
+- **first-class tool**: agent capability configured under `tools:`
+- **runtime**: installed language or execution environment
+
+If you place the feature in the right extension point from the start, the rest of the implementation tends to stay much simpler.
diff --git a/docs-site/src/content/docs/guides/network-config.mdx b/docs-site/src/content/docs/guides/network-config.mdx
new file mode 100644
index 00000000..77121f19
--- /dev/null
+++ b/docs-site/src/content/docs/guides/network-config.mdx
@@ -0,0 +1,177 @@
+---
+title: Configuring network access
+description: Learn how ado-aw network isolation works, which domains are allowed by default, and how to allow or block additional hosts.
+---
+
+# Configuring network access
+
+`ado-aw` runs agents inside **AWF** (Agentic Workflow Firewall), which applies network isolation to outbound traffic.
+
+The default model is simple:
+
+- the agent runs in an isolated environment
+- only approved domains are reachable
+- you explicitly allow anything extra the agent needs
+
+## Understand the AWF model
+
+When a pipeline runs, the agent does **not** get open internet access. Instead, AWF enforces an allowlist for outbound HTTP and HTTPS traffic.
+
+This gives you a safer default for AI-driven automation:
+
+- package registries can be allowed intentionally
+- internal APIs can be added explicitly
+- unwanted destinations can be blocked even if they would otherwise be reachable
+
+## Rely on the default allowed domains
+
+Several domains are always allowed so the platform can function.
+
+These include GitHub, Azure DevOps, and related authentication and asset hosts, such as:
+
+- `github.com`
+- `api.github.com`
+- `dev.azure.com`
+- `*.dev.azure.com`
+- Azure DevOps package and identity domains
+
+In practice, this means many common GitHub and Azure DevOps scenarios work without extra network configuration.
+
+## Enable ecosystem domains through runtimes
+
+The easiest way to allow package ecosystem traffic is often to enable the matching runtime.
+
+For example, enabling Python adds Python ecosystem domains such as PyPI hosts, and enabling Node adds Node ecosystem domains such as npm registry hosts.
+
+```yaml
+runtimes:
+ python: true
+ node: true
+ dotnet: true
+```
+
+Typical runtime-driven ecosystem access:
+
+- `python` adds Python package domains such as `pypi.org`
+- `node` adds Node package domains such as `registry.npmjs.org`
+- `dotnet` adds .NET package domains such as NuGet hosts
+- `lean` adds Lean ecosystem hosts
+
+This is usually better than hand-maintaining every package registry domain yourself.
+
+## Allow custom hosts
+
+To allow additional domains, add them under `network.allowed`.
+
+```yaml
+network:
+ allowed:
+ - "api.contoso.internal"
+ - "*.corp.example.com"
+```
+
+You can mix raw host patterns with ecosystem identifiers:
+
+```yaml
+network:
+ allowed:
+ - python
+ - node
+ - "packages.contoso.com"
+ - "*.services.contoso.com"
+```
+
+Use this when your agent needs:
+
+- an internal API
+- an internal package registry
+- a vendor service not covered by the defaults
+
+## Block specific domains
+
+Use `network.blocked` to remove entries from the final allowlist.
+
+```yaml
+network:
+ allowed:
+ - python
+ - node
+ - "*.example.com"
+ blocked:
+ - python
+ - "registry.npmjs.org"
+ - "bad.example.com"
+```
+
+This is useful when you want broad access in one area but still need to exclude specific destinations.
+
+A practical pattern is:
+
+1. allow an ecosystem or wildcard domain
+2. block the parts you do not want used
+
+## Give the agent Azure DevOps access with `permissions:`
+
+Network access and Azure DevOps API access are related but different.
+
+Even if Azure DevOps domains are reachable, the agent still needs credentials if it should call ADO APIs.
+
+Configure that with service connections:
+
+```yaml
+permissions:
+ read: my-read-arm-connection
+ write: my-write-arm-connection
+```
+
+Use this model as follows:
+
+- `permissions.read` gives the Stage 1 agent read-only ADO access
+- `permissions.write` gives Stage 3 safe-output execution write access
+- the write token is not exposed directly to the agent
+
+This lets you combine tight network rules with controlled ADO permissions.
+
+## Example: Python agent with internal feed access
+
+```yaml
+---
+name: "python-maintainer"
+description: "Maintains Python dependencies"
+runtimes:
+ python:
+ version: "3.12"
+network:
+ allowed:
+ - "pkgs.dev.azure.com"
+ - "*.pkgs.dev.azure.com"
+ - "pypi.contoso.internal"
+permissions:
+ read: my-read-arm-connection
+---
+
+Review Python dependencies, install what you need, and summarize any required
+changes.
+```
+
+## Example: Allow an internal API but block a public endpoint
+
+```yaml
+network:
+ allowed:
+ - "*.contoso.com"
+ blocked:
+ - "public-api.contoso.com"
+```
+
+## Recommended workflow
+
+When configuring network access:
+
+1. start with defaults only
+2. enable runtimes for package ecosystems
+3. add custom hosts under `network.allowed`
+4. block anything you explicitly want excluded
+5. add `permissions.read` or `permissions.write` if the agent must call Azure DevOps APIs
+
+This keeps agent access narrow, explicit, and easier to review.
diff --git a/docs-site/src/content/docs/guides/schedule-syntax.mdx b/docs-site/src/content/docs/guides/schedule-syntax.mdx
new file mode 100644
index 00000000..f3c9f69a
--- /dev/null
+++ b/docs-site/src/content/docs/guides/schedule-syntax.mdx
@@ -0,0 +1,158 @@
+---
+title: Writing schedule expressions
+description: Learn how to configure human-readable schedules, timezones, scattering, and how ado-aw turns them into concrete pipeline schedules.
+---
+
+# Writing schedule expressions
+
+`ado-aw` lets you write schedules in a human-readable format instead of hand-writing cron.
+
+You configure schedules under `on.schedule`.
+
+## Start with a simple schedule
+
+```yaml
+on:
+ schedule: daily around 14:00
+```
+
+This says: run every day at roughly 2 PM.
+
+## Use fuzzy human-readable expressions
+
+Here are common patterns you can use.
+
+### Daily
+
+```yaml
+on:
+ schedule: daily around 14:00
+```
+
+```yaml
+on:
+ schedule: daily between 09:00 and 17:00
+```
+
+### Weekly
+
+```yaml
+on:
+ schedule: weekly on monday
+```
+
+```yaml
+on:
+ schedule: weekly on friday around 17:00
+```
+
+### Hour-based intervals
+
+```yaml
+on:
+ schedule: every 6 hours
+```
+
+Other supported interval-style schedules include forms like `every 2h` and `every 30m`.
+
+## Add a timezone
+
+You can add UTC offsets directly in the expression.
+
+```yaml
+on:
+ schedule: daily around 14:00 utc+9
+```
+
+```yaml
+on:
+ schedule: daily between 09:00 utc-5 and 17:00 utc-5
+```
+
+Use this when you want the schedule to reflect a team's local business hours without manually converting everything to UTC.
+
+## Understand scattering
+
+`ado-aw` does not always compile a fuzzy schedule to the exact same wall-clock minute you typed.
+
+Instead, it applies **scattering**: a deterministic offset that spreads runs out to avoid a thundering herd effect.
+
+For example:
+
+```yaml
+on:
+ schedule: daily around 14:00
+```
+
+means:
+
+- stay near 14:00
+- pick a stable offset for this agent
+- avoid scheduling every agent at the exact same minute
+
+This helps when many teams use convenient schedule times like midnight, 9 AM, or the top of the hour.
+
+## Know what happens at compile time
+
+Azure DevOps pipelines ultimately need concrete schedule values. During compilation, `ado-aw` converts the fuzzy expression into a concrete schedule that Azure DevOps can run.
+
+In practice, that means:
+
+- your human-readable expression is parsed
+- timezone offsets are converted to UTC
+- scattering is applied
+- the compiled pipeline gets a concrete cron-style schedule
+
+You write the friendly expression; the compiler emits the precise schedule.
+
+## Schedule specific branches
+
+If you want more than the default branch behavior, use the object form:
+
+```yaml
+on:
+ schedule:
+ run: weekly on monday around 09:00
+ branches:
+ - main
+ - release/*
+```
+
+This is useful when scheduled automation should run on release branches as well as `main`.
+
+## Practical examples
+
+### Business-hours review agent
+
+```yaml
+on:
+ schedule: daily between 09:00 and 17:00 utc-5
+```
+
+Use this for an agent that should run once each day during US Eastern business hours.
+
+### Weekly maintenance window
+
+```yaml
+on:
+ schedule: weekly on monday around 06:00 utc+1
+```
+
+Use this for a Monday morning maintenance agent.
+
+### Regular polling
+
+```yaml
+on:
+ schedule: every 6 hours
+```
+
+Use this when you need a repeated check throughout the day.
+
+## Tips for choosing a schedule
+
+- Use `daily around ...` for routine maintenance jobs.
+- Use `weekly on ...` for lower-frequency cleanup or reporting.
+- Use `every N hours` for repeated monitoring or polling.
+- Add a timezone when the schedule should track local working hours.
+- Let scattering do its job instead of trying to force an exact shared minute across many agents.
diff --git a/docs-site/src/content/docs/guides/using-mcp.mdx b/docs-site/src/content/docs/guides/using-mcp.mdx
new file mode 100644
index 00000000..df88c382
--- /dev/null
+++ b/docs-site/src/content/docs/guides/using-mcp.mdx
@@ -0,0 +1,178 @@
+---
+title: Configuring MCP servers
+description: Learn how ado-aw uses MCP, which built-in servers are available, and how to add custom stdio or HTTP MCP backends.
+---
+
+# Configuring MCP servers
+
+In `ado-aw`, **MCP** means **Model Context Protocol** servers that expose tools to the agent.
+
+Instead of giving the agent direct access to every system, you configure specific MCP backends and allow only the tools you want the agent to call.
+
+## Start with the built-in MCPs
+
+Two built-in MCP integrations matter most for day-to-day usage:
+
+- **SafeOutputs** for approved write proposals such as PRs, work items, and comments
+- **GitHub** for GitHub-related operations made available by the compiler's built-in extension layer
+
+You usually interact with SafeOutputs through `safe-outputs:` configuration rather than by wiring the server yourself.
+
+## Add a custom stdio MCP server
+
+Use `mcp-servers:` when you want to run a containerized MCP server over stdio.
+
+```yaml
+mcp-servers:
+ azure-devops:
+ container: "node:20-slim"
+ entrypoint: "npx"
+ entrypoint-args: ["-y", "@azure-devops/mcp", "myorg"]
+ env:
+ AZURE_DEVOPS_EXT_PAT: ""
+ allowed:
+ - core_list_projects
+ - wit_get_work_item
+```
+
+This tells the compiler to make that MCP server available through the gateway and allow only the listed tools.
+
+### When to use stdio containers
+
+Use a containerized stdio MCP when:
+
+- you have an MCP server packaged as a CLI program
+- you want reproducible runtime dependencies
+- you want the gateway to launch the server for you
+
+## Add an HTTP MCP backend
+
+If you already host an MCP server elsewhere, point to it with `url:`.
+
+```yaml
+mcp-servers:
+ remote-ado:
+ url: "https://mcp.dev.azure.com/myorg"
+ headers:
+ X-MCP-Toolsets: "repos,wit"
+ X-MCP-Readonly: "true"
+ allowed:
+ - wit_get_work_item
+ - repo_list_repos_by_project
+```
+
+### When to use HTTP backends
+
+Use an HTTP backend when:
+
+- your MCP server already runs as a shared service
+- you do not want the pipeline to start a container for it
+- the service is managed outside the pipeline
+
+## Pass environment variables through
+
+Many MCP servers need credentials or configuration values.
+
+Use `env:` for that:
+
+```yaml
+env:
+ AZURE_DEVOPS_EXT_PAT: ""
+ STATIC_CONFIG: "readonly"
+```
+
+A blank string means **pass through the value from the pipeline environment**.
+A normal string means **use this literal value**.
+
+This is especially useful for tokens and service-specific configuration.
+
+## Restrict tool access with `allowed:`
+
+Always keep the `allowed:` list as small as practical.
+
+```yaml
+mcp-servers:
+ custom-tool:
+ container: "ghcr.io/example/my-tool:latest"
+ entrypoint: "my-tool"
+ allowed:
+ - search_docs
+ - get_status
+```
+
+This prevents the agent from calling tools you did not intend to expose.
+
+## Understand the MCP Gateway architecture
+
+`ado-aw` uses **MCPG** (MCP Gateway) as the routing layer between the agent and configured MCP servers.
+
+At a high level:
+
+1. the agent runs inside the AWF-isolated environment
+2. the gateway runs outside that container boundary
+3. SafeOutputs and your configured MCP servers are connected to the gateway
+4. the agent calls tools through the gateway
+5. the gateway routes each request to the correct backend
+
+This gives you one place to control:
+
+- which servers are available
+- which tools are exposed
+- how tool traffic is routed
+
+## Practical example: Azure DevOps MCP plus safe outputs
+
+```yaml
+---
+name: "triage-agent"
+description: "Triages work items and proposes follow-up actions"
+tools:
+ azure-devops:
+ toolsets: [core, wit]
+ allowed:
+ - core_list_projects
+ - wit_get_work_item
+permissions:
+ read: my-read-arm-connection
+ write: my-write-arm-connection
+safe-outputs:
+ update-work-item:
+ status: true
+ body: true
+ target: "*"
+ comment-on-work-item:
+ max: 5
+ target: "*"
+---
+
+Review assigned work items, summarize findings, and propose safe updates.
+```
+
+This combines:
+
+- Azure DevOps read access for the agent
+- SafeOutputs for controlled write actions
+- explicit tool scoping for safety
+
+## Practical example: custom MCP service
+
+```yaml
+mcp-servers:
+ docs-search:
+ url: "https://mcp.contoso.net/docs"
+ headers:
+ Authorization: "Bearer $(DOCS_MCP_TOKEN)"
+ allowed:
+ - search_docs
+ - fetch_doc
+```
+
+Use this pattern when you already operate an internal MCP service.
+
+## Recommended workflow
+
+1. start with built-in capabilities such as SafeOutputs
+2. add `tools.azure-devops` if the agent needs Azure DevOps tooling
+3. add custom `mcp-servers:` only for extra capabilities
+4. keep `allowed:` lists tight
+5. use `env:` passthrough for secrets and tokens instead of hardcoding values
diff --git a/docs-site/src/content/docs/index.mdx b/docs-site/src/content/docs/index.mdx
new file mode 100644
index 00000000..98b7a54e
--- /dev/null
+++ b/docs-site/src/content/docs/index.mdx
@@ -0,0 +1,182 @@
+---
+title: ado-aw
+description: Inject continuous AI into your Azure DevOps projects
+template: splash
+hero:
+ title: |
+ Azure DevOps
+ Agentic Workflows
+ tagline: Continuous AI for Azure DevOps
+ actions:
+ - text: Get Started
+ link: /ado-aw/setup/quick-start/
+ icon: right-arrow
+ - text: View on GitHub
+ link: https://github.com/githubnext/ado-aw
+ variant: minimal
+---
+
+import { Card, CardGrid, Tabs, TabItem } from '@astrojs/starlight/components';
+
+```markdown
+---
+on:
+ schedule: daily around 09:00
+repos:
+ - my-org/my-service
+safe-outputs:
+ create-pr:
+ title-prefix: "[security] "
+ max: 3
+---
+
+## Dependency Guardian
+
+Scan all package manifests for outdated or vulnerable dependencies.
+For each finding, create a pull request that bumps the version
+and updates the lockfile. Include a summary of CVEs addressed.
+```
+
+Wake up to security patch PRs, documentation updates, and pipeline failure analysis -- all proposed, reviewed, and applied automatically while you sleep.
+
+---
+
+## Wake up to results
+
+
+
+ Agents scan for CVEs overnight and open ready-to-merge pull requests by morning.
+
+
+ When a build breaks, an agent reads the logs, identifies the root cause, and proposes a fix PR.
+
+
+ Keep READMEs, changelogs, and API docs in sync with the code -- automatically.
+
+
+ Stale issues get flagged, duplicates get linked, and priorities get suggested -- every day.
+
+
+
+---
+
+## Five security layers, zero trust
+
+Every compiled pipeline enforces a defense-in-depth model. The agent **never** receives write credentials or secrets.
+
+```mermaid
+flowchart LR
+ E["Trigger"] --> Agent
+ subgraph Sandbox["Isolated Container -- Read-only Token -- Firewall"]
+ Agent["AI Agent"]
+ end
+ Agent --> Output["Proposed\nSafe Outputs"]
+ Output --> Detect["Threat\nDetection"]
+ Detect -->|"safe"| Write["Executor\n(write token)"]
+ Detect -->|"blocked"| Fail["Rejected"]
+ Write --> ADO["Azure DevOps\nAPIs"]
+
+ style Sandbox stroke:#7c3aed,stroke-width:2px
+ style Agent fill:#4361ee,color:#fff,stroke:#3a56d4
+ style Detect fill:#e6a817,color:#1a1a1a,stroke:#c49000
+ style Write fill:#2d9d78,color:#fff,stroke:#238066
+ style Fail fill:#e63946,color:#fff,stroke:#c5303c
+ style ADO fill:#4361ee,color:#fff,stroke:#3a56d4
+```
+
+| Layer | What it does |
+|-------|-------------|
+| **Read-only token** | The agent can observe your repos but cannot push, merge, or delete anything |
+| **Zero secrets** | Write tokens, API keys, and credentials exist only in the isolated executor stage |
+| **Network firewall** | All outbound traffic routes through an allowlist-only proxy; everything else is dropped |
+| **Safe outputs** | The agent proposes structured actions (PRs, comments, work items); hard limits and prefixes constrain what can be requested |
+| **Threat detection** | A dedicated AI scan checks proposals for prompt injection, secret leaks, and malicious patterns before anything is applied |
+
+---
+
+## Define agents in markdown
+
+No pipeline YAML to hand-write. No complex scripting. Just describe the agent's purpose in a markdown file with a YAML front-matter header for configuration.
+
+
+
+```markdown
+---
+on:
+ schedule: weekly on monday around 10:00
+engine:
+ model: gpt-4.1
+tools:
+ bash: [grep, find, wc, jq]
+safe-outputs:
+ create-pr:
+ title-prefix: "[docs] "
+ max: 1
+ comment-on-work-item:
+---
+
+## Documentation Sync
+
+Review all public API surfaces and ensure the corresponding
+docs are up to date. Open a PR with any corrections and
+comment on related work items with a summary.
+```
+
+
+```yaml
+# Auto-generated by ado-aw -- do not edit
+trigger: none
+schedules:
+ - cron: "23 10 * * 1"
+ branches:
+ include: [main]
+stages:
+ - stage: Agent
+ # Network-isolated sandbox, read-only token...
+ - stage: Detection
+ # AI threat scan of proposed outputs...
+ - stage: Execution
+ # Apply approved PRs and comments...
+```
+
+
+
+---
+
+## Get started in minutes
+
+
+
+ Download `ado-aw`, run `ado-aw init`, then co-create your first agent interactively with `/agent ado-aw`.
+
+ [Quick start with agents](/ado-aw/setup/quick-start/#with-agents-recommended)
+
+
+ Author an agent markdown file, compile it, push, and configure your Azure DevOps project.
+
+ [Manual quick start](/ado-aw/setup/quick-start/#manual)
+
+
+
+---
+
+## Same salad, different dressing
+
+Familiar with [GitHub Agentic Workflows](https://github.github.com/gh-aw/)? Azure DevOps Agentic Workflows leverages the exact same technologies -- network-isolated sandboxes, safe outputs, threat detection, and MCP tooling -- with a specialized compiler that targets Azure DevOps Pipelines instead of GitHub Actions.
+
+| | GitHub Agentic Workflows | Azure DevOps Agentic Workflows |
+|---|---|---|
+| **Platform** | GitHub Actions | Azure DevOps Pipelines |
+| **Agent format** | Markdown + YAML front matter | Markdown + YAML front matter |
+| **Security model** | Read-only token, AWF sandbox, safe outputs, threat detection | Read-only token, AWF sandbox, safe outputs, threat detection |
+| **Compiler** | `gh aw compile` | `ado-aw compile` |
+| **Safe outputs** | PRs, issues, labels, comments | PRs, work items, wiki pages, build tags |
+| **MCP support** | GitHub MCP, custom servers | Azure DevOps MCP, GitHub MCP, custom servers |
+
+If your team already writes `gh-aw` workflows, you already know how to write `ado-aw` agents. The markdown format, security architecture, and mental model are identical.
+
+
+
+Inspired by [GitHub Agentic Workflows](https://github.github.com/gh-aw/).
+
+
diff --git a/docs-site/src/content/docs/introduction/architecture.mdx b/docs-site/src/content/docs/introduction/architecture.mdx
new file mode 100644
index 00000000..49027a41
--- /dev/null
+++ b/docs-site/src/content/docs/introduction/architecture.mdx
@@ -0,0 +1,71 @@
+---
+title: Architecture
+description: Explore the main components that make up the ado-aw compiler and runtime model
+sidebar:
+ order: 3
+---
+
+`ado-aw` combines a Rust-based compiler with runtime components that support secure agent execution in Azure DevOps.
+
+## High-level architecture
+
+At a high level, the project includes:
+
+- a **compiler** that reads markdown agent files and emits Azure DevOps YAML
+- multiple **compile targets** for different pipeline shapes
+- configurable **runtimes** such as Python, Node.js, .NET, and Lean
+- first-class **tools** and **tool allow-lists** for agent execution
+- **safe outputs** that let agents propose controlled write actions
+- **MCP support** for exposing tools and services through the Model Context Protocol
+
+## Main building blocks
+
+### Compiler
+
+The compiler parses front matter, validates configuration, and renders pipeline templates for Azure DevOps.
+
+### Compile targets
+
+`ado-aw` supports different output targets, including standalone pipelines and Azure DevOps template-style targets such as job and stage outputs.
+
+### Runtimes
+
+Runtimes define the environment an agent needs for its work. For example, a workflow may require Python packages, Node.js tooling, or .NET support.
+
+### Tools and integrations
+
+Agents can be given controlled access to built-in tools and MCP-backed integrations. This keeps capabilities explicit and reviewable.
+
+### Safe outputs
+
+Safe outputs are the bridge between agent intent and real-world mutations. Instead of writing directly, the agent produces structured proposals that are checked before execution.
+
+### MCP
+
+`ado-aw` can run SafeOutputs as an MCP server and can integrate with additional MCP services, making it easier to expose tools in a structured, auditable way.
+
+## Directory structure overview
+
+A simplified view of the repository looks like this:
+
+```text
+src/
+ compile/ Pipeline compilation logic and targets
+ runtimes/ Runtime environment support
+ safeoutputs/ Safe-output tool implementations
+ tools/ First-class tool integrations
+ data/ Base pipeline templates and supporting assets
+docs/ Detailed reference documentation
+examples/ Example agent definitions
+tests/ Integration tests and fixtures
+```
+
+## How the pieces fit together
+
+1. You author an agent file in markdown.
+2. The compiler reads its front matter and body.
+3. Compile targets render the appropriate Azure DevOps YAML.
+4. Runtimes, tools, and MCP integrations are added based on configuration.
+5. Safe outputs connect Stage 1 proposals to Stage 3 execution.
+
+This separation helps keep the authoring model approachable while preserving strong runtime controls.
diff --git a/docs-site/src/content/docs/introduction/how-it-works.mdx b/docs-site/src/content/docs/introduction/how-it-works.mdx
new file mode 100644
index 00000000..9618c091
--- /dev/null
+++ b/docs-site/src/content/docs/introduction/how-it-works.mdx
@@ -0,0 +1,74 @@
+---
+title: How It Works
+description: Understand the compile-time and runtime flow behind ado-aw pipelines
+sidebar:
+ order: 2
+---
+
+`ado-aw` takes a markdown agent file and turns it into an Azure DevOps pipeline that runs in three stages.
+
+## The three-stage pipeline model
+
+### 1. Agent
+
+The first stage runs the AI agent inside a network-isolated sandbox with a read-only Azure DevOps token. The agent can inspect code, use its approved tools, and propose actions through safe outputs.
+
+Importantly, the agent does **not** perform write actions directly.
+
+### 2. Detection
+
+The second stage reviews the agent's proposed outputs. Its job is to detect problems such as:
+
+- prompt injection attempts
+- secret leakage
+- malformed or suspicious outputs
+- policy violations
+
+Only approved proposals continue to the next stage.
+
+### 3. Execution
+
+The third stage applies approved actions with a separate write-capable token. This stage can create or update Azure DevOps resources such as pull requests, comments, work items, and related artifacts.
+
+Because the write credential is isolated from the agent, the system keeps a strong boundary between reasoning and mutation.
+
+## Compile time vs. runtime
+
+### At compile time
+
+When you run `ado-aw compile`, the compiler:
+
+- parses the markdown body and YAML front matter
+- validates the configuration
+- selects the target pipeline template
+- injects runtime configuration for tools, runtimes, and safe outputs
+- emits Azure DevOps YAML and supporting agent assets
+
+### At runtime
+
+When Azure DevOps executes the compiled pipeline, it:
+
+- runs the agent with the configured tool set and permissions
+- records proposed safe outputs
+- analyzes those outputs for threats
+- executes approved outputs with the final executor stage
+
+## Flow diagram
+
+```mermaid
+flowchart TD
+ A["agent.md source"] --> B["ado-aw compile"]
+ B --> C["Azure DevOps pipeline YAML"]
+ C --> D["Stage 1: Agent"]
+ D -->|"safe-output proposals"| E["Stage 2: Detection"]
+ E -->|"approved proposals"| F["Stage 3: Execution"]
+
+ style A fill:#7c3aed,color:#fff,stroke:#5b21b6
+ style B fill:#6d28d9,color:#fff,stroke:#4c1d95
+ style C fill:#4338ca,color:#fff,stroke:#3730a3
+ style D fill:#2563eb,color:#fff,stroke:#1d4ed8
+ style E fill:#d97706,color:#fff,stroke:#b45309
+ style F fill:#059669,color:#fff,stroke:#047857
+```
+
+The key idea is that authoring happens once in markdown, compilation produces the pipeline definition, and runtime execution enforces the safety boundaries.
diff --git a/docs-site/src/content/docs/introduction/overview.mdx b/docs-site/src/content/docs/introduction/overview.mdx
new file mode 100644
index 00000000..db1be4a9
--- /dev/null
+++ b/docs-site/src/content/docs/introduction/overview.mdx
@@ -0,0 +1,54 @@
+---
+title: Overview
+description: Learn what ado-aw is, why it exists, and how it makes agentic pipelines easier to author
+sidebar:
+ order: 1
+---
+
+`ado-aw` is a compiler that turns natural-language markdown with YAML front matter into Azure DevOps agentic pipeline definitions.
+
+Instead of hand-authoring large YAML files, you describe your workflow in a markdown document:
+
+- what the agent should do
+- when it should run
+- which tools and runtimes it can use
+- which safe outputs it may propose
+
+The compiler turns that source file into Azure DevOps pipeline YAML plus the runtime assets needed to execute the agent safely.
+
+## Why ado-aw exists
+
+Authoring secure AI-driven pipelines directly in YAML is hard. Teams need a way to:
+
+- express intent in a readable format
+- keep security boundaries explicit
+- control write actions carefully
+- reuse a consistent pipeline structure
+
+`ado-aw` addresses that by giving you a higher-level authoring format while still producing standard Azure DevOps pipeline definitions.
+
+## Key benefits
+
+### Natural-language authoring
+
+Pipeline intent stays in markdown, which is easier to review, edit, and generate with AI tools.
+
+### Safer execution model
+
+The generated pipelines separate agent execution from threat detection and final write operations.
+
+### Consistent structure
+
+Every compiled pipeline follows the same three-stage pattern, making behavior more predictable across repositories.
+
+### Extensible integrations
+
+`ado-aw` supports runtimes, built-in tools, safe outputs, and MCP-based integrations for richer workflows.
+
+## Inspired by GitHub Agentic Workflows
+
+The design is inspired by [GitHub Agentic Workflows (gh-aw)](https://github.com/githubnext/gh-aw), adapted for Azure DevOps pipelines and OneBranch-style execution environments.
+
+## Who it is for
+
+`ado-aw` is a good fit for teams that want to use AI agents in Azure DevOps while keeping authoring approachable and operational controls explicit.
diff --git a/docs-site/src/content/docs/reference/codemods.mdx b/docs-site/src/content/docs/reference/codemods.mdx
new file mode 100644
index 00000000..f34da652
--- /dev/null
+++ b/docs-site/src/content/docs/reference/codemods.mdx
@@ -0,0 +1,387 @@
+---
+title: "Front-matter codemods"
+description: "Internal reference for the front-matter codemod framework, rewrite behavior, and contributor contract."
+---
+
+The `ado-aw` compiler keeps user front matter forward-compatible
+through **codemods**: small, detection-based transformations that
+rewrite deprecated front-matter shapes to the current shape during
+`compile`. The model is borrowed from
+[gh-aw's codemod registry](https://github.com/github/gh-aw/blob/main/pkg/cli/fix_codemods.go).
+
+This page is the reference for both users (what codemods mean for me)
+and contributors (how to add one).
+
+## How it works
+
+### No version field on user files
+
+Codemods don't stamp anything onto user front matter. There is no
+`schema-version` field. Each codemod inspects the mapping, decides
+whether the input contains the deprecated shape it knows about, and
+either rewrites it or returns "no-op". The whole registry runs on
+every compile; codemods that don't match are essentially free.
+
+### The compile flow
+
+1. `ado-aw compile` reads the source `.md` and parses the front
+ matter as an **untyped** `serde_yaml::Mapping`. This step never
+ trips on removed/renamed fields.
+2. The runner walks the registered codemod registry in order. Each
+ codemod returns `Ok(true)` if it modified the mapping, `Ok(false)`
+ if it didn't, or `Err` to abort the whole compile.
+3. The compiler runs all the usual validation and codegen against
+ the rewritten, typed `FrontMatter`.
+4. **Only on a fully successful compile** AND **only if at least one
+ codemod fired**, the source `.md` is atomically rewritten and a
+ warning prints to stderr. A failed compile leaves the source
+ untouched.
+5. The `.lock.yml` is written atomically last.
+
+### What gets preserved on rewrite
+
+- **Body markdown** is preserved byte-for-byte (everything after
+ the closing `---`).
+- **Leading whitespace** before the opening `---` is preserved
+ byte-for-byte (BOM-strippers and editor blank lines).
+- **Front-matter key order** is preserved for keys the codemod
+ doesn't touch (`serde_yaml`'s mapping is insertion-ordered).
+ Renamed keys, however, move to the **end** of the front-matter
+ block: `Mapping::insert` appends new keys, so when a codemod
+ removes `old-key` and inserts `new-key`, the new key lands at
+ the bottom regardless of where the old one was. The compile
+ warning calls this out so users aren't surprised.
+- **Front-matter comments** are NOT preserved. `serde_yaml`
+ round-trip drops them. The warning emitted on rewrite calls this
+ out so it isn't a surprise. If you have important context in a
+ front-matter comment, move it into the markdown body before
+ running compile.
+- **Quote and scalar styles** in YAML may be normalized. This is
+ cosmetic.
+
+### Atomicity and lost-update protection
+
+The rewrite uses `tempfile + rename` for atomicity (no torn writes).
+Before the rename, the runner re-reads the source and compares its
+SHA-256 to the snapshot taken at parse time. If the file changed
+between parse and rewrite, the runner aborts with a clear error
+("source file ... changed during compilation; refusing to apply
+codemods") rather than clobbering whoever wrote the file.
+
+### `check` command behavior
+
+`ado-aw check` exits non-zero when codemods would fire -- there is no
+opt-in flag and no warning-only mode. Rationale: compiled pipelines
+download the **same** `ado-aw` version that produced them
+(`src/data/base.yml`, `src/data/1es-base.yml`), so the in-pipeline
+integrity check is internally consistent by construction. The only
+time `check` sees pending codemods is when a developer runs a newer
+`ado-aw` locally against an older source -- exactly when we want to
+fail loudly. The fix is `ado-aw compile`, which applies the codemods
+in place.
+
+### `execute` command behavior
+
+The Stage 3 executor runs codemods in memory only. It never rewrites
+the source (the executor's working tree is not the source-of-truth
+tree). When at least one codemod would fire, it logs a warning and
+continues.
+
+## Adding a codemod
+
+You need a codemod whenever you introduce a breaking change to the
+front-matter grammar:
+
+- Renaming a field
+- Removing a field
+- Changing a field's type or shape
+- Adding a required field that didn't exist before
+- Changing the meaning of an existing field
+
+Non-breaking changes (adding an optional field, accepting a new
+variant) do **not** need a codemod.
+
+### File layout
+
+Codemods live in `src/compile/codemods/`:
+
+```text
+src/compile/codemods/
+├── mod.rs # Framework + CODEMODS registry
+├── helpers.rs # take_key, insert_no_overwrite, rename_key, ConflictPolicy
+├── 0001_engine_id_split.rs
+├── 0002_permissions_field.rs
+└── 0003_safeoutput_renames.rs
+```
+
+The filename prefix is a zero-padded sequence number (``). It
+sorts files naturally in directory listings; the **registry order**
+in `mod.rs` is what determines runtime order, not the filename.
+
+### Anatomy of a codemod
+
+```rust
+// src/compile/codemods/0001_engine_id_split.rs
+
+use anyhow::{bail, Result};
+use serde_yaml::{Mapping, Value};
+
+use super::{Codemod, CodemodContext};
+
+pub static CODEMOD: Codemod = Codemod {
+ id: "engine_id_split",
+ summary: "engine: -> engine: { id: copilot, model: }",
+ introduced_in: "0.27.0",
+ apply: apply_codemod,
+};
+
+fn apply_codemod(fm: &mut Mapping, _ctx: &CodemodContext) -> Result {
+ // ... your detection-based transformation ...
+ // Return Ok(true) if the mapping was modified, Ok(false) if the
+ // shape didn't match (no-op), or Err for a hard failure.
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rewrites_legacy_shape() { /* before / after fixture */ }
+
+ #[test]
+ fn already_current_shape_is_noop() { /* defensive */ }
+
+ #[test]
+ fn missing_field_is_noop() { /* defensive */ }
+
+ #[test]
+ fn unexpected_shape_is_rejected() { /* hard error */ }
+}
+```
+
+### Registry append
+
+Two edits in `src/compile/codemods/mod.rs`:
+
+```rust
+mod m0001_engine_id_split; // <-- add module declaration
+
+pub static CODEMODS: &[&'static Codemod] = &[
+ &m0001_engine_id_split::CODEMOD, // <-- append at the end
+];
+```
+
+Tests in `mod.rs` enforce that codemod ids are unique and that the
+file count matches the registry length. A malformed registry fails
+fast at runtime via `with_context`.
+
+### Author contract (invariants)
+
+Every codemod must satisfy these properties. We enforce them via
+review + per-codemod tests:
+
+1. **Idempotent.** Running twice produces the same final state as
+ running once. The runner re-runs the entire registry every
+ compile -- codemods that no longer apply must be no-ops.
+2. **Detection-based.** Returns `Ok(false)` and leaves the mapping
+ untouched when the input does not contain the targeted shape.
+ Never modifies a mapping that's already current.
+3. **Conflict-aware.** When both old and new shapes coexist in the
+ same source, error with a "manual migration required" message
+ rather than guess. Use `helpers::rename_key` with
+ `ConflictPolicy::Error` (the default) to get this for free.
+4. **Pure.** No I/O, no env, no time/randomness. (Convention; not
+ type-enforced.)
+5. **Mapping-only.** Cannot inspect the markdown body, the file
+ path, the lock file, or git state.
+6. **Order-aware.** If codemod B depends on shapes produced by
+ codemod A, A must precede B in the registry. Document the
+ ordering requirement in B's doc comment.
+7. **Receives unsanitized input.** The compiler runs sanitization
+ (`##vso[` neutralization, control-character stripping, length
+ limits) on the typed `FrontMatter` *after* codemods run, but the
+ raw `Mapping` you receive is whatever the user wrote -- including
+ any pipeline-injection attempts, control characters, or
+ over-length strings. Codemods should therefore treat values as
+ opaque (move them around, wrap them in objects, etc.) rather
+ than parse or interpolate them. If a codemod must inspect a
+ value, treat it defensively.
+
+### Use the helpers
+
+Codemods should prefer `helpers::*` over raw `Mapping` manipulation:
+
+- `take_key(map, "old")` -- remove and return.
+- `insert_no_overwrite(map, "new", value)` -- error on conflict.
+- `rename_key(map, "old", "new", ConflictPolicy::Error)` -- default
+ policy is **error**, never silent overwrite. The helper is
+ transactional: on the error path the mapping is unchanged.
+
+`rename_key` returns `Result` directly, so it composes with a
+codemod's return value:
+
+```rust
+fn apply_codemod(fm: &mut Mapping, _ctx: &CodemodContext) -> Result {
+ rename_key(fm, "old-key", "new-key", ConflictPolicy::Error)
+}
+```
+
+### What if my change can't be expressed as a Mapping rewrite?
+
+The codemod `apply` function receives only the front-matter mapping.
+It cannot inspect the markdown body, the file path, the lock file,
+or git state. If your change requires that information, do not
+write a codemod that guesses. Instead, return an `Err` with an
+actionable "manual migration required: \" message so
+the user knows exactly what to fix.
+
+## Worked example: `engine_id_split`
+
+This is the codemod that would have caught the `0.17.0` breaking
+change (`engine: ` -> `engine: { id: copilot, model: }`).
+Drop-in template for your own codemod.
+
+```rust
+// src/compile/codemods/0001_engine_id_split.rs
+
+//! engine: -> engine: { id: copilot, model: }
+//!
+//! Before 0.17.0 the `engine` field was a model name (e.g.
+//! `engine: claude-opus-4.5`). The grammar changed to use engine
+//! identifiers (`engine: copilot`), with the model nested in an
+//! object form (`engine: { id: copilot, model: }`).
+
+use anyhow::{bail, Result};
+use serde_yaml::{Mapping, Value};
+
+use super::{Codemod, CodemodContext};
+
+pub static CODEMOD: Codemod = Codemod {
+ id: "engine_id_split",
+ summary: "engine: -> engine: { id: copilot, model: }",
+ introduced_in: "0.27.0",
+ apply: apply_codemod,
+};
+
+/// Engine identifiers that are valid as the simple-form string. When
+/// `engine` is a string equal to one of these, the source is already
+/// using the current grammar and we leave it alone.
+const KNOWN_ENGINE_IDS: &[&str] = &["copilot"];
+
+fn apply_codemod(fm: &mut Mapping, _ctx: &CodemodContext) -> Result {
+ let key = Value::String("engine".to_string());
+
+ let Some(engine) = fm.get(&key) else {
+ // No engine field at all -- relies on default (copilot) in
+ // both old and new grammars. Nothing to do.
+ return Ok(false);
+ };
+
+ match engine {
+ // Already the object form: nothing to migrate.
+ Value::Mapping(_) => Ok(false),
+
+ // Simple-form string with a known engine identifier
+ // (e.g. `copilot`): already current. No-op.
+ Value::String(s) if KNOWN_ENGINE_IDS.contains(&s.as_str()) => Ok(false),
+
+ // Simple-form string that is NOT a known engine identifier:
+ // it's a *model name* from the old grammar. Wrap in object
+ // form.
+ Value::String(s) => {
+ let model = s.clone();
+ let mut object = Mapping::new();
+ object.insert(
+ Value::String("id".to_string()),
+ Value::String("copilot".to_string()),
+ );
+ object.insert(
+ Value::String("model".to_string()),
+ Value::String(model),
+ );
+ fm.insert(key, Value::Mapping(object));
+ Ok(true)
+ }
+
+ // Unexpected shape (number, bool, sequence, ...). Refuse rather
+ // than guess -- the user needs to fix this by hand.
+ other => bail!(
+ "engine field has unexpected shape (expected string or mapping, \
+ got {}); manual migration required",
+ describe(other)
+ ),
+ }
+}
+
+fn describe(v: &Value) -> &'static str {
+ match v {
+ Value::Null => "null",
+ Value::Bool(_) => "bool",
+ Value::Number(_) => "number",
+ Value::String(_) => "string",
+ Value::Sequence(_) => "sequence",
+ Value::Mapping(_) => "mapping",
+ Value::Tagged(_) => "tagged",
+ }
+}
+```
+
+### What this example illustrates
+
+1. **Detection-first.** Each match arm decides whether the input
+ matches the codemod's target shape. Three of the four arms
+ return `Ok(false)` -- those are the cases where the source is
+ already current.
+2. **Idempotent by construction.** Once the mapping is in object
+ form, the first match arm fires and returns `Ok(false)`. The
+ codemod can run on every compile without harm.
+3. **Conflict-aware.** The `Value::Mapping(_)` arm is the
+ "already-migrated" case. We never overwrite an existing object
+ form. The `bail!` on unexpected shapes is the manual-migration
+ escape hatch.
+4. **Two-line registry append:**
+
+ ```rust
+ mod m0001_engine_id_split;
+
+ pub static CODEMODS: &[&'static Codemod] = &[
+ &m0001_engine_id_split::CODEMOD,
+ ];
+ ```
+
+ That's it. The registry-uniqueness and filename-prefix tests
+ keep passing.
+
+## Tests
+
+The codemod framework is covered by three layers of tests:
+
+- **Unit tests** in `src/compile/codemods/{mod.rs,helpers.rs}`
+ cover registry health, helper edge cases, and (for shipped
+ codemods) individual `apply` functions.
+- **White-box integration tests** in
+ `src/compile/codemod_integration_test.rs` exercise the rewrite
+ path end-to-end (parse -> codemods -> compile -> atomic source
+ rewrite -> lock-file write) using a stub codemod registry
+ injected via the crate-private `compile_pipeline_with_registry`
+ and `parse_markdown_detailed_with_registry` hooks. They live
+ inside `src/` because the production registry is empty and
+ integration tests in `tests/` cannot link against crate
+ internals.
+- **Black-box CLI tests** in `tests/codemod_tests.rs` spawn the
+ compiled `ado-aw` binary as a subprocess and assert on the
+ user-visible behavior of `compile` and `check`.
+
+When you add a real codemod, ship the per-codemod before/after
+fixtures alongside it in the codemod's own file
+(`src/compile/codemods/_.rs`).
+
+## See also
+
+- [Front Matter](/ado-aw/reference/front-matter/) -- the full
+ front-matter grammar.
+- [Extending the Compiler](/ado-aw/guides/extending/) -- broader guidance for
+ adding features to the compiler, including the requirement to
+ add a codemod alongside any breaking front-matter change.
+- [gh-aw's `pkg/cli/fix_codemods.go`](https://github.com/github/gh-aw/blob/main/pkg/cli/fix_codemods.go) --
+ the upstream codemod model.
diff --git a/docs-site/src/content/docs/reference/engine.mdx b/docs-site/src/content/docs/reference/engine.mdx
new file mode 100644
index 00000000..455e4b4d
--- /dev/null
+++ b/docs-site/src/content/docs/reference/engine.mdx
@@ -0,0 +1,44 @@
+---
+title: "Engine configuration"
+description: "Reference for the engine field, including supported engine options, defaults, and generated pipeline behavior."
+---
+
+## Engine Configuration
+
+The `engine` field specifies which engine to use for the agentic task. The string form is an engine identifier (currently only `copilot` is supported). The object form uses `id` for the engine identifier plus additional options like model selection and timeout.
+
+```yaml
+# Simple string format (engine identifier, defaults to copilot)
+engine: copilot
+
+# Object format with additional options
+engine:
+ id: copilot
+ model: claude-opus-4.7
+ timeout-minutes: 30
+```
+
+### Fields
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `id` | string | `copilot` | Engine identifier. Currently only `copilot` (GitHub Copilot CLI) is supported. |
+| `model` | string | `claude-opus-4.7` | AI model to use. Options include `claude-sonnet-4.5`, `gpt-5.2-codex`, `gemini-3-pro-preview`, etc. |
+| `timeout-minutes` | integer | *(none)* | Maximum time in minutes the agent job is allowed to run. Sets `timeoutInMinutes` on the `Agent` job in the generated pipeline. |
+| `version` | string | *(none)* | Engine CLI version to install (e.g., `"1.0.43"`, `"latest"`). Overrides the pinned `COPILOT_CLI_VERSION`. Set to `"latest"` to use the newest available version. |
+| `agent` | string | *(none)* | Custom agent file identifier (Copilot only). Adds `--agent ` to the CLI invocation, selecting a custom agent from `.github/agents/`. |
+| `api-target` | string | *(none)* | Custom API endpoint hostname for GHES/GHEC (e.g., `"api.acme.ghe.com"`). Adds `--api-target ` to the CLI invocation and adds the hostname to the AWF network allowlist. |
+| `args` | list | `[]` | Custom CLI arguments appended after compiler-generated args. Subject to shell-safety validation and blocked from overriding compiler-controlled flags (`--prompt`, `--allow-tool`, `--disable-builtin-mcps`, etc.). |
+| `env` | map | *(none)* | Engine-specific environment variables merged into the sandbox step's `env:` block. Keys must be valid env var names; values must not contain ADO expressions (`$(`, `${{`) or pipeline command injection (`##vso[`). Compiler-controlled keys (`GITHUB_TOKEN`, `PATH`, `BASH_ENV`, etc.) are blocked. |
+| `command` | string | *(none)* | Custom engine executable path (skips default NuGet installation). The path must be accessible inside the AWF container (e.g., `/tmp/...` or workspace-mounted paths). |
+
+
+### `timeout-minutes`
+
+The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire agent job. It maps to the Azure DevOps `timeoutInMinutes` job property on `Agent`. This is useful for:
+
+- **Budget enforcement** -- hard-capping the total runtime of an agent to control compute costs.
+- **Pipeline hygiene** -- preventing agents from occupying a runner indefinitely if they stall or enter long retry loops.
+- **SLA compliance** -- ensuring scheduled agents complete within a known window.
+
+When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: ` on the agentic job.
diff --git a/docs-site/src/content/docs/reference/filter-ir.mdx b/docs-site/src/content/docs/reference/filter-ir.mdx
new file mode 100644
index 00000000..7817a9d3
--- /dev/null
+++ b/docs-site/src/content/docs/reference/filter-ir.mdx
@@ -0,0 +1,431 @@
+---
+title: "Filter IR specification"
+description: "Internal reference for the intermediate representation used to lower trigger filters into runtime gate steps."
+---
+
+This document specifies the intermediate representation (IR) used by the
+ado-aw compiler to translate trigger filter configurations (YAML front matter)
+into bash gate steps that run inside Azure DevOps pipelines.
+
+**Source**: `src/compile/filter_ir.rs`
+
+## Overview
+
+When an agent file declares runtime trigger filters under `on.pr.filters` or
+`on.pipeline.filters`, the compiler generates a *gate step* -- a bash script
+injected into the Setup job that evaluates each filter at pipeline runtime and
+self-cancels the build if any filter fails.
+
+The IR formalises this compilation as a three-pass pipeline:
+
+```text
+on.pr.filters / on.pipeline.filters (YAML front matter)
+ │
+ ▼
+ ┌──────────────┐
+ │ 1. Lower │ Filters -> Vec
+ └──────┬───────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ 2. Validate │ Vec -> Vec
+ └──────┬───────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ 3. Codegen │ GateContext + Vec -> bash string
+ └──────────────┘
+```
+
+## Core Concepts
+
+### Facts
+
+A **Fact** is a typed runtime value that can be acquired during pipeline
+execution. Each fact has:
+
+| Property | Type | Purpose |
+|----------|------|---------|
+| `dependencies()` | `&[Fact]` | Facts that must be acquired first |
+| `kind()` | `&str` | Unique identifier used in the serialized spec |
+| `ado_exports()` | `Vec<(&str, &str)>` | ADO macro -> env var mappings for the bash shim |
+| `failure_policy()` | `FailurePolicy` | What happens if acquisition fails |
+| `is_pipeline_var()` | `bool` | Whether this is a free ADO pipeline variable |
+
+Facts are organised into four tiers by acquisition cost:
+
+#### Pipeline Variables (free)
+
+These are always available via ADO macro expansion -- no I/O required.
+
+| Fact | ADO Variable | Shell Var | Applies To |
+|------|-------------|-----------|------------|
+| `PrTitle` | `$(System.PullRequest.Title)` | `TITLE` | PR |
+| `AuthorEmail` | `$(Build.RequestedForEmail)` | `AUTHOR` | PR |
+| `SourceBranch` | `$(System.PullRequest.SourceBranch)` | `SOURCE_BRANCH` | PR |
+| `TargetBranch` | `$(System.PullRequest.TargetBranch)` | `TARGET_BRANCH` | PR |
+| `CommitMessage` | `$(Build.SourceVersionMessage)` | `COMMIT_MSG` | PR, CI |
+| `BuildReason` | `$(Build.Reason)` | `REASON` | All |
+| `TriggeredByPipeline` | `$(Build.TriggeredBy.DefinitionName)` | `SOURCE_PIPELINE` | Pipeline |
+| `TriggeringBranch` | `$(Build.SourceBranch)` | `TRIGGER_BRANCH` | Pipeline, CI |
+
+#### REST API-Derived
+
+Require a `curl` call to the ADO REST API. `PrIsDraft` and `PrLabels` depend
+on `PrMetadata` being acquired first.
+
+| Fact | Source | Shell Var | Depends On |
+|------|--------|-----------|------------|
+| `PrMetadata` | `GET pullRequests/{id}` | `PR_DATA` | -- |
+| `PrIsDraft` | `json .isDraft` from `PR_DATA` | `IS_DRAFT` | `PrMetadata` |
+| `PrLabels` | `json .labels[].name` from `PR_DATA` | `PR_LABELS` | `PrMetadata` |
+
+#### Iteration API-Derived
+
+Require a separate API call to the PR iterations endpoint.
+
+| Fact | Source | Shell Var | Depends On |
+|------|--------|-----------|------------|
+| `ChangedFiles` | `GET pullRequests/{id}/iterations/{last}/changes` | `CHANGED_FILES` | -- |
+| `ChangedFileCount` | `grep -c` on `CHANGED_FILES` | `FILE_COUNT` | -- |
+
+#### Computed
+
+Derived from runtime computation (no API calls).
+
+| Fact | Source | Shell Var |
+|------|--------|-----------|
+| `CurrentUtcMinutes` | `date -u` -> minutes since midnight | `CURRENT_MINUTES` |
+
+### Failure Policies
+
+Each fact declares what happens if it cannot be acquired at runtime:
+
+| Policy | Behaviour | Used By |
+|--------|-----------|---------|
+| `FailClosed` | Check fails -> `SHOULD_RUN=false` | Pipeline vars, `PrIsDraft`, `CurrentUtcMinutes` |
+| `FailOpen` | Check passes -> assume OK | `PrLabels`, `ChangedFiles`, `ChangedFileCount` |
+| `SkipDependents` | Log warning, skip dependent predicates | `PrMetadata` |
+
+### Predicates
+
+A **Predicate** is a pure boolean test over one or more acquired facts. The IR
+supports these predicate types:
+
+| Predicate | Bash Shape | Example |
+|-----------|-----------|---------|
+| `GlobMatch { fact, pattern }` | `fnmatch(value, pattern)` | Title matches `*[review]*` |
+| `Equality { fact, value }` | `[ "$VAR" = "value" ]` | Draft is `false` |
+| `ValueInSet { fact, values, case_insensitive }` | `echo "$VAR" \| grep -q[i]E '^(a\|b)$'` | Author in allow-list |
+| `ValueNotInSet { fact, values, case_insensitive }` | Inverse of `ValueInSet` | Author not in block-list |
+| `NumericRange { fact, min, max }` | `[ "$VAR" -ge N ] && [ "$VAR" -le M ]` | Changed file count in range |
+| `TimeWindow { start, end }` | Arithmetic on `CURRENT_MINUTES` | Only during business hours |
+| `LabelSetMatch { any_of, all_of, none_of }` | `grep -qiF` per label | PR labels match criteria |
+| `FileGlobMatch { include, exclude }` | python3 `fnmatch` | Changed files match globs |
+| `And(Vec)` | All must pass | *(reserved for compound filters)* |
+| `Or(Vec)` | At least one must pass | *(reserved)* |
+| `Not(Box)` | Inner must fail | *(reserved)* |
+
+`And`, `Or`, and `Not` are reserved for future compound filter expressions.
+Currently all filter checks at the top level use AND semantics implicitly (all
+must pass).
+
+Each predicate can report the set of facts it requires via
+`required_facts() -> BTreeSet`. This drives fact acquisition planning in
+the codegen pass.
+
+### FilterCheck
+
+A **FilterCheck** pairs a predicate with metadata used for diagnostics and bash
+codegen:
+
+```rust
+struct FilterCheck {
+ name: &'static str, // "title", "author include", "labels", etc.
+ predicate: Predicate, // The boolean test
+ build_tag_suffix: &'static str, // "title-mismatch" -> "{prefix}:title-mismatch"
+}
+```
+
+`all_required_facts()` returns the transitive closure of all facts needed by
+the check, including dependencies (e.g. a `draft` check needs both `PrIsDraft`
+and its dependency `PrMetadata`).
+
+### GateContext
+
+A **GateContext** determines the trigger-type-specific behaviour of the gate step:
+
+| Context | `build_reason()` | `tag_prefix()` | `step_name()` | Bypass Condition |
+|---------|-------------------|----------------|----------------|-----------------|
+| `PullRequest` | `PullRequest` | `pr-gate` | `prGate` | `Build.Reason != PullRequest` |
+| `PipelineCompletion` | `ResourceTrigger` | `pipeline-gate` | `pipelineGate` | `Build.Reason != ResourceTrigger` |
+
+Non-matching builds bypass the gate automatically and set `SHOULD_RUN=true`.
+
+## Pass 1: Lowering
+
+### `lower_pr_filters(filters: &PrFilters) -> Vec`
+
+Maps each field of `PrFilters` to a `FilterCheck`:
+
+| Field | Predicate | Fact(s) | Tag Suffix |
+|-------|-----------|---------|------------|
+| `title` | `GlobMatch` | `PrTitle` | `title-mismatch` |
+| `author.include` | `ValueInSet` (case-insensitive) | `AuthorEmail` | `author-mismatch` |
+| `author.exclude` | `ValueNotInSet` (case-insensitive) | `AuthorEmail` | `author-excluded` |
+| `source_branch` | `GlobMatch` | `SourceBranch` | `source-branch-mismatch` |
+| `target_branch` | `GlobMatch` | `TargetBranch` | `target-branch-mismatch` |
+| `commit_message` | `GlobMatch` | `CommitMessage` | `commit-message-mismatch` |
+| `labels` | `LabelSetMatch` | `PrLabels` (-> `PrMetadata`) | `labels-mismatch` |
+| `draft` | `Equality` | `PrIsDraft` (-> `PrMetadata`) | `draft-mismatch` |
+| `changed_files` | `FileGlobMatch` | `ChangedFiles` | `changed-files-mismatch` |
+| `time_window` | `TimeWindow` | `CurrentUtcMinutes` | `time-window-mismatch` |
+| `min/max_changes` | `NumericRange` | `ChangedFileCount` | `changes-mismatch` |
+| `build_reason.include` | `ValueInSet` (case-insensitive) | `BuildReason` | `build-reason-mismatch` |
+| `build_reason.exclude` | `ValueNotInSet` (case-insensitive) | `BuildReason` | `build-reason-excluded` |
+
+### `lower_pipeline_filters(filters: &PipelineFilters) -> Vec`
+
+| Field | Predicate | Fact(s) | Tag Suffix |
+|-------|-----------|---------|------------|
+| `source_pipeline` | `GlobMatch` | `TriggeredByPipeline` | `source-pipeline-mismatch` |
+| `branch` | `GlobMatch` | `TriggeringBranch` | `branch-mismatch` |
+| `time_window` | `TimeWindow` | `CurrentUtcMinutes` | `time-window-mismatch` |
+| `build_reason.include` | `ValueInSet` | `BuildReason` | `build-reason-mismatch` |
+| `build_reason.exclude` | `ValueNotInSet` | `BuildReason` | `build-reason-excluded` |
+
+### The `expression` Escape Hatch
+
+The `expression` field on both `PrFilters` and `PipelineFilters` is **not**
+part of the IR. It is a raw ADO condition string applied directly to the Agent
+job's `condition:` field (not the bash gate step). It is handled by
+`generate_agentic_depends_on()` in `common.rs`.
+
+## Pass 2: Validation
+
+### `validate_pr_filters(filters: &PrFilters) -> Vec`
+
+Compile-time checks for impossible or conflicting configurations:
+
+| Check | Severity | Condition |
+|-------|----------|-----------|
+| Min exceeds max | **Error** | `min_changes > max_changes` |
+| Zero-width time window | **Error** | `time_window.start == time_window.end` |
+| Author include/exclude overlap | **Error** | `author.include ∩ author.exclude ≠ ∅` (case-insensitive) |
+| Build reason include/exclude overlap | **Error** | `build_reason.include ∩ build_reason.exclude ≠ ∅` |
+| Labels any-of ∩ none-of overlap | **Error** | `labels.any_of ∩ labels.none_of ≠ ∅` |
+| Labels all-of ∩ none-of overlap | **Error** | `labels.all_of ∩ labels.none_of ≠ ∅` |
+| Empty labels filter | **Warning** | All of `any_of`, `all_of`, `none_of` are empty |
+
+### `validate_pipeline_filters(filters: &PipelineFilters) -> Vec`
+
+| Check | Severity | Condition |
+|-------|----------|-----------|
+| Zero-width time window | **Error** | `time_window.start == time_window.end` |
+| Build reason include/exclude overlap | **Error** | `build_reason.include ∩ build_reason.exclude ≠ ∅` |
+
+**Error** diagnostics cause compilation to fail with an actionable message.
+**Warning** diagnostics are emitted to stderr but compilation continues.
+
+Regex and glob pattern overlap is intentionally not validated -- it would
+require heuristic analysis and could produce false positives.
+
+## Pass 3: Codegen
+
+### `compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String`
+
+Produces a complete ADO pipeline step (`- bash: |`) with a **data-driven
+architecture**: bash is a thin ADO-macro shim, all filter logic lives in a
+generic Python evaluator that reads a JSON gate spec.
+
+#### Generated Step Structure
+
+```yaml
+- bash: |
+ # 1. ADO macro exports (fact-specific, minimal set)
+ export ADO_BUILD_REASON="$(Build.Reason)"
+ export ADO_COLLECTION_URI="$(System.CollectionUri)"
+ export ADO_PROJECT="$(System.TeamProject)"
+ export ADO_BUILD_ID="$(Build.BuildId)"
+ export ADO_PR_TITLE="$(System.PullRequest.Title)"
+ # ... only the macros needed by this spec's facts ...
+
+ # 2. Base64-encoded gate spec (safe from ADO macro expansion)
+ export GATE_SPEC="eyJjb250ZXh0Ijp7Li4ufX0="
+
+ # 3. Access token passthrough
+ export ADO_SYSTEM_ACCESS_TOKEN="$SYSTEM_ACCESSTOKEN"
+
+ # 4. Embedded Python evaluator (heredoc -- never modified)
+ python3 << 'GATE_EVAL_EOF'
+ ...evaluator source...
+ GATE_EVAL_EOF
+ name: prGate
+ displayName: "Evaluate PR filters"
+ env:
+ SYSTEM_ACCESSTOKEN: $(System.AccessToken)
+```
+
+#### Gate Spec Format (JSON)
+
+The spec is base64-encoded to prevent ADO macro expansion and heredoc
+quoting issues. Decoded, it contains:
+
+```json
+{
+ "context": {
+ "build_reason": "PullRequest",
+ "tag_prefix": "pr-gate",
+ "step_name": "prGate",
+ "bypass_label": "PR"
+ },
+ "facts": [
+ {"id": "pr_title", "kind": "pr_title", "failure_policy": "fail_closed"},
+ {"id": "pr_metadata", "kind": "pr_metadata", "failure_policy": "skip_dependents"},
+ {"id": "pr_is_draft", "kind": "pr_is_draft", "failure_policy": "fail_closed"}
+ ],
+ "checks": [
+ {
+ "name": "title",
+ "predicate": {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"},
+ "tag_suffix": "title-mismatch"
+ },
+ {
+ "name": "draft",
+ "predicate": {"type": "equals", "fact": "pr_is_draft", "value": "false"},
+ "tag_suffix": "draft-mismatch"
+ }
+ ]
+}
+```
+
+The spec is declarative -- it uses fact *kinds* (e.g., `"pr_title"`,
+`"pr_metadata"`) not raw REST endpoints. The Python evaluator owns
+acquisition logic.
+
+#### Python Gate Evaluator (`scripts/gate-eval.py`)
+
+The evaluator is a self-contained Python script embedded via
+`include_str!()`. It handles:
+
+1. **Bypass logic** -- reads `ADO_BUILD_REASON` and exits early for non-matching
+ trigger types
+2. **Fact acquisition** -- maps fact kinds to acquisition methods:
+ - Pipeline variables -> `os.environ.get("ADO_*")`
+ - PR metadata -> `urllib` call to ADO REST API
+ - Changed files -> iteration API calls
+ - UTC time -> `datetime.now(timezone.utc)`
+3. **Failure policies** -- `fail_closed`, `fail_open`, `skip_dependents`
+4. **Predicate evaluation** -- recursive evaluator supporting all predicate types
+5. **Result reporting** -- `##vso[...]` logging commands, build tags, self-cancel
+
+The evaluator never changes per-pipeline -- all variation is in the spec.
+
+#### ADO Macro Export Strategy
+
+The bash shim exports only the ADO macros needed by the spec's facts:
+
+- **Always exported**: `ADO_BUILD_REASON`, `ADO_COLLECTION_URI`, `ADO_PROJECT`,
+ `ADO_BUILD_ID` (needed for bypass and self-cancel)
+- **PR API facts**: `ADO_REPO_ID`, `ADO_PR_ID` (only when `pr_metadata`,
+ `pr_is_draft`, `pr_labels`, or `changed_files` facts are required)
+- **Fact-specific**: each `Fact` variant declares its ADO exports via
+ `ado_exports()` (e.g., `PrTitle` -> `ADO_PR_TITLE`)
+
+#### Predicate Types in Spec
+
+| `type` | Fields | Description |
+|--------|--------|-------------|
+| `glob_match` | `fact`, `pattern` | Glob match (`*` any chars, `?` single char) |
+| `equals` | `fact`, `value` | Exact string equality |
+| `value_in_set` | `fact`, `values`, `case_insensitive` | Value membership |
+| `value_not_in_set` | `fact`, `values`, `case_insensitive` | Inverse membership |
+| `numeric_range` | `fact`, `min?`, `max?` | Integer range check |
+| `time_window` | `start`, `end` | UTC HH:MM window (overnight-aware) |
+| `label_set_match` | `fact`, `any_of?`, `all_of?`, `none_of?` | Label set predicates |
+| `file_glob_match` | `fact`, `include?`, `exclude?` | Python `fnmatch` globs |
+| `and` | `operands` | All must pass |
+| `or` | `operands` | At least one must pass |
+| `not` | `operand` | Inner must fail |
+
+## Integration Points
+
+### TriggerFiltersExtension
+
+When Tier 2/3 filters are configured, the `TriggerFiltersExtension`
+(`src/compile/extensions/trigger_filters.rs`) activates via
+`collect_extensions()`. It implements `CompilerExtension` and controls:
+
+1. **Download step** -- downloads `scripts.zip` from the ado-aw release
+ artifacts, verifies its SHA256 checksum via `checksums.txt`, then
+ extracts `gate-eval.py` to `/tmp/ado-aw-scripts/gate-eval.py`
+2. **Gate step** -- calls `compile_gate_step_external()` to generate a step
+ that references the downloaded script (no inline heredoc)
+3. **Validation** -- runs `validate_pr_filters()` / `validate_pipeline_filters()`
+ during compilation via the `validate()` trait method
+
+The extension uses the `setup_steps()` trait method (not `prepare_steps()`)
+because the gate must run in the **Setup job** (before the Execution job).
+
+### Tier 1 Inline Path
+
+When only Tier 1 filters are configured (pipeline variables -- title, author,
+branch, commit-message, build-reason), the extension is NOT activated.
+`generate_pr_gate_step()` generates an inline bash gate step directly, with
+no Python evaluator and no download step.
+
+### Gate Step Injection
+
+Gate steps are injected into the Setup job by `generate_setup_job()` in
+`common.rs`. When the `TriggerFiltersExtension` is active, its
+`setup_steps()` are collected and injected first (download + gate). When
+only Tier 1 filters are present, the inline gate step is injected directly.
+
+User setup steps are conditioned on the gate output:
+`condition: eq(variables['{stepName}.SHOULD_RUN'], 'true')`
+
+### Agent Job Condition
+
+`generate_agentic_depends_on()` in `common.rs` generates the Agent job's
+`dependsOn` and `condition` clauses:
+
+```yaml
+dependsOn: Setup
+condition: |
+ and(
+ succeeded(),
+ or(
+ ne(variables['Build.Reason'], 'PullRequest'),
+ eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')
+ )
+ )
+```
+
+When both PR and pipeline filters are active, both `or()` clauses are ANDed.
+The `expression` escape hatch is also ANDed if present.
+
+### Scripts Distribution
+
+`gate-eval.py` lives at `scripts/gate-eval.py` in the repository and is
+shipped inside a `scripts.zip` archive alongside the ado-aw binary. The
+download URL is deterministic based on the ado-aw version:
+`https://github.com/githubnext/ado-aw/releases/download/v{VERSION}/scripts.zip`
+
+A `checksums.txt` file is also published at the same URL base and used to
+verify the SHA256 integrity of `scripts.zip` before extraction.
+
+## Adding New Filter Types
+
+See [Extending the Compiler](/ado-aw/guides/extending/) for the
+step-by-step guide. In summary:
+
+1. Add a `Fact` variant if a new data source is needed (with `kind()`,
+ `ado_exports()`, `dependencies()`, `failure_policy()`)
+2. Add a `Predicate` variant if a new test shape is needed
+3. Add a `PredicateSpec` variant for serialization
+4. Add an evaluator handler in `scripts/gate-eval.py` for the new predicate
+ type
+5. Extend the lowering function (`lower_pr_filters` or
+ `lower_pipeline_filters`)
+6. Add validation rules if the new filter can conflict with existing ones
+7. Write tests: lowering, validation, spec serialization, and evaluator
diff --git a/docs-site/src/content/docs/reference/front-matter.mdx b/docs-site/src/content/docs/reference/front-matter.mdx
new file mode 100644
index 00000000..a14707d2
--- /dev/null
+++ b/docs-site/src/content/docs/reference/front-matter.mdx
@@ -0,0 +1,289 @@
+---
+title: "Agent file format"
+description: "Full specification of the ado-aw agent markdown format and all supported YAML front matter fields."
+---
+
+## Input Format (Markdown with Front Matter)
+
+The compiler expects markdown files with YAML front matter similar to gh-aw:
+
+```markdown
+---
+name: "name for this agent"
+description: "One line description for this agent"
+target: standalone # Optional: "standalone" (default), "1es", "job", or "stage". See docs/targets.md.
+engine: copilot # Engine identifier. Defaults to copilot. Currently only 'copilot' (GitHub Copilot CLI) is supported.
+# engine: # Alternative object format (with additional options)
+# id: copilot
+# model: claude-opus-4.7
+# timeout-minutes: 30
+workspace: repo # Optional: "root", "repo" (alias: "self"), or a checked-out repository alias. If not specified, defaults to "root" when no additional repositories are listed in `repos:`, and to "repo" when one or more additional repos are checked out. See "Workspace Defaults" below.
+pool: AZS-1ES-L-MMS-ubuntu-22.04 # Agent pool name (string format). Defaults to AZS-1ES-L-MMS-ubuntu-22.04.
+# pool: # Alternative object format (required for 1ES if specifying os)
+# name: AZS-1ES-L-MMS-ubuntu-22.04
+# os: linux # Operating system: "linux" or "windows". Defaults to "linux".
+repos: # compact repository declarations (replaces repositories: + checkout:)
+ - my-org/my-repo # shorthand: alias="my-repo", type=git, ref=refs/heads/main, checkout=true
+ - reponame=my-org/another-repo # shorthand with explicit alias
+ - name: my-org/templates # object form for full control
+ ref: refs/heads/release/2.x
+ checkout: false # declared as resource only, not checked out by the agent
+tools: # optional tool configuration
+ bash: ["cat", "ls", "grep"] # explicit bash allow-list; when omitted, all bash tools are allowed (unrestricted)
+ edit: true # enable file editing tool (default: true)
+ cache-memory: true # persistent memory across runs (see docs/tools.md)
+ # cache-memory: # Alternative object format (with options)
+ # allowed-extensions: [.md, .json]
+ azure-devops: true # first-class ADO MCP integration (see docs/tools.md)
+ # azure-devops: # Alternative object format (with scoping)
+ # toolsets: [repos, wit]
+ # allowed: [wit_get_work_item]
+ # org: myorg
+runtimes: # optional runtime configuration (language environments)
+ lean: true # Lean 4 theorem prover (see docs/runtimes.md)
+ # lean: # Alternative object format (with toolchain pinning)
+ # toolchain: "leanprover/lean4:v4.29.1"
+ # python: true # Python runtime -- auto-installs via UsePythonVersion@0 (see docs/runtimes.md)
+ # python: # Alternative object format (pin version, configure internal feed)
+ # version: "3.12"
+ # feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/pypi/simple/"
+ # node: true # Node.js runtime -- auto-installs via NodeTool@0 (see docs/runtimes.md)
+ # node: # Alternative object format (pin version, configure internal feed)
+ # version: "22.x"
+ # feed-url: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/"
+ # dotnet: true # .NET runtime -- auto-installs via UseDotNet@2 (see docs/runtimes.md)
+ # dotnet: # Alternative object format (pin version, configure internal feed via nuget.config)
+ # version: "8.0.x" # use "global.json" to pin from the repo's global.json
+ # feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"
+# env: # RESERVED: workflow-level environment variables (not yet implemented)
+# CUSTOM_VAR: "value"
+mcp-servers:
+ my-custom-tool: # containerized MCP server (requires container field)
+ container: "node:20-slim"
+ entrypoint: "node"
+ entrypoint-args: ["path/to/mcp-server.js"]
+ allowed:
+ - custom_function_1
+ - custom_function_2
+safe-outputs: # optional per-tool configuration for safe outputs
+ create-work-item:
+ work-item-type: Task
+ assignee: "user@example.com"
+ tags:
+ - automated
+ - agent-created
+ artifact-link: # optional: link work item to repository branch
+ enabled: true
+ branch: main
+on: # trigger configuration (unified under on: key)
+ schedule: daily around 14:00 # fuzzy schedule - see docs/schedule-syntax.md
+ pipeline:
+ name: "Build Pipeline" # source pipeline name
+ project: "OtherProject" # optional: project name if different
+ branches: # optional: branches to trigger on
+ - main
+ - release/*
+ filters: # optional runtime filters (compiled to gate step)
+ source-pipeline: "Build*"
+ time-window:
+ start: "09:00"
+ end: "17:00"
+ pr: # PR trigger
+ branches:
+ include: [main]
+ paths:
+ include: [src/*]
+ filters: # runtime PR filters (compiled to gate step)
+ title: "*[review]*"
+ author:
+ include: ["alice@corp.com"]
+ draft: false
+ labels:
+ any-of: ["run-agent"]
+ source-branch: "feature/*"
+ target-branch: "main"
+ commit-message: "*[skip-agent]*"
+ changed-files:
+ include: ["src/**/*.rs"]
+ min-changes: 5
+ max-changes: 100
+ time-window:
+ start: "09:00"
+ end: "17:00"
+ build-reason:
+ include: [PullRequest]
+ expression: "eq(variables['Custom.Flag'], 'true')" # raw ADO condition
+steps: # inline steps before agent runs (same job, generate context)
+ - bash: echo "Preparing context for agent"
+ displayName: "Prepare context"
+post-steps: # inline steps after agent runs (same job, process artifacts)
+ - bash: echo "Processing agent outputs"
+ displayName: "Post-steps"
+setup: # separate job BEFORE agentic task
+ - bash: echo "Setup job step"
+ displayName: "Setup step"
+teardown: # separate job AFTER safe outputs processing
+ - bash: echo "Teardown job step"
+ displayName: "Teardown step"
+network: # optional network policy (standalone target only)
+ allowed: # allowed host patterns and/or ecosystem identifiers
+ - python # ecosystem identifier -- expands to Python/PyPI domains
+ - "*.mycompany.com" # raw domain pattern
+ blocked: # blocked host patterns or ecosystems (removes from allow list)
+ - "evil.example.com"
+permissions: # optional ADO access token configuration
+ read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent)
+ write: my-write-arm-connection # ARM service connection for write ADO access (Stage 3 executor only)
+parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run)
+ - name: clearMemory
+ displayName: "Clear agent memory"
+ type: boolean
+ default: false
+---
+
+
+## Build and Test
+
+Build the project and run all tests...
+```
+
+## Workspace Defaults
+
+The `workspace:` field controls which directory the agent runs in. When it is
+not set explicitly, the compiler chooses a default based on which repositories
+are checked out (entries in `repos:` with `checkout: true`, which is the
+default):
+
+- If no additional repositories are checked out (i.e. only the pipeline's own
+ repository is checked out via the implicit `self`), `workspace:` defaults to
+ **`root`** -- the agent runs in the pipeline's working directory root.
+- If one or more additional repositories are checked out, `workspace:` defaults
+ to **`repo`** -- the agent runs inside the trigger repository's directory.
+
+Set `workspace:` explicitly to `root`, `repo` (alias `self`), or a specific
+checked-out repository alias to override this behavior.
+
+## Repositories (`repos:`)
+
+The `repos:` field provides a compact way to declare additional repository
+resources and control which ones the agent checks out. It replaces the legacy
+`repositories:` + `checkout:` pair.
+
+Each entry can be:
+
+| Form | Syntax | Description |
+|------|--------|-------------|
+| **Shorthand** | `- org/repo` | Alias derived from last segment, type=git, ref=refs/heads/main, checkout=true |
+| **Shorthand with alias** | `- alias=org/repo` | Explicit alias before `=` |
+| **Object** | `- name: org/repo` | Full control over all fields |
+
+Object fields:
+
+| Field | Default | Description |
+|------------|------------------------|-------------|
+| `name` | *(required)* | Full `org/repo` name (maps to ADO `name:`) |
+| `alias` | last segment of `name` | Repository alias (maps to ADO `repository:`) |
+| `type` | `git` | ADO repository resource type |
+| `ref` | `refs/heads/main` | Branch or tag reference |
+| `checkout` | `true` | Whether the agent job clones this repo |
+
+### Examples
+
+Three repos, all checked out (most common case):
+
+```yaml
+repos:
+ - my-org/tools
+ - my-org/schemas
+ - my-org/docs
+```
+
+Mixed: two checked out, one resource-only (used by templates):
+
+```yaml
+repos:
+ - my-org/tools
+ - my-org/schemas
+ - name: my-org/pipeline-templates
+ checkout: false
+```
+
+Custom ref and explicit alias:
+
+```yaml
+repos:
+ - name: my-org/docs
+ alias: docs-v2
+ ref: refs/heads/release/2.x
+```
+
+### Legacy syntax (auto-rewritten)
+
+The legacy `repositories:` + `checkout:` fields are auto-converted to
+`repos:` by the [`repos_unified` codemod](/ado-aw/reference/codemods/). On the next
+`ado-aw compile`, any source that still uses the legacy fields is
+rewritten in place to the new shape -- each `repositories:` entry
+becomes a `repos:` entry, with `checkout: false` added for entries
+that weren't listed under `checkout:`. Mixing the legacy fields with
+an existing `repos:` block is rejected; pick one shape.
+
+## Filter Validation
+
+The compiler validates filter configurations at compile time and will emit
+errors for impossible or conflicting combinations:
+
+| Condition | Severity | Message |
+|-----------|----------|---------|
+| `min-changes` > `max-changes` | Error | No PR can satisfy both constraints |
+| `time-window.start` = `time-window.end` | Error | Zero-width window never matches |
+| Same value in `author.include` and `author.exclude` | Error | Conflicting include/exclude |
+| Same value in `build-reason.include` and `build-reason.exclude` | Error | Conflicting include/exclude |
+| Label in both `labels.any-of` and `labels.none-of` | Error | Label both required and blocked |
+| Label in both `labels.all-of` and `labels.none-of` | Error | Label both required and blocked |
+| Empty `labels` filter (no any-of/all-of/none-of) | Warning | No label checks applied |
+
+Errors cause compilation to fail. Fix the conflicting filter configuration
+before recompiling.
+
+## Filter Behavior Notes
+
+### Time Windows
+
+Time windows use **half-open intervals**: `[start, end)`. A window of
+`start: "09:00", end: "17:00"` matches from 09:00 up to but **not
+including** 17:00. A build triggered at exactly 17:00 UTC will not match.
+
+Overnight windows are supported: `start: "22:00", end: "06:00"` matches
+from 22:00 through midnight to 05:59.
+
+All times are evaluated in **UTC**.
+
+### Changed Files
+
+The `changed-files` filter checks the list of files modified in the PR.
+If the PR has no changed files (empty diff) and an `include` pattern is
+set, the filter will not match. An exclude-only filter (no `include`)
+with no changed files passes vacuously (no excluded files are present).
+
+### Expression Escape Hatch
+
+The `expression` field on `pr.filters` and `pipeline.filters` is an
+**advanced, unsafe escape hatch**. Its value is inserted verbatim into
+the Agent job's ADO `condition:` field. It can reference any ADO
+pipeline variable, including secrets. The compiler validates against
+`##vso[` injection and `${{` template markers, but otherwise trusts the
+value. Only use this if the built-in filters are insufficient.
+
+### Pipeline Requirements
+
+The filter gate step uses `System.AccessToken` for self-cancellation
+(PATCH to the builds REST API) and PR metadata retrieval. This requires:
+
+1. **"Allow scripts to access the OAuth token"** must be enabled on the
+ pipeline definition in ADO (Project Settings -> Pipelines -> Settings).
+2. The pipeline's build service account must have permission to cancel
+ builds.
+
+If the token is unavailable, the gate step logs a warning and the build
+completes as "Succeeded" (with the agent job skipped via condition)
+rather than "Cancelled".
diff --git a/docs-site/src/content/docs/reference/mcp.mdx b/docs-site/src/content/docs/reference/mcp.mdx
new file mode 100644
index 00000000..8aaf7998
--- /dev/null
+++ b/docs-site/src/content/docs/reference/mcp.mdx
@@ -0,0 +1,101 @@
+---
+title: "MCP server configuration"
+description: "Reference for configuring Model Context Protocol servers in ado-aw front matter."
+---
+
+## MCP Configuration
+
+The `mcp-servers:` field configures MCP (Model Context Protocol) servers that are made available to the agent via the MCP Gateway (MCPG). MCPs can be **containerized stdio servers** (Docker-based) or **HTTP servers** (remote endpoints). All MCP traffic flows through the MCP Gateway.
+
+## Docker Container MCP Servers (stdio)
+
+Run containerized MCP servers. MCPG spawns these as sibling Docker containers:
+
+```yaml
+mcp-servers:
+ azure-devops:
+ container: "node:20-slim"
+ entrypoint: "npx"
+ entrypoint-args: ["-y", "@azure-devops/mcp", "myorg", "-d", "core", "work-items"]
+ env:
+ AZURE_DEVOPS_EXT_PAT: ""
+ allowed:
+ - core_list_projects
+ - wit_get_work_item
+ - wit_create_work_item
+```
+
+## HTTP MCP Servers (remote)
+
+Connect to remote MCP servers accessible via HTTP:
+
+```yaml
+mcp-servers:
+ remote-ado:
+ url: "https://mcp.dev.azure.com/myorg"
+ headers:
+ X-MCP-Toolsets: "repos,wit"
+ X-MCP-Readonly: "true"
+ allowed:
+ - wit_get_work_item
+ - repo_list_repos_by_project
+```
+
+## Configuration Properties
+
+**Container stdio servers:**
+- `container:` - Docker image to run (e.g., `"node:20-slim"`, `"ghcr.io/org/tool:latest"`)
+- `entrypoint:` - Container entrypoint override (equivalent to `docker run --entrypoint`)
+- `entrypoint-args:` - Arguments passed to the entrypoint (after the image in `docker run`)
+- `args:` - Additional Docker runtime arguments (inserted before the image in `docker run`). **Security note**: dangerous flags like `--privileged`, `--network host` will trigger compile-time warnings.
+- `mounts:` - Volume mounts in `"source:dest:mode"` format (e.g., `["/host/data:/app/data:ro"]`)
+
+**HTTP servers:**
+- `url:` - HTTP endpoint URL for the remote MCP server
+- `headers:` - HTTP headers to include in requests (e.g., `Authorization`, `X-MCP-Toolsets`)
+
+**Common (both types):**
+- `enabled:` - Whether this MCP server is active (default: `true`). Set to `false` to temporarily disable an entry without removing it from the front matter.
+- `allowed:` - Array of tool names the agent is permitted to call (required for security)
+- `env:` - Environment variables for the MCP server process. Use `""` (empty string) for passthrough from the pipeline environment.
+
+## Environment Variable Passthrough
+
+MCP containers may need secrets from the pipeline (e.g., ADO tokens). The `env:` field supports passthrough:
+
+```yaml
+env:
+ AZURE_DEVOPS_EXT_PAT: "" # Passthrough from pipeline environment
+ STATIC_CONFIG: "some-value" # Literal value embedded in config
+```
+
+When `permissions.read` is configured, the compiler automatically maps `SC_READ_TOKEN` -> `AZURE_DEVOPS_EXT_PAT` on the MCPG container, so agents can access ADO APIs without manual wiring.
+
+## Example: Azure DevOps MCP with Authentication
+
+```yaml
+mcp-servers:
+ azure-devops:
+ container: "node:20-slim"
+ entrypoint: "npx"
+ entrypoint-args: ["-y", "@azure-devops/mcp", "myorg"]
+ env:
+ AZURE_DEVOPS_EXT_PAT: ""
+ allowed:
+ - core_list_projects
+ - wit_get_work_item
+permissions:
+ read: my-read-arm-connection
+network:
+ allowed:
+ - "dev.azure.com"
+ - "*.dev.azure.com"
+```
+
+## Security Notes
+
+1. **Allow-listing**: Only tools explicitly listed in `allowed:` are accessible to agents
+2. **Containerization**: Stdio MCP servers run as isolated Docker containers (per MCPG spec §3.2.1)
+3. **Environment Isolation**: MCP containers are spawned by MCPG with only the configured environment variables
+4. **MCPG Gateway**: All MCP traffic flows through the MCP Gateway which enforces tool-level filtering
+5. **Network Isolation**: MCP containers run within the same AWF-isolated network. Users must explicitly allow external domains via `network.allowed`
diff --git a/docs-site/src/content/docs/reference/mcpg.mdx b/docs-site/src/content/docs/reference/mcpg.mdx
new file mode 100644
index 00000000..4250c86b
--- /dev/null
+++ b/docs-site/src/content/docs/reference/mcpg.mdx
@@ -0,0 +1,98 @@
+---
+title: "MCP Gateway (MCPG)"
+description: "Reference for the MCP Gateway architecture, generated configuration, and pipeline integration."
+---
+
+## MCP Gateway (MCPG)
+
+The MCP Gateway ([gh-aw-mcpg](https://github.com/github/gh-aw-mcpg)) is the upstream MCP routing layer that connects agents to their configured MCP servers. It replaces the previous custom MCP firewall with the standard gh-aw gateway implementation.
+
+## Architecture
+
+```text
+ Host
+┌─────────────────────────────────────────────────┐
+│ │
+│ ┌──────────────┐ ┌──────────────────────┐ │
+│ │ SafeOutputs │ │ MCPG Gateway │ │
+│ │ HTTP Server │◀────│ (Docker, --network │ │
+│ │ (ado-aw │ │ host, port 80) │ │
+│ │ mcp-http) │ │ │ │
+│ │ port 8100 │ │ Routes tool calls │ │
+│ └──────────────┘ │ to upstreams │ │
+│ └──────────┬───────────┘ │
+│ │ │
+│ ┌─────────────────┐ │ │
+│ │ Custom MCP │◀────┘ │
+│ │ (stdio server) │ │
+│ └─────────────────┘ │
+└─────────────────────────────────────────────────┘
+ │
+ host.docker.internal:80
+ │
+┌─────────────────────────────────────────────────┐
+│ AWF Container │
+│ │
+│ ┌──────────┐ │
+│ │ Copilot │──── HTTP ──── MCPG (via host) │
+│ │ Agent │ │
+│ └──────────┘ │
+└─────────────────────────────────────────────────┘
+```
+
+## How It Works
+
+1. **SafeOutputs HTTP server** starts on the host (port 8100) via `ado-aw mcp-http`
+2. **MCPG container** starts on the host network (`docker run --network host`)
+3. **MCPG config** (generated by the compiler) defines:
+ - SafeOutputs as an HTTP backend (`type: "http"`, URL points to localhost:8100)
+ - Custom MCPs as stdio servers (`type: "stdio"`, spawned by MCPG)
+ - Gateway settings (port 80, API key, payload directory)
+4. **Agent inside AWF** connects to MCPG via `http://host.docker.internal:80/mcp`
+5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom MCPs)
+6. After the agent completes, MCPG and SafeOutputs are stopped
+
+## MCPG Configuration Format
+
+The compiler generates MCPG configuration JSON from the `mcp-servers:` front matter:
+
+```json
+{
+ "mcpServers": {
+ "safeoutputs": {
+ "type": "http",
+ "url": "http://localhost:8100/mcp",
+ "headers": {
+ "Authorization": "Bearer "
+ }
+ },
+ "custom-tool": {
+ "type": "stdio",
+ "container": "node:20-slim",
+ "entrypoint": "node",
+ "entrypointArgs": ["server.js"],
+ "tools": ["process_data", "get_status"]
+ }
+ },
+ "gateway": {
+ "port": 80,
+ "domain": "host.docker.internal",
+ "apiKey": "",
+ "payloadDir": "/tmp/gh-aw/mcp-payloads"
+ }
+}
+```
+
+Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline before passing the config to MCPG.
+
+## Pipeline Integration
+
+The MCPG is automatically configured in generated standalone pipelines:
+
+1. **Config Generation**: The compiler generates `mcpg-config.json` from the agent's `mcp-servers:` front matter
+2. **SafeOutputs Start**: `ado-aw mcp-http` starts as a background process on the host
+3. **MCPG Start**: The MCPG Docker container starts on the host network with config via stdin
+4. **Agent Execution**: AWF runs the agent with `--enable-host-access`, copilot connects to MCPG via HTTP
+5. **Cleanup**: Both MCPG and SafeOutputs are stopped after the agent completes (condition: always)
+
+The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug.
diff --git a/docs-site/src/content/docs/reference/network.mdx b/docs-site/src/content/docs/reference/network.mdx
new file mode 100644
index 00000000..55b9abdc
--- /dev/null
+++ b/docs-site/src/content/docs/reference/network.mdx
@@ -0,0 +1,154 @@
+---
+title: "Network isolation and permissions"
+description: "Reference for AWF network isolation, allowed domains, ecosystem identifiers, blocking, and Azure DevOps access tokens."
+---
+
+## Network Isolation (AWF)
+
+Network isolation is provided by AWF (Agentic Workflow Firewall), which provides L7 (HTTP/HTTPS) egress control using Squid proxy and Docker containers. AWF restricts network access to a whitelist of approved domains.
+
+The `ado-aw` compiler binary is distributed via [GitHub Releases](https://github.com/githubnext/ado-aw/releases) with SHA256 checksum verification. The AWF binary is distributed via [GitHub Releases](https://github.com/github/gh-aw-firewall/releases) with SHA256 checksum verification. Docker is sourced via the `DockerInstaller@0` ADO task.
+
+## Default Allowed Domains
+
+The following domains are always allowed. Most are defined in `CORE_ALLOWED_HOSTS` in `allowed_hosts.rs`; `host.docker.internal` is the exception -- it is added by the standalone compiler in `generate_allowed_domains` (`src/compile/common.rs`) because standalone pipelines always use MCPG, which needs host access from the AWF container:
+
+| Host Pattern | Purpose |
+|-------------|---------|
+| `dev.azure.com`, `*.dev.azure.com` | Azure DevOps |
+| `vstoken.dev.azure.com` | Azure DevOps tokens |
+| `vssps.dev.azure.com` | Azure DevOps identity |
+| `*.visualstudio.com` | Visual Studio services |
+| `*.vsassets.io` | Visual Studio assets |
+| `*.vsblob.visualstudio.com` | Visual Studio blob storage |
+| `*.vssps.visualstudio.com` | Visual Studio identity |
+| `pkgs.dev.azure.com`, `*.pkgs.dev.azure.com` | Azure DevOps Artifacts/NuGet |
+| `aex.dev.azure.com`, `aexus.dev.azure.com` | Azure DevOps CDN |
+| `vsrm.dev.azure.com`, `*.vsrm.dev.azure.com` | Visual Studio Release Management |
+| `github.com` | GitHub main site |
+| `api.github.com` | GitHub API |
+| `*.githubusercontent.com` | GitHub raw content |
+| `*.github.com` | GitHub services |
+| `*.copilot.github.com` | GitHub Copilot |
+| `*.githubcopilot.com` | GitHub Copilot |
+| `copilot-proxy.githubusercontent.com` | GitHub Copilot proxy |
+| `login.microsoftonline.com` | Microsoft identity (OAuth) |
+| `login.live.com` | Microsoft account authentication |
+| `login.windows.net` | Azure AD authentication |
+| `*.msauth.net`, `*.msftauth.net` | Microsoft authentication assets |
+| `*.msauthimages.net` | Microsoft authentication images |
+| `graph.microsoft.com` | Microsoft Graph API |
+| `management.azure.com` | Azure Resource Manager |
+| `*.blob.core.windows.net` | Azure Blob storage |
+| `*.table.core.windows.net` | Azure Table storage |
+| `*.queue.core.windows.net` | Azure Queue storage |
+| `*.applicationinsights.azure.com` | Application Insights telemetry |
+| `*.in.applicationinsights.azure.com` | Application Insights ingestion |
+| `dc.services.visualstudio.com` | Visual Studio telemetry |
+| `rt.services.visualstudio.com` | Visual Studio runtime telemetry |
+| `config.edge.skype.com` | Configuration |
+| `host.docker.internal` | MCP Gateway (MCPG) on host -- added by the standalone compiler, not part of `CORE_ALLOWED_HOSTS` |
+
+## Adding Additional Hosts
+
+Agents can specify additional allowed hosts in their front matter using either ecosystem identifiers or raw domain patterns:
+
+```yaml
+network:
+ allowed:
+ - python # Ecosystem identifier -- expands to Python/PyPI domains
+ - rust # Ecosystem identifier -- expands to Rust/crates.io domains
+ - "*.mycompany.com" # Raw domain pattern
+ - "api.external-service.com" # Raw domain
+```
+
+### Ecosystem Identifiers
+
+Ecosystem identifiers are shorthand names that expand to curated domain lists for common language ecosystems and services. The domain lists are sourced from [gh-aw](https://github.com/github/gh-aw) and kept up to date via an automated workflow.
+
+Available ecosystem identifiers include:
+
+| Identifier | Includes |
+|------------|----------|
+| `defaults` | Certificate infrastructure, Ubuntu mirrors, common package registries |
+| `github` | GitHub domains (`github.com`, `*.githubusercontent.com`, etc.) |
+| `local` | Loopback addresses (`localhost`, `127.0.0.1`, `::1`) |
+| `containers` | Docker Hub, GHCR, Quay, Kubernetes |
+| `linux-distros` | Debian, Alpine, Fedora, CentOS, Arch Linux package repositories |
+| `dev-tools` | CI/CD and developer tool services (Codecov, Shields.io, Snyk, etc.) |
+| `python` | PyPI, pip, Conda, Anaconda |
+| `rust` | crates.io, rustup, static.rust-lang.org |
+| `node` | npm, Yarn, pnpm, Bun, Deno, Node.js |
+| `go` | proxy.golang.org, pkg.go.dev, Go module proxy |
+| `java` | Maven Central, Gradle, JDK downloads |
+| `dotnet` | NuGet, .NET SDK |
+| `ruby` | RubyGems, Bundler |
+| `swift` | Swift.org, CocoaPods |
+| `terraform` | HashiCorp releases, Terraform registry |
+
+Additional ecosystems: `bazel`, `chrome`, `clojure`, `dart`, `deno`, `elixir`, `fonts`, `github-actions`, `haskell`, `julia`, `kotlin`, `latex`, `lean`, `lua`, `node-cdns`, `ocaml`, `perl`, `php`, `playwright`, `powershell`, `python-native`, `r`, `scala`, `zig`.
+
+The full domain lists are defined in `src/data/ecosystem_domains.json`.
+
+All hosts (core + MCP-specific + ecosystem expansions + user-specified) are combined into a comma-separated domain list passed to AWF's `--allow-domains` flag.
+
+### Blocking Hosts
+
+The `network.blocked` field removes hosts from the combined allowlist. Both ecosystem identifiers and raw domain strings are supported. Blocking an ecosystem identifier removes all of its domains. Blocking a raw domain uses exact-string matching -- blocking `"github.com"` does **not** also remove `"*.github.com"`.
+
+```yaml
+network:
+ allowed:
+ - python
+ - node
+ blocked:
+ - python # Remove all Python ecosystem domains
+ - "github.com" # Remove exact domain
+ - "*.github.com" # Remove wildcard variant too
+```
+
+## Permissions (ADO Access Tokens)
+
+ADO does not support fine-grained permissions -- there are two access levels: blanket read and blanket write. Tokens are minted from ARM service connections; `System.AccessToken` is never used for agent or executor operations.
+
+**Exception:** The trigger filter gate step (Setup job) uses `System.AccessToken`
+for two purposes: (1) self-cancelling the build when filters don't match
+(`PATCH` to `_apis/build/builds/{id}`), and (2) fetching PR metadata for
+Tier 2 filters (labels, draft status, changed files). This runs in the
+Setup job before the agent starts, outside the AWF sandbox. The pipeline
+must have "Allow scripts to access the OAuth token" enabled for this to
+work. This is a deliberate scoped exception -- the token is not passed to
+the agent or executor.
+
+```yaml
+permissions:
+ read: my-read-arm-connection # Stage 1 agent -- read-only ADO access
+ write: my-write-arm-connection # Stage 3 executor -- write access for safe-outputs
+```
+
+### Security Model
+
+- **`permissions.read`**: Mints a read-only ADO-scoped token given to the agent inside the AWF sandbox (Stage 1). The agent can query ADO APIs but cannot write.
+- **`permissions.write`**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`Execution` job). This token is never exposed to the agent.
+- **Both omitted**: No ADO tokens are passed anywhere. The agent has no ADO API access.
+
+### Compile-Time Validation
+
+If write-requiring safe-outputs (`create-pull-request`, `create-work-item`) are configured but `permissions.write` is missing, compilation fails with a clear error message.
+
+### Examples
+
+```yaml
+# Agent can read ADO, safe-outputs can write
+permissions:
+ read: my-read-sc
+ write: my-write-sc
+
+# Agent can read ADO, no write safe-outputs needed
+permissions:
+ read: my-read-sc
+
+# Agent has no ADO access, but safe-outputs can create PRs/work items
+permissions:
+ write: my-write-sc
+```
diff --git a/docs-site/src/content/docs/reference/parameters.mdx b/docs-site/src/content/docs/reference/parameters.mdx
new file mode 100644
index 00000000..58eeea3c
--- /dev/null
+++ b/docs-site/src/content/docs/reference/parameters.mdx
@@ -0,0 +1,46 @@
+---
+title: "Runtime parameters"
+description: "Reference for Azure DevOps runtime parameters exposed by ado-aw generated pipelines."
+---
+
+## Runtime Parameters
+
+The `parameters` field defines Azure DevOps [runtime parameters](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/runtime-parameters) that are surfaced in the ADO UI when manually queuing a pipeline run. Parameters are emitted as a top-level `parameters:` block in the generated pipeline YAML.
+
+```yaml
+parameters:
+ - name: verbose
+ displayName: "Verbose output"
+ type: boolean
+ default: false
+ - name: region
+ displayName: "Target region"
+ type: string
+ default: "us-east"
+ values:
+ - us-east
+ - eu-west
+ - ap-south
+```
+
+### Fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | Yes | Parameter identifier (valid ADO identifier) |
+| `displayName` | string | No | Human-readable label in the ADO UI |
+| `type` | string | No | ADO parameter type: `boolean`, `string`, `number`, `object` |
+| `default` | any | No | Default value when not specified at queue time |
+| `values` | list | No | Allowed values (for `string`/`number` parameters) |
+
+Parameters can be referenced in custom steps using `${{ parameters.paramName }}`.
+
+### Auto-injected `clearMemory` Parameter
+
+When `tools.cache-memory` is configured, the compiler automatically injects a `clearMemory` boolean parameter (default: `false`) at the beginning of the parameters list. This parameter:
+
+- Is surfaced in the ADO UI when manually queuing a run
+- When set to `true`, skips downloading the previous agent memory artifact
+- Creates an empty memory directory so the agent starts fresh
+
+If you define your own `clearMemory` parameter in the front matter, the auto-injected one is suppressed -- your definition takes precedence.
diff --git a/docs-site/src/content/docs/reference/runtimes.mdx b/docs-site/src/content/docs/reference/runtimes.mdx
new file mode 100644
index 00000000..0676717f
--- /dev/null
+++ b/docs-site/src/content/docs/reference/runtimes.mdx
@@ -0,0 +1,184 @@
+---
+title: "Runtimes configuration"
+description: "Reference for runtime environments installed before agent execution, including Lean, Python, Node.js, and .NET."
+---
+
+## Runtimes Configuration
+
+The `runtimes` field configures language environments that are installed before the agent runs. Unlike tools (which are agent capabilities like edit, bash, memory), runtimes are execution environments that the compiler auto-installs via pipeline steps.
+
+Aligned with [gh-aw's `runtimes:` front matter field](https://github.github.com/gh-aw/reference/frontmatter/#runtimes-runtimes).
+
+### Lean 4 (`lean:`)
+
+Lean 4 theorem prover runtime. Auto-installs the Lean toolchain via elan, extends the bash command allow-list, adds Lean-specific domains to the network allowlist, and appends a prompt supplement informing the agent that Lean is available.
+
+```yaml
+# Simple enablement (installs latest stable toolchain)
+runtimes:
+ lean: true
+
+# With options (pin specific toolchain version)
+runtimes:
+ lean:
+ toolchain: "leanprover/lean4:v4.29.1"
+```
+
+When enabled, the compiler:
+- Injects an elan installation step into `{{ prepare_steps }}` (runs before AWF network isolation)
+- Defaults to the `stable` toolchain; if a `lean-toolchain` file exists in the repo, elan overrides to that version automatically
+- Auto-adds `lean`, `lake`, and `elan` to the bash command allow-list
+- Adds Lean-specific domains to the network allowlist: `elan.lean-lang.org`, `leanprover.github.io`, `lean-lang.org`
+- Mounts `$HOME/.elan` into the AWF container via `--mount` flag so the elan toolchain is accessible inside the chroot (AWF replaces `$HOME` with an empty overlay for security)
+- Appends a prompt supplement informing the agent about Lean 4 availability and basic commands
+- Emits a compile-time warning if `tools.bash` is empty (Lean requires bash access)
+
+**Note:** In the 1ES target, the bash command allow-list is updated but elan installation must be done manually via `steps:` front matter. The 1ES target handles network isolation separately.
+
+### Python (`python:`)
+
+Python runtime. Auto-installs Python via `UsePythonVersion@0`, emits `PipAuthenticate@1` for internal feed access, adds Python ecosystem domains to the AWF network allowlist, extends the bash command allow-list, and optionally injects feed URL env vars for pip and uv.
+
+```yaml
+# Simple enablement (installs default Python 3.x)
+runtimes:
+ python: true
+
+# With options (pin version, configure feed)
+runtimes:
+ python:
+ version: "3.12"
+ feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/pypi/simple/"
+```
+
+**Fields:**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `version` | string | Python version to install (e.g., `"3.12"`, `"3.11"`). Passed to `UsePythonVersion@0` `versionSpec`. Defaults to latest 3.x. |
+| `feed-url` | string | Internal PyPI feed URL. Injects `PIP_INDEX_URL` and `UV_DEFAULT_INDEX` env vars into the agent environment. |
+| `config` | string | Path to a pip/uv config file. Accepted with a warning -- the file will not be available inside the AWF agent environment until proxy-auth support lands. |
+
+When enabled, the compiler:
+- Injects `UsePythonVersion@0` into `{{ prepare_steps }}` (runs before AWF)
+- If `feed-url` is set, also injects `PipAuthenticate@1` to authenticate the ADO build service identity for internal feeds
+- Auto-adds `python`, `python3`, `pip`, `pip3`, `uv` to the bash command allow-list
+- Adds Python ecosystem domains to the network allowlist (pypi.org, pythonhosted.org, etc.)
+- If `feed-url` is set, injects `PIP_INDEX_URL` and `UV_DEFAULT_INDEX` env vars into the agent environment
+- Appends a prompt supplement informing the agent about Python availability
+- No AWF mounts or PATH prepends needed -- `UsePythonVersion@0` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH`
+
+**Note:** `PipAuthenticate@1` is currently emitted with an empty `artifactFeeds` input, which configures credentials for all feeds accessible to the build service identity. If your internal feed requires scoped authentication to a specific Azure Artifacts feed, this may need future refinement.
+
+### Node.js (`node:`)
+
+Node.js runtime. Auto-installs Node.js via `NodeTool@0`, emits `npmAuthenticate@0` for internal feed access, adds Node ecosystem domains to the AWF network allowlist, extends the bash command allow-list, and optionally injects feed URL env vars for npm.
+
+```yaml
+# Simple enablement (installs default Node LTS)
+runtimes:
+ node: true
+
+# With options (pin version, configure feed)
+runtimes:
+ node:
+ version: "22.x"
+ feed-url: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/"
+```
+
+**Fields:**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `version` | string | Node.js version to install (e.g., `"22.x"`, `"20.x"`). Passed to `NodeTool@0` `versionSpec`. Defaults to `"22.x"`. |
+| `feed-url` | string | Internal npm registry URL. Injects `NPM_CONFIG_REGISTRY` env var into the agent environment. |
+| `config` | string | Path to an .npmrc config file. Accepted with a warning -- the file will not be available inside the AWF agent environment until proxy-auth support lands. |
+
+When enabled, the compiler:
+- Injects `NodeTool@0` into `{{ prepare_steps }}` (runs before AWF)
+- If `feed-url` or `config` is set, also injects `npmAuthenticate@0` (and an ensure-`.npmrc` step) to authenticate the ADO build service identity for internal feeds
+- Auto-adds `node`, `npm`, `npx` to the bash command allow-list
+- Adds Node ecosystem domains to the network allowlist (npmjs.org, nodejs.org, etc.)
+- If `feed-url` is set, injects `NPM_CONFIG_REGISTRY` env var into the agent environment
+- Appends a prompt supplement informing the agent about Node.js availability
+- No AWF mounts or PATH prepends needed -- `NodeTool@0` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH`
+- Note: AWF overlays `~/.npmrc` with `/dev/null` for credential security -- the `NPM_CONFIG_REGISTRY` env var approach avoids conflicting with this overlay
+
+### .NET (`dotnet:`)
+.NET runtime. Auto-installs the .NET SDK via `UseDotNet@2`, emits `NuGetAuthenticate@1` for internal feed access, adds .NET ecosystem domains to the AWF network allowlist, and extends the bash command allow-list with `dotnet`.
+
+```yaml
+# Simple enablement (installs default .NET SDK, currently 8.0.x)
+runtimes:
+ dotnet: true
+
+# With options (pin version, configure internal feed)
+runtimes:
+ dotnet:
+ version: "8.0.x"
+ feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"
+
+# Or point at a checked-in nuget.config
+runtimes:
+ dotnet:
+ version: "8.0.x"
+ config: "nuget.config"
+
+# Pin SDK from the repo's global.json (UseDotNet@2 useGlobalJson mode)
+runtimes:
+ dotnet:
+ version: "global.json"
+```
+
+**Fields:**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `version` | string | .NET SDK version to install (e.g., `"8.0.x"`, `"9.0.x"`). Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. Defaults to `"8.0.x"`. The special value `"global.json"` (case-insensitive) emits `useGlobalJson: true` instead, which discovers and installs every SDK referenced by `global.json` files in the workspace. |
+| `feed-url` | string | Internal NuGet feed URL (typically the v3 `index.json` of an Azure Artifacts feed). When set, the compiler creates a minimal `nuget.config` if none exists and runs `NuGetAuthenticate@1`. |
+| `config` | string | Path to a checked-in `nuget.config` in the repo. When set, the compiler runs `NuGetAuthenticate@1` (which auto-discovers `nuget.config` files in the workspace). Mutually exclusive with `feed-url`. |
+
+**`global.json` precedence.** A `global.json` file in the repo is the canonical
+way to pin the .NET SDK. The compiler enforces a single source of truth:
+
+- If a `global.json` exists at the agent's compile directory **and** the front
+ matter sets a concrete `version`, compilation **errors out**. Either remove
+ the front-matter version or set it to the literal string `"global.json"` to
+ opt into `UseDotNet@2`'s `useGlobalJson: true` mode.
+- If `version: "global.json"` is set, the compiler emits
+ `useGlobalJson: true` (no explicit `version:` input) so the install task
+ walks the workspace for `global.json` files itself.
+- If no `version` is set and a `global.json` exists, the compiler does not
+ auto-promote -- the default `"8.0.x"` is used. Opt in explicitly with the
+ sentinel.
+
+When enabled, the compiler:
+- Injects `UseDotNet@2` into `{{ prepare_steps }}` (runs before AWF)
+- If `feed-url` is set, injects an ensure-`nuget.config` step (writes a minimal `nuget.config` referencing the feed only when one doesn't already exist) and `NuGetAuthenticate@1`
+- If `config` is set (and `feed-url` is not), injects `NuGetAuthenticate@1` only -- the user-checked-in `nuget.config` is assumed to be present in the workspace
+- Auto-adds `dotnet` to the bash command allow-list
+- Adds .NET ecosystem domains to the network allowlist (nuget.org, dotnet.microsoft.com, pkgs.dev.azure.com, etc.)
+- Appends a prompt supplement informing the agent about .NET availability
+- No AWF mounts or PATH prepends needed -- `UseDotNet@2` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH`
+
+**Differences from the Python and Node runtimes** (called out for clarity, since this runtime intentionally diverges):
+- **No agent env var is injected for `feed-url`.** Unlike `pip` (`PIP_INDEX_URL`) and `npm` (`NPM_CONFIG_REGISTRY`), NuGet has no first-class environment-variable equivalent for selecting a package source. Feed configuration always goes through a `nuget.config` file.
+- **`config:` is functional, not a deferred warning.** AWF only overlays files in `$HOME` (e.g., `~/.npmrc` -> `/dev/null`); workspace files such as `nuget.config` are preserved inside the agent sandbox, so a checked-in `nuget.config` works today.
+- **`NuGetAuthenticate@1` requires no `workingFile:` input.** It auto-discovers `nuget.config` files anywhere in the workspace, unlike `npmAuthenticate@0` which needs an explicit path.
+
+### Combining Runtimes
+
+Multiple runtimes can be enabled simultaneously:
+
+```yaml
+runtimes:
+ python:
+ version: "3.12"
+ node:
+ version: "22.x"
+ dotnet:
+ version: "8.0.x"
+ lean: true
+```
+
+All runtime extensions are sorted into `ExtensionPhase::Runtime` and execute before tool extensions (`ExtensionPhase::Tool`), ensuring language toolchains are available before any tools that depend on them.
diff --git a/docs-site/src/content/docs/reference/safe-outputs.mdx b/docs-site/src/content/docs/reference/safe-outputs.mdx
new file mode 100644
index 00000000..a4d7f0b4
--- /dev/null
+++ b/docs-site/src/content/docs/reference/safe-outputs.mdx
@@ -0,0 +1,617 @@
+---
+title: "Safe outputs reference"
+description: "Reference for safe output tools, their agent parameters, and per-tool front matter configuration."
+---
+
+## Safe Outputs Configuration
+
+The front matter supports a `safe-outputs:` field for configuring specific tool behaviors:
+
+```yaml
+safe-outputs:
+ create-work-item:
+ work-item-type: Task
+ assignee: "user@example.com"
+ tags:
+ - automated
+ - agent-created
+ create-pull-request:
+ target-branch: main
+ draft: false # default is true; set false to publish immediately (required for auto-complete)
+ auto-complete: true
+ delete-source-branch: true
+ squash-merge: true
+ reviewers:
+ - "user@example.com"
+ labels:
+ - automated
+ - agent-created
+ work-items:
+ - 12345
+```
+
+Safe output configurations are passed to Stage 3 execution and used when processing safe outputs.
+
+## Available Safe Output Tools
+
+### comment-on-work-item
+Adds a comment to an existing Azure DevOps work item. This is the ADO equivalent of gh-aw's `add-comment` tool.
+
+**Agent parameters:**
+- `work_item_id` - The work item ID to comment on (required, must be positive)
+- `body` - Comment text in markdown format (required, must be at least 10 characters)
+
+**Configuration options (front matter):**
+- `max` - Maximum number of comments per run (default: 1)
+- `include-stats` - Whether to append agent execution stats to the comment body (default: true)
+- `target` - **Required** -- scoping policy for which work items can be commented on:
+ - `"*"` - Any work item in the project (unrestricted, must be explicit)
+ - `12345` - A specific work item ID
+ - `[12345, 67890]` - A list of allowed work item IDs
+ - `"Some\\Path"` - Work items under the specified area path prefix (any string that isn't `"*"`, validated via ADO API at Stage 3)
+
+**Example configuration:**
+```yaml
+safe-outputs:
+ comment-on-work-item:
+ max: 3
+ target: "4x4\\QED"
+```
+
+**Note:** The `target` field is required. If omitted, compilation fails with an error. This ensures operators are intentional about which work items agents can comment on.
+
+### create-work-item
+Creates an Azure DevOps work item.
+
+**Agent parameters:**
+- `title` - A concise title for the work item (required, must be more than 5 characters)
+- `description` - Work item description in markdown format (required, must be more than 30 characters)
+- `tags` - Tags to apply to the work item (optional list; each tag must not contain a semicolon). May be subject to the `allowed-tags` allowlist. Merged with any static `tags` configured in front matter.
+
+**Configuration options (front matter):**
+- `work-item-type` - Work item type (default: "Task")
+- `area-path` - Area path for the work item
+- `iteration-path` - Iteration path for the work item
+- `assignee` - User to assign (email or display name)
+- `tags` - Static list of tags always applied to the work item (regardless of agent input)
+- `allowed-tags` - Allowlist of tags the agent is permitted to use via the `tags` parameter. If empty, any agent-provided tags are accepted. Supports `*` wildcards anywhere in the pattern (e.g., `"agent-*"` matches `"agent-created"`; `"copilot:repo=org/project/*@main"` matches any repo name).
+- `custom-fields` - Map of custom field reference names to values (e.g., `Custom.MyField: "value"`)
+- `max` - Maximum number of create-work-item outputs allowed per run (default: 1)
+- `include-stats` - Whether to append agent execution stats to the work item description (default: true)
+- `artifact-link` - Configuration for GitHub Copilot artifact linking:
+ - `enabled` - Whether to add an artifact link (default: false)
+ - `repository` - Repository name override (defaults to BUILD_REPOSITORY_NAME)
+ - `branch` - Branch name to link to (default: "main")
+
+### update-work-item
+Updates an existing Azure DevOps work item. Each field that can be modified requires explicit opt-in via configuration to prevent unintended updates.
+
+**Agent parameters:**
+- `id` - Work item ID to update (required, must be a positive integer)
+- `title` - New title for the work item (optional, requires `title: true` in config)
+- `body` - New description in markdown format (optional, requires `body: true` in config)
+- `state` - New state (e.g., `"Active"`, `"Resolved"`, `"Closed"`; optional, requires `status: true` in config)
+- `area_path` - New area path (optional, requires `area-path: true` in config)
+- `iteration_path` - New iteration path (optional, requires `iteration-path: true` in config)
+- `assignee` - New assignee email or display name (optional, requires `assignee: true` in config)
+- `tags` - New tags, replaces all existing tags (optional, requires `tags: true` in config)
+
+At least one field must be provided for update.
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ update-work-item:
+ status: true # enable state/status updates via `state` parameter (default: false)
+ title: true # enable title updates (default: false)
+ body: true # enable body/description updates (default: false)
+ markdown-body: true # store body as markdown in ADO (default: false; requires ADO Services or Server 2022+)
+ title-prefix: "[bot] " # only update work items whose title starts with this prefix
+ tag-prefix: "agent-" # only update work items that have at least one tag starting with this prefix
+ max: 3 # maximum number of update-work-item outputs allowed per run (default: 1)
+ target: "*" # "*" (default) allows any work item ID, or set to a specific work item ID number
+ area-path: true # enable area path updates (default: false)
+ iteration-path: true # enable iteration path updates (default: false)
+ assignee: true # enable assignee updates (default: false)
+ tags: true # enable tag updates (default: false)
+ allowed-tags: [] # Optional -- restrict which tags the agent can set (empty = any; supports * wildcards like "agent-*")
+```
+
+**Security note:** Every field that can be modified requires explicit opt-in (`true`) in the front matter configuration. If the `max` limit is exceeded, additional entries are skipped rather than aborting the entire batch.
+
+### create-pull-request
+Creates a pull request with code changes made by the agent. When invoked:
+1. Generates a patch file from `git diff` capturing all changes in the specified repository
+2. Saves the patch to the safe outputs directory
+3. Creates a JSON record with PR metadata (title, description, source branch, repository)
+
+During Stage 3 execution, the repository is validated against the allowed list (from `checkout:` + "self"), then the patch is applied and a PR is created in Azure DevOps.
+
+**Stage 3 Execution Architecture (Hybrid Git + ADO API):**
+
+```text
+┌─────────────────────────────────────────────────────────────────┐
+│ Stage 3 Execution │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ 1. Security Validation │
+│ ├── Patch file size limit (5 MB) │
+│ └── Path validation (no .., .git, absolute paths) │
+│ │
+│ 2. Git Worktree (local operations only) │
+│ ├── Create worktree at target branch │
+│ ├── git apply --check (dry run) │
+│ ├── git apply (apply patch correctly) │
+│ └── git status --porcelain (detect changes) │
+│ │
+│ 3. ADO REST API (authenticated, no git config needed) │
+│ ├── Read full file contents from worktree │
+│ ├── POST /pushes (create branch + commit) │
+│ ├── POST /pullrequests (create PR) │
+│ ├── PATCH (set auto-complete if configured) │
+│ └── PUT (add reviewers) │
+│ │
+│ 4. Cleanup │
+│ └── WorktreeGuard removes worktree on drop │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+This hybrid approach combines:
+- **Git worktree + apply**: Correct patch application using git's battle-tested diff parser
+- **ADO REST API**: No git config (user.email/name) needed, authentication handled via token
+
+**Agent parameters:**
+- `title` - PR title (required, 5-200 characters)
+- `description` - PR description in markdown (required, 10+ characters)
+- `repository` - Repository to create PR in: "self" for pipeline repo, or alias from `checkout:` list (default: "self")
+
+Note: The source branch name is auto-generated from a sanitized version of the PR title plus a unique suffix (e.g., `agent/fix-bug-in-parser-a1b2c3`). This format is human-readable while preventing injection attacks.
+
+**Configuration options (front matter):**
+- `target-branch` - Target branch to merge into (default: "main")
+- `draft` - Whether to create the PR as a draft (default: **true**). Set to `false` to publish the PR immediately. **Note:** `auto-complete` is silently skipped on draft PRs -- set `draft: false` when using `auto-complete: true`.
+- `auto-complete` - Set auto-complete on the PR (default: false). Requires `draft: false` to take effect.
+- `delete-source-branch` - Delete source branch after merge (default: true)
+- `squash-merge` - Squash commits on merge (default: true)
+- `title-prefix` - Optional string prepended to all PR titles created by this agent (e.g., `"[Bot] "`)
+- `if-no-changes` - Behavior when the agent's patch produces no file changes: `"warn"` (default, succeed with a warning), `"error"` (fail the step), `"ignore"` (succeed silently)
+- `max-files` - Maximum number of files allowed in a single PR (default: 100). PRs exceeding this limit are rejected.
+- `protected-files` - Controls whether manifest/CI files (e.g., `package-lock.json`, `.github/`, `*.lock`) can be modified: `"blocked"` (default, reject changes to these files) or `"allowed"` (permit all files)
+- `excluded-files` - Glob patterns for files to strip from the patch before applying (e.g., `["*.lock", "dist/**"]`)
+- `allowed-labels` - Allowlist of labels the agent is permitted to apply. If empty (default), any labels are accepted.
+- `reviewers` - List of reviewer emails to add
+- `labels` - List of labels to apply
+- `work-items` - List of work item IDs to link
+- `fallback-record-branch` - When PR creation fails, record the pushed branch name and target branch in the failure response so operators can manually create the PR (default: true)
+- `max` - Maximum number of create-pull-request outputs allowed per run (default: 1)
+- `include-stats` - Whether to append agent execution stats (token usage, duration, model) to the PR description (default: true)
+
+**Multi-repository support:**
+When `workspace: root` and multiple repositories are checked out, agents can create PRs for any allowed repository:
+```json
+{"title": "Fix in main repo", "description": "...", "repository": "self"}
+{"title": "Fix in other repo", "description": "...", "repository": "other-repo"}
+```
+The `repository` value must be `"self"`, an alias from the `checkout:` list in the front matter, the full Azure DevOps repository name (e.g. `project/repo`), or the bare repository name (case-insensitive, e.g. `sdk-FtdiDeviceControl` for an entry whose ADO name is `4x4/sdk-FtdiDeviceControl`).
+
+### noop
+Reports that no action was needed. Use this to provide visibility when analysis is complete but no changes or outputs are required.
+
+**Agent parameters:**
+- `context` - Optional context about why no action was taken
+
+### missing-data
+Reports that data or information needed to complete the task is not available.
+
+**Agent parameters:**
+- `data_type` - Type of data needed (e.g., 'API documentation', 'database schema')
+- `reason` - Why this data is required
+- `context` - Optional additional context about the missing information
+
+### missing-tool
+Reports that a tool or capability needed to complete the task is not available.
+
+**Agent parameters:**
+- `tool_name` - Name of the tool that was expected but not found
+- `context` - Optional context about why the tool was needed
+
+### report-incomplete
+Reports that a task could not be completed.
+
+**Agent parameters:**
+- `reason` - Why the task could not be completed (required, at least 10 characters)
+- `context` - Optional additional context about what was attempted
+
+### add-pr-comment
+Adds a new comment thread to a pull request.
+
+**Agent parameters:**
+- `pull_request_id` - The PR ID to comment on (required, must be positive)
+- `content` - Comment text in markdown format (required, at least 10 characters)
+- `repository` - Repository alias (default: "self")
+- `file_path` *(optional)* - File path for an inline comment anchored to a specific file
+- `line` *(optional)* - Line number for an inline comment. Requires `file_path`.
+- `start_line` *(optional)* - Starting line for a multi-line inline comment range. Requires `file_path` and `line`, and must be strictly less than `line`.
+- `status` *(optional)* - Initial thread status: `"active"` (default), `"fixed"`, `"wont-fix"`, `"closed"`, or `"by-design"`. Subject to the `allowed-statuses` allowlist.
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ add-pr-comment:
+ comment-prefix: "[Agent Review] " # Optional -- prepended to all comments
+ allowed-repositories: [] # Optional -- restrict which repos can be commented on
+ allowed-statuses: [] # Optional -- restrict which thread statuses the agent can set (empty = any)
+ max: 1 # Maximum per run (default: 1)
+ include-stats: true # Append agent stats to comment (default: true)
+```
+
+### reply-to-pr-comment
+Replies to an existing review comment thread on a pull request.
+
+**Agent parameters:**
+- `pull_request_id` - The PR ID containing the thread (required)
+- `thread_id` - The thread ID to reply to (required)
+- `content` - Reply text in markdown format (required, at least 10 characters)
+- `repository` - Repository alias (default: "self")
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ reply-to-pr-comment:
+ comment-prefix: "[Agent] " # Optional -- prepended to all replies
+ allowed-repositories: [] # Optional -- restrict which repos can be replied on
+ max: 1 # Maximum per run (default: 1)
+```
+
+### resolve-pr-thread
+Resolves or updates the status of a pull request review thread.
+
+**Agent parameters:**
+- `pull_request_id` - The PR ID containing the thread (required)
+- `thread_id` - The thread ID to resolve (required)
+- `status` - Target status: `fixed`, `wont-fix`, `closed`, `by-design`, or `active` (to reactivate)
+- `repository` - Repository alias (default: "self")
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ resolve-pr-thread:
+ allowed-repositories: [] # Optional -- restrict which repos can be operated on
+ allowed-statuses: [] # REQUIRED -- empty list rejects all status transitions
+ max: 1 # Maximum per run (default: 1)
+```
+
+### submit-pr-review
+Submits a review vote on a pull request.
+
+**Agent parameters:**
+- `pull_request_id` - The PR ID to review (required)
+- `event` - Review decision: `approve`, `approve-with-suggestions`, `request-changes`, or `comment` (required)
+- `body` *(optional)* - Review rationale in markdown (required for `request-changes`, at least 10 characters)
+- `repository` - Repository alias (default: "self")
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ submit-pr-review:
+ allowed-events: [] # REQUIRED -- empty list rejects all events
+ allowed-repositories: [] # Optional -- restrict which repos can be reviewed
+ max: 1 # Maximum per run (default: 1)
+```
+
+### update-pr
+Updates pull request metadata (reviewers, labels, auto-complete, vote, description).
+
+**Agent parameters:**
+- `pull_request_id` - The PR ID to update (required)
+- `operation` - Update operation: `add-reviewers`, `add-labels`, `set-auto-complete`, `vote`, or `update-description` (required)
+- `reviewers` - Reviewer emails (required for `add-reviewers`)
+- `labels` - Label names (required for `add-labels`)
+- `vote` - Vote value: `approve`, `approve-with-suggestions`, `wait-for-author`, `reject`, or `reset` (required for `vote`)
+- `description` - New PR description in markdown (required for `update-description`, at least 10 characters)
+- `repository` - Repository alias (default: "self")
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ update-pr:
+ allowed-operations: [] # Optional -- restrict which operations are permitted (empty = all)
+ allowed-repositories: [] # Optional -- restrict which repos can be updated
+ allowed-votes: [] # REQUIRED for vote operation -- empty rejects all votes
+ delete-source-branch: true # For set-auto-complete (default: true)
+ merge-strategy: "squash" # For set-auto-complete: squash, noFastForward, rebase, rebaseMerge
+ max: 1 # Maximum per run (default: 1)
+```
+
+### link-work-items
+Links two Azure DevOps work items together.
+
+**Agent parameters:**
+- `source_id` - Source work item ID (required, must be positive)
+- `target_id` - Target work item ID (required, must differ from source)
+- `link_type` - Relationship type: `parent`, `child`, `related`, `predecessor`, `successor`, `duplicate`, `duplicate-of` (required)
+- `comment` *(optional)* - Description of the relationship
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ link-work-items:
+ allowed-link-types: [] # Optional -- restrict which link types are allowed (empty = all)
+ target: "*" # Scoping policy (same as comment-on-work-item target)
+ max: 5 # Maximum per run (default: 5)
+```
+
+### queue-build
+Queues an Azure DevOps pipeline build by definition ID.
+
+**Agent parameters:**
+- `pipeline_id` - Pipeline definition ID to trigger (required, must be positive)
+- `branch` *(optional)* - Branch to build (defaults to configured default or "main")
+- `parameters` *(optional)* - Template parameter key-value pairs
+- `reason` *(optional)* - Human-readable reason for triggering the build (at least 5 characters)
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ queue-build:
+ allowed-pipelines: [] # REQUIRED -- pipeline definition IDs that can be triggered (empty rejects all)
+ allowed-branches: [] # Optional -- branches allowed to be built (empty = any)
+ allowed-parameters: [] # Optional -- parameter keys allowed to be passed (empty = any)
+ default-branch: "main" # Optional -- default branch when agent doesn't specify one
+ max: 3 # Maximum per run (default: 3)
+```
+
+### create-git-tag
+Creates a git tag on a repository ref.
+
+**Agent parameters:**
+- `tag_name` - Tag name (e.g., `v1.2.3`; 3-100 characters, alphanumeric plus `.`, `-`, `_`, `/`)
+- `commit` *(optional)* - Commit SHA to tag (40-character hex; defaults to HEAD of default branch)
+- `message` *(optional)* - Tag annotation message (at least 5 characters; creates annotated tag)
+- `repository` - Repository alias (default: "self")
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ create-git-tag:
+ tag-pattern: "^v\\d+\\.\\d+\\.\\d+$" # Optional -- regex pattern tag names must match
+ allowed-repositories: [] # Optional -- restrict which repos can be tagged
+ message-prefix: "[Release] " # Optional -- prefix prepended to tag message
+ max: 1 # Maximum per run (default: 1)
+```
+
+### add-build-tag
+Adds a tag to an Azure DevOps build.
+
+**Agent parameters:**
+- `build_id` - Build ID to tag (required, must be positive)
+- `tag` - Tag value (1-100 characters, alphanumeric and dashes only)
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ add-build-tag:
+ allowed-tags: [] # Optional -- restrict which tags can be applied (supports * wildcards)
+ tag-prefix: "agent-" # Optional -- prefix prepended to all tags
+ allow-any-build: false # When false, only the current pipeline build can be tagged (default: false)
+ max: 1 # Maximum per run (default: 1)
+```
+
+### create-branch
+Creates a new branch from an existing ref.
+
+**Agent parameters:**
+- `branch_name` - Branch name to create (1-200 characters)
+- `source_branch` *(optional)* - Branch to create from (default: "main")
+- `source_commit` *(optional)* - Specific commit SHA to branch from (overrides source_branch; 40-character hex)
+- `repository` - Repository alias (default: "self")
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ create-branch:
+ branch-pattern: "^agent/.*$" # Optional -- regex pattern branch names must match
+ allowed-repositories: [] # Optional -- restrict which repos can have branches created
+ allowed-source-branches: [] # Optional -- restrict which source branches can be branched from
+ max: 1 # Maximum per run (default: 1)
+```
+
+### upload-workitem-attachment
+Uploads a workspace file as an attachment to an Azure DevOps work item.
+
+**Agent parameters:**
+- `work_item_id` - Work item ID to attach the file to (required, must be positive)
+- `file_path` - Relative path to the file in the workspace (no directory traversal)
+- `comment` *(optional)* - Description of the attachment (at least 3 characters)
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ upload-workitem-attachment:
+ max-file-size: 5242880 # Maximum file size in bytes (default: 5 MB)
+ allowed-extensions: [] # Optional -- restrict file types (e.g., [".png", ".pdf"])
+ comment-prefix: "[Agent] " # Optional -- prefix prepended to the comment
+ max: 1 # Maximum per run (default: 1)
+```
+
+### upload-build-attachment
+
+Attaches a workspace file to an Azure DevOps build as a **build attachment** via
+the ADO build attachments REST API
+(`PUT /_apis/build/builds/{buildId}/attachments/{type}/{name}`).
+
+> **Important:** Build attachments are **not visible** in the standard Azure
+> DevOps build summary UI. They are only accessible via the REST API or through
+> a custom Azure DevOps extension that registers a tab matching the
+> `attachment-type` value. For artifacts that should appear in the **Artifacts
+> tab**, use [`upload-pipeline-artifact`](#upload-pipeline-artifact) instead.
+
+**Omit `build_id` to target the current pipeline run** -- the executor resolves
+the build ID from the `BUILD_BUILDID` environment variable automatically. When
+`build_id` is provided, the file is attached to that specific build -- useful for
+posthumously decorating a finished build with a generated report, screenshot, or
+log bundle.
+
+The tool stages the file during Stage 1 (MCP) by copying it into the
+safe-outputs directory; Stage 3 reads the staged copy and uploads it via the REST
+API.
+
+**Agent parameters:**
+- `build_id` *(optional)* - Target build ID. Omit to attach to the current pipeline run. Must be positive when specified.
+- `artifact_name` - Attachment name (1-100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`)
+- `file_path` - Relative path to the file in the workspace (no directory traversal)
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ upload-build-attachment:
+ max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB)
+ allowed-extensions: [] # Optional -- restrict file types (e.g., [".png", ".pdf", ".log"])
+ allowed-artifact-names: [] # Optional -- restrict names (suffix `*` = prefix match)
+ allowed-build-ids: [] # Optional -- restrict target builds (skipped when targeting current build)
+ name-prefix: "" # Optional -- prepended to the agent-supplied artifact name
+ attachment-type: "agent-artifact" # Optional -- {type} segment in the attachments URL (default: "agent-artifact")
+ max: 3 # Maximum per run (default: 3)
+```
+
+**Notes:**
+- Single-file only; directory uploads are not supported.
+- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped -- the current build is implicitly trusted.
+
+**About `attachment-type`:** This is the `{type}` segment in the ADO build
+attachments URL (`PUT .../attachments/{type}/{name}`). It acts as a category
+label. Azure DevOps extensions can register to display attachments of a specific
+type -- for example, the built-in code coverage extension displays attachments
+with type `CodeCoverageSummary`. The default `agent-artifact` is a custom type;
+without a matching ADO extension installed, attachments with this type are only
+accessible via the REST API. Change this only if you have a custom extension
+that displays attachments of a specific type. Most users should use
+[`upload-pipeline-artifact`](#upload-pipeline-artifact) for user-visible
+artifacts instead.
+
+### upload-pipeline-artifact
+
+Publishes a workspace file as an Azure DevOps **pipeline artifact** that appears
+in the **Artifacts tab** of the build summary page. Uses the ADO build artifacts
+REST API in two steps:
+
+1. **Upload bytes** to the agent's own per-build file container (Azure DevOps
+ creates one container per build and exposes its ID via `BUILD_CONTAINERID`).
+2. **Associate** the artifact record (`name = artifact_name`) with the target
+ build via `POST /{project}/_apis/build/builds/{effective_build_id}/artifacts`.
+
+**Omit `build_id` to target the current pipeline run** -- the executor resolves
+the build ID from the `BUILD_BUILDID` environment variable automatically. When
+`build_id` is provided, the artifact record is published to that specific build
+("cross-build publishing"). The artifact bytes still live in the agent's own
+build container; only the record's pointer is associated with the target build.
+This means cross-published artifacts share the agent build's retention -- if the
+agent's build is purged, the cross-referenced artifact stops being downloadable.
+Cross-project publishing is not supported (the associate POST uses the current
+pipeline's project).
+
+The tool stages the file during Stage 1 (MCP) by copying it into the
+safe-outputs directory; Stage 3 reads the staged copy and executes the two-step
+REST flow.
+
+**Agent parameters:**
+- `build_id` *(optional)* - Target build ID. Omit to publish to the current pipeline run. Must be positive when specified.
+- `artifact_name` - Artifact name shown in the Artifacts tab (1-100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`)
+- `file_path` - Relative path to the file in the workspace (no directory traversal)
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ upload-pipeline-artifact:
+ max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB)
+ allowed-extensions: [] # Optional -- restrict file types (e.g., [".png", ".pdf", ".log"])
+ allowed-artifact-names: [] # Optional -- restrict names (suffix `*` = prefix match)
+ allowed-build-ids: [] # Optional -- restrict target builds (skipped when targeting current build)
+ name-prefix: "" # Optional -- prepended to the agent-supplied artifact name
+ require-unique-names: false # Optional -- see "Reusing artifact names" below
+ max: 3 # Maximum per run (default: 3)
+```
+
+**Reusing artifact names within one agent run:**
+By default, the same `artifact_name` may be reused across multiple
+`upload-pipeline-artifact` calls in one run (e.g. publishing a `TriageSummary`
+to many failing builds at once). The executor inserts a short hash suffix
+(`{artifact_name}__{6 hex}`) into the **internal container folder name** so
+the calls don't silently overwrite each other's bytes in the agent's shared
+build container. The hash lives only in internal addressing -- it does not
+appear in the `record.name` your downstream consumers query for, in the web UI
+"Download as zip" filename, or in the contents of files extracted by the
+`DownloadBuildArtifacts@1` / `DownloadPipelineArtifact@2` tasks (all of which
+strip the container folder prefix).
+
+Set `require-unique-names: true` to use a clean container folder
+(`{artifact_name}` only, no suffix) and reject in-run reuse of
+`(effective_build_id, artifact_name)` with a clear early error before any HTTP
+call. Use this when you guarantee one artifact per name per run and want the
+shortest possible internal addressing.
+
+Two records with the same `name` on the **same** target build still collide at
+the record level (ADO returns 409 from the associate call) regardless of this
+setting; use distinct `artifact_name` values when targeting one build with
+multiple uploads.
+
+**Notes:**
+- Single-file only; directory uploads are not supported.
+- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped -- the current build is implicitly trusted.
+- Requires `BUILD_CONTAINERID`, `BUILD_BUILDID`, and `SYSTEM_TEAMPROJECTID` (all set automatically inside an Azure DevOps pipeline job) and `vso.build_execute` scope on the executor's token (the existing write service connection provides this).
+
+### cache-memory (moved to `tools:`)
+Memory is now configured as a first-class tool under `tools: cache-memory:` instead of `safe-outputs: memory:`. See the [Cache Memory section](/ado-aw/reference/tools/#cache-memory-cache-memory) in the Tools reference for details.
+
+### create-wiki-page
+Creates a new Azure DevOps wiki page. The page must **not** already exist; the tool enforces an atomic create-only operation (via `If-Match: ""`). Attempting to create a page that already exists results in an explicit failure.
+
+**Agent parameters:**
+- `path` - Wiki page path to create (e.g. `/Overview/NewPage`). Must not be empty and must not contain `..`.
+- `content` - Markdown content for the wiki page (at least 10 characters).
+- `comment` *(optional)* - Commit comment describing the change. Defaults to the value configured in the front matter, or `"Created by agent"` if not set.
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ create-wiki-page:
+ wiki-name: "MyProject.wiki" # Required -- wiki identifier (name or GUID)
+ wiki-project: "OtherProject" # Optional -- ADO project that owns the wiki; defaults to current pipeline project
+ branch: "main" # Optional -- git branch override; auto-detected for code wikis (see note below)
+ path-prefix: "/agent-output" # Optional -- prepended to the agent-supplied path (restricts write scope)
+ title-prefix: "[Agent] " # Optional -- prepended to the last path segment (the page title)
+ comment: "Created by agent" # Optional -- default commit comment when agent omits one
+ max: 1 # Maximum number of create-wiki-page outputs allowed per run (default: 1)
+ include-stats: true # Append agent stats to wiki page content (default: true)
+```
+
+Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message.
+
+**Code wikis vs project wikis:** The executor automatically detects code wikis (type 1) and resolves the published branch from the wiki metadata. You only need to set `branch` explicitly to override the auto-detected value (e.g. targeting a non-default branch). Project wikis (type 0) need no branch configuration.
+
+### update-wiki-page
+Updates the content of an existing Azure DevOps wiki page. The wiki page must already exist; this tool edits its content but does not create new pages.
+
+**Agent parameters:**
+- `path` - Wiki page path to update (e.g. `/Overview/Architecture`). Must not be empty and must not contain `..`.
+- `content` - Markdown content for the wiki page (at least 10 characters).
+- `comment` *(optional)* - Commit comment describing the change. Defaults to the value configured in the front matter, or `"Updated by agent"` if not set.
+
+**Configuration options (front matter):**
+```yaml
+safe-outputs:
+ update-wiki-page:
+ wiki-name: "MyProject.wiki" # Required -- wiki identifier (name or GUID)
+ wiki-project: "OtherProject" # Optional -- ADO project that owns the wiki; defaults to current pipeline project
+ branch: "main" # Optional -- git branch override; auto-detected for code wikis (see note below)
+ path-prefix: "/agent-output" # Optional -- prepended to the agent-supplied path (restricts write scope)
+ title-prefix: "[Agent] " # Optional -- prepended to the last path segment (the page title)
+ comment: "Updated by agent" # Optional -- default commit comment when agent omits one
+ max: 1 # Maximum number of update-wiki-page outputs allowed per run (default: 1)
+ include-stats: true # Append agent stats to wiki page content (default: true)
+```
+
+Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message.
+
+**Code wikis vs project wikis:** The executor automatically detects code wikis (type 1) and resolves the published branch from the wiki metadata. You only need to set `branch` explicitly to override the auto-detected value (e.g. targeting a non-default branch). Project wikis (type 0) need no branch configuration.
diff --git a/docs-site/src/content/docs/reference/targets.mdx b/docs-site/src/content/docs/reference/targets.mdx
new file mode 100644
index 00000000..06262650
--- /dev/null
+++ b/docs-site/src/content/docs/reference/targets.mdx
@@ -0,0 +1,110 @@
+---
+title: "Target platforms"
+description: "Reference for ado-aw target platforms and the pipeline or template shapes they generate."
+---
+
+## Target Platforms
+
+The `target` field in the front matter determines the output format and execution environment for the compiled pipeline.
+
+### `standalone` (default)
+
+Generates a self-contained Azure DevOps pipeline with:
+- Full 3-job pipeline: `Agent` -> `Detection` -> `Execution`
+- AWF (Agentic Workflow Firewall) L7 domain whitelisting via Squid proxy + Docker
+- MCP Gateway (MCPG) for MCP routing with SafeOutputs HTTP backend
+- Setup/teardown job support
+- All safe output features (create-pull-request, create-work-item, etc.)
+
+This is the recommended target for maximum flexibility and security controls.
+
+### `1es`
+
+Generates a pipeline that extends the 1ES Unofficial Pipeline Template:
+- Uses `templateContext.type: buildJob` with Copilot CLI + AWF + MCPG (same execution model as standalone)
+- Integrates with 1ES SDL scanning and compliance tools
+- Full 3-job pipeline: Agent -> Detection -> Execution
+- Requires 1ES Pipeline Templates repository access
+
+Example:
+```yaml
+target: 1es
+```
+
+When using `target: 1es`, the pipeline will extend `1es/1ES.Unofficial.PipelineTemplate.yml@1ESPipelinesTemplates`.
+
+### `job`
+
+Generates a **job-level ADO YAML template** with `jobs:` at root. This is a
+reusable template that can be included in an existing pipeline -- it does not
+generate a complete pipeline.
+
+The output contains the same 3-job chain (Agent -> Detection -> Execution) as
+`standalone`, with:
+- Job names prefixed with the agent name for uniqueness (e.g., `DailyReview_Agent`)
+- No triggers, pipeline name, or resource declarations (the parent pipeline owns those)
+- Pool baked in from the front matter `pool:` field
+
+Example front matter:
+```yaml
+target: job
+```
+
+#### Usage in a flat pipeline
+
+```yaml
+jobs:
+ - job: Build
+ steps: ...
+ - template: agents/review.lock.yml
+```
+
+#### Usage inside a user-defined stage
+
+```yaml
+stages:
+ - stage: Build
+ jobs: ...
+ - stage: AgenticReview
+ dependsOn: Build
+ jobs:
+ - template: agents/review.lock.yml
+```
+
+#### Notes
+
+- Triggers (`on:`) are ignored with a warning (the parent pipeline controls triggers)
+- If the agent declares additional repositories via `repos:`, add them to the
+ parent pipeline's `resources:` block (documented in the generated file header)
+
+### `stage`
+
+Generates a **stage-level ADO YAML template** with `stages:` at root. This
+wraps the 3-job chain inside a stage block for direct inclusion in multi-stage
+pipelines.
+
+Example front matter:
+```yaml
+target: stage
+```
+
+#### Usage
+
+```yaml
+stages:
+ - stage: Build
+ jobs: ...
+ - template: agents/review.lock.yml
+ dependsOn: Build
+ condition: succeeded()
+```
+
+ADO natively supports `dependsOn` and `condition` at the template call site --
+no template parameters are needed for stage ordering.
+
+#### Notes
+
+- Same 3-job chain, job-name prefixing, and pool handling as `target: job`
+- Triggers (`on:`) are ignored with a warning
+- If the agent declares additional repositories via `repos:`, add them to the
+ parent pipeline's `resources:` block
diff --git a/docs-site/src/content/docs/reference/template-markers.mdx b/docs-site/src/content/docs/reference/template-markers.mdx
new file mode 100644
index 00000000..dce726e7
--- /dev/null
+++ b/docs-site/src/content/docs/reference/template-markers.mdx
@@ -0,0 +1,551 @@
+---
+title: "Template markers"
+description: "Internal reference for the template markers used in ado-aw pipeline templates and how the compiler replaces them."
+---
+
+## Output Format (Azure DevOps YAML)
+
+The compiler transforms the input into valid Azure DevOps pipeline YAML based on the target platform:
+
+- **Standalone**: Uses `src/data/base.yml`
+- **1ES**: Uses `src/data/1es-base.yml`
+- **Job template**: Uses `src/data/job-base.yml`
+- **Stage template**: Uses `src/data/stage-base.yml`
+
+Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ engine_run }}` denotes the full engine invocation command. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template).
+
+## `{{ parameters }}`
+
+Should be replaced with the top-level `parameters:` block generated from the `parameters` front matter field. If no parameters are defined (and no auto-injected parameters apply), this marker is replaced with an empty string.
+
+When `tools.cache-memory` is configured, the compiler auto-injects a `clearMemory` boolean parameter (default: `false`) unless one is already user-defined.
+
+Example output:
+```yaml
+parameters:
+- name: clearMemory
+ displayName: Clear agent memory
+ type: boolean
+ default: false
+- name: verbose
+ displayName: Verbose output
+ type: boolean
+ default: false
+```
+
+## `{{ repositories }}`
+For each additional repository specified in the front matter append:
+
+```yaml
+- repository: reponame
+ type: git
+ name: reponame
+ ref: refs/heads/main
+```
+
+## `{{ schedule }}`
+
+This marker should be replaced with a cron-style schedule block generated from the fuzzy schedule syntax. The compiler parses the human-friendly schedule expression and generates a deterministic cron expression based on the agent name hash.
+
+By default, when no branches are explicitly configured, the schedule defaults to `main` branch only. When the object form is used with a `branches` list, a `branches.include` block is generated with the specified branches.
+
+```yaml
+# Default (string form) -- defaults to main branch
+schedules:
+ - cron: "43 14 * * *" # Generated from "daily around 14:00"
+ displayName: "Scheduled run"
+ branches:
+ include:
+ - main
+ always: true
+
+# With custom branches (object form)
+schedules:
+ - cron: "43 14 * * *"
+ displayName: "Scheduled run"
+ branches:
+ include:
+ - main
+ - release/*
+ always: true
+```
+
+Examples of fuzzy schedule -> cron conversion:
+- `daily` -> scattered across 24 hours (e.g., `"43 5 * * *"`)
+- `daily around 14:00` -> within 13:00-15:00 (e.g., `"13 14 * * *"`)
+- `hourly` -> every hour at scattered minute (e.g., `"43 * * * *"`)
+- `weekly on monday` -> Monday at scattered time (e.g., `"43 5 * * 1"`)
+- `every 2h` -> every 2 hours at scattered minute (e.g., `"53 */2 * * *"`)
+- `bi-weekly` -> every 14 days (e.g., `"43 5 */14 * *"`)
+
+## `{{ checkout_self }}`
+
+Should be replaced with the `checkout: self` step. This generates a simple checkout of the triggering branch.
+
+All checkout steps across all jobs (Agent, Detection, Execution, Setup, Teardown) use this marker.
+
+## `{{ checkout_repositories }}`
+Should be replaced with checkout steps for additional repositories the agent will work with. The behavior depends on the `checkout:` front matter:
+
+- **If `checkout:` is omitted or empty**: No additional repositories are checked out. Only `self` is checked out (from the template).
+- **If `checkout:` is specified**: The listed repository aliases are checked out in addition to `self`. Each entry must exist in `repositories:`.
+
+This distinction allows resources (like templates) to be available as pipeline resources without being checked out into the workspace for the agent to analyze.
+
+```yaml
+- checkout: reponame
+```
+
+## `{{ agent_name }}`
+
+Should be replaced with the human-readable name from the front matter (e.g., "Daily Code Review"). This is used for display purposes like stage names.
+
+## `{{ engine_install_steps }}`
+
+Should be replaced with engine-specific pipeline steps to install the engine binary. Generated by `Engine::install_steps()`. For Copilot, this produces:
+- `NuGetAuthenticate@1` task
+- `NuGetCommand@2` task to install `Microsoft.Copilot.CLI.linux-x64` (uses `engine.version` if set, otherwise `COPILOT_CLI_VERSION` constant)
+- Bash step to copy binary to `/tmp/awf-tools/copilot`
+- Bash step to verify installation
+
+Returns empty when `engine.command` is set (user provides own binary).
+
+## `{{ engine_run }}`
+
+Should be replaced with the full AWF `--` command string for the Agent job. Generated by `Engine::invocation()`. For Copilot, this produces:
+```text
+ --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json
+```
+
+The binary path defaults to `/tmp/awf-tools/copilot` but can be overridden via `engine.command`. The engine controls how the prompt is delivered (`--prompt "$(cat ...)"`), and how MCP config is referenced (`--additional-mcp-config @...`).
+
+Engine args include:
+- `--model ` - AI model from `engine` front matter field (default: claude-opus-4.7)
+- `--agent ` - Custom agent file from `engine.agent` (selects from `.github/agents/`)
+- `--api-target ` - Custom API endpoint from `engine.api-target` (GHES/GHEC)
+- `--no-ask-user` - Prevents interactive prompts
+- `--disable-builtin-mcps` - Disables all built-in Copilot CLI MCPs (single flag, no argument)
+- `--allow-all-tools` - When bash is omitted (default) or has a wildcard (`":*"` or `"*"`), allows all tools instead of individual `--allow-tool` flags
+- `--allow-tool ` - When bash is NOT wildcard, explicitly allows configured tools (github, safeoutputs, write, and shell commands from the `bash:` field plus any runtime-required commands)
+- `--allow-all-paths` - When `edit` tool is enabled (default), allows the agent to write to any file path
+- Custom args from `engine.args` -- appended after compiler-generated args (subject to shell-safety validation and blocked flag checks)
+
+MCP servers are handled entirely by the MCP Gateway (MCPG) and are not passed as copilot CLI params.
+
+## `{{ engine_run_detection }}`
+
+Same as `{{ engine_run }}` but for the Detection (threat analysis) job. Uses a different prompt path (`/tmp/awf-tools/threat-analysis-prompt.md`) and no MCP config.
+
+## `{{ engine_env }}`
+
+Generates engine-specific environment variable entries for the AWF sandbox step via `Engine::env()`. For the Copilot engine, this produces:
+
+- `GITHUB_TOKEN: $(GITHUB_TOKEN)` -- GitHub authentication
+- `GITHUB_READ_ONLY: 1` -- Restricts GitHub API to read-only access
+- `COPILOT_OTEL_ENABLED`, `COPILOT_OTEL_EXPORTER_TYPE`, `COPILOT_OTEL_FILE_EXPORTER_PATH` -- OpenTelemetry file-based tracing for agent statistics
+- Custom env vars from `engine.env` -- merged after compiler-controlled vars (YAML-quoted, validated for safety)
+
+ADO access tokens (`AZURE_DEVOPS_EXT_PAT`, `SYSTEM_ACCESSTOKEN`) are not part of this marker -- they are injected separately by `{{ acquire_ado_token }}` and extension pipeline variable mappings when `permissions.read` is configured.
+
+## `{{ engine_log_dir }}`
+
+Should be replaced with the engine's log directory path, generated by `Engine::log_dir()`. For Copilot: `~/.copilot/logs`. Used by log collection steps to copy engine logs to pipeline artifacts.
+
+## `{{ pool }}`
+
+Should be replaced with the agent pool name from the `pool` front matter field. Defaults to `AZS-1ES-L-MMS-ubuntu-22.04` if not specified.
+
+The pool configuration accepts both string and object formats:
+- **String format**: `pool: AZS-1ES-L-MMS-ubuntu-22.04`
+- **Object format**: `pool: { name: AZS-1ES-L-MMS-ubuntu-22.04, os: linux }`
+
+The `os` field (defaults to "linux") is primarily used for 1ES target compatibility.
+
+## `{{ setup_job }}`
+
+Generates a separate setup job YAML if `setup` contains steps. The job:
+- Runs before `Agent`
+- Uses the same pool as the main agentic task
+- Includes a checkout of self
+- Display name: `Setup`
+
+If `setup` is empty, this is replaced with an empty string.
+
+## `{{ teardown_job }}`
+
+Generates a separate teardown job YAML if `teardown` contains steps. The job:
+- Runs after `Execution` (depends on it)
+- Uses the same pool as the main agentic task
+- Includes a checkout of self
+- Display name: `Teardown`
+
+If `teardown` is empty, this is replaced with an empty string.
+
+## `{{ prepare_steps }}`
+
+Generates inline steps that run inside the `Agent` job, **before** the agent runs. These steps can generate context files, fetch secrets, or prepare the workspace for the agent.
+
+Steps are inserted after the agent prompt is prepared but before AWF network isolation starts.
+
+If `steps` is empty, this is replaced with an empty string.
+
+## `{{ finalize_steps }}`
+
+Generates inline steps that run inside the `Agent` job, **after** the agent completes. These steps can validate outputs, process workspace artifacts, or perform cleanup.
+
+Steps are inserted after the AWF-isolated agent completes but before logs are collected.
+
+If `post-steps` is empty, this is replaced with an empty string.
+
+## `{{ agentic_depends_on }}`
+
+Generates a `dependsOn: Setup` clause for `Agent` if a setup job is configured. The setup job is identified by the job name `Setup`, ensuring the agentic task waits for the setup job to complete.
+
+If no setup job is configured, this is replaced with an empty string.
+
+## `{{ job_timeout }}`
+
+Generates a `timeoutInMinutes: ` job property for `Agent` when `engine.timeout-minutes` is configured. This sets the Azure DevOps job-level timeout for the agentic task.
+
+If `timeout-minutes` is not configured, this is replaced with an empty string.
+
+## `{{ working_directory }}`
+
+Should be replaced with the appropriate working directory based on the effective workspace setting.
+
+**Workspace Resolution Logic:**
+1. If `workspace` is explicitly set in front matter, that value is used (after validation)
+2. If `workspace` is not set and `checkout:` contains additional repositories, defaults to `repo`
+3. If `workspace` is not set and only `self` is checked out, defaults to `root`
+
+**Warning:** If `workspace: repo` (or `self`) is explicitly set but no additional repositories are in `checkout:`, a warning is emitted because when only `self` is checked out, `$(Build.SourcesDirectory)` already contains the repository content directly.
+
+**Accepted values:**
+- `root` -> `$(Build.SourcesDirectory)` -- the checkout root directory
+- `repo` (or `self`) -> `$(Build.SourcesDirectory)/$(Build.Repository.Name)` -- the trigger repository's subfolder
+- `` -> `$(Build.SourcesDirectory)/` -- a specific checked-out repository's subfolder. The alias must appear in the `checkout:` list (which itself must be a subset of `repositories:`). This form is only valid when at least one additional repository is checked out; otherwise compilation fails.
+
+**Example -- pointing the agent's workspace at a checked-out repository:**
+```yaml
+repositories:
+ - repository: exp23-a7-nw
+ type: git
+ name: msazuresphere/exp23-a7-nw
+checkout:
+ - exp23-a7-nw
+workspace: exp23-a7-nw # Resolves to $(Build.SourcesDirectory)/exp23-a7-nw
+```
+
+This is used for the `workingDirectory` property of the copilot task.
+
+## `{{ source_path }}`
+
+Should be replaced with the path to the agent markdown source file for Stage 3 execution. The path is anchored at the **trigger ("self") repository** via `{{ trigger_repo_directory }}` (see below), independent of the user's `workspace:` setting, and mirrors the relative path used at compile time:
+- No additional checkouts: `$(Build.SourcesDirectory)/.md`
+- Additional checkouts present: `$(Build.SourcesDirectory)/$(Build.Repository.Name)/.md`
+
+For example, compiling `agents/my-agent.md` produces a runtime path of `$(Build.SourcesDirectory)/agents/my-agent.md` (or the equivalent under `$(Build.Repository.Name)` when additional repositories are checked out).
+
+Used by the execute command's --source parameter. The agent markdown only ever lives in the trigger repo, so this is intentionally not affected by `workspace:` pointing at a non-self alias.
+
+## `{{ pipeline_path }}`
+
+Should be replaced with the path to the compiled pipeline YAML file for runtime integrity checking. The path is **relative** to the trigger repository root (e.g. `agents/ctf.yml`, `pipelines/production/review.lock.yml`). The integrity check step itself sets `workingDirectory: {{ trigger_repo_directory }}` so the relative path resolves correctly regardless of whether additional repositories are checked out, and so that `ado-aw check`'s recompile step has access to the trigger repo's `.git` directory (required to infer the ADO org for `tools.azure-devops`).
+
+Used by the pipeline's integrity check step to verify the pipeline hasn't been modified outside the compilation process.
+
+## `{{ trigger_repo_directory }}`
+
+Should be replaced with the directory where the trigger ("self") repository is checked out. This is independent of the `workspace:` setting and depends only on whether any additional repositories are listed in `checkout:`:
+- No additional checkouts -> `$(Build.SourcesDirectory)` (ADO checks `self` into the root)
+- One or more additional checkouts -> `$(Build.SourcesDirectory)/$(Build.Repository.Name)` (ADO puts each checked-out repo, including `self`, into a subfolder named after the repository)
+
+Use this marker (rather than `{{ working_directory }}` / `{{ workspace }}`) for any path that refers to a file shipped in the trigger repo (e.g. the agent markdown source) or as a `workingDirectory:` for steps that need access to the trigger repo's `.git` (e.g. the integrity check step).
+
+## `{{ integrity_check }}`
+
+Generates the "Verify pipeline integrity" pipeline step that downloads the released ado-aw compiler and runs `ado-aw check` against the compiled pipeline YAML. This step ensures the pipeline file hasn't been modified outside the compilation process.
+
+The step sets `workingDirectory: {{ trigger_repo_directory }}` so that the relative `{{ pipeline_path }}` argument resolves correctly when `checkout:` produces a multi-repo `$(Build.SourcesDirectory)` layout, and so `ado-aw check`'s internal recompile can infer the ADO org from the trigger repo's git remote.
+
+When the compiler is built with `--skip-integrity` (debug builds only), this placeholder is replaced with an empty string and the integrity step is omitted from the generated pipeline.
+
+## `{{ mcpg_debug_flags }}`
+
+Generates MCPG debug environment flags for the Docker run command. When `--debug-pipeline` is passed (debug builds only), this inserts `-e DEBUG="*"` to enable verbose MCPG logging.
+
+When `--debug-pipeline` is not passed, this placeholder is replaced with a bare `\` to maintain bash line continuation.
+
+## `{{ verify_mcp_backends }}`
+
+Generates a pipeline step that probes each configured MCPG backend with an MCP initialize + tools/list handshake. This forces MCPG's lazy initialization and catches failures (e.g., container timeout, network blocked) before the agent runs, surfacing them as ADO pipeline warnings.
+
+When `--debug-pipeline` is not passed (the default), this placeholder is replaced with an empty string.
+
+## `{{ pr_trigger }}`
+
+Generates PR trigger configuration. When a schedule or pipeline trigger is configured, this generates `pr: none` to disable PR triggers. Otherwise, it generates an empty string, allowing the default PR trigger behavior.
+
+## `{{ ci_trigger }}`
+
+Generates CI trigger configuration. When a schedule or pipeline trigger is configured, this generates `trigger: none` to disable CI triggers. Otherwise, it generates an empty string, allowing the default CI trigger behavior.
+
+## `{{ pipeline_resources }}`
+
+Generates pipeline resource YAML when `triggers.pipeline` is configured in the front matter. Creates a pipeline resource with appropriate trigger configuration based on the specified branches. If no branches are specified, the pipeline triggers on any branch.
+
+Example output when `triggers.pipeline` is configured:
+```yaml
+resources:
+ pipelines:
+ - pipeline: source_pipeline
+ source: Build Pipeline
+ project: OtherProject
+ trigger:
+ branches:
+ include:
+ - main
+ - release/*
+```
+
+## `{{ agent_content }}`
+
+Should be replaced with the markdown body (agent instructions) extracted from the source markdown file, excluding the YAML front matter. This content provides the agent with its task description and guidelines.
+
+## `{{ mcpg_config }}`
+
+Should be replaced with the MCP Gateway (MCPG) configuration JSON generated from the `mcp-servers:` front matter. This configuration defines the MCPG server entries and gateway settings.
+
+The generated JSON has two top-level sections:
+- `mcpServers`: Maps server names to their configuration (type, container/url, tools, etc.)
+- `gateway`: Gateway settings (port, domain, apiKey, payloadDir)
+
+SafeOutputs is always included as an HTTP backend (`type: "http"`) pointing to `localhost` (MCPG runs with `--network host`, so `localhost` is the host loopback). Containerized MCPs with `container:` are included as stdio servers (`type: "stdio"` with `container`, `entrypoint`, `entrypointArgs`). HTTP MCPs with `url:` are included as HTTP servers. MCPs without a container or url are skipped.
+
+Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline at runtime before passing the config to MCPG.
+
+## `{{ mcpg_docker_env }}`
+
+Should be replaced with additional `-e` flags for the MCPG Docker run command, enabling environment variable passthrough from the pipeline to MCP containers.
+
+When `permissions.read` is configured, the compiler automatically adds `-e AZURE_DEVOPS_EXT_PAT="$(SC_READ_TOKEN)"` to forward the ADO access token to MCP containers that need it (e.g., Azure DevOps MCP).
+
+Additionally, any env vars in MCP configs with empty string values (`""`) are collected and forwarded as `-e VAR_NAME` flags, enabling passthrough from the pipeline environment through MCPG to MCP child containers.
+
+Environment variable names are validated against `[A-Za-z_][A-Za-z0-9_]*` to prevent Docker flag injection.
+
+If no passthrough env vars are needed, this marker is replaced with an empty string.
+
+## `{{ mcpg_step_env }}`
+
+Generates an `env:` block for the "Start MCP Gateway (MCPG)" pipeline step, forwarding pipeline variables required by enabled extensions (e.g., `AZURE_DEVOPS_EXT_PAT` when the Azure DevOps MCP tool is configured). The compiler iterates through all active `CompilerExtension` instances, collects their `required_pipeline_vars()` mappings, de-duplicates by variable name, and emits each as `VAR_NAME: $(VAR_NAME)` in ADO variable-reference syntax.
+
+When no extensions require pipeline variables, this marker is replaced with an empty string and the MCPG step has no `env:` block.
+
+## `{{ mcp_client_config }}`
+
+**Removed.** The Copilot CLI `mcp-config.json` is no longer generated at compile time. Instead, it is derived at **pipeline runtime** from MCPG's actual gateway output, matching gh-aw's `convert_gateway_config_copilot.cjs` pattern.
+
+The "Start MCP Gateway (MCPG)" pipeline step:
+1. Redirects MCPG's stdout to `gateway-output.json`
+2. Waits for the health check and for valid JSON output
+3. Transforms the output with a Python script that:
+ - Rewrites URLs from `127.0.0.1` -> `host.docker.internal` (AWF container loopback vs host)
+ - Ensures `tools: ["*"]` on each server entry (Copilot CLI requirement)
+ - Preserves all other fields (headers, type, etc.)
+4. Writes the result to `/tmp/awf-tools/mcp-config.json` and `$HOME/.copilot/mcp-config.json`
+
+This ensures the Copilot CLI config reflects MCPG's actual runtime state rather than a compile-time prediction.
+
+## `{{ allowed_domains }}`
+
+Should be replaced with the comma-separated domain list for AWF's `--allow-domains` flag. The list includes:
+1. Core Azure DevOps/GitHub endpoints (from `allowed_hosts.rs`)
+2. MCP-specific endpoints for each enabled MCP
+3. Engine-required hosts (e.g., `engine.api-target` hostname for GHES/GHEC)
+4. Ecosystem identifier expansions from `network.allowed:` (e.g., `python` -> PyPI/pip domains)
+5. User-specified additional hosts from `network.allowed:` front matter
+
+The output is formatted as a comma-separated string (e.g., `github.com,*.dev.azure.com,api.github.com`).
+
+## `{{ awf_mounts }}`
+
+Replaced with `--mount` flags for the **agent job** AWF invocation only (not the detection job), collected from `CompilerExtension::required_awf_mounts()`. Each extension can declare volume mounts needed inside the AWF chroot as `AwfMount` values (e.g., the Lean runtime mounts `$HOME/.elan` so the elan toolchain is accessible).
+
+When no extensions declare mounts, this is replaced with `\` (a bare bash continuation marker) so the surrounding `\`-continuation chain is preserved. When mounts are present, each is formatted as `--mount "spec" \` on its own line; indentation is handled by `replace_with_indent` at the call site.
+
+AWF replaces `$HOME` with an empty directory overlay for security; only explicitly mounted subdirectories are accessible inside the chroot. Shell variables like `$HOME` are expanded at runtime by bash.
+
+## `{{ awf_path_step }}`
+
+Replaced with a dedicated pipeline step that generates a `GITHUB_PATH` file for AWF chroot PATH discovery. The step is collected from `CompilerExtension::awf_path_prepends()` -- each extension can declare directories that should be on PATH inside the AWF chroot (e.g., the Lean runtime declares `$HOME/.elan/bin`).
+
+AWF reads the `$GITHUB_PATH` environment variable (a path to a file) at startup, reads path entries from it (one per line), and merges them into `AWF_HOST_PATH` which becomes the chroot PATH. This bypasses the `sudo` `secure_path` reset that strips custom PATH entries.
+
+When no extensions declare path prepends, this is replaced with an empty string and the step is omitted.
+
+Example generated step (with Lean enabled):
+
+```yaml
+- bash: |
+ AWF_PATH_FILE="/tmp/awf-tools/ado-path-entries"
+ cat > "$AWF_PATH_FILE" << AWF_PATH_EOF
+ $HOME/.elan/bin
+ AWF_PATH_EOF
+ echo "##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE"
+ displayName: "Generate GITHUB_PATH file"
+```
+
+The heredoc uses an unquoted delimiter so shell variables like `$HOME` are expanded by bash at write time -- AWF reads the file as literal resolved paths and does not perform shell expansion itself.
+
+The `GITHUB_PATH` pipeline variable is also explicitly passed through the AWF step's `env:` block (appended to `{{ engine_env }}`) as `GITHUB_PATH: $(GITHUB_PATH)` for robust environment passthrough.
+
+## `{{ enabled_tools_args }}`
+
+Should be replaced with `--enabled-tools ` CLI arguments for the SafeOutputs MCP HTTP server. The tool list is derived from `safe-outputs:` front matter keys plus always-on diagnostic tools (`noop`, `missing-data`, `missing-tool`, `report-incomplete`).
+
+When `safe-outputs:` is empty (or omitted), this is replaced with an empty string and all tools remain available (backward compatibility). When non-empty, the replacement includes a trailing space to prevent concatenation with the next positional argument in the shell command.
+
+Tool names are validated at compile time:
+- Names must contain only ASCII alphanumerics and hyphens (shell injection prevention)
+- Unrecognized names (not in `ALL_KNOWN_SAFE_OUTPUTS`) emit a warning to catch typos
+
+## `{{ threat_analysis_prompt }}`
+
+Should be replaced with the embedded threat detection analysis prompt from `src/data/threat-analysis.md`. This prompt template includes markers for `{{ source_path }}`, `{{ agent_name }}`, `{{ agent_description }}`, and `{{ working_directory }}` which are replaced during compilation.
+
+The threat analysis prompt instructs the security analysis agent to check for:
+- Prompt injection attempts
+- Secret leaks
+- Malicious patches (suspicious web calls, backdoors, encoded strings, suspicious dependencies)
+
+## `{{ agent_description }}`
+
+Should be replaced with the description field from the front matter. This is used in display contexts and the threat analysis prompt template.
+
+## `{{ acquire_ado_token }}`
+
+Generates an `AzureCLI@2` step that acquires a read-only ADO-scoped access token from the ARM service connection specified in `permissions.read`. This token is used by the agent in Stage 1 (inside the AWF sandbox).
+
+The step:
+- Uses the ARM service connection from `permissions.read`
+- Calls `az account get-access-token` with the ADO resource ID
+- Stores the token in a secret pipeline variable `SC_READ_TOKEN`
+
+If `permissions.read` is not configured, this marker is replaced with an empty string.
+
+## `{{ acquire_write_token }}`
+
+Generates an `AzureCLI@2` step that acquires a write-capable ADO-scoped access token from the ARM service connection specified in `permissions.write`. This token is used only by the executor in Stage 3 (`Execution` job) and is never exposed to the agent.
+
+The step:
+- Uses the ARM service connection from `permissions.write`
+- Calls `az account get-access-token` with the ADO resource ID
+- Stores the token in a secret pipeline variable `SC_WRITE_TOKEN`
+
+If `permissions.write` is not configured, this marker is replaced with an empty string.
+
+## `{{ executor_ado_env }}`
+
+Generates the complete `env:` block (including the `env:` key) for the Stage 3 executor step when `permissions.write` is configured. Sets `SYSTEM_ACCESSTOKEN` to the write service connection token (`SC_WRITE_TOKEN`).
+
+If `permissions.write` is not configured, this marker is replaced with an empty string so that no `env:` block is emitted at all. Note: `System.AccessToken` is never used directly -- all ADO tokens come from explicitly configured service connections.
+
+## `{{ compiler_version }}`
+
+Should be replaced with the version of the `ado-aw` compiler that generated the pipeline (derived from `CARGO_PKG_VERSION` at compile time). This version is used to construct the GitHub Releases download URL for the `ado-aw` binary.
+
+The generated pipelines download the compiler binary from:
+```text
+https://github.com/githubnext/ado-aw/releases/download/v{VERSION}/ado-aw-linux-x64
+```
+
+A `checksums.txt` file is also downloaded and verified via `sha256sum -c checksums.txt --ignore-missing` to ensure binary integrity.
+
+## `{{ firewall_version }}`
+
+Should be replaced with the pinned version of the AWF (Agentic Workflow Firewall) binary (defined as `AWF_VERSION` constant in `src/compile/common.rs`). This version is used to construct the GitHub Releases download URL for the AWF binary.
+
+The generated pipelines download the AWF binary from:
+```text
+https://github.com/github/gh-aw-firewall/releases/download/v{VERSION}/awf-linux-x64
+```
+
+A `checksums.txt` file is also downloaded and verified via `sha256sum -c checksums.txt --ignore-missing` to ensure binary integrity.
+
+## `{{ mcpg_version }}`
+
+Should be replaced with the pinned version of the MCP Gateway (defined as `MCPG_VERSION` constant in `src/compile/common.rs`). Used to tag the MCPG Docker image in the pipeline.
+
+## `{{ mcpg_image }}`
+
+Should be replaced with the MCPG Docker image name (defined as `MCPG_IMAGE` constant in `src/compile/common.rs`). Currently `ghcr.io/github/gh-aw-mcpg`.
+
+## `{{ mcpg_port }}`
+
+Should be replaced with the MCPG listening port (defined as `MCPG_PORT` constant in `src/compile/common.rs`, currently `80`). Used in the pipeline to set the `MCP_GATEWAY_PORT` ADO variable and in the MCPG health-check URL.
+
+## `{{ mcpg_domain }}`
+
+Should be replaced with the domain the AWF-sandboxed agent uses to reach MCPG on the host (defined as `MCPG_DOMAIN` constant in `src/compile/common.rs`, currently `host.docker.internal`). Used in the pipeline to set the `MCP_GATEWAY_DOMAIN` ADO variable. Docker's `host.docker.internal` resolves to the host loopback from inside containers.
+
+## `{{ copilot_version }}`
+
+**Removed.** This marker has been absorbed into `{{ engine_install_steps }}`. The `COPILOT_CLI_VERSION` constant now lives in `src/engine.rs` and is used internally by `Engine::install_steps()`. The version can be overridden per-agent via `engine: { id: copilot, version: "..." }` in front matter.
+
+## 1ES-Specific Template Markers
+
+The 1ES target uses the same template markers as standalone, plus the 1ES-specific `extends:` / `stages:` / `templateContext` wrapping. The 1ES template includes `templateContext.type: buildJob` for all jobs, and the pool is specified at the top-level `parameters.pool` rather than per-job.
+
+Both targets share the same execution model (Copilot CLI + AWF + MCPG) and the same set of template markers.
+
+## Job/Stage Template Markers
+
+The `target: job` and `target: stage` targets use `job-base.yml` and `stage-base.yml`
+respectively. Both include all the standard AWF/MCPG markers above, plus the two
+template-specific markers below.
+
+### `{{ stage_prefix }}`
+
+Replaced with a PascalCase ADO-safe identifier derived from the agent `name:` front
+matter field. Used to prefix the three job names so that including multiple templates
+in the same pipeline produces unique job identifiers.
+
+Derivation rules:
+
+- Non-ASCII-alphanumeric characters are treated as word separators (they are not
+ included in the output).
+- Each word is capitalised and the words are concatenated: `"daily code review"` ->
+ `"DailyCodeReview"`.
+- An empty result (all characters stripped) falls back to `"Agent"`.
+- A result starting with a digit is prefixed with `_`: `"123start"` -> `"_123start"`.
+- Names containing non-ASCII alphanumeric characters (e.g. `"über-agent"`) produce a
+ compiler warning because those characters are silently dropped.
+
+Example job names produced for `name: Daily Code Review`:
+
+```yaml
+jobs:
+ - job: DailyCodeReview_Agent
+ - job: DailyCodeReview_Detection
+ dependsOn: DailyCodeReview_Agent
+ - job: DailyCodeReview_Execution
+ dependsOn: [DailyCodeReview_Agent, DailyCodeReview_Detection]
+```
+
+### `{{ template_parameters }}`
+
+Replaced with the `parameters:` block that callers pass when including the template.
+Contains `clearMemory` (auto-injected when `tools.cache-memory` is configured) and any
+user-defined `parameters:` from front matter. Replaced with an empty string when no
+parameters are needed.
+
+Example output when `tools.cache-memory` is configured:
+
+```yaml
+parameters:
+- name: clearMemory
+ displayName: Clear agent memory
+ type: boolean
+ default: false
+```
diff --git a/docs-site/src/content/docs/reference/tools.mdx b/docs-site/src/content/docs/reference/tools.mdx
new file mode 100644
index 00000000..a6ae3f2a
--- /dev/null
+++ b/docs-site/src/content/docs/reference/tools.mdx
@@ -0,0 +1,88 @@
+---
+title: "Tools configuration"
+description: "Reference for the tools field, including bash access, file editing, cache memory, and Azure DevOps MCP integration."
+---
+
+## Tools Configuration
+
+The `tools` field controls which tools are available to the agent. Both sub-fields are optional and have sensible defaults.
+
+### Default Bash Command Allow-list
+
+When `tools.bash` is omitted, the agent defaults to **unrestricted bash access** (`--allow-all-tools`). This matches gh-aw's sandbox behavior -- since ado-aw agents always run inside the AWF sandbox, all tools are allowed by default.
+
+### Configuring Bash Access
+
+```yaml
+# Default: unrestricted bash access (bash field omitted -> --allow-all-tools)
+tools:
+ edit: true
+
+# Explicit unrestricted bash (same as default) -- also accepts "*"
+tools:
+ bash: [":*"]
+
+# Explicit command allow-list (restricts to named commands only)
+tools:
+ bash: ["cat", "ls", "grep", "find"]
+
+# Disable bash entirely (empty list)
+tools:
+ bash: []
+```
+
+### Disabling File Writes
+
+By default, the `edit` tool (file writing) is enabled. To disable it:
+
+```yaml
+tools:
+ edit: false
+```
+
+### Cache Memory (`cache-memory:`)
+
+Persistent memory storage across agent runs. The agent reads/writes files to a memory directory that persists between pipeline executions via pipeline artifacts.
+
+```yaml
+# Simple enablement
+tools:
+ cache-memory: true
+
+# With options
+tools:
+ cache-memory:
+ allowed-extensions: [.md, .json, .txt]
+```
+
+When enabled, the compiler auto-generates pipeline steps to:
+- Download previous memory from the last successful run's artifact
+- Restore files to `/tmp/awf-tools/staging/agent_memory/`
+- Append a memory prompt to the agent instructions
+- Auto-inject a `clearMemory` pipeline parameter (allows clearing memory from the ADO UI)
+
+During Stage 3 execution, memory files are validated (path safety, extension filtering, `##vso[` injection detection, 5 MB size limit) and published as a pipeline artifact.
+
+### Azure DevOps MCP (`azure-devops:`)
+
+First-class Azure DevOps MCP integration. Auto-configures the ADO MCP container, token mapping, MCPG entry, and network allowlist.
+
+```yaml
+# Simple enablement (auto-infers org from git remote)
+tools:
+ azure-devops: true
+
+# With scoping options
+tools:
+ azure-devops:
+ toolsets: [repos, wit, core] # ADO API toolset groups
+ allowed: [wit_get_work_item, core_list_projects] # Explicit tool allow-list
+ org: myorg # Optional override (inferred from git remote)
+```
+
+When enabled, the compiler:
+- Generates a containerized stdio MCP entry (`node:20-slim` + `npx @azure-devops/mcp`) in the MCPG config
+- Auto-maps `AZURE_DEVOPS_EXT_PAT` token passthrough when `permissions.read` is configured
+- Adds ADO-specific hosts to the network allowlist
+- Auto-infers org from the git remote URL at compile time (overridable via `org:` field)
+- Fails compilation if org cannot be determined (no explicit override and no ADO git remote)
diff --git a/docs-site/src/content/docs/setup/cli.mdx b/docs-site/src/content/docs/setup/cli.mdx
new file mode 100644
index 00000000..1683ed23
--- /dev/null
+++ b/docs-site/src/content/docs/setup/cli.mdx
@@ -0,0 +1,129 @@
+---
+title: CLI Commands
+description: Reference for the ado-aw command-line interface
+sidebar:
+ order: 2
+---
+
+The `ado-aw` CLI provides commands for initializing repositories, compiling pipelines, running MCP servers, executing safe outputs, and configuring Azure DevOps definitions.
+
+## Global flags
+
+These flags apply to all commands:
+
+- `--verbose`, `-v` -- enable more detailed logging
+- `--debug`, `-d` -- enable debug logging
+- `--log-output-dir ` -- write log files to a specific directory
+
+## Commands
+
+### `init`
+
+Initialize a repository for AI-first agentic pipeline authoring.
+
+```bash
+ado-aw init [--path ] [--force]
+```
+
+Options:
+
+- `--path ` -- target directory
+- `--force` -- continue even when repository checks would normally block initialization
+
+### `compile []`
+
+Compile markdown into Azure DevOps pipeline YAML. If you omit the path, `ado-aw` auto-discovers agentic pipeline sources in the current directory.
+
+```bash
+ado-aw compile [] [--output ]
+```
+
+Options:
+
+- `--output`, `-o ` -- write the generated YAML to a specific file or directory
+- `--skip-integrity` -- debug-only option to skip the generated integrity check step
+- `--debug-pipeline` -- debug-only option to include extra pipeline diagnostics
+
+### `check `
+
+Verify that a compiled pipeline still matches its source markdown.
+
+```bash
+ado-aw check
+```
+
+### `mcp `
+
+Run SafeOutputs as a stdio MCP server.
+
+```bash
+ado-aw mcp [--enabled-tools ]
+```
+
+Options:
+
+- `--enabled-tools ` -- limit the server to specific tools; repeat to allow more than one
+
+### `mcp-http `
+
+Run SafeOutputs as an HTTP MCP server.
+
+```bash
+ado-aw mcp-http [options]
+```
+
+Options:
+
+- `--port ` -- port to listen on
+- `--api-key ` -- API key used to authenticate requests
+- `--enabled-tools ` -- limit the server to specific tools; repeat to allow more than one
+
+### `execute`
+
+Execute safe outputs as the final runtime stage.
+
+```bash
+ado-aw execute [options]
+```
+
+Options:
+
+- `--source`, `-s ` -- source markdown file
+- `--safe-output-dir ` -- directory containing safe output records
+- `--output-dir ` -- directory for processed artifacts
+- `--ado-org-url ` -- Azure DevOps organization URL override
+- `--ado-project ` -- Azure DevOps project name override
+- `--dry-run` -- validate inputs without making write calls
+
+### `configure`
+
+Detect agentic pipelines in a local repository and update the `GITHUB_TOKEN` pipeline variable on their Azure DevOps build definitions. The Copilot CLI engine needs this token to authenticate with GitHub.
+
+For Azure DevOps API authentication, the command first tries the **Azure CLI** (`az` login session) and falls back to prompting for a PAT if unavailable.
+
+```bash
+ado-aw configure [options]
+```
+
+Options:
+
+- `--token ` -- GitHub PAT value to apply (prompted if omitted). Must be a [fine-grained PAT](/ado-aw/setup/quick-start/#github-pat-permissions) with **Copilot Requests: Read** permission.
+- `--org ` -- Azure DevOps organization URL
+- `--project ` -- Azure DevOps project name
+- `--pat ` -- PAT used for Azure DevOps API authentication (fallback if Azure CLI is unavailable)
+- `--path ` -- repository path to inspect
+- `--dry-run` -- preview changes without applying them
+- `--definition-ids ` -- comma-separated definition IDs to update
+
+## Common examples
+
+```bash
+# Compile one source file
+ado-aw compile agent.md
+
+# Recompile all detected pipelines in the current directory
+ado-aw compile
+
+# Verify a generated pipeline
+ado-aw check agent.lock.yml
+```
diff --git a/docs-site/src/content/docs/setup/local-development.mdx b/docs-site/src/content/docs/setup/local-development.mdx
new file mode 100644
index 00000000..d7bc1407
--- /dev/null
+++ b/docs-site/src/content/docs/setup/local-development.mdx
@@ -0,0 +1,81 @@
+---
+title: Local Development
+description: Set up a local ado-aw development environment and test changes from source
+sidebar:
+ order: 3
+---
+
+If you want to work on `ado-aw` itself, a local Rust-based development setup is enough for most compiler and documentation tasks.
+
+## Prerequisites
+
+- **Rust 1.94.0 or later** (the project uses the Rust 2024 edition)
+- Git
+- An editor of your choice
+
+Install or update Rust:
+
+```bash
+rustup toolchain install stable
+rustup default stable
+rustc --version # must be 1.94.0 or later
+```
+
+## Build from source
+
+From the repository root:
+
+```bash
+cargo build
+```
+
+For an optimized build:
+
+```bash
+cargo build --release
+```
+
+## Run the test suite
+
+Use the standard Rust workflow while developing:
+
+```bash
+cargo test
+cargo clippy
+```
+
+These commands help catch regressions and style issues before you commit changes.
+
+## Test compilation locally
+
+A simple way to validate changes is to compile an example or your own agent file.
+
+```bash
+cargo run -- compile path/to/agent.md
+```
+
+To verify the generated YAML matches the source definition:
+
+```bash
+cargo run -- check path/to/agent.lock.yml
+```
+
+## Iterate on documentation or compiler changes
+
+A common local loop looks like this:
+
+```bash
+cargo build
+cargo test
+cargo run -- compile path/to/agent.md
+cargo run -- check path/to/agent.lock.yml
+```
+
+If you are working on the docs site itself, run the site from `docs-site/` with your usual Astro workflow after installing dependencies.
+
+## What to verify before submitting changes
+
+- the project builds successfully
+- tests pass
+- your sample pipeline compiles cleanly
+- generated YAML matches the markdown source when checked
diff --git a/docs-site/src/content/docs/setup/quick-start.mdx b/docs-site/src/content/docs/setup/quick-start.mdx
new file mode 100644
index 00000000..a470be14
--- /dev/null
+++ b/docs-site/src/content/docs/setup/quick-start.mdx
@@ -0,0 +1,178 @@
+---
+title: Quick Start
+description: Get your first ado-aw agentic pipeline running in minutes
+sidebar:
+ order: 1
+---
+
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+
+There are two ways to get started with `ado-aw` -- using a Copilot agent to co-create your first workflow interactively, or writing the agent file manually.
+
+## Install ado-aw
+
+Download the latest release for your platform from [GitHub Releases](https://github.com/githubnext/ado-aw/releases/latest).
+
+
+
+ ```bash
+ curl -fSL "https://github.com/githubnext/ado-aw/releases/latest/download/ado-aw-linux-x64" \
+ -o ado-aw
+ chmod +x ado-aw
+ sudo mv ado-aw /usr/local/bin/
+ ```
+
+
+ ```bash
+ curl -fSL "https://github.com/githubnext/ado-aw/releases/latest/download/ado-aw-darwin-arm64" \
+ -o ado-aw
+ chmod +x ado-aw
+ sudo mv ado-aw /usr/local/bin/
+ ```
+
+
+ ```powershell
+ Invoke-WebRequest `
+ -Uri "https://github.com/githubnext/ado-aw/releases/latest/download/ado-aw-windows-x64.exe" `
+ -OutFile ado-aw.exe
+ Move-Item ado-aw.exe "$env:LOCALAPPDATA\Microsoft\WindowsApps\"
+ ```
+
+
+
+:::tip[Pinning a version]
+Replace `latest` in the URL with a tag name (e.g., `download/v0.19.0/ado-aw-linux-x64`) to pin a specific version.
+:::
+
+---
+
+## With Agents (recommended)
+
+The fastest way to create an agentic pipeline is to let Copilot do the heavy lifting.
+
+### 1. Initialize your project
+
+In your Azure DevOps repository root, run:
+
+```bash
+ado-aw init
+```
+
+This creates `.github/agents/ado-aw.agent.md` -- a Copilot dispatcher agent that knows how to create, update, and debug agentic pipelines. It auto-downloads the `ado-aw` compiler and handles the full lifecycle.
+
+### 2. Co-create a workflow with Copilot
+
+Open your project in an editor with Copilot and invoke the agent:
+
+```
+/agent ado-aw
+```
+
+Describe what you want your agentic pipeline to do. For example:
+
+> *"Create a workflow that runs daily, reads open work items tagged 'stale', and adds a reminder comment."*
+
+The agent will walk you through the front-matter configuration, write the agent prompt, and compile the pipeline -- all interactively.
+
+### 3. Push and configure
+
+Once you're happy with the generated files, commit and push them to your Azure DevOps repository. Then create a pipeline in Azure DevOps pointing at the compiled `.lock.yml` file, and configure it:
+
+```bash
+ado-aw configure
+```
+
+This sets the `GITHUB_TOKEN` pipeline variable on the ADO build definition. The command prompts for:
+- A **GitHub Personal Access Token (PAT)** -- used by the Copilot CLI at runtime (see [required permissions](#github-pat-permissions) below)
+- For Azure DevOps authentication, the command first tries the **Azure CLI** (`az` login session). If the Azure CLI is not available or not logged in, it falls back to prompting for an **Azure DevOps PAT**.
+
+:::caution[Temporary limitation: GitHub PAT required]
+The Copilot CLI does not yet support GitHub App tokens. You must provide a fine-grained GitHub PAT. This requirement will be removed once GitHub App token support is added to the Copilot CLI.
+:::
+
+Run the pipeline in Azure DevOps -- it executes the three-stage workflow: Agent -> Detection -> Execution.
+
+---
+
+## Manual
+
+If you prefer full control, you can author agent files by hand.
+
+### 1. Create an agent file
+
+Create a file named `agent.md`:
+
+```markdown
+---
+name: Hello from ado-aw
+description: A minimal agentic pipeline example
+engine:
+ id: copilot
+on:
+ workflow_dispatch:
+pool: AZS-1ES-L-MMS-ubuntu-22.04
+---
+
+## Instructions
+
+Inspect the repository and summarize what this project does.
+```
+
+This file combines YAML front matter for configuration with markdown instructions for the agent.
+
+### 2. Compile it
+
+```bash
+ado-aw compile agent.md
+```
+
+This writes a compiled `.lock.yml` pipeline alongside the source file. You should now have:
+
+- `agent.md` -- your source definition
+- `agent.lock.yml` -- the compiled Azure DevOps pipeline
+
+### 3. Push to Azure DevOps
+
+1. Commit both `agent.md` and the compiled `.lock.yml` file.
+2. Push them to your Azure DevOps repository.
+3. In Azure DevOps, create a pipeline that points at the compiled YAML file.
+4. Save the pipeline.
+
+### 4. Configure the pipeline
+
+```bash
+ado-aw configure
+```
+
+This sets the `GITHUB_TOKEN` pipeline variable. See the [With Agents](#3-push-and-configure) section above for details on what the command prompts for and the current GitHub PAT limitation.
+
+### 5. Run the pipeline
+
+Back in Azure DevOps, run the pipeline. It executes the compiled three-stage workflow: Agent -> Detection -> Execution.
+
+---
+
+## GitHub PAT permissions
+
+The Copilot CLI engine requires a **fine-grained GitHub Personal Access Token** to authenticate. [Create one here](https://github.com/settings/personal-access-tokens/new) with the following settings:
+
+| Setting | Value |
+|---------|-------|
+| **Resource owner** | Your **personal user account** (not an organization) |
+| **Permissions** | Account permissions -> **Copilot Requests: Read** |
+
+No repository permissions are needed -- the token is only used for Copilot inference.
+
+:::caution[Temporary limitation]
+GitHub App tokens and OAuth tokens are **not supported** for this secret. Only fine-grained PATs work. The token owner's account must have an active Copilot license.
+:::
+
+The token is stored as the `GITHUB_TOKEN` pipeline variable on your Azure DevOps build definition (set by `ado-aw configure`). It is never exposed to the agent -- only the pipeline runtime uses it.
+
+---
+
+## Next steps
+
+- Learn the available commands in [CLI Commands](/ado-aw/setup/cli/)
+- Read [How It Works](/ado-aw/introduction/how-it-works/)
+- Explore the [Creating Agents](/ado-aw/guides/creating-agents/) guide for the full agent file format
diff --git a/docs-site/src/content/docs/troubleshooting/common-issues.mdx b/docs-site/src/content/docs/troubleshooting/common-issues.mdx
new file mode 100644
index 00000000..ea242e3f
--- /dev/null
+++ b/docs-site/src/content/docs/troubleshooting/common-issues.mdx
@@ -0,0 +1,81 @@
+---
+title: Common Issues
+description: Solutions to frequently encountered problems with ado-aw
+---
+
+## Pipeline Compilation Errors
+
+### "Source markdown not found"
+
+The `check` command couldn't locate the source `.md` file referenced in the compiled pipeline's `@ado-aw` header.
+
+**Solution:** Ensure the markdown source file hasn't been moved or renamed since compilation. Recompile with `ado-aw compile`.
+
+### Front matter parse errors
+
+YAML front matter must be valid YAML between `---` delimiters.
+
+**Common causes:**
+- Unquoted strings containing special YAML characters (`:`, `#`, `{`, `}`)
+- Incorrect indentation (YAML uses spaces, not tabs)
+- Missing required fields (`name`, `description`)
+
+```yaml
+---
+# ✗ Bad -- colon in unquoted string
+description: Agent: does things
+
+# ✓ Good -- quoted string
+description: "Agent: does things"
+---
+```
+
+## Runtime Errors
+
+### Network timeouts in AWF sandbox
+
+The agent can only access explicitly allowed domains. If your agent needs to reach an external service:
+
+1. Check if a runtime enables the domain (e.g., `runtimes: [python]` enables `pypi.org`)
+2. Add custom domains via `allowed-hosts:` in front matter
+3. For broader access, use ADO `permissions:` with a service connection
+
+### Safe output validation failures
+
+Stage 2 (Detection) may reject safe outputs that appear to contain:
+- Prompt injection patterns
+- Embedded secrets or tokens
+- Malformed parameters
+
+**Solution:** Review the agent's output proposals. Ensure generated content doesn't accidentally include patterns that resemble secrets (long base64 strings, key-value patterns matching `password=...`).
+
+## Build & Development
+
+### `cargo build` fails with missing dependencies
+
+Ensure you're using the Rust 2024 edition toolchain:
+
+```bash
+rustup update
+rustup default stable
+```
+
+### `cargo test` hangs
+
+Some tests require `shellcheck` to be installed. If missing, tests are skipped (unless `ENFORCE_BASH_LINT=1` is set).
+
+```bash
+# macOS
+brew install shellcheck
+
+# Ubuntu/Debian
+sudo apt-get install -y shellcheck
+```
+
+## Getting Help
+
+If your issue isn't covered here:
+
+1. Check the [GitHub Issues](https://github.com/githubnext/ado-aw/issues)
+2. Review the [Reference documentation](/ado-aw/reference/front-matter/) for field specifications
+3. Run with `--debug` for verbose logging: `ado-aw --debug compile ./agent.md`
diff --git a/docs-site/src/styles/custom.css b/docs-site/src/styles/custom.css
new file mode 100644
index 00000000..2c578a09
--- /dev/null
+++ b/docs-site/src/styles/custom.css
@@ -0,0 +1,158 @@
+/* ado-aw docs — custom Starlight theme */
+
+/* Fonts */
+@font-face {
+ font-family: 'Mona Sans';
+ src: url('/ado-aw/fonts/Mona-Sans.woff2') format('woff2');
+ font-weight: 200 900;
+ font-style: normal;
+ font-display: swap;
+}
+
+:root {
+ --sl-font: 'Mona Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+ Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+}
+
+/* Dark mode (default) */
+:root {
+ --sl-color-accent-low: #6639ba;
+ --sl-color-accent: #d896ff;
+ --sl-color-accent-high: #e5c7ff;
+ --sl-color-text-accent: #d896ff;
+ --sl-color-black: #010409;
+ --sl-color-text: #f0f6fc;
+ --sl-color-bg: #0d1117;
+ --sl-color-bg-sidebar: #010409;
+ --sl-color-bg-nav: #161b22;
+ --sl-color-hairline-light: #21262d;
+ --sl-color-hairline: #30363d;
+}
+
+/* Light mode */
+[data-theme='light'] {
+ --sl-color-accent-low: #ede5f7;
+ --sl-color-accent: #6b46c1;
+ --sl-color-accent-high: #4c2889;
+ --sl-color-text-accent: #6b46c1;
+ --sl-color-black: #1f2328;
+ --sl-color-text: #1f2328;
+ --sl-color-bg: #ffffff;
+ --sl-color-bg-sidebar: #f6f8fa;
+ --sl-color-bg-nav: #f6f8fa;
+ --sl-color-hairline-light: #d1d9e0;
+ --sl-color-hairline: #d1d9e0;
+}
+
+/* Hero section styling */
+.hero {
+ text-align: center;
+ padding: 3rem 1rem;
+}
+
+.hero h1 {
+ font-size: 2.5rem;
+ font-weight: 800;
+ background: linear-gradient(135deg, var(--sl-color-accent), #a855f7);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+/* Card styling -- good contrast in both modes */
+.card {
+ border: 1px solid var(--sl-color-hairline);
+ border-radius: 0.75rem;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.card:hover {
+ border-color: var(--sl-color-accent);
+ box-shadow: 0 0 0 1px var(--sl-color-accent);
+}
+
+/* Dark mode cards */
+:root .card {
+ background: #161b22;
+ border-color: #30363d;
+}
+
+:root .card .title {
+ color: #f0f6fc;
+}
+
+:root .card .icon {
+ color: #d896ff;
+}
+
+/* Light mode cards */
+[data-theme='light'] .card {
+ background: #ffffff;
+ border-color: #d1d9e0;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+[data-theme='light'] .card:hover {
+ background: #faf8ff;
+ border-color: #6b46c1;
+ box-shadow: 0 2px 8px rgba(107, 70, 193, 0.12);
+}
+
+[data-theme='light'] .card .title {
+ color: #1f2328;
+}
+
+[data-theme='light'] .card .icon {
+ color: #1f2328;
+}
+
+[data-theme='light'] .card .body {
+ color: #424a53;
+}
+
+/* Hero gradient for light mode */
+[data-theme='light'] .hero h1 {
+ background: linear-gradient(135deg, #6b46c1, #7c3aed);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+/* Search box — consistent display on splash pages */
+[data-has-hero] .search-input,
+[data-has-hero] site-search button {
+ border: 1px solid var(--sl-color-hairline);
+ border-radius: 0.5rem;
+}
+
+[data-theme='light'] site-search button {
+ background: #ffffff;
+ border-color: #d1d9e0;
+ color: #424a53;
+}
+
+[data-theme='light'] site-search button:hover {
+ border-color: #6b46c1;
+}
+
+/* Code blocks */
+pre {
+ border: 1px solid var(--sl-color-hairline);
+ border-radius: 0.5rem;
+}
+
+/* Table styling */
+table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+th {
+ background: var(--sl-color-bg-nav);
+ font-weight: 600;
+}
+
+td, th {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--sl-color-hairline);
+}
diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json
new file mode 100644
index 00000000..bcbf8b50
--- /dev/null
+++ b/docs-site/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "astro/tsconfigs/strict"
+}
diff --git a/docs/cli.md b/docs/cli.md
index b3de7970..8ce80310 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -15,6 +15,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
- `--output, -o ` - Optional output path for the generated YAML (only valid when a path is provided). If the path is an existing directory, the compiled YAML is written inside that directory using the default filename derived from the markdown source (e.g. `foo.md` → `/foo.lock.yml`).
- `--skip-integrity` - *(debug builds only)* Omit the "Verify pipeline integrity" step from the generated pipeline. Useful during local development when the compiled output won't match a released compiler version. This flag is not available in release builds.
- `--debug-pipeline` - *(debug builds only)* Include MCPG debug diagnostics in the generated pipeline: `DEBUG=*` environment variable for verbose MCPG logging, stderr streaming to log files, and a "Verify MCP backends" step that probes each backend with MCP initialize + tools/list before the agent runs. This flag is not available in release builds.
+ - For `target: job` and `target: stage`, the output is an ADO YAML template (not a complete pipeline). Job names are prefixed with the agent name for uniqueness. Triggers configured via `on:` are ignored with a warning.
- `check ` - Verify that a compiled pipeline matches its source markdown
- `` - Path to the pipeline YAML file to verify
- The source markdown path is auto-detected from the `@ado-aw` header in the pipeline file
diff --git a/docs/front-matter.md b/docs/front-matter.md
index d26c2884..0beb8d8b 100644
--- a/docs/front-matter.md
+++ b/docs/front-matter.md
@@ -10,7 +10,7 @@ The compiler expects markdown files with YAML front matter similar to gh-aw:
---
name: "name for this agent"
description: "One line description for this agent"
-target: standalone # Optional: "standalone" (default) or "1es". See docs/targets.md.
+target: standalone # Optional: "standalone" (default), "1es", "job", or "stage". See docs/targets.md.
engine: copilot # Engine identifier. Defaults to copilot. Currently only 'copilot' (GitHub Copilot CLI) is supported.
# engine: # Alternative object format (with additional options)
# id: copilot
diff --git a/docs/targets.md b/docs/targets.md
index 20074305..f75dbe73 100644
--- a/docs/targets.md
+++ b/docs/targets.md
@@ -31,3 +31,79 @@ target: 1es
```
When using `target: 1es`, the pipeline will extend `1es/1ES.Unofficial.PipelineTemplate.yml@1ESPipelinesTemplates`.
+
+### `job`
+
+Generates a **job-level ADO YAML template** with `jobs:` at root. This is a
+reusable template that can be included in an existing pipeline — it does not
+generate a complete pipeline.
+
+The output contains the same 3-job chain (Agent → Detection → Execution) as
+`standalone`, with:
+- Job names prefixed with the agent name for uniqueness (e.g., `DailyReview_Agent`)
+- No triggers, pipeline name, or resource declarations (the parent pipeline owns those)
+- Pool baked in from the front matter `pool:` field
+
+Example front matter:
+```yaml
+target: job
+```
+
+#### Usage in a flat pipeline
+
+```yaml
+jobs:
+ - job: Build
+ steps: ...
+ - template: agents/review.lock.yml
+```
+
+#### Usage inside a user-defined stage
+
+```yaml
+stages:
+ - stage: Build
+ jobs: ...
+ - stage: AgenticReview
+ dependsOn: Build
+ jobs:
+ - template: agents/review.lock.yml
+```
+
+#### Notes
+
+- Triggers (`on:`) are ignored with a warning (the parent pipeline controls triggers)
+- If the agent declares additional repositories via `repos:`, add them to the
+ parent pipeline's `resources:` block (documented in the generated file header)
+
+### `stage`
+
+Generates a **stage-level ADO YAML template** with `stages:` at root. This
+wraps the 3-job chain inside a stage block for direct inclusion in multi-stage
+pipelines.
+
+Example front matter:
+```yaml
+target: stage
+```
+
+#### Usage
+
+```yaml
+stages:
+ - stage: Build
+ jobs: ...
+ - template: agents/review.lock.yml
+ dependsOn: Build
+ condition: succeeded()
+```
+
+ADO natively supports `dependsOn` and `condition` at the template call site —
+no template parameters are needed for stage ordering.
+
+#### Notes
+
+- Same 3-job chain, job-name prefixing, and pool handling as `target: job`
+- Triggers (`on:`) are ignored with a warning
+- If the agent declares additional repositories via `repos:`, add them to the
+ parent pipeline's `resources:` block
diff --git a/docs/template-markers.md b/docs/template-markers.md
index a5fe6dae..6c28551a 100644
--- a/docs/template-markers.md
+++ b/docs/template-markers.md
@@ -8,6 +8,8 @@ The compiler transforms the input into valid Azure DevOps pipeline YAML based on
- **Standalone**: Uses `src/data/base.yml`
- **1ES**: Uses `src/data/1es-base.yml`
+- **Job template**: Uses `src/data/job-base.yml`
+- **Stage template**: Uses `src/data/stage-base.yml`
Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ engine_run }}` denotes the full engine invocation command. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template).
@@ -495,3 +497,54 @@ Should be replaced with the domain the AWF-sandboxed agent uses to reach MCPG on
The 1ES target uses the same template markers as standalone, plus the 1ES-specific `extends:` / `stages:` / `templateContext` wrapping. The 1ES template includes `templateContext.type: buildJob` for all jobs, and the pool is specified at the top-level `parameters.pool` rather than per-job.
Both targets share the same execution model (Copilot CLI + AWF + MCPG) and the same set of template markers.
+
+## Job/Stage Template Markers
+
+The `target: job` and `target: stage` targets use `job-base.yml` and `stage-base.yml`
+respectively. Both include all the standard AWF/MCPG markers above, plus the two
+template-specific markers below.
+
+### {{ stage_prefix }}
+
+Replaced with a PascalCase ADO-safe identifier derived from the agent `name:` front
+matter field. Used to prefix the three job names so that including multiple templates
+in the same pipeline produces unique job identifiers.
+
+Derivation rules:
+
+- Non-ASCII-alphanumeric characters are treated as word separators (they are not
+ included in the output).
+- Each word is capitalised and the words are concatenated: `"daily code review"` →
+ `"DailyCodeReview"`.
+- An empty result (all characters stripped) falls back to `"Agent"`.
+- A result starting with a digit is prefixed with `_`: `"123start"` → `"_123start"`.
+- Names containing non-ASCII alphanumeric characters (e.g. `"über-agent"`) produce a
+ compiler warning because those characters are silently dropped.
+
+Example job names produced for `name: Daily Code Review`:
+
+```yaml
+jobs:
+ - job: DailyCodeReview_Agent
+ - job: DailyCodeReview_Detection
+ dependsOn: DailyCodeReview_Agent
+ - job: DailyCodeReview_Execution
+ dependsOn: [DailyCodeReview_Agent, DailyCodeReview_Detection]
+```
+
+### {{ template_parameters }}
+
+Replaced with the `parameters:` block that callers pass when including the template.
+Contains `clearMemory` (auto-injected when `tools.cache-memory` is configured) and any
+user-defined `parameters:` from front matter. Replaced with an empty string when no
+parameters are needed.
+
+Example output when `tools.cache-memory` is configured:
+
+```yaml
+parameters:
+- name: clearMemory
+ displayName: Clear agent memory
+ type: boolean
+ default: false
+```
diff --git a/src/compile/common.rs b/src/compile/common.rs
index 9aed8350..71d46370 100644
--- a/src/compile/common.rs
+++ b/src/compile/common.rs
@@ -999,6 +999,73 @@ pub fn sanitize_filename(name: &str) -> String {
/// Default pool name
pub const DEFAULT_POOL: &str = "AZS-1ES-L-MMS-ubuntu-22.04";
+/// Derive a valid ADO identifier from the agent name for use as a job-name
+/// prefix and stage name. Converts to PascalCase, stripping non-alphanumeric
+/// characters.
+///
+/// Examples:
+/// - `"Daily Code Review"` → `"DailyCodeReview"`
+/// - `"my-agent-123"` → `"MyAgent123"`
+/// - `""` → `"Agent"` (fallback)
+/// - `"123start"` → `"_123start"` (prefix underscore for leading digit)
+/// - `"über-agent"` → `"BerAgent"` (non-ASCII stripped; ADO requires `[A-Za-z0-9_]`)
+pub fn generate_stage_prefix(name: &str) -> String {
+ // Warn if any Unicode alphanumeric characters are present — they will be
+ // treated as word-separator boundaries and stripped from the output, which
+ // may surprise users whose agent name starts with a non-ASCII letter.
+ if name.chars().any(|c| c.is_alphanumeric() && !c.is_ascii_alphanumeric()) {
+ log::warn!(
+ "Agent name '{}' contains non-ASCII alphanumeric characters; \
+ these are dropped from the ADO job-name prefix because ADO identifiers \
+ require [A-Za-z0-9_]. Rename the agent to use ASCII characters only \
+ if the prefix is important.",
+ name
+ );
+ }
+
+ let pascal: String = name
+ .split(|c: char| !c.is_ascii_alphanumeric())
+ .filter(|s| !s.is_empty())
+ .map(|word| {
+ let mut chars = word.chars();
+ match chars.next() {
+ None => String::new(),
+ Some(first) => {
+ let upper = first.to_uppercase().to_string();
+ upper + chars.as_str()
+ }
+ }
+ })
+ .collect();
+
+ if pascal.is_empty() {
+ "Agent".to_string()
+ } else if pascal.starts_with(|c: char| c.is_ascii_digit()) {
+ format!("_{}", pascal)
+ } else {
+ pascal
+ }
+}
+
+/// Generate the template-level `parameters:` YAML block for job/stage
+/// template targets.
+///
+/// Includes clearMemory (if cache-memory enabled) and user-defined
+/// parameters from front matter. Returns empty string if no parameters
+/// are needed.
+pub fn generate_template_parameters(front_matter: &FrontMatter) -> Result {
+ let has_memory = front_matter
+ .tools
+ .as_ref()
+ .and_then(|t| t.cache_memory.as_ref())
+ .is_some_and(|cm| cm.is_enabled());
+ let params = build_parameters(&front_matter.parameters, has_memory);
+ if params.is_empty() {
+ return Ok(String::new());
+ }
+ generate_parameters(¶ms)
+}
+
/// Version of the AWF (Agentic Workflow Firewall) binary to download from GitHub Releases.
/// Update this when upgrading to a new AWF release.
/// See: https://github.com/github/gh-aw-firewall/releases
@@ -2423,6 +2490,23 @@ pub struct CompileConfig {
/// to append `GITHUB_PATH: $(GITHUB_PATH)` to the engine env block without
/// re-collecting path prepends from extensions.
pub has_awf_paths: bool,
+ /// When true, `compile_shared` omits the standard `# @ado-aw` header.
+ /// Template-producing compilers (Job, Stage) set this to prepend their
+ /// own custom header with usage instructions.
+ pub skip_header: bool,
+}
+
+/// Input configuration for [`compile_template_target`].
+///
+/// Groups the template-specific settings so that the function stays within
+/// the seven-argument limit while remaining easy to extend.
+pub struct TemplateTargetConfig<'a> {
+ /// Raw YAML template string (e.g. `job-base.yml` or `stage-base.yml`).
+ pub template: &'a str,
+ /// When true, the "Verify pipeline integrity" step is omitted.
+ pub skip_integrity: bool,
+ /// When true, MCPG debug diagnostics are included in the generated pipeline.
+ pub debug_pipeline: bool,
}
/// Shared compilation flow used by both standalone and 1ES compilers.
@@ -2704,9 +2788,92 @@ pub async fn compile_shared(
replace_with_indent(&yaml, placeholder, replacement)
});
- // 15. Prepend header
- let header = generate_header_comment(input_path);
- Ok(format!("{}{}", header, pipeline_yaml))
+ // 15. Prepend header (unless the caller will prepend its own)
+ if config.skip_header {
+ Ok(pipeline_yaml)
+ } else {
+ let header = generate_header_comment(input_path);
+ Ok(format!("{}{}", header, pipeline_yaml))
+ }
+}
+
+/// Shared compilation flow for template-producing compilers (`target: job` and
+/// `target: stage`).
+///
+/// Handles the full setup — collecting extensions, building the compile context,
+/// generating the stage prefix and template parameters, computing AWF/MCPG
+/// values — and delegates to [`compile_shared`]. The caller supplies:
+///
+/// - `cfg`: target-specific settings (template string, integrity / debug flags).
+/// - `header_fn`: a function that generates the leading comment block prepended
+/// to the compiled YAML. The two template compilers use different header
+/// layouts, so this lets each compiler keep its own generator while sharing
+/// all of the boilerplate setup.
+///
+/// Returns the final YAML string with the header prepended.
+pub async fn compile_template_target(
+ input_path: &Path,
+ output_path: &Path,
+ front_matter: &FrontMatter,
+ markdown_body: &str,
+ cfg: TemplateTargetConfig<'_>,
+ header_fn: impl FnOnce(&Path, &Path, &FrontMatter) -> String,
+) -> Result {
+ // Collect extensions (needed before compile_shared for MCPG config)
+ let extensions = super::extensions::collect_extensions(front_matter);
+
+ // Build compile context for MCPG config generation
+ let input_dir = input_path.parent().unwrap_or(Path::new("."));
+ let ctx = CompileContext::new(front_matter, input_dir).await?;
+
+ // Generate stage prefix for job-name uniqueness and template parameters
+ let stage_prefix = generate_stage_prefix(&front_matter.name);
+ let template_params = generate_template_parameters(front_matter)?;
+
+ // AWF / MCPG values (same as standalone)
+ let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
+ let awf_mounts = generate_awf_mounts(&extensions);
+ let awf_paths = collect_awf_path_prepends(&extensions);
+ let awf_path_step = generate_awf_path_step(&awf_paths);
+ let enabled_tools_args = generate_enabled_tools_args(front_matter);
+
+ let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?;
+ let mcpg_config_json =
+ serde_json::to_string_pretty(&config_obj).context("Failed to serialize MCPG config")?;
+ let mcpg_docker_env = generate_mcpg_docker_env(front_matter, &extensions);
+ let mcpg_step_env = generate_mcpg_step_env(&extensions);
+
+ let config = CompileConfig {
+ template: cfg.template.to_string(),
+ extra_replacements: vec![
+ ("{{ stage_prefix }}".into(), stage_prefix),
+ ("{{ template_parameters }}".into(), template_params),
+ ("{{ firewall_version }}".into(), AWF_VERSION.into()),
+ ("{{ mcpg_version }}".into(), MCPG_VERSION.into()),
+ ("{{ mcpg_image }}".into(), MCPG_IMAGE.into()),
+ ("{{ mcpg_port }}".into(), MCPG_PORT.to_string()),
+ ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
+ ("{{ allowed_domains }}".into(), allowed_domains),
+ ("{{ awf_mounts }}".into(), awf_mounts),
+ ("{{ awf_path_step }}".into(), awf_path_step),
+ ("{{ enabled_tools_args }}".into(), enabled_tools_args),
+ ("{{ mcpg_config }}".into(), mcpg_config_json),
+ ("{{ mcpg_docker_env }}".into(), mcpg_docker_env),
+ ("{{ mcpg_step_env }}".into(), mcpg_step_env),
+ ],
+ skip_integrity: cfg.skip_integrity,
+ debug_pipeline: cfg.debug_pipeline,
+ has_awf_paths: !awf_paths.is_empty(),
+ skip_header: true,
+ };
+
+ let yaml = compile_shared(
+ input_path, output_path, front_matter, markdown_body,
+ &extensions, &ctx, config,
+ ).await?;
+
+ let header = header_fn(input_path, output_path, front_matter);
+ Ok(format!("{}{}", header, yaml))
}
#[cfg(test)]
diff --git a/src/compile/job.rs b/src/compile/job.rs
new file mode 100644
index 00000000..a19f1ded
--- /dev/null
+++ b/src/compile/job.rs
@@ -0,0 +1,143 @@
+//! Job-level ADO template compiler.
+//!
+//! This compiler generates a reusable ADO YAML template with `jobs:` at root.
+//! Users include it in their existing pipelines via `- template: `.
+//!
+//! Two inclusion patterns:
+//! - Directly in a flat pipeline's `jobs:` list
+//! - Inside a user-defined stage's `jobs:` list
+
+use anyhow::Result;
+use async_trait::async_trait;
+use log::warn;
+use std::path::Path;
+
+use super::Compiler;
+use super::common::{
+ compile_template_target, TemplateTargetConfig,
+ generate_header_comment,
+};
+use super::types::FrontMatter;
+
+/// Job-level template compiler.
+pub struct JobCompiler;
+
+#[async_trait]
+impl Compiler for JobCompiler {
+ fn target_name(&self) -> &'static str {
+ "job"
+ }
+
+ async fn compile(
+ &self,
+ input_path: &Path,
+ output_path: &Path,
+ front_matter: &FrontMatter,
+ markdown_body: &str,
+ skip_integrity: bool,
+ debug_pipeline: bool,
+ ) -> Result {
+ if front_matter.on_config.is_some() {
+ warn!("on: trigger configuration is ignored for target: job (triggers are the parent pipeline's concern)");
+ }
+
+ compile_template_target(
+ input_path,
+ output_path,
+ front_matter,
+ markdown_body,
+ TemplateTargetConfig {
+ template: include_str!("../data/job-base.yml"),
+ skip_integrity,
+ debug_pipeline,
+ },
+ generate_job_header,
+ ).await
+ }
+}
+
+/// Generate the header comment block for job-level templates.
+fn generate_job_header(input_path: &Path, output_path: &Path, front_matter: &FrontMatter) -> String {
+ let base_header = generate_header_comment(input_path);
+ let mut lock_path = output_path
+ .to_string_lossy()
+ .replace('\\', "/");
+ // Strip redundant leading "./" (same normalization as generate_header_comment)
+ while lock_path.starts_with("./") {
+ lock_path = lock_path[2..].to_string();
+ }
+
+ let mut header = base_header;
+ header.push_str("#\n");
+ header.push_str("# Job-level ADO template. Include in your pipeline:\n");
+ header.push_str("#\n");
+ header.push_str("# jobs:\n");
+ header.push_str(&format!("# - template: {}\n", lock_path));
+ header.push_str("#\n");
+ header.push_str("# Or inside a stage in a multi-stage pipeline:\n");
+ header.push_str("#\n");
+ header.push_str("# stages:\n");
+ header.push_str("# - stage: AgenticReview\n");
+ header.push_str("# dependsOn: Build\n");
+ header.push_str("# jobs:\n");
+ header.push_str(&format!("# - template: {}\n", lock_path));
+
+ // Document required resources if agent uses repos
+ if !front_matter.repositories.is_empty() {
+ header.push_str("#\n");
+ header.push_str("# Add these repositories to your pipeline's resources: block:\n");
+ header.push_str("#\n");
+ header.push_str("# resources:\n");
+ header.push_str("# repositories:\n");
+ for repo in &front_matter.repositories {
+ header.push_str(&format!("# - repository: {}\n", repo.repository));
+ header.push_str(&format!("# type: {}\n", repo.repo_type));
+ header.push_str(&format!("# name: {}\n", repo.name));
+ }
+ }
+
+ header.push('\n');
+ header
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::compile::common::generate_stage_prefix;
+
+ #[test]
+ fn test_generate_stage_prefix_basic() {
+ assert_eq!(generate_stage_prefix("Daily Code Review"), "DailyCodeReview");
+ }
+
+ #[test]
+ fn test_generate_stage_prefix_hyphens() {
+ assert_eq!(generate_stage_prefix("my-agent-123"), "MyAgent123");
+ }
+
+ #[test]
+ fn test_generate_stage_prefix_empty() {
+ assert_eq!(generate_stage_prefix(""), "Agent");
+ }
+
+ #[test]
+ fn test_generate_stage_prefix_leading_digit() {
+ assert_eq!(generate_stage_prefix("123start"), "_123start");
+ }
+
+ #[test]
+ fn test_generate_stage_prefix_single_word() {
+ assert_eq!(generate_stage_prefix("review"), "Review");
+ }
+
+ #[test]
+ fn test_generate_stage_prefix_underscores() {
+ assert_eq!(generate_stage_prefix("code_review_agent"), "CodeReviewAgent");
+ }
+
+ #[test]
+ fn test_generate_stage_prefix_unicode_stripped() {
+ // ADO identifiers require [A-Za-z0-9_]; non-ASCII chars are split points
+ assert_eq!(generate_stage_prefix("über-agent"), "BerAgent");
+ }
+}
diff --git a/src/compile/mod.rs b/src/compile/mod.rs
index 58cbfbf7..90375dc3 100644
--- a/src/compile/mod.rs
+++ b/src/compile/mod.rs
@@ -14,8 +14,10 @@ mod gitattributes;
#[cfg(test)]
mod codemod_integration_test;
pub(crate) mod codemods;
+mod job;
mod onees;
pub(crate) mod pr_filters;
+mod stage;
mod standalone;
pub mod types;
@@ -191,8 +193,9 @@ async fn compile_pipeline_inner(
let compiler: Box = match front_matter.target {
CompileTarget::OneES => Box::new(onees::OneESCompiler),
CompileTarget::Standalone => Box::new(standalone::StandaloneCompiler),
+ CompileTarget::Job => Box::new(job::JobCompiler),
+ CompileTarget::Stage => Box::new(stage::StageCompiler),
};
-
info!("Using {} compiler", compiler.target_name());
// Compile (no source mutation yet — a failure here must leave the
@@ -235,11 +238,18 @@ async fn compile_pipeline_inner(
)
})?;
- println!(
- "Generated {} pipeline: {}",
- compiler.target_name(),
- yaml_output_path.display()
- );
+ {
+ let kind = match front_matter.target {
+ CompileTarget::Job | CompileTarget::Stage => "template",
+ _ => "pipeline",
+ };
+ println!(
+ "Generated {} {}: {}",
+ compiler.target_name(),
+ kind,
+ yaml_output_path.display()
+ );
+ }
// Update .gitattributes at the repo root so every compiled pipeline is
// marked as a generated file with `merge=ours`. Best-effort: skip with a
@@ -545,6 +555,8 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> {
let compiler: Box = match front_matter.target {
CompileTarget::OneES => Box::new(onees::OneESCompiler),
CompileTarget::Standalone => Box::new(standalone::StandaloneCompiler),
+ CompileTarget::Job => Box::new(job::JobCompiler),
+ CompileTarget::Stage => Box::new(stage::StageCompiler),
};
// Pass the header's relative source path to compile so the generated
diff --git a/src/compile/onees.rs b/src/compile/onees.rs
index 416d479a..2e66b310 100644
--- a/src/compile/onees.rs
+++ b/src/compile/onees.rs
@@ -90,6 +90,7 @@ impl Compiler for OneESCompiler {
skip_integrity,
debug_pipeline,
has_awf_paths: !awf_paths.is_empty(),
+ skip_header: false,
};
compile_shared(input_path, output_path, front_matter, markdown_body, &extensions, &ctx, config).await
diff --git a/src/compile/stage.rs b/src/compile/stage.rs
new file mode 100644
index 00000000..fda341ee
--- /dev/null
+++ b/src/compile/stage.rs
@@ -0,0 +1,102 @@
+//! Stage-level ADO template compiler.
+//!
+//! This compiler generates a reusable ADO YAML template with `stages:` at root
+//! wrapping the 3-job chain (Agent → Detection → Execution).
+//!
+//! Users include it in their multi-stage pipeline via:
+//!
+//! ```yaml
+//! stages:
+//! - template: agents/review.lock.yml
+//! dependsOn: Build
+//! condition: succeeded()
+//! ```
+//!
+//! ADO natively supports `dependsOn` and `condition` at the template call site,
+//! so these don't need to be template parameters.
+
+use anyhow::Result;
+use async_trait::async_trait;
+use log::warn;
+use std::path::Path;
+
+use super::Compiler;
+use super::common::{
+ compile_template_target, TemplateTargetConfig,
+ generate_header_comment,
+};
+use super::types::FrontMatter;
+
+/// Stage-level template compiler.
+pub struct StageCompiler;
+
+#[async_trait]
+impl Compiler for StageCompiler {
+ fn target_name(&self) -> &'static str {
+ "stage"
+ }
+
+ async fn compile(
+ &self,
+ input_path: &Path,
+ output_path: &Path,
+ front_matter: &FrontMatter,
+ markdown_body: &str,
+ skip_integrity: bool,
+ debug_pipeline: bool,
+ ) -> Result {
+ if front_matter.on_config.is_some() {
+ warn!("on: trigger configuration is ignored for target: stage (triggers are the parent pipeline's concern)");
+ }
+
+ compile_template_target(
+ input_path,
+ output_path,
+ front_matter,
+ markdown_body,
+ TemplateTargetConfig {
+ template: include_str!("../data/stage-base.yml"),
+ skip_integrity,
+ debug_pipeline,
+ },
+ generate_stage_header,
+ ).await
+ }
+}
+
+/// Generate the header comment block for stage-level templates.
+fn generate_stage_header(input_path: &Path, output_path: &Path, front_matter: &FrontMatter) -> String {
+ let base_header = generate_header_comment(input_path);
+ let mut lock_path = output_path
+ .to_string_lossy()
+ .replace('\\', "/");
+ while lock_path.starts_with("./") {
+ lock_path = lock_path[2..].to_string();
+ }
+
+ let mut header = base_header;
+ header.push_str("#\n");
+ header.push_str("# Stage-level ADO template. Include in your pipeline:\n");
+ header.push_str("#\n");
+ header.push_str("# stages:\n");
+ header.push_str(&format!("# - template: {}\n", lock_path));
+ header.push_str("# dependsOn: Build\n");
+ header.push_str("# condition: succeeded()\n");
+
+ // Document required resources if agent uses repos
+ if !front_matter.repositories.is_empty() {
+ header.push_str("#\n");
+ header.push_str("# Add these repositories to your pipeline's resources: block:\n");
+ header.push_str("#\n");
+ header.push_str("# resources:\n");
+ header.push_str("# repositories:\n");
+ for repo in &front_matter.repositories {
+ header.push_str(&format!("# - repository: {}\n", repo.repository));
+ header.push_str(&format!("# type: {}\n", repo.repo_type));
+ header.push_str(&format!("# name: {}\n", repo.name));
+ }
+ }
+
+ header.push('\n');
+ header
+}
diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs
index a683bd1c..063c7dff 100644
--- a/src/compile/standalone.rs
+++ b/src/compile/standalone.rs
@@ -83,6 +83,7 @@ impl Compiler for StandaloneCompiler {
skip_integrity,
debug_pipeline,
has_awf_paths: !awf_paths.is_empty(),
+ skip_header: false,
};
compile_shared(input_path, output_path, front_matter, markdown_body, &extensions, &ctx, config).await
diff --git a/src/compile/types.rs b/src/compile/types.rs
index 0cac5d3a..b84770dd 100644
--- a/src/compile/types.rs
+++ b/src/compile/types.rs
@@ -17,6 +17,10 @@ pub enum CompileTarget {
/// 1ES Pipeline Template integration using agencyJob
#[serde(rename = "1es")]
OneES,
+ /// Job-level ADO template: produces `jobs:` at root for inclusion in existing pipelines
+ Job,
+ /// Stage-level ADO template: produces `stages:` wrapping jobs for multi-stage pipelines
+ Stage,
}
/// Pool configuration - accepts both string and object formats
diff --git a/src/data/job-base.yml b/src/data/job-base.yml
new file mode 100644
index 00000000..9d8659f3
--- /dev/null
+++ b/src/data/job-base.yml
@@ -0,0 +1,663 @@
+
+{{ template_parameters }}
+jobs:
+ {{ setup_job }}
+ - job: {{ stage_prefix }}_Agent
+ displayName: "Agent"
+ {{ agentic_depends_on }}
+ {{ job_timeout }}
+ pool:
+ name: {{ pool }}
+ steps:
+ {{ checkout_self }}
+ {{ checkout_repositories }}
+
+ {{ acquire_ado_token }}
+
+ {{ engine_install_steps }}
+
+ - bash: |
+ set -eo pipefail
+ COMPILER_VERSION="{{ compiler_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64"
+ CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading ado-aw v${COMPILER_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/ado-aw-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "ado-aw-linux-x64" checksums.txt | sha256sum -c -
+ mv ado-aw-linux-x64 ado-aw
+ chmod +x ado-aw
+ displayName: "Download agentic pipeline compiler (v{{ compiler_version }})"
+
+ {{ integrity_check }}
+
+ - bash: |
+ mkdir -p "$(Agent.TempDirectory)/staging"
+
+ # Generate MCPG API key early so it's available as an ADO secret variable
+ # for both the MCPG config and the agent's mcp-config.json
+ MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "##vso[task.setvariable variable=MCP_GATEWAY_API_KEY;issecret=true]$MCP_GATEWAY_API_KEY"
+
+ # Export gateway port and domain as pipeline variables (matching gh-aw pattern).
+ # These duplicate the compile-time values baked into the YAML, but MCPG's
+ # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars
+ # to start — the ADO variable indirection satisfies that contract.
+ echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]{{ mcpg_port }}"
+ echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]{{ mcpg_domain }}"
+
+ # Write MCPG (MCP Gateway) configuration to a file
+ cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF'
+ {{ mcpg_config }}
+ MCPG_CONFIG_EOF
+
+ echo "MCPG config:"
+ cat "$(Agent.TempDirectory)/staging/mcpg-config.json"
+
+ # Validate JSON
+ python3 -m json.tool "$(Agent.TempDirectory)/staging/mcpg-config.json" > /dev/null && echo "JSON is valid"
+ displayName: "Prepare MCPG config"
+
+ - bash: |
+ mkdir -p /tmp/awf-tools/staging
+
+ echo "HOME: $HOME"
+
+ # Use absolute path since MCP subprocess may not inherit PATH
+ AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw"
+
+ # Verify the binary exists and is executable
+ ls -la "$AGENTIC_PIPELINES_PATH"
+ chmod +x "$AGENTIC_PIPELINES_PATH"
+
+ $AGENTIC_PIPELINES_PATH -h
+
+ # Copy compiler binary to /tmp so it's accessible inside AWF container
+ cp "$AGENTIC_PIPELINES_PATH" /tmp/awf-tools/ado-aw
+ chmod +x /tmp/awf-tools/ado-aw
+
+ # Copy MCPG config to /tmp
+ cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json
+ displayName: "Prepare tooling"
+
+ - bash: |
+ # Write agent instructions to /tmp so it's accessible inside AWF container
+ cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF'
+ {{ agent_content }}
+ AGENT_PROMPT_EOF
+
+ echo "Agent prompt:"
+ cat "/tmp/awf-tools/agent-prompt.md"
+ displayName: "Prepare agent prompt"
+
+ - task: DockerInstaller@0
+ displayName: "Install Docker"
+ inputs:
+ dockerVersion: 26.1.4
+
+ - bash: |
+ set -eo pipefail
+
+ AWF_VERSION="{{ firewall_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/awf"
+ DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64"
+ CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading AWF v${AWF_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/awf-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "awf-linux-x64" checksums.txt | sha256sum -c -
+ mv awf-linux-x64 awf
+ chmod +x awf
+ echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf"
+ ./awf --version
+ displayName: "Download AWF (Agentic Workflow Firewall) v{{ firewall_version }}"
+
+ - bash: |
+ set -eo pipefail
+
+ docker pull ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }}
+ docker pull ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }}
+ docker tag ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/squid:latest
+ docker tag ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/agent:latest
+ docker pull {{ mcpg_image }}:v{{ mcpg_version }}
+ displayName: "Pre-pull AWF and MCPG container images (v{{ firewall_version }})"
+
+ {{ prepare_steps }}
+
+ {{ awf_path_step }}
+
+ # Start SafeOutputs HTTP server on host (MCPG proxies to it)
+ - bash: |
+ SAFE_OUTPUTS_PORT=8100
+ SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT"
+ echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY"
+
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+
+ # Start SafeOutputs as HTTP server in the background
+ # NOTE: {{ enabled_tools_args }} expands to either "" or "--enabled-tools X ... "
+ # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this.
+ # Positional args (output_directory, bounding_directory) MUST come after all named
+ # options — clap parses them positionally and reordering would break the command.
+ nohup /tmp/awf-tools/ado-aw mcp-http \
+ --port "$SAFE_OUTPUTS_PORT" \
+ --api-key "$SAFE_OUTPUTS_API_KEY" \
+ {{ enabled_tools_args }}"/tmp/awf-tools/staging" \
+ "{{ working_directory }}" \
+ > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 &
+ SAFE_OUTPUTS_PID=$!
+ echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID"
+ echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)"
+
+ # Wait for server to be ready
+ READY=false
+ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop
+ for i in $(seq 1 30); do
+ if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then
+ echo "SafeOutputs HTTP server is ready"
+ READY=true
+ break
+ fi
+ sleep 1
+ done
+ if [ "$READY" != "true" ]; then
+ echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s"
+ exit 1
+ fi
+ displayName: "Start SafeOutputs HTTP server"
+
+ # Start MCP Gateway (MCPG) on host
+ - bash: |
+ # Substitute runtime values into MCPG config
+ MCPG_CONFIG=$(sed \
+ -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \
+ -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \
+ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \
+ /tmp/awf-tools/staging/mcpg-config.json)
+
+ # Log the template config (before API key substitution) for debugging.
+ echo "Starting MCPG with config template:"
+ python3 -m json.tool < /tmp/awf-tools/staging/mcpg-config.json
+
+ # Remove any leftover container or stale output from a previous interrupted run
+ # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind)
+ docker rm -f mcpg 2>/dev/null || true
+ GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json"
+ mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs
+ rm -f "$GATEWAY_OUTPUT"
+
+ # Start MCPG Docker container on host network.
+ # The Docker socket mount is required because MCPG spawns stdio-based MCP
+ # servers as sibling containers. This grants significant host access — acceptable
+ # here because the pipeline agent is already trusted and network-isolated by AWF.
+ #
+ # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a
+ # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports`
+ # which is empty with --network host (by design), causing a spurious error:
+ # [ERROR] Port 80 is not exposed from the container
+ # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD
+ #
+ # stdout → gateway-output.json (machine-readable config, read after health check)
+ echo "$MCPG_CONFIG" | docker run -i --rm \
+ --name mcpg \
+ --network host \
+ --entrypoint /app/awmg \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \
+ -e MCP_GATEWAY_DOMAIN="$(MCP_GATEWAY_DOMAIN)" \
+ -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \
+ {{ mcpg_debug_flags }}
+ {{ mcpg_docker_env }}
+ {{ mcpg_image }}:v{{ mcpg_version }} \
+ --routed --listen 0.0.0.0:{{ mcpg_port }} --config-stdin --log-dir /tmp/gh-aw/mcp-logs \
+ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) &
+ MCPG_PID=$!
+ echo "MCPG started (PID: $MCPG_PID)"
+
+ # Wait for MCPG to be ready
+ READY=false
+ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop
+ for i in $(seq 1 30); do
+ if curl -sf "http://localhost:{{ mcpg_port }}/health" > /dev/null 2>&1; then
+ echo "MCPG is ready"
+ READY=true
+ break
+ fi
+ sleep 1
+ done
+ if [ "$READY" != "true" ]; then
+ echo "##vso[task.complete result=Failed]MCPG did not become ready within 30s"
+ exit 1
+ fi
+
+ # Wait for gateway output file to contain valid JSON with mcpServers.
+ # Health check passing doesn't guarantee stdout is flushed, so poll.
+ echo "Waiting for gateway output file..."
+ GATEWAY_READY=false
+ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop
+ for i in $(seq 1 15); do
+ if [ -s "$GATEWAY_OUTPUT" ] && jq -e '.mcpServers' "$GATEWAY_OUTPUT" > /dev/null 2>&1; then
+ echo "Gateway output is ready"
+ GATEWAY_READY=true
+ break
+ fi
+ sleep 1
+ done
+ if [ "$GATEWAY_READY" != "true" ]; then
+ echo "##vso[task.complete result=Failed]Gateway output file not ready within 15s"
+ echo "Gateway output content:"
+ cat "$GATEWAY_OUTPUT" 2>/dev/null || echo "(empty or missing)"
+ exit 1
+ fi
+
+ echo "Gateway output:"
+ cat "$GATEWAY_OUTPUT"
+
+ # Convert gateway output to Copilot CLI mcp-config.json.
+ # Mirrors gh-aw's convert_gateway_config_copilot.cjs:
+ # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs
+ # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback)
+ # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement)
+ # - Preserve all other fields (headers, type, etc.)
+ jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \
+ '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \
+ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json
+
+ chmod 600 /tmp/awf-tools/mcp-config.json
+
+ echo "Generated MCP config at: /tmp/awf-tools/mcp-config.json"
+ cat /tmp/awf-tools/mcp-config.json
+ displayName: "Start MCP Gateway (MCPG)"
+ {{ mcpg_step_env }}
+
+ {{ verify_mcp_backends }}
+
+ # Network isolation via AWF (Agentic Workflow Firewall)
+ - bash: |
+ set -o pipefail
+
+ AGENT_OUTPUT_FILE="$(Agent.TempDirectory)/staging/logs/agent-output.txt"
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+
+ echo "=== Running AI agent with AWF network isolation ==="
+ echo "Allowed domains: {{ allowed_domains }}"
+
+ # AWF provides L7 domain whitelisting via Squid proxy + Docker containers.
+ # --enable-host-access allows the AWF container to reach host services
+ # (MCPG and SafeOutputs) via host.docker.internal.
+ # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary,
+ # agent prompt, and MCP config are placed under /tmp/awf-tools/.
+ # Stream agent output in real-time while filtering VSO commands.
+ # sed -u = unbuffered (line-by-line) so output appears immediately.
+ # tee writes to both stdout (ADO pipeline log) and the artifact file.
+ # pipefail (set above) ensures AWF's exit code propagates through the pipe.
+ sudo -E "$(Pipeline.Workspace)/awf/awf" \
+ --allow-domains "{{ allowed_domains }}" \
+ --skip-pull \
+ --env-all \
+ --enable-host-access \
+ {{ awf_mounts }}
+ --container-workdir "{{ working_directory }}" \
+ --log-level info \
+ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \
+ -- '{{ engine_run }}' \
+ 2>&1 \
+ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \
+ | tee "$AGENT_OUTPUT_FILE" \
+ && AGENT_EXIT_CODE=0 || AGENT_EXIT_CODE=$?
+
+ # Print firewall summary if available
+ if [ -x "$(Pipeline.Workspace)/awf/awf" ]; then
+ echo "=== Firewall Summary ==="
+ "$(Pipeline.Workspace)/awf/awf" logs summary --source "$(Agent.TempDirectory)/staging/logs/firewall" 2>/dev/null || true
+ fi
+
+ exit "$AGENT_EXIT_CODE"
+ displayName: "Run copilot (AWF network isolated)"
+ workingDirectory: {{ working_directory }}
+ env:
+ {{ engine_env }}
+
+ - bash: |
+ # Copy safe outputs from /tmp back to staging for artifact publish
+ mkdir -p "$(Agent.TempDirectory)/staging"
+ cp -r /tmp/awf-tools/staging/* "$(Agent.TempDirectory)/staging/" 2>/dev/null || true
+ echo "Safe outputs copied to $(Agent.TempDirectory)/staging"
+ ls -la "$(Agent.TempDirectory)/staging" 2>/dev/null || echo "No safe outputs found"
+ displayName: "Collect safe outputs from AWF container"
+ condition: always()
+
+ - bash: |
+ # Stop MCPG container
+ echo "Stopping MCPG..."
+ docker stop mcpg 2>/dev/null || true
+ echo "MCPG stopped"
+
+ # Stop SafeOutputs HTTP server
+ if [ -n "$(SAFE_OUTPUTS_PID)" ]; then
+ echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..."
+ kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true
+ echo "SafeOutputs stopped"
+ fi
+ displayName: "Stop MCPG and SafeOutputs"
+ condition: always()
+
+ {{ finalize_steps }}
+
+ - bash: |
+ # Copy all logs to output directory for artifact upload
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+ if [ -d "{{ engine_log_dir }}" ]; then
+ cp -r "{{ engine_log_dir }}"/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true
+ fi
+ ADO_AW_LOG_DIR="${ADO_AW_LOG_DIR:-$HOME/.ado-aw/logs}"
+ if [ -d "$ADO_AW_LOG_DIR" ]; then
+ cp -r "$ADO_AW_LOG_DIR"/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true
+ fi
+ if [ -d /tmp/gh-aw/mcp-logs ]; then
+ mkdir -p "$(Agent.TempDirectory)/staging/logs/mcpg"
+ cp -r /tmp/gh-aw/mcp-logs/* "$(Agent.TempDirectory)/staging/logs/mcpg/" 2>/dev/null || true
+ fi
+ echo "Logs copied to $(Agent.TempDirectory)/staging/logs"
+ ls -la "$(Agent.TempDirectory)/staging/logs" 2>/dev/null || echo "No logs found"
+ displayName: "Copy logs to output directory"
+ condition: always()
+
+ - publish: $(Agent.TempDirectory)/staging
+ artifact: agent_outputs_$(Build.BuildId)
+ condition: always()
+
+ - job: {{ stage_prefix }}_Detection
+ displayName: "Detection"
+ dependsOn: {{ stage_prefix }}_Agent
+ pool:
+ name: {{ pool }}
+ steps:
+ {{ checkout_self }}
+ {{ checkout_repositories }}
+
+ - download: current
+ artifact: agent_outputs_$(Build.BuildId)
+
+ {{ engine_install_steps }}
+
+ - bash: |
+ set -eo pipefail
+ COMPILER_VERSION="{{ compiler_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64"
+ CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading ado-aw v${COMPILER_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/ado-aw-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "ado-aw-linux-x64" checksums.txt | sha256sum -c -
+ mv ado-aw-linux-x64 ado-aw
+ chmod +x ado-aw
+ displayName: "Download agentic pipeline compiler (v{{ compiler_version }})"
+
+ - task: DockerInstaller@0
+ displayName: "Install Docker"
+ inputs:
+ dockerVersion: 26.1.4
+
+ - bash: |
+ set -eo pipefail
+
+ AWF_VERSION="{{ firewall_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/awf"
+ DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64"
+ CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading AWF v${AWF_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/awf-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "awf-linux-x64" checksums.txt | sha256sum -c -
+ mv awf-linux-x64 awf
+ chmod +x awf
+ echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf"
+ ./awf --version
+ displayName: "Download AWF (Agentic Workflow Firewall) v{{ firewall_version }}"
+
+ - bash: |
+ set -eo pipefail
+
+ docker pull ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }}
+ docker pull ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }}
+ docker tag ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/squid:latest
+ docker tag ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/agent:latest
+ displayName: "Pre-pull AWF container images (v{{ firewall_version }})"
+
+ - bash: |
+ mkdir -p "{{ working_directory }}/safe_outputs"
+ cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "{{ working_directory }}/safe_outputs"
+ displayName: "Prepare safe outputs for analysis"
+
+ - bash: |
+ # Write threat analysis prompt to /tmp (accessible inside AWF container)
+ cat > "/tmp/awf-tools/threat-analysis-prompt.md" << 'THREAT_ANALYSIS_EOF'
+ {{ threat_analysis_prompt }}
+ THREAT_ANALYSIS_EOF
+
+ echo "Threat analysis prompt:"
+ cat "/tmp/awf-tools/threat-analysis-prompt.md"
+ displayName: "Prepare threat analysis prompt"
+
+ - bash: |
+ AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw"
+ chmod +x "$AGENTIC_PIPELINES_PATH"
+ displayName: "Setup agentic pipeline compiler"
+
+ - bash: |
+ set -o pipefail
+
+ # Run threat analysis with AWF network isolation
+ THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt"
+
+ # Stream threat analysis output in real-time with VSO command filtering
+ sudo -E "$(Pipeline.Workspace)/awf/awf" \
+ --allow-domains "{{ allowed_domains }}" \
+ --skip-pull \
+ --env-all \
+ --container-workdir "{{ working_directory }}" \
+ --log-level info \
+ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \
+ -- '{{ engine_run_detection }}' \
+ 2>&1 \
+ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \
+ | tee "$THREAT_OUTPUT_FILE" \
+ && AGENT_EXIT_CODE=0 || AGENT_EXIT_CODE=$?
+
+ exit "$AGENT_EXIT_CODE"
+ displayName: "Run threat analysis (AWF network isolated)"
+ workingDirectory: {{ working_directory }}
+ env:
+ GITHUB_TOKEN: $(GITHUB_TOKEN)
+ GITHUB_READ_ONLY: 1
+
+ - bash: |
+ # Create analyzed outputs directory with original safe outputs and analysis
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs"
+
+ # Copy original safe outputs
+ cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Agent.TempDirectory)/analyzed_outputs/"
+
+ # Copy threat analysis output
+ if [ -f "$(Agent.TempDirectory)/threat-analysis-output.txt" ]; then
+ cp "$(Agent.TempDirectory)/threat-analysis-output.txt" "$(Agent.TempDirectory)/analyzed_outputs/"
+ fi
+
+ # Extract JSON from THREAT_DETECTION_RESULT line in threat analysis output
+ if [ -f "$(Agent.TempDirectory)/threat-analysis-output.txt" ]; then
+ RESULT_LINE=$(grep "THREAT_DETECTION_RESULT:" "$(Agent.TempDirectory)/threat-analysis-output.txt" | tail -1)
+ if [ -n "$RESULT_LINE" ]; then
+ # Extract JSON after the prefix
+ JSON_CONTENT="${RESULT_LINE##*THREAT_DETECTION_RESULT:}"
+ echo "$JSON_CONTENT" > "$(Agent.TempDirectory)/analyzed_outputs/threat-analysis.json"
+ echo "Extracted threat analysis JSON:"
+ cat "$(Agent.TempDirectory)/analyzed_outputs/threat-analysis.json"
+ else
+ echo "Warning: No THREAT_DETECTION_RESULT found in threat analysis output"
+ fi
+ else
+ echo "Warning: No threat analysis output file found"
+ fi
+
+ echo "Analyzed outputs directory contents:"
+ ls -laR "$(Agent.TempDirectory)/analyzed_outputs"
+ displayName: "Prepare analyzed outputs"
+ condition: always()
+
+ - bash: |
+ SAFE_TO_PROCESS="false"
+ JSON_FILE="$(Agent.TempDirectory)/analyzed_outputs/threat-analysis.json"
+
+ if [ -f "$JSON_FILE" ]; then
+ if jq -e . "$JSON_FILE" > /dev/null 2>&1; then
+ echo "JSON is valid"
+
+ # Check if any threat field is true
+ if jq -e '.prompt_injection or .secret_leak or .malicious_patch' "$JSON_FILE" > /dev/null 2>&1; then
+ echo "##vso[task.logissue type=warning]Threats detected - safe outputs will NOT be processed"
+ jq -r '.reasons[]? // empty' "$JSON_FILE" | sed 's/^/ - /'
+ else
+ echo "No threats detected - safe outputs will be processed"
+ SAFE_TO_PROCESS="true"
+ fi
+ else
+ echo "##vso[task.logissue type=warning]Invalid JSON in threat analysis - defaulting to unsafe"
+ fi
+ else
+ echo "##vso[task.logissue type=warning]No threat analysis JSON found - defaulting to unsafe"
+ fi
+
+ echo "##vso[task.setvariable variable=SafeToProcess;isOutput=true]$SAFE_TO_PROCESS"
+ echo "SafeToProcess set to: $SAFE_TO_PROCESS"
+ displayName: "Evaluate threat analysis"
+ name: threatAnalysis
+ condition: always()
+
+ - bash: |
+ # Copy all logs to analyzed outputs for artifact upload
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs"
+ if [ -d "{{ engine_log_dir }}" ]; then
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/copilot"
+ cp -r "{{ engine_log_dir }}"/* "$(Agent.TempDirectory)/analyzed_outputs/logs/copilot/" 2>/dev/null || true
+ fi
+ ADO_AW_LOG_DIR="${ADO_AW_LOG_DIR:-$HOME/.ado-aw/logs}"
+ if [ -d "$ADO_AW_LOG_DIR" ]; then
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/ado-aw"
+ cp -r "$ADO_AW_LOG_DIR"/* "$(Agent.TempDirectory)/analyzed_outputs/logs/ado-aw/" 2>/dev/null || true
+ fi
+ echo "Logs copied to $(Agent.TempDirectory)/analyzed_outputs/logs"
+ ls -laR "$(Agent.TempDirectory)/analyzed_outputs/logs" 2>/dev/null || echo "No logs found"
+ displayName: "Copy logs to output directory"
+ condition: always()
+
+ - publish: $(Agent.TempDirectory)/analyzed_outputs
+ artifact: analyzed_outputs_$(Build.BuildId)
+ condition: always()
+
+ - job: {{ stage_prefix }}_Execution
+ displayName: "Execution"
+ dependsOn:
+ - {{ stage_prefix }}_Agent
+ - {{ stage_prefix }}_Detection
+ condition: and(succeeded(), eq(dependencies.{{ stage_prefix }}_Detection.outputs['threatAnalysis.SafeToProcess'], 'true'))
+ pool:
+ name: {{ pool }}
+ steps:
+ {{ checkout_self }}
+ {{ checkout_repositories }}
+
+ {{ acquire_write_token }}
+
+ - download: current
+ artifact: analyzed_outputs_$(Build.BuildId)
+
+ - bash: |
+ set -eo pipefail
+ COMPILER_VERSION="{{ compiler_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64"
+ CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading ado-aw v${COMPILER_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/ado-aw-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "ado-aw-linux-x64" checksums.txt | sha256sum -c -
+ mv ado-aw-linux-x64 ado-aw
+ chmod +x ado-aw
+ displayName: "Download agentic pipeline compiler (v{{ compiler_version }})"
+
+ - bash: |
+ ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw"
+ echo "##vso[task.prependpath]$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ displayName: Add agentic compiler to path
+
+ - bash: |
+ mkdir -p "$(Agent.TempDirectory)/staging"
+ displayName: "Prepare output directory"
+
+ - bash: |
+ ado-aw execute --source "{{ source_path }}" --safe-output-dir "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)" --output-dir "$(Agent.TempDirectory)/staging"
+ EXIT_CODE=$?
+ if [ $EXIT_CODE -eq 2 ]; then
+ echo "##vso[task.complete result=SucceededWithIssues;]Executor completed with warnings"
+ exit 0
+ fi
+ exit $EXIT_CODE
+ displayName: Execute safe outputs (Stage 3)
+ workingDirectory: {{ working_directory }}
+ {{ executor_ado_env }}
+
+ - bash: |
+ # Copy all logs to output directory for artifact upload
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+ # Copy agent output log from analyzed_outputs for optimisation use
+ cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \
+ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true
+ if [ -d "{{ engine_log_dir }}" ]; then
+ mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot"
+ cp -r "{{ engine_log_dir }}"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true
+ fi
+ ADO_AW_LOG_DIR="${ADO_AW_LOG_DIR:-$HOME/.ado-aw/logs}"
+ if [ -d "$ADO_AW_LOG_DIR" ]; then
+ mkdir -p "$(Agent.TempDirectory)/staging/logs/ado-aw"
+ cp -r "$ADO_AW_LOG_DIR"/* "$(Agent.TempDirectory)/staging/logs/ado-aw/" 2>/dev/null || true
+ fi
+ echo "Logs copied to $(Agent.TempDirectory)/staging/logs"
+ ls -laR "$(Agent.TempDirectory)/staging/logs" 2>/dev/null || echo "No logs found"
+ displayName: "Copy logs to output directory"
+ condition: always()
+
+ - publish: $(Agent.TempDirectory)/staging
+ artifact: safe_outputs
+ condition: always()
+
+ {{ teardown_job }}
diff --git a/src/data/stage-base.yml b/src/data/stage-base.yml
new file mode 100644
index 00000000..47fe6e5a
--- /dev/null
+++ b/src/data/stage-base.yml
@@ -0,0 +1,667 @@
+
+{{ template_parameters }}
+
+stages:
+- stage: {{ stage_prefix }}
+ displayName: "{{ agent_name }}"
+ jobs:
+ {{ setup_job }}
+ - job: {{ stage_prefix }}_Agent
+ displayName: "Agent"
+ {{ agentic_depends_on }}
+ {{ job_timeout }}
+ pool:
+ name: {{ pool }}
+ steps:
+ {{ checkout_self }}
+ {{ checkout_repositories }}
+
+ {{ acquire_ado_token }}
+
+ {{ engine_install_steps }}
+
+ - bash: |
+ set -eo pipefail
+ COMPILER_VERSION="{{ compiler_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64"
+ CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading ado-aw v${COMPILER_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/ado-aw-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "ado-aw-linux-x64" checksums.txt | sha256sum -c -
+ mv ado-aw-linux-x64 ado-aw
+ chmod +x ado-aw
+ displayName: "Download agentic pipeline compiler (v{{ compiler_version }})"
+
+ {{ integrity_check }}
+
+ - bash: |
+ mkdir -p "$(Agent.TempDirectory)/staging"
+
+ # Generate MCPG API key early so it's available as an ADO secret variable
+ # for both the MCPG config and the agent's mcp-config.json
+ MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "##vso[task.setvariable variable=MCP_GATEWAY_API_KEY;issecret=true]$MCP_GATEWAY_API_KEY"
+
+ # Export gateway port and domain as pipeline variables (matching gh-aw pattern).
+ # These duplicate the compile-time values baked into the YAML, but MCPG's
+ # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars
+ # to start — the ADO variable indirection satisfies that contract.
+ echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]{{ mcpg_port }}"
+ echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]{{ mcpg_domain }}"
+
+ # Write MCPG (MCP Gateway) configuration to a file
+ cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF'
+ {{ mcpg_config }}
+ MCPG_CONFIG_EOF
+
+ echo "MCPG config:"
+ cat "$(Agent.TempDirectory)/staging/mcpg-config.json"
+
+ # Validate JSON
+ python3 -m json.tool "$(Agent.TempDirectory)/staging/mcpg-config.json" > /dev/null && echo "JSON is valid"
+ displayName: "Prepare MCPG config"
+
+ - bash: |
+ mkdir -p /tmp/awf-tools/staging
+
+ echo "HOME: $HOME"
+
+ # Use absolute path since MCP subprocess may not inherit PATH
+ AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw"
+
+ # Verify the binary exists and is executable
+ ls -la "$AGENTIC_PIPELINES_PATH"
+ chmod +x "$AGENTIC_PIPELINES_PATH"
+
+ $AGENTIC_PIPELINES_PATH -h
+
+ # Copy compiler binary to /tmp so it's accessible inside AWF container
+ cp "$AGENTIC_PIPELINES_PATH" /tmp/awf-tools/ado-aw
+ chmod +x /tmp/awf-tools/ado-aw
+
+ # Copy MCPG config to /tmp
+ cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json
+ displayName: "Prepare tooling"
+
+ - bash: |
+ # Write agent instructions to /tmp so it's accessible inside AWF container
+ cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF'
+ {{ agent_content }}
+ AGENT_PROMPT_EOF
+
+ echo "Agent prompt:"
+ cat "/tmp/awf-tools/agent-prompt.md"
+ displayName: "Prepare agent prompt"
+
+ - task: DockerInstaller@0
+ displayName: "Install Docker"
+ inputs:
+ dockerVersion: 26.1.4
+
+ - bash: |
+ set -eo pipefail
+
+ AWF_VERSION="{{ firewall_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/awf"
+ DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64"
+ CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading AWF v${AWF_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/awf-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "awf-linux-x64" checksums.txt | sha256sum -c -
+ mv awf-linux-x64 awf
+ chmod +x awf
+ echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf"
+ ./awf --version
+ displayName: "Download AWF (Agentic Workflow Firewall) v{{ firewall_version }}"
+
+ - bash: |
+ set -eo pipefail
+
+ docker pull ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }}
+ docker pull ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }}
+ docker tag ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/squid:latest
+ docker tag ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/agent:latest
+ docker pull {{ mcpg_image }}:v{{ mcpg_version }}
+ displayName: "Pre-pull AWF and MCPG container images (v{{ firewall_version }})"
+
+ {{ prepare_steps }}
+
+ {{ awf_path_step }}
+
+ # Start SafeOutputs HTTP server on host (MCPG proxies to it)
+ - bash: |
+ SAFE_OUTPUTS_PORT=8100
+ SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT"
+ echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY"
+
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+
+ # Start SafeOutputs as HTTP server in the background
+ # NOTE: {{ enabled_tools_args }} expands to either "" or "--enabled-tools X ... "
+ # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this.
+ # Positional args (output_directory, bounding_directory) MUST come after all named
+ # options — clap parses them positionally and reordering would break the command.
+ nohup /tmp/awf-tools/ado-aw mcp-http \
+ --port "$SAFE_OUTPUTS_PORT" \
+ --api-key "$SAFE_OUTPUTS_API_KEY" \
+ {{ enabled_tools_args }}"/tmp/awf-tools/staging" \
+ "{{ working_directory }}" \
+ > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 &
+ SAFE_OUTPUTS_PID=$!
+ echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID"
+ echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)"
+
+ # Wait for server to be ready
+ READY=false
+ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop
+ for i in $(seq 1 30); do
+ if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then
+ echo "SafeOutputs HTTP server is ready"
+ READY=true
+ break
+ fi
+ sleep 1
+ done
+ if [ "$READY" != "true" ]; then
+ echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s"
+ exit 1
+ fi
+ displayName: "Start SafeOutputs HTTP server"
+
+ # Start MCP Gateway (MCPG) on host
+ - bash: |
+ # Substitute runtime values into MCPG config
+ MCPG_CONFIG=$(sed \
+ -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \
+ -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \
+ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \
+ /tmp/awf-tools/staging/mcpg-config.json)
+
+ # Log the template config (before API key substitution) for debugging.
+ echo "Starting MCPG with config template:"
+ python3 -m json.tool < /tmp/awf-tools/staging/mcpg-config.json
+
+ # Remove any leftover container or stale output from a previous interrupted run
+ # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind)
+ docker rm -f mcpg 2>/dev/null || true
+ GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json"
+ mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs
+ rm -f "$GATEWAY_OUTPUT"
+
+ # Start MCPG Docker container on host network.
+ # The Docker socket mount is required because MCPG spawns stdio-based MCP
+ # servers as sibling containers. This grants significant host access — acceptable
+ # here because the pipeline agent is already trusted and network-isolated by AWF.
+ #
+ # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a
+ # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports`
+ # which is empty with --network host (by design), causing a spurious error:
+ # [ERROR] Port 80 is not exposed from the container
+ # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD
+ #
+ # stdout → gateway-output.json (machine-readable config, read after health check)
+ echo "$MCPG_CONFIG" | docker run -i --rm \
+ --name mcpg \
+ --network host \
+ --entrypoint /app/awmg \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \
+ -e MCP_GATEWAY_DOMAIN="$(MCP_GATEWAY_DOMAIN)" \
+ -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \
+ {{ mcpg_debug_flags }}
+ {{ mcpg_docker_env }}
+ {{ mcpg_image }}:v{{ mcpg_version }} \
+ --routed --listen 0.0.0.0:{{ mcpg_port }} --config-stdin --log-dir /tmp/gh-aw/mcp-logs \
+ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) &
+ MCPG_PID=$!
+ echo "MCPG started (PID: $MCPG_PID)"
+
+ # Wait for MCPG to be ready
+ READY=false
+ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop
+ for i in $(seq 1 30); do
+ if curl -sf "http://localhost:{{ mcpg_port }}/health" > /dev/null 2>&1; then
+ echo "MCPG is ready"
+ READY=true
+ break
+ fi
+ sleep 1
+ done
+ if [ "$READY" != "true" ]; then
+ echo "##vso[task.complete result=Failed]MCPG did not become ready within 30s"
+ exit 1
+ fi
+
+ # Wait for gateway output file to contain valid JSON with mcpServers.
+ # Health check passing doesn't guarantee stdout is flushed, so poll.
+ echo "Waiting for gateway output file..."
+ GATEWAY_READY=false
+ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop
+ for i in $(seq 1 15); do
+ if [ -s "$GATEWAY_OUTPUT" ] && jq -e '.mcpServers' "$GATEWAY_OUTPUT" > /dev/null 2>&1; then
+ echo "Gateway output is ready"
+ GATEWAY_READY=true
+ break
+ fi
+ sleep 1
+ done
+ if [ "$GATEWAY_READY" != "true" ]; then
+ echo "##vso[task.complete result=Failed]Gateway output file not ready within 15s"
+ echo "Gateway output content:"
+ cat "$GATEWAY_OUTPUT" 2>/dev/null || echo "(empty or missing)"
+ exit 1
+ fi
+
+ echo "Gateway output:"
+ cat "$GATEWAY_OUTPUT"
+
+ # Convert gateway output to Copilot CLI mcp-config.json.
+ # Mirrors gh-aw's convert_gateway_config_copilot.cjs:
+ # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs
+ # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback)
+ # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement)
+ # - Preserve all other fields (headers, type, etc.)
+ jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \
+ '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \
+ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json
+
+ chmod 600 /tmp/awf-tools/mcp-config.json
+
+ echo "Generated MCP config at: /tmp/awf-tools/mcp-config.json"
+ cat /tmp/awf-tools/mcp-config.json
+ displayName: "Start MCP Gateway (MCPG)"
+ {{ mcpg_step_env }}
+
+ {{ verify_mcp_backends }}
+
+ # Network isolation via AWF (Agentic Workflow Firewall)
+ - bash: |
+ set -o pipefail
+
+ AGENT_OUTPUT_FILE="$(Agent.TempDirectory)/staging/logs/agent-output.txt"
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+
+ echo "=== Running AI agent with AWF network isolation ==="
+ echo "Allowed domains: {{ allowed_domains }}"
+
+ # AWF provides L7 domain whitelisting via Squid proxy + Docker containers.
+ # --enable-host-access allows the AWF container to reach host services
+ # (MCPG and SafeOutputs) via host.docker.internal.
+ # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary,
+ # agent prompt, and MCP config are placed under /tmp/awf-tools/.
+ # Stream agent output in real-time while filtering VSO commands.
+ # sed -u = unbuffered (line-by-line) so output appears immediately.
+ # tee writes to both stdout (ADO pipeline log) and the artifact file.
+ # pipefail (set above) ensures AWF's exit code propagates through the pipe.
+ sudo -E "$(Pipeline.Workspace)/awf/awf" \
+ --allow-domains "{{ allowed_domains }}" \
+ --skip-pull \
+ --env-all \
+ --enable-host-access \
+ {{ awf_mounts }}
+ --container-workdir "{{ working_directory }}" \
+ --log-level info \
+ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \
+ -- '{{ engine_run }}' \
+ 2>&1 \
+ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \
+ | tee "$AGENT_OUTPUT_FILE" \
+ && AGENT_EXIT_CODE=0 || AGENT_EXIT_CODE=$?
+
+ # Print firewall summary if available
+ if [ -x "$(Pipeline.Workspace)/awf/awf" ]; then
+ echo "=== Firewall Summary ==="
+ "$(Pipeline.Workspace)/awf/awf" logs summary --source "$(Agent.TempDirectory)/staging/logs/firewall" 2>/dev/null || true
+ fi
+
+ exit "$AGENT_EXIT_CODE"
+ displayName: "Run copilot (AWF network isolated)"
+ workingDirectory: {{ working_directory }}
+ env:
+ {{ engine_env }}
+
+ - bash: |
+ # Copy safe outputs from /tmp back to staging for artifact publish
+ mkdir -p "$(Agent.TempDirectory)/staging"
+ cp -r /tmp/awf-tools/staging/* "$(Agent.TempDirectory)/staging/" 2>/dev/null || true
+ echo "Safe outputs copied to $(Agent.TempDirectory)/staging"
+ ls -la "$(Agent.TempDirectory)/staging" 2>/dev/null || echo "No safe outputs found"
+ displayName: "Collect safe outputs from AWF container"
+ condition: always()
+
+ - bash: |
+ # Stop MCPG container
+ echo "Stopping MCPG..."
+ docker stop mcpg 2>/dev/null || true
+ echo "MCPG stopped"
+
+ # Stop SafeOutputs HTTP server
+ if [ -n "$(SAFE_OUTPUTS_PID)" ]; then
+ echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..."
+ kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true
+ echo "SafeOutputs stopped"
+ fi
+ displayName: "Stop MCPG and SafeOutputs"
+ condition: always()
+
+ {{ finalize_steps }}
+
+ - bash: |
+ # Copy all logs to output directory for artifact upload
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+ if [ -d "{{ engine_log_dir }}" ]; then
+ cp -r "{{ engine_log_dir }}"/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true
+ fi
+ ADO_AW_LOG_DIR="${ADO_AW_LOG_DIR:-$HOME/.ado-aw/logs}"
+ if [ -d "$ADO_AW_LOG_DIR" ]; then
+ cp -r "$ADO_AW_LOG_DIR"/* "$(Agent.TempDirectory)/staging/logs/" 2>/dev/null || true
+ fi
+ if [ -d /tmp/gh-aw/mcp-logs ]; then
+ mkdir -p "$(Agent.TempDirectory)/staging/logs/mcpg"
+ cp -r /tmp/gh-aw/mcp-logs/* "$(Agent.TempDirectory)/staging/logs/mcpg/" 2>/dev/null || true
+ fi
+ echo "Logs copied to $(Agent.TempDirectory)/staging/logs"
+ ls -la "$(Agent.TempDirectory)/staging/logs" 2>/dev/null || echo "No logs found"
+ displayName: "Copy logs to output directory"
+ condition: always()
+
+ - publish: $(Agent.TempDirectory)/staging
+ artifact: agent_outputs_$(Build.BuildId)
+ condition: always()
+
+ - job: {{ stage_prefix }}_Detection
+ displayName: "Detection"
+ dependsOn: {{ stage_prefix }}_Agent
+ pool:
+ name: {{ pool }}
+ steps:
+ {{ checkout_self }}
+ {{ checkout_repositories }}
+
+ - download: current
+ artifact: agent_outputs_$(Build.BuildId)
+
+ {{ engine_install_steps }}
+
+ - bash: |
+ set -eo pipefail
+ COMPILER_VERSION="{{ compiler_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64"
+ CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading ado-aw v${COMPILER_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/ado-aw-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "ado-aw-linux-x64" checksums.txt | sha256sum -c -
+ mv ado-aw-linux-x64 ado-aw
+ chmod +x ado-aw
+ displayName: "Download agentic pipeline compiler (v{{ compiler_version }})"
+
+ - task: DockerInstaller@0
+ displayName: "Install Docker"
+ inputs:
+ dockerVersion: 26.1.4
+
+ - bash: |
+ set -eo pipefail
+
+ AWF_VERSION="{{ firewall_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/awf"
+ DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64"
+ CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading AWF v${AWF_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/awf-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "awf-linux-x64" checksums.txt | sha256sum -c -
+ mv awf-linux-x64 awf
+ chmod +x awf
+ echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf"
+ ./awf --version
+ displayName: "Download AWF (Agentic Workflow Firewall) v{{ firewall_version }}"
+
+ - bash: |
+ set -eo pipefail
+
+ docker pull ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }}
+ docker pull ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }}
+ docker tag ghcr.io/github/gh-aw-firewall/squid:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/squid:latest
+ docker tag ghcr.io/github/gh-aw-firewall/agent:{{ firewall_version }} ghcr.io/github/gh-aw-firewall/agent:latest
+ displayName: "Pre-pull AWF container images (v{{ firewall_version }})"
+
+ - bash: |
+ mkdir -p "{{ working_directory }}/safe_outputs"
+ cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "{{ working_directory }}/safe_outputs"
+ displayName: "Prepare safe outputs for analysis"
+
+ - bash: |
+ # Write threat analysis prompt to /tmp (accessible inside AWF container)
+ cat > "/tmp/awf-tools/threat-analysis-prompt.md" << 'THREAT_ANALYSIS_EOF'
+ {{ threat_analysis_prompt }}
+ THREAT_ANALYSIS_EOF
+
+ echo "Threat analysis prompt:"
+ cat "/tmp/awf-tools/threat-analysis-prompt.md"
+ displayName: "Prepare threat analysis prompt"
+
+ - bash: |
+ AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw"
+ chmod +x "$AGENTIC_PIPELINES_PATH"
+ displayName: "Setup agentic pipeline compiler"
+
+ - bash: |
+ set -o pipefail
+
+ # Run threat analysis with AWF network isolation
+ THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt"
+
+ # Stream threat analysis output in real-time with VSO command filtering
+ sudo -E "$(Pipeline.Workspace)/awf/awf" \
+ --allow-domains "{{ allowed_domains }}" \
+ --skip-pull \
+ --env-all \
+ --container-workdir "{{ working_directory }}" \
+ --log-level info \
+ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \
+ -- '{{ engine_run_detection }}' \
+ 2>&1 \
+ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \
+ | tee "$THREAT_OUTPUT_FILE" \
+ && AGENT_EXIT_CODE=0 || AGENT_EXIT_CODE=$?
+
+ exit "$AGENT_EXIT_CODE"
+ displayName: "Run threat analysis (AWF network isolated)"
+ workingDirectory: {{ working_directory }}
+ env:
+ GITHUB_TOKEN: $(GITHUB_TOKEN)
+ GITHUB_READ_ONLY: 1
+
+ - bash: |
+ # Create analyzed outputs directory with original safe outputs and analysis
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs"
+
+ # Copy original safe outputs
+ cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Agent.TempDirectory)/analyzed_outputs/"
+
+ # Copy threat analysis output
+ if [ -f "$(Agent.TempDirectory)/threat-analysis-output.txt" ]; then
+ cp "$(Agent.TempDirectory)/threat-analysis-output.txt" "$(Agent.TempDirectory)/analyzed_outputs/"
+ fi
+
+ # Extract JSON from THREAT_DETECTION_RESULT line in threat analysis output
+ if [ -f "$(Agent.TempDirectory)/threat-analysis-output.txt" ]; then
+ RESULT_LINE=$(grep "THREAT_DETECTION_RESULT:" "$(Agent.TempDirectory)/threat-analysis-output.txt" | tail -1)
+ if [ -n "$RESULT_LINE" ]; then
+ # Extract JSON after the prefix
+ JSON_CONTENT="${RESULT_LINE##*THREAT_DETECTION_RESULT:}"
+ echo "$JSON_CONTENT" > "$(Agent.TempDirectory)/analyzed_outputs/threat-analysis.json"
+ echo "Extracted threat analysis JSON:"
+ cat "$(Agent.TempDirectory)/analyzed_outputs/threat-analysis.json"
+ else
+ echo "Warning: No THREAT_DETECTION_RESULT found in threat analysis output"
+ fi
+ else
+ echo "Warning: No threat analysis output file found"
+ fi
+
+ echo "Analyzed outputs directory contents:"
+ ls -laR "$(Agent.TempDirectory)/analyzed_outputs"
+ displayName: "Prepare analyzed outputs"
+ condition: always()
+
+ - bash: |
+ SAFE_TO_PROCESS="false"
+ JSON_FILE="$(Agent.TempDirectory)/analyzed_outputs/threat-analysis.json"
+
+ if [ -f "$JSON_FILE" ]; then
+ if jq -e . "$JSON_FILE" > /dev/null 2>&1; then
+ echo "JSON is valid"
+
+ # Check if any threat field is true
+ if jq -e '.prompt_injection or .secret_leak or .malicious_patch' "$JSON_FILE" > /dev/null 2>&1; then
+ echo "##vso[task.logissue type=warning]Threats detected - safe outputs will NOT be processed"
+ jq -r '.reasons[]? // empty' "$JSON_FILE" | sed 's/^/ - /'
+ else
+ echo "No threats detected - safe outputs will be processed"
+ SAFE_TO_PROCESS="true"
+ fi
+ else
+ echo "##vso[task.logissue type=warning]Invalid JSON in threat analysis - defaulting to unsafe"
+ fi
+ else
+ echo "##vso[task.logissue type=warning]No threat analysis JSON found - defaulting to unsafe"
+ fi
+
+ echo "##vso[task.setvariable variable=SafeToProcess;isOutput=true]$SAFE_TO_PROCESS"
+ echo "SafeToProcess set to: $SAFE_TO_PROCESS"
+ displayName: "Evaluate threat analysis"
+ name: threatAnalysis
+ condition: always()
+
+ - bash: |
+ # Copy all logs to analyzed outputs for artifact upload
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs"
+ if [ -d "{{ engine_log_dir }}" ]; then
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/copilot"
+ cp -r "{{ engine_log_dir }}"/* "$(Agent.TempDirectory)/analyzed_outputs/logs/copilot/" 2>/dev/null || true
+ fi
+ ADO_AW_LOG_DIR="${ADO_AW_LOG_DIR:-$HOME/.ado-aw/logs}"
+ if [ -d "$ADO_AW_LOG_DIR" ]; then
+ mkdir -p "$(Agent.TempDirectory)/analyzed_outputs/logs/ado-aw"
+ cp -r "$ADO_AW_LOG_DIR"/* "$(Agent.TempDirectory)/analyzed_outputs/logs/ado-aw/" 2>/dev/null || true
+ fi
+ echo "Logs copied to $(Agent.TempDirectory)/analyzed_outputs/logs"
+ ls -laR "$(Agent.TempDirectory)/analyzed_outputs/logs" 2>/dev/null || echo "No logs found"
+ displayName: "Copy logs to output directory"
+ condition: always()
+
+ - publish: $(Agent.TempDirectory)/analyzed_outputs
+ artifact: analyzed_outputs_$(Build.BuildId)
+ condition: always()
+
+ - job: {{ stage_prefix }}_Execution
+ displayName: "Execution"
+ dependsOn:
+ - {{ stage_prefix }}_Agent
+ - {{ stage_prefix }}_Detection
+ condition: and(succeeded(), eq(dependencies.{{ stage_prefix }}_Detection.outputs['threatAnalysis.SafeToProcess'], 'true'))
+ pool:
+ name: {{ pool }}
+ steps:
+ {{ checkout_self }}
+ {{ checkout_repositories }}
+
+ {{ acquire_write_token }}
+
+ - download: current
+ artifact: analyzed_outputs_$(Build.BuildId)
+
+ - bash: |
+ set -eo pipefail
+ COMPILER_VERSION="{{ compiler_version }}"
+ DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64"
+ CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt"
+
+ mkdir -p "$DOWNLOAD_DIR"
+ echo "Downloading ado-aw v${COMPILER_VERSION} from GitHub Releases..."
+ curl -fsSL -o "$DOWNLOAD_DIR/ado-aw-linux-x64" "$DOWNLOAD_URL"
+ curl -fsSL -o "$DOWNLOAD_DIR/checksums.txt" "$CHECKSUM_URL"
+
+ echo "Verifying checksum..."
+ cd "$DOWNLOAD_DIR" || exit 1
+ grep "ado-aw-linux-x64" checksums.txt | sha256sum -c -
+ mv ado-aw-linux-x64 ado-aw
+ chmod +x ado-aw
+ displayName: "Download agentic pipeline compiler (v{{ compiler_version }})"
+
+ - bash: |
+ ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw"
+ echo "##vso[task.prependpath]$(Pipeline.Workspace)/agentic-pipeline-compiler"
+ displayName: Add agentic compiler to path
+
+ - bash: |
+ mkdir -p "$(Agent.TempDirectory)/staging"
+ displayName: "Prepare output directory"
+
+ - bash: |
+ ado-aw execute --source "{{ source_path }}" --safe-output-dir "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)" --output-dir "$(Agent.TempDirectory)/staging"
+ EXIT_CODE=$?
+ if [ $EXIT_CODE -eq 2 ]; then
+ echo "##vso[task.complete result=SucceededWithIssues;]Executor completed with warnings"
+ exit 0
+ fi
+ exit $EXIT_CODE
+ displayName: Execute safe outputs (Stage 3)
+ workingDirectory: {{ working_directory }}
+ {{ executor_ado_env }}
+
+ - bash: |
+ # Copy all logs to output directory for artifact upload
+ mkdir -p "$(Agent.TempDirectory)/staging/logs"
+ # Copy agent output log from analyzed_outputs for optimisation use
+ cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \
+ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true
+ if [ -d "{{ engine_log_dir }}" ]; then
+ mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot"
+ cp -r "{{ engine_log_dir }}"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true
+ fi
+ ADO_AW_LOG_DIR="${ADO_AW_LOG_DIR:-$HOME/.ado-aw/logs}"
+ if [ -d "$ADO_AW_LOG_DIR" ]; then
+ mkdir -p "$(Agent.TempDirectory)/staging/logs/ado-aw"
+ cp -r "$ADO_AW_LOG_DIR"/* "$(Agent.TempDirectory)/staging/logs/ado-aw/" 2>/dev/null || true
+ fi
+ echo "Logs copied to $(Agent.TempDirectory)/staging/logs"
+ ls -laR "$(Agent.TempDirectory)/staging/logs" 2>/dev/null || echo "No logs found"
+ displayName: "Copy logs to output directory"
+ condition: always()
+
+ - publish: $(Agent.TempDirectory)/staging
+ artifact: safe_outputs
+ condition: always()
+
+ {{ teardown_job }}
diff --git a/tests/bash_lint_tests.rs b/tests/bash_lint_tests.rs
index ded51c0d..198d633c 100644
--- a/tests/bash_lint_tests.rs
+++ b/tests/bash_lint_tests.rs
@@ -78,6 +78,8 @@ const FIXTURES: &[&str] = &[
"pipeline-filter-agent.md",
"runtime-coverage-agent.md",
"runtime-coverage-1es-agent.md",
+ "job-agent.md",
+ "stage-agent.md",
];
/// Step display names that the lint expects to find at least once across all
@@ -158,13 +160,17 @@ fn compile_fixture(workspace: &Path, fixture: &str) -> (PathBuf, String) {
String::from_utf8_lossy(&output.stderr),
);
- // `ado-aw compile` prints `Generated pipeline: ` to stdout;
+ // `ado-aw compile` prints `Generated pipeline/template: ` to stdout;
// parse the target so the test can assert coverage of every known target.
let stdout = String::from_utf8_lossy(&output.stdout);
let target = if stdout.contains("Generated 1ES pipeline:") {
"1es"
} else if stdout.contains("Generated standalone pipeline:") {
"standalone"
+ } else if stdout.contains("Generated job template:") {
+ "job"
+ } else if stdout.contains("Generated stage template:") {
+ "stage"
} else {
panic!(
"could not determine compile target for {fixture} from stdout:\n{stdout}"
@@ -333,7 +339,7 @@ fn compiled_bash_bodies_pass_shellcheck() {
// at least one fixture, so we shellcheck the bash output of every template
// (`src/data/base.yml` and `src/data/1es-base.yml`) and every code-generated
// step on both targets.
- const REQUIRED_TARGETS: &[&str] = &["standalone", "1es"];
+ const REQUIRED_TARGETS: &[&str] = &["standalone", "1es", "job", "stage"];
let missing_targets: Vec<&str> = REQUIRED_TARGETS
.iter()
.copied()
diff --git a/tests/fixtures/job-agent.md b/tests/fixtures/job-agent.md
new file mode 100644
index 00000000..0850a601
--- /dev/null
+++ b/tests/fixtures/job-agent.md
@@ -0,0 +1,9 @@
+---
+name: "Job Test Agent"
+description: "Agent compiled as job template for testing"
+target: job
+---
+
+## Job Test Agent
+
+Review code changes and provide feedback.
diff --git a/tests/fixtures/stage-agent.md b/tests/fixtures/stage-agent.md
new file mode 100644
index 00000000..009b12c0
--- /dev/null
+++ b/tests/fixtures/stage-agent.md
@@ -0,0 +1,9 @@
+---
+name: "Stage Test Agent"
+description: "Agent compiled as stage template for testing"
+target: stage
+---
+
+## Stage Test Agent
+
+Review code changes and provide feedback.