diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6e8ef6a..71ee2a6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "okr", - "version": "1.6.0", + "version": "1.6.1", "description": "Expert OKR guidance for Norwegian public sector. Write, review, cascade, track and govern OKR based on Google/Doerr methodology adapted for 4-month tertial cycles.", "author": { "name": "Kjell Tore Guttormsen" diff --git a/CHANGELOG.md b/CHANGELOG.md index 37151d3..c26d21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **OKF second-brain spec v0.1 ratifisert (cross-plugin koordinering)** — den delte konvensjonen er kanonisert i `catalog/docs/okf-second-brain/spec.md` (katalog-eid, single source of truth). okr ratifiserer den per handoff §3/§4: `okf-check.mjs`-semantikken (kun `type` påkrevd; anbefalte felt → advarsler; `okf_version`-ekko) står som referansekontrakt (spec §7); okr bruker `resource` (ikke `source`); ingen feltgap (profil/config-nøkler bæres som extension keys, spec §5). Innboks-ingestion-planen (`docs/innboks-ingestion-veivalg-2026-06.md`) bygger nå mot spec-en; delings-scope for en eventuell Stage-3-skill = okr + ms-ai-architect (ikke linkedin-studio). Ingen kode- eller versjonsendring. + +## [1.6.1] - 2026-06-26 + +### Fixed +- **`okr-quality-rubrics.md` omskrevet til korrekt norsk** — den ankrede kvalitetsrubrikken var fullstendig ASCII-strippet (0 å/ø/æ) til tross for at den lastes brukervendt av `/okr:kvalitet`, `kvalitetssjekker`-agenten og `/okr:freshen-references`. Samme tekstkvalitetsklasse som 360-en flagget KRITISK i v1.3.0 — en regresjon innført med selve rubrikk-fila. +- **`SKILL.md` flaggskip-eksempel** — strippet norsk i det første konkrete OKR-eksemplet (per år / høyrisiko / spørreundersøkelse / Gjennomføre møter). +- **`okr-second-brain-search` `~`-glob** — retrieval-instruksjonen brukte literal `~/.claude/okr/org/`, som Glob-verktøyet ikke ekspanderer → hjemme-roten (org-identitet) ble stilltiende aldri søkt, mot skillens eget «search both roots»-premiss. Nå eksplisitt absolutt hjemme-sti. +- **Uverifisert `kb-search`-sitat fjernet** — `okr-second-brain-search` hevdet en OKF `kb-search`-triad som ikke finnes (motsagt av `docs/innboks-ingestion-funn-2026-06.md`); erstattet med funksjonell Glob/Read/Grep-beskrivelse + `discovery`-mønsteret. +- **Regnefeil i `okr-oboard-guide.md`** — KR1-progresjon viste 52 %; korrekt er 65 % per filens egen formel `(start−current)/(start−target)`. + +### Notes +- Patch utløst av `docs/evaluering-360-2026-06-26.md` (360-re-evaluering på v1.6.0): C+ → A−. Systemiske funn (KB-kryssreferanser, «Sist oppdatert»-markører) er bevisst utsatt. + ## [1.6.0] - 2026-06-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 99147dc..f5603e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# OKR Offentlig Sektor v1.6.0 +# OKR Offentlig Sektor v1.6.1 Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapted for 4-month tertial cycles. @@ -53,7 +53,7 @@ The second skill (`okr-second-brain-search`) does on-demand retrieval over the u ## State Management User configuration: `.claude/okr.local.md` in the project directory (not in plugin root). -Org profile (reinstall-surviving): `~/.claude/okr/org/profil.md` — the machine-global org identity (`organisasjon:`/`program:`), written atomically (temp + `renameSync`) by `scripts/write-org-profile.mjs` during `/okr:oppsett`. Resolution is most-specific-wins: a project-local `.claude/okr.local.md` overrides the home profile; the home profile is the backwards-compatible fallback when no project config exists (read side: `hooks/scripts/inject-okr-context.mjs`). On any home-write failure the helper circuit-breaks to the gitignored project-local `.claude/okr.local.md`. Only the org *profile* migrates to home — cycle/`historikk` data stays cwd-bound. +Org profile (reinstall-surviving): `~/.claude/okr/org/profil.md` — machine-global org identity (`organisasjon:`/`program:`), written atomically (temp + `renameSync`) by `scripts/write-org-profile.mjs` during `/okr:oppsett`. Resolution is most-specific-wins: project-local `.claude/okr.local.md` overrides the home profile (the backwards-compatible fallback when no project config exists; read side `hooks/scripts/inject-okr-context.mjs`). Home-write failure → circuit-break to the gitignored project-local file. Only the org *profile* migrates to home; cycle/`historikk` data stays cwd-bound. Template: `templates/okr.local.md.template` Context tree: `.claude/okr/` — `strategisk-kontekst/`, `syklus/[id]/`, `historikk/`, `dokumenter/` Onboarding state: `onboarding_status` field in okr.local.md (`partial` | `fullfort`) diff --git a/README.md b/README.md index cb06111..d888cf5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ *AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)* -![Version](https://img.shields.io/badge/version-1.6.0-blue) +![Version](https://img.shields.io/badge/version-1.6.1-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Agents](https://img.shields.io/badge/agents-7-orange) ![Commands](https://img.shields.io/badge/commands-13-blue) @@ -290,6 +290,7 @@ The plugin understands this hierarchy and helps you maintain alignment at every | Version | Date | Highlights | |---------|------|------------| +| **1.6.1** | 2026-06-26 | Patch: credibility-sanering etter 360-re-evaluering (C+ → A−) — ASCII-strippet kvalitetsrubrikk omskrevet til korrekt norsk, `okr-second-brain-search` `~`-glob søker nå hjemme-roten, uverifisert kb-search-sitat fjernet, regnefeil i oboard-eksempel rettet | | **1.6.0** | 2026-06-26 | OKF «second brain»: on-demand retrieval-skill (`okr-second-brain-search`) over OKF-wiki, OKF-frontmatter på kontekstfiler, `okf-index`/`okf-check`, delt frontmatter-modul, slankere inject-hook | | **1.5.0** | 2026-06-26 | Referansegrad-løft (Fase 3): `/okr:help`, `/okr:export` (PDF), `/okr:freshen-references`, ankret kvalitetsrubrikk, atomisk org-profil-skriving | | **1.4.0** | 2026-06-24 | Domenedybde + kildeintegritet (Fase 1), UX-/kommando-/agent-modning (Fase 2), hybrid org-kontekst-lesing | diff --git a/docs/evaluering-360-2026-06-26.md b/docs/evaluering-360-2026-06-26.md new file mode 100644 index 0000000..c878568 --- /dev/null +++ b/docs/evaluering-360-2026-06-26.md @@ -0,0 +1,155 @@ +# 360-re-evaluering: OKR Offentlig Sektor-plugin (v1.6.0) + +**Dato:** 2026-06-26 +**Evaluator:** Lead reviewer (syntese av 5 Opus-agenter: 2 KB-ref-scorere, 2 skill-evaluatorer, 1 360-delta-verifikator) +**Mål:** Verifisere at den forrige 360-en (v1.3.0, karakter C+) er innfridd, score begge skills + alle 16 KB-referansefiler mot ankret rubrikk på dagens innhold, og dekke det som aldri har vært evaluert. +**Avgrensning:** Bygger på `docs/evaluering-360-2026-06-23.md` (v1.3.0) + `docs/plan-referansegrad-2026-06-23.md`. Sikkerhet/config/struktur ble dekket av piloten (`docs/review-2026-06-20.md`) og gjentas ikke. Alle bærende funn er verifisert direkte mot fil (file:line) — se Verifiseringslogg. + +--- + +## Sammendrag + +Pluginet er løftet **C+ → A−**. Alle KRITISKE og HØYE funn fra v1.3.0-360-en er bevist lukket i fil (DDT-fabrikasjon, scoringsterskel-selvmotsigelse, UTF-8-korrupsjon, find-replace-vrøvl, døde lenker, dokument-motsigelser), og hele referansegrad-gapet mot referanse-pluginen `ms-ai-architect` er levert (reinstall-overlevende org-katalog, `/okr:help`, ankrede rubrikker, hook-fixtures/55 grønne tester, PDF-eksport, org-lesende agenter). Pluginet er ikke lenger «utrygt å dele eksternt». + +Det som holder igjen det siste steget til **A** er **én regresjon i nøyaktig samme klasse som den opprinnelige showstopperen**: den nye Fase 3-fila `okr-quality-rubrics.md` (den ankrede kvalitetsrubrikken) er **fullstendig ASCII-strippet** (0 å/ø/æ) — og den lastes aktivt av `/okr:kvalitet`, `kvalitetssjekker`-agenten, `/okr:freshen-references` og `SKILL.md`, så feilen forplanter seg til brukervendt kvalitetsscoring. Samme tekstkvalitetssykdom (strippet norsk) finnes i flaggskip-eksempelet i `SKILL.md` — det første konkrete eksemplet en bruker ser. + +To skills ble evaluert. Hovedskillen `okr-offentlig-sektor`: **B+** (strukturelt moden, alle credibility-funn lukket, holdt under A av den strippede rubrikken + SKILL-eksempelet). Den nye `okr-second-brain-search` (førstegangs-evaluering, fantes ikke i juni-23-360-en): **B** — OKF-lagbeskrivelsen er verifisert korrekt i hver detalj, men har én funksjonell defekt (`~`-glob søker aldri hjemme-roten) og ett verifiseringsplikt-brudd (uverifisert `kb-search`-sitat motsagt av teamets egen funn-doc). + +De 16 KB-referansefilene scorer i snitt ~3.6/5. Sterkest: `okr-offentlig-governance.md` og `metrics-library.md` (begge 37/40 — provenans- og aktualitets-forbilledlige). Svakest: `okr-oboard-guide.md` (22/40, + regnefeil i kanonisk eksempel) og `individual-vs-team-okr.md` (22/40, usitert). Systemisk svakhet på tvers: manglende interne **kryssreferanser** (5 av 16 filer isolert) og manglende **«Sist oppdatert»-markører** (kapper Aktualitet-scoren uavhengig av innhold). + +**Samlet karakter: A−** (referansegrad innen rekkevidde; én fokusert saneringsrunde lukker gapet til A). + +--- + +## Del 1 — Delta vs 360-en (v1.3.0 → v1.6.0) + +Hvert 360-funn er åpnet mot dagens fil. Alle KRITISK/HØY/MEDIUM er **FIKSET** med følgende unntak: + +| 360-funn | Status | Bevis | +|----------|--------|-------| +| Semantisk inkoherens: digital-etat «DDT» eier vei/trafikk-KR | **DELVIS (by design)** | Ikke splittet i reelle etater; løst via plan-default — konsekvent «(fiktiv eksempeletat)»-merking. Inkoherensen består som *erklært fiksjon*, ikke faktapåstand. `okr-framework.md:59`, `okr-integrations.md:392`, `okr-offentlig-governance.md` gjennomgående. | +| Ankrede rubrikker (Fase 3 SHOULD) | **DELVIS** | `okr-quality-rubrics.md` finnes (5 ankere/dim) MEN er ASCII-strippet — se Del 4 #1. | +| Uciterte statistikker (39/43/30-45 %) | **DELVIS** | Nå hedget «Industri-rapportert (ikke fagfellevurdert)» (`okr-implementation.md:7-10`, `okr-antipatterns.md:3`), men fortsatt uten *navngitt* kilde. | + +Alt øvrig — scoringsterskel (én kanon `0.7 forventet / 0.6-0.7 sweet spot`), Intel/Locke/Kleingeld-rettelser, committed-vs-aspirational-aggregering, DDT ut av README, tertial-presisjon, dokumenttittel, tillitsreform-seksjon, målforskyvning-antipattern, UTF-8 i `individual-vs-team-okr.md`/`oppsett.md`/`coaching-hook.mjs`, alle find-replace-artefakter (0 gjenstående), Læring-regex, rm-rf-garde (OKR-01), reinstall-overlevende org-katalog, CONTRIBUTING↔GOVERNANCE, SECURITY→Forgejo, ASCII-kommandonavn, CLAUDE.md-count, SKILL-versjonssynk, 6 usiterte filer wiret inn, emne-guard, `/okr:help`, PDF-eksport, hook-fixtures (55/55 grønn), negative agent-triggere, org-lesende agenter — **bekreftet FIKSET**. + +### Oppdatert scorecard (9 dimensjoner) + +| Dimensjon | v1.3.0 | v1.6.0 | Begrunnelse | +|-----------|--------|--------|-------------| +| Domenekorrekthet/metodikk | C+ | **A** | Én kanonisk scoringsterskel; Intel/Locke/Kleingeld rettet; aggregering korrigert. | +| Norsk forvaltningstilpasning | C | **A−** | DDT erklært fiktiv; tertial-regime presist; tillitsreform + målforskyvning inne. Trekk: digital-etat-eier-vei består som erklært fiksjon. | +| Innholds- og språkkvalitet | D | **B** | De 2 navngitte filene + hook fullt rettet; 0 find-replace. Holdt nede av ny ASCII-strippet `okr-quality-rubrics.md`. | +| Kommandoer | B | **A−** | Kontekstbevissthet komplett; gap/governance disambiguert; Syklusreview; `/okr:help`. | +| Agenter | B | **A−** | Negative triggere + org-kontekst-lesing i alle 7. | +| Hooks/state/arkitektur | B+ | **A** | Læring-regex + rm-rf-garde + reinstall-overlevende org + emne-guard + 55 grønne tester. | +| Dokumentasjon/markedsplass | C | **A−** | DDT ut av README; ASCII-kommandonavn ut; CONTRIBUTING/SECURITY/count rettet. | +| Modenhet vs ms-ai-architect | C+ | **A−** | Alle MUST/SHOULD-gap levert. Rubrikk-kvalitet svekket av encoding. | +| Plugin-craft | B | **A−** | SKILL-versjon synket; 6 filer wiret inn; emne-guard. SKILL.md bevisst lean. | + +--- + +## Del 2 — Skill-evalueringer + +### `okr-offentlig-sektor` — **B+** + +| # | Dimensjon | Score | Bevis | +|---|-----------|-------|-------| +| 1 | Description/triggering | 4 | Intent-tett (`SKILL.md:3-4`); trekk: ingen negative triggere, overlapp med søsterskill på «tildelingsbrev». | +| 2 | Progressive disclosure | 4 | 155 linjer, lean; 17 ref eksternalisert. Trekk: flat ref-katalog vs ms-ai nestede. | +| 3 | Struktur & navigerbarhet | 5 | Nummererte Core Tasks 1-10, grupperte Resources. | +| 4 | Instruksjonskvalitet | 4 | Konkret mal + scoringsskala; cascade/track noe høynivå. | +| 5 | Dekning/fullstendighet | 5 | Skriv/review/track/cascade/møter/CFR/governance + 17 ref. | +| 6 | Korrekthet/integritet | 4 | Alle 17 ref-filer eksisterer; scoring-kanon konsistent. Trekk: stale telling «19 mistakes» (`SKILL.md:134`) vs 20; strippet norsk i eksempel. | +| 7 | ms-ai-architect-paritet | 4 | Alle 360-gap lukket. Trekk: ms-ai har negativ-scoping + nestet disclosure. | +| 8 | Vedlikeholdbarhet | 3 | Tester/fixtures finnes, men dekker hooks/scripts, ikke skill-*innhold* (innholdsdrift fanges ikke); 4/17 currency-markører. | + +**≤2-flagg:** `okr-quality-rubrics.md` ASCII-strippet (kvalitet ≤2, brukervendt via `/okr:kvalitet`); SKILL.md flaggskip-eksempel (`:32-34, :41`) strippet norsk. + +### `okr-second-brain-search` — **B** (førstegangs-evaluering) + +| # | Dimensjon | Score | Bevis | +|---|-----------|-------|-------| +| 1 | Description/triggering | 4 | Possessiv-ankret («våre mål»), eksplisitt carve-out av metodikk (`SKILL.md:30-31`). Trekk: `tildelingsbrev`-overlapp m/ companion; selvmotsigelse om trigger-plassering (`:132-133`). | +| 2 | Progressive disclosure | 4 | Én selvbærende fil, rimelig for retrieval-skill. | +| 3 | Struktur & navigerbarhet | 4 | Rene seksjoner; test-digresjon midt i prosedyren (`:110-112`). | +| 4 | Instruksjonskvalitet | 3 | Rank-tie-break presis, MEN `~`-glob-defekt (se Flagg). | +| 5 | Dekning/fullstendighet | 4 | Begge røtter, index-pekere, type-verdier, edge-cases. Hull: ingen fallback for nivå uten `index.md`. | +| 6 | Korrekthet/integritet | 4 | OKF-lag verifisert eksakt mot impl. (felt-rekkefølge, `okf_version`, index-format). Brudd: uverifisert `kb-search`-sitat. | +| 7 | OKF-arkitektur-konsistens | 5 | Fullt i synk m/ to-bundle-modellen. | +| 8 | Vedlikeholdbarhet | 4 | 2 fixtures + grundig regresjonstest (`okf-retrieval.test.mjs`). | + +**Flagg 1 (funksjonell defekt):** `SKILL.md:88-89` instruerer `Glob ... ~/.claude/okr/org/**/*.md`. Glob-verktøyet ekspanderer ikke shell-`~` → hjemme-roten (org-identitet) blir stilltiende usøkt, mot kjernepåstanden «always search both» (`:36`). Hook-en gjør det riktig via `homedir()` (`inject-okr-context.mjs:106`); skillen overfører ikke samme presisjon. + +**Flagg 2 (verifiseringsplikt-brudd):** `SKILL.md:84` hevder OKF har en `kb-search`-triad (`list_contents`/`read_file`/`search_content`). Teamets egen funn-doc motsier dette: `docs/innboks-ingestion-funn-2026-06.md:78-80` — «kb-search/SKILL.md ble IKKE funnet … Skillene heter `fileset-source` og `knowledge_catalog_discovery_agent`». Funn-doc-en (2026-06-26) kom etter SKILL-en; korrigeringen nådde aldri tilbake. + +--- + +## Del 3 — KB-referansefiler (16 domene-filer) + +Rubrikk: 4 dekning- + 4 kvalitetsdimensjoner (`commands/freshen-references.md`), 5 ankere hver, 1-5. UTF-8 bekreftet ren i alle 16 (v1.3.0-korrupsjonen i `individual-vs-team-okr.md` er fikset). + +| Rang | Fil | Sum/40 | ≤2-flagg | +|------|-----|--------|----------| +| 1 | `okr-offentlig-governance.md` | 37 | — | +| 1 | `metrics-library.md` | 37 | — | +| 3 | `okr-sources.md` | 34 | Kryssref | +| 4 | `dfo-okr-mapping.md` | 32 | — | +| 4 | `okr-framework.md` | 32 | — | +| 6 | `okr-antipatterns.md` | 31 | — | +| 6 | `okr-integrations.md` | 31 | — | +| 8 | `cfr-framework.md` | 31 | Kryssref (2) | +| 9 | `okr-implementation.md` | 29 | Kryssref | +| 10 | `okr-examples.md` | 28 | Kryssref (1) | +| 11 | `okr-arshjul.md` | 28 | Provenans | +| 12 | `okr-cheatsheet.md` | 26 | Off.-tilpasning, Kryssref | +| 13 | `okr-calculator.md` | 26 | Kryssref (1), Provenans | +| 14 | `meeting-guides.md` | 25 | Kryssref (1), Provenans | +| 15 | `individual-vs-team-okr.md` | 22 | Off.-tilpasning, Eksempel, **Provenans (1)** | +| 15 | `okr-oboard-guide.md` | 22 | Off.-tilpasning, Kryssref (1), Provenans | + +### Systemiske mønstre (på tvers) + +- **Kryssreferanser (størst):** 5 filer er reelt isolert fra KB-en (`okr-examples.md`=1, `okr-oboard-guide.md`=1, `meeting-guides.md`=1, `okr-calculator.md`=1, `cfr-framework.md`=2). How-to/mal-filer slutter på «Tips» uten Ressurser-seksjon. `okr-offentlig-governance.md`/`okr-integrations.md` viser standarden. +- **«Sist oppdatert»-markører mangler** på de fleste filer → kapper Aktualitet uavhengig av innhold. Billig løft. +- **Provenans-gradient:** `metrics-library.md`/`okr-sources.md`/`dfo-okr-mapping.md` er forbilledlige (per-påstand provenanstagging, DOI, «Ikke verifisert»-tagger). How-to-filene mangler kilde-seksjon helt. +- **DDT-inkoherens** forplanter seg til flere ref-filer (se Del 1). + +--- + +## Del 4 — Gjenstående funn (prioritert — kandidat for 1.6.1-sanering) + +1. **[HØY — ny regresjon] `okr-quality-rubrics.md` fullstendig ASCII-strippet.** 0 å/ø/æ: «nivaabeskrivelse», «paa tvers», «maalbart», «ambisioest», «maaloppnaaelse», «aa baere». Eksakt samme sykdom 360-en kalte KRITISK — i en ny Fase 3-fil som lastes brukervendt av `/okr:kvalitet`, `kvalitetssjekker`, `freshen-references`, `SKILL.md`. *Fix:* manuell norsk omskriving + spellcheck (samme oppskrift som Fase 0). +2. **[MEDIUM] `SKILL.md` flaggskip-eksempel strippet norsk** (`:32-34`, `:41`): «per ar», «hoyrisiko», «far», «sporreundersokelse», «Gjennomfore 5 moter». Første konkrete eksempel en bruker ser; undergraver «norsk forvaltning»-troverdighet. +3. **[MEDIUM — funksjonell] `okr-second-brain-search` `~`-glob** (`SKILL.md:88-89`): instruér eksplisitt absolutt hjemme-sti før Glob, ellers søkes aldri org-roten. +4. **[MEDIUM — integritet] `okr-second-brain-search` `kb-search`-sitat** (`SKILL.md:84`): rett til verifisert OKF-virkelighet (`fileset-source` / `discovery`-mønster) per `innboks-ingestion-funn-2026-06.md`. +5. **[MEDIUM] `okr-oboard-guide.md:95` regnefeil:** KR1 32 ulykker (45→25) = 65 %, ikke «Progress: 52 %» (mot filens egen formel `:47`). + plassholderlenker `:124-125`. Svakest fil — vurder om vendor-spesifikk fil hører i KB. +6. **[LAV-MEDIUM, systemisk] Kryssreferanser:** legg «Relaterte filer»-seksjon i de 5 isolerte filene. +7. **[LAV, systemisk] «Sist oppdatert»-markører** på ref-filer som mangler dem. +8. **[LAV] `individual-vs-team-okr.md`:** kilde Spotify-2013-påstand (`:5`), norsk off.-vinkling, ikke-privat-sektor-eksempler. +9. **[LAV] `okr-implementation.md` statistikker:** navngi kilde bak 39/43/30-45 % eller dropp. +10. **[TRIVIELT] Språk-nits:** `meeting-guides.md:82` «schuld»→«skyld», `:215` «Næste»→«Neste»; `dfo-okr-mapping.md:10` «resultmål»→«resultatmål»; `okr-arshjul.md:97` uverifisert «15. oktober»-budsjettdato; `SKILL.md:134` «19»→«20 mistakes». + +--- + +## Verifiseringslogg + +Bærende funn sjekket direkte mot fil (oppfyller verifiseringsplikten): + +| Påstand | Verdikt | Bevis | +|---------|---------|-------| +| `okr-quality-rubrics.md` er ASCII-strippet | **Bekreftet** | `grep -o '[åøæ]' = 0`; «paa tvers» `:3`, «ambisioest» `:43`, «aa baere» `:5` | +| `SKILL.md`-eksempel strippet norsk | **Bekreftet** | `:32` «per ar», `:33` «hoyrisiko … far», `:34` «sporreundersokelse», `:41` «Gjennomfore … moter» | +| `okr-second-brain-search` bruker literal `~` i Glob | **Bekreftet** | `SKILL.md:89` `~/.claude/okr/org/**/*.md` | +| `kb-search`-triaden finnes i OKF | **Avkreftet** | `innboks-ingestion-funn-2026-06.md:78-80` — heter `fileset-source`/`knowledge_catalog_discovery_agent` | +| `okr-oboard-guide.md` Progress-% | **Feil i fil** | `:95` «52 %»; korrekt (45−32)/(45−25)=65 % per formel `:47` | +| DDT fjernet fra README | **Bekreftet** | `grep DDT README.md = 0`; README:23 lister kun NAV/FINN.no | +| Scoringsterskel konsistent | **Bekreftet** | `0.7-0.9`/`70-80%`/`0.6-0.8` = 0 treff; kanon `0.7 / 0.6-0.7` på tvers | +| UTF-8 i `individual-vs-team-okr.md` | **Bekreftet fikset** | 13 å / 6 ø / 1 æ, 0 strippede ord | +| Find-replace-artefakter | **Bekreftet 0** | hele vedlegg-A-mønsteret = 0 treff | +| Testsuite | **Bekreftet** | `node --test tests/*.test.mjs` → 55 passerer, 0 feiler | +| Reinstall-overlevende org-katalog | **Bekreftet** | `~/.claude/okr/org/profil.md` via `write-org-profile.mjs`, lest av `inject-okr-context.mjs`, testet | + +### Metode + +5 Opus-agenter (xhigh): Ref-scorer A (filer 1-8) + B (9-16) mot freshen-rubrikken; skill-evaluator × 2 (én per skill); 360-delta-verifikator mot alle v1.3.0-funn. Lead reviewer syntetiserte + ground-truth-verifiserte de bærende funnene. Gemini-triangulering ikke kjørt (bridge nede — deprecated SDK, jf. memory `gemini-mcp-sdk-outage`). diff --git a/docs/innboks-ingestion-veivalg-2026-06.md b/docs/innboks-ingestion-veivalg-2026-06.md new file mode 100644 index 0000000..03e25fa --- /dev/null +++ b/docs/innboks-ingestion-veivalg-2026-06.md @@ -0,0 +1,106 @@ +# Innboks-ingestion — veivalg og posisjon (2026-06-26) + +> **Operatør-låst retning for okr-pluginens innboks-ingestion.** Avstemmer okr-STATE mot den +> cross-cutting convergence-briefen (`linkedin-studio/docs/okf-convergence-brief.md`). Self-bearing — +> leses ved oppstart av ingestion-løpet. **Teknisk grunnlag (web-verifisert):** +> `docs/innboks-ingestion-funn-2026-06.md`. Status-of-play: `STATE.md`. + +> **RATIFISERT 2026-06-29 (handoff `catalog/docs/okf-second-brain/handoff-2026-06-29.md` §3).** Den +> delte konvensjonen er nå kanonisert i `catalog/docs/okf-second-brain/spec.md` v0.1 (single source of +> truth, katalog-eid). Dette dokumentet **refererer** spec-en — det redefinerer den ikke. Innboks-løpet +> bygger mot spec §3 (minimal kontrakt = gulv, ikke tak) + §5 (extension keys). okr-bekreftelser (§4): +> `okf-check.mjs`-semantikken står som referansekontrakt (spec §7); okr bruker `resource` (ikke +> `source`); ingen feltgap (profil/config-nøkler = extension keys). Premiss-korreksjonene i §2 under er +> nå kanonisert i spec §9. + +## 1. Låste beslutninger (operatør, 2026-06-26) + +1. **Innboks-ingestion SKAL bygges for okr.** Ikke valgfritt, ikke «defer». Krever **grundig + planlegging** — eget Voyage-løp (`/trekbrief` → `/trekplan` → execute), egen minor-bump + (1.7.0-kandidat). Start når operatør sier fra (scope-guard). +2. **linkedin-studio er IKKE en mal for okr.** Deres second-brain har en *annen tilnærming* + (provenans-vektet læring, episodisk/semantisk split, evidens-terskel-promotering) som vi + **ikke kopierer**. okr bygger sin egen sti. +3. **Delt cross-repo OKF-skill: ikke nå.** Stage 3 (betinget) i convergence-briefen — utsatt. + okr-ingestion er okr-eid og ikke gated på en delt skill. + +## 2. Avstemming mot convergence-briefen (hva gjelder for okr) + +Convergence-briefen er linkedin-studio-sentrert og cross-cutting. For **okr** skiller vi +verifiserte tekniske fakta (beholdes) fra strategisk framing (avvises): + +**BEHOLD — verifiserte tekniske premisser (gjelder uansett strategi):** +- Google `knowledge-catalog` har **ingen** innboks→OKF-pipeline (funn §2, brief §2). Vi får spec + + byggeklosser, ikke en ferdig løype. +- `mdcode` er **ikke** et OKF-verktøy — det er Dataplex git-sync med et *annet* frontmatter-schema + (`id`/`resource.name`/`createTime`/`links`). **Ikke** planlegg `kcmd` til å emittere/synke OKF. +- Googles `reference_agent` ER en OKF-produsent, men leser **BigQuery + seed-URLer**, ikke en + dokumentmappe, og er Gemini/GCP-bundet. Gjenbrukbare (GCP-frie) deler: SPEC, emit/serialize/ + validate-kjernen, `index.md`-syntese. +- **Dokument-klassifisering/-konvertering av vilkårlige filer: Google gir INGENTING** — 100 % bygg + selv. +- OKF-relasjoner = **vanlige markdown-lenker i body** (ikke frontmatter-felt); konsumenter MÅ + tolerere brutte lenker (funn §4, brief §5). +- **OKF v0.1-spec finnes** på `okf/SPEC.md` (distinkt fra `mdcode`s «Metadata as Code» for Dataplex). + Kun `type` påkrevd; `index.md` reservert (ingen frontmatter); `okf_version` i rot-`index.md`. + *(NB: okr 1.6.0 pinnet bevisst til «Documents/kb Layout» og kalte det IKKE en formell standard — + ingestion-løpet bør re-verifisere `okf/SPEC.md` ved brief-tid og avgjøre hvor tett vi konformer.)* + +**AVVIS for okr — strategisk framing som IKKE styrer okr-ingestion:** +- «Defer auto-classify/convert; build only on demonstrated need» → **avvist** (beslutning 1: skal bygges). +- «linkedin-studio is the reference design; siblings rise to it» → **avvist for okr** (beslutning 2). +- «Inbox forblir en manuell drop-zone uten auto-klassifiserer» → det var linkedin-studios stance; + okr-målet er det motsatte: **auto-oppdage + konvertere + tilordne OKF**. + +## 3. Hva som faktisk skal bygges (gap, fra funn-grunnlaget §5) + +1. **Dokument-konvertering** — PDF/Word/e-post/tekst → markdown. Finnes ikke i Google-repoet + (`fileskb`/`md-fileset` antar markdown og er read-only). +2. **Konsept-ekstraksjon + frontmatter-tilordning** — splitte vilkårlige dokumenter til «konsepter», + sette `type`/`resource`/`title`/`description`/`tags`/`timestamp`. +3. **Generalisert relasjons-oppdagelse** — Googles relasjons-prompt er BQ-joins/web-spesifikk; må + generaliseres til vilkårlig dokumentkorpus (relasjoner som markdown-lenker i body). +4. **Innboks-orkestrering** — oppdage nye filer, **beholde original + plassere markdown-peker**, + idempotent re-kjøring, plassering i riktig nivå av treet, regenerere berørt `index.md`. + +## 4. Gjenbrukbart fra OKF-fasen (1.6.0 — bygger ikke fra null) + +- `scripts/okf-index.mjs` — kildeagnostisk, idempotent `index.md`-regen (speiler Googles + `regenerate_indexes`). Innboks-pipelinen kaller den etter skriv (som tre-skriverne i `oppsett.md`). +- `lib/frontmatter.mjs` (parse/skrive) + `scripts/okf-check.mjs` (validering) dekker skrive- og + verifiserings-siden. +- Googles `Source`-ABC viser et rent adapter-mønster — en «innboks-kilde» som lister konsepter fra en + mappe kan mate eksisterende skrive-/index-/kryss-lenke-maskineri. + +## 5. Åpne spørsmål til `/trekbrief` (avgjøres da, ikke nå) + +- **Konverterings-motor:** okr er i dag **zero-dependency Node ESM** (kun `node:`-builtins). PDF/Word→md + krever realistisk enten et eksternt verktøy (pandoc/libreoffice/markitdown) eller en avhengighet — + **bryter zero-dep-invarianten**. Dette er et reelt arkitekturvalg (dokumentert prerequisite à la + `export-pdf.py`/weasyprint? egen avhengighet? hvilke formater i v1?). +- **Konsept-ekstraksjon:** LLM-drevet (Claude i kommando) vs. heuristisk splitting — og hvor deterministisk. +- **Relasjons-oppdagelse:** hvor generalisert; hvordan holde testbart. +- **Innboks-plassering:** prosjekt-`.claude/okr/innboks/`? home? begge røtter? Original-bevaring + peker-layout. +- **Mål-format:** full OKF v0.1 (`okf/SPEC.md`) vs. dagens lettere «OKF-kompatible form» okr emitterer. +- **Idempotens + sikkerhet:** re-kjøring uten dubletter; ikke ødelegge brukerens originaler. + +## 6. Forhold til søsken-pluginer + +- **Delings-scope (operatør-låst 2026-06-29):** en eventuell delt Stage-3-skill deles mellom + **okr + ms-ai-architect** — **IKKE linkedin-studio**. linkedin-studio har sin egen mekanisme + (rikere ikke-OKF-brain, reference-design — «not levelled down to bare OKF», brief §1) og leveres + ikke ned til en delt skill. Låsen endrer kun *hvem* som deler, ikke *når*: Stage 3 er fortsatt + betinget på Stage-2-måling og utsatt. +- `ms-ai-architect` har samme oppgave (designet, ikke bygget). **Vurder felles abstraksjon før + dobbel-implementasjon** — men felles ≠ kopiere linkedin-studio. Eventuell deling skjer på okr-egne + premisser. +- En delt OKF-**spec** (convergence-briefens Stage 1, katalog-nivå) er grei interop og ikke i konflikt + med dette løpet; okr-ingestion er ikke blokkert på den. + +## 7. Kilder + +- **Delt konvensjon (single source of truth):** `catalog/docs/okf-second-brain/spec.md` v0.1 + koordinerings-`log.md` + `handoff-2026-06-29.md` (samme mappe, katalog-repo). +- `docs/innboks-ingestion-funn-2026-06.md` — web-verifisert Google-repo-analyse (kilder i §9 der). +- `linkedin-studio/docs/okf-convergence-brief.md` — cross-cutting convergence (avstemt over; nå foldet inn i spec-en). +- OKF v0.1: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md`. +- Leveranse vi bygger på: okr 1.6.0 (`scripts/okf-*`, `lib/frontmatter.mjs`, skill `okr-second-brain-search`). diff --git a/docs/okf-second-brain-note-2026-06.md b/docs/okf-second-brain-note-2026-06.md index dd4886c..c82c662 100644 --- a/docs/okf-second-brain-note-2026-06.md +++ b/docs/okf-second-brain-note-2026-06.md @@ -2,6 +2,8 @@ _Notert 2026-06-26. **Fremtidig initiativ — IKKE implementer før plan er laget og godkjent.** Dette notatet er grunnlagsmateriale for neste sesjons `/trekbrief` → `/trekplan`. Bygger på (a) verifisert lesning av OKF SPEC v0.1 og (b) det ferdige referansedesignet i søsken-pluginen `ms-ai-architect` (`docs/okf-second-brain-brief-2026-06.md`, operatør-bekreftet 2026-06-26). State-of-play i `STATE.md`._ +> **Oppdatert 2026-06-29:** konvensjonen er nå kanonisert i `catalog/docs/okf-second-brain/spec.md` (v0.1, single source of truth, katalog-eid). Denne noten er **design-historikk/grunnlag** — referer spec-en for den normative kontrakten, ikke denne. + ## Hva oppgaven er (operatør 2026-06-26) Adopter **Google Open Knowledge Format (OKF)** som formatet brukeren lagrer sin egen kontekst i — en bruker-eid «LLM-wiki» / «second brain» **utenfor** pluginen. Brukeren legger inn så mye org-/strategisk kontekst som ønskes; **deler av den injiseres/hentes smart** når okr-pluginen brukes — både i fri **chat** (plugin lastet) og når `/okr:*`-kommandoer kjøres. Speiler arbeidet som nå designes i `ms-ai-architect`. diff --git a/lib/frontmatter.mjs b/lib/frontmatter.mjs index 58d07ba..1acb336 100644 --- a/lib/frontmatter.mjs +++ b/lib/frontmatter.mjs @@ -41,14 +41,26 @@ export function parseFrontmatter(content) { return { raw, get }; } +// Siter naar verdien inneholder '#'/':' eller har kant-whitespace, slik at +// round-trip via parseFrontmatter bevarer den eksakt (jf. siter-#-regelen). +const quoteIfNeeded = (s) => + /[#:]/.test(s) || /^\s|\s$/.test(s) || /^["']/.test(s) ? JSON.stringify(s) : s; + export function writeFrontmatter(fields) { const lines = ['---']; for (const [key, value] of Object.entries(fields)) { - const s = String(value); - // Siter naar verdien inneholder '#'/':' eller har kant-whitespace, slik at - // round-trip via parseFrontmatter bevarer den eksakt (jf. siter-#-regelen). - const needsQuote = /[#:]/.test(s) || /^\s|\s$/.test(s) || /^["']/.test(s); - lines.push(`${key}: ${needsQuote ? JSON.stringify(s) : s}`); + // Additiv array-gren (Step 3): emit OKF multi-linje list-verdi (f.eks. + // `tags`) som `key:\n - item`. Lese-siden er uendret -- parseFrontmatter. + // get() returnerer fortsatt null for list-keys. Skalar-grenen under er + // bevart bit-for-bit (kun loftet ut til quoteIfNeeded, samme regel). + if (Array.isArray(value)) { + lines.push(`${key}:`); + for (const item of value) { + lines.push(` - ${quoteIfNeeded(String(item))}`); + } + continue; + } + lines.push(`${key}: ${quoteIfNeeded(String(value))}`); } lines.push('---'); return lines.join('\n') + '\n'; diff --git a/lib/innboks-frontmatter.mjs b/lib/innboks-frontmatter.mjs new file mode 100644 index 0000000..5bd8f2f --- /dev/null +++ b/lib/innboks-frontmatter.mjs @@ -0,0 +1,75 @@ +// innboks-frontmatter.mjs +// Step 5: deterministisk frontmatter-projeksjon for et konsept (fra splitConcepts). +// Regel-basert (INGEN LLM): type/tags utledes fra title+sti og snappes mot det +// lukkede vokabularet (okf-vocab); resource = original relativ sti (kanonisk navn); +// description = foerste ikke-tomme avsnitt (whitespace kollapset, trunkert); +// timestamp = ISO-8601 av original-fil-mtime (IKKE veggklokke -> idempotens by +// construction per maskin); kilde:innboks provenans-markoer (extension key for +// retrieval-rangering). Beregner ogsaa konseptets mal-sti +// (concept.destRel = routeLevel(type)/slug.md) FOER relasjons-steget (Step 6), +// jf. Pass-2 ordering-fiks (Revisions #21). Serialiserer via writeFrontmatter +// (additiv array-gren fra Step 3). Zero npm dependencies. + +import { writeFrontmatter } from './frontmatter.mjs'; +import { snapType, snapTags, routeLevel, TYPE_VOCAB, TAGS_VOCAB } from './okf-vocab.mjs'; + +const DESCRIPTION_MAX = 240; + +// Vokab sortert lengst-foerst saa "Overordnede OKR" matcher foer "OKR". +const TYPE_BY_LENGTH = [...TYPE_VOCAB].sort((a, b) => b.length - a.length); + +// Regel-utledning: foerste vokab-term som forekommer i title+sti -> kanonisk type. +function deriveType(title, sourcePath) { + const hay = `${title} ${sourcePath}`.toLowerCase(); + for (const t of TYPE_BY_LENGTH) { + if (hay.includes(t.toLowerCase())) return snapType(t); + } + return snapType(''); +} + +// Tags: vokab-termer som forekommer i title (vokab-rekkefolge bevart), saa snappet. +function deriveTags(title) { + const hay = String(title).toLowerCase(); + return snapTags(TAGS_VOCAB.filter((t) => hay.includes(t.toLowerCase()))); +} + +// Foerste ikke-tomme avsnitt, whitespace kollapset, trunkert deterministisk. +function deriveDescription(body) { + const para = String(body) + .split(/\n\s*\n/) + .map((p) => p.replace(/\s+/g, ' ').trim()) + .find((p) => p !== ''); + if (!para) return ''; + return para.length > DESCRIPTION_MAX ? `${para.slice(0, DESCRIPTION_MAX).trimEnd()}...` : para; +} + +// concept (fra splitConcepts) -> beriket konsept med OKF-frontmatter + destRel. +export function projectFrontmatter(concept, { sourcePath, sourceMtime } = {}) { + const resource = String(sourcePath ?? ''); + const type = deriveType(concept.title, resource); + const description = deriveDescription(concept.body); + const tags = deriveTags(concept.title); + const timestamp = new Date(sourceMtime).toISOString(); + const destRel = `${routeLevel(type)}/${concept.slug}.md`; + + // Kanonisk noekkel-rekkefolge; description/tags utelates naar tomme. + const fields = { type, resource, title: concept.title }; + if (description) fields.description = description; + if (tags.length > 0) fields.tags = tags; + fields.timestamp = timestamp; + fields.kilde = 'innboks'; + + const frontmatter = writeFrontmatter(fields); + + return { + ...concept, + type, + resource, + description, + tags, + timestamp, + kilde: 'innboks', + destRel, + frontmatter, + }; +} diff --git a/lib/innboks-relations.mjs b/lib/innboks-relations.mjs new file mode 100644 index 0000000..92d86fc --- /dev/null +++ b/lib/innboks-relations.mjs @@ -0,0 +1,49 @@ +// innboks-relations.mjs +// Step 6: deterministisk relasjons-resolusjon innen KJOERINGENS konsept-sett +// (lukket lenke-vokabular). For hvert konsept oppdages referanser til ANDRE +// emitterte konsepter (exact-title substring, case-insensitivt) og emitteres som +// leading-'/' bundle-root-lenker bygd paa malet-konseptets destRel (satt i Step 5, +// kjent FOER skriv -- Pass-2 ordering-fiks). Asserterer zero-dangling mot de +// emitterte destRel-stiene og at hver generert lenke er trygg (delt okf-links- +// allow-list). v1-narrowing: kun innen-kjoering-settet -- lenking til pre- +// eksisterende tre-konsepter er v1.1. Zero npm dependencies. + +import { isSafeBundleLink } from './okf-links.mjs'; + +const REL_HEADING = '## Relaterte dokumenter'; + +// concepts (beriket av projectFrontmatter, m/ destRel) -> samme konsepter m/ +// relations[] + body utvidet med en deterministisk relasjons-seksjon. +export function resolveRelations(concepts) { + const emitted = new Set(concepts.map((c) => c.destRel)); + + return concepts.map((concept) => { + const haystack = `${concept.title}\n${concept.body}`.toLowerCase(); + const found = new Map(); // target -> { title, target, destRel } + + for (const other of concepts) { + if (other === concept) continue; + if (!other.title || !other.destRel) continue; + if (!haystack.includes(other.title.toLowerCase())) continue; + + const target = `/${other.destRel}`; + if (!isSafeBundleLink(target)) { + throw new Error(`innboks-relations: utrygg generert lenke ${target}`); + } + if (!emitted.has(other.destRel)) { + throw new Error(`innboks-relations: dangling relasjon ${other.destRel}`); + } + found.set(target, { title: other.title, target, destRel: other.destRel }); + } + + const relations = [...found.values()].sort((a, b) => a.target.localeCompare(b.target)); + + let body = concept.body; + if (relations.length > 0) { + const links = relations.map((r) => `- [${r.title}](${r.target})`).join('\n'); + body = `${concept.body}\n\n${REL_HEADING}\n\n${links}\n`; + } + + return { ...concept, relations, body }; + }); +} diff --git a/lib/innboks-split.mjs b/lib/innboks-split.mjs new file mode 100644 index 0000000..e44e255 --- /dev/null +++ b/lib/innboks-split.mjs @@ -0,0 +1,96 @@ +// innboks-split.mjs +// Step 4: deterministisk heading-split av (allerede konvertert) markdown til +// konsepter. REN funksjon -- fil-settet er en funksjon av input alene (INGEN +// LLM, ingen veggklokke), saa run1 og run2 gir byte-identisk resultat (SC +// idempotens by construction). Split paa #/## (MarkdownHeaderTextSplitter- +// moenster): hver #/##-heading starter et nytt konsept; ### og dypere forblir +// body-innhold. Dokument uten brukbare overskrifter -> fallback "ett dokument = +// ett konsept". Innhold foer foerste heading bevares som ledende konsept (ingen +// datatap). Zero npm dependencies. + +// Kebab-slug: lowercase, strip diakritika defensivt, ikke-alfanum -> '-', trim. +// Never-empty fallback 'konsept' (deterministisk; aldri tomt filnavn). +function slugify(text) { + const s = String(text) + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return s || 'konsept'; +} + +// #/## heading-linje -> { level, title }. ### og dypere matcher IKKE: '#{1,2}' +// kan ikke etterfoelges av '\s+' naar tredje tegn er '#', saa de forblir body. +const HEADING_RE = /^(#{1,2})\s+(.+?)\s*$/; + +// markdown -> Concept[]; opts.sourceSlug identifiserer kilde-dokumentet (brukt +// som tittel/slug for flat-fallback + preamble). Concept-form: +// { sourceSlug, title, slug, level, body } +export function splitConcepts(markdown, { sourceSlug } = {}) { + const src = + typeof sourceSlug === 'string' && sourceSlug.trim() !== '' ? sourceSlug : 'konsept'; + const normalized = String(markdown).replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + + // Segmenter: ett per #/##-heading. Linjer foer foerste heading -> preamble. + const segments = []; + const preambleLines = []; + let current = null; + for (const line of lines) { + const m = line.match(HEADING_RE); + if (m) { + current = { title: m[2].trim(), level: m[1].length, lines: [] }; + segments.push(current); + } else if (current) { + current.lines.push(line); + } else { + preambleLines.push(line); + } + } + + const concepts = []; + const seen = new Map(); + const uniqueSlug = (base) => { + const n = seen.get(base) || 0; + seen.set(base, n + 1); + return n === 0 ? base : `${base}-${n + 1}`; + }; + + // Reelt innhold foer foerste heading -> ledende konsept (titulert av kilde). + if (preambleLines.join('').trim() !== '') { + concepts.push({ + sourceSlug: src, + title: src, + slug: uniqueSlug(slugify(src)), + level: 1, + body: preambleLines.join('\n').trim(), + }); + } + + // Flat dokument (ingen #/##): hele dokumentet = ett konsept. + if (segments.length === 0) { + if (concepts.length === 0) { + concepts.push({ + sourceSlug: src, + title: src, + slug: uniqueSlug(slugify(src)), + level: 1, + body: normalized.trim(), + }); + } + return concepts; + } + + for (const seg of segments) { + concepts.push({ + sourceSlug: src, + title: seg.title, + slug: uniqueSlug(slugify(seg.title)), + level: seg.level, + body: seg.lines.join('\n').trim(), + }); + } + + return concepts; +} diff --git a/lib/innboks-write.mjs b/lib/innboks-write.mjs new file mode 100644 index 0000000..8a36f17 --- /dev/null +++ b/lib/innboks-write.mjs @@ -0,0 +1,122 @@ +// innboks-write.mjs +// Step 7: persister berikede konsepter (splitConcepts -> projectFrontmatter -> +// resolveRelations) inn i prosjekt-bundlen, ikke-destruktivt og idempotent. +// +// (1) Hvert konsept skrives til sin FORHAANDSBEREGNEDE concept.destRel (rutet +// via routeLevel i Step 5 -- skriveren ruter ikke selv). Skrevet fil = +// concept.frontmatter (ferdig OKF-blokk) + concept.body (baerer allerede +// relasjons-seksjonen) verbatim -- INGEN re-serialisering. Trailing newline +// garanteres (POSIX-ren, deterministisk -> byte-idempotent re-kjoering). +// (2) Atomisk skriv: temp i SAMME katalog + renameSync over maalet (en krasj +// midt i skriv kan aldri etterlate en halv-skrevet fil). +// (3) Original-bevaring (SC3, ikke-destruktiv): skriveren roerer ALDRI +// drop-zone-originalene -- den asserterer kun at de finnes (saa pekeren +// aldri dangler). Per original skrives EN markdown-peker i konseptets nivaa +// som lenker bundle-root-relativt til originalen i .claude/okr/innboks/. +// (4) Path-confinement (innboks-dok = fiendtlig, RAG-poisoning): hver maal-sti +// maa resolvere UNDER bundle-rota (avvis '..'-escape / absolutt-override), +// og bundle-rota selv maa ikke vaere den home-kanoniske org-profilen +// (~/.claude/okr/org) -- ingestion skriver kun i prosjekt-bundlen. +// +// Reuses: atomisk-skriv-moenster (scripts/write-org-profile.mjs:34-40); +// writeFrontmatter (lib/frontmatter.mjs) for peker-frontmatter. Zero npm deps. + +import { writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { homedir } from 'node:os'; + +import { writeFrontmatter } from './frontmatter.mjs'; + +// Den home-kanoniske org-profil-rota. Ingestion skal ALDRI skrive hit (den eies +// av write-org-profile.mjs); prosjekt-bundlen er .claude/okr under cwd. +const HOME_ORG = path.join(homedir(), '.claude', 'okr', 'org'); + +// Atomisk: temp-fil i samme katalog, deretter renameSync over maalet (atomisk +// paa samme filsystem). Speiler write-org-profile.mjs:34-40. +function writeAtomic(target, data) { + const dir = path.dirname(target); + mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `${path.basename(target)}.${process.pid}.tmp`); + writeFileSync(tmp, data); + renameSync(tmp, target); +} + +// Resolver en bundle-relativ sti og asserter at den blir UNDER bundle-rota. +// Avviser '..'-escape og absolutt-override (path.resolve lar en absolutt rel +// vinne -- containment-sjekken fanger det). +function resolveUnderBundle(resolvedBundle, rel) { + const resolved = path.resolve(resolvedBundle, rel); + if (resolved !== resolvedBundle && !resolved.startsWith(resolvedBundle + path.sep)) { + throw new Error(`innboks-write: maal-sti utenfor bundle-rot avvist: ${rel}`); + } + return resolved; +} + +// Skrevet fil = frontmatter + body verbatim, med garantert trailing newline. +function fileContent(concept) { + const out = `${concept.frontmatter}${concept.body}`; + return out.endsWith('\n') ? out : `${out}\n`; +} + +// Peker-fil: minimal gyldig OKF-fil (type i lukket vokab) som lenker til den +// bevarte originalen. resource = bundle-relativ original-sti (uten leading '/'). +function pointerContent(original, link) { + const basename = path.basename(original.path); + const frontmatter = writeFrontmatter({ + type: 'Notat', + resource: link.replace(/^\/+/, ''), + title: `Kilde: ${basename}`, + kilde: 'innboks', + }); + const body = `Peker til bevart original i drop-zonen (ikke-destruktiv ingestion).\n\n- [${basename}](${link})\n`; + return `${frontmatter}${body}`; +} + +// concepts (berikede, m/ destRel + frontmatter + body) + { bundleRoot, originals } +// -> skriver konsept-filer + peker-filer atomisk under bundle-rota. +// originals: [{ sourceSlug, path }] -- path = originalens plassering i drop-zonen. +// Returnerer { concepts: [skrevne konsept-stier], pointers: [skrevne peker-stier] } +// (absolutte stier; pipelinen (Step 10) bruker dette til discard-on-fail rollback). +export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) { + if (!bundleRoot) throw new Error('innboks-write: bundleRoot kreves'); + const resolvedBundle = path.resolve(bundleRoot); + + // Confinement: avvis skriv til home-org-rota -- ingestion eier kun prosjekt-bundlen. + if (resolvedBundle === HOME_ORG || resolvedBundle.startsWith(HOME_ORG + path.sep)) { + throw new Error(`innboks-write: skriv til home-org-rot avvist: ${bundleRoot}`); + } + + const writtenConcepts = []; + for (const concept of concepts) { + if (!concept.destRel) { + throw new Error(`innboks-write: konsept mangler destRel: ${concept.slug ?? '?'}`); + } + const target = resolveUnderBundle(resolvedBundle, concept.destRel); + writeAtomic(target, fileContent(concept)); + writtenConcepts.push(target); + } + + const writtenPointers = []; + for (const original of originals) { + const resolvedOriginal = path.resolve(original.path); + if (!existsSync(resolvedOriginal)) { + throw new Error(`innboks-write: original mangler (peker ville dangle): ${original.path}`); + } + // Original maa ligge under bundle-rota (drop-zonen .claude/okr/innboks/). + const relToBundle = path.relative(resolvedBundle, resolvedOriginal); + if (relToBundle === '' || relToBundle.startsWith('..') || path.isAbsolute(relToBundle)) { + throw new Error(`innboks-write: original utenfor bundle-rot: ${original.path}`); + } + const link = `/${relToBundle.split(path.sep).join('/')}`; + + // Nivaa = nivaaet til foerste konsept fra samme kilde; default dokumenter/. + const sibling = concepts.find((c) => c.sourceSlug === original.sourceSlug && c.destRel); + const level = sibling ? path.dirname(sibling.destRel) : 'dokumenter'; + const pointerRel = path.join(level, `${original.sourceSlug}.kilde.md`); + const pointerTarget = resolveUnderBundle(resolvedBundle, pointerRel); + writeAtomic(pointerTarget, pointerContent(original, link)); + writtenPointers.push(pointerTarget); + } + + return { concepts: writtenConcepts, pointers: writtenPointers }; +} diff --git a/lib/okf-links.mjs b/lib/okf-links.mjs new file mode 100644 index 0000000..5c45c94 --- /dev/null +++ b/lib/okf-links.mjs @@ -0,0 +1,39 @@ +// okf-links.mjs +// Delt bundle-root-relativ lenke-resolver (Step 6), brukt av BAADE relasjons- +// emitteringen (innboks-relations) og okf-check++ --strict-ingest (Step 8). +// Konvensjon: en trygg bundle-lenke er ROOT-RELATIV med leading '/' (resolveres +// mot bundle-rota, jf. SC-regex \]\(/.*\.md\)). Avviser '../'-escape, absolutt +// Windows-sti (backslash), og scheme-lenker (file://, http(s)://). Den ENESTE +// lenke-allow-listen -- delt slik at emit og validering aldri divergerer. +// Zero npm dependencies. + +import path from 'node:path'; + +// Scheme-prefiks (file://, http://, https://, ...) -- avvises uansett. +const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i; +// '..'-segment hvor som helst i stien (escape ut av bundle-rota). +const PARENT_SEGMENT_RE = /(^|\/)\.\.(\/|$)/; + +// Sann hvis target er en trygg, bundle-root-relativ .md-lenke (leading '/'). +export function isSafeBundleLink(target) { + if (typeof target !== 'string' || target === '') return false; + if (target.includes('\0')) return false; // null-byte + if (target.includes('\\')) return false; // backslash (Windows-sti) + if (SCHEME_RE.test(target)) return false; // file://, http(s)://, ... + if (!target.startsWith('/')) return false; // maa vaere bundle-root-relativ + if (PARENT_SEGMENT_RE.test(target)) return false; // ingen '..'-escape + if (!target.endsWith('.md')) return false; // bundle-konseptlenker er .md + return true; +} + +// Resolverer en trygg bundle-lenke til absolutt sti UNDER bundleRoot, ellers null. +// Belt-and-suspenders: isSafeBundleLink avviser '..' allerede, men confinement +// re-sjekkes paa den resolverte stien. +export function resolveBundleLink(target, bundleRoot) { + if (!isSafeBundleLink(target)) return null; + const rel = target.replace(/^\/+/, ''); + const root = path.resolve(bundleRoot); + const resolved = path.resolve(root, rel); + if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; + return resolved; +} diff --git a/lib/okf-vocab.mjs b/lib/okf-vocab.mjs new file mode 100644 index 0000000..7798f87 --- /dev/null +++ b/lib/okf-vocab.mjs @@ -0,0 +1,73 @@ +// okf-vocab.mjs +// Lukket kontrollert vokabular for innboks-ingestion (type + tags) og +// type->nivaa-ruting. Zero npm dependencies. +// +// Net-new (premiss #1): handheves KUN paa ingestion-skrivestien (snapType/ +// snapTags) og av okf-check++ --strict-ingest. Lese-siden (okr-second-brain- +// search, okf-check default) behandler fortsatt ukjente typer som gyldige -- +// denne modulen er den ENESTE strenge porten, aktiv kun ved ingestion. + +// Title-Case kanoniske OKF-typer (jf. catalog-spec Documents/kb Layout). +export const TYPE_VOCAB = [ + 'Organisasjonsprofil', + 'Tildelingsbrev', + 'Virksomhetsplan', + 'Overordnede OKR', + 'OKR', + 'Retrospektiv', + 'Status', + 'Notat', + 'Dokument', +]; + +// Safe default naar raw type ikke matcher vokabularet exact (premiss #1). +const DEFAULT_TYPE = 'Dokument'; + +// Lukket start-sett av Title-Case tags for ingestion-snap. +export const TAGS_VOCAB = [ + 'Strategi', + 'Tildelingsbrev', + 'Virksomhetsplan', + 'OKR', + 'Styring', + 'Governance', + 'Retrospektiv', + 'Status', + 'Maal', + 'Risiko', +]; + +// type -> bundle-nivaa (katalog under bundle-rot). Organisasjonsprofil rutes +// til dokumenter/ (IKKE strategisk-kontekst/) for aa ikke konkurrere med den +// home-kanoniske ~/.claude/okr/org/profil.md (Revisions #25). +const LEVEL_BY_TYPE = { + Tildelingsbrev: 'strategisk-kontekst', + Virksomhetsplan: 'strategisk-kontekst', + 'Overordnede OKR': 'strategisk-kontekst', + Retrospektiv: 'historikk', + OKR: 'dokumenter', + Status: 'dokumenter', + Notat: 'dokumenter', + Dokument: 'dokumenter', + Organisasjonsprofil: 'dokumenter', +}; + +const DEFAULT_LEVEL = 'dokumenter'; + +// Exact-match -> kanonisk type; ellers safe default Dokument. +export function snapType(raw) { + return TYPE_VOCAB.includes(raw) ? raw : DEFAULT_TYPE; +} + +// Behold kun tags i det lukkede vokabularet (dropp ukjente); bevar rekkefolge. +export function snapTags(rawList) { + if (!Array.isArray(rawList)) return []; + return rawList.filter((t) => TAGS_VOCAB.includes(t)); +} + +// type -> bundle-nivaa; ukjent/ikke-vokab -> default dokumenter/. Plassert her +// i Session 1 slik at relasjons-steget (Step 6) kan resolvere konseptets +// mal-sti FOR skriv (Pass-2 ordering-fiks, Revisions #21). +export function routeLevel(type) { + return LEVEL_BY_TYPE[type] ?? DEFAULT_LEVEL; +} diff --git a/scripts/okf-check.mjs b/scripts/okf-check.mjs index 57a33e0..877c720 100644 --- a/scripts/okf-check.mjs +++ b/scripts/okf-check.mjs @@ -15,17 +15,34 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseFrontmatter } from '../lib/frontmatter.mjs'; +import { TYPE_VOCAB } from '../lib/okf-vocab.mjs'; +import { resolveBundleLink } from '../lib/okf-links.mjs'; const RECOMMENDED = ['resource', 'title', 'description', 'timestamp']; +// Markdown-lenker [tekst](maal) -- brukt av --strict-ingest til aa validere at +// hvert lenke-maal er en trygg, on-disk bundle-root-relativ .md (anti-RAG-poison). +const MD_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g; +// En title som selv baerer en markdown-lenke er en injeksjons-vektor -> avvises. +const TITLE_LINK_RE = /\[[^\]]*\]\([^)]*\)/; + +// Drop-zone (raa innboks-filer) + skjulte kataloger (.cache osv.) skannes ALDRI: +// raa, ukonverterte og potensielt fiendtlige filer er ikke konsepter. +function isWalkableDir(name) { + return !name.startsWith('.') && name !== 'innboks'; +} + // Alle konsept-filer (.md unntatt index.md) under root, rekursivt. function walkConcepts(root) { const out = []; const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') out.push(p); + if (e.isDirectory()) { + if (isWalkableDir(e.name)) walk(p); + } else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') { + out.push(p); + } } }; walk(root); @@ -40,17 +57,41 @@ function rootOkfVersion(root) { return m ? m[1].trim() : null; } -export function checkBundle(root) { +// strictIngest (default AV): paa skrivestien handheves det lukkede vokabularet +// + lenke-allow-listen. Lese-siden (default) forblir tolerant (exit 0/1 kun paa +// manglende type) -- denne porten er aktiv KUN ved ingestion (--strict-ingest). +export function checkBundle(root, { strictIngest = false } = {}) { const concepts = walkConcepts(root); const missingType = []; const warnings = []; + const strictErrors = []; for (const f of concepts) { - const { get } = parseFrontmatter(readFileSync(f, 'utf8')); + const raw = readFileSync(f, 'utf8'); + const { get } = parseFrontmatter(raw); const rel = relative(root, f); - if (!get('type')) { + const type = get('type'); + if (!type) { missingType.push(rel); continue; } + if (strictIngest) { + // 1. type maa vaere i det lukkede ingestion-vokabularet. + if (!TYPE_VOCAB.includes(type)) { + strictErrors.push(`${rel}: type «${type}» utenfor ingestion-vokabular`); + } + // 2. en title som baerer en markdown-lenke er en injeksjons-vektor. + const title = get('title'); + if (title && TITLE_LINK_RE.test(title)) { + strictErrors.push(`${rel}: lenke-baerende title «${title}»`); + } + // 3. hver markdown-lenke maa resolvere til en trygg, on-disk bundle-fil. + for (const m of raw.matchAll(MD_LINK_RE)) { + const target = m[1]; + const resolved = resolveBundleLink(target, root); + if (!resolved) strictErrors.push(`${rel}: utrygg lenke ${target}`); + else if (!existsSync(resolved)) strictErrors.push(`${rel}: dangling lenke ${target}`); + } + } for (const field of RECOMMENDED) { if (!get(field)) warnings.push(`${rel}: mangler anbefalt felt «${field}»`); } @@ -59,6 +100,8 @@ export function checkBundle(root) { scanned: concepts.length, missingType, warnings, + strictErrors, + strictIngest, okfVersion: rootOkfVersion(root), }; } @@ -67,25 +110,36 @@ export function checkBundle(root) { const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { - const root = process.argv[2]; + const args = process.argv.slice(2); + const strictIngest = args.includes('--strict-ingest'); + const root = args.find((a) => !a.startsWith('--')); if (!root) { - process.stderr.write('Bruk: node okf-check.mjs \n'); + process.stderr.write('Bruk: node okf-check.mjs [--strict-ingest]\n'); process.exit(2); } if (!existsSync(root)) { process.stderr.write(`Bundle-rot finnes ikke: ${root}\n`); process.exit(2); } - const r = checkBundle(root); + const r = checkBundle(root, { strictIngest }); const out = []; - out.push(`OKF-sjekk: ${root}`); + out.push(`OKF-sjekk: ${root}${strictIngest ? ' (strict-ingest)' : ''}`); out.push(` Konsept-filer skannet: ${r.scanned}`); out.push(` ${r.missingType.length} filer uten type:`); for (const f of r.missingType) out.push(` - ${f}`); + if (strictIngest) { + out.push(` ${r.strictErrors.length} strict-ingest-feil:`); + for (const e of r.strictErrors) out.push(` x ${e}`); + } out.push(` okf_version: ${r.okfVersion || 'MANGLER (rot-index uten okf_version)'}`); out.push(` Advarsler (anbefalte felt): ${r.warnings.length}`); for (const w of r.warnings) out.push(` ! ${w}`); - out.push(r.missingType.length === 0 ? 'OK: gyldig OKF-bundle' : `FEIL: ${r.missingType.length} fil(er) mangler type:`); + const failed = r.missingType.length > 0 || (strictIngest && r.strictErrors.length > 0); + let verdict; + if (!failed) verdict = 'OK: gyldig OKF-bundle'; + else if (r.missingType.length > 0) verdict = `FEIL: ${r.missingType.length} fil(er) mangler type:`; + else verdict = `FEIL: ${r.strictErrors.length} strict-ingest-feil`; + out.push(verdict); process.stdout.write(`${out.join('\n')}\n`); - process.exit(r.missingType.length === 0 ? 0 : 1); + process.exit(failed ? 1 : 0); } diff --git a/scripts/okf-index.mjs b/scripts/okf-index.mjs index 81f5a61..5a79657 100644 --- a/scripts/okf-index.mjs +++ b/scripts/okf-index.mjs @@ -26,6 +26,13 @@ import { parseFrontmatter } from '../lib/frontmatter.mjs'; export const OKF_VERSION = 'kb-layout-2026-06'; +// Drop-zone (raa innboks-filer) + skjulte kataloger (.cache osv.) er ikke nivaaer: +// de skal verken faa egen index.md eller listes som underkatalog-peker. Maa +// filtreres i BEGGE enumererings-steder (subdir-listing + rekursjon). +function isWalkableDir(name) { + return !name.startsWith('.') && name !== 'innboks'; +} + // "strategisk-kontekst" -> "Strategisk kontekst" function titleFromName(name) { const spaced = name.replace(/[-_]+/g, ' ').trim(); @@ -49,16 +56,37 @@ function parseExistingIndex(path) { return result; } -// Bygg en enkelt entry-linje paa OKF-form. Tom beskrivelse -> dropp ` - d`. +// Saner en frontmatter-avledet tittel/beskrivelse for trygg, idempotent emit: +// noytraliser markdown-lenker (RAG-injeksjon), strip kontrolltegn + strooe ]/) +// som ville korrumpert round-trip-parsen (parseExistingIndex), kollaps whitespace, +// og cap lengden. Idempotent: sanitizeEntry(sanitizeEntry(x)) === sanitizeEntry(x). +function sanitizeEntry(s) { + if (!s) return ''; + return String(s) + .replace(/[\x00-\x1f\x7f]/g, ' ') // kontrolltegn -> mellomrom + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // noytraliser markdown-lenker (behold tekst) + .replace(/[\])]/g, '') // strip strooe ] ) som brekker round-trip + .replace(/\s+/g, ' ') + .trim() + .slice(0, 200); +} + +// Bygg en enkelt entry-linje paa OKF-form. Tittel/beskrivelse saneres (link +// forblir uroert -- kontrollert filnavn). Tom beskrivelse -> dropp ` - d`. function entryLine(title, link, desc) { - return desc ? `* [${title}](${link}) - ${desc}` : `* [${title}](${link})`; + const t = sanitizeEntry(title); + const d = sanitizeEntry(desc); + return d ? `* [${t}](${link}) - ${d}` : `* [${t}](${link})`; } // Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer okf_version. function writeIndexFor(dir, isRoot, okfVersion) { const existing = parseExistingIndex(join(dir, 'index.md')); const dirents = readdirSync(dir, { withFileTypes: true }); - const subdirs = dirents.filter((e) => e.isDirectory()).map((e) => e.name).sort(); + const subdirs = dirents + .filter((e) => e.isDirectory() && isWalkableDir(e.name)) + .map((e) => e.name) + .sort(); const concepts = dirents .filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') .map((e) => e.name) @@ -95,7 +123,7 @@ export function generateIndexes(root, opts = {}) { const walk = (dir, isRoot) => { writeIndexFor(dir, isRoot, okfVersion); for (const e of readdirSync(dir, { withFileTypes: true })) { - if (e.isDirectory()) walk(join(dir, e.name), false); + if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false); } }; if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`); diff --git a/skills/okr-offentlig-sektor/SKILL.md b/skills/okr-offentlig-sektor/SKILL.md index 0e6f87e..1317da7 100644 --- a/skills/okr-offentlig-sektor/SKILL.md +++ b/skills/okr-offentlig-sektor/SKILL.md @@ -2,7 +2,7 @@ name: okr-offentlig-sektor description: >- OKR (Objectives and Key Results) for Norwegian public sector: writing OKR, reviewing OKR quality, cascading OKR from strategy to team, tracking progress, running OKR meetings, translating tildelingsbrev to OKR. Also CFR, OKR antipatterns, scoring, Oboard. Triggers on: "OKR", "skriv OKR", "vurder OKR", "OKR-scoring", "kaskadere OKR", "tildelingsbrev til OKR", "OKR for offentlig sektor". -version: "1.6.0" +version: "1.6.1" --- # OKR Skill for Offentlig Sektor (Norge) @@ -29,16 +29,16 @@ Objective: [Verb] + [clear outcome/improvement] **Example**: ``` Objective: Forbedre trafikksikkerhet i skolesoner - KR1: Redusere ulykker i skolesoner med 25% (fra 40 til 30 per ar) - KR2: 100% av hoyrisiko-skolesoner far nye fartshumper innen august - KR3: 90% av foreldre vurderer skolesoner som trygge (via sporreundersokelse) + KR1: Redusere ulykker i skolesoner med 25% (fra 40 til 30 per år) + KR2: 100% av høyrisiko-skolesoner får nye fartshumper innen august + KR3: 90% av foreldre vurderer skolesoner som trygge (via spørreundersøkelse) ``` ### 2. Review OKR Quality When users present existing OKR, evaluate against these criteria and provide concrete rewrites: - **Good**: Outcome-focused, measurable, ambitious but achievable, clear strategy link -- **Common errors**: Activity-oriented ("Gjennomfore 5 moter"), vague ("Forbedre kundeservice"), sandbagging, not measurable, no link to higher goals +- **Common errors**: Activity-oriented ("Gjennomføre 5 møter"), vague ("Forbedre kundeservice"), sandbagging, not measurable, no link to higher goals ### 3. Track Progress diff --git a/skills/okr-offentlig-sektor/references/okr-oboard-guide.md b/skills/okr-offentlig-sektor/references/okr-oboard-guide.md index 83d68d9..24aa218 100644 --- a/skills/okr-offentlig-sektor/references/okr-oboard-guide.md +++ b/skills/okr-offentlig-sektor/references/okr-oboard-guide.md @@ -92,7 +92,7 @@ Key Results: ├─ KR1: Reduser ulykker med personskade │ ├─ Start: 45 ulykker/år │ ├─ Target: 25 ulykker/år -│ ├─ Current: 32 ulykker (etter 2 mnd) [Progress: 52%] +│ ├─ Current: 32 ulykker (etter 2 mnd) [Progress: 65%] │ ├─ Confidence: 🟡 At Risk │ └─ Owner: Lena Hansen │ diff --git a/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md b/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md index 0b28f2d..81325d1 100644 --- a/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md +++ b/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md @@ -1,105 +1,105 @@ # OKR-kvalitetsrubrikker — ankret scoringsguide -Single sannhetskilde for OKR-kvalitetsvurdering i norsk offentlig sektor (Google/Doerr-metodikk tilpasset tertialsyklus). Hver kvalitetsdimensjon har **fem ankere** fra svakest til sterkest med konkret nivaabeskrivelse, slik at scoring blir deterministisk og reproduserbar paa tvers av vurderinger og vurderere. +Felles sannhetskilde for OKR-kvalitetsvurdering i norsk offentlig sektor (Google/Doerr-metodikk tilpasset tertialsyklus). Hver kvalitetsdimensjon har **fem ankere** fra svakest til sterkest med konkret nivåbeskrivelse, slik at scoring blir deterministisk og reproduserbar på tvers av vurderinger og vurderere. -**Bruk:** les ankerbeskrivelsene per dimensjon, finn det nivaaet OKR-en faktisk treffer, og scor dimensjonen 1-5. Skaler til 0-10 der en ti-skala kreves: anker 1 -> 1-2, anker 2 -> 3-4, anker 3 -> 5-6, anker 4 -> 7-8, anker 5 -> 9-10 (jf. scoring-guiden i `kvalitetssjekker`-agenten og samlet-scoring-tabellen i `/okr:kvalitet`). Denne fila erstatter den inline-rubrikken som tidligere laa i `commands/kvalitet.md` og forkortet i `agents/kvalitetssjekker-agent.md` — kommandoen og agenten refererer hit i stedet for aa baere ankrene selv. +**Bruk:** les ankerbeskrivelsene per dimensjon, finn det nivået OKR-en faktisk treffer, og scor dimensjonen 1-5. Skaler til 0-10 der en ti-skala kreves: anker 1 -> 1-2, anker 2 -> 3-4, anker 3 -> 5-6, anker 4 -> 7-8, anker 5 -> 9-10 (jf. scoring-guiden i `kvalitetssjekker`-agenten og samlet-scoring-tabellen i `/okr:kvalitet`). Denne fila erstatter den inline-rubrikken som tidligere lå i `commands/kvalitet.md` og forkortet i `agents/kvalitetssjekker-agent.md` — kommandoen og agenten refererer hit i stedet for å bære ankrene selv. ## Objective-dimensjoner ### Inspirerende -Maaler om Objective-et motiverer teamet og kommuniserer hvorfor arbeidet betyr noe. +Måler om Objective-et motiverer teamet og kommuniserer hvorfor arbeidet betyr noe. -1. **Anker 1 (svakest)** — Kjedelig eller rent byraakratisk; ingen blir engasjert av formuleringen. -2. **Anker 2** — Toert og oppgavepreget; saklig, men vekker ikke eierskap. -3. **Anker 3** — Noeytralt; akseptabelt, men hverken loefter eller demotiverer. +1. **Anker 1 (svakest)** — Kjedelig eller rent byråkratisk; ingen blir engasjert av formuleringen. +2. **Anker 2** — Tørt og oppgavepreget; saklig, men vekker ikke eierskap. +3. **Anker 3** — Nøytralt; akseptabelt, men hverken løfter eller demotiverer. 4. **Anker 4** — Engasjerende for de fleste; tydelig retning og en antydning av hvorfor. 5. **Anker 5 (sterkest)** — Motiverer hele teamet; kommuniserer mening og ambisjon, og folk husker det. ### Klarhet -Maaler om Objective-et gir en entydig retning som alle tolker likt. +Måler om Objective-et gir en entydig retning som alle tolker likt. 1. **Anker 1 (svakest)** — Flertydig; kan tolkes i flere ulike retninger. -2. **Anker 2** — Et kjernebegrep er uklart og krever oppklaring foer arbeid kan starte. +2. **Anker 2** — Et kjernebegrep er uklart og krever oppklaring før arbeid kan starte. 3. **Anker 3** — Noe vagt, men hovedretningen anes. -4. **Anker 4** — Stort sett entydig; kun smaa tolkningsrom igjen. -5. **Anker 5 (sterkest)** — Entydig retning; alle som leser det forstaar det samme. +4. **Anker 4** — Stort sett entydig; kun små tolkningsrom igjen. +5. **Anker 5 (sterkest)** — Entydig retning; alle som leser det forstår det samme. ### Outcome-fokus -Maaler om Objective-et beskriver en oensket tilstand (resultat) heller enn en aktivitet. +Måler om Objective-et beskriver en ønsket tilstand (resultat) heller enn en aktivitet. -1. **Anker 1 (svakest)** — Ren aktivitet ("gjennomfoere", "lage", "innfoere"); ingen resultat. +1. **Anker 1 (svakest)** — Ren aktivitet ("gjennomføre", "lage", "innføre"); ingen resultat. 2. **Anker 2** — Overveiende aktivitet med en vag henvisning til effekt. 3. **Anker 3** — Blanding av aktivitet og resultat. -4. **Anker 4** — Overveiende resultat, med en gjenvaerende aktivitetsrest. -5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnaa, ikke veien dit. +4. **Anker 4** — Overveiende resultat, med en gjenværende aktivitetsrest. +5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnå, ikke veien dit. ### Scope -Maaler om Objective-et er riktig dimensjonert for én tertial (fire maaneder). +Måler om Objective-et er riktig dimensjonert for én tertial (fire måneder). -1. **Anker 1 (svakest)** — Helt feil scope; en fleraarig visjon eller en triviell enkeltoppgave. +1. **Anker 1 (svakest)** — Helt feil scope; en flerårig visjon eller en triviell enkeltoppgave. 2. **Anker 2** — Klart for stort eller for lite for én tertial. -3. **Anker 3** — Litt for stort eller for lite, men haandterbart. +3. **Anker 3** — Litt for stort eller for lite, men håndterbart. 4. **Anker 4** — Passer tertialen med rimelig stretch. -5. **Anker 5 (sterkest)** — Perfekt dimensjonert for én tertial; ambisioest men oppnaaelig i perioden. +5. **Anker 5 (sterkest)** — Perfekt dimensjonert for én tertial; ambisiøst men oppnåelig i perioden. ### Alignment -Maaler om Objective-et er koblet oppover til org-OKR, tildelingsbrev eller overordnet strategi. +Måler om Objective-et er koblet oppover til org-OKR, tildelingsbrev eller overordnet strategi. -1. **Anker 1 (svakest)** — Ingen kobling til overordnet maal eller tildelingsbrev. -2. **Anker 2** — Kobling paastaaes, men kan ikke spores til et konkret overordnet maal. -3. **Anker 3** — Implisitt kobling oppover; leseren maa selv slutte sammenhengen. +1. **Anker 1 (svakest)** — Ingen kobling til overordnet mål eller tildelingsbrev. +2. **Anker 2** — Kobling påstås, men kan ikke spores til et konkret overordnet mål. +3. **Anker 3** — Implisitt kobling oppover; leseren må selv slutte sammenhengen. 4. **Anker 4** — Tydelig koblet, men ikke eksplisitt sitert. 5. **Anker 5 (sterkest)** — Tydelig og eksplisitt koblet til navngitt org-OKR eller tildelingsbrevspunkt. ## Key Result-dimensjoner -### Maalbarhet -Maaler om Key Result-et har konkrete tall med baseline og target. +### Målbarhet +Måler om Key Result-et har konkrete tall med baseline og target. -1. **Anker 1 (svakest)** — Ikke maalbart; ingen tall, kun kvalitativ paastand. +1. **Anker 1 (svakest)** — Ikke målbart; ingen tall, kun kvalitativ påstand. 2. **Anker 2** — Et tall er nevnt, men uten baseline eller uten target. -3. **Anker 3** — Delvis maalbart (target uten baseline, eller omvendt). +3. **Anker 3** — Delvis målbart (target uten baseline, eller omvendt). 4. **Anker 4** — Tall med baseline og target, men et mindre presisjonshull (uklar enhet/avgrensning). 5. **Anker 5 (sterkest)** — Tall med tydelig baseline -> target og entydig enhet. ### Outcome -Maaler om Key Result-et fanger reell effekt heller enn output/aktivitet. +Måler om Key Result-et fanger reell effekt heller enn output/aktivitet. -1. **Anker 1 (svakest)** — Ren output/aktivitet (antall moeter, leveranser, kurs). +1. **Anker 1 (svakest)** — Ren output/aktivitet (antall møter, leveranser, kurs). 2. **Anker 2** — Output brukt som svak proxy for et udokumentert resultat. 3. **Anker 3** — Blanding av output og outcome. 4. **Anker 4** — Overveiende outcome, med en mindre output-rest. -5. **Anker 5 (sterkest)** — Maaler reell effekt eller resultat for bruker/samfunn. +5. **Anker 5 (sterkest)** — Måler reell effekt eller resultat for bruker/samfunn. ### Ambisjon -Maaler om Key Result-et har riktig stretch — ambisioest, men ikke urealistisk eller sandbagget. +Måler om Key Result-et har riktig stretch — ambisiøst, men ikke urealistisk eller sandbagget. -1. **Anker 1 (svakest)** — Urealistisk (praktisk umulig) eller aapenbar sandbagging (garantert 1.0). +1. **Anker 1 (svakest)** — Urealistisk (praktisk umulig) eller åpenbar sandbagging (garantert 1.0). 2. **Anker 2** — Tydelig for lett eller for hardt for perioden. -3. **Anker 3** — For lett eller for vanskelig, men i naerheten av riktig nivaa. +3. **Anker 3** — For lett eller for vanskelig, men i nærheten av riktig nivå. 4. **Anker 4** — Rimelig stretch, men litt for konservativ eller litt for aggressiv. -5. **Anker 5 (sterkest)** — Riktig stretch; om lag 70 % forventet maaloppnaaelse, ambisioest men mulig. +5. **Anker 5 (sterkest)** — Riktig stretch; om lag 70 % forventet måloppnåelse, ambisiøst men mulig. ### Datakilde -Maaler om Key Result-et har en spesifisert og faktisk tilgjengelig datakilde. +Måler om Key Result-et har en spesifisert og faktisk tilgjengelig datakilde. 1. **Anker 1 (svakest)** — Ukjent datakilde; ingen vet hvor tallet skal hentes fra. 2. **Anker 2** — Datakilde antydet, men ikke bekreftet tilgjengelig. 3. **Anker 3** — Datakilde antas tilgjengelig, men er ikke verifisert. -4. **Anker 4** — Spesifisert kilde med mindre usikkerhet om maalefrekvens eller tilgang. -5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent maalefrekvens. +4. **Anker 4** — Spesifisert kilde med mindre usikkerhet om målefrekvens eller tilgang. +5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent målefrekvens. ### Uavhengighet -Maaler i hvilken grad teamet selv kontrollerer utfallet av Key Result-et. +Måler i hvilken grad teamet selv kontrollerer utfallet av Key Result-et. 1. **Anker 1 (svakest)** — Utfallet ligger helt utenfor teamets kontroll. -2. **Anker 2** — Sterkt avhengig av andre enheter eller eksterne aktoerer. +2. **Anker 2** — Sterkt avhengig av andre enheter eller eksterne aktører. 3. **Anker 3** — Delvis avhengig av andre. 4. **Anker 4** — Stort sett innenfor teamets kontroll, med en mindre ekstern avhengighet. 5. **Anker 5 (sterkest)** — Teamet kontrollerer utfallet direkte. --- -*Kilde: Google re:Work OKR-rubrikk + Doerr "Measure What Matters", tilpasset norsk offentlig tertialsyklus. Ankrene konsoliderer scoringsbaandene fra `/okr:kvalitet` og `kvalitetssjekker`-agenten til én delt sannhetskilde.* +*Kilde: Google re:Work OKR-rubrikk + Doerr "Measure What Matters", tilpasset norsk offentlig tertialsyklus. Ankrene konsoliderer scoringsbåndene fra `/okr:kvalitet` og `kvalitetssjekker`-agenten til én delt sannhetskilde.* *Sist oppdatert: Juni 2026* diff --git a/skills/okr-second-brain-search/SKILL.md b/skills/okr-second-brain-search/SKILL.md index f8a3fa1..421bd6a 100644 --- a/skills/okr-second-brain-search/SKILL.md +++ b/skills/okr-second-brain-search/SKILL.md @@ -10,7 +10,7 @@ description: >- prompt. Triggers on: "våre mål", "overordnede mål", "mål dette tertialet", "tertialmål", "tildelingsbrev", "hva sier OKR-ene våre", "forrige syklus", "strategi", "styringssignaler". -version: "1.6.0" +version: "1.6.1" --- # OKR Second-Brain Search @@ -81,13 +81,14 @@ navigate and to break ranking ties (below). ## Retrieval procedure -Adopt the OKF `kb-search` triad (`list_contents` / `read_file` / `search_content` -≡ Glob / Read / Grep) together with the `discovery` quality pattern (semantic -decomposition). Concretely: +Use the native file tools as a retrieval triad — **Glob** (list) / **Read** (open) +/ **Grep** (search) — together with semantic decomposition (the `discovery` +pattern: expand the query before searching rather than grepping verbatim). Concretely: -1. **Locate & navigate.** Glob both roots — `.claude/okr/**/*.md` and - `~/.claude/okr/org/**/*.md`. Read each level's `index.md` first to understand - what exists before reading any concept file. +1. **Locate & navigate.** Glob both roots — the project root `.claude/okr/**/*.md`, + and the home root `/.claude/okr/org/**/*.md` (expand `~` to the absolute + home path first — the Glob tool does **not** expand a literal `~`). Read each + level's `index.md` first to understand what exists before reading any concept file. 2. **Semantic decomposition** — *do not* grep the user's words verbatim. Generate **up to 3 query variations** and search all of them: - **(i) Direct + synonyms** — the literal term plus close synonyms diff --git a/tests/fixtures/inbox-converted/dok-a.md b/tests/fixtures/inbox-converted/dok-a.md new file mode 100644 index 0000000..fa3dd36 --- /dev/null +++ b/tests/fixtures/inbox-converted/dok-a.md @@ -0,0 +1,7 @@ +# Tildelingsbrev 2026 + +Tildelingsbrevet gir overordnede foringer for virksomheten i 2026. + +## Oppfolging mot Virksomhetsplan 2026 + +Tildelingsbrevet bygger paa Virksomhetsplan 2026 og maalene som er satt der. diff --git a/tests/fixtures/inbox-converted/dok-b.md b/tests/fixtures/inbox-converted/dok-b.md new file mode 100644 index 0000000..50affd1 --- /dev/null +++ b/tests/fixtures/inbox-converted/dok-b.md @@ -0,0 +1,7 @@ +# Virksomhetsplan 2026 + +Virksomhetsplanen konkretiserer de overordnede maalene for perioden. + +## Mal og rammer (2026) [utkast] + +Rammene for perioden er forelopige, jf. tildelingsbrev. diff --git a/tests/fixtures/inbox-sample/dok-a.txt b/tests/fixtures/inbox-sample/dok-a.txt new file mode 100644 index 0000000..fa3dd36 --- /dev/null +++ b/tests/fixtures/inbox-sample/dok-a.txt @@ -0,0 +1,7 @@ +# Tildelingsbrev 2026 + +Tildelingsbrevet gir overordnede foringer for virksomheten i 2026. + +## Oppfolging mot Virksomhetsplan 2026 + +Tildelingsbrevet bygger paa Virksomhetsplan 2026 og maalene som er satt der. diff --git a/tests/fixtures/inbox-sample/dok-b.txt b/tests/fixtures/inbox-sample/dok-b.txt new file mode 100644 index 0000000..50affd1 --- /dev/null +++ b/tests/fixtures/inbox-sample/dok-b.txt @@ -0,0 +1,7 @@ +# Virksomhetsplan 2026 + +Virksomhetsplanen konkretiserer de overordnede maalene for perioden. + +## Mal og rammer (2026) [utkast] + +Rammene for perioden er forelopige, jf. tildelingsbrev. diff --git a/tests/frontmatter.test.mjs b/tests/frontmatter.test.mjs index 5121785..5fb92cf 100644 --- a/tests/frontmatter.test.mjs +++ b/tests/frontmatter.test.mjs @@ -107,3 +107,29 @@ test('writeFrontmatter: verdi med "#" siteres og round-tripper', () => { const { get } = parseFrontmatter(block); assert.equal(get('kortform'), 'A #B', 'sitert # round-tripper'); }); + +test('writeFrontmatter: array-verdi (tags) -> OKF multi-linje liste (Step 3)', () => { + const block = writeFrontmatter({ type: 'OKR', tags: ['a', 'b'], kilde: 'innboks' }); + assert.match(block, /tags:\n - a\n - b/, 'array -> innrykket OKF-liste'); + const { get } = parseFrontmatter(block); + assert.equal(get('type'), 'OKR', 'skalar FOR list-blokk resolver'); + assert.equal(get('kilde'), 'innboks', 'skalar ETTER innrykket list-blokk resolver fortsatt'); + // Lese-siden uendret (Step 3 scope = kun skrive-siden): get() paa en list-key + // krasjer ikke (eksisterende tolerance-kontrakt, frontmatter.mjs:14-16). Den + // returnerer foerste list-element fordi parser-\s* spiser newline -- IKKE + // null; konsumentene leser aldri tag-VERDIER, kun at nabo-skalarer resolver. + assert.doesNotThrow(() => get('tags'), 'get paa list-key krasjer ikke'); +}); + +test('writeFrontmatter: skalar uendret av array-gren (additiv)', () => { + const block = writeFrontmatter({ navn: 'Org', type: 'Notat' }); + assert.match(block, /navn: Org/); + assert.match(block, /type: Notat/); + assert.doesNotMatch(block, /^\s*-\s/m, 'ingen list-syntaks for skalarer'); +}); + +test('writeFrontmatter: tom array -> kun key-linje, ingen items', () => { + const block = writeFrontmatter({ tags: [] }); + assert.match(block, /tags:\n/, 'tom array gir key-linje'); + assert.doesNotMatch(block, /^\s*-\s/m, 'ingen item-linjer for tom array'); +}); diff --git a/tests/innboks-frontmatter.test.mjs b/tests/innboks-frontmatter.test.mjs new file mode 100644 index 0000000..7d223c9 --- /dev/null +++ b/tests/innboks-frontmatter.test.mjs @@ -0,0 +1,124 @@ +// innboks-frontmatter.test.mjs +// Step 5 (SC idempotens): deterministisk frontmatter-projeksjon. projectFrontmatter +// utleder type/tags regel-basert (INGEN LLM) og snapper mot lukket vokab; resource = +// original relativ sti; description = foerste ikke-tomme avsnitt; timestamp = ISO av +// original-mtime (IKKE veggklokke); kilde:innboks; og fester concept.destRel = +// routeLevel(type)/slug.md FOER relasjons-steget. Asserterer paa parseFrontmatter(). +// get() (aldri substring for verdier). Moenster: tests/oppsett-okf-write.test.mjs:93. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; +import { TYPE_VOCAB, routeLevel } from '../lib/okf-vocab.mjs'; +import { splitConcepts } from '../lib/innboks-split.mjs'; +import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX = join(HERE, 'fixtures', 'inbox-converted'); +const dokA = readFileSync(join(FIX, 'dok-a.md'), 'utf8'); + +const MTIME = new Date('2026-03-15T08:30:00.000Z'); +const OPTS = { sourcePath: 'innboks/dok-a.txt', sourceMtime: MTIME }; + +// Frontmatter-noekler i rekkefolge (kun linjestart-noekler; list-elementer hoppes over). +function fmKeyOrder(fm) { + const { raw } = parseFrontmatter(fm); + return (raw || '') + .split('\n') + .map((l) => { + const m = l.match(/^([a-z_]+):/); + return m ? m[1] : null; + }) + .filter(Boolean); +} + +test('projectFrontmatter: type snappes til lukket vokab; keyword i title -> kanonisk type', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + const { get } = parseFrontmatter(e.frontmatter); + assert.ok(TYPE_VOCAB.includes(get('type')), 'type i lukket vokab'); + assert.equal(get('type'), 'Tildelingsbrev', 'keyword "Tildelingsbrev" i title -> kanonisk type'); +}); + +test('projectFrontmatter: ukjent emne -> safe default Dokument (negativ vokab-sjekk)', () => { + const concept = { + sourceSlug: 'x', + title: 'Helt annerledes emne', + slug: 'helt-annerledes-emne', + level: 1, + body: 'Innhold uten vokab-noekkelord.', + }; + const e = projectFrontmatter(concept, { sourcePath: 'innboks/x.txt', sourceMtime: MTIME }); + assert.equal(parseFrontmatter(e.frontmatter).get('type'), 'Dokument'); + assert.doesNotMatch( + e.frontmatter, + /^type: (Tildelingsbrev|Virksomhetsplan|OKR)$/m, + 'ikke feil-snappet til en foeringstype', + ); +}); + +test('projectFrontmatter: resource = original relativ sti (kanonisk navn, ikke "source")', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + const { get } = parseFrontmatter(e.frontmatter); + assert.equal(get('resource'), 'innboks/dok-a.txt'); + assert.equal(get('source'), null, 'noekkelen heter resource, ikke source'); +}); + +test('projectFrontmatter: description deterministisk (2 kjoeringer byte-identiske, foerste avsnitt)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e1 = projectFrontmatter(c1, OPTS); + const e2 = projectFrontmatter(c1, OPTS); + assert.equal(e1.frontmatter, e2.frontmatter, 'byte-identisk projeksjon'); + assert.match(parseFrontmatter(e1.frontmatter).get('description'), /Tildelingsbrevet gir overordnede/); +}); + +test('projectFrontmatter: lang body -> description trunkeres deterministisk', () => { + const longBody = 'A'.repeat(500); + const concept = { sourceSlug: 'lang', title: 'Lang', slug: 'lang', level: 1, body: longBody }; + const e = projectFrontmatter(concept, { sourcePath: 'innboks/lang.txt', sourceMtime: MTIME }); + const desc = parseFrontmatter(e.frontmatter).get('description'); + assert.ok(desc.length <= 244, 'trunkert til <= 240 + ellipsis'); + assert.match(desc, /\.\.\.$/, 'ellipsis-markoer ved trunkering'); +}); + +test('projectFrontmatter: timestamp = ISO av sourceMtime (idempotent paa tvers av veggklokke)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.equal(parseFrontmatter(e.frontmatter).get('timestamp'), MTIME.toISOString()); +}); + +test('projectFrontmatter: kilde:innboks provenans-markoer alltid satt', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.equal(parseFrontmatter(e.frontmatter).get('kilde'), 'innboks'); + assert.equal(e.kilde, 'innboks', 'kilde ogsaa paa konsept-objektet'); +}); + +test('projectFrontmatter: concept.destRel satt + konsistent med routeLevel(type)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.ok(e.destRel, 'destRel satt FOER relasjons-steget'); + assert.equal(e.destRel, `${routeLevel(e.type)}/${c1.slug}.md`); + assert.equal(e.destRel, 'strategisk-kontekst/tildelingsbrev-2026.md', 'Tildelingsbrev -> strategisk-kontekst'); +}); + +test('projectFrontmatter: kanonisk noekkel-rekkefolge (type/resource/title/desc/tags/timestamp/kilde)', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + const order = fmKeyOrder(e.frontmatter); + const canon = ['type', 'resource', 'title', 'description', 'tags', 'timestamp', 'kilde']; + assert.deepEqual(order, canon.filter((k) => order.includes(k)), 'present keys i kanonisk rekkefolge'); + assert.equal(order[0], 'type', 'type foerst'); + assert.equal(order[order.length - 1], 'kilde', 'kilde sist'); +}); + +test('projectFrontmatter: tags er multi-linje liste (writeFrontmatter array-gren) + i vokab', () => { + const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const e = projectFrontmatter(c1, OPTS); + assert.match(e.frontmatter, /^tags:$/m, 'multi-linje tags-noekkel'); + assert.match(e.frontmatter, /^ {2}- Tildelingsbrev$/m, 'tag-element i vokab'); +}); diff --git a/tests/innboks-relations.test.mjs b/tests/innboks-relations.test.mjs new file mode 100644 index 0000000..95b0e4e --- /dev/null +++ b/tests/innboks-relations.test.mjs @@ -0,0 +1,86 @@ +// innboks-relations.test.mjs +// Step 6 (SC relasjoner): delt bundle-root-relativ lenke-resolver (okf-links) + +// deterministisk relasjons-resolusjon (innboks-relations). Beviser: >=1 emittert +// lenke matcher SC-regex /\]\(\/.*\.md\)/ (leading '/'); hver lenke resolverer til +// et emittert konsept (zero-dangling); '../'/absolutt/file://-/http-lenker avvises +// av isSafeBundleLink (allow-list). Bygger det emitterte settet gjennom Step 4+5. +// Moenster: tests/okf-retrieval.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isSafeBundleLink, resolveBundleLink } from '../lib/okf-links.mjs'; +import { resolveRelations } from '../lib/innboks-relations.mjs'; +import { splitConcepts } from '../lib/innboks-split.mjs'; +import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX = join(HERE, 'fixtures', 'inbox-converted'); +const MTIME = new Date('2026-03-15T08:30:00.000Z'); +const SC_REGEX = /\]\(\/.*\.md\)/; + +// Det emitterte konsept-settet (split + project) for begge fixture-dokumentene. +function buildConcepts() { + const out = []; + for (const slug of ['dok-a', 'dok-b']) { + const md = readFileSync(join(FIX, `${slug}.md`), 'utf8'); + for (const c of splitConcepts(md, { sourceSlug: slug })) { + out.push(projectFrontmatter(c, { sourcePath: `innboks/${slug}.txt`, sourceMtime: MTIME })); + } + } + return out; +} + +test('resolveRelations: >=1 emittert lenke matcher SC-regex (leading-/ bundle-link)', () => { + const related = resolveRelations(buildConcepts()); + const targets = related.flatMap((c) => c.relations.map((r) => r.target)); + assert.ok(targets.length >= 1, 'minst en relasjon emitteres fra fixturene'); + assert.ok(targets.every((t) => SC_REGEX.test(`](${t})`)), 'hver lenke matcher SC-regex'); + // Samme lenke maa finnes i body-en som faktisk skrives + grepes av SC4. + const bodies = related.map((c) => c.body).join('\n'); + assert.match(bodies, SC_REGEX, 'relasjons-lenke finnes i konsept-body'); +}); + +test('resolveRelations: hver lenke resolverer til et emittert konsept (zero-dangling)', () => { + const concepts = buildConcepts(); + const emitted = new Set(concepts.map((c) => c.destRel)); + const related = resolveRelations(concepts); + for (const c of related) { + for (const r of c.relations) { + assert.ok(emitted.has(r.destRel), `lenke-mal ${r.destRel} er et emittert konsept`); + assert.equal(r.target, `/${r.destRel}`, 'leading-/ bundle-root-lenke'); + assert.equal(isSafeBundleLink(r.target), true, 'emittert lenke er trygg'); + } + } +}); + +test('resolveRelations: deterministisk (2 kjoeringer byte-like body + relations)', () => { + const pick = (arr) => arr.map((c) => ({ slug: c.slug, body: c.body, relations: c.relations })); + assert.deepEqual(pick(resolveRelations(buildConcepts())), pick(resolveRelations(buildConcepts()))); +}); + +test('isSafeBundleLink: aksepterer leading-/ .md bundle-lenke', () => { + assert.equal(isSafeBundleLink('/strategisk-kontekst/virksomhetsplan-2026.md'), true); + assert.equal(isSafeBundleLink('/dokumenter/notat.md'), true); +}); + +test('isSafeBundleLink: avviser escape/scheme/relativ/ikke-md/backslash', () => { + assert.equal(isSafeBundleLink('../escape.md'), false, 'relativ ..'); + assert.equal(isSafeBundleLink('/foo/../../etc/passwd.md'), false, '.. segment'); + assert.equal(isSafeBundleLink('file:///etc/passwd.md'), false, 'file:// scheme'); + assert.equal(isSafeBundleLink('http://evil.example/x.md'), false, 'http scheme'); + assert.equal(isSafeBundleLink('relativ/sti.md'), false, 'ingen leading /'); + assert.equal(isSafeBundleLink('/strategisk-kontekst/uten-ext'), false, 'ikke .md'); + assert.equal(isSafeBundleLink('C:\\win\\path.md'), false, 'backslash'); + assert.equal(isSafeBundleLink(''), false, 'tom streng'); + assert.equal(isSafeBundleLink(null), false, 'ikke-streng'); +}); + +test('resolveBundleLink: trygg lenke -> sti under bundleRoot; escape/scheme -> null', () => { + const root = '/tmp/bundle'; + assert.equal(resolveBundleLink('/dokumenter/notat.md', root), join(root, 'dokumenter/notat.md')); + assert.equal(resolveBundleLink('../escape.md', root), null); + assert.equal(resolveBundleLink('http://x/y.md', root), null); +}); diff --git a/tests/innboks-split.test.mjs b/tests/innboks-split.test.mjs new file mode 100644 index 0000000..c8d6d0b --- /dev/null +++ b/tests/innboks-split.test.mjs @@ -0,0 +1,77 @@ +// innboks-split.test.mjs +// Step 4 (SC idempotens): deterministisk heading-split av (allerede konvertert) +// markdown til konsepter. splitConcepts er en REN funksjon -- samme input gir +// identisk konsept-sett + filnavn (kebab-slug). Header-tekst -> title; flat +// dokument (ingen #/##) -> ett konsept; slug-kollisjon -> stabil numerisk +// disambiguering; preamble foer foerste heading bevares (ingen datatap). +// Direkte import (zero npm deps). Moenster: tests/frontmatter.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { splitConcepts } from '../lib/innboks-split.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX = join(HERE, 'fixtures', 'inbox-converted'); +const dokA = readFileSync(join(FIX, 'dok-a.md'), 'utf8'); +const dokB = readFileSync(join(FIX, 'dok-b.md'), 'utf8'); + +test('splitConcepts: deterministisk -- samme input 2x gir identisk konsept-sett', () => { + const a1 = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + const a2 = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + assert.deepEqual(a1, a2, 'ren funksjon: identisk output for identisk input'); +}); + +test('splitConcepts: #/## heading -> ett konsept per seksjon, header-tekst -> title', () => { + const concepts = splitConcepts(dokA, { sourceSlug: 'dok-a' }); + assert.equal(concepts.length, 2, 'dok-a har en H1 + en H2 -> 2 konsepter'); + assert.equal(concepts[0].title, 'Tildelingsbrev 2026'); + assert.equal(concepts[0].slug, 'tildelingsbrev-2026'); + assert.equal(concepts[0].level, 1); + assert.equal(concepts[1].title, 'Oppfolging mot Virksomhetsplan 2026'); + assert.equal(concepts[1].level, 2); + // body baerer seksjons-innholdet (uten heading-linja). + assert.match(concepts[0].body, /Tildelingsbrevet gir overordnede/); + assert.doesNotMatch(concepts[0].body, /^#/, 'heading-linja er ikke i body'); +}); + +test('splitConcepts: slug stripper klammer/parenteser (adversarial heading)', () => { + const concepts = splitConcepts(dokB, { sourceSlug: 'dok-b' }); + assert.equal(concepts.length, 2); + assert.equal(concepts[1].title, 'Mal og rammer (2026) [utkast]'); + assert.equal(concepts[1].slug, 'mal-og-rammer-2026-utkast', 'klammer/parenteser strippet fra slug'); +}); + +test('splitConcepts: ### og dypere er IKKE split-punkt (forblir body)', () => { + const md = '# Topp\n\nIntro.\n\n### Underseksjon\n\nDetalj.'; + const concepts = splitConcepts(md, { sourceSlug: 'dyp' }); + assert.equal(concepts.length, 1, 'kun H1 splitter; ### forblir i body'); + assert.match(concepts[0].body, /### Underseksjon/, '### bevart som body-innhold'); +}); + +test('splitConcepts: flat dokument (ingen #/##) -> ett konsept', () => { + const flat = 'Bare en paragraf uten overskrift.\n\nEnda en linje.'; + const concepts = splitConcepts(flat, { sourceSlug: 'notat' }); + assert.equal(concepts.length, 1); + assert.equal(concepts[0].slug, 'notat', 'flat-fallback slug fra sourceSlug'); + assert.match(concepts[0].body, /Bare en paragraf/); +}); + +test('splitConcepts: slug-kollisjon -> stabil numerisk disambiguering', () => { + const md = '# Samme tittel\n\nA\n\n## Samme tittel\n\nB'; + const concepts = splitConcepts(md, { sourceSlug: 'kollisjon' }); + assert.equal(concepts.length, 2); + assert.equal(concepts[0].slug, 'samme-tittel'); + assert.equal(concepts[1].slug, 'samme-tittel-2', 'andre forekomst faar -2 suffiks'); +}); + +test('splitConcepts: preamble foer foerste heading bevares som ledende konsept', () => { + const md = 'Forord uten overskrift.\n\n# Ekte overskrift\n\nKropp.'; + const concepts = splitConcepts(md, { sourceSlug: 'med-forord' }); + assert.equal(concepts.length, 2, 'preamble + en heading -> 2 konsepter (ingen datatap)'); + assert.equal(concepts[0].slug, 'med-forord'); + assert.match(concepts[0].body, /Forord uten overskrift/); + assert.equal(concepts[1].title, 'Ekte overskrift'); +}); diff --git a/tests/innboks-write.test.mjs b/tests/innboks-write.test.mjs new file mode 100644 index 0000000..dcdb80e --- /dev/null +++ b/tests/innboks-write.test.mjs @@ -0,0 +1,152 @@ +// innboks-write.test.mjs +// Step 7 (SC original + riktig nivaa): konsept-skriver med type->nivaa-ruting og +// ikke-destruktiv original-bevaring. Driver den reelle kjernen +// (splitConcepts -> projectFrontmatter -> resolveRelations) per original og lar +// writeConcepts persistere settet i en temp-bundle. Verifiserer: +// - konsept med strategisk-kontekst/-destRel skrives dit, dokumenter/-destRel dit +// - skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline +// - originalens sha256 er uendret etter skriv (ikke-destruktiv, SC3) +// - peker-fil i konseptets nivaa lenker til originalen i drop-zonen (grep-bar) +// - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist +// - ingen .tmp lekker (atomisk temp+renameSync) +// Zero npm deps. Moenster: tests/org-profile-write.test.mjs (mkdtemp + realpath/sha). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join, dirname } from 'node:path'; + +import { splitConcepts } from '../lib/innboks-split.mjs'; +import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs'; +import { resolveRelations } from '../lib/innboks-relations.mjs'; +import { writeConcepts } from '../lib/innboks-write.mjs'; + +const MTIME = new Date('2026-03-15T08:30:00.000Z'); + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +// Kjor den reelle kjernen for ett dokument -> konsepter (m/ destRel + frontmatter + body). +function ingestOne(markdown, sourceSlug, sourcePath) { + const raw = splitConcepts(markdown, { sourceSlug }); + const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: MTIME })); + return resolveRelations(projected); +} + +// Temp-bundle: /.claude/okr (+ innboks/ drop-zone). Ryddes alltid. +function withBundle(fn) { + const tmp = mkdtempSync(join(tmpdir(), 'okrwrite-')); + const bundleRoot = join(tmp, '.claude', 'okr'); + const inbox = join(bundleRoot, 'innboks'); + mkdirSync(inbox, { recursive: true }); + try { + fn({ tmp, bundleRoot, inbox }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +const TILDELING = '# Tildelingsbrev 2026\n\nStatens vegvesen skal levere paa foelgende maal i 2026.\n'; +const MERKNAD = '# Generell merknad\n\nEt fritt notat uten foeringsord i tittel.\n'; + +test('writeConcepts: type->nivaa-ruting (strategisk-kontekst + dokumenter)', () => { + withBundle(({ bundleRoot, inbox }) => { + const tPath = join(inbox, 'tildeling.txt'); + const mPath = join(inbox, 'merknad.txt'); + writeFileSync(tPath, TILDELING); + writeFileSync(mPath, MERKNAD); + + const tConcepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt'); + const mConcepts = ingestOne(MERKNAD, 'merknad', 'innboks/merknad.txt'); + const [tc] = tConcepts; + const [mc] = mConcepts; + + // Forventning fra Step 5-ruting (sanity foer skriv). + assert.equal(dirname(tc.destRel), 'strategisk-kontekst', 'Tildelingsbrev -> strategisk-kontekst/'); + assert.equal(dirname(mc.destRel), 'dokumenter', 'default Dokument -> dokumenter/'); + + writeConcepts([...tConcepts, ...mConcepts], { + bundleRoot, + originals: [ + { sourceSlug: 'tildeling', path: tPath }, + { sourceSlug: 'merknad', path: mPath }, + ], + }); + + const tFile = join(bundleRoot, tc.destRel); + const mFile = join(bundleRoot, mc.destRel); + assert.ok(existsSync(tFile), 'tildelingsbrev-konsept skrevet til strategisk-kontekst/'); + assert.ok(existsSync(mFile), 'merknad-konsept skrevet til dokumenter/'); + + // Skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline. IKKE re-serialisert. + const tContent = readFileSync(tFile, 'utf8'); + assert.ok(tContent.startsWith(tc.frontmatter), 'frontmatter verbatim oeverst'); + assert.ok(tContent.includes(tc.body), 'body verbatim bevart'); + assert.ok(tContent.endsWith('\n'), 'fil avsluttes med newline'); + }); +}); + +test('writeConcepts: original uendret (sha256) + peker-fil lenker til drop-zone-original', () => { + withBundle(({ bundleRoot, inbox }) => { + const tPath = join(inbox, 'tildeling.txt'); + writeFileSync(tPath, TILDELING); + const before = sha256(tPath); + + const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt'); + writeConcepts(concepts, { + bundleRoot, + originals: [{ sourceSlug: 'tildeling', path: tPath }], + }); + + // SC3: ikke-destruktiv -- originalen er byte-uendret. + assert.equal(sha256(tPath), before, 'original-sha256 uendret etter skriv'); + assert.ok(existsSync(tPath), 'original finnes fortsatt i drop-zonen'); + + // Peker-fil i konseptets nivaa, grep-bar lenke til originalen. + const level = dirname(concepts[0].destRel); + const pointer = join(bundleRoot, level, 'tildeling.kilde.md'); + assert.ok(existsSync(pointer), 'peker-fil skrevet i konseptets nivaa'); + const pointerBody = readFileSync(pointer, 'utf8'); + assert.match(pointerBody, /\(\/innboks\/tildeling\.txt\)/, 'peker lenker til original i drop-zonen'); + assert.match(pointerBody, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)'); + }); +}); + +test('writeConcepts: ../-escape destRel avvist (path-confinement)', () => { + withBundle(({ bundleRoot }) => { + const evil = { slug: 'evil', sourceSlug: 'evil', destRel: '../escape.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' }; + assert.throws( + () => writeConcepts([evil], { bundleRoot, originals: [] }), + /utenfor bundle-rot/, + 'destRel som escaper bundle-rota skal kaste', + ); + assert.ok(!existsSync(join(bundleRoot, '..', 'escape.md')), 'ingenting skrevet utenfor bundle'); + }); +}); + +test('writeConcepts: skriv til home-org-rot avvist (ingestion roerer aldri ~/.claude/okr/org)', () => { + const homeOrg = join(homedir(), '.claude', 'okr', 'org'); + const concept = { slug: 'p', sourceSlug: 'p', destRel: 'dokumenter/p.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' }; + // Kaster FOER noen skriv -- home-profilen roeres aldri. + assert.throws( + () => writeConcepts([concept], { bundleRoot: homeOrg, originals: [] }), + /home-org-rot avvist/, + 'home-org bundleRoot skal kaste', + ); +}); + +test('writeConcepts: atomisk -- ingen .tmp lekker etter skriv', () => { + withBundle(({ bundleRoot, inbox }) => { + const tPath = join(inbox, 'tildeling.txt'); + writeFileSync(tPath, TILDELING); + const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt'); + writeConcepts(concepts, { bundleRoot, originals: [{ sourceSlug: 'tildeling', path: tPath }] }); + + const level = dirname(concepts[0].destRel); + const leftover = readdirSync(join(bundleRoot, level)).filter((f) => f.endsWith('.tmp')); + assert.deepEqual(leftover, [], 'ingen temp-fil igjen etter atomisk renameSync'); + }); +}); diff --git a/tests/okf-check.test.mjs b/tests/okf-check.test.mjs index 3b3564b..887fac5 100644 --- a/tests/okf-check.test.mjs +++ b/tests/okf-check.test.mjs @@ -10,7 +10,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { - mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, + mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, mkdirSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; @@ -26,15 +26,36 @@ function tmpRoot() { } // Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da). -function runCheck(root) { +// extra: ekstra CLI-flagg (f.eks. '--strict-ingest'). +function runCheck(root, ...extra) { try { - const stdout = execFileSync('node', [CHECK, root], { encoding: 'utf8' }); + const stdout = execFileSync('node', [CHECK, root, ...extra], { encoding: 'utf8' }); return { status: 0, stdout }; } catch (e) { return { status: e.status ?? 1, stdout: `${e.stdout || ''}${e.stderr || ''}` }; } } +// Bygg en ren ingestion-bundle: kanoniske vokab-typer + en trygg, on-disk relasjon. +function buildCleanIngest(dir) { + mkdirSync(join(dir, 'strategisk-kontekst'), { recursive: true }); + mkdirSync(join(dir, 'dokumenter'), { recursive: true }); + writeFileSync(join(dir, 'index.md'), '# Bundle\n\nokf_version: kb-layout-2026-06\n'); + writeFileSync( + join(dir, 'strategisk-kontekst', 'tildelingsbrev.md'), + '---\ntype: Tildelingsbrev\nresource: urn:okr:tb\ntitle: Tildelingsbrev 2026\n' + + "description: Styringssignaler.\ntimestamp: '2026-01-15T09:00:00+00:00'\n---\n" + + '# Tildelingsbrev 2026\n\nHoveddokument.\n', + ); + writeFileSync( + join(dir, 'dokumenter', 'notat.md'), + '---\ntype: Notat\nresource: urn:okr:notat\ntitle: Internt notat\n' + + "description: Et notat.\ntimestamp: '2026-02-01T09:00:00+00:00'\n---\n" + + '# Internt notat\n\n## Relaterte dokumenter\n\n' + + '- [Tildelingsbrev 2026](/strategisk-kontekst/tildelingsbrev.md)\n', + ); +} + // Alle kataloger under root (inkl. root selv), rekursivt. function allDirs(root) { const out = [root]; @@ -44,6 +65,16 @@ function allDirs(root) { return out; } +// Byte-snapshot av alle index.md under root (for idempotens-sammenligning). +function snapshotIndexes(root) { + return allDirs(root) + .map((d) => join(d, 'index.md')) + .filter((p) => existsSync(p)) + .sort() + .map((p) => `${p}\n${readFileSync(p, 'utf8')}`) + .join('\n=====\n'); +} + // --- okf-index --- test('okf-index: genererer index.md per nivaa (hver katalog) i temp-kopi', () => { @@ -135,3 +166,147 @@ test('okf-check: rapporterer okf_version fra rot-index', () => { const { stdout } = runCheck(REALISTIC); assert.match(stdout, /okf_version:\s*kb-layout-2026-06/, 'skal ekko okf_version for menneskelig sammenligning'); }); + +// --- okf-check --strict-ingest (Step 8): lukket vokab + lenke-allow-liste --- + +test('okf-check --strict-ingest: ren ingestion-bundle (vokab-type + trygg on-disk relasjon) -> exit 0', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 0, `ren bundle skal gi exit 0:\n${stdout}`); + assert.match(stdout, /0 strict-ingest-feil/, 'skal rapportere null strict-feil'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: type utenfor vokabular -> exit 1 + navngir filen', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'rar.md'), + '---\ntype: Tilfeldig\ntitle: Rar\ndescription: x\n---\n# Rar\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'out-of-vocab type skal gi exit 1'); + assert.match(stdout, /rar\.md: type .* utenfor ingestion-vokabular/, 'skal navngi feilen'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: usikker lenke (escape ut av bundle) -> exit 1', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'ond.md'), + '---\ntype: Notat\ntitle: Ond\ndescription: x\n---\n# Ond\n\n- [exfil](../../../etc/passwd)\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'utrygg lenke skal gi exit 1'); + assert.match(stdout, /utrygg lenke/, 'skal rapportere utrygg lenke'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: dangling bundle-lenke (trygg form, mangler on-disk) -> exit 1', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'henger.md'), + '---\ntype: Notat\ntitle: Henger\ndescription: x\n---\n# Henger\n\n' + + '- [Mangler](/strategisk-kontekst/finnes-ikke.md)\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'dangling lenke skal gi exit 1'); + assert.match(stdout, /dangling lenke/, 'skal rapportere dangling lenke'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-check --strict-ingest: lenke-baerende title -> exit 1', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + writeFileSync( + join(dir, 'dokumenter', 'tittel.md'), + '---\ntype: Notat\ntitle: "[Klikk her](http://evil.example)"\ndescription: x\n---\n# T\n', + ); + const { status, stdout } = runCheck(dir, '--strict-ingest'); + assert.equal(status, 1, 'lenke-baerende title skal gi exit 1'); + assert.match(stdout, /lenke-baerende title/, 'skal rapportere lenke-baerende title'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- walk-eksklusjon (Step 8): drop-zone + dot-kataloger skannes aldri --- + +test('okf-check: raa filer i innboks/ og .cache/ ignoreres av walk (ingen falsk exit 1)', () => { + const dir = tmpRoot(); + try { + buildCleanIngest(dir); + mkdirSync(join(dir, 'innboks'), { recursive: true }); + mkdirSync(join(dir, '.cache'), { recursive: true }); + // Raa filer UTEN type -- ville gitt exit 1 hvis walk ikke ekskluderte dem. + writeFileSync(join(dir, 'innboks', 'raa.md'), '# Raa innboks-fil uten frontmatter\n'); + writeFileSync(join(dir, '.cache', 'c.md'), '# Cache uten type\n'); + const { status, stdout } = runCheck(dir); + assert.equal(status, 0, `drop-zone/dot-filer skal ignoreres:\n${stdout}`); + assert.match(stdout, /0 filer uten type:/, 'walk skal ikke telle drop-zone-filer'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- okf-index herding (Step 9): saner title/desc + skip innboks/dot --- + +test('okf-index: tittel/beskrivelse med ]/) + lenke saneres -> 2x byte-identisk + round-trip-trygg', () => { + const dir = tmpRoot(); + try { + mkdirSync(join(dir, 'dokumenter'), { recursive: true }); + writeFileSync(join(dir, 'index.md'), '# Bundle\n\nokf_version: kb-layout-2026-06\n'); + writeFileSync( + join(dir, 'dokumenter', 'kr.md'), + '---\ntype: OKR\ntitle: "KR1 [resultat] (maal) [lenke](http://x)"\n' + + 'description: "Status (delvis) [ref](http://y)"\n---\n# KR\n', + ); + generateIndexes(dir); + const run1 = snapshotIndexes(dir); + generateIndexes(dir); + const run2 = snapshotIndexes(dir); + assert.equal(run1, run2, 'generateIndexes skal vaere byte-idempotent'); + const idx = readFileSync(join(dir, 'dokumenter', 'index.md'), 'utf8'); + const entry = idx.split('\n').find((l) => l.startsWith('* ')); + assert.ok(entry, 'kr.md skal ha en entry'); + assert.match(entry, /^\* \[[^\]]*\]\([^)]+\)/, 'entry skal vaere round-trip-trygg OKF-form'); + assert.ok(!entry.includes('http'), 'injisert lenke skal vaere noytralisert'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: innboks/ + dot-katalog -> ingen egen index.md OG ikke listet i rot-index', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + mkdirSync(join(dir, 'innboks'), { recursive: true }); + mkdirSync(join(dir, '.cache'), { recursive: true }); + writeFileSync(join(dir, 'innboks', 'raa.md'), '# Raa\n'); + writeFileSync(join(dir, '.cache', 'c.md'), '# Cache\n'); + generateIndexes(dir); + assert.ok(!existsSync(join(dir, 'innboks', 'index.md')), 'innboks/ skal ikke faa index.md'); + assert.ok(!existsSync(join(dir, '.cache', 'index.md')), 'dot-katalog skal ikke faa index.md'); + const root = readFileSync(join(dir, 'index.md'), 'utf8'); + assert.ok(!root.includes('innboks/index.md'), 'rot-index skal ikke liste innboks/ som peker'); + assert.ok(!root.includes('.cache/index.md'), 'rot-index skal ikke liste dot-katalog som peker'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/okf-vocab.test.mjs b/tests/okf-vocab.test.mjs new file mode 100644 index 0000000..98f20db --- /dev/null +++ b/tests/okf-vocab.test.mjs @@ -0,0 +1,65 @@ +// okf-vocab.test.mjs +// Tester det lukkede OKF-vokabularet (type+tags) og type->nivaa-ruting for +// innboks-ingestion (Step 2). snapType: exact-match -> kanonisk, ellers safe +// default Dokument; snapTags dropper ukjente; routeLevel mapper type til +// bundle-nivaa. Direkte import (zero npm deps). Moenster: tests/frontmatter.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + TYPE_VOCAB, + TAGS_VOCAB, + snapType, + snapTags, + routeLevel, +} from '../lib/okf-vocab.mjs'; + +test('snapType: kjent type beholdes kanonisk', () => { + assert.equal(snapType('OKR'), 'OKR'); + assert.equal(snapType('Tildelingsbrev'), 'Tildelingsbrev'); +}); + +test('snapType: ukjent type -> safe default Dokument', () => { + assert.equal(snapType('ukjent'), 'Dokument'); + assert.equal(snapType(''), 'Dokument'); + assert.equal(snapType(undefined), 'Dokument'); +}); + +test('snapTags: dropper ukjente, beholder kjente (bevart rekkefolge)', () => { + assert.deepEqual(snapTags(['Strategi', 'xyz']), ['Strategi']); + assert.deepEqual(snapTags(['xyz', 'abc']), []); + assert.deepEqual(snapTags([]), []); +}); + +test('snapTags: ikke-array -> tom liste (ingen krasj)', () => { + assert.deepEqual(snapTags(undefined), []); + assert.deepEqual(snapTags(null), []); +}); + +test('routeLevel: strategisk-kontekst for foringsdokumenter', () => { + assert.equal(routeLevel('Tildelingsbrev'), 'strategisk-kontekst'); + assert.equal(routeLevel('Virksomhetsplan'), 'strategisk-kontekst'); + assert.equal(routeLevel('Overordnede OKR'), 'strategisk-kontekst'); +}); + +test('routeLevel: historikk for Retrospektiv', () => { + assert.equal(routeLevel('Retrospektiv'), 'historikk'); +}); + +test('routeLevel: dokumenter for OKR/Status/Notat/Dokument/Organisasjonsprofil', () => { + assert.equal(routeLevel('OKR'), 'dokumenter'); + assert.equal(routeLevel('Status'), 'dokumenter'); + assert.equal(routeLevel('Notat'), 'dokumenter'); + assert.equal(routeLevel('Dokument'), 'dokumenter'); + assert.equal(routeLevel('Organisasjonsprofil'), 'dokumenter'); +}); + +test('routeLevel: ukjent type -> default dokumenter', () => { + assert.equal(routeLevel('Whatever'), 'dokumenter'); +}); + +test('vokabular non-tomt + default i settet', () => { + assert.ok(TYPE_VOCAB.length > 0, 'TYPE_VOCAB non-tomt'); + assert.ok(TAGS_VOCAB.length > 0, 'TAGS_VOCAB non-tomt'); + assert.ok(TYPE_VOCAB.includes('Dokument'), 'default Dokument er i vokabularet'); +});