nika: v1
workflow:
id: resume-screener
description: "screen resumes ยท glob CVs โ local-model rubric per candidate โ deterministic hiring shortlist"
model: ollama/qwen3.5:4b # PII stays on the machine ยท the whole screen is offline
const:
role: "Senior Rust engineer"
shortlist_size: 5
# The committed rehearsal inbox. Point this at ./hiring/inbox (or wherever
# your exports land) and change `permits.fs.read` to match, same edit.
cv_glob: "./examples/fixtures/cvs/*.md"
permits:
tools: ["nika:glob", "nika:jq", "nika:read", "nika:write"]
# NO `net:` category. That absence is the whole sovereignty claim: with no
# host granted, a CV that talks the model into exfiltrating itself still
# has nowhere to go โ the boundary refuses before the request is made.
fs:
# TWO entries, and both earn their place. `nika:glob` opens the ROOT of
# its pattern before it lists anything, and a directory grant does not
# cover its children while a child grant does not cover the directory โ
# so each is named. Measured: with only the second line, the run dies
# `NIKA-SEC-004 ยท ./examples/โฆ/cvs resolves outside permits.fs.read`
# before a single CV is read.
#
# `*` is one segment and never crosses `/`, which is the right width
# here: CVs land flat in the inbox. `cvs/**` would also work and would
# additionally hand over every sub-tree someone drops in there.
read:
- "./examples/fixtures/cvs" # the directory the glob opens
- "./examples/fixtures/cvs/*" # the CVs one level inside it
# One brief, one path. Nothing else on this machine is writable by this
# workflow โ not `./hiring/**`, not a sibling of the brief.
write: ["./hiring/shortlist-brief.md"]
tasks:
# โโ the pool is discovered at runtime ยท the count is never written down โโ
pool:
invoke:
tool: "nika:glob"
args: { pattern: "${{ const.cv_glob }}" }
cvs:
with:
paths: ${{ tasks.pool.output }}
for_each: ${{ with.paths }}
max_parallel: 8
fail_fast: false # one unreadable CV must not stop the batch
on_error:
recover: null # null holds the index so the zip below stays aligned
invoke:
tool: "nika:read"
args: { path: "${{ item }}" }
# A for_each output is an array in ITEM ORDER, so `transpose` pairs each
# path with its own text. The `select` is where the unreadable CVs leave.
pairs:
with:
paths: ${{ tasks.pool.output }}
texts: ${{ tasks.cvs.output }}
invoke:
tool: "nika:jq"
args:
input: ["${{ with.paths }}", "${{ with.texts }}"]
expression: >-
transpose
| map(select(.[1] != null))
| map({ path: .[0], text: .[1] })
# โโ one rubric, applied identically to everyone โโโโโโโโโโโโโโโโโโโโ
# `with.cv_path` reads `${{ item.path }}` โ a pre-fan-out import that is
# re-evaluated per iteration, which is how a loop-local reaches the prompt
# under a name that means something.
screened:
with:
pairs: ${{ tasks.pairs.output }}
cv_path: ${{ item.path }}
for_each: ${{ with.pairs }}
max_parallel: 2 # a local model shares one GPU ยท don't thrash it
fail_fast: false
on_error:
recover: null
infer:
max_tokens: 1200 # a rubric row, not an essay
prompt: |
Role ยท ${{ const.role }}
Candidate file ยท ${{ with.cv_path }}
CV ยท
${{ item.text }}
Score this candidate against the role. Quote evidence from the CV
for every rating: no rating without a quote.
# `additionalProperties: false` on EVERY object node โ the top level and
# each nested one. Without it a provider is free to add keys, and the
# jq below would be sorting a shape that changes between runs.
schema:
type: object
additionalProperties: false
required: [file, fit, strengths, concerns]
properties:
file: { type: string }
fit: { type: string, enum: [strong, possible, weak] }
years_relevant: { type: integer }
strengths: { type: array, items: { type: string } }
concerns: { type: array, items: { type: string } }
# โโ the model judged ยท jq decides โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Ranking lives HERE, not in a prompt, so the order is reproducible: same
# scores in, same order out. `.fit != "strong"` sorts false (0) before true
# (1), which puts the strong candidates first; `// 0` covers the candidates
# whose CV never stated a number.
ranked:
with:
screened: ${{ tasks.screened.output }}
invoke:
tool: "nika:jq"
args:
input: "${{ with.screened }}"
expression: >-
map(select(. != null))
| map(select(.fit != "weak"))
| sort_by(.fit != "strong", -(.years_relevant // 0))
shortlist:
with:
ranked: ${{ tasks.ranked.output }}
invoke:
tool: "nika:jq"
args:
input: "${{ with.ranked }}"
expression: ".[:${{ const.shortlist_size }}]"
brief:
with:
shortlist: ${{ tasks.shortlist.output }}
when: ${{ size(with.shortlist) > 0 }} # nobody cleared the bar ยท spend nothing
infer:
max_tokens: 2000
prompt: |
Write the screening brief for these shortlisted candidates ยท
${{ with.shortlist }}
One paragraph each ยท lead with the evidence quotes ยท end with the
suggested interview focus.
save:
with:
brief: ${{ tasks.brief.output }}
when: ${{ with.brief != null && size(with.brief) > 0 }} # skipped (null) or an EMPTY answer ยท gate on the VALUE, both ways
invoke:
tool: "nika:write"
args:
path: "./hiring/shortlist-brief.md"
content: "${{ with.brief }}"
create_dirs: true
outputs:
ranked:
value: ${{ tasks.ranked.output }}
description: "Everyone who was not scored weak ยท strong first, then by relevant years"
shortlist:
value: ${{ tasks.shortlist.output }}
description: "The top `const.shortlist_size` of the ranking ยท what the brief covers"