Claude Skill

zuke-write-build

Write or edit a Zuke build (zuke.ts) — the code-first, strongly-typed build system for Deno/TypeScript. Use when adding or changing targets, wiring dependencies, calling a tool wrapper (DenoTasks, NpmTasks, DockerTasks, ...), generating CI, or authoring/refactoring a zuke.ts buil

LLM Mart · 0 points · 13 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zuke-build-zuke-skills_zuke-write-build-9fd4537.zip · 44 KB
Part of zuke-build/zuke — 4 skills

Install

skills CLI npx skills add https://github.com/zuke-build/zuke/tree/master/skills/zuke-write-build
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zuke-build-zuke@llmmart
Git git clone https://github.com/zuke-build/zuke.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zuke-build/zuke collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Write or edit a Zuke build

A build is a class that extends Build. Each target is a class field created with target() and made runnable with await run(MyBuild) at the bottom of zuke.ts (no import.meta.main guard — run no-ops on import).

import { Build, run, target } from "@zuke/core";
import { DenoTasks } from "@zuke/deno";

class CI extends Build {
  lint = target()
    .description("Lint sources")
    .executes(async () => {
      await DenoTasks.lint();
    });

  test = target()
    .description("Type-check and test")
    .dependsOn(this.lint)
    .executes(async () => {
      await DenoTasks.test((s) => s.allowAll().coverage("cov_profile"));
    });

  // A field named `default` runs when no target is named on the CLI.
  default = target().dependsOn(this.test).executes(() => {});
}

await run(CI);

Non-negotiable rules

  1. Import @zuke/* by bare specifier, and declare it in deno.json. Write import { DenoTasks } from "@zuke/deno"; and add "@zuke/deno": "jsr:@zuke/deno@^1" to the imports block of the project's deno.json — never an inline jsr:@zuke/deno@^1 in the import statement. Deno's default lint set (the one that applies when deno.json configures no lint.rules, which is what zuke setup scaffolds) rejects an inline jsr: specifier under no-import-prefix, so an inlined one fails the project's own deno task lint. Pin the caret major in the import map instead, so a future @zuke major cannot land unannounced. zuke setup already declares @zuke/core there; adding a wrapper means adding its entry too.
  2. Dependencies are this.<field> references, never strings. .dependsOn(this.lint), not .dependsOn("lint") — so renames and typos are compile-time errors.
  3. A target may only depend on siblings declared above it. Class fields initialise top-to-bottom; a forward reference is undefined and is reported as an error (TypeScript also flags it, TS2729). Order fields so dependencies come first.
  4. Check the package catalogue before writing any command. llms.txt's ## Packages catalogue (raw: https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt) and the package table in references/cheatsheet.md are the only ways to answer "does a @zuke/<tool> wrapper exist for this CLI?" — per-package deno doc jsr:@zuke/<pkg> only describes a package whose name you already know; it cannot tell you a wrapper exists. Whatever runs in an .executes(...) body drives an external tool through its namespaced *Tasks object, configured with a settings lambda that mirrors the real CLI's flags — DenoTasks, NpmTasks, DockerTasks, GitTasks, and 30+ more — never a raw Deno.Command or shell string. jsr:@zuke/cmd (CmdTasks.exec) or the $ shell from jsr:@zuke/core/shell is the last resort, reached for only once the catalogue confirms no typed wrapper exists — using it for a tool that has a @zuke/<tool> package is a bug, not a style choice: it discards typed flags, argv purity, and tool resolution. (If a build delegates its side effects to your own tested modules behind injected clients, the wrapper rule still governs whatever those modules run in the target body.)
  5. A body is required, unless the target is one of the four forms that replace it: a service(), a .forEach() fan-out, a .waitsFor() gate, or a target declaring only .effect(...). Otherwise set .executes(...); it may be sync or async, and its return value is ignored — .executes(() => DenoTasks.lint()) is fine as-is; never wrap a single wrapper call in an async block just to discard its result.

Find the exact signature first

Before calling any task or settings method, confirm the real shape — but first confirm a wrapper exists at all: deno doc needs a package name to target, so it cannot answer "does one exist for this tool?"; only the catalogue (llms.txt's ## Packages list or the cheatsheet table below) can.

  • One package — prefer this in a consumer repo, once you know its name: deno doc jsr:@zuke/<package> (e.g. deno doc jsr:@zuke/deno). It resolves the version the project actually has installed, so it cannot describe an API that version lacks.
  • Whole surface: llms-full.txt (index: llms.txt) — at the repo root in the Zuke repo itself. From a consumer repo, fetch https://raw.githubusercontent.com/zuke-build/zuke/master/llms-full.txt (index: https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt). Both track master, so they can document symbols that are merged but not yet in any published release. Use them for breadth — which packages and tasks exist — and confirm a signature with deno doc before relying on it.
  • A quick map of the most common methods and task objects is in references/cheatsheet.md next to this file — read it when wiring targets, then verify specifics against the sources above.

Workflow for a change

  1. Read the existing zuke.ts to learn the targets already declared and their order.
  2. Identify the tool you need and look up its *Tasks object and settings methods (cheatsheet → deno doc / llms-full.txt).
  3. Add or edit the target field. Place it below every target it depends on. Wire dependencies with this.<field>.
  4. Validate: ./zuke --list shows it; ./zuke <target> --dry-run previews the plan; ./zuke <target> runs it.

Common building blocks (see the cheatsheet for details)

  • Parallel batches: group() + .partOf(this.group) run members concurrently; depend on the group to wait for all of them.
  • Reusable bundles: a component is a function returning related targets; assign it to a field and reference members as this.release.publish.
  • Long-lived processes: service() models a process that must stay running while dependents execute (dev server, database, mock API). Declared and depended on like a target, but with a .start(...) / .readyWhen(...) lifecycle instead of .executes(...); the executor starts it, waits until ready, then stops it in a finally so it never leaks. See the cheatsheet.
  • Authorization by role: .requiresRole("operator") gates running a target over MCP, enforced across the whole plan it would execute; override mcpAuthorize(identity, call) decides the rest. See the cheatsheet.
  • Target context & cancellation: a body may take a context — .executes((ctx) => …) — with ctx.runId, ctx.initiator (who asked for the run, unchanged by a resume), ctx.target, ctx.signal (an AbortSignal fired when the run is cancelled; a plain $`…` in the body is SIGTERM'd automatically), ctx.state, ctx.dryRun, ctx.plan() (the run's planned shape — targets, includes(name), dependenciesOf(name) — so a body can ask whether deploy was part of what was asked for; it reports the plan, never what will actually execute, which is ctx.outcomeOf(name)), and ctx.reportSummary({ … }) (key: value notes on the target's own row of the Build Summary; every test-runner wrapper — DenoTasks.test, VitestTasks.run, JestTasks.run, BunTasks.test, NodeTasks.test, PlaywrightTasks.test, CypressTasks.run — reports its test counts there by itself, and the ambient reportSummary does the same from code with no ctx). Zero-argument bodies keep working unchanged. Cancel a run programmatically by passing { signal } to execute. See the cheatsheet.
  • Caching: .inputs(...) / .outputs(...) make a target incremental. Add a remote store to share results across machines (fresh CI, teammates); --affected runs only targets changed since a git base; --no-cache / --no-remote-cache bypass them. A restore is confined to the target's declared .outputs(...) (and never .git/.zuke); a refused archive is a cache miss with a warning, not a failure. A cancelled run keeps its cache unless a compensation actually rolled something back.
  • Durable run state: persist a run's status and per-target metadata to a pluggable StateStore so it survives the process — turn it on with --state, ZUKE_STATE_DIR / ZUKE_STATE_URL, or override stateStore(). Every ZUKE_*_URL backend must be https: (loopback exempt; ZUKE_ALLOW_INSECURE_URL=1 opts out). In a body, ctx.state.set({ … }) / ctx.state.get() records per-target metadata (JSON, never secrets — secret parameters and redacted values are excluded). set awaits the write; ctx.state.trySet({ … }) is the same write reporting true when it reached the store and false when it was dropped — use it before an irreversible step that depends on the value. A store-less build and a compensation body always see true (nothing durable behind them). Inspect persisted runs afterwards with zuke runs list (filter by --status/--target/--since/--limit) and zuke runs show <id> (--json on both). Prune old ones with zuke runs prune --keep <age> --keep-last <n> (only terminal runs; never suspended/running). A run whose process is killed is picked up by zuke resume --check, which reaps it — its lease tells a dead holder from a slow one — and resumes it in the same sweep. A process that merely looked dead and then finds its lease taken over stops, running no compensations and settling nothing: the run is the new holder's now. override deadline() gives a run a wall-clock budget ("45m", or milliseconds) that survives suspension; an abandoned run found past it is settled failed with its compensations instead of resumed. On a shared store, set ZUKE_BUILD_ID (or rely on GITHUB_REPOSITORY) so each build only recovers its own runs — a resume runs this build's bodies against whatever record it is given, and a templated zuke.ts looks identical to the shape checks. See the cheatsheet.
  • Cross-run locks: .lock((s) => s.lockKey(...).withTtl("4h")) — a settings lambda — gives a target an exclusive claim across runs/machines; a second run wanting the same key fails with a LockConflictError naming the holder, or queues when the target adds .waitUpTo("30m") (paced by .pollEvery). The lambda runs after params resolve, so the key can read this.<param>.value. The lock releases when the target settles and expires after the TTL if the holder is killed. Needs a state store (a build with .lock() enables the filesystem store by default). See the cheatsheet.
  • External-event waits: .waitsFor((s) => s.on(externalSignal("approved")).timeout("72h")) makes a target a gate with no body: the run proceeds past it only when the trigger is satisfied, otherwise it suspends (state saved, exits 0) to be resumed later in a fresh process. Triggers: externalSignal(name) (payload read via ctx.signals) and resumeWhen(predicate). Continue it with zuke resume <id> --signal <name> [--data <json>] (or --check for predicate waits/timeouts) — exactly-once, re-running only the not-yet-succeeded targets. Needs a state store. See the cheatsheet / docs/orchestration.md.
  • Cancellation & compensation: .onCancel(() => this.rollback) registers a compensation that runs iff this target succeeded when the run is later cancelled — compensations run in reverse order, and the compensation body's ctx.state exposes the original target's persisted metadata (so a rollback reads what the deploy recorded). Cancel with zuke cancel <id> (or Ctrl-C, or the MCP cancel_run tool). Idempotent; a timed-out wait can route its onTimeout here ("cancel-run" or a named target). Needs a state store. See docs/orchestration.md.
  • Durable side effects: .effect(name, fn) records the intent to run fn before it runs, so a resume re-drives an effect a dead process left owed. Effects run after the body, in declaration order; a target may declare effects and no body. The guarantee is at-least-once, so write bodies that tolerate a repeat (an upsert, not an append), and read what the effect acts on from ctx.state rather than looking up "the current value" — a re-drive happens later, against a world that moved on. Needs a state store (enabled automatically). See the cheatsheet / docs/orchestration.md.
  • Fan-out over a list: .forEach(() => this.repos.value, (repo) => ({ checks: target()…, deploy: target()… }), (s) => s.concurrency(3).continueOnItemFailure()) runs the same pipeline over a runtime list — items concurrent, each item's stages sequential. Sub-targets are materialised at run time (parent[item].stage), each a first-class row in the summary and the run record; continueOnItemFailure() isolates a failed item. An .onCancel(...) on a fan-out stage runs per item on cancel (item-scoped ctx.state, reverse order). See docs/orchestration.md.
  • Typed inputs: parameter("...") (with .number() / .boolean() / .options(...) / .secret() / .required()), read as this.x.value, gated with .requires(this.x). .array() composes and comes last: .options(...).array() validates each element, .number().array() → number[], and a required list is .required().array() (required before array — .array().required() does not typecheck). A parameter may not be named so that it renders as a built-in CLI flag (actor, actorKind, limit, target, output, …) or as an MCP control key (dryRun, confirm, operatorToken) — the build refuses to load, naming the field. The flag is one dash per lower-to-upper transition, and a digit ends a run of capitals, so skipE2E gives --skip-e2-e; name it skipE2e or declare .flag("--skip-e2e"), which replaces the derived spelling everywhere.
  • Secrets from a manager: parameter(...).secret().from(source) sources a value at run time (e.g. execSecret(...) shelling out to a secret CLI) and redacts it from all of Zuke's output. See the cheatsheet.
  • Provisioning tools: ToolTasks.install((s) => …) / toolchain((t) => …) fetch pinned, checksum-verified release binaries so a build is hermetic, and t.npm({ name, version, bin? }) / ToolTasks.npm(...) provision a version-pinned, cached npm-registry package (needs npm on PATH); hand the returned path to a wrapper's .toolPath(...). In a Node monorepo, resolve a wrapper's binary from node_modules/.bin npx-style instead — .fromNodeModules() on the settings (or ZUKE_TOOL_RESOLUTION=node_modules repo-wide) walks up for the local shim and falls back to PATH; .fromPath() forces PATH and an explicit .toolPath(...) always wins. See the cheatsheet / docs/tools.md.
  • Code-first CI: cicd({ provider: "github" }) generates and verifies the workflow YAML from the build.
  • Operate the build from an agent: zuke mcp serves the build over MCP so an AI client can list, inspect, and (with --allow-run) run targets — on stdio, or over HTTP with --http <host:port> (loopback by default; a non-loopback bind needs a ZUKE_MCP_TOKEN bearer token or an mcpAuth()/mcpIdentity() authenticator, else the server exits 1). With a state store it also exposes list_runs/show_run (+ signal_run, resume_check and cancel_run). Tier access with --allow-run=<globs> (an allow-list over invocation — invoking a target runs its dependencies, and the read tools narrow to the allow-listed targets' closure), --protect <globs> + ZUKE_OPERATOR_TOKEN (enforced over a run's whole plan, so a protected target reached as a dependency still needs the token), and --confirm-destructive; mark inspect-only targets .readOnly(). Mutating/denied calls are audited — read the trail on the host with zuke runs show mcp-audit; it is deliberately not readable over MCP. A registry-backed server (zuke register then zuke mcp --registry) instead serves every registered pipeline live, each as a run:<buildId>:<target> tool that takes the build's declared parameters (secrets excluded, validated, forwarded to the spawn) — see the cheatsheet. Because the registry names where a build launches from, a descriptor with a remote entry module is refused unless its origin is in ZUKE_REGISTRY_LAUNCH_HOSTS, and a command location is refused unless its program is in ZUKE_REGISTRY_LAUNCH_COMMANDS (the registry writer picks the program and its arguments); zuke register writes a local module, so both only affect a hand-authored or second-party entry. For a shared, multi-user endpoint, override mcpAuth() authenticates a trusted caller per request — an async authenticate(ctx) returning { actor, kind?, roles?, via? } or an McpAuthReject ({ status, error, detail?, challenge? }), so a refused HTTP request answers that status with WWW-Authenticate instead of a 200. override mcpIdentity() is the sugar for the proxy-header case (a sync hook reading a header; any throw rejects), adapted onto the same path — declare one or the other, never both, or the server exits 1. Either overrides the client-reported actor and flows to the audit trail, run records, lock holders, and a registry-spawned child's ZUKE_ACTOR/ZUKE_ACTOR_KIND/ ZUKE_ACTOR_ROLES. Both are fail-closed: a throw, a non-object, or an empty actor refuses the request, and nothing runs. override mcpProtectedResource() publishes the RFC 9728 metadata document and names it in every challenge, so a client that has no token can discover the identity provider — Zuke is the resource only, and hosts no OAuth endpoints of its own. See the cheatsheet.
  • AI review & self-healing (@zuke/ai): gate a target on a structured LLM review of the diff (securityReviewer(...) etc. via .validateBefore), or attach aiFixer(...) with .recoverWith(...) so a failing target is diagnosed and (opt-in) auto-fixed, with a committable PR suggestion. Override recoverWith() on the build to apply one fixer to every target. A reviewer can go deeper and hold a discussion: .conventionsFile("AGENTS.md") (judged against the project's rules, read from the diff base), .criteriaFile(...) (project-specific notes read from that base too — .criteria(text) is build code and travels with the change), .fileContext() (whole changed files, not bare hunks), .verify() (adversarial re-check of every finding), and .discussion() (maintainers refute a finding by replying with its id — or, with .discussion((d) => d.threads()), by replying in the finding's own line-anchored review thread; accepted dismissals persist instead of resurfacing, including when the model rewords the finding, refutations from .verify() are remembered too, and a rebuttal is adjudicated even when the next round's model drops the finding — only platform-verified maintainer comments ever reach the model, on GitHub, GitLab, Azure DevOps and Bitbucket alike). A maintainer's decision is inherited by a restatement on any file of the diff and at any severity, and shared with every reviewer on the pull request. A rebuttal needs no push: commenting the workflow's command (e.g. @zuke-build review) starts a run that adjudicates it and answers in the thread. With .discussion((d) => d.commands("@zuke-build")) the comment carries a collapsed Commands panel, and @zuke-build accept <id> <reason> records a finding as accepted for the pull request without adjudication; the workflow generator derives the on-demand job from that mention, and command: (c) => c.role(...).users(...) chooses who may start it. Reviews post as github-actions[bot] by default, or as your own GitHub App via GhTasks.appTokenSource. See the cheatsheet's AI section.
  • Wait on an external GitHub workflow (@zuke/gh): in a .waitsFor(...) gate, s.on(githubWorkflow((g) => g.repo("o/r").workflow("e2e.yml"))) dispatches a workflow in another repo and suspends until it finishes; read the per-job result with readWorkflowResult(ctx.stateOf("<gate>")). Correlates by a run-name: marker by default, or .correlate("created-window") for a workflow you can't modify; fails fast (.discoveryTimeout(...)) if the run never correlates. The dispatched workflow has a contract: declare the marker input (zuke_marker, or rename via .markerInput(...)), echo it as its entire run-name: (equality, not substring), and receive any of its required: true inputs via .inputs(...) — see the cheatsheet's receiving-workflow contract. Triggers are extensible — write your own against the exported WaitTrigger/WaitContext.
  • OpenTelemetry export (@zuke/otel): register otel((s) => s.endpoint(…)) as a plugin (run(MyBuild, { plugins: [otel(…)] })) to ship run/target spans and zuke.run.started / zuke.run.suspended / zuke.runs counters as OTLP/HTTP JSON. Needs a state store; the trace id is derived from the run id, so a suspend/resume across processes is one trace. Config falls back to the standard OTEL_* env vars, and it is inert with no endpoint. Dependency-free.
Files (zuke)
  • references
    • cheatsheet.md 105.1 KB
      # Zuke authoring cheatsheet
      
      A quick map for writing targets. **Always confirm exact signatures** with
      `deno doc jsr:@zuke/<package>`, which resolves the version the project actually
      has installed. For breadth — which packages and tasks exist — use
      `llms-full.txt`: at the repo root in the Zuke repo itself, or from a consumer
      repo <https://raw.githubusercontent.com/zuke-build/zuke/master/llms-full.txt>,
      which tracks `master` and so may list symbols not yet in any published release.
      This cheatsheet is a summary, not the source of truth.
      
      ## `target()` — the fluent builder
      
      Everything is optional except a body (`.executes`) — with four exceptions, all
      below: a `service()`, a `.forEach()` fan-out, a `.waitsFor()` gate, and a target
      that declares only `.effect(...)` each legitimately have no `.executes(...)`.
      
      | Method                                                                                                   | Purpose                                                                                                                                                    |
      | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `.description(text)`                                                                                     | Summary shown in `--list`.                                                                                                                                 |
      | `.dependsOn(...t)`                                                                                       | Hard prerequisites; run first, transitively. Pass `this.<field>`.                                                                                          |
      | `.executes(fn)`                                                                                          | The body. Sync or async. **Required.** `fn` may take a `TargetContext` (`(ctx) => …`); see below.                                                          |
      | `.before(...t)` / `.after(...t)`                                                                         | Soft ordering — only reorders targets already in the plan; never pulls new ones in.                                                                        |
      | `.triggers(...t)`                                                                                        | Pull targets into the plan and run them _after_ this one.                                                                                                  |
      | `.dependentFor(...t)`                                                                                    | Reverse of `dependsOn`: make this a prerequisite of others.                                                                                                |
      | `.inputs(...p)` / `.outputs(...p)`                                                                       | Incremental cache: skip when inputs unchanged and outputs exist.                                                                                           |
      | `.cacheKey(fn)`                                                                                          | Add a non-file value (version, git sha, param) to the cache fingerprint.                                                                                   |
      | `.onlyWhen(cond)`                                                                                        | Run only when the (possibly async) predicate holds, else skip. The predicate may take a context: `(ctx) => ctx.plan().includes("deploy")`.                 |
      | `.whenSkipped("skip-dependencies")`                                                                      | When `onlyWhen` skips this target, also skip deps no other planned target needs. Condition is evaluated up front, so it must not read run-produced state.  |
      | `.requires(...params)`                                                                                   | Fail unless the listed parameters resolved to a value.                                                                                                     |
      | `.retry(times, delayMs?)`                                                                                | Retry the body on failure.                                                                                                                                 |
      | `.timeout(ms)`                                                                                           | Fail the body if it runs longer than `ms` (per attempt).                                                                                                   |
      | `.lock((s) => s.lockKey(...).withTtl(...))`                                                              | Hold a cross-run lock while running; a second run wanting the key fails, or queues with `.waitUpTo(...)`. See below.                                       |
      | `.waitsFor((s) => s.on(externalSignal(...)))`                                                            | Gate (no body): suspend the run until an external event; resume later. See below.                                                                          |
      | `.onCancel(() => this.rollback)`                                                                         | Compensation run (reverse order) iff this target succeeded when the run is cancelled. See below.                                                           |
      | `.effect(name, fn)`                                                                                      | A side effect whose intent is recorded before it runs, so a resume re-drives it. At-least-once. See below.                                                 |
      | `.forEach(() => items, (item) => ({stage: target()…}), (s) => s.concurrency(3).continueOnItemFailure())` | Fan out a pipeline over a runtime list: items concurrent, stages sequential per item. See below.                                                           |
      | `.proceedAfterFailure()`                                                                                 | Keep the build going if this target fails.                                                                                                                 |
      | `.always()`                                                                                              | Run even after the build failed (cleanup/teardown).                                                                                                        |
      | `.unlisted()`                                                                                            | Hide from `--list`/`--help`; still runnable by name.                                                                                                       |
      | `.dryRunnable()`                                                                                         | Run this body under `--dry-run` with `$` in echo mode (prints argv, no spawn); others stay skipped.                                                        |
      | `.validateBefore(...v)` / `.validateAfter(...v)`                                                         | Run `Validation` checks around the body; a throw fails the target.                                                                                         |
      | `.recoverWith(...r)` / `.recoverAttempts(n)`                                                             | Run `Remediation`s if the body fails (self-healing); re-run when one asks to. A remediation gets the target name, attempt and error — **no state handle**. |
      | `.partOf(group)`                                                                                         | Join a parallel batch (see `group()`).                                                                                                                     |
      | `.produces(...p)` / `.consumes(...t)`                                                                    | Declare and consume artifact paths.                                                                                                                        |
      | `.readOnly()`                                                                                            | Advertise the target as query-only over MCP (`readOnlyHint` instead of `destructiveHint`).                                                                 |
      
      **Lifecycle hooks** — `override` on the `Build` to observe a run without
      wrapping every target: `onStart()` once before anything runs, `onFinish(result)`
      once after (success _or_ failure), `onTargetStart(name)` just before a body
      executes (not for a skipped or cached target), and `onTargetEnd(name, status)`
      after each target settles. All may be async. For exporting rather than
      observing, prefer a plugin (see `@zuke/otel`).
      
      **External ordering:** `override extraEdges(targets)` on the `Build` returns
      `[before, after]` pairs (from the discovered `targets` map) to impose soft
      ordering beyond per-target `.before()`/`.after()` — the seam for feeding an
      external dependency graph in. `override orderWith(targets)` is the same, but
      **async and resolved per run** (load the graph at run time); its edges merge
      with `extraEdges`. Cycle-checked; edges outside the run's set are ignored; both
      are honoured by a run and `zuke cancel`, but not by static `graph`/`--list`.
      
      > **Fan-out caveat:** `targets` holds only **class-field targets** — a
      > `.forEach()` fan-out's per-item sub-targets (`parent[item].stage`) don't exist
      > at plan time, so **per-item ordering across a fan-out is not expressible**
      > with `orderWith`/`extraEdges`. Order whole fan-out **waves** instead: split
      > the work into one `.forEach()` per wave and chain the waves with `.dependsOn`.
      > An edge to a target that isn't in the build (a fan-out item name, a typo, an
      > ad-hoc `target()`) is logged as ignored rather than silently dropped.
      
      ## `group()` — parallel batches
      
      ```ts
      checks = group();
      
      clean = target().executes(/* ... */);
      lint = target().dependsOn(this.clean).partOf(this.checks).executes(/* ... */);
      format = target().dependsOn(this.clean).partOf(this.checks).executes(/* ... */);
      
      ship = target().dependsOn(this.checks).executes(/* ... */); // waits for all members
      ```
      
      Members of a group run concurrently with each other (each still awaiting its own
      deps), no `--parallel` flag needed. Declare the group field above its members.
      
      ## Components — reusable target bundles
      
      A component is a function returning related targets; discovery names them with a
      dotted path (`release.publish`).
      
      ```ts
      function releasable(opts: { registry: string }) {
        const pack = target().executes(/* ... */);
        const publish = target().dependsOn(pack).executes(/* ... */);
        return { pack, publish };
      }
      
      class MyBuild extends Build {
        release = releasable({ registry: "https://registry.npmjs.org" });
        deploy = target().dependsOn(this.release.publish).executes(/* ... */);
      }
      ```
      
      ## Services — long-lived processes
      
      `service()` models a process that must stay **running while its dependents
      execute** (dev server, DB container, mock API). Declared and depended on like a
      target, but with a lifecycle instead of `.executes(...)`: the executor starts
      it, waits until ready, keeps it alive, then stops it in a `finally` (reverse
      order) so a failed test never leaks a process.
      
      ```ts
      import { Build, run, service, target, tcpReachable } from "@zuke/core";
      import { $ } from "@zuke/core/shell";
      
      class E2E extends Build {
        api = service()
          .description("API under test")
          .start(() => $`deno run -A server.ts`.spawn()) // spawn — don't await
          .readyWhen(() => tcpReachable("localhost:8080")); // polled until ready
      
        test = target()
          .dependsOn(this.api) // started + ready before this runs
          .executes(() => DenoTasks.test((s) => s.allowAll()));
      }
      ```
      
      | Method                      | Purpose                                                          |
      | --------------------------- | ---------------------------------------------------------------- |
      | `.start(() => handle)`      | Start the process; return a handle with `.stop()`. **Required.** |
      | `.readyWhen(() => boolean)` | Readiness probe, polled (200ms) until `true`.                    |
      | `.readyTimeout(ms)`         | Wait before failing readiness (default 30s).                     |
      | `.stop((handle) => …)`      | Custom teardown; receives what `.start()` returned.              |
      
      The shell's `Command` gains `.spawn()` (starts without awaiting, returns a
      `SpawnedProcess` whose `.stop()` sends `SIGTERM`) — a valid handle, so the
      common case needs no explicit `.stop()`. `tcpReachable("host:port")` is the
      built-in "is the port up yet?" probe. Shares `dependsOn`/`before`/`after`/
      `description` with `target()`.
      
      ## Target context — `ctx`
      
      A body may accept a `TargetContext`. Zero-argument bodies keep working — the
      parameter is optional.
      
      ```ts
      deploy = target().executes(async (ctx) => {
        ctx.runId; // stable id for the whole run
        ctx.initiator; // who ASKED for the run — { actor, kind, at }, never rewritten
        ctx.target; // "deploy"
        ctx.signal; // AbortSignal, fired when the run is cancelled
        ctx.dryRun; // true under a dry run
        await ctx.state.set({ where: "sit-7" }); // durable metadata — see below
        await ctx.state.trySet({ where: "sit-7" }); // ...same write, false if dropped
        ctx.stateOf("build").get(); // read ANOTHER target's published state
        ctx.signals.get("approved"); // an external signal's payload (see waits)
        ctx.outcomeOf("checks")?.status; // one target's settled outcome, or undefined
        ctx.outcomeOf("test")?.summary; // its Build Summary notes (durable, e.g. Tests/Passed)
        ctx.outcomes(); // every outcome settled SO FAR, keyed by dotted name
        ctx.plan().targets; // every target THIS run planned, in execution order
        ctx.plan().includes("deploy"); // was `deploy` part of what was asked for?
        ctx.plan().dependenciesOf("test"); // what must finish before `test` starts
        ctx.reportSummary({ Version: "3.6.2" }); // a note on THIS row of the Build Summary
      });
      ```
      
      **Summary notes.** `ctx.reportSummary({ key: value, … })` puts `key: value`
      pairs on the target's own row of the end-of-build summary, NUKE-style:
      `test  Succeeded  8.1s  // Tests: 837 · Passed: 837 · Failed: 0`. Notes
      accumulate; a repeated key replaces its value; each renders on one line, in the
      terminal and in the Actions job summary. A failed target keeps its notes. Every
      test-runner wrapper (`DenoTasks.test`, `VitestTasks.run`, `JestTasks.run`,
      `BunTasks.test`, `NodeTasks.test`, `PlaywrightTasks.test`, `CypressTasks.run`)
      reports Tests/Passed/Failed (and Skipped/Todo/Flaky when non-zero) itself, and
      `DenoTasks.coverage` reports the measured Lines/Branches — a body only adds what
      its tools do not. Library code with no `ctx` (a wrapper, a helper) uses the
      ambient `reportSummary(pairs)` from `@zuke/core`, which lands on the running
      target's row and is a no-op outside a run. Test counts have one shared shape:
      `reportTestCounts({ passed, failed, skipped?, todo?, flaky? })` reports `Tests`
      (the sum), `Passed`, `Failed`, then `Skipped`/`Todo`/`Flaky` only when non-zero
      — the labels every test-runner wrapper uses.
      
      `ctx.outcomes()` is a snapshot, not a live view, and a target that has not
      settled is **absent** rather than present with a placeholder — so depend on what
      you intend to read (an `.always()` gate reads what ran before it).
      
      **Cancellation.** When the run is cancelled, `ctx.signal` fires and any plain
      `` $`…` `` in the body is terminated with `SIGTERM` automatically (the run's
      signal is the shell's ambient signal). Pass `ctx.signal` to `.signal(...)` to
      cancel a command explicitly; it composes with `.killAfter(ms)`. Cancel a run
      with `zuke cancel <id>`, `Ctrl-C`/`SIGTERM`, the MCP `cancel_run` tool, or
      programmatically with `execute(build, root, { signal })` /
      `cancelRun(build, {
      runId })`. A body that ignores its signal and never shells
      out runs to completion. Register **compensations** with `.onCancel(...)` (see
      below) to undo a target's effect when the run is cancelled.
      
      ## Durable run state
      
      Persist a run's status and per-target metadata so it survives the process
      exiting. **Opt-in** — a plain build writes nothing. Enable a store by (first
      wins): `execute(..., { stateStore })` → `override stateStore()` →
      `ZUKE_STATE_URL` (+ `ZUKE_STATE_TOKEN`) → `ZUKE_STATE_DIR` → `--state` (defaults
      to `.zuke/runs`). Every `ZUKE_*_URL` backend — state, registry, remote cache —
      **must be `https:`**: a plaintext one is refused with a named error and exit
      code 1, because an on-path attacker who answers it chooses what the build reads
      back. Loopback is exempt; `ZUKE_ALLOW_INSECURE_URL=1` opts a deliberate
      plaintext endpoint back in.
      
      ```ts
      import { Build, HttpStateStore, target } from "@zuke/core";
      
      class CD extends Build {
        override stateStore() {
          return new HttpStateStore({ url: this.url.value, token: this.token.value });
        }
        deploy = target().executes(async (ctx) => {
          // trySet resolves true when the patch reached the store, false when the
          // write was dropped. Check it before an irreversible step that needs it.
          if (!await ctx.state.trySet({ image: tag })) {
            throw new Error("not recorded");
          }
          const meta = ctx.state.get(); // read back (this run and later ones)
        });
      }
      ```
      
      - Backends: `FileSystemStateStore(dir)` (single host, dev) and
        `HttpStateStore({ url, token? })` (hosted, production — see
        `docs/state-api.md`). Both dependency-free and pluggable behind `StateStore`.
        A hosted backend is verified with the **conformance kit**
        (`deno run -A jsr:@zuke/core/conformance --url <base> [--token …]`, or
        `checkStateStore`/`checkBuildRegistry` from `@zuke/core/conformance`) — it
        exercises CAS, listing, and TTL-lock semantics. The HTTP clients stamp every
        request with `x-zuke-state-protocol: 1` and fail loudly on a server-declared
        mismatch.
      - The run record holds status, the graph shape, resolved **non-secret**
        parameters, and per-target status/timing/metadata. Inspect it from the CLI
        with
        `zuke runs list [--status <s>] [--target <t>] [--since <iso>] [--limit <n>] [--counts]`
        (newest first) and `zuke runs show <id>` (`--json` on both), or
        programmatically with `store.listRuns({ status?, target?, since?, limit? })`
        and `store.getRun(id)`.
      - **Retention:** `zuke runs prune --keep <age> --keep-last <n>` deletes only
        **terminal** runs matching neither rule (`--dry-run` to preview); a
        non-terminal run (suspended/running) is never pruned. The FS store owns
        pruning via the CLI; for the HTTP backend retention is the server's job
        (`GET /runs` takes `limit`; `DELETE /runs/:id` is optional). See
        `docs/state.md`.
      - **Run leases and reaping.** A run that writes durable state takes a TTL lease
        on its own id and heartbeats it, so two processes cannot both believe they own
        one run — a resume that adopts a run whose lease has lapsed takes it over, and
        the original **stops**: it runs no compensations, settles nothing, and writes
        nothing further, because the run belongs to whoever holds the claim now
        (unwinding would roll back the work the new holder is building on). A claim
        lost _during_ a cancellation's rollback stops that walk where it stands, too.
        A run that cannot take its lease at all — a store that never answers, after
        retries — fails with a named error rather than running unclaimed. A run that
        settles, cancellation included, hands the claim straight back. The lease is
        also how a dead run is told from a slow one: `zuke resume --check` looks at
        `running` runs before it sweeps suspended ones, and a lease it can acquire
        means the holder is gone. Such a run is put back to `suspended` with a reap
        event saying why, and the same pass resumes it — so a process killed mid-run
        has its owed effects driven without an operator stepping in. A run whose lease
        is still being renewed is merely slow, and is left alone. The same sweep also
        finishes runs a dead settler left `cancelling`.
      - **Run deadlines.** `override deadline()` on the `Build` gives a run a
        wall-clock budget (`"45m"`, or milliseconds), stamped as `deadlineAt` when it
        starts and pushed forward on resume by however long the run sat parked — so
        time spent waiting at a gate does not count against it. An abandoned run found
        **past** its deadline is not handed back: the reaper settles it `failed` and
        runs its compensations. Without a deadline a reaped run is always returned to
        `suspended` and resumed.
      - **Whose run is it — `ZUKE_BUILD_ID`.** A shared store means a sweep sees every
        build's runs, and recovery does not merely read them: a resume runs **this**
        build's target bodies against the record it is handed. The shape checks (build
        class name, root target, graph) cannot separate one `zuke.ts` templated across
        a dozen services — same names, same graph, different bodies. So a run records
        an **origin** at creation: `ZUKE_BUILD_ID`, else `GITHUB_REPOSITORY`, else
        none. Every recovery path (`resume`, `resume --check`, `cancel`, the reaper)
        compares it; a sweep silently **skips** a foreign run (so a cron's exit code
        stays meaningful) and a by-name `resume <id>` / `cancel <id>` **reports** it.
        An origin only ever **narrows** what the shape checks permit — it can refuse a
        run, never claim one — so two builds in one repository, which share the
        repository default, stay separated by the build-name check exactly as before.
        An absent origin on either side abstains rather than refusing, so records
        written before the field existed stay recoverable — which means in a container
        you must set `ZUKE_BUILD_ID` yourself (there is no `GITHUB_REPOSITORY` in a
        CronJob), using the same value everywhere that build runs. Alternatively give
        each build its own URL prefix on the shared service
        (`ZUKE_STATE_URL=https://state/svc-a`) and neither can see the other's runs at
        all. See `docs/orchestration.md`.
      - **What a sweep counts as failed.** Not a race it lost: a run another process
        is already driving, one that process finished between the listing and the
        resume, and a run belonging to another build are all skipped and reported,
        never counted. A degraded record _is_ counted, on every sweep, because only an
        operator can decide whether its targets are safe to repeat.
      - **Never put secrets in `ctx.state`** — it is stored as plain JSON. Secret
        parameters are excluded from the record and state values are run through the
        redactor, but treat state as a non-secret channel. See `docs/state.md`.
      
      ## Cross-run locks
      
      `.lock((s) => …)` takes a **settings lambda** (like the tool wrappers) and
      claims an exclusive resource across runs and machines. A second run that wants
      the same key **fails** with a `LockConflictError` (naming the holder) — it does
      not queue.
      
      ```ts
      import { Build, target } from "@zuke/core";
      
      class CD extends Build {
        repo = parameter("service");
        promote = target()
          .lock((s) =>
            s.lockKey("deploy", this.repo.value) // sanitised composite key
              .withTtl("4h") // renewed while running; expires this long after a kill -9
              .onConflict((h) =>
                `${this.repo.value} held by ${h.actor} (run ${h.runId}).`
              )
          )
          .executes(async (ctx) => {/* … */});
      }
      ```
      
      - `s.lockKey(...parts)` sanitises and joins a composite key; `s.key(literal)`
        sets one directly. The lambda runs after params resolve, so the key can read
        `this.<param>.value`.
      - Released when the target settles (success, failure, cancellation); `ttl` is
        only the backstop for a killed holder.
      - Needs a state store — a build using `.lock()` enables the `.zuke/runs`
        filesystem store by default; use the HTTP backend to share locks across
        machines. See `docs/locks.md`.
      - `s.waitUpTo("30m")` queues for a held lock instead of failing at once, with
        `s.pollEvery("5s")` pacing the retries; the conflict is raised only once the
        wait is spent, and the run prints who holds the lock while it waits. This is a
        retry loop, not a queue: a waiter takes the lock on its next poll after it
        frees, racing every other waiter, so there is no arrival order. Reach for it
        on a shared resource a developer wants to use, not on one where a second run
        is a mistake worth reporting.
      
      ## External-event waits
      
      `.waitsFor((s) => …)` makes a target a **gate** (no body): the run proceeds past
      it only when the trigger is satisfied; otherwise it **suspends** — the run's
      state is saved, independent branches finish, and the process exits 0 — to be
      resumed later in a fresh process.
      
      ```ts
      import { Build, externalSignal, target } from "@zuke/core";
      
      class Deploy extends Build {
        deploy = target().executes(async (ctx) => {
          await applyToSit();
          await ctx.state.set({ at: "sit-7" }); // only durable state crosses the resume
        });
        awaitQa = target()
          .dependsOn(this.deploy)
          .waitsFor((s) =>
            s.on(externalSignal("qa-approved")) // or resumeWhen(async () => …)
              .timeout("72h")
              .onTimeout(() => this.rollback)
          ); // thunk: sibling compensation target
        promote = target().dependsOn(this.awaitQa).executes((ctx) => {
          const approval = ctx.signals.get("qa-approved"); // the signal's JSON payload
        });
        rollback = target().executes(() => rollBack());
      }
      ```
      
      - Triggers: `externalSignal(name)` (payload read via `ctx.signals`),
        `resumeWhen(fn, { interval? })` (async predicate, re-checked on resume), and
        `githubWorkflow((g) => g.repo(...).workflow(...))` from `@zuke/gh` (dispatches
        an external GitHub Actions workflow, satisfied when it finishes; read its
        per-job result with `readWorkflowResult(ctx.stateOf("<gate>"))`). By default
        it correlates via a marker echoed into the run's `run-name:`; for a workflow
        you can't modify use `.correlate("created-window")` (best-effort). A marker is
        copyable and anyone who can dispatch the workflow can wear it, so a run is
        adopted only if it is _also_ a `workflow_dispatch`, on the dispatched ref, and
        created within the discovery window; two survivors are a refusal, and the
        identity is re-checked before the result is read. A holder of `actions: write`
        on that repo can still dispatch the same workflow (branch protection does not
        gate a dispatch), so narrow that permission or use an environment with
        required reviewers when the gate authorizes work in another trust domain.
        Either way it **fails fast** (`.discoveryTimeout(...)`, default 1m) if the run
        never correlates, instead of eating the whole `.timeout()`. The **dispatched**
        workflow has its own contract (marker input, run-name, required inputs) — see
        [The dispatched workflow's contract](#the-dispatched-workflows-contract-githubworkflow)
        below. Write your own trigger against the exported `WaitTrigger` /
        `WaitContext` interface.
      - Needs a state store (a build with `.waitsFor()` enables `.zuke/runs` by
        default). A resume is a fresh process, so **only `ctx.state`/`ctx.signals`
        cross the boundary**. See `docs/orchestration.md`.
      - Continue a suspended run with
        `zuke resume <id> --signal <name> [--data <json>]` (or
        `zuke resume --check [<id>]` for predicate waits/timeouts). Resumption is
        **exactly-once** (concurrent resumers get `AlreadyResumedError`) and re-runs
        only the not-yet-succeeded targets; `--force-graph` overrides a changed graph.
      
      ### The dispatched workflow's contract (`githubWorkflow`)
      
      The gate is only half the wiring — the **target** workflow has a contract, and
      each of these three is a deterministic dispatch `422` or a gate that hangs until
      timeout:
      
      ```yaml
      # .github/workflows/e2e.yml — in the repo being dispatched
      on:
        workflow_dispatch:
          inputs:
            zuke_marker: {
              required: false,
            } # rename → .markerInput("name") on the gate
      # any `required: true` input here must be supplied via .inputs(...) below
      run-name: ${{ inputs.zuke_marker }} # the ENTIRE run-name; equality, not substring
      ```
      
      - **Marker input name.** The marker is dispatched as an input named
        `zuke_marker` by default; a dispatch carrying an input the workflow does not
        declare is `422`ed, so a workflow that names it anything else rejects the
        dispatch. Declare `zuke_marker`, or point the gate at your name with
        `.markerInput("<name>")`.
      - **Required inputs.** Every `required: true` input on the target workflow must
        be passed from the gate with `.inputs({ … })` / `.input(name, value)`, or the
        dispatch `422`s. The settings lambda is captured when the build is defined and
        has **no run state** — it can read params but not a value an earlier target
        recorded in `ctx.state`; for a run-time value, write a custom `WaitTrigger`.
      - **Strict run-name equality.** Marker mode matches `display_title === marker`
        **exactly, not by substring**. A decorated run-name
        (`run-name: E2E [${{ inputs.zuke_marker }}]`) dispatches fine but never
        correlates — the gate just times out. Echo the marker as the workflow's
        _entire_ `run-name:`, or use `.correlate("created-window")`.
      
      ## Cancellation & compensation — `.onCancel()`
      
      Undo a target's effect when the run is cancelled.
      `.onCancel(target | () =>
      target)` registers a **compensation** that runs **iff
      this target succeeded**; on cancel, compensations run in **reverse order** of
      the succeeded targets.
      
      ```ts
      class CD extends Build {
        deploy = target()
          .executes((ctx) => ctx.state.set({ slot: "sit-7" })) // record what it did
          .onCancel(() => this.rollback); // thunk → sibling compensation
        rollback = target().executes((ctx) => tearDown(ctx.state.get().slot));
        gate = target().dependsOn(this.deploy)
          .waitsFor((s) => s.on(externalSignal("approved")));
      }
      ```
      
      - The compensation body's `ctx.state` exposes **the original target's**
        persisted metadata (persist what a rollback needs in `ctx.state` when you do
        the work). `ctx.state` is seeded with the meta of the target the step is
        **for**: under `.onCancel` that is the compensated target (so `ctx.target`
        names the compensation but `ctx.state` holds `deploy`'s meta), while a
        timed-out `.onTimeout(() => this.cleanup)` makes `cleanup` compensate
        **itself** — its own meta, `{}` if it never ran forward. One rule spans both:
        `ctx.stateOf(ctx.target)` is `ctx.state`, every other name reads **empty** (so
        `stateOf("deploy")` is empty under `.onCancel`). Writes stay in memory.
        `ctx.outcomeOf(...)` works; `ctx.plan()` is the whole run's plan, so a
        compensation is in it only when it is also a graph target.
      - Cancel with `zuke cancel <id>`, `Ctrl-C`/`SIGTERM`, or the MCP `cancel_run`
        tool (all run the same walk). A live run aborts on its next state write.
      - A compensation that throws is recorded but does **not** stop the walk (cleanup
        is maximal). Cancelling a finished run is a friendly no-op.
      - A timed-out `.waitsFor()` can route here: `.onTimeout(() => "cancel-run")`
        cancels the run (running compensations); `.onTimeout(() => this.cleanup)` runs
        that target too. Needs a state store (a build with `.onCancel()` enables
        `.zuke/runs` by default). See `docs/orchestration.md`.
      
      ## Durable side effects — `.effect()`
      
      A body that dies partway through leaves no record of what it had already done.
      `.effect(name, fn)` records the **intent** to run `fn` in the run record before
      `fn` runs, so a resume can see the effect was owed and drive it again. Effects
      run after the body, in declaration order; a target may declare effects and no
      body at all. Requires a state store, which is enabled automatically — an intent
      that cannot be recorded fails the target before the effect runs, by design.
      
      ```ts
      gate = target().dependsOn(this.checks).always()
        .effect("post-gate", async (ctx) => {
          await postCheckRun(ctx.outcomeOf("checks")?.status === "succeeded");
        });
      ```
      
      - **At-least-once, not exactly-once.** A process that dies after the side effect
        but before recording it repeats the effect on the re-drive. Write bodies that
        tolerate that — either repeating is harmless, or the far side converges (an
        upsert, not an append). The body's `ctx` is an `EffectContext`: `ctx.effect`
        is the effect's name and **`ctx.redriven`** is true when a previous attempt
        already committed its intent, so a body that cannot be made idempotent can at
        least detect the repeat and check the far side first.
      - **Pin the inputs.** A re-drive happens later, sometimes much later, so a body
        that looks up "the current value" of anything acts on a world that has moved
        on. Read what the effect acts on from `ctx.state`/`ctx.stateOf(...)`, written
        by an earlier target and replayed from the record. A parameter is nearly as
        good: an unsupplied one keeps the value the run started with, but a resume
        that passes one explicitly overrides it — so prefer state for a value that
        must not drift.
      - **What re-drives it.** An effect owed by a run that suspended for any ordinary
        reason is re-driven by the ordinary resume. A process **killed outright**
        leaves its run `running`, which `zuke resume --check` reaps: it reads the
        run's lease to tell a dead holder from a slow one, returns an abandoned run to
        `suspended`, and resumes it in the same pass (see Run leases above) — unless
        the run is past its `deadline()`, in which case it is settled `failed` and its
        effects are never driven. On a shared store the sweep must share the run's
        origin, or it skips the run and the effect is never driven — see
        `ZUKE_BUILD_ID` above.
      
      ## Fan-out over a list — `.forEach()`
      
      Run the same pipeline over a runtime list, with per-item isolation and bounded
      concurrency.
      
      > **Four combinations throw**, loudly, when the fan-out is materialised — not at
      > type-check time, so they are easy to write by accident. A `.forEach()` parent
      > may not also declare `.waitsFor()` or `.effect()`, and **no stage** inside the
      > fan-out may declare either. A stage _can_ declare `.onCancel()`, which is why
      > the restriction is worth stating: the neighbouring features do not compose the
      > way the compensation one does. Suspend or record an effect in a target
      > **before or after** the fan-out instead.
      
      ```ts
      import { Build, parameter, target } from "@zuke/core";
      
      class CD extends Build {
        repos = parameter("services").required().array();
      
        deployBatch = target().forEach(
          () => this.repos.value, // items: thunk, read when the target runs
          (repo) => ({ // ordered pipeline per item (each stage depends on the prev)
            checks: target().executes(() => checkDeployable(repo)),
            deploy: target().executes((ctx) => applyToSit(repo, ctx)),
          }),
          (s) => s.concurrency(3).continueOnItemFailure(),
        );
      }
      ```
      
      - Items run **concurrently** (up to `.concurrency(n)`, default CPU count); each
        item's stages run **sequentially** — the pipeline model, no barrier between
        items.
      - `.continueOnItemFailure()` isolates a failed item (its later stages skip, the
        others finish); otherwise the first failure stops the batch. Either way the
        fan-out target fails if any item did.
      - Sub-targets are materialised at run time (`deployBatch[<item>].<stage>`), each
        a first-class row in the summary and the [run record](#durable-run-state) (so
        `zuke runs show` reports per-item verdicts). `--list`/`graph` show the one
        node, annotated `[fan-out]`.
      - **Per-item compensation:** an `.onCancel(...)` on a fan-out **stage** runs on
        cancel for each item that had succeeded — or was still in-flight — with its
        own item-scoped `ctx.state`, in reverse order, before the parent's own
        `.onCancel`. The item list must be deterministic (cancel re-materialises it to
        find items).
      - Pairs with array params: `.options(...).array()` / `.number().array()` type
        and validate the list before the batch runs.
      
      ## Parameters — typed build inputs
      
      <!-- check -->
      
      ```ts
      import { Build, parameter, target } from "@zuke/core";
      
      class MyBuild extends Build {
        apiKey = parameter("Anthropic API key").secret().required();
        env = parameter("Target environment"); // optional
      
        deploy = target()
          .requires(this.apiKey)
          .onlyWhen(() => this.env.value === "production")
          .executes(() => {/* use this.apiKey.value */});
      }
      ```
      
      Secrets are masked in CI output. Read a resolved value with `this.x.value`.
      
      Kinds & modifiers: `.number()` → `number`, `.boolean()` → `boolean` (a flag,
      defaults to `false`), `.options("a", "b")` restricts a string, `.secret()`
      masks + redacts, `.default(v)`/`.required()` set optionality, `.env(NAME)`
      overrides the env var, `.flag("--name")` overrides the CLI flag.
      
      Flag/env derivation: one dash (underscore) per lower-to-upper transition, so
      `targetEnv` gives `--target-env` / `TARGET_ENV` and `runs.limit` gives
      `--runs-limit`. A run of capitals stays together (`apiURL` gives `--api-url`),
      but a **digit ends the run** — `skipE2E` gives `--skip-e2-e`, not `--skip-e2e`.
      Either name it `skipE2e`, or declare `.flag("--skip-e2e")`. A declared flag
      replaces the derived one everywhere (parser, `--help`, JSON surface,
      completions, registry descriptor); it must be lowercase letters/digits/dashes
      starting with a letter, may not be a built-in, and no two parameters may claim
      the same one. The env var is derived separately and is unaffected.
      
      Lists: `.array()` (comma-separated or repeated flag) comes **last** and composes
      — `.options("a", "b").array()` validates each element, and `.number().array()`
      yields a `number[]`. Order is kind/options → `.required()` → `.array()`: put
      `.required()` **before** `.array()` (`.required().array()`), not after —
      `.array().required()` fails to typecheck, and a non-required list defaults to
      `[]`.
      
      Reserved names: a parameter may not be named so that it renders as a built-in
      CLI flag (`actor` → `--actor`, `actorKind` → `--actor-kind`, `limit`, `target`,
      `output`, `state`, … — every flag `zuke --help` lists), nor `dryRun`, `confirm`
      or `operatorToken` (the MCP run-tool control keys). The build fails to load with
      a `ParameterError` naming the field. Only the rendered flag collides, so
      `actorName` and a grouped `runs.limit` are fine.
      
      ### Secrets from a manager — `.from(source)`
      
      A `.secret()` parameter can be **sourced at run time** so the value never lands
      in a shell, `.env`, or CI YAML — and its resolved value is **redacted from all
      of Zuke's output**:
      
      ```ts
      import { execSecret, parameter } from "@zuke/core";
      
      token = parameter("Deploy token")
        .secret()
        .from(
          execSecret((s) => s.command("op").arg("read", "op://vault/deploy/token")),
        );
      ```
      
      The other source is
      **`fileSecret((s) => s.path("/run/secrets/deploy-token"))`**, for a secret
      mounted as a file by Kubernetes, Docker, or a systemd credential — no
      subprocess, and the common shape for a multi-line value like a private key.
      
      A sourced secret is still an ordinary parameter (flag, env var, `.required()`,
      `.number()`); `.from(...)` just adds the run-time provider.
      
      ## Provisioning tools — hermetic builds
      
      Fetch pinned, checksum-verified tool binaries from the build itself instead of
      assuming they're installed. Both return the installed binary's `AbsolutePath`;
      hand it to a wrapper's `.toolPath(...)`, to `CmdTasks`, or to `defineTool`
      (`jsr:@zuke/core/tooling` — the submodule, not the package root).
      
      ```ts
      import { toolchain, ToolTasks } from "@zuke/core";
      
      // One release binary:
      bin = target().executes(async () =>
        await ToolTasks.install((s) =>
          s.name("shellcheck").url(shellcheckUrl).checksum(shellcheckSum)
        )
      );
      
      // Many at once — release binaries via .tool(), npm packages via .npm():
      tools = toolchain((t) =>
        t.tool((s) => s.name("helm").url(helmUrl))
          .npm({ name: "vitest", version: "4.1.9" })
      );
      // install() returns Map<name, AbsolutePath>; npm packages need `npm` on PATH.
      ```
      
      `.archive("tar.gz")` or `.archive("zip")` unpacks an archive and copies
      `.binaryPath(...)` (default the name) out — zip reading covers the `stored`/
      `deflate` methods release assets use, rejects encrypted/zip64 archives, and
      blocks zip-slip. `.checksum(sha256)` verifies (the archive's SHA-256 for an
      archive, the binary's for `"raw"`) and doubles as the install cache key.
      
      **Multi-file runtimes (Node.js, a JDK, …)** — `ToolTasks.installTree((s) => …)`
      (or `toolchain().tree((s) => …)`) keeps the _whole_ extracted tree instead of
      one binary, for a runtime that ships several bins plus `lib/`. `.strip(1)`
      unwraps the `tool-v1.2.3/` top directory, `.bins("bin/node", "bin/npm")` marks
      executables (symlinks preserved). It returns the tree root as a callable
      `AbsolutePath`, so `root("bin", "node")` is a binary and `root("bin")` the
      directory to put on PATH:
      
      ```ts
      import { prependPath, ToolTasks } from "@zuke/core";
      
      const node = await ToolTasks.installTree((s) =>
        s.name("node").archive("tar.gz").strip(1).bins("bin/node", "bin/npm")
          .url(nodeUrl).checksum(nodeSum)
      );
      prependPath(node("bin")); // node/npm (and node_modules/.bin shims) now on PATH
      ```
      
      `prependPath(dir)` puts `dir` first on the process `PATH` (idempotent, platform
      separator) so every subprocess Zuke spawns — the shell `$`, `Command`, and the
      tool wrappers, which inherit `Deno.env` — finds the provisioned tool.
      
      **Resolve from `node_modules/.bin`** — in a Node monorepo where tool binaries
      are hoisted to the repo root, a wrapper can find its binary npx-style instead of
      needing a `.toolPath(...)`. `.fromNodeModules()` on any settings object walks up
      from the working directory for `node_modules/.bin/<tool>` (the `.cmd`/`.bat`
      shims on Windows, spawned as themselves) and falls back to `PATH` on a miss;
      `.fromPath()` forces `PATH`; and `ZUKE_TOOL_RESOLUTION=node_modules|path` flips
      every wrapper repo-wide without touching call sites (a per-call setting wins
      over it). An explicit `.toolPath(...)` always wins, so a `toolchain()` pin stays
      hermetic. `resolvedArgv()` shows what a run will spawn. See `docs/tools.md`.
      
      ## Tool wrappers — the settings-lambda style
      
      Every external tool is a `*Tasks` object; each task takes `(s) => s.…` mirroring
      the real CLI's flags. A non-exhaustive map (run `deno doc jsr:@zuke/<pkg>` for
      the full task list and settings methods of each):
      
      | Package                                                                                                                                             | Object                                                    | Typical tasks                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
      | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | `@zuke/core`                                                                                                                                        | `FileTasks`, `AnnounceTasks`, `ToolTasks`, `BrowserTasks` | copy/move/remove files, symlink/readLink (`force` re-points an existing link); Slack/Teams/Discord posts; install tool binaries; open an http(s) URL in the default browser (`BrowserTasks.open`)                                                                                                                                                                                                                                                                                                      |
      | `@zuke/cli`                                                                                                                                         | the `zuke` command                                        | not a wrapper — `deno install -A -g -n zuke jsr:@zuke/cli`, then `zuke setup` scaffolds a project; inside one, `zuke <target>` forwards to its `zuke.ts` like `./zuke <target>`                                                                                                                                                                                                                                                                                                                        |
      | `@zuke/console`                                                                                                                                     | `ConsoleTasks`                                            | themed console output (headings, notices, the `logo()` splash) so a build never hand-rolls `console.log`                                                                                                                                                                                                                                                                                                                                                                                               |
      | `@zuke/deno`                                                                                                                                        | `DenoTasks`                                               | `check`, `test`, `bench`, `fmt`, `lint`, `cache`, `clean`, `doc`, `run`, `serve`, `eval`, `task`, `compile`, `info`, `init`, `upgrade`, `add`, `remove`, `install`, `uninstall`, `outdated`, `why`, `ci`, `approveScripts`, `bumpVersion`, `publish`, `pack`, `coverage`; readers `moduleGraph`, `cacheInfo`                                                                                                                                                                                           |
      | `@zuke/docs`                                                                                                                                        | `DocsTasks`                                               | turn generated API docs into published output                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
      | `@zuke/npm`, `@zuke/npx`, `@zuke/bun`, `@zuke/pnpm`, `@zuke/yarn`, `@zuke/node`                                                                     | `NpmTasks`, `NpxTasks`, `BunTasks`, ...                   | JS package managers + `npx` runner + `node`. `NpmTasks` covers npm's everyday surface — install/publish/registry/inspect — and hands back values from `outdatedEntries`, `auditSummary`, `pkgGet`, `whoamiName`                                                                                                                                                                                                                                                                                        |
      | `@zuke/cmd`                                                                                                                                         | `CmdTasks`                                                | `exec` — generic fallback for any CLI                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
      | `@zuke/docker`, `@zuke/docker-compose`                                                                                                              | `DockerTasks`, ...                                        | build/run/compose. `DockerComposeTasks` covers the Compose surface — the lifecycle (`up`/`down`/`create`/`scale`/`wait`), images, containers (`run`/`exec`/`cp`/`export`), and the listings — with `servicePort`, `waitExitCode` and `composeVersion` handing back values. `DockerTasks` covers the everyday docker surface — containers, images, registry, and the `volume`/`network`/`system`/`context` groups — with `psEntries`, `imageEntries`, `volumeNames`, `networkNames` handing back values |
      | `@zuke/git`, `@zuke/gh`                                                                                                                             | `GitTasks`, `GhTasks`                                     | git — the everyday surface, typed (see below) — and GitHub CLI: typed `pr`/`issue`/`release`/`run`/`workflow`/`repo`/`secret`/`variable`/`label`/`cache` tasks (see below), `GhTasks.run` for the long tail, `GhTasks.api` for REST endpoints without a verb                                                                                                                                                                                                                                           |
      | `@zuke/cspell`, `@zuke/eslint`, `@zuke/oxlint`, `@zuke/biome`, `@zuke/dprint`, `@zuke/knip`, `@zuke/dpdm`, `@zuke/lint-staged`, `@zuke/shellcheck`  | `*Tasks`                                                  | lint/format/spell/dead-code. `ShellcheckTasks.lint` analyses shell scripts; give it `.shell("sh")` or ShellCheck reads the shebang and checks a POSIX script as bash                                                                                                                                                                                                                                                                                                                                   |
      | `@zuke/tsc`, `@zuke/tsx`, `@zuke/tsc-alias`, `@zuke/tsup`, `@zuke/tsdown`, `@zuke/vite`, `@zuke/storybook`, `@zuke/turbo`, `@zuke/nx`, `@zuke/nest` | `*Tasks`                                                  | TS compile / bundle / monorepo / framework CLIs                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
      | `@zuke/openapi-ts`, `@zuke/orval`, `@zuke/redocly`                                                                                                  | `*Tasks`                                                  | generate API clients from OpenAPI                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
      | `@zuke/husky`                                                                                                                                       | `HuskyTasks`                                              | git hooks                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
      | `@zuke/jest`, `@zuke/vitest`, `@zuke/playwright`, `@zuke/cypress`                                                                                   | `*Tasks`                                                  | test runners                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
      | `@zuke/jsr`, `@zuke/codecov`, `@zuke/release-please`                                                                                                | `JsrTasks`, `CodecovTasks`, ...                           | publish / coverage upload / releases                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
      | `@zuke/kubectl`, `@zuke/helm`, `@zuke/kustomize`, `@zuke/terraform`, `@zuke/tofu`, `@zuke/gcloud`                                                   | `*Tasks`                                                  | infra/deploy. `KubectlTasks` covers the deploy surface — manifests, workloads, pods, nodes, kubeconfig — with `diffHasChanges`, `canI`, `getEntries`, `eventEntries`, `currentContext`, `versionInfo` handing back values (see below). `GcloudTasks` types the Google Cloud deploy path — auth, config, builds, Cloud Run, Artifact Registry, GKE credentials, storage, functions, secrets (see below)                                                                                                 |
      | `@zuke/security`                                                                                                                                    | `*Tasks`                                                  | security scanning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
      | `@zuke/claude`, `@zuke/codex`, `@zuke/gemini`                                                                                                       | `ClaudeTasks`, ...                                        | headless AI CLIs                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
      | `@zuke/ai`                                                                                                                                          | `securityReviewer`, ..., `aiFixer`, `agentFixer`          | AI review gates + self-healing (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
      | `@zuke/otel`                                                                                                                                        | `otel` (a plugin)                                         | export runs and targets as OpenTelemetry traces (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                            |
      
      The catalog keeps growing — the package list in `llms.txt`'s `## Packages`
      catalogue (or the table above) is the source of truth for **whether a wrapper
      exists**; `deno doc jsr:@zuke/<pkg>` only confirms the **shape** of a package
      whose name you already know, it cannot tell you one exists. Check the catalogue
      before reaching for the fallback below — using `CmdTasks.exec`/`$` for a tool
      that has a `@zuke/<tool>` package is a bug, not a style choice.
      
      ```ts
      await DenoTasks.test((s) => s.allowAll().coverage("cov_profile"));
      await DenoTasks.test((s) =>
        s.allowAll().reporter("junit").junitPath("report.xml").traceLeaks()
      );
      await DenoTasks.fmt((s) => s.check().paths("mod.ts"));
      await DenoTasks.lint((s) => s.json().rulesExclude("no-explicit-any"));
      await CmdTasks.exec("my-tool", (s) => s.args("--flag", "value")); // no wrapper in the catalogue? last resort: cmd
      
      // Wrong — @zuke/docker has a typed wrapper, so this discards typed flags,
      // argv purity, and tool resolution:
      await CmdTasks.exec("docker", (s) => s.args("build", "-t", "app", "."));
      // Right — check the catalogue, find @zuke/docker, use it:
      await DockerTasks.build((s) => s.tag("app").context("."));
      ```
      
      ### Reading a value out of a Node module — `NodeTasks.evaluate`
      
      Some builds need a **value** from the Node side of the project rather than an
      exit code: an OpenAPI document produced by booting the app, a resolved config.
      `NodeTasks.evaluate(module, (s) => …)` imports the module in Node, awaits one
      export — calling it when it is a function — and resolves to its JSON value, so
      the target keeps the result instead of a script having to write it somewhere.
      
      ```ts
      // tools/openapi.mjs — the consumer's own module: export default async () => document
      const spec = await NodeTasks.evaluate("tools/openapi.mjs");
      await FileTasks.writeText("openapi.json", JSON.stringify(spec, null, 2));
      
      // A named export, called with arguments:
      const config = await NodeTasks.evaluate(
        "dist/config.js",
        (s) => s.export("resolveConfig").callWith("production"),
      );
      ```
      
      `module` is a **path** resolved against the working directory (`.cwd()` moves
      it); the module resolves its own imports from the surrounding `node_modules`, so
      this is how a build re
  • SKILL.md 21.4 KB
    ---
    name: zuke-write-build
    description: Write or edit a Zuke build (zuke.ts) — the code-first, strongly-typed build system for Deno/TypeScript. Use when adding or changing targets, wiring dependencies, calling a tool wrapper (DenoTasks, NpmTasks, DockerTasks, ...), generating CI, or authoring/refactoring a zuke.ts build file. For first-time project scaffolding, use the zuke-setup skill instead.
    ---
    
    # Write or edit a Zuke build
    
    A build is a class that **extends `Build`**. Each **target is a class field**
    created with `target()` and made runnable with `await run(MyBuild)` at the
    bottom of `zuke.ts` (no `import.meta.main` guard — `run` no-ops on import).
    
    <!-- check -->
    
    ```ts
    import { Build, run, target } from "@zuke/core";
    import { DenoTasks } from "@zuke/deno";
    
    class CI extends Build {
      lint = target()
        .description("Lint sources")
        .executes(async () => {
          await DenoTasks.lint();
        });
    
      test = target()
        .description("Type-check and test")
        .dependsOn(this.lint)
        .executes(async () => {
          await DenoTasks.test((s) => s.allowAll().coverage("cov_profile"));
        });
    
      // A field named `default` runs when no target is named on the CLI.
      default = target().dependsOn(this.test).executes(() => {});
    }
    
    await run(CI);
    ```
    
    ## Non-negotiable rules
    
    1. **Import `@zuke/*` by bare specifier, and declare it in `deno.json`.** Write
       `import { DenoTasks } from "@zuke/deno";` and add
       `"@zuke/deno": "jsr:@zuke/deno@^1"` to the `imports` block of the project's
       `deno.json` — never an inline `jsr:@zuke/deno@^1` in the import statement.
       Deno's default lint set (the one that applies when `deno.json` configures no
       `lint.rules`, which is what `zuke setup` scaffolds) rejects an inline `jsr:`
       specifier under `no-import-prefix`, so an inlined one fails the project's own
       `deno task lint`. Pin the caret major in the import map instead, so a future
       `@zuke` major cannot land unannounced. `zuke setup` already declares
       `@zuke/core` there; adding a wrapper means adding its entry too.
    2. **Dependencies are `this.<field>` references, never strings.**
       `.dependsOn(this.lint)`, not `.dependsOn("lint")` — so renames and typos are
       compile-time errors.
    3. **A target may only depend on siblings declared _above_ it.** Class fields
       initialise top-to-bottom; a forward reference is `undefined` and is reported
       as an error (TypeScript also flags it, `TS2729`). Order fields so
       dependencies come first.
    4. **Check the package catalogue before writing any command.** `llms.txt`'s
       `## Packages` catalogue (raw:
       <https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt>) and the
       package table in [`references/cheatsheet.md`](references/cheatsheet.md) are
       the only ways to answer "does a `@zuke/<tool>` wrapper exist for this CLI?" —
       per-package `deno doc jsr:@zuke/<pkg>` only describes a package whose name
       you already know; it cannot tell you a wrapper exists. Whatever runs in an
       `.executes(...)` body drives an external tool through its namespaced `*Tasks`
       object, configured with a **settings lambda** that mirrors the real CLI's
       flags — `DenoTasks`, `NpmTasks`, `DockerTasks`, `GitTasks`, and 30+ more —
       never a raw `Deno.Command` or shell string. `jsr:@zuke/cmd` (`CmdTasks.exec`)
       or the `$` shell from `jsr:@zuke/core/shell` is the **last resort**, reached
       for only once the catalogue confirms no typed wrapper exists — using it for a
       tool that has a `@zuke/<tool>` package is a **bug**, not a style choice: it
       discards typed flags, argv purity, and tool resolution. (If a build delegates
       its side effects to your own tested modules behind injected clients, the
       wrapper rule still governs whatever those modules run in the target body.)
    5. **A body is required**, unless the target is one of the four forms that
       replace it: a `service()`, a `.forEach()` fan-out, a `.waitsFor()` gate, or a
       target declaring only `.effect(...)`. Otherwise set `.executes(...)`; it may
       be sync or async, and its return value is ignored —
       `.executes(() => DenoTasks.lint())` is fine as-is; never wrap a single
       wrapper call in an `async` block just to discard its result.
    
    ## Find the exact signature first
    
    Before calling any task or settings method, confirm the real shape — but first
    confirm a wrapper exists at all: `deno doc` needs a package name to target, so
    it cannot answer "does one exist for this tool?"; only the catalogue
    (`llms.txt`'s `## Packages` list or the cheatsheet table below) can.
    
    - **One package — prefer this in a consumer repo, once you know its name:**
      `deno doc jsr:@zuke/<package>` (e.g. `deno doc jsr:@zuke/deno`). It resolves
      the version the project actually has installed, so it cannot describe an API
      that version lacks.
    - **Whole surface:** `llms-full.txt` (index: `llms.txt`) — at the repo root in
      the Zuke repo itself. From a consumer repo, fetch
      <https://raw.githubusercontent.com/zuke-build/zuke/master/llms-full.txt>
      (index: <https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt>).
      Both track `master`, so they can document symbols that are merged but not yet
      in any published release. Use them for breadth — which packages and tasks
      exist — and confirm a signature with `deno doc` before relying on it.
    - A quick map of the most common methods and task objects is in
      [`references/cheatsheet.md`](references/cheatsheet.md) next to this file —
      read it when wiring targets, then verify specifics against the sources above.
    
    ## Workflow for a change
    
    1. Read the existing `zuke.ts` to learn the targets already declared and their
       order.
    2. Identify the tool you need and look up its `*Tasks` object and settings
       methods (cheatsheet → `deno doc` / `llms-full.txt`).
    3. Add or edit the target field. Place it **below** every target it depends on.
       Wire dependencies with `this.<field>`.
    4. Validate: `./zuke --list` shows it; `./zuke <target> --dry-run` previews the
       plan; `./zuke <target>` runs it.
    
    ## Common building blocks (see the cheatsheet for details)
    
    - **Parallel batches:** `group()` + `.partOf(this.group)` run members
      concurrently; depend on the group to wait for all of them.
    - **Reusable bundles:** a _component_ is a function returning related targets;
      assign it to a field and reference members as `this.release.publish`.
    - **Long-lived processes:** `service()` models a process that must stay _running
      while dependents execute_ (dev server, database, mock API). Declared and
      depended on like a target, but with a `.start(...)` / `.readyWhen(...)`
      lifecycle instead of `.executes(...)`; the executor starts it, waits until
      ready, then stops it in a `finally` so it never leaks. See the cheatsheet.
    - **Authorization by role:** `.requiresRole("operator")` gates running a target
      over MCP, enforced across the whole plan it would execute;
      `override mcpAuthorize(identity, call)` decides the rest. See the cheatsheet.
    - **Target context & cancellation:** a body may take a context —
      `.executes((ctx) => …)` — with `ctx.runId`, `ctx.initiator` (who asked for the
      run, unchanged by a resume), `ctx.target`, `ctx.signal` (an `AbortSignal`
      fired when the run is cancelled; a plain `` $`…` `` in the body is `SIGTERM`'d
      automatically), `ctx.state`, `ctx.dryRun`, `ctx.plan()` (the run's planned
      shape — `targets`, `includes(name)`, `dependenciesOf(name)` — so a body can
      ask whether `deploy` was part of what was asked for; it reports the plan,
      never what will actually execute, which is `ctx.outcomeOf(name)`), and
      `ctx.reportSummary({ … })` (`key: value` notes on the target's own row of the
      Build Summary; every test-runner wrapper — `DenoTasks.test`,
      `VitestTasks.run`, `JestTasks.run`, `BunTasks.test`, `NodeTasks.test`,
      `PlaywrightTasks.test`, `CypressTasks.run` — reports its test counts there by
      itself, and the ambient `reportSummary` does the same from code with no
      `ctx`). Zero-argument bodies keep working unchanged. Cancel a run
      programmatically by passing `{ signal }` to `execute`. See the cheatsheet.
    - **Caching:** `.inputs(...)` / `.outputs(...)` make a target incremental. Add a
      **remote store** to share results across machines (fresh CI, teammates);
      `--affected` runs only targets changed since a git base; `--no-cache` /
      `--no-remote-cache` bypass them. A restore is confined to the target's
      declared `.outputs(...)` (and never `.git`/`.zuke`); a refused archive is a
      cache miss with a warning, not a failure. A cancelled run keeps its cache
      unless a compensation actually rolled something back.
    - **Durable run state:** persist a run's status and per-target metadata to a
      pluggable `StateStore` so it survives the process — turn it on with `--state`,
      `ZUKE_STATE_DIR` / `ZUKE_STATE_URL`, or `override stateStore()`. Every
      `ZUKE_*_URL` backend must be `https:` (loopback exempt;
      `ZUKE_ALLOW_INSECURE_URL=1` opts out). In a body, `ctx.state.set({ … })` /
      `ctx.state.get()` records per-target metadata (JSON, **never secrets** —
      secret parameters and redacted values are excluded). `set` awaits the write;
      `ctx.state.trySet({ … })` is the same write reporting `true` when it reached
      the store and `false` when it was dropped — use it before an irreversible step
      that depends on the value. A store-less build and a compensation body always
      see `true` (nothing durable behind them). Inspect persisted runs afterwards
      with `zuke runs list` (filter by `--status`/`--target`/`--since`/`--limit`)
      and `zuke runs show <id>` (`--json` on both). Prune old ones with
      `zuke runs prune --keep <age> --keep-last <n>` (only terminal runs; never
      suspended/running). A run whose process is killed is picked up by
      `zuke resume --check`, which reaps it — its lease tells a dead holder from a
      slow one — and resumes it in the same sweep. A process that merely _looked_
      dead and then finds its lease taken over **stops**, running no compensations
      and settling nothing: the run is the new holder's now. `override deadline()`
      gives a run a wall-clock budget (`"45m"`, or milliseconds) that survives
      suspension; an abandoned run found past it is settled `failed` with its
      compensations instead of resumed. On a **shared** store, set `ZUKE_BUILD_ID`
      (or rely on `GITHUB_REPOSITORY`) so each build only recovers its own runs — a
      resume runs _this_ build's bodies against whatever record it is given, and a
      templated `zuke.ts` looks identical to the shape checks. See the cheatsheet.
    - **Cross-run locks:** `.lock((s) => s.lockKey(...).withTtl("4h"))` — a settings
      lambda — gives a target an exclusive claim across runs/machines; a second run
      wanting the same key fails with a `LockConflictError` naming the holder, or
      queues when the target adds `.waitUpTo("30m")` (paced by `.pollEvery`). The
      lambda runs after params resolve, so the key can read `this.<param>.value`.
      The lock releases when the target settles and expires after the TTL if the
      holder is killed. Needs a state store (a build with `.lock()` enables the
      filesystem store by default). See the cheatsheet.
    - **External-event waits:**
      `.waitsFor((s) => s.on(externalSignal("approved")).timeout("72h"))` makes a
      target a **gate** with no body: the run proceeds past it only when the trigger
      is satisfied, otherwise it **suspends** (state saved, exits 0) to be resumed
      later in a fresh process. Triggers: `externalSignal(name)` (payload read via
      `ctx.signals`) and `resumeWhen(predicate)`. Continue it with
      `zuke resume <id> --signal <name> [--data <json>]` (or `--check` for predicate
      waits/timeouts) — exactly-once, re-running only the not-yet-succeeded targets.
      Needs a state store. See the cheatsheet / `docs/orchestration.md`.
    - **Cancellation & compensation:** `.onCancel(() => this.rollback)` registers a
      compensation that runs **iff this target succeeded** when the run is later
      cancelled — compensations run in reverse order, and the compensation body's
      `ctx.state` exposes the original target's persisted metadata (so a rollback
      reads what the deploy recorded). Cancel with `zuke cancel <id>` (or Ctrl-C, or
      the MCP `cancel_run` tool). Idempotent; a timed-out wait can route its
      `onTimeout` here (`"cancel-run"` or a named target). Needs a state store. See
      `docs/orchestration.md`.
    - **Durable side effects:** `.effect(name, fn)` records the intent to run `fn`
      before it runs, so a resume re-drives an effect a dead process left owed.
      Effects run after the body, in declaration order; a target may declare effects
      and no body. The guarantee is **at-least-once**, so write bodies that tolerate
      a repeat (an upsert, not an append), and read what the effect acts on from
      `ctx.state` rather than looking up "the current value" — a re-drive happens
      later, against a world that moved on. Needs a state store (enabled
      automatically). See the cheatsheet / `docs/orchestration.md`.
    - **Fan-out over a list:**
      `.forEach(() => this.repos.value, (repo) => ({ checks: target()…, deploy: target()… }), (s) => s.concurrency(3).continueOnItemFailure())`
      runs the same pipeline over a runtime list — items concurrent, each item's
      stages sequential. Sub-targets are materialised at run time
      (`parent[item].stage`), each a first-class row in the summary and the run
      record; `continueOnItemFailure()` isolates a failed item. An `.onCancel(...)`
      on a fan-out stage runs per item on cancel (item-scoped `ctx.state`, reverse
      order). See `docs/orchestration.md`.
    - **Typed inputs:** `parameter("...")` (with `.number()` / `.boolean()` /
      `.options(...)` / `.secret()` / `.required()`), read as `this.x.value`, gated
      with `.requires(this.x)`. `.array()` composes and comes **last**:
      `.options(...).array()` validates each element, `.number().array()` →
      `number[]`, and a required list is `.required().array()` (required before
      array — `.array().required()` does not typecheck). A parameter may not be
      named so that it renders as a built-in CLI flag (`actor`, `actorKind`,
      `limit`, `target`, `output`, …) or as an MCP control key (`dryRun`, `confirm`,
      `operatorToken`) — the build refuses to load, naming the field. The flag is
      one dash per lower-to-upper transition, and a **digit ends a run of
      capitals**, so `skipE2E` gives `--skip-e2-e`; name it `skipE2e` or declare
      `.flag("--skip-e2e")`, which replaces the derived spelling everywhere.
    - **Secrets from a manager:** `parameter(...).secret().from(source)` sources a
      value at run time (e.g. `execSecret(...)` shelling out to a secret CLI) and
      **redacts** it from all of Zuke's output. See the cheatsheet.
    - **Provisioning tools:** `ToolTasks.install((s) => …)` / `toolchain((t) => …)`
      fetch pinned, checksum-verified release binaries so a build is hermetic, and
      `t.npm({ name, version, bin? })` / `ToolTasks.npm(...)` provision a
      version-pinned, cached npm-registry package (needs `npm` on `PATH`); hand the
      returned path to a wrapper's `.toolPath(...)`. In a Node monorepo, resolve a
      wrapper's binary from `node_modules/.bin` npx-style instead —
      `.fromNodeModules()` on the settings (or `ZUKE_TOOL_RESOLUTION=node_modules`
      repo-wide) walks up for the local shim and falls back to PATH; `.fromPath()`
      forces PATH and an explicit `.toolPath(...)` always wins. See the cheatsheet /
      `docs/tools.md`.
    - **Code-first CI:** `cicd({ provider: "github" })` generates and verifies the
      workflow YAML from the build.
    - **Operate the build from an agent:** `zuke mcp` serves the build over MCP so
      an AI client can list, inspect, and (with `--allow-run`) run targets — on
      stdio, or over HTTP with `--http <host:port>` (loopback by default; a
      non-loopback bind needs a `ZUKE_MCP_TOKEN` bearer token **or** an
      `mcpAuth()`/`mcpIdentity()` authenticator, else the server exits 1). With a
      state store it also exposes `list_runs`/`show_run` (+ `signal_run`,
      `resume_check` and `cancel_run`). Tier access with `--allow-run=<globs>` (an
      allow-list over **invocation** — invoking a target runs its dependencies, and
      the read tools narrow to the allow-listed targets' closure),
      `--protect <globs>` + `ZUKE_OPERATOR_TOKEN` (enforced over a run's **whole
      plan**, so a protected target reached as a dependency still needs the token),
      and `--confirm-destructive`; mark inspect-only targets `.readOnly()`.
      Mutating/denied calls are audited — read the trail on the host with
      `zuke runs show mcp-audit`; it is deliberately not readable over MCP. A
      **registry-backed** server (`zuke register` then `zuke mcp --registry`)
      instead serves every registered pipeline live, each as a
      `run:<buildId>:<target>` tool that takes the build's declared parameters
      (secrets excluded, validated, forwarded to the spawn) — see the cheatsheet.
      Because the registry names _where_ a build launches from, a descriptor with a
      **remote** entry module is refused unless its origin is in
      `ZUKE_REGISTRY_LAUNCH_HOSTS`, and a `command` location is refused unless its
      program is in `ZUKE_REGISTRY_LAUNCH_COMMANDS` (the registry writer picks the
      program and its arguments); `zuke register` writes a local module, so both
      only affect a hand-authored or second-party entry. For a shared, multi-user
      endpoint, `override mcpAuth()` authenticates a **trusted** caller per request
      — an async `authenticate(ctx)` returning `{ actor, kind?, roles?, via? }` or
      an `McpAuthReject` (`{ status, error, detail?, challenge? }`), so a refused
      HTTP request answers that status with `WWW-Authenticate` instead of a `200`.
      `override mcpIdentity()` is the sugar for the proxy-header case (a sync hook
      reading a header; any throw rejects), adapted onto the same path — declare one
      or the other, never both, or the server exits 1. Either overrides the
      client-reported actor and flows to the audit trail, run records, lock holders,
      and a registry-spawned child's `ZUKE_ACTOR`/`ZUKE_ACTOR_KIND`/
      `ZUKE_ACTOR_ROLES`. Both are fail-closed: a throw, a non-object, or an empty
      actor refuses the request, and nothing runs. `override mcpProtectedResource()`
      publishes the RFC 9728 metadata document and names it in every challenge, so a
      client that has no token can discover the identity provider — Zuke is the
      resource only, and hosts no OAuth endpoints of its own. See the cheatsheet.
    - **AI review & self-healing (`@zuke/ai`):** gate a target on a structured LLM
      review of the diff (`securityReviewer(...)` etc. via `.validateBefore`), or
      attach `aiFixer(...)` with `.recoverWith(...)` so a failing target is
      diagnosed and (opt-in) auto-fixed, with a committable PR suggestion. Override
      `recoverWith()` on the build to apply one fixer to every target. A reviewer
      can go deeper and hold a discussion: `.conventionsFile("AGENTS.md")` (judged
      against the project's rules, read from the diff base), `.criteriaFile(...)`
      (project-specific notes read from that base too — `.criteria(text)` is build
      code and travels with the change), `.fileContext()` (whole changed files, not
      bare hunks), `.verify()` (adversarial re-check of every finding), and
      `.discussion()` (maintainers refute a finding by replying with its id — or,
      with `.discussion((d) => d.threads())`, by replying in the finding's own
      line-anchored review thread; accepted dismissals persist instead of
      resurfacing, including when the model rewords the finding, refutations from
      `.verify()` are remembered too, and a rebuttal is adjudicated even when the
      next round's model drops the finding — only platform-verified maintainer
      comments ever reach the model, on GitHub, GitLab, Azure DevOps and Bitbucket
      alike). A maintainer's decision is inherited by a restatement on any file of
      the diff and at any severity, and shared with every reviewer on the pull
      request. A rebuttal needs no push: commenting the workflow's `command` (e.g.
      `@zuke-build review`) starts a run that adjudicates it and answers in the
      thread. With `.discussion((d) => d.commands("@zuke-build"))` the comment
      carries a collapsed Commands panel, and `@zuke-build accept <id> <reason>`
      records a finding as accepted for the pull request without adjudication; the
      workflow generator derives the on-demand job from that mention, and
      `command: (c) => c.role(...).users(...)` chooses who may start it. Reviews
      post as `github-actions[bot]` by default, or as your own GitHub App via
      `GhTasks.appTokenSource`. See the cheatsheet's AI section.
    - **Wait on an external GitHub workflow (`@zuke/gh`):** in a `.waitsFor(...)`
      gate, `s.on(githubWorkflow((g) => g.repo("o/r").workflow("e2e.yml")))`
      dispatches a workflow in another repo and suspends until it finishes; read the
      per-job result with `readWorkflowResult(ctx.stateOf("<gate>"))`. Correlates by
      a `run-name:` marker by default, or `.correlate("created-window")` for a
      workflow you can't modify; fails fast (`.discoveryTimeout(...)`) if the run
      never correlates. The **dispatched** workflow has a contract: declare the
      marker input (`zuke_marker`, or rename via `.markerInput(...)`), echo it as
      its _entire_ `run-name:` (equality, not substring), and receive any of its
      `required: true` inputs via `.inputs(...)` — see the cheatsheet's
      receiving-workflow contract. Triggers are extensible — write your own against
      the exported `WaitTrigger`/`WaitContext`.
    - **OpenTelemetry export (`@zuke/otel`):** register `otel((s) => s.endpoint(…))`
      as a plugin (`run(MyBuild, { plugins: [otel(…)] })`) to ship run/target spans
      and `zuke.run.started` / `zuke.run.suspended` / `zuke.runs` counters as
      OTLP/HTTP JSON. Needs a state store; the trace id is derived from the run id,
      so a suspend/resume across processes is one trace. Config falls back to the
      standard `OTEL_*` env vars, and it is inert with no endpoint. Dependency-free.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related