feat(m1): add kandidatprofil skill with schema reference
This commit is contained in:
parent
ac7de63dc1
commit
f75718d82c
3 changed files with 425 additions and 0 deletions
96
skills/kandidatprofil/SKILL.md
Normal file
96
skills/kandidatprofil/SKILL.md
Normal 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`.
|
||||
84
skills/kandidatprofil/references/schema.md
Normal file
84
skills/kandidatprofil/references/schema.md
Normal 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.
|
||||
245
tests/test_skills_contract.py
Normal file
245
tests/test_skills_contract.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue