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
Install
npx skills add https://github.com/zuke-build/zuke/tree/master/skills/zuke-write-build
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zuke-build-zuke@llmmart
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
- Import
@zuke/*by bare specifier, and declare it indeno.json. Writeimport { DenoTasks } from "@zuke/deno";and add"@zuke/deno": "jsr:@zuke/deno@^1"to theimportsblock of the project'sdeno.json— never an inlinejsr:@zuke/deno@^1in the import statement. Deno's default lint set (the one that applies whendeno.jsonconfigures nolint.rules, which is whatzuke setupscaffolds) rejects an inlinejsr:specifier underno-import-prefix, so an inlined one fails the project's owndeno task lint. Pin the caret major in the import map instead, so a future@zukemajor cannot land unannounced.zuke setupalready declares@zuke/corethere; adding a wrapper means adding its entry too. - Dependencies are
this.<field>references, never strings..dependsOn(this.lint), not.dependsOn("lint")— so renames and typos are compile-time errors. - A target may only depend on siblings declared above it. Class fields
initialise top-to-bottom; a forward reference is
undefinedand is reported as an error (TypeScript also flags it,TS2729). Order fields so dependencies come first. - Check the package catalogue before writing any command.
llms.txt's## Packagescatalogue (raw: https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt) and the package table inreferences/cheatsheet.mdare the only ways to answer "does a@zuke/<tool>wrapper exist for this CLI?" — per-packagedeno 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*Tasksobject, configured with a settings lambda that mirrors the real CLI's flags —DenoTasks,NpmTasks,DockerTasks,GitTasks, and 30+ more — never a rawDeno.Commandor shell string.jsr:@zuke/cmd(CmdTasks.exec) or the$shell fromjsr:@zuke/core/shellis 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.) - 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 anasyncblock 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 trackmaster, 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 withdeno docbefore relying on it. - A quick map of the most common methods and task objects is in
references/cheatsheet.mdnext to this file — read it when wiring targets, then verify specifics against the sources above.
Workflow for a change
- Read the existing
zuke.tsto learn the targets already declared and their order. - Identify the tool you need and look up its
*Tasksobject and settings methods (cheatsheet →deno doc/llms-full.txt). - Add or edit the target field. Place it below every target it depends on.
Wire dependencies with
this.<field>. - Validate:
./zuke --listshows it;./zuke <target> --dry-runpreviews 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 afinallyso 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) => …)— withctx.runId,ctx.initiator(who asked for the run, unchanged by a resume),ctx.target,ctx.signal(anAbortSignalfired when the run is cancelled; a plain$`…`in the body isSIGTERM'd automatically),ctx.state,ctx.dryRun,ctx.plan()(the run's planned shape —targets,includes(name),dependenciesOf(name)— so a body can ask whetherdeploywas part of what was asked for; it reports the plan, never what will actually execute, which isctx.outcomeOf(name)), andctx.reportSummary({ … })(key: valuenotes 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 ambientreportSummarydoes the same from code with noctx). Zero-argument bodies keep working unchanged. Cancel a run programmatically by passing{ signal }toexecute. See the cheatsheet. - Caching:
.inputs(...)/.outputs(...)make a target incremental. Add a remote store to share results across machines (fresh CI, teammates);--affectedruns only targets changed since a git base;--no-cache/--no-remote-cachebypass 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
StateStoreso it survives the process — turn it on with--state,ZUKE_STATE_DIR/ZUKE_STATE_URL, oroverride stateStore(). EveryZUKE_*_URLbackend must behttps:(loopback exempt;ZUKE_ALLOW_INSECURE_URL=1opts out). In a body,ctx.state.set({ … })/ctx.state.get()records per-target metadata (JSON, never secrets — secret parameters and redacted values are excluded).setawaits the write;ctx.state.trySet({ … })is the same write reportingtruewhen it reached the store andfalsewhen it was dropped — use it before an irreversible step that depends on the value. A store-less build and a compensation body always seetrue(nothing durable behind them). Inspect persisted runs afterwards withzuke runs list(filter by--status/--target/--since/--limit) andzuke runs show <id>(--jsonon both). Prune old ones withzuke runs prune --keep <age> --keep-last <n>(only terminal runs; never suspended/running). A run whose process is killed is picked up byzuke 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 settledfailedwith its compensations instead of resumed. On a shared store, setZUKE_BUILD_ID(or rely onGITHUB_REPOSITORY) so each build only recovers its own runs — a resume runs this build's bodies against whatever record it is given, and a templatedzuke.tslooks 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 aLockConflictErrornaming the holder, or queues when the target adds.waitUpTo("30m")(paced by.pollEvery). The lambda runs after params resolve, so the key can readthis.<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 viactx.signals) andresumeWhen(predicate). Continue it withzuke resume <id> --signal <name> [--data <json>](or--checkfor 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'sctx.stateexposes the original target's persisted metadata (so a rollback reads what the deploy recorded). Cancel withzuke cancel <id>(or Ctrl-C, or the MCPcancel_runtool). Idempotent; a timed-out wait can route itsonTimeouthere ("cancel-run"or a named target). Needs a state store. Seedocs/orchestration.md. - Durable side effects:
.effect(name, fn)records the intent to runfnbefore 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 fromctx.staterather 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-scopedctx.state, reverse order). Seedocs/orchestration.md. - Typed inputs:
parameter("...")(with.number()/.boolean()/.options(...)/.secret()/.required()), read asthis.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, soskipE2Egives--skip-e2-e; name itskipE2eor 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, andt.npm({ name, version, bin? })/ToolTasks.npm(...)provision a version-pinned, cached npm-registry package (needsnpmonPATH); hand the returned path to a wrapper's.toolPath(...). In a Node monorepo, resolve a wrapper's binary fromnode_modules/.binnpx-style instead —.fromNodeModules()on the settings (orZUKE_TOOL_RESOLUTION=node_modulesrepo-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 mcpserves 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 aZUKE_MCP_TOKENbearer token or anmcpAuth()/mcpIdentity()authenticator, else the server exits 1). With a state store it also exposeslist_runs/show_run(+signal_run,resume_checkandcancel_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 withzuke runs show mcp-audit; it is deliberately not readable over MCP. A registry-backed server (zuke registerthenzuke mcp --registry) instead serves every registered pipeline live, each as arun:<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 inZUKE_REGISTRY_LAUNCH_HOSTS, and acommandlocation is refused unless its program is inZUKE_REGISTRY_LAUNCH_COMMANDS(the registry writer picks the program and its arguments);zuke registerwrites 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 asyncauthenticate(ctx)returning{ actor, kind?, roles?, via? }or anMcpAuthReject({ status, error, detail?, challenge? }), so a refused HTTP request answers that status withWWW-Authenticateinstead of a200.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'sZUKE_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 attachaiFixer(...)with.recoverWith(...)so a failing target is diagnosed and (opt-in) auto-fixed, with a committable PR suggestion. OverriderecoverWith()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'scommand(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, andcommand: (c) => c.role(...).users(...)chooses who may start it. Reviews post asgithub-actions[bot]by default, or as your own GitHub App viaGhTasks.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 withreadWorkflowResult(ctx.stateOf("<gate>")). Correlates by arun-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 entirerun-name:(equality, not substring), and receive any of itsrequired: trueinputs via.inputs(...)— see the cheatsheet's receiving-workflow contract. Triggers are extensible — write your own against the exportedWaitTrigger/WaitContext. - OpenTelemetry export (
@zuke/otel): registerotel((s) => s.endpoint(…))as a plugin (run(MyBuild, { plugins: [otel(…)] })) to ship run/target spans andzuke.run.started/zuke.run.suspended/zuke.runscounters 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 standardOTEL_*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.
Reviews (0)
No reviews yet.
No comments yet.