feat(m2): add dagens text view with golden output

This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 22:04:36 +02:00
commit bdb6291042
4 changed files with 578 additions and 0 deletions

253
scripts/dagens.py Normal file
View file

@ -0,0 +1,253 @@
"""The daily operating view, as deterministic text (plan Step 23).
Build-brief 6 says the silence flags "surface in `dagens`". This is where they
surface, and at this milestone that is plain text -- the HTML dashboard belongs
to M6, and building it now would put a rendering layer between the operator and
the first thing the status machine ever produced for them.
Four sections, in the order the brief names them: what needs action, what has
gone silent, upcoming deadlines, and the pipeline by state.
**It writes nothing.** Not the cache, not a log, not a temporary file. The
operator reads this several times a day against a workspace they are working
in, and a view that refreshed something as a side effect would make reading a
write. `sak_status.py --oppdater` stays the only writer of the four cached
keys; when this view disagrees with a `sak.md`, it says so and names the
command rather than fixing it in passing.
**Two kinds of deadline, told apart rather than merged.** The status machine
derives `neste_frist` as the date a case goes silent, and derives nothing at
all in `vurderer` and `soker` -- where the date in the frontmatter is the
operator's own application deadline. Both are deadlines and neither is the
other, so each entry carries which kind it is. Printing a single unlabelled
date would have quietly told the operator that an application deadline is
something the machine computed.
**An absent inbound event is a fact about the log.** With the mail server
deferred to M4 nothing reads the inbox, so "nothing inbound recorded" cannot
mean "nobody answered" (risk H9). Those cases carry the status machine's own
note, verbatim, and never turn up in the silence section on that basis alone.
Determinism is a property of the interface. The date is injected, never read
off the wall clock, and nothing that reaches stdout is iterated out of a dict
or a set: two runs are byte-identical, including under different hash seeds.
"""
import datetime
import sak_status
from jobbsok_lib import frontmatter as frontmatter_lib
from jobbsok_lib import jsonl, paths
#: Section headings, in build-brief 6's order. Norwegian, because everything
#: the operator reads is Norwegian.
KREVER_HANDLING = "Krever handling"
GATT_STILLE = "Gått stille"
KOMMENDE_FRISTER = "Kommende frister"
PER_TILSTAND = "Saker per tilstand"
#: What a deadline is, when there is one. The derived one is the date silence
#: trips; the cached one is the operator's own, and no machine here can
#: recompute it.
STILLHETSFRIST = "stillhetsfrist"
SOKNADSFRIST = "søknadsfrist"
TOM = " (ingen)"
def samle(root, today):
"""Everything the view shows, as one ordered structure. Pure reader."""
data = sak_status.rapport(root, today)
saker = []
for sak in data["saker"]:
meta, _body = frontmatter_lib.read(root, "saker", sak["sak_id"], "sak.md")
frist, frist_kilde = _frist(sak, meta)
beriket = dict(sak)
beriket["frist"] = frist
beriket["frist_kilde"] = frist_kilde
saker.append(beriket)
return {"dato": data["dato"], "saker": saker}
def _frist(sak, meta):
"""The date this case is next measured against, and which kind it is."""
utledet = sak.get("neste_frist")
if utledet:
return utledet, STILLHETSFRIST
bufret = meta.get("neste_frist")
if bufret in (None, "", sak_status.NULL):
return None, None
return str(bufret), SOKNADSFRIST
def krever_handling(saker):
"""Non-terminal cases the operator is the one holding up."""
return [s for s in saker
if s["status"] not in sak_status.TERMINALE and s["ventende_part"] == "meg"]
def gatt_stille(saker):
"""Cases a silence rule has actually fired on -- and only those.
Membership is the flag list the status machine derived, never the absence
of an inbound event: those two look alike and mean opposite things.
"""
return [s for s in saker if s["flagg"]]
def kommende_frister(saker, dagen):
"""Deadlines still ahead, earliest first. A passed one is in the section
above, and repeating it here would double-count the same fact."""
ut = [s for s in saker
if s["status"] not in sak_status.TERMINALE and s["frist"] and s["frist"] >= dagen]
# ISO-8601 dates sort correctly as text, and sorting on the text keeps the
# ordering independent of any locale.
return sorted(ut, key=lambda s: (s["frist"], s["sak_id"]))
def per_tilstand(saker):
"""(state, count) for all nine states, in the brief's order.
All nine, including the empty ones: a pipeline with nothing in `tilbud` is
something the operator should be able to see rather than infer from a
missing line. Iterating the fixed tuple is also what keeps this out of a
dict's ordering.
"""
return [(tilstand, len([s for s in saker if s["status"] == tilstand]))
for tilstand in sak_status.TILSTANDER]
def render(data):
"""The whole view as text. Every ordering here is fixed, none is a dict's."""
saker = data["saker"]
dagen = data["dato"]
linjer = ["dagens %s (%d saker)" % (dagen, len(saker)), ""]
avvikende = [s["sak_id"] for s in saker if s["divergens"]]
if avvikende:
# Reported, never repaired: this view does not write, and a cache
# quietly fixed by a reader is a divergence nobody ever learns about.
linjer.append(
"NB: %d %s en hurtigbuffer som er uenig med loggen: %s."
% (len(avvikende), "sak har" if len(avvikende) == 1 else "saker har",
", ".join(avvikende))
)
linjer.append(
" Tallene nedenfor er utledet fra loggen. Kjør sak_status.py "
"--oppdater for å rette sak.md."
)
linjer.append("")
linjer.extend(_saksseksjon(KREVER_HANDLING, krever_handling(saker)))
linjer.extend(_saksseksjon(GATT_STILLE, gatt_stille(saker)))
linjer.extend(_fristseksjon(kommende_frister(saker, dagen)))
linjer.extend(_tilstandsseksjon(saker))
return "\n".join(linjer)
def _saksseksjon(tittel, utvalg):
linjer = ["## %s (%d)" % (tittel, len(utvalg)), ""]
if not utvalg:
linjer.extend([TOM, ""])
return linjer
for sak in utvalg:
linjer.extend(_sakslinjer(sak))
return linjer
def _sakslinjer(sak):
linjer = [
"- %s -- %s, %s" % (sak["sak_id"], sak["arbeidsgiver"], sak["rolle"]),
" %s, ventende: %s, sist aktivitet: %s"
% (sak["status"], sak["ventende_part"], sak["sist_aktivitet"] or "-"),
]
if sak["flagg"]:
linjer.append(
" flagg: %s (terskel passert %s)"
% (", ".join(sak["flagg"]), sak["neste_frist"] or "-")
)
# The flag line above already states a passed threshold by its date, so a
# `frist:` line repeating the same number is noise rather than a second
# fact. A deadline the flag line does not carry is still printed.
if sak["frist"] and not (sak["flagg"] and sak["frist"] == sak["neste_frist"]):
linjer.append(" frist: %s (%s)" % (sak["frist"], sak["frist_kilde"]))
if not sak["innkommende"]["registrert"]:
linjer.append(" merknad: %s" % sak["innkommende"]["merknad"])
linjer.append("")
return linjer
def _fristseksjon(utvalg):
linjer = ["## %s (%d)" % (KOMMENDE_FRISTER, len(utvalg)), ""]
if not utvalg:
linjer.extend([TOM, ""])
return linjer
for sak in utvalg:
linjer.append("- %s %s -- %s" % (sak["frist"], sak["sak_id"], sak["frist_kilde"]))
linjer.append("")
return linjer
def _tilstandsseksjon(saker):
linjer = ["## %s" % PER_TILSTAND, ""]
for tilstand, antall in per_tilstand(saker):
linjer.append(" %-11s %d" % (tilstand, antall))
linjer.append(" %-11s %d" % ("i alt", len(saker)))
linjer.append("")
return linjer
# ---------------------------------------------------------------------------
# Command line
# ---------------------------------------------------------------------------
EXIT_OK = 0
EXIT_FEIL = 2
def build_parser():
import argparse
parser = argparse.ArgumentParser(
prog="dagens.py",
description=(
"Dagens arbeidsbilde som ren tekst: hva som krever handling, hva "
"som har gaatt stille, kommende frister og saker per tilstand. "
"Skriver ingenting."
),
)
parser.add_argument("--workspace", required=True,
help="arbeidsomraadets rot (ingen underforstaatt standard)")
parser.add_argument("--today", default=None, metavar="YYYY-MM-DD",
help="klokka som skal brukes; injisert, aldri veggklokka")
return parser
def main(argv=None, stdout=None, stderr=None):
import sys as _sys
argv = _sys.argv[1:] if argv is None else argv
stdout = _sys.stdout if stdout is None else stdout
stderr = _sys.stderr if stderr is None else stderr
args = build_parser().parse_args(argv)
today = args.today or datetime.date.today().isoformat()
try:
root = paths.workspace_root(args.workspace)
tekst = render(samle(root, today))
except (sak_status.StatusError, paths.WorkspaceError, jsonl.JsonlError,
frontmatter_lib.FrontmatterError, OSError) as feil:
stderr.write("dagens: %s\n" % feil)
return EXIT_FEIL
stdout.write(tekst)
stdout.flush()
return EXIT_OK
if __name__ == "__main__":
import sys as _sys
_sys.exit(main())

86
skills/dagens/SKILL.md Normal file
View file

@ -0,0 +1,86 @@
---
name: dagens
description: "Viser dagens arbeidsbilde fra arbeidsmappa: hva som krever handling, hva som har gaatt stille, kommende frister og saker per tilstand. Skriver ingenting, og eier ogsaa versjonsekkoet paa forespoersel. Utloeses av vis dagens, hva skjer i dag, hva har gått stille, hva har gatt stille, hvilke frister kommer og vis byggestempelet."
triggers:
- vis dagens
- hva skjer i dag
- hva har gått stille
- hva har gatt stille
- hvilke frister kommer
- vis byggestempelet
---
# dagens
Operatørens daglige inngang. **Skriver ingenting** — ingen sak, ingen logg,
ingen hurtigbuffer. Visningen leses flere ganger om dagen mens arbeidsmappa er
i bruk, og en visning som oppdaterte noe underveis ville gjort lesing til
skriving.
All operatørrettet tekst er norsk bokmål.
## Kjør den
```
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/dagens.py \
--workspace <arbeidsmappa> --today <YYYY-MM-DD>
```
På Windows heter tolken `python`, ikke `python3`.
Datoen injiseres. Utelates `--today`, brukes dagens dato — men i alt som skal
kunne reproduseres, oppgi den. Utdata er ren tekst og er byte-stabil: samme
arbeidsmappe og samme dato gir samme bytes hver gang.
Regn aldri ut noe av dette selv. Alt kommer fra statusmaskinen, og en visning
som resonnerte seg fram til en tilstand ved siden av scriptet ville vært en
andre statusmaskin ingen har testet.
## De fire seksjonene
| Seksjon | Hva som står der |
|---|---|
| **Krever handling** | Ikke-terminale saker der ventende part er `meg` |
| **Gått stille** | Saker der en av de tre stillhetsreglene faktisk har slått til |
| **Kommende frister** | Frister som ennå ikke har passert, tidligst først |
| **Saker per tilstand** | Alle ni tilstandene, også de tomme |
**«Gått stille» er flagg, ikke fravær.** En sak havner der bare fordi
statusmaskinen har satt et flagg — aldri fordi det mangler en innkommende
hendelse. De to ser like ut og betyr det motsatte.
**To slags frister, og de er merket.** `stillhetsfrist` er datoen saken går
stille, utledet av maskinen. `søknadsfrist` er operatørens egen dato fra
saksfila, som ingen maskin kan regne ut på nytt. Gjengi merkingen — en
umerket dato ville fortalt operatøren at maskinen står inne for begge.
## «Ingen innkommende registrert»
Står den merknaden på en sak, gjengi den som den er. E-postserveren er utsatt
til M4, så ingenting leser innboksen ennå: fraværet er i loggen, ikke i verden.
Ikke oppsummer det til «ingen har svart».
## Står det NB øverst
Da er en saksfil uenig med sin egen logg. Visningen viser det loggen sier, og
retter ingenting. Si fra om avviket og foreslå
`${CLAUDE_PLUGIN_ROOT}/scripts/sak_status.py --oppdater` — la `sak` gjøre
selve rettingen.
## Versjonsekko — bare på forespørsel
Blir du spurt om hvilken versjon som kjører, skriv ut begge deler: innholdet i
`${CLAUDE_PLUGIN_ROOT}/BUILD_STAMP` og `version` fra plugin-manifestet
`.claude-plugin/plugin.json`. Samme form som `kandidatprofil` bruker. Mangler
stempelfila, si det rett ut i stedet for å oppgi manifestversjonen alene som om
den var hele svaret.
**Aldri i den daglige visningen.** Stempelet endrer seg ved hver commit, og et
stempel inni tekstutdataen ville gjort visningen ustabil fra commit til commit.
Ekkoet kommer fra skillen, ikke fra scriptet, og bare når noen spør.
## Uten verktøyserveren
Er `jobbsok-tools` ikke tilgjengelig, degraderer skillen i stedet for å stoppe:
be operatøren kjøre kommandoen over fra kommandolinja, og les teksten som ble
skrevet ut. Innholdet er det samme uansett hvilken vei det kom.

View file

@ -0,0 +1,52 @@
dagens 2026-09-15 (8 saker)
NB: 1 sak har en hurtigbuffer som er uenig med loggen: 2026-09-havbris-energi-as-losningsarkitekt.
Tallene nedenfor er utledet fra loggen. Kjør sak_status.py --oppdater for å rette sak.md.
## Krever handling (3)
- 2026-09-nordlys-data-as-ai-radgiver -- Nordlys Data AS, AI-rådgiver
vurderer, ventende: meg, sist aktivitet: 2026-09-13
frist: 2026-09-25 (søknadsfrist)
merknad: ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.
- 2026-09-solvang-helse-as-teknisk-radgiver -- Solvang Helse AS, Teknisk rådgiver
tilbud, ventende: meg, sist aktivitet: 2026-09-12
- 2026-09-vaeroy-sjomat-as-dataingenior -- Værøy Sjømat AS, Dataingeniør
soker, ventende: meg, sist aktivitet: 2026-09-10
frist: 2026-09-20 (søknadsfrist)
merknad: ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.
## Gått stille (3)
- 2026-09-bratthaug-industri-as-systemarkitekt -- Bratthaug Industri AS, Systemarkitekt
intervju, ventende: dem, sist aktivitet: 2026-08-26
flagg: intervju_10 (terskel passert 2026-09-05)
- 2026-09-havbris-energi-as-losningsarkitekt -- Havbris Energi AS, Løsningsarkitekt
sendt, ventende: dem, sist aktivitet: 2026-08-27
flagg: sendt_14 (terskel passert 2026-09-09)
merknad: ingen innkommende hendelse er registrert. Med e-postserveren utsatt betyr det ikke at ingen finnes.
- 2026-09-storelva-kommune-fagleder-digitalisering -- Storelva kommune, Fagleder digitalisering
dialog, ventende: dem, sist aktivitet: 2026-08-22
flagg: dialog_7 (terskel passert 2026-08-28)
## Kommende frister (2)
- 2026-09-20 2026-09-vaeroy-sjomat-as-dataingenior -- søknadsfrist
- 2026-09-25 2026-09-nordlys-data-as-ai-radgiver -- søknadsfrist
## Saker per tilstand
vurderer 1
soker 1
sendt 1
dialog 1
intervju 1
tilbud 1
avsluttet 0
avslag 1
trukket 1
i alt 8

187
tests/test_dagens_text.py Normal file
View file

@ -0,0 +1,187 @@
"""The daily operating view as plain text, and what it must not become
(plan Step 23).
`dagens` is build-brief 6's consumer: the flags the status machine derives
surface here or nowhere. At this milestone it is text -- the HTML dashboard is
M6's job -- and text pinned to a golden file, because a daily view whose
wording drifts is a daily view nobody can diff.
Three properties carry the section, and each is here for a measured reason.
**It writes nothing.** The view is read on a workspace the operator is working
in, often several times a day. A view that refreshed a cache as a side effect
would make reading the pipeline a write, and `sak_status.py --oppdater` is
deliberately the only writer of those four keys.
**"No inbound recorded" is not "gone silent".** With the mail server deferred
to M4, nothing reads the inbox, so an absent inbound event is a fact about the
log and not about the world (risk H9). A view that read the absence as silence
would flag most of the pipeline on its first run and be switched off by the
second.
**The version echo lives in the skill, never in this output.** Step 9 promised
the echo from `dagens` and Step 23 is where that promise is kept -- but the
stamp changes with every commit, so a stamp inside the text would break this
step's own golden at the next commit. The echo is on request, from the skill.
Style note: this file follows tests/test_sak_status_cli.py.
"""
import hashlib
import os
import re
import subprocess
import sys
import pytest
import sak_status
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CLI = os.path.join(REPO, "scripts", "dagens.py")
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
GOLDEN = os.path.join(REPO, "tests", "golden", "dagens-2026-09-15.txt")
SKILL = os.path.join(REPO, "skills", "dagens", "SKILL.md")
STEMPEL = os.path.join(REPO, "BUILD_STAMP")
TODAY = "2026-09-15"
#: The three the status machine flags at the frozen date, one per rule.
FLAGGEDE = {
"2026-09-bratthaug-industri-as-systemarkitekt": "intervju_10",
"2026-09-havbris-energi-as-losningsarkitekt": "sendt_14",
"2026-09-storelva-kommune-fagleder-digitalisering": "dialog_7",
}
#: A case with no inbound recorded and no flag. It must be labelled, and it
#: must not be presented as having gone quiet.
UTEN_INNKOMMENDE = "2026-09-nordlys-data-as-ai-radgiver"
def kjor(*argv, **kwargs):
env = dict(os.environ)
env.pop("JOBBSOK_WORKSPACE", None)
env.pop("CLAUDE_PLUGIN_DATA", None)
env.update(kwargs.get("env", {}))
proc = subprocess.run(
[sys.executable, CLI] + list(argv),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env,
)
return proc.returncode, proc.stdout, proc.stderr
def visning(workspace=KORPUS, today=TODAY):
kode, ut, feil = kjor("--workspace", workspace, "--today", today)
assert kode == 0, feil.decode("utf-8")
return ut.decode("utf-8")
def seksjoner(tekst):
"""Split the view into {heading: body}, so a claim about a section is one."""
deler = re.split(r"^## ", tekst, flags=re.M)
ut = {}
for del_ in deler[1:]:
linjer = del_.split("\n")
overskrift = re.sub(r"\s*\(\d+\)\s*$", "", linjer[0]).strip()
ut[overskrift] = "\n".join(linjer[1:])
return ut
def digest(root):
ut = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for navn in sorted(filenames):
full = os.path.join(dirpath, navn)
with open(full, "rb") as handle:
ut[os.path.relpath(full, root)] = hashlib.sha256(handle.read()).hexdigest()
return ut
def test_the_text_view_matches_the_golden_for_the_fixture_workspace(golden):
golden(GOLDEN, visning())
def test_the_three_flagged_cases_appear_under_the_silence_section():
deler = seksjoner(visning())
stille = [navn for navn in deler if "stille" in navn.lower()]
assert len(stille) == 1, "expected exactly one silence section, found %r" % (list(deler),)
kropp = deler[stille[0]]
for sak_id, flagg in FLAGGEDE.items():
assert sak_id in kropp, "%s is flagged but not in the silence section" % sak_id
assert flagg in kropp, "the section never names the rule %r that fired" % flagg
# And nothing else: a section that listed the whole pipeline would be a
# section the operator learns to skip. Membership is checked against the
# corpus's own case list rather than a pattern, because a pattern loose
# enough to catch a sak-id also catches the ISO dates in the same block.
oppforte = [sak_id for sak_id in sak_status.alle_saker(KORPUS) if sak_id in kropp]
assert sorted(oppforte) == sorted(FLAGGEDE), (
"the silence section lists %r; only the flagged three belong there"
% (sorted(oppforte),)
)
def test_a_case_with_no_recorded_inbound_is_labelled_and_not_called_silent():
tekst = visning()
deler = seksjoner(tekst)
stille = deler[[navn for navn in deler if "stille" in navn.lower()][0]]
assert UTEN_INNKOMMENDE not in stille, (
"a case with nothing recorded inbound was presented as having gone "
"quiet; with the mail server deferred that is a fact about the log"
)
assert UTEN_INNKOMMENDE in tekst, "the case is missing from the view entirely"
# Labelled, and in the status machine's own words rather than a second
# paraphrase that could drift from it.
rapport = sak_status.rapport(KORPUS, TODAY, UTEN_INNKOMMENDE)
merknad = rapport["saker"][0]["innkommende"]["merknad"]
assert merknad and merknad in tekst, (
"the view never carries the %r note" % (merknad,)
)
def test_the_run_writes_nothing(workspace):
forst = digest(workspace)
kode, _ut, feil = kjor("--workspace", workspace, "--today", TODAY)
assert kode == 0, feil.decode("utf-8")
assert digest(workspace) == forst, "the daily view wrote into the workspace"
def test_two_runs_are_byte_identical():
forste = visning()
andre = visning()
assert forste == andre
# And under two hash seeds, which is what turns "no dict reaches stdout"
# from an intention into a measurement.
_kode, null, _ = kjor("--workspace", KORPUS, "--today", TODAY,
env={"PYTHONHASHSEED": "0"})
_kode, mange, _ = kjor("--workspace", KORPUS, "--today", TODAY,
env={"PYTHONHASHSEED": "1048573"})
assert null == mange, (
"output moved with PYTHONHASHSEED, so a dict or a set reached stdout"
)
def test_the_skill_carries_the_version_echo_and_the_golden_does_not():
with open(SKILL, "r", encoding="utf-8") as handle:
skill = handle.read()
assert "BUILD_STAMP" in skill, (
"Step 9 promised the echo from dagens in two places and kept it in "
"none; this is the step that keeps it"
)
assert "plugin.json" in skill, "the echo prints the stamp and the version, not one"
tekst = visning()
assert "BUILD_STAMP" not in tekst, (
"the daily view carries the build stamp; it changes with every commit "
"and would break this step's own golden at the next one"
)
if os.path.isfile(STEMPEL):
with open(STEMPEL, "r", encoding="utf-8") as handle:
stempel = handle.read().strip()
assert stempel and stempel not in tekst, (
"the stamp %r leaked into the daily view" % (stempel,)
)