Bulgarian is welcome in issues and pull requests. The code and these guides are in English so that people who do not read Bulgarian can still review them.
Never add a rate from memory. Not a minimum wage, not a contribution percentage, not a threshold. This repository exists because a payroll report that confidently applies last year's figure is worse than no report: it reads as authoritative and it is wrong.
Every figure, in whichever topic file under
skills/trz-expert/references/stavki/ actually
carries the table — stavki.md itself is an index of statuses, per-section
verification dates and links, not the tables — carries a status:
| Status | Means |
|---|---|
ДВ |
verified against the statutory text, with the State Gazette issue cited |
официален |
from a state authority's site, not cross-checked against the Gazette |
вторичен |
from a professional accounting source; usable, verify if disputed |
за потвърждение |
working hypothesis. Cannot support a нарушение finding. |
| empty | missing. The skill asks the user instead of guessing. |
To add or change a rate:
- Find the primary source. State Gazette issue and date if you have it.
- Add or edit the row in its topic file under
stavki/with its status. A new topic needs a file of its own plus a linking bullet instavki.md's "Ставки по теми". - Add a line to the changelog table at the bottom of
stavki.md(the index), with the date and what you verified - the changelog stays centralised, not duplicated per topic file. - If a test needs the figure, update
test/trz_model.pyto match. The reference file is the source of truth; the model follows it, never the other way round. - Run
python test/rates_test.py. It cross-checks every figure the model carries against the reference file and fails on any drift.
If you are downgrading a rate's status — because a source turned out weaker than it looked — say so in the changelog. That is as valuable as adding one.
references/proverki.md is an index — every check's title, grouped A–K, and nothing
else. The full text of a check — its statement, basis, arithmetic, example — lives in
references/proverki/, one file per group
(a.md…k.md); group K's file also carries "Как се чете електронна таблица" above its
own bullets and "Формули" below them, in that order. Edit the group file, then keep the
index's one-line title in step if you changed the title itself. A check needs three
things:
- a statement of what passes and what does not;
- a statutory basis in
references/normativna-baza.md— except group K, which rests on arithmetic and must say so rather than invent an article; - a formula, if the check computes anything, in the formulas section.
A new check also moves two numbers written down elsewhere by hand: the coverage
denominator in test/scenarios.md ("N of the M checks", "describes all M") and the
check count on .github/social-preview.html. skill_test.py counts the bullets in
proverki.md and fails if either has drifted, so run it before opening the pull
request rather than finding out in CI.
If a check depends on a contested reading, write it as the skill is told to behave: enumerate the possible readings, say what follows from each in money, and have it ask. Do not pick one and present it as settled.
Suite 5 (test/generate_komplekt.py + test/komplekt_test.py) is the whole month:
ведомост, договори, обр. 1, обр. 6, платежен файл. To add a break:
- Write the mutation. If it changes a link, rebuild what follows it — обр. 6 from обр. 1, the payments from обр. 6 — so only the transition you broke disagrees.
- Return the index of the person it landed on, or
Nonewhen the finding is about the file. The paid eval uses that to say where to look. - Register it in
BREAKS, inORDER(before anything that edits обр. 6 or the payment file, if it rebuilds them) and inGROUPS, with the breaks it is mutually exclusive with. - Teach
reconcile()inkomplekt_test.pyto find it, and add an entry toBASISintest/findings.py. - Add a sentence to
CORRECT_REPORTintest/komplekt_test.pyand a pattern set toKOMPLEKT_KEYWORDSintest/eval_scenarios.py. The suite grades that sentence against those patterns, so a pattern that matches nothing fails here instead of turning a paid seed into a miss. - Prove it: break the detection on purpose, watch the suite go red, revert.
The structural suite injects a defect and requires it to be found exactly once. To add one:
- Write a mutation in
test/generate_wide.py: take a clean row, break one thing, recompute the dependent cells the way a wrong file would. Return the row and the set of expected finding ids. - Register it in
SCENARIOSintest/trz_model.pyand inROW_MUTATIONS. - Implement the detection in
test/structural_test.py, and give the finding id a basis inBASIS(test/findings.py): a citation quoted verbatim from the reference files for groups A–J,arithmeticfor K, I and the F10 consistency checks.Findings.addrefuses an id without one, and the citation must already stand inreferences/— add it there first, with a source and a status. - Document it in
test/scenarios.md. - Add keyword patterns to
KEYWORDSand a sample sentence toSAMPLE_TEXTintest/eval_skill.py— not optional:--selftest(which CI runs) fails for a scenario without them, and a paid run is refused. Then bring the counts along: the scenario figure on.github/social-preview.html(re-render the PNG, see the comment at its top) and the "twenty-eight scenarios" sentence in both READMEs;skill_test.pyasserts the card againsttrz_model.SCENARIOS. - Run
python test/run_tests.py --seeds 300. A run passes only when every injected defect is found and no finding is raised beyond them. False positives fail the suite like misses.
Prove the check has teeth: break something on purpose and confirm the suite goes red. A check that has never failed has not been tested.
A finding this skill missed against a real audit, a real payroll, or a paid eval
transcript is not closed by fixing the guidance or the check alone. It stays open
until the shape that produced it is a scenario one of these suites injects and
requires found — permanently, so nothing that fixed it once can regress in silence.
This is not a new idea here; it is what already happened without being written down:
suite 4 (the formula layer) exists because the first real audit this skill performed
found every one of its defects in formulas no suite could see, and the "не по-малко
от" incident (test/scenarios.md) is in structural_test.py's closed-form pins today
because a shared helper's bug once agreed with itself across every seed. Neither would
have stayed fixed as a sentence in a report.
What "permanent" requires, concretely:
- the mutation exists in a generator (
generate_wide.py,generate_komplekt.py,generate_lifecycle.py,generate_formula.py, or a hand-built fixture when none of those fit), not only in the one real file that exposed it; - a suite requires it found, the same way every other scenario is required — a miss fails the suite, not just this one case;
- if the miss was a guidance gap rather than a missing check, the fix in
SKILL.mdorreferences/is one sentence at the site the miss happened, citing the case that found it, not a rewritten section — and the same scenario proves the fix, not a new paid transcript, once it is in a suite that runs for free.
A miss that cannot be turned into a scenario (a document shape this skill does not
yet read, a rule with no fixture format to carry it) is still recorded — in
test/scenarios.md if it concerns the suites, or as a one-line note at the guidance
site otherwise — so the gap is visible rather than quietly repeated next time.
python3 -m venv .venv && .venv/bin/pip install -r test/requirements.txt
git config core.hooksPath .githooks # once
python test/run_tests.py # all five suites, 50 seeds
python test/rates_test.py # rates only, no dependencies
python test/skill_test.py # packaging: frontmatter, manifests, licences
python test/eval_skill.py --dry # what the skill eval would send, freeWhat runs when:
| Trigger | Runs |
|---|---|
| every commit (hook) | the rates cross-check |
commit touching the skill, a manifest, a licence, a README, the social card, eval_skill.py, skill_test.py or trz_model.py |
plus the packaging test |
commit touching test/*.py |
plus all five suites (0–4), 25 seeds |
| push and pull request (CI) | rates, packaging, the grader's self-test, the leak guards (text files and inside every tracked workbook), the five suites (0–4) at 300 seeds on Python 3.10–3.13 |
| weekly (CI) | the five suites at 3000 seeds; monthly, the verification-date freshness check |
| by hand | eval_skill.py — it calls Claude and costs about USD 5 per seed on Claude Fable 5.1, about USD 2 on Claude Sonnet 5 |
eval_skill.py is the only test that exercises the skill rather than the rules.
Run it when you change the guidance in SKILL.md, because nothing else will
notice.
Payrolls are personal data under the GDPR, and sick-leave records are health data. Every fixture in this repository is invented and derived from a seed. CI greps for numbers shaped like a national ID or a company ID — in the text files and inside every tracked spreadsheet — and fails the build.
If you need a realistic case, generate one:
python test/generate_wide.py --seed 12345main is protected by a ruleset, so nobody pushes to it directly — not outside
contributors and not the maintainer. Every change goes through a pull request,
and the pull request cannot merge until CI is green: the packaging check, the
personal-data guard, and the five suites (0–4) on Python 3.10 through 3.13.
Approvals are not required, so a solo maintainer is not deadlocked, but the status checks are. Linear history is enforced, so rebase rather than merge when your branch falls behind:
git fetch origin && git rebase origin/mainForce-pushing to main and deleting it are blocked outright, and so is rewriting or
deleting a release tag. Release tags here are unprefixed — 2.6.1, not v2.6.1 —
so the tag ruleset must match refs/tags/[0-9]*; a ruleset written for v* alone
protects none of the tags this repository actually has, which is how a tag once got
moved after landing on a stale main. Those are the rules that exist so that a bad
afternoon cannot lose history.
Bump version in all three manifests — skills/trz-expert/.claude-plugin/plugin.json,
skills/trz-expert/plugin.json and .claude-plugin/marketplace.json — then copy
.claude-plugin/marketplace.json over .github/plugin/marketplace.json. Users are
pinned by the version, and skill_test.py fails if any of the four drift. If the
rates changed, update metadata.rates_verified in .claude-plugin/plugin.json to
match the verification date in stavki.md; the same test checks that too.
The plugin manifest lives inside the skill directory because that directory is
the plugin: marketplace.json points source at ./skills/trz-expert, and an
install copies that directory whole — no ignore file is honoured, so a source
of . would hand every user test/, the fixtures and any local .venv. That is
also why LICENSE-DOCS is duplicated into the skill directory: an installed user
never sees the repository root, and CC BY 4.0 asks for its terms to travel with
the text. skill_test.py fails if the copy drifts from the root one.
skills/trz-expert/plugin.json is the same plugin in the
Agent Plugins 1.0 shape that GitHub Copilot and the
awesome-copilot marketplace gate read. Copilot CLI installs the plugin from the
Claude manifest on its own; the gate does not look in .claude-plugin/, and the 1.0
schema closes its field set, so this file carries no metadata or userConfig and
only ASCII kebab-case keywords. skill_test.py compares every field it shares with
the Claude manifest. .github/plugin/marketplace.json is where VS Code — and Copilot
CLI, before it falls back to .claude-plugin/ — look for the marketplace; it is a
byte-identical copy, and the same test says so when it stops being one.