showcase · tier 3 · builders
PR review swarm
One read-only review agent per changed file, in parallel and under budget. jq merges the findings.
- 8tasksthe plan falls out of the bindings
- 7wavesparallel by construction
- 4verbs exercisedinvoke · exec · agent · infer
- T3the tierbuilders
The whole file
8 tasks · 7 wavesThe plan falls out of the bindings, nothing is scheduled by hand.
pr-review-fanout.nika.yamlsource
nika: pr-review-fanoutmodel: ollama/qwen3.5:4b # local tool-calling model · swap for anthropic/claude-sonnet-4-6 for depthconst: base_ref: "main" review_root: "src/" # the swarm's territory · keep in step with permits.fs.readpermits: # The argv form (`exec.command:`) runs one named program with its arguments # and no shell between them, which is what makes a program allowlist mean # something. A `exec.shell:` task is refused against a list — measured: # `NIKA-SEC-004 · a shell-string command cannot be verified against a # permits.exec program allowlist (a pipeline can launch any program) — use # the array form`. Only `exec: true` admits a shell, and this workflow # deliberately never needs one. # # An allowlisted program is not an unconfined one: `git` runs inside the # `fs:` boundary below, which is why the header's second `Needs ·` line is # about git's global config rather than about git. exec: ["git"] tools: ["nika:done", "nika:grep", "nika:jq", "nika:prompt", "nika:read", "nika:write"] fs: # `./src/**` covers the directory AND every descendant at any depth, which # is what both consumers need: `nika:grep` opens `./src` itself, and the # swarm reads files nested under it. A bare `./src` would cover only the # directory entry and refuse every file inside it. # # This bound is why `files` below filters the diff down to `const.review_root`: # `git diff` happily reports `Cargo.toml` or `.github/workflows/ci.yml`, and # an agent handed one of those would die `NIKA-SEC-004` mid-swarm. The claim # and the territory are kept equal on purpose. read: ["./src/**"] write: ["./REVIEW.md"]tasks: # The human gate (NEP-0002 · v2.2). `git diff` output is UNTRUSTED # content — a hostile branch names its own files — and this workflow # pairs it with a private `fs.read` and egress-capable tasks: the # lethal trifecta, unless a human dominates the path. The gate comes # FIRST so every task the diff output reaches descends from it (a gate # on a sibling branch dominates nothing — the data edge would bypass # it), and each root CONSUMES the answer affirmatively (NEP-0020 — a # bare `after:` admits the refusal: it settles success with value # false). Under `--answer approve=true` the CI one-pass answers it here. # The one [headless-prompt] hint is the price of a real gate — pay it here. approve: invoke: tool: "nika:prompt" args: mode: "confirm" message: "diff ${{ const.base_ref }}...HEAD and fan out one review agent per changed file?" # `--diff-filter=d` drops deletions: a file that no longer exists cannot be # reviewed, and asking an agent to read it just burns a turn on an error. changed: after: { approve: success } with: go: ${{ tasks.approve.output }} when: ${{ with.go == true }} # « no » at the gate → no diff, no swarm exec: command: ["git", "diff", "--name-only", "--diff-filter=d", "${{ const.base_ref }}...HEAD"] files: with: changed: ${{ tasks.changed.output }} when: ${{ with.changed != null && size(with.changed) > 0 }} # a refused gate skips the diff (null) · an empty diff has nothing to split invoke: tool: "nika:jq" args: input: "${{ with.changed }}" expression: >- split("\n") | map(select(length > 0)) | map(select(startswith("${{ const.review_root }}"))) # The deterministic pass. Debt does not need a model to be found, and this # runs whether or not anything changed. It too descends from the gate — # dominance is structural: a sibling branch that bypasses `approve` # would re-open the trifecta no matter how clean its own content is. todo_sweep: after: { approve: success } with: go: ${{ tasks.approve.output }} when: ${{ with.go == true }} # the same « no » closes this branch too invoke: tool: "nika:grep" args: pattern: "TODO|FIXME|HACK" path: "./src" reviews: with: files: ${{ tasks.files.output }} for_each: items: ${{ with.files }} max_parallel: 4 # four reviewers in flight · not forty fail_fast: false # one reviewer's bad day is not the swarm's on_error: recover: null # a budget-exhausted review yields null · filtered below agent: system: >- You are a precise code reviewer. Read the file, then finish with ONLY the schema'd object ({file, findings: [{severity, message, line}]} · severity exactly one of blocker|high|med|low), then call nika:done. prompt: "Review ${{ item }} · bugs first, then risky patterns. Read it before judging." tools: - "nika:read" # read-only swarm · least privilege - "nika:done" max_turns: 6 # read · think · answer · a little slack max_tokens_total: 30000 # per reviewer · the swarm's ceiling is this × the file count # `additionalProperties: false` on BOTH object nodes. Without it on the # inner one, a provider may add keys to a finding and the sort below is # ordering a shape that changed between runs. schema: type: object additionalProperties: false required: [file, findings] properties: file: { type: string } findings: type: array items: type: object additionalProperties: false required: [severity, message] properties: severity: { type: string, enum: [blocker, high, med, low] } message: { type: string } line: { type: integer } # The fan-in. # `. // []` · a clean diff SKIPS the swarm (an empty for_each resolves # null), and the fan-in has to survive its own good news. # `select(. != null)` · the reviewers that recovered leave here, by value. # `.findings[] + {file}` · flatten [[finding]] → [finding], each carrying # the file it came from. # the rank object · `sort_by(.severity)` would be ALPHABETICAL, which puts # `low` above `med`. Severity order is a decision, so it is # written down. merged: with: reviews: ${{ tasks.reviews.output }} invoke: tool: "nika:jq" args: input: "${{ with.reviews }}" expression: >- (. // []) | map(select(. != null)) | map(.findings[] + { file: .file }) | sort_by({ blocker: 0, high: 1, med: 2, low: 3 }[.severity]) summary: with: merged: ${{ tasks.merged.output }} todo_sweep: ${{ tasks.todo_sweep.output }} when: ${{ with.todo_sweep != null && size(with.todo_sweep) > 0 }} # skipped (null) or an empty sweep · no page to write infer: max_tokens: 2500 # a review page · not the whole diff back prompt: | Merge these review findings into REVIEW.md · blockers first · ${{ with.merged }} Open TODO debt found by grep · ${{ with.todo_sweep }} End with a verdict · ship / fix-first / redesign. save: with: summary: ${{ tasks.summary.output }} when: ${{ with.summary != null && size(with.summary) > 0 }} # skipped (null) or an EMPTY answer · never a 0-byte REVIEW.md invoke: tool: "nika:write" args: path: "./REVIEW.md" content: "${{ with.summary }}"outputs: findings: value: ${{ tasks.merged.output }} description: "Every finding, blockers first, each attributed to its file" review: value: ${{ tasks.summary.output }} description: "The page a human reads · also written to ./REVIEW.md"nika inspect · engine 0.111.0 · vendored graph, never re-derived
10 declared edges · 7 waves · the plan falls out of the bindings