Skip to content

ado-script

ado-script is the umbrella name for the TypeScript workspace at scripts/ado-script/. It produces small, ncc-bundled Node programs that the compiler injects into emitted pipelines as runtime helpers. Today it produces fifteen bundles:

  • gate.js — trigger-filter gate evaluator (Setup job)
  • import.js — runtime prompt resolver described in Runtime Imports (Agent job)
  • exec-context-pr.js — PR merge-base precompute described in Execution Context (Agent job, PR builds only)
  • exec-context-pr-synth.js — synthetic-PR resolver that promotes CI builds with an open PR to PR semantics (Setup job)
  • exec-context-manual.js — manual-context precompute that stages requested-by identity and parameter snapshot (Agent job)
  • exec-context-pipeline.js — pipeline-completion precompute that stages upstream build metadata (Agent job)
  • exec-context-ci-push.js — CI/push precompute that stages changed files and commits since the last green build (Agent job)
  • exec-context-workitem.js — PR-linked work-item precompute that stages work-item metadata and descriptions (Agent job)
  • exec-context-schedule.js — scheduled-run precompute that stages changed files since the last successful scheduled run (Agent job)
  • exec-context-pr-checks.js — PR Build Validation checks precompute that stages succeeded/failing check results (Agent job)
  • exec-context-repo.js — repository identity precompute that stages branch, SHA, last release tag, and commits-since-tag facts (Agent job)
  • conclusion.js — Conclusion job reporter that files/comments ADO work items for pipeline failures and diagnostic signals (Conclusion job)
  • approval-summary.js — renders a sanitized per-tool summary of proposed safe outputs into the build’s ado-aw-safe-outputs summary tab (end of Agent job)
  • github-app-token.js — mints (and revokes) a GitHub App installation token for the Copilot engine when engine.github-app-token is configured (Agent + Detection jobs)
  • prepare-pr-base.js — Agent patch-base mode uses ADO diff metadata to fetch and verify only the source/target history needed for the merge-base (bounded 200/500/2000 dual-ref fallback); SafeOutputs target-worktree mode fetches only the target tip at depth 1 (issue #1453)

gate.js is a single-shot Node program that runs as a step in the pipeline’s Setup job and decides whether the downstream Agent / SafeOutputs jobs should execute. It evaluates a declarative GateSpec against runtime facts (PR title, labels, changed files, build reason, etc.) and emits exactly one ##vso[task.setvariable] line:

##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true (or false)

Downstream jobs gate themselves on that variable via a condition: clause emitted by the compiler.

The gate is a data interpreter, not a code evaluator. The GateSpec is a typed JSON document; predicates are dispatched via a switch on a discriminated union. There is no eval, no Function, no vm — a compromised compiler cannot use the spec to run arbitrary code on the pipeline runner.

import.js is a single-shot Node program. It accepts the prompt file path as its first positional argument and resolves {{#runtime-import path}} markers in place. The compiler runs it as a post-prepare-prompt step when inlined-imports: false. See Runtime Imports for the author-facing marker syntax.

import.js takes no environment variables — all inputs are explicit CLI flags so the resolver cannot be influenced by ambient pipeline state:

Terminal window
node import.js <prompt-file> --base "$(Build.SourcesDirectory)" \
--var "Build.SourcesDirectory=$(Build.SourcesDirectory)" \
--var "Build.Repository.Name=$(Build.Repository.Name)"
  • --base <path> — root that relative marker paths resolve against. The compiler always passes $(Build.SourcesDirectory) (ADO expands the macro before Node runs). import.js rejects absolute and .. paths.
  • --var name=value (repeatable) — a small, compiler-owned allowlist of non-secret ADO variables (currently Build.BuildId, Build.Repository.Name, Build.SourcesDirectory, and System.CollectionUri, defined by PROMPT_ADO_VARS in src/compile/extensions/ado_script.rs). import.js receives the concrete expanded value and literally substitutes every $(name) occurrence in the final prompt. Unknown $(...) macros are left untouched. The allowlist is the set of --var flags the compiler emits, so an untrusted agent body cannot introduce new substitution variables.

Resolution is single-pass: nested markers inside an inlined body are not re-expanded.

The bundle lives at import.js and ships in the same ado-script.zip release asset as all other bundles, so pipelines download it through the same Agent-job asset flow. import.js uses only the Node standard library, so the ncc bundle is small (~1.5 KB) and carries no SDK dependency.

The Stage-2 threat-analysis prompt is not runtime-imported. src/data/threat-analysis.md is include_str!’d into the ado-aw binary and inlined into the emitted YAML at compile time, matching gh-aw’s pattern (their threat_detection.md ships with the setup action and is read directly from disk — no marker, no resolver).

exec-context-pr.js is a single-shot Node program injected into the Agent job’s prepare phase only on PR-triggered builds. It resolves the PR merge-base and stages artefacts the agent can use to reason about the diff:

aw-context/
└── pr/
├── base.sha # target merge-base SHA (40-char hex)
├── head.sha # PR head SHA (40-char hex)
└── error.txt # present only on failure; one-line reason

It also appends a tailored prompt fragment to /tmp/awf-tools/agent-prompt.md (the file assembled by the “Prepare agent prompt” base-YAML step). On success the fragment tells the agent its PR number, project, repository, and provides pre-filled example ADO MCP tool call arguments. On failure it surfaces a graceful degradation message so the agent can still proceed without panicking.

Two paths, both computing the same “merge-base of target tip and PR head”:

  1. Synthetic merge commit — ADO’s default PR checkout produces a merge commit (HEAD has ≥ 2 parents). HEAD^1 is the target tip; HEAD^2 is the PR head. The bundle runs git merge-base HEAD^1 HEAD^2; if that fails in a shallow checkout, it fetches both target and source refs with bounded deepening and retries before failing closed.
  2. Bounded deepening — when HEAD is a normal commit (shallow clone without a merge commit), the bundle fetches source and target together at increasing depths (--depth=200, 500, 2000) until git merge-base origin/<target> HEAD resolves.

SYSTEM_ACCESSTOKEN is mapped only into this step’s env: block — never into the agent step’s env. The bearer is passed to spawned git child processes via GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 env vars (the http.extraheader pattern), so it never appears in argv or in .git/config.

Env var ADO source Purpose
SYSTEM_ACCESSTOKEN $(System.AccessToken) Bearer for git fetch of the target branch
SYSTEM_PULLREQUEST_PULLREQUESTID $(System.PullRequest.PullRequestId) PR number (validated: digits only)
SYSTEM_PULLREQUEST_TARGETBRANCH $(System.PullRequest.TargetBranch) Target branch ref (e.g. refs/heads/main)
SYSTEM_TEAMPROJECT $(System.TeamProject) ADO project name (validated: alphanumeric + . _ -)
BUILD_REPOSITORY_NAME $(Build.Repository.Name) Repository name (validated: alphanumeric + . _ -)
BUILD_SOURCESDIRECTORY $(Build.SourcesDirectory) Workspace root — where aw-context/pr/ is staged

All four identifier env vars are validated against strict allowlist regexes before any value is interpolated into a git refspec or the agent prompt. A validation failure writes aw-context/pr/error.txt and a failure-fragment to the agent prompt, then exits 0 so the rest of the pipeline can continue.

┌──────────────────────┐
│ Rust compiler │
│ (filter_ir.rs) │
└──────────┬───────────┘
│ build_gate_spec(...) → GateSpec (JSON, base64)
┌──────────────────────┐
│ Generated pipeline │
│ Setup job: │
│ 1. UseNode@1 │
│ 2. curl + sha256 │ downloads ado-script.zip
│ + unzip │ from the matching ado-aw release
│ 3. node gate/index │ reads GATE_SPEC env var
│ .js │
└──────────┬───────────┘
│ ##vso[task.setvariable variable=SHOULD_RUN;…]
┌──────────────────────┐
│ Agent / SafeOutputs │ conditioned on SHOULD_RUN=true
│ jobs │
└──────────────────────┘

The same GateSpec shape is generated as a JSON Schema by cargo run -- export-gate-schema and converted to TypeScript by json-schema-to-typescript into src/shared/types.gen.ts. The TS gate evaluator imports from types.gen.ts, never from a hand-written mirror of the IR — so the spec contract cannot drift between compiler and evaluator. CI enforces this with a git diff --exit-code step on the codegen output.

gate.js’s entry point is src/gate/index.ts. It runs five stages, all single-shot, all fail-closed on error:

  1. Decode + size-cap — base64-decode GATE_SPEC, reject if the decoded JSON exceeds MAX_SPEC_DECODED_BYTES (256 KiB), then JSON.parse.
  2. Pre-flight validation — walk the predicate tree and throw on any unknown type discriminant. This catches version drift between a newer compiler and an older bundled gate.js before fact acquisition runs, so the failure mode is “loud” rather than “silent skip when the dependent fact is unavailable”. Deliberately runs before runBypass so a malformed spec fails fast regardless of build reason.
  3. Bypass — if ADO_BUILD_REASON does not match spec.context.build_reason (e.g. spec is for PullRequest but the build is Manual), auto-pass: emit SHOULD_RUN=true, tag the build, complete Succeeded, exit.
  4. Fact acquisition — for every FactSpec in the spec, either read a pipeline env var (isPipelineVarFact) or call the ADO REST API (pr_metadata, pr_labels, changed_files, …). Each per-fact failure is recorded in the PolicyTracker and dispatched via that fact’s failure_policy (fail_closed / fail_open / skip_dependents).
  5. Predicate evaluation — for each CheckSpec, the PolicyTracker decides whether the check is evaluate, pass, skip, or fail based on which referenced facts are still available. Evaluator dispatches the predicate via the switch in evaluatePredicate. Failing checks emit addBuildTag and the overall SHOULD_RUN is true iff every check is pass or skip.

If SHOULD_RUN ends up false, selfCancelIfRequested issues a best-effort BuildStatus.Cancelling PATCH so the pipeline run is visibly cancelled in the ADO UI rather than just paused on a gated job.

The compiler injects these environment variables on the bash: node gate.js step. gate.js reads them via process.env:

Env var Source Purpose
GATE_SPEC compiled inline (base64) The full GateSpec JSON
SYSTEM_ACCESSTOKEN $(System.AccessToken) ADO REST auth
SYSTEM_COLLECTIONURI ADO auto-injected ADO org base URL (read via getWebApi(); falls back to SYSTEM_TEAMFOUNDATIONCOLLECTIONURI)
ADO_BUILD_REASON $(Build.Reason) Used by the bypass branch
ADO_BUILD_ID $(Build.BuildId) Used for selfCancelIfRequested
ADO_PROJECT / ADO_REPO_ID / ADO_PR_ID compiler-injected PR-derived facts
ADO_* (fact-specific) Fact::ado_exports() in Rust Per-fact pipeline-variable readers (e.g. ADO_PR_TITLE, ADO_SOURCE_BRANCH)
ADO_API_TIMEOUT_MS optional override Per-attempt timeout for every ADO REST call. Default 30 000. On timeout, the call is retried once; if the retry also times out, the gate falls back to the per-fact FailurePolicy.

The exact contract for pipeline-variable facts (which env var maps to which FactKind) lives in two places that must stay in lockstep:

  • Rust: Fact::ado_exports() in src/compile/filter_ir.rs
  • TS: ENV_BY_FACT plus the FactKind union in scripts/ado-script/src/shared/env-facts.ts

The codegen drift check only mirrors the GateSpec shape, not the env-var mapping, so when adding a new pipeline-variable fact you must update both sides by hand. Fact::ado_exports() carries a docstring pointing at the TS mirror as a reminder.

scripts/ado-script/
├── package.json # type:module; dep: azure-devops-node-api (lazy-imported)
├── tsconfig.json # strict; noUncheckedIndexedAccess; NodeNext
├── src/
│ ├── shared/ # Reusable across all bundles
│ │ ├── types.gen.ts # AUTO-GENERATED from Rust IR — do not edit
│ │ ├── auth.ts # WebApi factory; SDK is dynamic-imported here
│ │ ├── ado-client.ts # azure-devops-node-api wrapper + retry + timeout + pagination
│ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping
│ │ ├── policy.ts # PolicyTracker state machine
│ │ ├── vso-logger.ts # ##vso[…] emitters with property/message escaping; complete() is idempotent
│ │ ├── git.ts # execFile wrappers + bearerEnv helper
│ │ ├── merge-base.ts # synthetic-merge detection + progressive-deepening fetch
│ │ ├── validate.ts # identifier regex guards
│ │ ├── prompt.ts # agent-prompt-file append helpers
│ │ └── build.ts # Build REST helpers (used by pipeline / ci-push / pr-checks)
│ ├── gate/ # gate.js entry point + per-concern modules
│ │ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit
│ │ ├── bypass.ts # build-reason auto-pass
│ │ ├── facts.ts # fact acquisition (env + REST)
│ │ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening
│ │ └── selfcancel.ts # best-effort build cancellation
│ ├── import/ # import.js entry point + runtime prompt resolver
│ │ ├── index.ts # main(): expand runtime-import markers in place
│ │ └── __tests__/ # marker, path-resolution, and single-pass coverage
│ ├── exec-context-pr/ # exec-context-pr.js entry point + PR precompute
│ │ ├── index.ts # main(): validate → resolve merge-base → stage SHAs → append prompt
│ │ └── __tests__/ # end-to-end / integration tests
│ ├── exec-context-pr-synth/ # exec-context-pr-synth.js entry point + synthetic-PR resolver
│ │ ├── index.ts # main(): real-PR / GitHub / synth-promote branch resolution → emit AW_PR_*
│ │ ├── match.ts # branch/path include-exclude glob matching
│ │ ├── spec.ts # PR_SYNTH_SPEC base64 decode + validation
│ │ └── __tests__/ # unit tests across the three modules
│ ├── exec-context-manual/ # exec-context-manual.js entry point + manual-context precompute
│ │ ├── index.ts # main(): collect PARAM_* env vars → JSON snapshot → prompt fragment
│ │ └── __tests__/ # unit tests for success / failure / sanitisation paths
│ ├── exec-context-pipeline/ # exec-context-pipeline.js entry point + pipeline-completion precompute
│ │ ├── index.ts # main(): validate TriggeredBy ids → fetch upstream Build via REST → stage + prompt
│ │ └── __tests__/ # unit tests for validate / success / failure / sanitisation paths
│ ├── exec-context-ci-push/ # exec-context-ci-push.js entry point + CI/push diff context
│ │ ├── index.ts # main(): find last green build → stage changed files / commits → prompt
│ │ └── __tests__/ # unit tests for success / fallback / sanitisation paths
│ ├── exec-context-workitem/ # exec-context-workitem.js entry point + PR-linked work-item context
│ │ ├── index.ts # main(): fetch linked WIs → stage metadata + descriptions → prompt
│ │ └── __tests__/ # unit tests for fetch / truncation / sanitisation paths
│ ├── exec-context-schedule/ # exec-context-schedule.js entry point + scheduled-run diff context
│ │ ├── index.ts # main(): find prior scheduled success → stage changed files / commits → prompt
│ │ └── __tests__/ # unit tests for success / no-baseline / sanitisation paths
│ ├── exec-context-pr-checks/ # exec-context-pr-checks.js entry point + PR validation checks context
│ │ ├── index.ts # main(): fetch policy/build checks → stage failing/succeeded JSON → prompt
│ │ └── __tests__/ # unit tests for checks filtering / sanitisation paths
│ ├── exec-context-repo/ # exec-context-repo.js entry point + repository identity context
│ │ ├── index.ts # main(): stage branch/SHA/tag/commits-since-tag facts → prompt
│ │ └── __tests__/ # unit tests for identity / tag fallback / sanitisation paths
│ └── conclusion/ # conclusion.js entry point + Conclusion-job reporter
│ ├── index.ts # main(): inspect upstream results + safe-outputs manifest → file/append work items
│ └── __tests__/ # unit tests for signal detection and work-item filing behaviour
├── test/ # End-to-end smoke tests
├── gate.js # ncc bundle output (gitignored)
├── import.js # ncc bundle output (gitignored)
├── exec-context-pr.js # ncc bundle output (gitignored)
├── exec-context-pr-synth.js # ncc bundle output (gitignored)
├── exec-context-manual.js # ncc bundle output (gitignored)
├── exec-context-pipeline.js # ncc bundle output (gitignored)
├── exec-context-ci-push.js # ncc bundle output (gitignored)
├── exec-context-workitem.js # ncc bundle output (gitignored)
├── exec-context-schedule.js # ncc bundle output (gitignored)
├── exec-context-pr-checks.js # ncc bundle output (gitignored)
├── exec-context-repo.js # ncc bundle output (gitignored)
├── conclusion.js # ncc bundle output (gitignored)
└── approval-summary.js # ncc bundle output (gitignored)

The release workflow (.github/workflows/release.yml) runs npm ci && npm run build, then zips all thirteen bundles — gate.js, import.js, exec-context-pr.js, exec-context-pr-synth.js, exec-context-manual.js, exec-context-pipeline.js, exec-context-ci-push.js, exec-context-workitem.js, exec-context-schedule.js, exec-context-pr-checks.js, exec-context-repo.js, conclusion.js, and approval-summary.js — into the ado-script.zip release asset. Pipelines download that asset at runtime by URL pinned to the compiler’s CARGO_PKG_VERSION, verify its SHA-256 against the checksums.txt asset, then extract.

types.gen.ts is derived from the Rust IR via schemarsjson-schema-to-typescript:

┌──────────────────────────┐ schemars ┌──────────────────────────┐
│ src/compile/filter_ir.rs │ ───────────► │ schema/gate-spec.schema │
│ #[derive(JsonSchema)] │ │ .json │
└──────────────────────────┘ └────────────┬─────────────┘
│ json2ts
┌──────────────────────────────┐
│ src/shared/types.gen.ts │
│ (consumed by gate/*.ts) │
└──────────────────────────────┘

npm run codegen runs both stages. The CI workflow (.github/workflows/ado-script.yml) regenerates the file and runs git diff --exit-code to fail on drift, on both PRs and pushes to main. If you change the IR shape in Rust, run cd scripts/ado-script && npm run codegen and commit the regenerated types.gen.ts.

The Rust subcommand that emits the schema is intentionally hidden:

Terminal window
cargo run -- export-gate-schema --output schema/gate-spec.schema.json

How the bundles are wired into emitted pipelines

Section titled “How the bundles are wired into emitted pipelines”

AdoScriptExtension (src/compile/extensions/ado_script.rs) is the always-on single extension that owns all ado-script wiring. It has two independent features, each emitted into the job that actually consumes the bundle:

When filters: lowers to non-empty checks, AdoScriptExtension::declarations() returns three typed Declarations::setup_steps entries for the Setup job:

  1. UseNode@1 — installs Node 22.x LTS, capped at timeoutInMinutes: 5.
  2. curl download + verify + extract — fetches checksums.txt and ado-script.zip from the githubnext/ado-aw release matching CARGO_PKG_VERSION, verifies the zip’s SHA-256, then unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/. Also capped at timeoutInMinutes: 5.
  3. bash: node '/tmp/ado-aw-scripts/ado-script/gate.js' — runs the gate with GATE_SPEC and the env-var contract documented above.

AdoScriptExtension::declarations() contributes Agent-job prepare steps when import.js is active (i.e. inlined-imports: false, the default). It returns install + download first, then the invocation step.

import.js invocation — active when inlined-imports: false (the default):

  1. UseNode@1 — installs Node 22.x LTS, capped at timeoutInMinutes: 5.
  2. curl download + verify + extract — same artefact and verification as the Setup job.
  3. bash: node '/tmp/ado-aw-scripts/ado-script/import.js' — expands {{#runtime-import …}} markers in /tmp/awf-tools/agent-prompt.md in place. See Runtime Imports for marker syntax.

The execution-context bundles (exec-context-*.js) are owned by ExecContextExtension (src/compile/extensions/exec_context/), not AdoScriptExtension. Phase ordering (AdoScriptExtension::phase() == System < ExecContextExtension::phase() == Tool) guarantees the bundle is installed and on disk before any exec-context invocation runs. ExecContextExtension emits its own install + download pair for the Agent job and the Setup job (exec-context-pr-synth.js) independently.

ADO jobs use isolated VMs/tmp is not shared between jobs. The ado-script.zip bundle therefore has to be downloaded once per job that consumes it. When both the Setup gate and Agent resolver/PR-context are active, install + download steps appear in both Setup and Agent. That’s correct architecture given ADO’s topology, not waste.

The rows below assume the synthetic-PR resolver is not active:

Setup consumer Agent consumer Setup-job steps Agent-job extra steps
no gate none (none) (none)
no gate inlined-imports: false only (no Setup job) install + download + resolver
no gate execution-context contributor(s) only (no Setup job) install + download + exec-context bundle(s)
no gate resolver + execution-context (no Setup job) install + download + resolver + exec-context bundle(s)
gate none install + download + gate (none)
gate any combination install + download + gate install + download + (resolver?) + (exec-context bundle(s)?)

When the synthetic-PR resolver is active (on.pr configured with synthetic-PR support), the Setup job gains a synthPr step (node exec-context-pr-synth.js) before any gate step — and the Setup job is emitted even with no gate:

Setup consumer Setup-job steps
synth-PR (no gate) install + download + synth-PR
gate (no synth-PR) install + download + gate
synth-PR + gate install + download + synth-PR + gate

The IR-to-bash codegen that produces the gate step is compile_gate_step_external in src/compile/filter_ir.rs.

  1. Add a Predicate + PredicateSpec variant in src/compile/filter_ir.rs. Run cargo test and update spec tests.
  2. In scripts/ado-script/, run npm run codegen so types.gen.ts picks up the new variant.
  3. Add a case to the switch in src/gate/predicates.ts::evaluatePredicate.
  4. Add the new type name to KNOWN_PREDICATE_TYPES (right above the validatePredicateTree function). Both updates are required — the drift test KNOWN_PREDICATE_TYPES stays in sync with evaluatePredicate switch in predicates.test.ts will fail if you forget either.
  5. Add a vitest case under src/gate/__tests__/ports/<new-predicate>.test.ts.
  1. Add a Fact variant in src/compile/filter_ir.rs and update Fact::ado_exports(). (Its docstring reminds you about step 3.)
  2. npm run codegen to regenerate types.
  3. Add an entry to ENV_BY_FACT and extend the FactKind union in scripts/ado-script/src/shared/env-facts.ts. Without this step the gate silently treats the fact as missing.
  4. If the fact value is ref-shaped (e.g. a branch name), add it to the exported BRANCH_FACTS set so the read-time strip is applied.

The existing exec-context-pr.js bundle is a working example of this pattern — see scripts/ado-script/src/exec-context-pr/ and src/compile/extensions/exec_context/pr.rs.

  1. Create src/poll/index.ts and supporting modules under scripts/ado-script/src/poll/. Reuse anything in src/shared/.
  2. Add a build script to package.json:
    "build:poll": "ncc build src/poll/index.ts -o .ado-build/poll -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/poll/index.js','poll.js'); fs.rmSync('.ado-build/poll',{recursive:true,force:true});\""
    and extend build to also run it.
  3. Add vitest tests under src/poll/__tests__/.
  4. Wire from a new CompilerExtension (or extend an existing one) that downloads ado-script.zip (already a release asset) and invokes node /tmp/ado-aw-scripts/ado-script/poll.js as a runtime step.
  5. Update release packaging to include scripts/ado-script/poll.js in ado-script.zip alongside other bundles.

From scripts/ado-script/:

Terminal window
npm ci # one-time
npm run codegen # regenerate types.gen.ts (compiles ado-aw first)
npm test # vitest unit tests
npm run typecheck # strict tsc --noEmit
npm run build # ncc-bundle to gate.js
npm run test:smoke # build + smoke test the bundle end-to-end

The Rust-side E2E gate test compiles a real agent, extracts the emitted GATE_SPEC, and shells out to the bundled gate.js:

Terminal window
cargo test --test gate_e2e -- --ignored --nocapture

Each bundled artifact must stay under 5 MB. The entry-point chunk for gate.js is ~78 KB; the lazy-imported azure-devops-node-api SDK lives in a separate ~2.7 MB chunk loaded only when an ADO REST call is needed. Pipelines that bypass or rely only on pipeline-variable facts never load the SDK.

If a future bundle blows the budget:

  • First, check ncc’s --minify and --target flags.
  • If still too large, weigh dropping azure-devops-node-api in favor of hand-rolled fetch for the hot endpoints. The retry / timeout / pagination helpers in src/shared/ado-client.ts are written so they could wrap either approach.
  • A user-facing ado-script: front-matter block. Letting authors run arbitrary TypeScript at pipeline runtime would bypass the safe-output trust boundary and require sandboxing the project does not have.
  • Migrating the safe-output executors (src/safe_outputs/*.rs) to Node. Stage 3 keeps a Rust-only execution path.
  • Migrating the agent-stats parser. It runs in-pipeline as part of Stage 1 wrap-up and has no TypeScript dependency need.
  • Bundling Node itself. Pipelines install Node via UseNode@1.