feat(m2): expose status and daily tools through jobbsok-tools
This commit is contained in:
parent
bdb6291042
commit
c7db9f16a0
4 changed files with 578 additions and 19 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,11 +44,31 @@ Two environment variables, and they are NOT the same one:
|
|||
spelling problem above: a Windows adopter setting it
|
||||
merely to say `python` would serve without the guard.
|
||||
|
||||
The gate is on the VERSION, not on the guard being importable. That is the
|
||||
shell version's semantics carried over unchanged, and it is a known weakness
|
||||
(O4 in docs/cowork-probe.md): `selvsjekk` can report `guard_versjon: null`
|
||||
while the server runs happily. It is harmless while nothing writes and becomes
|
||||
a defect in M2. Fixing it is an M2 decision, not a porting one.
|
||||
Two gates, in this order (O4, decided in docs/cowork-probe.md and implemented
|
||||
at plan Step 24):
|
||||
|
||||
1. VERSION. The interpreter reports at least 3.10, or it is not used.
|
||||
2. THE GUARD. The interpreter can import `llm_ingestion_guard`, or the
|
||||
launcher refuses to serve at all and says which command installs it.
|
||||
|
||||
Until M2 there was only the first, and the measured consequence is the reason
|
||||
for the second: the M1 probe found the server running happily on the host's
|
||||
3.14.0 with `selvsjekk` reporting `guard_versjon: null`, because nothing had
|
||||
ever asked. That cost nothing while no tool wrote anything. From M2 the case
|
||||
folder and the decision log persist employer names, titles, URLs and notes
|
||||
that came out of a listing, and the guard is the boundary those writes are
|
||||
supposed to cross.
|
||||
|
||||
Refusing is cheap here, and that is the deciding argument rather than a
|
||||
consolation: every skill in this plugin degrades to manual paste with
|
||||
`jobbsok-tools` absent. A launcher that refuses removes a connector; it does
|
||||
not strand the operator. Failing open would persist untrusted text through the
|
||||
one boundary this plugin exists to hold.
|
||||
|
||||
The guard gate costs one extra interpreter start-up at launch, which is paid
|
||||
once per session against Cowork's MCP start-up timeout. It is a real cost and
|
||||
it is worth naming; it is not a reason to check something cheaper that answers
|
||||
a different question.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -118,6 +138,25 @@ def version_ok(tolk):
|
|||
)
|
||||
|
||||
|
||||
#: The ingestion guard, by the name the interpreter has to be able to import.
|
||||
#: build-brief calls this the trust boundary, and the boundary is the write.
|
||||
GUARD_MODUL = "llm_ingestion_guard"
|
||||
|
||||
#: What fixes a failed guard gate. Named in the refusal, because a refusal
|
||||
#: that does not say what to run is a dead end.
|
||||
BOOTSTRAP = "python scripts/bootstrap.py"
|
||||
|
||||
|
||||
def guard_ok(tolk):
|
||||
"""True when ``tolk`` can import the ingestion guard. Runs it; does not
|
||||
look for a file, because what matters is that interpreter's own path."""
|
||||
return 0 == subprocess.call(
|
||||
[tolk, "-c", "import " + GUARD_MODUL],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def version_of(tolk):
|
||||
"""The version ``tolk`` reports, or "ukjent" when it will not say."""
|
||||
try:
|
||||
|
|
@ -206,9 +245,11 @@ def resolve_interpreter(env, plugin_root, which=None, gate=None):
|
|||
return None
|
||||
|
||||
|
||||
def main(argv, env=None):
|
||||
def main(argv, env=None, guard=None):
|
||||
if env is None:
|
||||
env = os.environ
|
||||
if guard is None:
|
||||
guard = guard_ok
|
||||
plugin_root = env.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(SCRIPT_DIR)
|
||||
server = os.path.join(plugin_root, "scripts", "jobbsok_tools.py")
|
||||
|
||||
|
|
@ -243,6 +284,23 @@ def main(argv, env=None):
|
|||
sys.stderr.write("jobbsok-tools: server not found at %s\n" % server)
|
||||
return 1
|
||||
|
||||
# Gate two. Last, because it is the expensive one and there is no point
|
||||
# probing an interpreter that already failed the floor.
|
||||
if not guard(tolk):
|
||||
sys.stderr.write(
|
||||
"jobbsok-tools: %s cannot import %s.\n" % (tolk, GUARD_MODUL)
|
||||
)
|
||||
sys.stderr.write(
|
||||
"jobbsok-tools: run '%s' against the installed plugin; the guard "
|
||||
"is installed with it.\n" % BOOTSTRAP
|
||||
)
|
||||
sys.stderr.write(
|
||||
"jobbsok-tools: refusing to start rather than serving tools that "
|
||||
"write without the ingestion guard. Every skill still works by "
|
||||
"manual paste.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
# POSIX replaces this process, as the shell version's `exec` did: the MCP
|
||||
# client's child stays the process it spawned, and killing it kills the
|
||||
# server. Windows has no such replacement -- os.execv there starts a new
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue