feat(m1): add kandidatprofil skill with schema reference

This commit is contained in:
Kjell Tore Guttormsen 2026-09-04 21:22:31 +02:00
commit f75718d82c
3 changed files with 425 additions and 0 deletions

View file

@ -0,0 +1,96 @@
---
name: kandidatprofil
description: "Leser, validerer og redigerer kandidatprofilen i arbeidsmappa, og eier den mekaniske sjekken mot build-brief 5.1. Utloeses av vis kandidatprofilen, vis profilen min, valider kandidatprofilen, oppdater lønnsgulvet, oppdater lonnsgulvet, sett opp arbeidsmappa, hvilken versjon kjører jobbsok og hvilken versjon kjorer jobbsok."
triggers:
- vis kandidatprofilen
- vis profilen min
- valider kandidatprofilen
- oppdater lønnsgulvet
- oppdater lonnsgulvet
- sett opp arbeidsmappa
- hvilken versjon kjører jobbsok
- hvilken versjon kjorer jobbsok
---
# kandidatprofil
Eier `profil/kandidat.md` i arbeidsmappa. Frontmatter er absolutt og leses
mekanisk; kroppen er prosa og leses med skjønn. Denne skillen validerer og
foreslår — den skriver aldri i profilen uten at operatøren har sagt ja til
akkurat den endringen.
All operatørrettet tekst er norsk bokmål.
## Løs opp arbeidsmappa først
Arbeidsmappa gjettes aldri. Løsningsrekkefølgen er `--workspace`, deretter
miljøvariabelen `JOBBSOK_WORKSPACE`, deretter en `workspace:`-linje i
`jobbsok.conf` under plugin-datamappa. Svarer ingen av de tre, si det og be om
stien. Ikke anta en hjemmekatalog.
Finnes ikke mappa ennå, kall `scaffold(root)` fra
`${CLAUDE_PLUGIN_ROOT}/scripts/jobbsok_lib/paths.py` og rapporter nøyaktig
hvilke kataloger og filer kallet opprettet. Kallet er idempotent, så en tom
liste betyr at alt allerede var på plass — si det, ikke påstå at noe ble laget.
`beslutninger.jsonl` blir aldri tømt av et scaffold.
## Valider
Kjør den mekaniske sjekken:
```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/kandidat_schema.py
```
Bruk `validate_file(root, "profil", "kandidat.md")` når du kaller den som
modul. Rapporten er JSON med feltene `gyldig`, `feil`, `advarsler`,
`seksjoner`, `senioritet`, `ansettelsesform`, `absolutte_nei`,
`arbeidssteder`, `vekter_kilde` og `vekter`.
Les rapporten slik:
- **`feil`** er brudd på build-brief 5.1. Hvert element navngir nøkkelstien.
Gjengi stien ordrett til operatøren; ikke omskriv den til prosa.
- **`advarsler`** er ikke feil. En nøkkel utenfor 5.1 er operatørens egen, og
profilen er fortsatt gyldig. Advarselen for `arbeidssteder` sier at
vurderingen faktisk leser nøkkelen — skill den fra en advarsel om en nøkkel
ingenting leser.
- **`vekter_kilde`** er `default` når profilen ikke oppgir `vekter`. Det er en
normal tilstand, ikke et avvik.
- Malformert frontmatter er ingen rapport i det hele tatt: sjekken hever
`FrontmatterError` med linjenummeret. Gjengi linjenummeret.
Hele nøkkellista, seksjonsrekkefølgen, skilletegnkontrakten og standardvektene
står i `${CLAUDE_PLUGIN_ROOT}/skills/kandidatprofil/references/schema.md`. Les
den før du uttaler deg om hva som er påkrevd.
## Der fila og 5.1 er uenige, vinner fila
Operatøren eier denne fila. Foreslå aldri å skrive den om for å passe
kontrakten. Er strukturen ny og fornuftig, er det skjemaet som skal utvides —
si fra om det, ikke rett i fila.
Kroppen er skjønn. Et signaturprosjekt som sier *ikke målt* er en ærlig
tilstand, ikke et hull som skal fylles med et estimat. Et breddepunkt kan være
en disposisjon og ikke et fagfelt.
## Rediger bare som forslag
Ved endringsønske:
1. Les fila og valider den først.
2. Vis diffen — gammel linje, ny linje, og hvorfor.
3. Vent på et eksplisitt ja.
4. Skriv, valider på nytt, og vis den nye rapporten.
Hardfiltrene (`lonn.gulv_nok`, `geografi`, `absolutte_nei`) strammes aldri på
eget initiativ. En serie avslag på lønn er like gjerne et tegn på markedet som
på et feil gulv.
## Versjonsekko
Ber operatøren om versjon, skriv ut begge deler: innholdet i
`${CLAUDE_PLUGIN_ROOT}/BUILD_STAMP` og `version` fra plugin-manifestet
`.claude-plugin/plugin.json`. Mangler stempelfila, si det rett ut i stedet for
å oppgi bare manifestversjonen som om den var hele svaret. Fra M2 er det samme
ekkoet også tilgjengelig fra `dagens`.

View file

@ -0,0 +1,84 @@
# `profil/kandidat.md` — nøkler, seksjoner og vekter
Normativ kilde er `docs/build-brief.md` 5.1. Denne fila gjengir kontrakten slik
den faktisk håndheves, inkludert de utvidelsene som kom av at operatørens egen
fil vant over kontrakten.
## Påkrevd frontmatter — 17 nøkler inkludert nestede
| Nøkkel | Type | Merknad |
| --- | --- | --- |
| `oppdatert` | skalar | dato, `YYYY-MM-DD` |
| `geografi.base` | skalar | stedsnavn, skrives ordrett |
| `geografi.maks_reisetid_min` | heltall | sammenlignes numerisk |
| `geografi.hjemmekontor_min_dager` | heltall | sammenlignes numerisk |
| `geografi.vurderer_flytting` | ord | `ja`, `nei` eller `kanskje` |
| `lonn.gulv_nok` | heltall | avviser mekanisk |
| `lonn.onsket_nok` | heltall | vekter, avviser ikke |
| `lonn.kommentar` | skalar | fri tekst |
| `ansettelsesform.aksepterer` | skalar med kommaliste | se skilletegn |
| `ansettelsesform.avviser` | skalar med kommaliste | se skilletegn |
| `absolutte_nei` | liste | ett punkt per linje |
| `senioritet.min` | fri tekst | ingen enum i 5.1 |
| `senioritet.maks` | fri tekst | ingen enum i 5.1 |
Mangler en av dem, er det en **feil** som navngir nøkkelstien. Mangler hele
blokka, navngis både blokka og hver eneste nøkkel under den.
## Nøkler utenfor 5.1
En ukjent nøkkel er en **advarsel**, aldri en feil. Operatøren eier fila.
| Nøkkel | Status |
| --- | --- |
| `vekter` | additiv, operatørbeslutning 14.3 |
| `arbeidssteder` | utenfor 5.1, men lest av vurderingen |
| alt annet | beholdt, ingenting leser det |
`arbeidssteder` er en flat liste over steder operatøren faktisk vil jobbe.
Står stedet i lista, blir `maks_reisetid_min` en advarsel i stedet for et
avslag for akkurat det stedet.
## Skilletegn
Grammatikken tillater bare en skalar under en mapping-nøkkel. Derfor bærer
skalaren lista, med én regel som gjelder likt overalt:
- **komma** deler en skalar i elementer
- **skråstrek** deler ett element i alternative skrivemåter
- **mellomrom** inne i et alternativ er en frase
Så `åremål/engasjement, vikariat` er to avviste former, den første med to navn.
`personalansvar / linjeledelse` er ett nei med to navn. Regelen gjelder
`ansettelsesform.aksepterer`, `ansettelsesform.avviser` og hvert punkt i
`absolutte_nei`.
Prisen er at en ansettelsesform ikke kan inneholde komma. Ingen gjør det.
## Kroppsseksjoner, i rekkefølge
1. Kjerne
2. Kompetanse
3. Retning
4. Signaturprosjekter
5. Kjente svakheter
6. Formuleringer som virker
7. Profilgap
Bare `##` teller. En `#`-tittel øverst og `###`-underseksjoner inni er lov og
er ikke seksjoner i 5.1-forstand. Feil rekkefølge er en feil; innholdet inne i
en seksjon valideres ikke.
## Standardvekter
| Kriterium | Standard |
| --- | --- |
| `fagomrade` | 40 |
| `oppgavetype` | 30 |
| `teknologi` | 20 |
| `selskapstype` | 10 |
Oppgir profilen `vekter`, gjelder de i stedet og rapporten sier
`vekter_kilde: profil`. Manglende kriterium arver standarden og gir en
advarsel. Vektene normaliseres på summen, så tallene trenger ikke summere til
100 — de gjør det bare for at vektoren skal leses som en fordeling.

View file

@ -0,0 +1,245 @@
"""The contract every `skills/*/SKILL.md` has to hold (plan Steps 9 and 11).
A skill is a document, and a document has no unit tests -- which is exactly
why it needs a contract test. What can be checked mechanically is checked
here: the frontmatter parses, the name matches the directory, the trigger
phrases the operator would actually type are declared and unique, the body is
short enough to be loaded without crowding the context, every intra-plugin
path is written as `${CLAUDE_PLUGIN_ROOT}`, no absolute home path is baked in,
and every `references/` file is linked from the skill that owns it.
Two checks in the plan's Step 9 list read as behavioural, and they are
implemented as far as a zero-network test honestly can, with the boundary
stated rather than blurred:
* *"invoking the skill against a non-existent workspace scaffolds it and
reports the created tree"* -- the invocation is a model turn and cannot run
here. What runs is the mechanism the skill delegates to,
`jobbsok_lib.paths.scaffold`, against a path that does not exist, plus the
assertion that SKILL.md actually names it. A skill that stopped calling
scaffold would fail the second half.
* *"the version echo prints both the build stamp and the manifest version"* --
`BUILD_STAMP` is created in Step 13. So the check is that the skill names
both the stamp path and the manifest version, and that
`.claude-plugin/plugin.json` really carries a version to echo. The file's
existence becomes assertable at Step 13 and not before; claiming otherwise
here would be asserting a fact about a file this repository does not have.
The trigger-phrase rule deserves its own note. Build-brief 7 requires the
description to name the trigger phrases in Norwegian, and the plan requires
them "in both accented and ASCII spellings" -- because an operator typing in a
hurry writes `lonn` as often as `lønn`. So every accented phrase must have an
ASCII twin, folded through `paths.slug`'s rules so the fold is defined in one
place rather than twice.
Style note: this file follows tests/test_kandidatprofil_schema.py.
"""
import json
import os
import re
import pytest
from jobbsok_lib import frontmatter, paths
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKILLS = os.path.join(REPO, "skills")
MANIFEST = os.path.join(REPO, ".claude-plugin", "plugin.json")
NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
PLUGIN_ROOT = "${CLAUDE_PLUGIN_ROOT}"
#: A line that names one of these is naming a file inside the plugin, and has
#: to say so with the variable rather than a bare relative path.
INTRA_PLUGIN = (".py", "references/", "scripts/", "assets/", "templates/")
#: Absolute or home-anchored paths. `paths.py` refuses to guess at a home
#: directory; a skill that hardcodes one would put the guess back.
HOME_PATHS = ("/Users/", "/home/", "$HOME", "~/")
#: First and second person, word-bounded. Build-brief 7 wants a description
#: that says what the skill does, not one that addresses the operator.
PERSON_RE = re.compile(
r"(?<![a-zA-ZæøåÆØÅ])(jeg|meg|min|mitt|mine|du|deg|din|ditt|dine|I|you|your)"
r"(?![a-zA-ZæøåÆØÅ])"
)
ACCENTED_RE = re.compile(r"[æøåÆØÅ]")
MAX_ORD = 3000
def skill_dirs():
if not os.path.isdir(SKILLS):
return []
return sorted(
name for name in os.listdir(SKILLS)
if os.path.isfile(os.path.join(SKILLS, name, "SKILL.md"))
)
def load(name):
path = os.path.join(SKILLS, name, "SKILL.md")
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
meta, body = frontmatter.parse(text)
return meta, body, text
def body_words(body):
"""Word count with fenced blocks removed, per the plan's 3000-word rule."""
prose = re.sub(r"```.*?```", " ", body, flags=re.S)
return len(prose.split())
def ascii_fold(phrase):
"""Fold a phrase the way `paths.slug` folds a path component."""
return " ".join(paths.slug(word) for word in phrase.split() if paths.slug(word))
ALLE = skill_dirs()
@pytest.fixture(params=ALLE, ids=ALLE)
def skill(request):
name = request.param
meta, body, text = load(name)
return {"name": name, "meta": meta, "body": body, "text": text}
def test_there_is_at_least_one_skill_to_check():
# A parametrised suite over an empty list is a suite that passes without
# looking at anything.
assert ALLE, "skills/ holds no SKILL.md; every check below would be vacuous"
def test_frontmatter_parses_and_name_matches_the_directory(skill):
assert skill["meta"], "%s has no frontmatter" % skill["name"]
assert skill["meta"].get("name") == skill["name"]
assert NAME_RE.match(skill["name"]), "%r is not a valid skill name" % skill["name"]
def test_description_is_third_person_and_names_every_trigger(skill):
description = skill["meta"].get("description", "")
assert description, "%s has no description" % skill["name"]
triggers = skill["meta"].get("triggers")
assert isinstance(triggers, list) and triggers, "%s declares no triggers" % skill["name"]
for phrase in triggers:
assert phrase in description, (
"trigger %r is declared but does not appear verbatim in the description" % phrase
)
# The person check runs on the skill's own voice. The quoted trigger
# phrases are the operator's words -- "vis profilen min" is exactly what
# gets typed -- and holding them to third person would force the
# descriptions to name phrases nobody uses.
egen_stemme = description
for phrase in sorted(triggers, key=len, reverse=True):
egen_stemme = egen_stemme.replace(phrase, " ")
funnet = PERSON_RE.search(egen_stemme)
assert funnet is None, (
"%s description is not third person: %r" % (skill["name"], funnet.group(0))
)
def test_every_accented_trigger_has_an_ascii_twin(skill):
triggers = skill["meta"].get("triggers", [])
accented = [phrase for phrase in triggers if ACCENTED_RE.search(phrase)]
assert accented, "%s declares no Norwegian trigger phrase" % skill["name"]
for phrase in accented:
twin = ascii_fold(phrase)
# Declared verbatim, not merely derivable: folding the accented
# phrase and then looking for the fold among the folds would find
# itself, and pass on every input.
assert twin in triggers, (
"accented trigger %r has no ASCII spelling (%r) among the triggers"
% (phrase, twin)
)
def test_trigger_phrases_are_unique_across_skills():
seen = {}
for name in ALLE:
meta, _body, _text = load(name)
for phrase in meta.get("triggers", []):
assert phrase not in seen, (
"trigger %r is claimed by both %s and %s" % (phrase, seen[phrase], name)
)
seen[phrase] = name
assert seen, "no triggers declared anywhere"
def test_body_is_under_the_word_budget(skill):
ord_ = body_words(skill["body"])
assert ord_ < MAX_ORD, "%s body is %d words, budget is %d" % (
skill["name"], ord_, MAX_ORD
)
def test_every_intra_plugin_path_uses_the_plugin_root(skill):
for lineno, line in enumerate(skill["text"].split("\n"), start=1):
if not any(token in line for token in INTRA_PLUGIN):
continue
assert PLUGIN_ROOT in line, (
"%s:%d names a plugin file without %s: %r"
% (skill["name"], lineno, PLUGIN_ROOT, line.strip())
)
def test_no_absolute_home_path_is_baked_into_a_skill(skill):
for token in HOME_PATHS:
assert token not in skill["text"], (
"%s hardcodes %r; the workspace is resolved, never guessed" % (skill["name"], token)
)
def test_reference_files_resolve_in_both_directions(skill):
directory = os.path.join(SKILLS, skill["name"], "references")
on_disk = sorted(os.listdir(directory)) if os.path.isdir(directory) else []
linked = sorted(set(re.findall(r"references/([A-Za-z0-9_.-]+)", skill["text"])))
assert on_disk == linked, (
"%s: references/ on disk %r, referenced from SKILL.md %r"
% (skill["name"], on_disk, linked)
)
def test_skill_files_are_valid_utf8_and_carry_norwegian(skill):
for root, _dirs, files in os.walk(os.path.join(SKILLS, skill["name"])):
for name in files:
with open(os.path.join(root, name), "rb") as handle:
handle.read().decode("utf-8")
assert ACCENTED_RE.search(skill["text"]), (
"%s carries no Norwegian characters; operator-facing output is bokmaal"
% skill["name"]
)
def test_kandidatprofil_scaffolds_a_workspace_that_does_not_exist_yet(tmp_path):
# The invocation is a model turn. What is testable is the mechanism the
# skill delegates to, and that the skill still says it delegates to it.
root = str(tmp_path / "fersk-workspace")
assert not os.path.exists(root)
created = paths.scaffold(root)
assert set(paths.WORKSPACE_DIRS) <= set(created)
assert set(paths.WORKSPACE_FILES) <= set(created)
assert os.path.isdir(os.path.join(root, "profil"))
# Idempotent, so a second run reports an empty tree rather than claiming
# to have created what was already there.
assert paths.scaffold(root) == []
_meta, _body, text = load("kandidatprofil")
assert "scaffold" in text
def test_kandidatprofil_declares_the_version_echo_both_halves():
_meta, _body, text = load("kandidatprofil")
assert "BUILD_STAMP" in text
assert "plugin.json" in text
with open(MANIFEST, "r", encoding="utf-8") as handle:
manifest = json.load(handle)
# There has to be a version for the echo to print. The stamp file itself
# arrives in Step 13; asserting it here would assert a file this
# repository does not yet have.
assert manifest["version"]
assert manifest["name"] == "jobbsok"