AGPL-3.0-or-later · forever.

showcase · tier 3 · business

Resume screener

One local-model rubric per candidate, with enums and required evidence quotes. jq does the ranking.

Candidate #1 and #40 get the same rubric, and the PII never leaves the machine. · conformance-gated in nika-spec ↗ · re-proven at every push

The whole file

8 tasks · 8 waves · the plan falls out of the bindings, nothing is scheduled by hand.

t3-resume-screener.nika.yamlsource
nika: v1workflow:  id: resume-screener  description: "glob CVs → local-model rubric per candidate → deterministic shortlist"model: ollama/qwen3.5:4b   # PII stays on the machine · the whole screen is offlinepermits:                    # the file IS the blast radius · no net category at all:  fs:                       # CVs cannot leave this machine even if a prompt is hijacked    read: ["./hiring/inbox/**"]    write: ["./hiring/out/**", "./hiring/shortlist-brief.md"]  tools: ["nika:glob", "nika:read", "nika:jq", "nika:write"]vars:  role: "Senior Rust engineer"  cv_glob: "./hiring/inbox/*.md"  shortlist_size: 5tasks:  pool:    invoke:      tool: "nika:glob"      args: { pattern: "${{ vars.cv_glob }}" }  cvs:    with:      paths: ${{ tasks.pool.output }}    # the collection crosses the boundary here    for_each: ${{ with.paths }}    max_parallel: 8    fail_fast: false                   # one unreadable CV must not stop the batch    on_error:      recover: null                    # null keeps the zip aligned · filtered later    invoke:      tool: "nika:read"      args: { path: "${{ item }}" }  pairs:    with:      paths: ${{ tasks.pool.output }}      texts: ${{ tasks.cvs.output }}    invoke:                            # zip path + content · order survived the nulls      tool: "nika:jq"      args:        input: ["${{ with.paths }}", "${{ with.texts }}"]        expression: "transpose | map(select(.[1] != null)) | map({path: .[0], text: .[1]})"  screened:    with:      pairs: ${{ tasks.pairs.output }}   # pre-fan-out import · the collection      cv_path: ${{ item.path }}          # re-evaluated per iteration    for_each: ${{ with.pairs }}    max_parallel: 2                    # local model · don't thrash the GPU    fail_fast: false    on_error:      recover: null    infer:      prompt: |        Role · ${{ vars.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.      schema:        type: object        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 } }  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: ".[:${{ vars.shortlist_size }}]"  brief:    with:      shortlist: ${{ tasks.shortlist.output }}    when: ${{ size(with.shortlist) > 0 }}    infer:      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 }}    # skip cascades by VALUE · a skipped brief reads null    invoke:      tool: "nika:write"      args:        path: "./hiring/shortlist-brief.md"        content: "${{ with.brief }}"        create_dirs: trueoutputs:  shortlist:    value: ${{ tasks.shortlist.output }}    description: "Ranked candidates · strong first, then by relevant years"

nika inspect · engine 0.105.0 · vendored graph, never re-derived

8 declared edges · 8 waves · the plan falls out of the bindings