feat(m2): expose status and daily tools through jobbsok-tools

This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 22:09:17 +02:00
commit c7db9f16a0
4 changed files with 578 additions and 19 deletions

View file

@ -80,6 +80,111 @@
],
"additionalProperties": false
}
},
{
"name": "sak_status",
"description": "Utled status, ventende part, siste aktivitet, neste frist og stillhets-flagg for hver sak, fra logg.jsonl. sak.md er en hurtigbuffer og loggen vinner. Skriver ingenting.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"today": {
"type": "string",
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
},
"sak": {
"type": "string",
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
},
"format": {
"type": "string",
"enum": [
"tekst",
"json"
],
"description": "Utdataform. Standard: tekst."
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
},
{
"name": "sak_status_check",
"description": "Samme utledning, men rapporterer om en hurtigbuffer i sak.md er uenig med loggen. Avviket staar i teksten, med saken navngitt; kommandolinja svarer i tillegg med exit-kode 1, som MCP ikke har. Retter ingenting og skriver ingenting.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"today": {
"type": "string",
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
},
"sak": {
"type": "string",
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
},
{
"name": "dagens",
"description": "Dagens arbeidsbilde som ren tekst: hva som krever handling, hva som har gaatt stille, kommende frister og saker per tilstand. Byte-stabil for samme arbeidsomraade og samme dato. Skriver ingenting.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"today": {
"type": "string",
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
},
{
"name": "beslutning_append",
"description": "Legg til én beslutning i den append-only beslutningsloggen (build-brief 5.4), eller en korrigering som navngir en tidligere beslutning. En retting er alltid en ny linje, aldri en redigering. Returnerer posten som ble skrevet.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"post": {
"type": "object",
"description": "Posten. Feltene er build-brief 5.4: id, dato, kilde, url, tittel, arbeidsgiver, beslutning, arsak, notat, score_da, delscore, vekter, vekt_hash, korrigerer."
},
"korriger": {
"type": "boolean",
"description": "Posten er en korrigering av en tidligere beslutning. Standard: false."
}
},
"required": [
"workspace",
"post"
],
"additionalProperties": false
}
}
]
}

View file

@ -44,6 +44,21 @@ SCRIPTS = os.path.join(REPO, "scripts")
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json")
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.py")
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
TODAY = "2026-09-15"
#: The tool surface, in the order the server declares it. A tool list is an
#: API: M1 shipped the first three, Step 24 added the four M2 tools.
VERKTOY = [
"kandidat_valider",
"annonse_vurder",
"selvsjekk",
"sak_status",
"sak_status_check",
"dagens",
"beslutning_append",
]
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
@ -96,9 +111,10 @@ def test_initialize_and_tools_list_match_the_golden_surface(klient, golden):
verktoy = klient.tools()
golden(GOLDEN, json.dumps({"tools": verktoy}, ensure_ascii=False, indent=2) + "\n")
# The golden pins the shape; this pins the fact that M1 ships exactly three
# tools, so Step 24 adding four more is a visible change and not a drift.
assert [t["name"] for t in verktoy] == ["kandidat_valider", "annonse_vurder", "selvsjekk"]
# The golden pins the shape; this pins the surface, so a tool appearing or
# disappearing is a visible change and not a drift. M1 shipped the first
# three; Step 24 added the four M2 tools.
assert [t["name"] for t in verktoy] == VERKTOY
def test_every_tool_requires_an_explicit_workspace(klient, arbeidsomrade):
@ -206,7 +222,8 @@ def test_importing_the_server_does_not_import_the_capability_modules():
"-c",
"import sys; import jobbsok_tools; "
"print(sorted(m for m in ('kandidat_schema', 'vurdering', "
"'llm_ingestion_guard') if m in sys.modules))",
"'sak_status', 'dagens', 'beslutninger', 'llm_ingestion_guard') "
"if m in sys.modules))",
],
cwd=REPO,
env=dict(os.environ, PYTHONPATH=SCRIPTS),
@ -350,8 +367,177 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
"expected exactly two replies, one per request: %r" % (servert.stdout,)
)
assert svar[0]["result"]["serverInfo"]["name"] == "jobbsok-tools"
assert [t["name"] for t in svar[1]["result"]["tools"]] == [
"kandidat_valider",
"annonse_vurder",
"selvsjekk",
]
assert [t["name"] for t in svar[1]["result"]["tools"]] == VERKTOY
def cli(script, *argv):
"""Run one of the command-line entry points and return its stdout."""
env = dict(os.environ)
env.pop("JOBBSOK_WORKSPACE", None)
env.pop("CLAUDE_PLUGIN_DATA", None)
proc = subprocess.run(
[sys.executable, os.path.join(SCRIPTS, script)] + list(argv),
capture_output=True, text=True, env=env,
)
return proc.stdout
def test_the_m2_tools_answer_with_the_bytes_the_command_line_produces(
klient, empty_workspace, tmp_path
):
"""Parity is what makes the degradation branch honest.
Every skill degrades to reading what the CLI produced when the server is
absent. That instruction is only true if the two paths produce the same
content -- otherwise the degraded operator is reading a different report
and does not know it.
"""
for verktoy, argumenter, script, argv in (
("sak_status",
{"workspace": KORPUS, "today": TODAY, "format": "json"},
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--format", "json"]),
("sak_status_check",
{"workspace": KORPUS, "today": TODAY},
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--check"]),
("dagens",
{"workspace": KORPUS, "today": TODAY},
"dagens.py", ["--workspace", KORPUS, "--today", TODAY]),
):
fra_server = klient.call_text(verktoy, argumenter)
fra_kommandolinja = cli(script, *argv)
assert fra_server == fra_kommandolinja, (
"%s and the command line disagree; the degradation branch tells "
"the operator these are the same content" % verktoy
)
# `sak_status_check` must carry the verdict in the payload: over MCP there
# is no exit code, and a check whose answer was only an exit status would
# arrive at Cowork as an unqualified report.
svar = klient.call_text("sak_status_check", {"workspace": KORPUS, "today": TODAY})
assert "utdatert hurtigbuffer" in svar, (
"the corpus holds a deliberately divergent case and the check never "
"says so: %r" % (svar[-200:],)
)
# The writing tool, appended through each path into its own fresh
# workspace, because the log refuses a reused id by design.
from jobbsok_lib import paths as _paths
andre = str(tmp_path / "andre-workspace")
_paths.scaffold(andre)
post = {
"id": "b-0001", "dato": "2026-09-01T09:00:00+02:00", "kilde": "manuell",
"url": "https://stillinger.example/sak-1", "tittel": "Dataingeniør",
"arbeidsgiver": "Værøy Sjømat AS", "beslutning": "ja",
"arsak": ["fagomrade"], "notat": "treffer kjernen", "score_da": 78,
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68,
"selskapstype": 63},
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20,
"selskapstype": 10},
"vekt_hash": "sha256:" + "e4" * 32,
}
fra_server = klient.call_text(
"beslutning_append", {"workspace": empty_workspace, "post": post}
)
fra_kommandolinja = cli(
"beslutninger.py", "--workspace", andre, "--json", json.dumps(post)
)
assert fra_server == fra_kommandolinja
# And it actually wrote, in both places: a parity test over two no-ops
# would pass just as quietly.
for rot in (empty_workspace, andre):
with open(os.path.join(rot, "beslutninger.jsonl"), "r", encoding="utf-8") as handle:
assert len(handle.read().splitlines()) == 1
def tolk_uten_vakt(katalog, navn, versjon="3.14.0", har_vakt=False):
"""A program that clears the version gate and answers the guard probe.
Two probes, and the fake has to tell them apart: the version gate runs
`sys.version_info >= ...` and the guard gate runs `import
llm_ingestion_guard`. With ``har_vakt`` false the second one fails, which
is exactly the measured M1 state -- an interpreter at 3.14.0 that clears
the floor and has no guard (O4 in docs/cowork-probe.md).
The Windows form is written but NOT measured; this suite has only ever run
on macOS.
"""
if os.name == "nt":
sti = katalog / (navn + ".cmd")
linjer = ["@echo off"]
if not har_vakt:
linjer.append('echo %* | findstr /C:"llm_ingestion_guard" >nul && exit /b 1')
linjer.append('echo %%* | findstr /C:"print" >nul && (echo %s & exit /b 0)' % versjon)
linjer.append("exit /b 0")
sti.write_text("\r\n".join(linjer) + "\r\n")
return sti
avvis = "" if har_vakt else " *llm_ingestion_guard*) exit 1 ;;\n"
sti = katalog / navn
sti.write_text(
"#!/bin/sh\n"
"# Clears the version gate at %s; the guard import is the variable.\n"
'case "$*" in\n'
"%s"
" *print*) echo '%s'; exit 0 ;;\n"
"esac\n"
"exit 0\n" % (versjon, avvis, versjon)
)
sti.chmod(0o755)
return sti
def test_the_launcher_refuses_to_serve_without_the_ingestion_guard(tmp_path):
"""O4, decided in docs/cowork-probe.md and implemented here.
The measured M1 incident: the server served on the host's 3.14.0, cleared
the version floor, and reported `guard_versjon: null` because nothing had
ever asked. From M2 the case folder and the decision log persist employer
names, titles, URLs and notes that came out of a listing, so serving
without the guard is a defect rather than an observation.
Failing closed is cheap here and that is the deciding argument: every
skill degrades to manual paste with the server absent, so a launcher that
refuses removes a connector -- it does not strand anyone.
"""
(tmp_path / "uten").mkdir()
uten = tolk_uten_vakt(tmp_path / "uten", "python3.14", har_vakt=False)
avvist = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(uten)),
input="",
capture_output=True,
text=True,
timeout=30,
)
assert avvist.returncode != 0, (
"the launcher served on an interpreter that cannot import the guard"
)
assert "llm_ingestion_guard" in avvist.stderr, (
"the refusal must name what was missing: %r" % (avvist.stderr,)
)
assert "scripts/bootstrap.py" in avvist.stderr, (
"the refusal must name the command that fixes it, or it is a dead end: "
"%r" % (avvist.stderr,)
)
# The positive control, without which the assertion above could be passing
# on the version gate, on a missing file, or on nothing at all: the same
# fake with the guard import answering must NOT be refused.
(tmp_path / "med").mkdir()
med = tolk_uten_vakt(tmp_path / "med", "python3.14", har_vakt=True)
sluppet_gjennom = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(med)),
input="",
capture_output=True,
text=True,
timeout=30,
)
assert sluppet_gjennom.returncode == 0, (
"an interpreter that clears both gates was still refused: %r"
% (sluppet_gjennom.stderr,)
)
assert "refusing" not in sluppet_gjennom.stderr