nika: v1
workflow:
id: release-radar
description: "dependency release feed → diff vs last run → only the NEW ships"
model: ollama/qwen3.5:4b # local · zero key · swap for any provider in the catalog
const:
releases_feed: "https://github.com/tokio-rs/tokio/releases.atom"
state_path: "out/release-radar.json"
permits:
tools: ["nika:fetch", "nika:jq", "nika:json_diff", "nika:prompt", "nika:read", "nika:write"]
net:
http: ["github.com"] # the feed's host · net entries are exact names, never globs
fs:
# The state-file pattern touches exactly ONE path, on both sides: read
# last run's state, write the next. `const.state_path` names it and a run
# cannot move a const, so the same literal is the whole boundary —
# `create_dirs:` makes out/ underneath it on the first run.
read: ["out/release-radar.json"]
write: ["out/release-radar.json"]
tasks:
# The human gate first (NIKA-SEC-009 · NEP-0002, the Rule of Two as a
# static check). This boundary permits all three legs — private read
# (the state file) + untrusted ingress (the release feed) + egress
# (the state write) — so a blocking prompt (no default:) dominates the
# flow before a byte moves. Headless surfaces pause durably and resume
# with `--resume <trace> --answer approve=true`.
approve:
invoke:
tool: "nika:prompt"
args:
message: "This run fetches ${{ const.releases_feed }}, diffs against ${{ const.state_path }}, and rewrites that state file. Proceed?"
# First run has no state file · recover to an empty list.
no_state:
with:
go: ${{ tasks.approve.output }}
when: ${{ with.go == true }}
invoke:
tool: "nika:jq"
args: { input: [], expression: "." }
previous:
with:
go: ${{ tasks.approve.output }}
when: ${{ with.go == true }}
invoke:
tool: "nika:read"
args: { path: "${{ const.state_path }}" }
on_error:
on_codes: [NIKA-BUILTIN-READ-001] # not-found ONLY · a permission error still fails loudly
recover: ${{ tasks.no_state.output }}
feed:
with:
go: ${{ tasks.approve.output }}
when: ${{ with.go == true }}
invoke:
tool: "nika:fetch"
args:
url: "${{ const.releases_feed }}"
mode: feed
output:
entries: "[.items[] | {title, link}]"
on_error:
# No network → this sample stands in for the RAW feed response: it
# carries `items:` under the keys `mode: feed` actually returns
# (probed live on this feed: title · link · id · author · content —
# there is no `published` field), so the `output:` jq above runs over
# it unchanged. Recovering the BINDING's shape instead makes the jq
# throw NIKA-VAR-004 — the recovery must mirror the response, never
# the binding.
recover:
items:
- { title: "tokio 1.48.0", link: "https://github.com/tokio-rs/tokio/releases/tag/tokio-1.48.0" }
- { title: "tokio 1.47.1", link: "https://github.com/tokio-rs/tokio/releases/tag/tokio-1.47.1" }
# `nika:read` hands back a STRING — a file is text until you parse it.
# Measured: diffing the unparsed read against the entries array emits ONE
# whole-document `replace` op at path "" on every run after the first,
# so everything is reported as new, forever. The first-run recovery is
# already an array; the guard keeps both shapes.
previous_parsed:
with:
previous: ${{ tasks.previous.output }}
invoke:
tool: "nika:jq"
args:
input: "${{ with.previous }}"
expression: 'if type == "string" then fromjson else . end'
fresh:
with:
go: ${{ tasks.approve.output }}
previous: ${{ tasks.previous_parsed.output }}
entries: ${{ tasks.feed.entries }}
when: ${{ with.go == true }}
invoke:
tool: "nika:json_diff"
args:
before: "${{ with.previous }}"
after: "${{ with.entries }}"
digest:
with:
go: ${{ tasks.approve.output }}
fresh: ${{ tasks.fresh.output }}
entries: ${{ tasks.feed.entries }}
when: ${{ with.go == true && size(with.fresh) > 0 }}
infer:
max_tokens: 500
prompt: |
New releases appeared on our dependency radar (RFC 6902 patch
against last run) ·
${{ with.fresh }}
Full current feed · ${{ with.entries }}
Write 3 bullets · what shipped · whether it looks breaking ·
what to check in our code.
save_state:
with:
go: ${{ tasks.approve.output }}
entries: ${{ tasks.feed.entries }}
when: ${{ with.go == true }}
invoke:
tool: "nika:write"
args:
path: "${{ const.state_path }}"
content: "${{ with.entries }}"
create_dirs: true
overwrite: true
outputs:
new_entries:
value: ${{ tasks.fresh.output }}
description: "RFC 6902 ops · empty = nothing new since last run"
# Untyped on purpose: `digest` is `when:`-gated, so on a quiet week it is
# SKIPPED and this reads null. Exporting it is also what keeps those tokens
# from being dead spend — an infer whose output nothing consumes is paid for
# and thrown away, and `check` says so.
briefing: ${{ tasks.digest.output }}