showcase · tier 3 · builders
PR review swarm
One read-only review agent per changed file, in parallel and under budget. jq merges the findings.
- 7tasksthe plan falls out of the bindings
- 6wavesparallel by construction
- 4verbs exercisedexec · invoke · agent · infer
- T3the tierbuilders
The whole file
7 tasks · 6 wavesThe plan falls out of the bindings, nothing is scheduled by hand.
pr-review-fanout.nika.yamlsource
nika: v1workflow: id: pr-review-fanout description: "changed files → one read-only review agent each → merged REVIEW.md"model: 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: tools: ["nika:done", "nika:grep", "nika:jq", "nika:read", "nika:write"] # 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"] 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: # `--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: exec: command: ["git", "diff", "--name-only", "--diff-filter=d", "${{ const.base_ref }}...HEAD"] files: with: changed: ${{ tasks.changed.output }} 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. todo_sweep: invoke: tool: "nika:grep" args: pattern: "TODO|FIXME|HACK" path: "./src" reviews: with: files: ${{ tasks.files.output }} for_each: ${{ 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 }} 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 }} 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.106.1 · vendored graph, never re-derived
6 declared edges · 6 waves · the plan falls out of the bindings