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

@ -13,10 +13,19 @@ SDK would add a PyPI dependency the Cowork sandbox cannot install, and would
cost import time at every cold start against an MCP start-up timeout. Three
JSON-RPC methods is less code than the dependency it would replace.
**Nothing heavy is imported at module load.** `kandidat_schema` and
`vurdering` are imported inside the handlers, not at the top. The server has
to answer `initialize` fast enough that Cowork does not time it out, and it
answers that one without touching either module.
**Nothing heavy is imported at module load.** `kandidat_schema`,
`vurdering`, `sak_status`, `dagens` and `beslutninger` are imported inside the
handlers, not at the top. The server has to answer `initialize` fast enough
that Cowork does not time it out, and it answers that one without touching any
of them.
**The M2 tools call the command line's own `main()`, with the streams
redirected** (Step 24). Parity between the two paths is not a nicety here: every
skill degrades to reading what the CLI produced when the server is absent, and
that instruction is only true if the content is the same. A second renderer on
this side would be a copy free to drift from the first, so what runs is the
same entry point -- same argument parsing, same output, same refusals -- and
the only thing this layer adds is turning an exit code into an MCP answer.
**Every tool takes an explicit `workspace`.** `jobbsok_lib.paths` refuses to
guess at a home directory, and this process runs unsandboxed on the operator's
@ -61,6 +70,23 @@ WORKSPACE_ARG = {
),
}
#: The injected clock. Every M2 tool takes it, and none of them reads the wall
#: clock when it is absent only because a caller that cannot pin the date is
#: still better served by today than by a refusal -- but a `Verifiser`-step
#: that wants a reproducible answer passes it.
TODAY_ARG = {
"type": "string",
"description": (
"Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens "
"dato. Oppgi den i alt som skal kunne reproduseres."
),
}
SAK_ARG = {
"type": "string",
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet.",
}
TOOLS = [
{
"name": "kandidat_valider",
@ -145,6 +171,95 @@ TOOLS = [
"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": WORKSPACE_ARG,
"today": TODAY_ARG,
"sak": SAK_ARG,
"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": WORKSPACE_ARG,
"today": TODAY_ARG,
"sak": SAK_ARG,
},
"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": WORKSPACE_ARG, "today": TODAY_ARG},
"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": WORKSPACE_ARG,
"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,
},
},
]
@ -242,6 +357,95 @@ def tool_annonse_vurder(arguments):
return til_json(resultat)
def _streng(arguments, navn):
"""An optional string argument, refused rather than coerced.
Types are checked before anything reaches `argparse`: a non-string would
become a malformed argv, and argparse answers a malformed argv with
SystemExit -- which is a BaseException and would take the server down
rather than returning a tool error.
"""
verdi = arguments.get(navn)
if verdi is None:
return None
if not isinstance(verdi, str) or not verdi.strip():
raise ToolError("%s må være en ikke-tom tekst, fikk %r" % (navn, verdi))
return verdi
def _kjor_cli(modul, argv):
"""Run a command-line entry point in-process and return its stdout.
The exit code is translated, not swallowed: a hard failure becomes a tool
error carrying what the CLI wrote to stderr. `sak_status`'s exit code 1 --
"a cache diverged" -- is deliberately not a failure; it is an answer, and
it is in the text.
"""
import io as _io
ut, feil = _io.StringIO(), _io.StringIO()
kode = modul.main(argv, stdout=ut, stderr=feil)
if kode == modul.EXIT_FEIL:
raise ToolError(feil.getvalue().strip() or "kommandoen feilet uten melding")
return ut.getvalue()
def _sak_status_argv(arguments):
argv = ["--workspace", _workspace(arguments)]
today = _streng(arguments, "today")
if today:
argv.extend(["--today", today])
sak = _streng(arguments, "sak")
if sak:
argv.extend(["--sak", sak])
return argv
def tool_sak_status(arguments):
import sak_status
argv = _sak_status_argv(arguments)
form = _streng(arguments, "format") or "tekst"
if form not in ("tekst", "json"):
raise ToolError("format må være tekst eller json, fikk %r" % (form,))
return _kjor_cli(sak_status, argv + ["--format", form])
def tool_sak_status_check(arguments):
import sak_status
# Text, not JSON: the CLI appends the divergence line to stdout after the
# report, which is natural in the text form and would make the JSON form
# unparseable. The answer is in that line, because MCP has no exit code.
return _kjor_cli(sak_status, _sak_status_argv(arguments) + ["--check"])
def tool_dagens(arguments):
import dagens
argv = ["--workspace", _workspace(arguments)]
today = _streng(arguments, "today")
if today:
argv.extend(["--today", today])
return _kjor_cli(dagens, argv)
def tool_beslutning_append(arguments):
import beslutninger
root = _workspace(arguments)
post = arguments.get("post")
if not isinstance(post, dict):
raise ToolError(
"post mangler; forventet beslutningen som et kart med feltene fra "
"build-brief 5.4"
)
argv = ["--workspace", root, "--json", json.dumps(post, ensure_ascii=False)]
if arguments.get("korriger"):
argv.append("--korriger")
return _kjor_cli(beslutninger, argv)
def tool_selvsjekk(arguments):
root = _workspace(arguments)
@ -277,10 +481,16 @@ def tool_selvsjekk(arguments):
)
#: Insertion order is the order `tools/list` publishes, and the golden file
#: pins it. Keep it the same as TOOLS above.
HANDLERS = {
"kandidat_valider": tool_kandidat_valider,
"annonse_vurder": tool_annonse_vurder,
"selvsjekk": tool_selvsjekk,
"sak_status": tool_sak_status,
"sak_status_check": tool_sak_status_check,
"dagens": tool_dagens,
"beslutning_append": tool_beslutning_append,
}