{
  "version": "0.1.0-draft",
  "templates": [
    {
      "name": "chain",
      "file": "chain.nika.yaml",
      "intent": "take data, produce words, save them",
      "patterns": [
        "deterministic gather",
        "one model job",
        "explicit persist"
      ],
      "slots": 10,
      "sha256": "68c26c13060fce2279634f0640ccacd054f65f04b00a583ae1b73568e494d624",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · chain · gather facts → one model step → persist.\n#\n#   [gather]────▶[think]────▶[persist]\n#   nika:read     infer       nika:write\n#\n# The everyday shape: pull in something real, run exactly ONE model job over\n# it, write the result somewhere you can find it again. Most useful workflows\n# are this and nothing more — reach for a bigger skeleton only when this one\n# genuinely cannot hold the job.\n#\n# Demonstrates ·\n#   - the three-verb spine · invoke → infer → invoke\n#   - `with:` as the DAG edge · a task reads its input from a binding, and the\n#     binding IS what orders the graph (nobody writes the arrows)\n#   - `on_error: recover:` · the skeleton rehearses green in an empty\n#     directory, before it is pointed at real data\n#\n# Needs · nothing. Scaffolds and runs green in an empty directory.\n#\n# Run · nika run <file> --model mock/echo    # offline rehearsal · zero keys\nnika: chain-template       # SLOT: kebab-case workflow id\n\n# SLOT: provider/model · local · zero key. Measured seat — `ollama/llama3.2:3b`\n# answers this prompt in ~50s. `--model mock/echo` needs no seat at all and is\n# the path this file guarantees.\nmodel: ollama/llama3.2:3b\n\nconst:\n  source: \"./README.md\"             # SLOT: your input · README.md exists in most repos\n  destination: \"./output.md\"        # SLOT: where the result lands\n\npermits:                            # the blast radius · default-deny once present\n  tools: [\"nika:read\", \"nika:write\"]\n  # Two literal paths, one each way. `check` can read these (they come from\n  # `const:`) and proves them against the boundary before anything runs — keep\n  # them in step with the two `const:` entries above.\n  fs:\n    read: [\"./README.md\"]\n    write: [\"./output.md\"]\n\ntasks:\n  gather:\n    invoke:                         # SLOT: the fact source · nika:read / nika:fetch / exec\n      tool: \"nika:read\"\n      args: { path: \"${{ const.source }}\" }\n    on_error:\n      # Offline rehearsal. A freshly scaffolded directory has no README, and a\n      # skeleton that dies on its first run teaches nothing — so a not-found\n      # recovers into a literal standing in for the real document. `on_codes:`\n      # keeps that narrow: ONLY not-found is forgiven, a permission error\n      # still fails loudly. Delete this block once the source really exists.\n      # In an EMPTY directory check prints one [inputs] hint that this read\n      # would fail — the run does not: this recover carries it (measured ·\n      # rc=0 · « 1 recovered »). The hint retires once your source exists.\n      on_codes: [NIKA-BUILTIN-READ-001]\n      recover: \"REHEARSAL · no source document here yet.\"\n\n  think:\n    with:\n      gather: ${{ tasks.gather.output }}\n    infer:\n      max_tokens: 800               # SLOT: the spend ceiling · sized for the SEAT, not the answer\n      # SLOT: the one model job. Keep slot markers OUT of the block below —\n      # everything indented under `prompt: |` is prompt TEXT, so a stray\n      # `# SLOT:` line is sent to the model verbatim (visible in the mock\n      # echo). Comments belong out here, where YAML eats them.\n      prompt: |\n        Summarize the following in a short paragraph.\n\n        ${{ with.gather }}\n\n  persist:\n    with:\n      think: ${{ tasks.think.output }}\n    invoke:\n      tool: \"nika:write\"\n      args:\n        path: \"${{ const.destination }}\"   # SLOT: destination · same path as permits.fs.write\n        content: \"${{ with.think }}\"       # ALWAYS pass content · a write without it writes nothing\n    on_error:\n      # Golden rehearsal. Under `nika test` the mock plane simulates the\n      # MODEL, not effects — this write is refused (NIKA-452) so the pin\n      # lane needs this recover to walk its own scaffold. `nika run` never\n      # fires it on success (a recover only runs on failure). Delete this\n      # block once pointed at real data, so a genuine write failure is loud.\n      recover: \"REHEARSAL · write refused under nika test — the real run persists to the destination\"\n\noutputs:\n  result: ${{ tasks.think.output }}  # SLOT: the callable contract\n"
    },
    {
      "name": "gate-and-act",
      "file": "gate-and-act.nika.yaml",
      "intent": "watch X, act when Y",
      "patterns": [
        "jq extraction",
        "CEL skip-gate",
        "often zero model calls"
      ],
      "slots": 9,
      "sha256": "4870c70f7a6198f86b1da8587e7e1345bbece86ce07446d74e16866cbe514610",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · gate-and-act · check a condition, act only when it holds.\n#\n#   [check]────with · when(cond)────▶[act]\n#   nika:fetch                        nika:notify\n#\n# The watcher. Read a number, stay silent unless it crosses a line. Most runs\n# do nothing at all — and \"nothing happened\" is a SUCCESS here, not a failure.\n# Often zero model calls, so it is cheap enough to run on a schedule.\n#\n# Demonstrates ·\n#   - `when:` as a SKIP gate · routing, not failure · a skipped task is not an\n#     error and does not fail the run\n#   - flow vs capability · `egress:` sanctions the SEND, `permits.net.http`\n#     grants the REACH · an alert path needs BOTH (see `permits.net`)\n#   - `on_error: recover:` · the gate rehearses CLOSED, so the skeleton is\n#     green before the real source is wired\n#\n# Needs · nothing to rehearse. To alert FOR REAL: ALERTS_WEBHOOK_URL in the\n#   environment and a live source in `const.source_url` — then delete the\n#   `on_error:` so a dead source is loud.\n#\n# Run · nika run <file>          # offline rehearsal · the gate stays closed\nnika: gate-and-act-template       # SLOT: kebab-case workflow id\n\nconst:\n  source_url: \"https://api.example.com/v1/value\"   # SLOT: what to watch\n  threshold: 100                    # SLOT: the trigger condition value\n\npermits:                            # the blast radius · default-deny once present\n  tools: [\"nika:fetch\", \"nika:notify\"]\n  net:\n    http:\n      - \"api.example.com\"           # SLOT: the watched host from const.source_url\n      - \"hooks.slack.com\"           # SLOT: the host ALERTS_WEBHOOK_URL points at.\n                                    #   `host_from_self` below means the host is not\n                                    #   knowable at check — it is judged at RUN against\n                                    #   this list, so an unnamed host fails mid-flight.\n\nsecrets:\n  webhook:\n    source: env\n    key: ALERTS_WEBHOOK_URL         # SLOT: where the act lands\n    egress:\n      - to: \"nika:notify\"\n        host_from_self: true        # the secret value IS the destination URL\n\ntasks:\n  check:\n    invoke:\n      tool: \"nika:fetch\"\n      args:\n        url: \"${{ const.source_url }}\"\n        mode: jq\n        jq: \".\"\n    extract:\n      value: \".value\"               # SLOT: the jq path to the watched field\n    on_error:\n      # Offline rehearsal · a sample UNDER the threshold — the gate stays\n      # closed, the skeleton runs green before you wire the real source.\n      recover: { value: 42 }\n\n  act:\n    with:\n      value: ${{ tasks.check.value }}   # the binding IS the edge · check → act\n    # when: is a SKIP gate — routing, not failure (a skipped task is not an error)\n    when: ${{ with.value > const.threshold }}   # SLOT: the CEL condition\n    invoke:\n      tool: \"nika:notify\"           # SLOT: the action · notify / write / exec\n      args:\n        channel: webhook\n        target: \"${{ secrets.webhook }}\"\n        message: \"Threshold crossed · ${{ with.value }}\"   # SLOT\n        severity: warning\n\noutputs:\n  value: ${{ tasks.check.value }}\n"
    },
    {
      "name": "fanout",
      "file": "fanout.nika.yaml",
      "intent": "do this for EVERY item",
      "patterns": [
        "runtime collection",
        "the full leash (`for_each.max_parallel`",
        "`for_each.fail_fast`",
        "retry)"
      ],
      "slots": 10,
      "sha256": "0de58ee20ce82d44815988767e433a340b0c4f2a7b75d8965f6ed3309c269519",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · fanout · discover a collection → process every item in parallel → merge.\n#\n#   [discover]──▶[process ∥×N]───▶[survivors]──▶[merge]\n#   nika:glob     for_each·infer    nika:jq        infer\n#\n# The \"do this for EVERY one of them\" job, where you do not know how many there\n# are until the run starts. Discovery yields a collection, `for_each` fans it\n# out under a real leash (bounded parallelism, per-item timeout, retries), one\n# bad item cannot sink the batch, and the survivors merge into a single answer.\n#\n# Demonstrates ·\n#   - `for_each:` over a RUNTIME collection · the fan width is discovered, never\n#     written down\n#   - the full leash · `for_each.max_parallel` + `timeout` + `retry` +\n#     `for_each.fail_fast: false`\n#   - `on_error: recover: null` + a null-aware fan-in · a failed item becomes a\n#     hole at its index, order is preserved, the batch survives\n#\n# Needs · nothing. Scaffolds and runs green in an empty directory — an empty\n#   discovery skips the fan-out and the fan-in still returns a value.\n#\n# Run · nika run <file> --model mock/echo    # offline rehearsal\nnika: fanout-template       # SLOT: kebab-case workflow id\n\n# SLOT: provider/model · local · zero key. Measured seat — `ollama/llama3.2:3b`.\n# `--model mock/echo` needs no seat and is the path this file guarantees.\nmodel: ollama/llama3.2:3b\n\nrun:\n  # A `timeout:` is a deadline, and a deadline needs a clock. Declaring it says\n  # out loud which one: `system` is the ambient wall clock (the honest default);\n  # `virtual` drives a simulated clock for deterministic tests.\n  clock: system\n\nconst:\n  collection_source: \"./items\"      # SLOT: where the collection comes from\n\npermits:\n  tools: [\"nika:glob\", \"nika:jq\", \"nika:read\"]\n  fs:\n    # TWO entries, and both earn their place. `nika:glob` is gated on the\n    # DIRECTORY it walks, not on the files it returns — measured: with\n    # `./items/*.md` here (the pattern) the run dies `NIKA-SEC-004 ·\n    # ./items resolves outside the declared permits.fs.read boundary`,\n    # because `*` never crosses `/` and so never matches `./items` itself.\n    # And `nika:read` is gated on the FILES — a directory grant does not\n    # cover its children (measured: dir-only here made every per-item read\n    # die SEC-004, `recover: null` swallowed each, and the model was\n    # handed nulls — a green run inventing content). A deeper pattern\n    # (`./items/**/*.md`) walks deeper and needs `./items/**` for both.\n    read: [\"./items\", \"./items/*\"]\n\ntasks:\n  discover:\n    invoke:                         # SLOT: glob / fetch sitemap / exec + jq split\n      tool: \"nika:glob\"\n      args: { pattern: \"${{ const.collection_source }}/*.md\" }\n\n  # `discover` hands back PATH STRINGS — a prompt that interpolates the raw\n  # item shows the model a FILENAME, never the file (measured: a green run\n  # whose fluent « report » was invented from the names alone — check, mock\n  # rehearsal and run all green around it). Reading the item is its own fan.\n  # Delete this task — and fan `process` over `discover` — when your items\n  # already ARE the content (an inline list · fetched records). Need each\n  # content paired with its path? `resume-screener` shows the transpose.\n  read:\n    with:\n      discover: ${{ tasks.discover.output }}\n    for_each:\n      items: ${{ with.discover }}\n      max_parallel: 8\n      fail_fast: false\n    on_error:\n      recover: null                 # an unreadable item yields null · the batch lives\n    invoke:\n      tool: \"nika:read\"\n      args: { path: \"${{ item }}\" }\n\n  process:\n    with:\n      read: ${{ tasks.read.output }}\n    # The quiet-day guard: an EMPTY discovery skips the `read` fan, and a\n    # skipped task hands NULL — which a bare for_each refuses (NIKA-VAR-006).\n    # The ternary keeps the no-items day green end to end.\n    for_each:\n      items: \"${{ with.read == null ? [] : with.read }}\"\n      max_parallel: 4                 # SLOT: the polite ceiling\n      fail_fast: false\n    timeout: \"60s\"                  # SLOT: per-iteration bound\n    retry:\n      max_attempts: 3\n      backoff_strategy: exponential\n      jitter: true\n    on_error:\n      recover: null                 # a failed item yields null at its index · the batch lives\n    infer:                          # SLOT: the per-item job (any verb)\n      max_tokens: 500               # SLOT: the per-ITEM ceiling · multiply by the fan width\n      prompt: |\n        Process this item · ${{ item }}\n\n  survivors:\n    with:\n      process: ${{ tasks.process.output }}\n    invoke:                         # the null-aware fan-in · order preserved\n      tool: \"nika:jq\"\n      args:\n        input: ${{ with.process }}\n        # `. // []` · an EMPTY discovery skips the whole for_each (null\n        # upstream) — the fan-in must survive its own quiet day.\n        expression: \"(. // []) | [ .[] | select(. != null) ]\"\n\n  merge:\n    with:\n      survivors: ${{ tasks.survivors.output }}\n    infer:\n      max_tokens: 800               # SLOT: the fan-in ceiling\n      # SLOT: the fan-in · the survivors array (failed items filtered). Keep\n      # slot markers out of the block below — it is prompt TEXT, not YAML.\n      prompt: |\n        Merge these results into one report · ${{ with.survivors }}\n\noutputs:\n  report: ${{ tasks.merge.output }}\n"
    },
    {
      "name": "etl-state",
      "file": "etl-state.nika.yaml",
      "intent": "only what changed since last run · survive bad input",
      "patterns": [
        "state read→parse→diff→write",
        "`on_error: on_codes:` quarantine"
      ],
      "slots": 8,
      "sha256": "f58cc453b0441dfe99f12438620dfea0739b26128b9c38f3923588e55953419f",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · etl-state · incremental data job with a state file.\n#\n#   [approve]──▶[fresh]──┬───────────────────────▶[save_state]   when approved\n#    prompt      fetch   │                         nika:write\n#   [previous_raw]─▶[previous]──┴──▶[delta]──when ≠∅──▶[process]\n#    nika:read      nika:jq          json_diff          nika:jq\n#\n# The \"only what changed since last time\" job. A cursor file on disk remembers\n# the last run, a fetch brings the current truth, and an RFC-6902 diff says\n# exactly what moved — so downstream work is proportional to the CHANGE, not to\n# the size of the dataset.\n#\n# Demonstrates ·\n#   - a state file read→diff→write cycle · one path, both directions\n#   - `nika:read` returns TEXT · it is parsed with `fromjson` before diffing\n#     (skip that and the diff is meaningless — see `previous`)\n#   - a blocking human gate that DOMINATES every path to the network and the\n#     write · NEP-0002's Rule of Two, as a check\n#   - `on_error: on_codes:` · forgive not-found ONLY, never a permission error\n#\n# TWO HONEST HINTS · this file ships RED-adjacent on purpose:\n#   [headless-prompt] the gate is deliberately blocking · a `default:` here\n#     completes the lethal trifecta and lights NIKA-SEC-009 (measured — add one\n#     and watch the TRIFECTA rung flip). The hint is right that it needs a\n#     human; that is the point.\n#   [inputs] `previous_raw` reads a cursor file that does not exist yet · the\n#     `on_error:` below handles exactly that, but `check` does not model\n#     recovery. First run creates it.\n#\n# GOLDEN LANE · `nika test` pauses at the gate BY DESIGN (the blocking\n#   prompt has no headless answer seam yet) — the pin lane waits on a\n#   `nika test --answer <task>=<value>` passthrough. The pause is the\n#   trifecta lesson working, not a broken scaffold.\n#\n# Needs · nothing to rehearse. For REAL use: a reachable source in\n#   `const.source_url` — then delete the `on_error:` on `fresh`.\n#\n# Run · nika run <file> --output json\n#     The run PAUSES at the gate (exit 4 · durable, not a failure) and prints\n#     its own resume command. Answer it to continue.\nnika: etl-state-template       # SLOT: kebab-case workflow id\n\nconst:\n  source_url: \"https://api.example.com/v1/records\"   # SLOT: the data source\n  state_path: \"./state/etl-state.json\"               # SLOT: the cursor file\n\npermits:                            # the blast radius · default-deny once present\n  tools: [\"nika:fetch\", \"nika:jq\", \"nika:json_diff\", \"nika:prompt\", \"nika:read\", \"nika:write\"]\n  net: { http: [\"api.example.com\"] }    # SLOT: the source host from const.source_url\n  fs:                               # the cursor file, read then rewritten — one path, both ways\n    read: [\"./state/etl-state.json\"]    # SLOT: keep in step with const.state_path\n    write: [\"./state/etl-state.json\"]   # SLOT: idem — the job writes nothing else\n\ntasks:\n  # NEP-0002 · the Rule of Two, as a check. This run holds all three legs at\n  # once: it reads a private file, ingests UNTRUSTED network content, and\n  # persists that content into the very file the NEXT run reads as trusted\n  # state. One human decision has to dominate EVERY path to that write — so the\n  # gate sits before the first network touch, not next to the write (a gate the\n  # fetch can route around dominates nothing). Blocking on purpose: a `default:`\n  # here would disarm it, and the checker knows — measured, adding `default:`\n  # flips TRIFECTA to `✖ NIKA-SEC-009 lethal trifecta complete`.\n  approve:\n    invoke:\n      tool: \"nika:prompt\"\n      args:\n        message: \"Fetch ${{ const.source_url }} and persist it into ${{ const.state_path }}?\"\n\n  previous_raw:\n    invoke:\n      tool: \"nika:read\"\n      args: { path: \"${{ const.state_path }}\" }\n    on_error:\n      on_codes: [NIKA-BUILTIN-READ-001]   # not-found ONLY · a permission error still fails loudly\n      recover: \"[]\"                       # first run · the STRING \"[]\", so `previous` parses it\n                                          # exactly like a real file — one code path, not two\n\n  previous:\n    with:\n      raw: ${{ tasks.previous_raw.output }}\n    invoke:\n      # `nika:read` hands back TEXT, and `nika:json_diff` compares VALUES. Feed\n      # it the raw string and the diff degrades to a single\n      # `{\"op\":\"replace\",\"path\":\"\"}` carrying the entire new document — it looks\n      # like it works, and it reports \"everything changed\" forever. Measured.\n      # `fromjson` is what makes the next task an actual diff.\n      tool: \"nika:jq\"\n      args:\n        input: \"${{ with.raw }}\"\n        expression: \"fromjson\"\n\n  fresh:\n    with:\n      go: ${{ tasks.approve.output }}   # the binding IS the edge · the gate dominates the fetch\n    when: ${{ with.go == true }}        # declined → no network touch at all\n    invoke:\n      tool: \"nika:fetch\"            # SLOT: fetch / read / exec · the fresh data\n      args:\n        url: \"${{ const.source_url }}\"\n        mode: jq\n        jq: \".records\"\n    on_error:\n      recover: []                   # offline rehearsal · an empty batch, the delta stays quiet\n\n  delta:\n    with:                           # the bindings ARE the edges · previous + fresh → delta\n      previous: ${{ tasks.previous.output }}\n      fresh: ${{ tasks.fresh.output }}\n    invoke:\n      tool: \"nika:json_diff\"        # RFC 6902 · empty patch = nothing new\n      args:\n        before: \"${{ with.previous }}\"\n        after: \"${{ with.fresh }}\"\n\n  process:\n    with:\n      delta: ${{ tasks.delta.output }}\n    when: ${{ size(with.delta) > 0 }}\n    invoke:\n      tool: \"nika:jq\"               # SLOT: the delta job (jq · infer · write…)\n      args:\n        input: \"${{ with.delta }}\"\n        expression: \"length\"\n\n  save_state:\n    with:\n      fresh: ${{ tasks.fresh.output }}\n      go: ${{ tasks.approve.output }}   # the binding IS the edge · the gate dominates the write\n    when: ${{ with.go == true }}        # a refusal is a VALUE · the write is skipped, never failed\n    invoke:\n      tool: \"nika:write\"\n      args:\n        path: \"${{ const.state_path }}\"\n        content: \"${{ with.fresh }}\"    # a VALUE · the engine serializes it, and the next run's\n                                        # `fromjson` reads it straight back\n        create_dirs: true\n        overwrite: true\n\noutputs:\n  changes:\n    value: ${{ tasks.delta.output }}\n    description: \"RFC 6902 ops since last run · empty = no-op run\"\n"
    },
    {
      "name": "agent-loop",
      "file": "agent-loop.nika.yaml",
      "intent": "research / review / open-ended",
      "patterns": [
        "plan-then-execute",
        "default-deny tools",
        "budgets",
        "engine-owned typed result"
      ],
      "slots": 7,
      "sha256": "5ded8b7dbdc23065d47ed48f32e8072ac74765cdde30101b2b03d6b5a59992da",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · agent-loop · plan fast → execute with a leashed agent → validate the typed result.\n#\n#   [plan]────▶[execute]─────▶[confirm]\n#   infer       agent·leashed  nika:assert\n#\n# The open-ended job — research, review, triage — where the steps cannot be\n# written in advance. A cheap `infer` drafts the plan, a LEASHED agent works\n# it (bounded turns, bounded spend, a default-deny tool grant), and an assert\n# refuses to pass an empty result downstream.\n#\n# Demonstrates ·\n#   - `agent:` under the full leash · `max_turns` + `max_tokens_total` + an\n#     explicit `tools:` list · nothing outside it is reachable\n#   - `schema:` on the agent · the engine owns the final shape and RE-ASKS\n#     until it conforms — you never hand-instruct it (see `execute`)\n#   - `nika:assert` as a trust boundary · an empty run must not read as a\n#     successful one\n#\n# Needs · nothing. Scaffolds and runs green in an empty directory.\n#\n# Run · nika run <file> --model mock/echo                    # offline rehearsal\n#     · nika run <file> --var goal=\"triage the open bugs\"    # a real seat\nnika: agent-loop-template       # SLOT: kebab-case workflow id\n\n# SLOT: a TOOL-CALLING model · local by default. This seat is measured, not\n# assumed: `ollama/qwen2.5:14b` completes the planning call in ~65s under the\n# ceiling below. A reasoning seat (`ollama/qwen3.5:4b`) spends its whole\n# budget thinking and never emits JSON here — the guaranteed offline path is\n# `--model mock/echo`, which needs no seat at all.\nmodel: ollama/qwen2.5:14b\n\ninputs:\n  goal:\n    type: string\n    required: true\n    # A `required:` input still carries a `default:` here so the skeleton\n    # RUNS the moment it is scaffolded. Replace the default with your job;\n    # `--var goal=…` overrides it at any time.\n    default: \"list the risks of running an agent without a turn budget\"   # SLOT\n    description: \"What the agent must accomplish\"   # SLOT\n\npermits:                            # the blast radius · default-deny once present\n  # Exactly what the body invokes: `nika:assert` (the confirm task) plus the\n  # two the agent may call. No `fs:` — see the note on `tools:` below.\n  tools: [\"nika:assert\", \"nika:done\", \"nika:jq\"]\n\ntasks:\n  plan:\n    infer:\n      prompt: \"Break '${{ inputs.goal }}' into at most 4 concrete steps.\"   # SLOT\n      # SLOT: the spend ceiling for the planning call. Size it for the SEAT,\n      # not for the answer: a reasoning model spends tokens thinking before\n      # it emits the first brace, and a ceiling that cuts it off mid-thought\n      # fails NIKA-INFER-002 (\"no JSON value found · the reply was cut off at\n      # the token limit\"). Measured — 400 starves qwen3.5:4b on this prompt.\n      max_tokens: 2000\n      schema:\n        type: object\n        additionalProperties: false\n        required: [steps]\n        properties:\n          steps: { type: array, items: { type: string } }\n\n  execute:\n    with:\n      steps: ${{ tasks.plan.output.steps }}\n    agent:\n      # Say what the JOB is. Do NOT describe the output shape.\n      #\n      # The engine binds `schema:` to the FINAL answer: a free-text answer\n      # that does not conform is RE-ASKED with the schema wired, bounded by a\n      # retry budget. Measured on ollama/qwen3.5:4b — this task, with zero\n      # shape instruction, returns a clean typed object.\n      #\n      # Hand-instructing \"reply with the object, then call nika:done\" is\n      # actively harmful: a `nika:done` carrying `result:` is validated\n      # DIRECTLY and is NEVER re-asked (nika-verb-agent/src/lib.rs · \"a miss\n      # is a verdict, never a re-ask\"), so a model that hands its JSON back\n      # as a *string* dies NIKA-INFER-002 with the budget already spent.\n      # Measured twice on that exact instruction. Let the engine own shape.\n      system: \"You are a careful analyst. Work the plan step by step and report what you actually found.\"   # SLOT\n      prompt: \"Plan · ${{ with.steps }}\"\n      tools:                        # SLOT: the MINIMUM grant for the job\n        # Pure compute — this pair needs no filesystem, so the skeleton runs\n        # anywhere. Granting the agent a tool here is only HALF a grant: every\n        # call still crosses the workflow boundary above. Adding `nika:read`\n        # to this list WITHOUT adding `permits.fs.read` fails at run with\n        # `NIKA-SEC-004 · agent tool \"nika:read\" refused by the security\n        # boundary` — measured, mid-loop, after the turns are paid for.\n        - \"nika:jq\"\n        - \"nika:done\"               # the early-exit sentinel · loop-owned\n      max_turns: 15                 # SLOT: the loop bound\n      max_tokens_total: 80000       # SLOT: the spend bound\n      schema:                       # SLOT: the typed final-message contract\n        type: object\n        additionalProperties: false\n        required: [findings]\n        properties:\n          findings: { type: array, items: { type: string } }\n\n  confirm:\n    with:\n      has_findings: ${{ size(tasks.execute.output.findings) > 0 }}   # the check crosses as ONE boundary expression\n    invoke:\n      tool: \"nika:assert\"\n      args:\n        condition: \"${{ with.has_findings }}\"\n        message: \"Agent returned no findings, do not trust an empty run\"   # SLOT\n\noutputs:\n  findings:\n    value: ${{ tasks.execute.output.findings }}\n    description: \"The agent's typed findings\"   # SLOT\n"
    },
    {
      "name": "human-gated-ship",
      "file": "human-gated-ship.nika.yaml",
      "intent": "anything irreversible (deploy · send · publish)",
      "patterns": [
        "parallel gates",
        "assert",
        "`nika:prompt` GO",
        "`after: {…: terminal}` record"
      ],
      "slots": 9,
      "sha256": "e7f233d8d6a016003fef360b80b9ffb4cc21cce19c733f465103b38adb3b6821",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · human-gated-ship · gates in parallel → a human signs → ship · evidence always lands.\n#\n#   [check_a]─┐                             ┌──▶[act]──▶[record]\n#   [check_b]─┴─▶[gates]──▶[human ⏸]──yes──┘    exec     nika:notify (always)\n#   exec ×2       assert     nika:prompt\n#\n# Anything you cannot take back — a deploy, a send, a publish. The machine\n# proves the preconditions in parallel, an assert refuses to even ASK when a\n# gate is red, the human decides once with the full picture, and the evidence\n# lands whether the answer was yes, no, or the ship blew up.\n#\n# Demonstrates ·\n#   - `nika:assert` before the question · never ask a human to rubber-stamp a\n#     red board\n#   - `after: <task>: success` · a STATE edge (ordering, no data) vs `with:` (a\n#     value edge) — two different kinds of arrow\n#   - `after: <task>: terminal` · the always-runs pattern · evidence on success,\n#     failure OR refusal\n#   - fail-closed defaults · unattended, the answer is NO\n#\n# Needs · nothing to rehearse. To ship FOR REAL: TEAM_WEBHOOK_URL in the\n#   environment, real commands in the three `exec:` slots — then delete the\n#   `on_error:` on `record` so a lost audit trail is loud.\n#\n# Run · nika run <file>                    # unattended · gates run, the answer defaults to NO\n#       Headless, `default: false` answers for you — delete that line and the\n#       run PAUSES instead (exit 4 · durable, not a failure) and prints its\n#       own resume line ·\n#       nika run <file> --resume <trace> --answer human=true · or false\nnika: human-gated-ship-template       # SLOT: kebab-case workflow id\n\npermits:                            # SLOT: the blast radius · default-deny once present\n  exec: [\"echo\"]                    # SLOT: ONLY the programs the gates + act run (argv form)\n  tools: [\"nika:assert\", \"nika:prompt\", \"nika:notify\"]\n  # `host_from_self` below sanctions the FLOW (the secret may BE the URL) — it\n  # does not grant the capability to reach anyone. The host stays unknown at\n  # check, so it is judged at RUN against this list: name the webhook host here\n  # or the record is refused mid-run, after the ship already happened.\n  net: { http: [\"hooks.slack.com\"] }   # SLOT: the webhook host · nothing else may leave\n\nsecrets:\n  webhook:\n    source: env\n    key: TEAM_WEBHOOK_URL           # SLOT: where the record lands\n    egress:\n      - to: \"nika:notify\"\n        host_from_self: true        # the secret value IS the destination URL\n\ntasks:\n  # ── the verification wave · all checks run in parallel ──\n  check_a:\n    exec:\n      command: [\"echo\", \"ok\"]        # SLOT: gate 1 (argv form · injection-safe)\n      capture: structured\n    on_error:\n      # Golden rehearsal · `nika test` refuses exec (NIKA-SEC-001 · the mock\n      # plane simulates the model, not effects). Catch-all ON PURPOSE — never\n      # `on_codes: [NIKA-SEC-001]`: that code is also a real blocklist stop,\n      # and a narrow forgiveness would swallow a security refusal. The shape\n      # matches `capture: structured` so the gates expression reads it.\n      # Delete once the gate runs a real check.\n      recover: { exit_code: 0, stdout: \"REHEARSAL\", stderr: \"\" }\n\n  check_b:\n    exec:\n      command: [\"echo\", \"ok\"]        # SLOT: gate 2\n      capture: structured\n    on_error:\n      # Same rehearsal armor as check_a · delete once real.\n      recover: { exit_code: 0, stdout: \"REHEARSAL\", stderr: \"\" }\n\n  gates:\n    with:\n      all_green: ${{ tasks.check_a.output.exit_code == 0 && tasks.check_b.output.exit_code == 0 }}   # two value edges · one boundary expression\n    invoke:\n      tool: \"nika:assert\"\n      args:\n        condition: \"${{ with.all_green }}\"\n        message: \"A gate is RED: refusing to proceed\"   # SLOT\n\n  human:\n    after:\n      gates: success              # state, no data · no question until the board is green\n    invoke:\n      # On a terminal this ASKS. Headless, it answers with `default:` below —\n      # the run completes, it does not pause (measured · act settles skipped).\n      # The durable pause belongs to a prompt with NO default: headless it\n      # PAUSES (exit 4 · not a failure) and prints its own resume line:\n      #   nika run <file> --resume <trace> --answer human=true · or false\n      tool: \"nika:prompt\"\n      args:\n        message: \"All gates GREEN. Proceed?\"   # SLOT: the decision, fully informed\n        # Fail CLOSED. The unattended answer to \"should I do the irreversible\n        # thing\" is no — `default: true` would hand CI a rubber stamp.\n        default: false\n\n  act:\n    with:\n      go: ${{ tasks.human.output }}   # the answer crosses as a value edge\n    when: ${{ with.go == true }}\n    exec:\n      command: [\"echo\", \"shipped\"]   # SLOT: the irreversible action (argv · program must be in permits.exec)\n      # default capture · a failing ship fails LOUDLY (NIKA-EXEC-001):\n      # never `capture: structured` on the irreversible step (exit codes\n      # would become data and a red ship would read as success)\n\n  record:\n    after:\n      act: terminal                 # the always-pattern · runs on success, failure, OR refusal\n    with:\n      acted: ${{ tasks.act.status }}   # observe the outcome · same pass-set as the after edge\n    invoke:\n      tool: \"nika:notify\"\n      args:\n        channel: webhook\n        target: \"${{ secrets.webhook }}\"\n        message: \"Run finished · act=${{ with.acted }}\"   # SLOT · success | failure | skipped\n        severity: info\n    on_error:\n      # Rehearsal only. With TEAM_WEBHOOK_URL unset the reference cannot resolve\n      # and this task fails NIKA-VAR-001 (measured) — which would make the\n      # skeleton red on a machine that has no webhook. Recovering keeps the\n      # scaffold runnable; DELETE this block for real use, because a ship whose\n      # audit trail silently vanished is exactly what this task exists to prevent.\n      recover: \"REHEARSAL · no webhook configured · nothing was sent\"\n\noutputs:\n  acted: ${{ tasks.act.status }}\n"
    },
    {
      "name": "website-brief",
      "file": "website-brief.nika.yaml",
      "intent": "understand a site (domain · theme · assets) from a URL",
      "patterns": [
        "fetch `traverse:` crawl",
        "one typed infer",
        "explicit persist",
        "zero exec"
      ],
      "slots": 10,
      "sha256": "bb0fe02ead87623166cfdd57647346344959ccc622447a7651a2209c52f19d8a",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · website-brief · crawl a site → understand it → persist the brief.\n#\n#   [crawl_site]────▶[brief]────▶[persist]\n#   nika:fetch        infer       nika:write\n#\n# \"Here is a URL, tell me what this company actually does.\" One bounded crawl,\n# one typed model pass, one JSON file downstream tools can read. The output is\n# a SHAPE, not prose — which is what makes it composable.\n#\n# Demonstrates ·\n#   - `traverse:` · a bounded same-origin crawl from a single URL · one fetch\n#     task, N pages, no loop to write\n#   - `schema:` on `infer` · the brief is typed, so the next workflow can bind\n#     `.colors` without parsing anything\n#   - a JSON artifact written as a VALUE · `content:` takes one `${{ … }}`, never\n#     hand-typed braces\n#\n# Needs · nothing to rehearse. For a REAL brief: network access and a live site\n#   in `const.site_url` — then delete the `on_error:` block so a dead host is loud.\n#\n# Run · nika run <file> --model mock/echo    # rehearsal · green even with no network\n#   (on a networked machine the crawl really fetches const.site_url — it is permitted)\nnika: website-brief-template       # SLOT: kebab-case workflow id\n\n# SLOT: provider/model · local · zero key. The schema below has five required\n# fields, so give it a seat that holds structure — `ollama/qwen2.5:14b` is\n# measured on this shape. `--model mock/echo` needs no seat at all.\nmodel: ollama/qwen2.5:14b\n\nconst:\n  site_url: \"https://example.com\"   # SLOT: the site to understand\n  out_path: \"./out/brief.json\"      # SLOT: where the brief lands\n\npermits:                            # the blast radius · default-deny once present\n  tools: [\"nika:fetch\", \"nika:write\"]\n  net: { http: [\"example.com\"] }    # SLOT: the host from const.site_url. ONE entry is\n                                    #   enough — `traverse:` is a same-origin BFS (+ the\n                                    #   robots probe), so the crawl never leaves this host.\n  fs: { write: [\"./out/brief.json\"] }   # SLOT: the one file · keep in step with const.out_path\n                                        #   (the infer step needs no grant — it is pure compute)\n\ntasks:\n  crawl_site:\n    invoke:\n      tool: \"nika:fetch\"\n      args:\n        url: \"${{ const.site_url }}\"\n        traverse: { max_pages: 5 }  # SLOT: crawl bound · 1..=25 (robots honored)\n    on_error:\n      # Offline rehearsal · a literal standing in for the crawl digest, so the\n      # brief step runs with no network at all. Delete once the site is real.\n      recover: \"REHEARSAL DIGEST · Example Co · a small tool company · plain blue and white pages · buttons, a pricing table, one logo.\"\n\n  brief:\n    with:\n      crawl_site: ${{ tasks.crawl_site.output }}\n    infer:\n      max_tokens: 1200\n      # SLOT: the one model job · what should the brief capture? Keep slot\n      # markers out of the block below — it is prompt TEXT, not YAML, so a\n      # stray comment line is sent to the model verbatim.\n      prompt: |\n        From this site crawl, produce a creative brief: the domain of\n        activity, the dominant visual theme, the audience, the usable\n        colors and image assets.\n        Crawl digest · ${{ with.crawl_site }}\n      schema:                       # SLOT: the typed shape downstream tasks rely on\n        type: object\n        additionalProperties: false\n        properties:\n          domain: { type: string }\n          theme: { type: string }\n          audience: { type: string }\n          colors: { type: array, items: { type: string } }\n          assets: { type: array, items: { type: string } }\n        required: [domain, theme, audience, colors, assets]\n\n  persist:\n    with:\n      brief: ${{ tasks.brief.output }}\n    invoke:\n      tool: \"nika:write\"\n      args:\n        path: \"${{ const.out_path }}\"\n        create_dirs: true\n        # The path ends `.json`, so `content:` is ONE interpolation of a value\n        # the engine serializes. Typing `{ \"domain\": ${{ … }} }` by hand emits\n        # unquoted fields and the artifact stops being JSON.\n        content: \"${{ with.brief }}\"\n    on_error:\n      # Golden rehearsal · `nika test` refuses effects (NIKA-452 · the mock\n      # plane simulates the model, not effects) — this recover lets the pin\n      # lane walk the scaffold. Never fires on a successful real write.\n      # Delete once wired, so a genuine write failure is loud.\n      recover: \"REHEARSAL · write refused under nika test — the real run persists the brief\"\n\noutputs:\n  brief: ${{ tasks.brief.output }}   # SLOT: the callable contract\n"
    },
    {
      "name": "media-asset-pack",
      "file": "media-asset-pack.nika.yaml",
      "intent": "generate image/audio assets from a brief",
      "patterns": [
        "`nika:image_generate`",
        "`nika:jq` manifest",
        "local/mock provider first"
      ],
      "slots": 10,
      "sha256": "565a0b8080a077435e47474798e4e33fe31ebeea39044455170c765435466a84",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · media-asset-pack · brief → generate assets → manifest.\n#\n#   [brief]──▶[render]────────────▶[manifest]──▶[persist]\n#   infer      nika:image_generate  nika:jq       nika:write\n#\n# Turning a sentence into shippable art plus the paperwork that makes it\n# usable: a model writes the image prompt, the generator renders it, and a\n# manifest records WHAT was made from WHICH brief — so the pack can be\n# regenerated, audited, or handed to someone else.\n#\n# Demonstrates ·\n#   - `nika:image_generate` with `provider: mock` · byte-real assets, zero key\n#   - the directory-AND-children permit · the one shape that passes `check` and\n#     survives the run (see `permits.fs.write`)\n#   - a JSON artifact built as a VALUE by `nika:jq`, never typed by hand\n#\n# Needs · nothing. Scaffolds and runs green in an empty directory.\n#\n# Run · nika run <file> --model mock/echo    # offline rehearsal · assets land in ./out/assets\nnika: media-asset-pack-template       # SLOT: kebab-case workflow id\n\n# SLOT: provider/model · local · zero key. Measured seat — `ollama/qwen2.5:14b`\n# holds the small schema below. `--model mock/echo` needs no seat at all.\nmodel: ollama/qwen2.5:14b\n\nconst:\n  subject: \"a calm cosmic landing hero\"   # SLOT: what the asset is about\n  out_dir: \"./out/assets\"           # SLOT: where assets land\n\npermits:                            # the blast radius · default-deny once present\n  tools: [\"nika:image_generate\", \"nika:jq\", \"nika:write\"]\n  fs:\n    write:\n      # Two entries, and both earn their place: `check` judges the `output_dir:`\n      # ARGUMENT (`./out/assets`), while the RUN gates every FINAL file path under\n      # it — the asset, its provenance manifest, and manifest.json. Grant only the\n      # directory and the file sails through check, then dies at run on the first\n      # asset. `*` is one segment and never crosses `/`, which is all this needs:\n      # image_generate lands its files flat, so no subtree grant is warranted.\n      - \"./out/assets\"              # SLOT: keep in step with const.out_dir\n      - \"./out/assets/*\"            # SLOT: idem · the files that land inside it\n\ntasks:\n  brief:\n    infer:\n      max_tokens: 600\n      # SLOT: the creative direction · style · constraints. Keep slot markers\n      # out of the block below — it is prompt TEXT, not YAML, so a stray\n      # comment line is sent to the model verbatim.\n      prompt: |\n        Write one vivid, concrete image prompt for: ${{ const.subject }}.\n        No text in the image · no watermark · a calm central zone.\n      schema:\n        type: object\n        additionalProperties: false\n        properties:\n          image_prompt: { type: string }\n        required: [image_prompt]\n\n  render:\n    with:\n      brief_image_prompt: ${{ tasks.brief.output.image_prompt }}\n    invoke:\n      tool: \"nika:image_generate\"\n      args:\n        provider: mock              # SLOT: local | openai | gemini | xai (local/mock first)\n        prompt: \"${{ with.brief_image_prompt }}\"\n        output_dir: \"${{ const.out_dir }}\"\n        filename_prefix: \"asset\"    # SLOT: filename stem\n    on_error:\n      # Golden rehearsal · the image tool is an EFFECT, refused under\n      # `nika test` even on `provider: mock` (the plane simulates the\n      # model, not effects). The literal keeps the manifest jq's shape\n      # (`.images`) intact. Delete once a real seat renders.\n      recover: { images: [] }\n\n  manifest:\n    with:\n      brief: ${{ tasks.brief.output }}\n      render: ${{ tasks.render.output }}\n    invoke:\n      tool: \"nika:jq\"\n      args:\n        expression: \"{ brief: .[0], images: .[1].images }\"\n        input:\n          - \"${{ with.brief }}\"\n          - \"${{ with.render }}\"\n\n  persist:\n    with:\n      manifest: ${{ tasks.manifest.output }}\n    invoke:\n      tool: \"nika:write\"\n      args:\n        path: \"${{ const.out_dir }}/manifest.json\"\n        create_dirs: true\n        # `.json` path → `content:` is ONE interpolation of a value the engine\n        # serializes. `nika:jq` above BUILT that value; hand-typing braces\n        # around an interpolation emits unquoted fields and breaks the artifact.\n        content: \"${{ with.manifest }}\"\n    on_error:\n      # Golden rehearsal · same NIKA-452 refusal as `render` above. Never\n      # fires on a successful real write. Delete once wired.\n      recover: \"REHEARSAL · write refused under nika test — the real run lands the manifest\"\n\noutputs:\n  manifest: ${{ tasks.manifest.output }}  # SLOT: the callable contract\n"
    },
    {
      "name": "api-upload-and-create",
      "file": "api-upload-and-create.nika.yaml",
      "intent": "call a product API: upload a file and create from it",
      "patterns": [
        "fetch `multipart:` (file + text parts)",
        "masked secrets header",
        "mode/jq extraction"
      ],
      "slots": 11,
      "sha256": "e823c5f3d2b8935c31eb69a87ec4367b11cef285a0198c164a213a6801e12f54",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · api-upload-and-create · upload a file and create the resource in one authenticated call.\n#\n#   [create]\n#   nika:fetch · multipart\n#\n# The product-API job: push the bytes AND the metadata that describe them, get\n# the created resource back, keep only the fields you need. One round trip —\n# `multipart:` carries a file part and text parts together, which is what most\n# create-with-upload endpoints actually want.\n#\n# Demonstrates ·\n#   - `multipart:` · a file part (`path:`) and text parts (`value:`) in one\n#     request · exactly one of the two per part\n#   - a masked `secrets:` entry in a header, with `egress:` naming the two\n#     places it may travel (the call, and the returned value)\n#   - `mode: jq` response narrowing · the workflow carries fields, not payloads\n#   - `on_error: recover:` · rehearses green with no key and no endpoint, and\n#     the same bindings work once it is wired\n#\n# WHY ONE CALL, not upload-then-create · a file part is `permits.fs.read`-gated,\n#   and a private read + untrusted ingress + an onward authenticated POST is the\n#   lethal trifecta (NEP-0002): chaining a second call onto the first response\n#   fails NIKA-SEC-009 and demands a human gate. Measured both orderings. One\n#   round trip needs no ceremony — reach for `human-gated-ship` when the job\n#   genuinely wants a person in the loop.\n#\n# Needs · nothing to rehearse. To run it FOR REAL: a reachable API, the file at\n#   `const.asset_path`, and EXAMPLE_API_KEY in the environment — then delete the\n#   `on_error:` block so a genuine failure is loud.\n#\n# Run · nika run <file>          # offline rehearsal · no key, no endpoint\nnika: api-upload-and-create-template       # SLOT: kebab-case workflow id\n\nconst:\n  api_base: \"https://api.example.com\"     # SLOT: the product API base\n  asset_path: \"./out/assets/asset-1.png\"  # SLOT: the file to upload\n\nsecrets:\n  API_KEY:\n    source: env\n    key: EXAMPLE_API_KEY            # SLOT: the OS env var holding the key\n    egress:\n      - to: \"nika:fetch\"            # the send · default-deny otherwise\n      - to: \"outputs\"               # the return value derives from the authed response\n\npermits:                            # the blast radius · default-deny once present\n  tools: [\"nika:fetch\"]\n  # `egress:` above sanctions the FLOW (this secret may ride a fetch); it does\n  # NOT grant the capability to reach anyone. The host is the separate, required\n  # half — an unlisted host is refused at RUN, mid-flight, with the bytes\n  # already on the wire.\n  net: { http: [\"api.example.com\"] }   # SLOT: the host from const.api_base\n  fs:\n    # A `multipart:` file part names a path, and that read crosses the boundary\n    # like any other: measured, without this entry the call dies `NIKA-SEC-004 ·\n    # ./out/assets/asset-1.png resolves outside the declared permits.fs.read\n    # boundary`. One exact file, never the tree it sits in. The drift detector\n    # models a multipart part as a read (2026-07-29) — the former NIKA-DRIFT-001\n    # false hint is closed.\n    read: [\"./out/assets/asset-1.png\"]   # SLOT: keep in step with const.asset_path\n\ntasks:\n  create:\n    invoke:\n      tool: \"nika:fetch\"\n      args:\n        url: \"${{ const.api_base }}/items\"   # SLOT: the create endpoint\n        method: POST\n        headers:\n          x-api-key: \"${{ secrets.API_KEY }}\"  # SLOT: the auth header name\n        multipart:\n          # Exactly one of `path:` (file) or `value:` (text) per part — a part\n          # carrying both, or neither, is refused before anything is sent.\n          - { name: file, path: \"${{ const.asset_path }}\" }\n          - { name: title, value: \"Rehearsal item\" }   # SLOT: the metadata fields\n        mode: jq\n        jq: \"{ id: .id, url: .url }\"   # SLOT: the fields downstream needs\n    on_error:\n      # Offline rehearsal · a literal shaped EXACTLY like what the jq above\n      # projects, so `outputs.result` has the same shape on both paths. It also\n      # absorbs the unset key: a `secrets:` entry whose env var is missing fails\n      # NIKA-VAR-001, and a recover catches that too (measured).\n      recover: { id: \"rehearsal-0001\", url: \"https://app.example.com/items/rehearsal-0001\" }\n\noutputs:\n  result: ${{ tasks.create.output }}   # SLOT: the callable contract\n"
    },
    {
      "name": "docker-report",
      "file": "docker-report.nika.yaml",
      "intent": "read a system's state (docker · kubectl · gh), explain it, keep the report",
      "patterns": [
        "argv-array exec (provable allowlist)",
        "parallel reads",
        "one artifact"
      ],
      "slots": 9,
      "sha256": "d90e7384122ba0f78e1b8a97d2c0cc8d506d8d151c5d468eedea580f1d8b6d12",
      "yaml": "# SPDX-License-Identifier: Apache-2.0\n# yaml-language-server: $schema=https://nika.sh/spec/v1/workflow.schema.json\n#\n# TEMPLATE · docker-report · read real state via a pinned CLI → explain it → keep the report.\n#\n#   [ps]────┐\n#   [df]────┴──▶[diagnose]────▶[keep]\n#   exec ×2      infer          nika:write\n#\n# The \"ask the machine what is actually true, then explain it\" job. Two reads\n# run in parallel because nothing connects them, one model pass turns raw CLI\n# output into a report a human can act on, and the report is kept on disk. Swap\n# `docker` for `kubectl`, `gh`, `systemctl` — the shape does not change.\n#\n# Demonstrates ·\n#   - argv-ARRAY `exec:` · one program, exactly these arguments, no shell to\n#     inject into · and `permits.exec` names the single program allowed\n#   - implicit parallelism · `ps` and `df` share no edge, so the scheduler runs\n#     them together · nobody writes \"in parallel\" anywhere\n#   - `on_error: recover:` on an exec · the skeleton rehearses green on a host\n#     with no Docker at all\n#\n# Needs · nothing to rehearse. For a REAL report: a running Docker daemon —\n#   then delete the two `on_error:` blocks so a broken daemon is loud.\n#\n# Run · nika run <file> --model mock/echo    # offline rehearsal · no daemon\nnika: docker-report-template       # SLOT: kebab-case workflow id\n\n# SLOT: local-first · `--model mock/echo` for the offline rehearsal. Measured\n# seat — `ollama/llama3.2:3b` answers this prose prompt in well under a minute.\nmodel: ollama/llama3.2:3b\n\npermits:                            # the blast radius · default-deny once present\n  exec:\n    - \"docker\"                      # SLOT: the ONE program the reads may launch\n  tools:\n    - \"nika:write\"\n  fs:\n    write:\n      - \"./docker-health.md\"        # SLOT: where the report lands (must match `keep`)\n\ntasks:\n  # The reads run IN PARALLEL (no edges between them) — the\n  # scheduler proves it from the DAG, nobody orders it.\n  ps:\n    exec:\n      # SLOT: argv ARRAY form — one program, exactly these arguments. The array\n      # is why there is no shell here: no word-splitting, no globbing, nothing\n      # to quote wrong. `permits.exec` above is the provable allowlist.\n      command: [\"docker\", \"ps\", \"--all\", \"--format\", \"{{.Names}}\\t{{.Status}}\\t{{.Image}}\"]\n    on_error:\n      # Offline rehearsal · a host with no daemon answers with a literal that\n      # LOOKS like the real thing, so `diagnose` reads the same shape either\n      # way. Delete this once the daemon is really there.\n      recover: \"REHEARSAL\\tno docker daemon on this host\\tn/a\"\n\n  df:\n    exec:\n      command: [\"docker\", \"system\", \"df\"]   # SLOT: the second read (drop the task if one suffices)\n    on_error:\n      recover: \"REHEARSAL · disk usage unavailable without a daemon\"\n\n  diagnose:\n    with:\n      ps: ${{ tasks.ps.output }}\n      df: ${{ tasks.df.output }}\n    infer:\n      max_tokens: 600               # SLOT: the spend ceiling for this call\n      # SLOT: what should the model DO with the readings? Keep slot markers out\n      # of the block below — everything indented under `prompt: |` is prompt\n      # TEXT, and a stray comment line is sent to the model verbatim.\n      prompt: |\n        You are reading a Docker host's state. Containers (name·status·image):\n        ${{ with.ps }}\n\n        Disk usage:\n        ${{ with.df }}\n\n        Write a short health report: what is running, what exited, what\n        looks unhealthy (restart loops · old exits), and whether disk\n        usage needs attention. Plain prose, no preamble.\n\n  keep:\n    with:\n      diagnose: ${{ tasks.diagnose.output }}\n    invoke:\n      tool: \"nika:write\"\n      args:\n        path: \"./docker-health.md\"  # SLOT: same path as permits.fs.write\n        content: \"${{ with.diagnose }}\"\n    on_error:\n      # Golden rehearsal · `nika test` refuses effects (NIKA-452) — the pin\n      # lane recovers into this marker (outputs.report pins it · honest and\n      # deterministic). Never fires on a successful real write. Delete once\n      # the report really lands, so a genuine write failure is loud.\n      recover: \"REHEARSAL · write refused under nika test — the real run lands ./docker-health.md\"\n\noutputs:\n  report:\n    value: ${{ tasks.keep.output }}\n    description: \"Where the report landed — nika:write hands back the path it wrote\"\n"
    }
  ]
}
