diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 45012cc..71ee2a6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "okr", - "version": "1.3.2", + "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 1bfac5e..c26d21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ 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 +- **`okr-second-brain-search`-skill** — on-demand retrieval over en OKF-kompatibel markdown-wiki (`.claude/okr/` prosjekt + `~/.claude/okr/org/` home) via native Glob/Read/Grep, i fri chat OG under `/okr:*`-kommandoer. Semantisk dekomponering med opptil 3 query-variasjoner; ingen for-injeksjon av alt innhold. +- **OKF-frontmatter på alle kontekstfiler** — org-profil (`type: Organisasjonsprofil`) og tre-skrivere (`type: Tildelingsbrev`/`Virksomhetsplan`/`Overordnede OKR`/`OKR`/`Retrospektiv`/`Status`) emitterer ekte OKF-schema (Title-Case `type` + `resource`/`title`/`description`/`tags`/`timestamp`). +- **`scripts/okf-index.mjs`** — genererer `index.md` per nivå (verbatim OKF-format `* [title](link) - description`) per bundle-rot. +- **`scripts/okf-check.mjs`** — validerer at hver konsept-fil bærer `type:`; rapporterer `okf_version` per rot. +- **`scripts/compose-org-profile.mjs`** — bygger nestet org-profil-YAML med top-level OKF-nøkler før pipe til `write-org-profile.mjs`. +- **`lib/frontmatter.mjs`** — delt frontmatter-parser/skriver; konsoliderer duplisert parse-logikk i begge hooks og fikser comment-leak på usiterte verdier. + +### Changed +- **`inject-okr-context.mjs` splittet** — for-injiserer ikke lenger hele kontekstfil-enumerasjonen; emitterer kjerne-profil + én resolvert peker til `index.md` (< 512 B, uavhengig av filantall). Retrieval skjer on-demand via skill. +- **`/okr:analyse`** leser `historikk/` direkte (Glob/Read) etter hook-splitten. +- **Begge hooks** ruter frontmatter-parsing gjennom delt `lib/frontmatter.mjs`. + +## [1.5.0] - 2026-06-26 + +### Added +- **`/okr:help`** — full oversikt over alle kommandoer (13), agenter (7) og anbefalt syklusarbeidsflyt. +- **`/okr:export`** — eksporter OKR-leveranser (kvalitetsvurdering, gap-matrise, statusrapport, retrospektiv) til print-klar PDF via `scripts/export-pdf.py` (weasyprint). +- **`/okr:freshen-references`** — KB-selvevaluator: scorer de 16 referansefilene mot en ankret dekning-/kvalitet-rubrikk og currency-poller offentlige kilder. +- **Ankret kvalitetsrubrikk** (`references/okr-quality-rubrics.md`, 10 dimensjoner × 5 forankrede nivåer) wiret inn i `/okr:kvalitet`, `kvalitetssjekker`-agenten og SKILL.md. +- **Atomisk org-profil-skriving**: `scripts/write-org-profile.mjs` skriver maskin-global org-identitet til `~/.claude/okr/org/profil.md` (temp + `renameSync`), med circuit-breaker til prosjekt-lokal `.claude/okr.local.md` ved hjem-skrivefeil. Fullfører hybrid org-kontekst (skrive-sti) startet i 1.4.0. +- **Node-test-dekning**: nye fixturer for org-profil-skriving, coaching-hookens fase-deteksjon og UserPromptSubmit emne-guard (17 tester totalt, grønn). + +### Changed +- **Metrics-library** utvidet med verifiserte kryss-sektor offentlig-metrikker (EGDI m.fl., kildebelagt; udokumentert `eForvaltningsindeks` fjernet). +- **Coaching-hook** fikk en klokke-seam for testbar fase-deteksjon (early/mid/late). +- **UserPromptSubmit emne-guard**: injiserer kun OKR-kontekst på relevante prompts (tvil → injiser). + +## [1.4.0] - 2026-06-24 + +### Added +- **Fase 1 — Domenedybde og kildeintegritet**: tertial-korreksjon (DFØ interim-/årsrapport-regime), OKR posisjonert som lag oppå mål- og resultatstyring (økonomireglementet §4), tillitsreform-seksjon, «målforskyvning» som norsk antipattern, reelle norske offentlig-sektor-referanser (Digdir/Oslo/Ruter/Knowit) med proveniens. +- **Fase 2 — UX-koherens og kommando-/agent-modning**: Kontekstbevissthet-blokk i `/okr:møter` (9/10 kommandoer; `oppsett` bevisst unntatt), `/okr:gap` vs `/okr:governance` disambiguert, negative triggere + direkte org-lesing i alle 7 agenter, 6 referansefiler wiret inn ved bruksstedet, template↔onboarding skjema-paritet. +- **Hybrid org-kontekst-lesing (lese-sti)**: `inject-okr-context.mjs` resolverer prosjekt-lokal `.claude/okr.local.md` → maskin-global `~/.claude/okr/org/profil.md` (mest-spesifikk-vinner; forward-compat, migrasjon kommer i Fase 3). `node:test`-fixtur for hooken. + +### Changed +- Metodikk-presisjoner (Intel/Locke-fakta, committed+aspirational ikke snittet sammen), «Kvartalsreview» → «Syklusreview». + +## [1.3.2] - 2026-06-24 + +### Fixed +- Fjernet loopende Stop-hook (type `prompt` → enhver output blokkerte stop → re-invoke-loop). + +## [1.3.1] - 2026-06-23 + +### Fixed +- Fakta- og troverdighetssanering: scoringsterskel harmonisert (0.6–0.7, Google/Doerr), fiktiv eksempeletat merket, find-replace-artefakter rettet, UTF-8/ASCII-korreksjoner, døde lenker fjernet. + ## [1.3.0] - 2026-04-08 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1a58339..f5603e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# OKR Offentlig Sektor v1.3.2 +# OKR Offentlig Sektor v1.6.1 Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapted for 4-month tertial cycles. @@ -16,6 +16,9 @@ Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapte | `/okr:gap` | Automatic gap analysis between tildelingsbrev and current OKR | | `/okr:analyse` | Cross-cycle analytics with Mermaid trend visualizations | | `/okr:oppsett` | Configure plugin: onboarding interview (full/mvp), cycle archival, profile update. Args: `full\|mvp\|arkiver\|oppdater\|vis` | +| `/okr:export` | Export OKR deliverables (quality review, gap matrix, status report, retrospective) to print-ready PDF via weasyprint | +| `/okr:freshen-references` | KB self-evaluator: score the 16 domain reference files against an anchored rubric + currency-poll public sources | +| `/okr:help` | Full overview of all commands, agents, and recommended cycle workflow | ## Agents @@ -37,21 +40,38 @@ Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapte | UserPromptSubmit | command | Inject org profile (expanded YAML), current cycle, `.claude/okr/` summary, and archived cycle count | | PreCompact | prompt | Preserve OKR draft state during compaction | -## Skill +## Skills | Component | Location | |-----------|----------| -| SKILL.md | `skills/okr-offentlig-sektor/SKILL.md` | -| References (15) | `skills/okr-offentlig-sektor/references/` | +| SKILL.md (okr-offentlig-sektor) | `skills/okr-offentlig-sektor/SKILL.md` | +| References (17) | `skills/okr-offentlig-sektor/references/` | +| SKILL.md (okr-second-brain-search) | `skills/okr-second-brain-search/SKILL.md` | + +The second skill (`okr-second-brain-search`) does on-demand retrieval over the user's OKF-compatible wiki (native Glob/Read/Grep, no MCP) — see OKF Knowledge Layout below. ## 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` — 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`) Cycle archival: `/okr:oppsett arkiver` — moves `syklus/` to `historikk/`, generates `retrospektiv.md` +## OKF Knowledge Layout + +Context files carry OKF-compatible frontmatter (Knowledge Catalog "Documents/kb Layout", aka "Metadata as Code" — not a formal "OKF v0.1" standard): a Title-Case `type` (`Organisasjonsprofil`, `Tildelingsbrev`, `Virksomhetsplan`, `Overordnede OKR`, `OKR`, `Retrospektiv`, `Status`) plus recommended `resource`/`title`/`description`/`tags`/`timestamp`. Only `type` is de-facto required. The flat frontmatter parser reads only single-line keys (`type`, profile/config fields); tree files may carry multi-line `tags` lists without breaking it. + +**Two bundle roots** with distinct lifecycles: project `.claude/okr/` (cwd-bound cycle/work) and home `~/.claude/okr/org/` (reinstall-surviving org identity). Each root carries its own `index.md` (no frontmatter; `# H1` + `* [title](link) - description`) and an `okf_version` marker. `okf-index`/`okf-check` run per root; retrieval Globs both (project preferred, else home). + +- `lib/frontmatter.mjs` — shared frontmatter parser/writer; both hooks route through it (strips trailing ` #…` only from unquoted values). +- `scripts/okf-index.mjs` — regenerate a root's per-level `index.md` (verbatim OKF index format). +- `scripts/okf-check.mjs` — validate each concept file carries `type:` (exit 1 + count otherwise); report `okf_version` per root. +- `scripts/compose-org-profile.mjs` — build the nested org-profile YAML with top-level OKF keys (`type`/`resource`/`timestamp`) before piping to `write-org-profile.mjs`. + +Retrieval is on-demand via the `okr-second-brain-search` skill. The UserPromptSubmit hook no longer pre-injects the full context-file enumeration — it emits the core profile plus one resolved pointer to `index.md` (< 512 B, independent of file count). + ## Language Policy - Commands, agents, user-facing text: Norwegian @@ -72,5 +92,10 @@ Cycle archival: `/okr:oppsett arkiver` — moves `syklus/` to `historikk/`, gene /okr:analyse ──→ trendanalytiker /okr:oppsett ──→ (inline wizard: full/mvp/arkiver/oppdater/vis) /okr:oppsett arkiver ──→ cycle archival + retrospektiv-generering +/okr:export ──→ scripts/export-pdf.py (weasyprint, documented prerequisite) +/okr:freshen-references ──→ (inline KB-evaluator + currency-polling via WebSearch/Task) +/okr:help ──→ (inline command/agent/workflow overview) SessionStart ──→ coaching-hook.mjs (proactive coaching) +UserPromptSubmit ──→ inject-okr-context.mjs (core profile + resolved index.md pointer) +okr-second-brain-search (skill) ──→ Glob/Read/Grep over .claude/okr/ + ~/.claude/okr/org/ (on-demand retrieval) ``` diff --git a/README.md b/README.md index 590fcdf..d888cf5 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ *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.3.2-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-10-blue) +![Commands](https://img.shields.io/badge/commands-13-blue) ![Hooks](https://img.shields.io/badge/hooks-4-green) -![References](https://img.shields.io/badge/references-16-yellow) +![References](https://img.shields.io/badge/references-17-yellow) ![License](https://img.shields.io/badge/license-MIT-lightgrey) --- @@ -124,6 +124,23 @@ Planning to introduce OKR in your organization? Get a phased rollout plan with r Translate tildelingsbrev requirements into OKR. Map the governance chain (Stortingsmelding → tildelingsbrev → etatsstrategi → OKR). Verify that your OKR documentation meets Riksrevisjon standards. +### Export Deliverables + +``` +> /okr:export +``` + +Renders any OKR deliverable — quality review, gap matrix, status report, or retrospective — to a print-ready A4 PDF for management or Riksrevisjon. Tables get striping and score cells are color-coded (green/yellow/red). PDF generation is a documented prerequisite (`pip install markdown weasyprint`, `brew install pango`), not bundled — a missing dependency exits with a clear install hint, never a traceback. + +### Help and Maintenance + +``` +> /okr:help +> /okr:freshen-references +``` + +`/okr:help` lists all 13 commands, 7 agents, and a recommended workflow keyed to where you are in the tertial cycle. `/okr:freshen-references` is a knowledge-base self-evaluator: it scores the 16 domain reference files against an anchored quality rubric and polls named public sources (UN EGDI, EU eGov Benchmark, OECD DGI, Digdir, WCAG, forvaltningsloven) to flag outdated `Sist oppdatert` markers. + --- ## Getting Started @@ -235,9 +252,16 @@ The plugin understands this hierarchy and helps you maintain alignment at every | PreCompact | Preserves OKR draft state if the conversation gets long | | Stop | Reminds you to save work to your tracking system | +### Skills + +| Skill | Role | +|-------|------| +| okr-offentlig-sektor | Core OKR methodology and Norwegian public-sector domain knowledge | +| okr-second-brain-search | On-demand retrieval from your personal OKF wiki (`.claude/okr/` + `~/.claude/okr/org/`) — in free chat and during `/okr:*` commands, without pre-injecting everything | + ### Knowledge Base -16 reference files covering OKR methodology, Norwegian public sector governance, antipatterns, meeting guides, metrics library, integration patterns, and more. The plugin reads only what's relevant to each interaction — never the whole library at once. +17 reference files covering OKR methodology, Norwegian public sector governance, antipatterns, meeting guides, metrics library, anchored quality rubrics, integration patterns, and more. The plugin reads only what's relevant to each interaction — never the whole library at once. ### Persistent Context @@ -266,6 +290,12 @@ 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 | +| 1.3.2 | 2026-06-24 | Fix: loopende Stop-hook fjernet | +| 1.3.1 | 2026-06-23 | Fakta- og troverdighetssanering | | **1.3.0** | 2026-04-08 | Gap analysis, cross-cycle analytics with Mermaid visualizations, proactive SessionStart coaching | | **1.1.0** | 2026-04-08 | Persistent context, deep onboarding, context-aware commands, cycle archival | | **1.0.0** | 2026-04-08 | Architecture overhaul, self-contained commands, hooks, marketplace-ready | diff --git a/agents/fremdriftssporer-agent.md b/agents/fremdriftssporer-agent.md index d8927e2..fbf57d8 100644 --- a/agents/fremdriftssporer-agent.md +++ b/agents/fremdriftssporer-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten når brukeren vil oppdatere OKR-status, beregne score, få prognose for måloppnåelse, eller generere statusrapport. + Ikke bruk denne for kryss-syklus-trender — bruk trendanalytiker i stedet. + Context: Bruker har nye tall og vil oppdatere status user: "Vi har oppnådd 130 av 150 på KR1, hva er scoren?" @@ -24,6 +26,10 @@ tools: ["Read", "Glob", "ToolSearch"] Du er en ekspert på å beregne OKR-fremgang, score og prognose. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Scoring-formel ``` @@ -141,3 +147,4 @@ Hvis Linear er konfigurert, tilby å: - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-calculator.md` - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-framework.md` +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-antipatterns.md` — les HELE filen ved fremdriftsvurdering (fange aktivitets-/sandbagging-/målforskyvnings-mønstre i statusen, ikke bare et utvalg) diff --git a/agents/gapanalytiker-agent.md b/agents/gapanalytiker-agent.md index 42b6c06..3e5dd2c 100644 --- a/agents/gapanalytiker-agent.md +++ b/agents/gapanalytiker-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten for automatisk gap-analyse mellom tildelingsbrev-krav og gjeldende OKR. Identifiserer udekte krav og OKR uten forankring. + Ikke bruk denne for kvalitativ tildelingsbrev-oversettelse eller compliance — bruk styringsrådgiver i stedet. Denne agenten leverer en kvantitativ dekningsmatrise. + Context: Bruker har tildelingsbrev og OKR lagret i .claude/okr/ user: "Sjekk om OKR dekker tildelingsbrevet" @@ -25,6 +27,10 @@ tools: ["Read", "Glob"] Du er en ekspert på å analysere samsvaret mellom tildelingsbrev-krav og gjeldende OKR i norsk offentlig sektor. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Din oppgave Analyser tildelingsbrev-krav mot gjeldende OKR og identifiser gap i begge diff --git a/agents/kaskadebygger-agent.md b/agents/kaskadebygger-agent.md index e576243..f0c781a 100644 --- a/agents/kaskadebygger-agent.md +++ b/agents/kaskadebygger-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten når brukeren trenger hjelp med å kaskadere OKR fra organisasjon til team, sikre alignment mellom nivåer, eller visualisere hvordan OKR henger sammen. + Ikke bruk denne for kvalitetsvurdering av enkelt-OKR — bruk kvalitetssjekker i stedet. + Context: Bruker vil kaskadere fra etat til team user: "Hvordan kobler vi team-OKR til etatens mål?" @@ -24,6 +26,10 @@ tools: ["Read", "Glob"] Du er en ekspert på å kaskadere OKR mellom organisasjonsnivåer og sikre vertikal alignment. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Kaskaderingsprinsipp ``` diff --git a/agents/kvalitetssjekker-agent.md b/agents/kvalitetssjekker-agent.md index ff29ae4..2abb976 100644 --- a/agents/kvalitetssjekker-agent.md +++ b/agents/kvalitetssjekker-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten når brukeren presenterer OKR for vurdering, ber om feedback på OKR-kvalitet, eller ønsker å forbedre eksisterende OKR. + Ikke bruk denne for dekning av tildelingsbrev — bruk gapanalytiker i stedet. + Context: Bruker deler OKR for vurdering user: "Er dette gode OKR? Objective: Forbedre kundeservice. KR1: Gjennomføre 5 kurs" @@ -24,6 +26,10 @@ tools: ["Read", "Glob"] Du er en ekspert på å vurdere OKR-kvalitet basert på Google/Doerr-metodikken tilpasset norsk offentlig sektor. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Din oppgave Når du mottar OKR for vurdering: @@ -110,5 +116,6 @@ Når du mottar OKR for vurdering: Les disse filene for metodikk: - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/SKILL.md` +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md` — ankret scoringsguide (5 ankere per dimensjon; single sannhetskilde for scoring) - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-antipatterns.md` - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-examples.md` diff --git a/agents/møtefasilitator-agent.md b/agents/møtefasilitator-agent.md index 957eeac..e617a4f 100644 --- a/agents/møtefasilitator-agent.md +++ b/agents/møtefasilitator-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten når brukeren skal planlegge eller gjennomføre OKR-møter, workshops, eller 1:1-samtaler. + Ikke bruk denne for fremdriftsscoring eller statusrapport — bruk fremdriftssporer i stedet. + Context: Bruker skal ha planleggingsworkshop user: "Vi skal ha OKR-planleggingsworkshop neste uke for 8 personer" @@ -24,6 +26,10 @@ tools: ["Read", "Glob"] Du er en ekspert på å planlegge og fasilitere OKR-relaterte møter i norsk offentlig sektor. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Møtetyper ### 1. Planleggingsworkshop diff --git a/agents/styringsrådgiver-agent.md b/agents/styringsrådgiver-agent.md index dda11f5..aca072c 100644 --- a/agents/styringsrådgiver-agent.md +++ b/agents/styringsrådgiver-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten når brukeren har spørsmål om OKR og offentlig sektor-styring, tildelingsbrev, Riksrevisjon-krav, politisk styring, eller dokumentasjon. + Ikke bruk denne for kvantitativ gap-/dekningsanalyse av tildelingsbrev — bruk gapanalytiker i stedet. Denne agenten gir kvalitativ oversettelse og compliance-rådgivning. + Context: Bruker har tildelingsbrev som skal bli OKR user: "Hvordan kobler vi OKR til tildelingsbrevet?" @@ -24,6 +26,10 @@ tools: ["Read", "Glob"] Du er en ekspert på OKR i kontekst av norsk offentlig sektor-styring. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Styringsrammeverk ``` diff --git a/agents/trendanalytiker-agent.md b/agents/trendanalytiker-agent.md index f60c873..21653ef 100644 --- a/agents/trendanalytiker-agent.md +++ b/agents/trendanalytiker-agent.md @@ -4,6 +4,8 @@ description: | Bruk denne agenten for å analysere OKR-trender på tvers av sykluser. Leser arkiverte resultater og identifiserer mønstre, fremgang og risiko. + Ikke bruk denne for én enkelt syklus' scoring — bruk fremdriftssporer i stedet. + Context: Bruker har 3+ arkiverte sykluser user: "Vis OKR-trender over tid" @@ -25,6 +27,10 @@ tools: ["Read", "Glob"] Du er en ekspert på å analysere OKR-trender over tid og identifisere mønstre i organisasjonens OKR-praksis. +## Kontekstbevissthet + +Org-kontekst kan være sendt med i Task-prompten fra den kallende kommandoen — bruk den hvis den finnes. Hvis ikke, les prosjekt-lokal `.claude/okr.local.md` (relativt til brukerens prosjekt-cwd) direkte. Forsøk ALDRI å lese en hjem-sti (under brukerens hjemmemappe) selv — hooken eier den hybrid-resolusjonen. Finnes ingen org-kontekst: fortsett uten — ikke feil. + ## Din oppgave Les arkiverte sykluser fra `.claude/okr/historikk/` og produser trendanalyse @@ -143,7 +149,7 @@ Map score-bane til modenhetsnivåer: | 0.5-0.7 | Skalering | | > 0.7 | Moden | -Sammenlign med selvrapportert `modenhetsnivaa` fra okr.local.md. +Sammenlign med selvrapportert `modenhetsnivaa` fra `.claude/okr.local.md`. Hvis avvik: kommenter forsiktig ("Score-trenden tilsier [nivå], mens organisasjonen rapporterer [nivå]. Vurder å oppdatere profilen."). diff --git a/commands/analyse.md b/commands/analyse.md index b76ea24..5b26160 100644 --- a/commands/analyse.md +++ b/commands/analyse.md @@ -12,11 +12,12 @@ antipatterns og alignment-utvikling med Mermaid-visualiseringer. ## Kontekstbevissthet -OKR-kontekst injiseres automatisk via hook. Sjekk system-konteksten: -- Hvis arkiverte sykluser er listet (f.eks. "Arkiverte sykluser (3): T1-2025, T2-2025, T3-2025"): - les filene i `.claude/okr/historikk/` direkte. -- Hvis ingen arkiverte sykluser finnes: vis hjelpsom melding (se edge cases). -- Sjekk også gjeldende syklus for sammenligning mot historikk. +Oppdag arkiverte sykluser direkte fra disk (hooken for-injiserer ikke lenger en +arkiv-liste — den emitterer kun et kjerne-sammendrag + en peker til wikien): +- Glob `.claude/okr/historikk/*/` for å liste arkiverte sykluser. Hver undermappe + er én arkivert syklus (f.eks. `T1-2025/`, `T2-2025/`, `T3-2025/`). +- Hvis ingen undermapper finnes: vis hjelpsom melding (se edge cases). +- Glob også `.claude/okr/syklus/` for gjeldende syklus til sammenligning mot historikk. ## Ruting basert på argument diff --git a/commands/export.md b/commands/export.md new file mode 100644 index 0000000..30adde4 --- /dev/null +++ b/commands/export.md @@ -0,0 +1,84 @@ +--- +name: okr:export +description: Eksporter OKR-dokumenter (kvalitetsvurdering, gap-matrise, statusrapport, retrospektiv) til print-klar PDF +allowed-tools: Read, Bash +argument-hint: "[dokumenttype eller filsti]" +--- + +# OKR Export - Eksporter OKR-leveranser til PDF + +Generer en print-klar A4-PDF av et OKR-dokument for deling med ledelse, +Riksrevisjon eller styringsdialog. Konverterer Markdown til PDF via +`scripts/export-pdf.py` (weasyprint), med tabell-striping og fargekodede +score-celler (`.score-green` / `.score-yellow` / `.score-red`). + +## Kontekstbevissthet + +OKR-kontekst injiseres automatisk via hook. Sjekk system-konteksten FØR du spør: +- Hvis syklus og aktive OKR-filer er listet (f.eks. `.claude/okr/syklus/T1-2026/`): + tilby å eksportere dem direkte. +- Hvis `.claude/okr/dokumenter/` inneholder genererte rapporter: tilby disse. +- Spør kun om dokumenttype når konteksten ikke gjør valget åpenbart. + +## Arbeidsflyt + +1. **Velg dokumenttype** — hvilken leveranse skal eksporteres: + - **kvalitetsvurdering** — output fra `/okr:kvalitet` + - **gap-matrise** — output fra `/okr:gap` + - **statusrapport** — output fra `/okr:sporing` + - **retrospektiv** — `retrospektiv.md` fra `/okr:oppsett arkiver` + - eller en vilkårlig Markdown-fil brukeren oppgir. + +2. **Sørg for Markdown-kilde** — hvis dokumentet finnes som fil, bruk filstien. + Hvis det kun finnes i samtalen, skriv det til en `.md`-fil først (f.eks. + `.claude/okr/dokumenter/kvalitetsvurdering-T1-2026.md`). + +3. **Kjør eksporten** via Bash: + ```bash + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/export-pdf.py + ``` + Eksempel: + ```bash + python3 ${CLAUDE_PLUGIN_ROOT}/scripts/export-pdf.py \ + .claude/okr/dokumenter/kvalitetsvurdering-T1-2026.md \ + .claude/okr/dokumenter/kvalitetsvurdering-T1-2026.pdf + ``` + +4. **Bekreft resultatet** — skriptet skriver `Skrev PDF: ` til stderr ved + suksess. Rapporter filstien til brukeren. + +**Tips for fargekoding:** I Markdown-tabellen kan score-celler tagges med +`attr_list`-syntaks slik at de blir fargelagt i PDF-en: + +```markdown +| KR | Score | +|----|-------| +| KR1 | 0.9 {: .score-green} | +| KR2 | 0.5 {: .score-yellow} | +| KR3 | 0.2 {: .score-red} | +``` + +## Feilhåndtering + +Eksporten har to dokumenterte forutsetninger (ikke bundlet med pluginet): + +- **Manglende Python-pakker** (`markdown`, `weasyprint`) → skriptet avslutter med + exit 1 og hintet: + ``` + pip install markdown weasyprint + ``` +- **Manglende native bibliotek** (Pango/cairo/gdk-pixbuf) → feiler ved rendring + med exit 1 og hintet: + ``` + brew install pango + ``` + +Begge feil gir en tydelig instruks (ikke en traceback). Når en forutsetning +mangler: rapporter instruksen til brukeren, ikke prøv å installere noe selv. + +## Referanser + +- `${CLAUDE_PLUGIN_ROOT}/scripts/export-pdf.py` — selve eksport-skriptet +- `${CLAUDE_PLUGIN_ROOT}/commands/kvalitet.md` — produserer kvalitetsvurdering +- `${CLAUDE_PLUGIN_ROOT}/commands/gap.md` — produserer gap-matrise +- weasyprint: https://doc.courtbouillon.org/weasyprint/stable/ diff --git a/commands/freshen-references.md b/commands/freshen-references.md new file mode 100644 index 0000000..dfa78e3 --- /dev/null +++ b/commands/freshen-references.md @@ -0,0 +1,166 @@ +--- +name: okr:freshen-references +description: KB-selvevaluator og currency-polling — scorer de 16 domene-referansefilene mot en ankret kvalitetsrubrikk og oppdager utdaterte kilder +allowed-tools: Read, Glob, WebSearch, Task +argument-hint: "[referansefil å fokusere på, eller tom for full gjennomgang]" +--- + +# OKR Freshen References - KB-selvevaluator + currency-polling + +Vedlikehold kunnskapsbasen (KB) bak OKR-pluginet. Kommandoen gjør to ting: + +1. **KB-selvevaluator** — scorer hver av de 16 domene-referansefilene mot en + ankret kvalitetsrubrikk (dekning + kvalitet), slik at svake filer kan + prioriteres for forbedring. +2. **Currency-polling** — sjekker navngitte offentlige kilder for å oppdage om + `Sist oppdatert`-markører i KB-en har blitt utdaterte siden sist. + +Dette speiler KB-selvevaluatoren i ms-ai-architect (ankret rubrikk, dekning- og +kvalitetsdimensjoner med fem ankere hver), tilpasset OKR-domenet for norsk +offentlig sektor. + +## Kontekstbevissthet + +OKR-kontekst injiseres automatisk via hook. Før du starter: +- Hvis brukeren oppgir en spesifikk referansefil som argument: scor kun den. +- Ellers: kjør full gjennomgang av alle 16 filer. +- Bruk `Glob` på `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/*.md` + for å bekrefte at fil-listen under fortsatt stemmer før scoring. + +## Arbeidsflyt + +### Del A — KB-selvevaluator + +**Filer som scores (16 domene-referansefiler):** + +1. `cfr-framework.md` +2. `dfo-okr-mapping.md` +3. `individual-vs-team-okr.md` +4. `meeting-guides.md` +5. `metrics-library.md` +6. `okr-antipatterns.md` +7. `okr-arshjul.md` +8. `okr-calculator.md` +9. `okr-cheatsheet.md` +10. `okr-examples.md` +11. `okr-framework.md` +12. `okr-implementation.md` +13. `okr-integrations.md` +14. `okr-oboard-guide.md` +15. `okr-offentlig-governance.md` +16. `okr-sources.md` + +**Eksplisitt ekskludert:** `okr-quality-rubrics.md` scores IKKE av denne +evaluatoren. Den er selv scoringsinstrumentet for `/okr:kvalitet`; en +KB-evaluator som scorer sin egen rubrikk blir sirkulær. + +**Slik scorer du:** les hver fil, finn ankeret den faktisk treffer per +dimensjon, og gi 1-5. Rapporter en tabell (fil × dimensjon) + en prioritert +liste over filer som scorer ≤ 2 på en eller flere dimensjoner. + +#### KB-kvalitetsrubrikk + +**Dekning** — måler om filen dekker domenet sitt fullstendig. + +##### Fullstendighet +Måler om filen dekker hele sitt erklærte domene uten åpenbare hull. + +1. **Anker 1 (svakest)** — Fragmentarisk; dekker bare en liten del av domenet filen lover. +2. **Anker 2** — Vesentlige hull; flere sentrale undertemaer mangler. +3. **Anker 3** — Dekker kjernen, men med merkbare hull i randsoner. +4. **Anker 4** — Nær fullstendig; kun mindre kanttilfeller utelatt. +5. **Anker 5 (sterkest)** — Fullstendig dekning av domenet med kanttilfeller adressert. + +##### Offentlig-sektor-tilpasning +Måler om innholdet er tilpasset norsk offentlig kontekst (ikke generisk privat-sektor-OKR). + +1. **Anker 1 (svakest)** — Rent generisk; ingen tilpasning til offentlig sektor. +2. **Anker 2** — Overflatisk tilpasning; norske termer limt på generisk innhold. +3. **Anker 3** — Delvis tilpasset; noen offentlig-spesifikke eksempler. +4. **Anker 4** — Godt tilpasset; tertialsyklus, tildelingsbrev og etatskontekst gjennomgående. +5. **Anker 5 (sterkest)** — Fullt forankret i norsk offentlig praksis med konkrete, etterprøvbare eksempler. + +##### Eksempeldekning +Måler om filen gir konkrete eksempler, ikke bare abstrakte prinsipper. + +1. **Anker 1 (svakest)** — Ingen eksempler; ren teori. +2. **Anker 2** — Ett eller to tynne eksempler. +3. **Anker 3** — Noen eksempler, men ujevnt fordelt. +4. **Anker 4** — Gode eksempler på de fleste sentrale punkter. +5. **Anker 5 (sterkest)** — Rike, varierte gode-vs-dårlige eksempler gjennomgående. + +##### Kryssreferanser +Måler om filen kobler til relevante søsken-referanser og kommandoer. + +1. **Anker 1 (svakest)** — Isolert; ingen kobling til resten av KB-en. +2. **Anker 2** — Én løs henvisning. +3. **Anker 3** — Noen kryssreferanser, men ufullstendige. +4. **Anker 4** — Godt koblet til relaterte filer og kommandoer. +5. **Anker 5 (sterkest)** — Fullt integrert; peker presist til alle relevante naboer. + +**Kvalitet** — måler hvor pålitelig og brukbart innholdet er. + +##### Presisjon +Måler om påstandene er presise og entydige. + +1. **Anker 1 (svakest)** — Vagt og flertydig; vanskelig å handle på. +2. **Anker 2** — Flere upresise eller løse formuleringer. +3. **Anker 3** — Stort sett presist, med noen vage partier. +4. **Anker 4** — Presist; kun ubetydelige uklarheter. +5. **Anker 5 (sterkest)** — Skarpt og entydig hele veien; ingen tolkningsrom. + +##### Provenans +Måler om faktapåstander er kildebelagt (jf. verifiseringsplikt). + +1. **Anker 1 (svakest)** — Ingen kilder; påstander hviler på antakelse. +2. **Anker 2** — Få kilder; det meste ubelagt. +3. **Anker 3** — Sentrale påstander belagt, men hull gjenstår. +4. **Anker 4** — Godt kildebelagt; uverifisert merket som «Ikke verifisert». +5. **Anker 5 (sterkest)** — Hver faktapåstand provenans-tagget til primærkilde. + +##### Aktualitet +Måler om innholdet er oppdatert mot gjeldende rammeverk, lover og tall. + +1. **Anker 1 (svakest)** — Utdatert; refererer opphevde lover eller døde rammeverk. +2. **Anker 2** — Flere foreldede referanser. +3. **Anker 3** — Stort sett aktuelt, men enkelte utdaterte tall. +4. **Anker 4** — Aktuelt; `Sist oppdatert`-markør innenfor siste år. +5. **Anker 5 (sterkest)** — Fullt aktuelt mot nyeste rammeverk og gjeldende rett. + +##### Handlingsbarhet +Måler om leseren kan handle på innholdet uten ekstra oppslag. + +1. **Anker 1 (svakest)** — Ren bakgrunn; gir ingen handlingsretning. +2. **Anker 2** — Antyder handling, men for abstrakt til å følge. +3. **Anker 3** — Handlingsbart for den erfarne, men krever tolkning. +4. **Anker 4** — Klar handlingsretning på de fleste punkter. +5. **Anker 5 (sterkest)** — Umiddelbart handlingsbart; konkrete steg og maler. + +### Del B — Currency-polling + +Volatile offentlige kilder endrer seg uavhengig av KB-en. Bruk en +`WebSearch`/Tavily-subagent (via `Task`) til å sjekke om `Sist oppdatert`- +markørene i KB-en (særlig i `metrics-library.md` og `okr-offentlig-governance.md`) +har blitt utdaterte. Poll disse navngitte kildene: + +- **UN E-Government Survey (EGDI)** — ny utgave annethvert år; sjekk siste indeksår. +- **EU eGovernment Benchmark** — årlig rapport; sjekk gjeldende årgang. +- **OECD Digital Government Index (DGI)** — periodisk; sjekk siste publisering. +- **Digdir «Rikets digitale tilstand»** — årlig; sjekk siste utgave. +- **WCAG** — sjekk om gjeldende referansenivå (2.1 A+AA) fortsatt er det som + henvises i offentlig regelverk, og status for EAA-ikrafttredelse i Norge. +- **Forvaltningsloven** — sjekk om den nye forvaltningsloven (vedtatt 2025) har + trådt i kraft og erstattet 1967-loven KB-en refererer. +- **eFormidling** — sjekk om status fortsatt er «bør» (ikke påbudt) for statlige virksomheter. + +Kildegrunnlaget for de volatile markørene er dokumentert i +`${CLAUDE_PLUGIN_ROOT}/.claude/projects/2026-06-24-fase3-referansegrad-loft/research/01-norsk-offentlig-metrikker.md` +(lokal research). Rapporter hvilke `Sist oppdatert`-markører som bør bumpes, og +hvilke faktapåstander som må re-verifiseres. + +## Referanser + +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/` — de 16 domenefilene som scores +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md` — OKR-scoringsrubrikk (ekskludert fra KB-scoring; ankerstilen gjenbrukt her) +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-sources.md` — primærkilde-register (provenans-stil) +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/metrics-library.md` — bærer volatile offentlig-metrikker (hovedmål for currency-polling) diff --git a/commands/gap.md b/commands/gap.md index b0e252c..bcfd031 100644 --- a/commands/gap.md +++ b/commands/gap.md @@ -10,6 +10,8 @@ argument-hint: "[tildelingsbrev-fil eller tomt for auto-deteksjon]" Analyser automatisk om gjeldende OKR dekker kravene i tildelingsbrevet, og om OKR har forankring i styrende dokumenter. +> **Bruk /okr:gap når** du trenger en *kvantitativ dekningsmatrise* mellom tildelingsbrev og gjeldende OKR (hvilke krav er dekket, hvilke mangler). For *kvalitativ oversettelse* av tildelingsbrev til OKR + Riksrevisjon-compliance, bruk `/okr:governance`. + ## Kontekstbevissthet OKR-kontekst injiseres automatisk via hook. Sjekk system-konteksten FØR du spør brukeren: @@ -39,15 +41,12 @@ Auto-les fra persistent context: Les referansemateriale: - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-offentlig-governance.md` -Ekstraher individuelle krav fra tildelingsbrevet. Kategoriser hvert krav: - -| Type innhold | OKR-egnet | Riktig håndtering | -|--------------|-----------|-------------------| -| Driftskrav ("Oppretthold X") | Lav | KPI-dashboard | -| Resultatmål med tall | Høy | Key Result-kandidat | -| Strategiske satsinger | Høy | Objective-kandidat | -| Rapporteringskrav | Lav | Rapporteringsrutine | -| Særskilte oppdrag | Medium | Case by case | +Ekstraher individuelle krav fra tildelingsbrevet. Kategoriser hvert krav etter +innholdsklassifiseringen i `/okr:governance` (Driftskrav → KPI-dashboard, +Resultatmål med tall → Key Result-kandidat, Strategiske satsinger → +Objective-kandidat, Rapporteringskrav → rapporteringsrutine, Budsjettføringer → +økonomioppfølging, Særskilte oppdrag → case by case). Se `/okr:governance` for +full tabell — denne kommandoen dupliserer den ikke. ### 3. OKR-mapping (tildelingsbrev → OKR) diff --git a/commands/governance.md b/commands/governance.md index 79aace9..7a567c8 100644 --- a/commands/governance.md +++ b/commands/governance.md @@ -9,6 +9,8 @@ argument-hint: "[tildelingsbrev, revisjonsrapport, eller spørsmål]" Hjelp brukeren med å koble OKR til styringsmekanismer i norsk offentlig sektor. +> **Bruk /okr:governance når** du trenger *kvalitativ oversettelse* av tildelingsbrev til OKR + Riksrevisjon-compliance og politisk styringskontekst. For en *kvantitativ dekningsmatrise* (hvilke tildelingsbrev-krav er dekket av gjeldende OKR), bruk `/okr:gap`. + ## Kontekstbevissthet OKR-kontekst injiseres automatisk via hook. Sjekk system-konteksten FØR du spør brukeren: @@ -115,3 +117,5 @@ Politiske signaler kan endre seg midt i syklusen: - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-offentlig-governance.md` — full governance-veiledning - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-arshjul.md` — årshjul og budsjettprosess +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/dfo-okr-mapping.md` — DFØ mål- og resultatstyring → OKR-mapping +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-sources.md` — kildebelegg og proveniens for governance-påstander diff --git a/commands/help.md b/commands/help.md new file mode 100644 index 0000000..7cfa216 --- /dev/null +++ b/commands/help.md @@ -0,0 +1,77 @@ +--- +name: okr:help +description: Full oversikt over alle OKR-kommandoer, agenter og anbefalt arbeidsflyt gjennom tertialsyklusen +allowed-tools: Read +argument-hint: "[tema å få hjelp med, eller tom for full oversikt]" +--- + +# OKR Help - Kommando- og agentoversikt + +Gi brukeren en oversikt over hva pluginet kan, og hvilken kommando som passer +til hva. Hvis brukeren oppgir et tema som argument, foreslå de mest relevante +kommandoene for det temaet. Ellers vis full oversikt. + +## Kommandoer (13) + +| Kommando | Hva den gjør | +|----------|--------------| +| `/okr:skriv` | Skriv ny OKR med veiledet Objective- og KR-utforming | +| `/okr:kvalitet` | Kvalitetssjekk OKR mot ankret rubrikk og 19 antipatterns | +| `/okr:kaskade` | Kaskader OKR fra org til team, visualiser alignment | +| `/okr:sporing` | Spor fremdrift, beregn score (0.0–1.0), generer check-ins | +| `/okr:møter` | Planlegg OKR-workshops, check-ins, reviews og 1:1 (CFR) | +| `/okr:innføring` | Innføringsplan, motstandshåndtering, modenhetsvurdering | +| `/okr:governance` | Tildelingsbrev-oversettelse, Riksrevisjon-compliance | +| `/okr:gap` | Automatisk gap-analyse mellom tildelingsbrev og gjeldende OKR | +| `/okr:analyse` | Kryss-syklus-analyse med Mermaid-trendvisualisering | +| `/okr:oppsett` | Konfigurer plugin: onboarding (`full`/`mvp`), `arkiver`, `oppdater`, `vis` | +| `/okr:export` | Eksporter OKR-dokumenter til print-klar PDF (ledelse/Riksrevisjon) | +| `/okr:freshen-references` | KB-selvevaluator + currency-polling av offentlige kilder | +| `/okr:help` | Denne oversikten — kommandoer, agenter, anbefalt arbeidsflyt | + +## Agenter (7) + +Agentene aktiveres automatisk av kommandoene over; du kaller dem ikke direkte. + +| Agent | Rolle | +|-------|-------| +| kvalitetssjekker | Score kvalitet, oppdage antipatterns, sjekke alignment | +| kaskadebygger | Bygge OKR-kaskader mellom organisasjonsnivåer | +| fremdriftssporer | Beregne score, lage prognoser, flagge risiko-KR | +| møtefasilitator | Generere møteagendaer og fasiliteringsmateriell | +| styringsrådgiver | Governance-analyse, tildelingsbrev-oversettelse, audit-compliance | +| gapanalytiker | Dekningsmatrise mellom strategidokumenter og OKR | +| trendanalytiker | Kryss-syklus mønsteranalyse med trendvisualisering | + +## Anbefalt arbeidsflyt + +OKR-arbeid følger tertialsyklusen (16 uker). Velg kommando etter hvor i syklusen +du er — coaching-hooken minner deg på dette ved sesjonsstart. + +### Tidlig i syklus (uke 1–5) — sett retning +1. `/okr:oppsett full` (én gang) — la pluginet lære organisasjonen din +2. `/okr:governance` / `/okr:gap` — forankre i tildelingsbrev, finn dekningshull +3. `/okr:skriv` — skriv Objectives og Key Results +4. `/okr:kvalitet` — kvalitetssjekk og forbedre +5. `/okr:kaskade` — juster mot org-OKR + +### Midt i syklus (uke 6–11) — hold tempo +6. `/okr:sporing` — oppdater fremdrift, fang risiko tidlig +7. `/okr:møter` — forbered check-in og 1:1 (CFR) + +### Sent i syklus (uke 12–16) — lukk og lær +8. `/okr:sporing` — endelig scoring +9. `/okr:export` — eksporter status/retrospektiv til PDF for ledelse/Riksrevisjon +10. `/okr:oppsett arkiver` — arkiver syklus, generer retrospektiv +11. `/okr:analyse` — se trender på tvers av sykluser +12. `/okr:skriv` — start neste syklus med lærdommene + +### Løpende vedlikehold +- `/okr:help` — denne oversikten når du er usikker på hvilken kommando du trenger +- `/okr:freshen-references` — hold kunnskapsbasen aktuell (KB-scoring + kilde-polling) + +## Referanser + +- `${CLAUDE_PLUGIN_ROOT}/README.md` — fyldig introduksjon og bruksområder +- `${CLAUDE_PLUGIN_ROOT}/CLAUDE.md` — kommando-/agent-/hook-arkitektur +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/SKILL.md` — kunnskapsbase-indeks diff --git a/commands/kaskade.md b/commands/kaskade.md index c137f87..1d7d6d9 100644 --- a/commands/kaskade.md +++ b/commands/kaskade.md @@ -98,3 +98,4 @@ Team KR: "100% av identifiserte strekninger remarked innen august" - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-framework.md` — kaskaderingsmetodikk - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-offentlig-governance.md` — hierarki i offentlig sektor +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/individual-vs-team-okr.md` — individ- vs team-OKR ved kaskadering diff --git a/commands/kvalitet.md b/commands/kvalitet.md index cd3cfad..ad42a81 100644 --- a/commands/kvalitet.md +++ b/commands/kvalitet.md @@ -114,5 +114,6 @@ OKR-kontekst injiseres automatisk via hook. Sjekk system-konteksten FØR du spø ## Referanser +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md` — ankret scoringsrubrikk (5 ankere per dimensjon); kanonisk kilde for scoringen over - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-antipatterns.md` — alle 19 antipatterns - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-examples.md` — gode vs dårlige eksempler diff --git a/commands/møter.md b/commands/møter.md index 14924b5..27923ba 100644 --- a/commands/møter.md +++ b/commands/møter.md @@ -9,13 +9,22 @@ argument-hint: "[møtetype eller kontekst]" Hjelp brukeren med å planlegge og gjennomføre OKR-relaterte møter. +## Kontekstbevissthet + +OKR-kontekst injiseres automatisk via hook. Sjekk system-konteksten FØR du spør brukeren: +- Hvis organisasjon og syklus er kjent: hopp over de spørsmålene +- Hvis relevante filer er listet (f.eks. `.claude/okr/syklus/T1-2026/okr-teamet.md`): + les den filen direkte i stedet for å be brukeren lime inn innhold +- Hvis `.claude/okr/strategisk-kontekst/` inneholder relevante docs: les dem +- Hvis syklusfase er kjent (fra injisert kontekst), tilpass møtetype og timing + direkte uten å spørre «hvor i syklusen er dere». + ## Arbeidsflyt 1. **Identifiser møtetype** — spør med AskUserQuestion: - Planleggingsworkshop, check-in, review, eller 1:1? - Hvor mange deltakere? - Fysisk eller digitalt? - - Hvor i syklusen er dere? 2. **Les relevant referansemateriale**: - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/meeting-guides.md` — alle møteguider diff --git a/commands/oppsett.md b/commands/oppsett.md index 545ed79..a006729 100644 --- a/commands/oppsett.md +++ b/commands/oppsett.md @@ -137,12 +137,30 @@ Still alle spørsmål med AskUserQuestion (grupper 2-3 sammen der naturlig): (stikkpunkter, ikke hele brevet) så kan pluginen bruke dem til alignment." Hvis brukeren limer inn tekst: skriv til - `.claude/okr/strategisk-kontekst/tildelingsbrev-2026.md` med YAML-frontmatter - og innlimt innhold. Opprett mappene med Write-tool. + `.claude/okr/strategisk-kontekst/tildelingsbrev-2026.md` med **OKF-frontmatter** + (second-brain-kompatibel: påkrevd `type:` i Title-Case + anbefalt `resource`/ + `title`/`description`/`tags`-liste/`timestamp`) og innlimt innhold. Opprett mappene + med Write-tool: + + ```markdown + --- + type: Tildelingsbrev + resource: "[lenke/arkiv-ID til kilden]" + title: "Tildelingsbrev 2026" + description: "Nøkkelmål fra tildelingsbrevet for alignment" + tags: + - tildelingsbrev + - styringssignaler + timestamp: "[ISO-8601, f.eks. 2026-01-15T09:00:00+00:00]" + --- + + [innlimt innhold] + ``` 14. **Har dere en virksomhetsplan eller strategiplan?** Eventuelt lenke eller nøkkelpunkter. Hvis innhold gis: skriv til - `.claude/okr/strategisk-kontekst/virksomhetsplan.md`. + `.claude/okr/strategisk-kontekst/virksomhetsplan.md` med OKF-frontmatter + (`type: Virksomhetsplan` + anbefalte felt som over). 15. **Har dere org-nivå OKR for 2026?** Alternativer: ja | nei | planlegges @@ -150,9 +168,19 @@ Still alle spørsmål med AskUserQuestion (grupper 2-3 sammen der naturlig): Hvis ja: "Lim inn org-OKR (Objectives og Key Results) så pluginen kan bruke dem til kaskadering og alignment-sjekk." - Hvis innhold gis: skriv til `.claude/okr/strategisk-kontekst/overordnede-okr.md`. + Hvis innhold gis: skriv til `.claude/okr/strategisk-kontekst/overordnede-okr.md` + med OKF-frontmatter (`type: Overordnede OKR` + anbefalte felt som over). **Etter fase 3:** Oppdater `.claude/okr.local.md` med eventuell strategisk kontekst-info. +Hvis du skrev én eller flere strategisk-kontekst-filer, regenerér OKF-indeksen for +prosjekt-treet så `index.md` per nivå reflekterer de nye konsept-filene: + +```bash +node ${CLAUDE_PLUGIN_ROOT}/scripts/okf-index.mjs .claude/okr +``` + +(Idempotent: bygger `index.md`-entries fra konsept-filenes `title`/`description`, +bevarer menneske-skrevne overskrifter og rotens `okf_version`.) ### Fase 4 — Struktur (3 min) @@ -223,7 +251,31 @@ Etter alle 6 faser: 1. Skriv komplett YAML til `.claude/okr.local.md` (oppdater alle seksjoner) 2. Sett `onboarding_status: fullfort` og `Sist oppdatert: [dato]` -3. Vis oppsummering: +3. **Skriv reinstall-overlevende org-profil til hjem-stien.** Profilen + (`organisasjon:`- og `program:`-seksjonene — org-identiteten som skal overleve + reinstall og være tilgjengelig på tvers av prosjekter) skrives **uten `---`-fences** + til en temp-fil og sendes gjennom OKF-komponisten + skrivehelperen via Bash: + + ```bash + OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/compose-org-profile.mjs < /tmp/okr-profil.tmp \ + | node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs) + echo "$OUT" + ``` + + `compose-org-profile.mjs` prepender de tre top-level OKF-nøklene (`type: + Organisasjonsprofil`, `resource:`, `timestamp:`) over den nestede YAML-en og pakker + alt i **ett** frontmatter-blokk (second-brain-kompatibelt). Legg **aldri** til en ny + top-level `navn:`/`id:`/`fase:`/`domene:`/`sektor:` i profilen — de ville skygge de + nestede verdiene som hooken leser. Helperen skriver atomisk (temp + `renameSync`) til `~/.claude/okr/org/profil.md` og + rapporterer den **faktisk brukte stien** på stdout (`$OUT`); en eventuell + fallback-notice går til stderr. Rapporter til brukeren: «Org-profil skrevet til: $OUT». + Hvis `$OUT` peker på den prosjektlokale fallbacken (`.claude/okr.local.md` — skjer kun + ved skrivefeil mot hjem), vis i tillegg hele profil-YAML-en for manuell plassering + (jf. Feilhåndtering «Fil kan ikke skrives»), slik at brukeren kan legge + `~/.claude/okr/org/profil.md` på plass selv. Behold den prosjektlokale + `.claude/okr.local.md`-skrivingen (steg 1) for syklus- og strategisk-kontekst-data — + kun selve profilen migrerer til hjem. +4. Vis oppsummering: ``` Onboarding fullført for [organisasjon]! @@ -317,6 +369,22 @@ preferanser: --- ``` +**Skriv også reinstall-overlevende org-profil til hjem-stien.** Skriv profil-YAML-en +(`organisasjon:` + `program:`, **uten `---`-fences**) til en temp-fil og kjør OKF-komponisten ++ skrivehelperen via Bash: + +```bash +OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/compose-org-profile.mjs < /tmp/okr-profil.tmp \ + | node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs) +echo "$OUT" +``` + +`compose-org-profile.mjs` prepender top-level OKF-nøkler (`type: Organisasjonsprofil`, +`resource:`, `timestamp:`) over den nestede profilen i ett frontmatter-blokk. Rapporter +«Org-profil skrevet til: $OUT». Hvis `$OUT` er den prosjektlokale fallbacken, vis YAML-en +for manuell plassering (jf. Feilhåndtering). Syklus-/kontekstdata forblir prosjektlokalt +i `.claude/okr.local.md`. + Vis kort oppsummering og foreslå `/okr:oppsett full` for å fylle ut resten senere. --- @@ -410,6 +478,7 @@ Opprett `.claude/okr/syklus/[id]/` med OKR-filer for å bruke arkivering. ```markdown --- + type: Retrospektiv syklus: [id] periode: [periode fra config] arkivert: [dato] @@ -472,7 +541,27 @@ Opprett `.claude/okr/syklus/[id]/` med OKR-filer for å bruke arkivering. - Oppdater `gjeldende_syklus.periode` til neste periodestreng 7. **Opprett ny syklusmappe** — skriv `.claude/okr/syklus/[ny-id]/status.md` med - tom mal (tabellstruktur med KR-kolonner, ingen data ennå). + OKF-frontmatter + tom mal (tabellstruktur med KR-kolonner, ingen data ennå): + + ```markdown + --- + type: Status + syklus: [ny-id] + title: "Status [ny-id]" + description: "Fremdrift og scoring for syklus [ny-id]" + timestamp: "[ISO-8601]" + --- + + # OKR Status [ny-id] + + [tom tabellstruktur med KR-kolonner] + ``` + + Regenerér deretter OKF-indeksen for prosjekt-treet: + + ```bash + node ${CLAUDE_PLUGIN_ROOT}/scripts/okf-index.mjs .claude/okr + ``` 8. **Bekreft** — vis oppsummering: ``` diff --git a/commands/skriv.md b/commands/skriv.md index 1adebf9..1bfe089 100644 --- a/commands/skriv.md +++ b/commands/skriv.md @@ -131,7 +131,22 @@ Hjelp med å balansere committed vs aspirational mål. ## Eksempel på komplett output -``` +Når en OKR lagres som konsept-fil i second-brain-treet +(`.claude/okr/syklus/[id]/okr-[team].md`), bær den **OKF-frontmatter** (påkrevd +`type: OKR` + anbefalt `resource`/`title`/`description`/`tags`-liste/`timestamp`): + +```markdown +--- +type: OKR +resource: ".claude/okr/syklus/T2-2026/okr-digital.md" +title: "OKR Digital avdeling T2-2026" +description: "Gjøre tjenestefornyelse friksjonsfri" +tags: +- digital +- tjenestefornyelse +timestamp: "[ISO-8601]" +--- + ## OKR for Digital avdeling — T2-2026 **Objective**: Gjøre tjenestefornyelse til en friksjonsfri opplevelse @@ -152,3 +167,4 @@ Hjelp med å balansere committed vs aspirational mål. - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-framework.md` — full metodikk - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-examples.md` — eksempler +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-cheatsheet.md` — hurtigreferanse for OKR-formulering diff --git a/commands/sporing.md b/commands/sporing.md index b4a20e4..8be9cad 100644 --- a/commands/sporing.md +++ b/commands/sporing.md @@ -69,7 +69,19 @@ Generer en strukturert check-in: ## Eksempel på output -``` +Når statusrapporten lagres som `.claude/okr/syklus/[id]/status.md` i second-brain-treet, +bær filen **OKF-frontmatter** (påkrevd `type: Status` + anbefalt `title`/`description`/ +`timestamp`); rapportinnholdet under følger frontmatteren: + +```markdown +--- +type: Status +syklus: T1-2026 +title: "Status T1-2026" +description: "Fremdrift og scoring uke 8 av 16" +timestamp: "[ISO-8601]" +--- + ## OKR Status - Uke 8 av 16 ### Objective: Forbedre trafikksikkerhet i skolesoner @@ -102,3 +114,5 @@ Hvis Linear er konfigurert (sjekk med ToolSearch): - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-calculator.md` — beregningsformler - `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-framework.md` — scoring-metodikk +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/metrics-library.md` — metrikkbibliotek for offentlige KR +- `${CLAUDE_PLUGIN_ROOT}/skills/okr-offentlig-sektor/references/okr-integrations.md` — Linear-/verktøyintegrasjon for statussporing 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-funn-2026-06.md b/docs/innboks-ingestion-funn-2026-06.md new file mode 100644 index 0000000..0094ea4 --- /dev/null +++ b/docs/innboks-ingestion-funn-2026-06.md @@ -0,0 +1,109 @@ +# Innboks-ingestion — funn-grunnlag (2026-06-26) + +> Self-bearing grunnlag for den **køede** innboks-ingestion-oppgaven (start **etter** +> OKF-fasen er stengt; egen minor-bump). Speiler rollen `okf-second-brain-note-2026-06.md` +> hadde for OKF-fasen. Alle påstander om Google-repoet er **verifisert mot faktisk lest +> innhold** (kilder i §9) — ikke antakelser. + +## 1. Oppgaven + +Bruker slipper vilkårlige dokumenter (PDF, Word, tekst, e-post) i en **innboks-mappe**. +okr-pluginen skal oppdage dem, konvertere til markdown, og legge dem inn **OKF-kompatibelt** +i second-brain-treet — med korrekt frontmatter (`type/resource/title/description/tags/timestamp`), +`index.md` per nivå, **og relasjoner/kryss-lenker** mellom konsepter. Original beholdes, +markdown-peker plasseres. Søsken-pluginen `ms-ai-architect` har samme oppgave (delt mønster). + +## 2. Hovedkonklusjon + +**Det finnes ingen ferdig innboks→OKF-ingestion-løsning med relasjonshåndtering — verken i +`GoogleCloudPlatform/knowledge-catalog` eller i okr-pluginen.** Google gir oss spec + +gjenbrukbare byggeklosser (index-generator, relasjons-mønster, kilde-utvidelsespunkt), men +**ikke** en pipeline fra «ustrukturert innboks» til «ferdig OKF-bundle». Dette blir et reelt +byggeløp. + +Rammeavklaring (verifisert): «knowledge-catalog» = Google Cloud **Knowledge Catalog (tidl. +Dataplex)** — metadata for *dataassets*. «OKF» (`okf/`) er et eget, vendor-nøytralt +markdown-format. Beslektede, men distinkte spor i repoet. + +## 3. Hva Google-repoet faktisk har + +| Komponent | Sti | Hva det er | Innboks-ingestion? | +|---|---|---|---| +| `reference_agent` | `okf/src/reference_agent/` | OKF-**produsent** (CLI: `enrich`/`visualize`). Auto-genererer `index.md` + auto-vever kryss-lenker | **Delvis** — kun fra **BigQuery + web-crawl**, ingen «ingest folder»-kommando | +| `bundle/index.py` (`regenerate_indexes`) | `okf/src/reference_agent/bundle/` | Kildeagnostisk `index.md`-generator per nivå (LLM-synt. mappebeskrivelser) | Gjenbrukbart | +| `sources/base.py` (`Source`-ABC) | `okf/src/reference_agent/sources/` | Utvidelsespunkt for kilder; eneste impl. er `bq` (`_SOURCES = ("bq",)`) | Utvidelsespunkt — ingen fil-/dokumentkilde finnes | +| `fileskb` / `md-fileset` | `samples/enrichment/src/tools/fileskb/`, `toolbox/enrichment/src/tools/md/` | **Read-only** MCP over en markdown-mappe (`fileset-source`-skill) | Nei — retrieval, antar allerede markdown | +| `enrichment` | `samples/enrichment/`, `toolbox/enrichment/` | Beriker **katalog-metadata** for et BQ-datasett, publiserer til Dataplex | Nei — ikke OKF-output | +| `discovery` | `samples/discovery/SKILL.md` | `knowledge_catalog_discovery_agent` — søk/ranking | Nei — ren retrieval | +| `mdcode` | `toolbox/mdcode/` | Spec + bi-direksjonell sync (`kcmd` pull/push) mot Dataplex | Nei — sync, ikke ingestion | +| `okf/SPEC.md` | `okf/` | OKF v0.1 spec (markdown + frontmatter, `index.md`, `log.md`, kryss-lenker, citations) | Spec | + +## 4. Relasjons-modellen i OKF (verifisert, `okf/SPEC.md` §5, §8) + +- Relasjoner = **vanlige markdown-lenker i body**, IKKE et frontmatter-felt. Anbefalt + bundle-relativ form (`/tables/customers.md`). En lenke er en *uttypet, rettet* relasjon; + *hvilken* relasjon (join/parent/depends-on) bæres av prosaen rundt lenken. +- Citations (§8) = lenker til eksterne kilder. Vieweren beregner «Cited by»-backlinks som + reverserte kanter. +- **Auto-etablering finnes** i `reference_agent` (`prompts/reference_instruction.md` + + `web_ingestion_instruction.md`: mynter `references/joins/__.md`, `references/metrics/.md`, + legger til `# Joins`/`# Metrics`-seksjoner) — men logikken er **skreddersydd for BQ-joins/metrikker + + web-dokumentasjon**, ikke et vilkårlig dokumentkorpus. +- Kontrast: `mdcode` har et eget `EntryLink`-konsept i YAML for *katalog*-relasjoner — separat fra OKF. + +## 5. Gap — hva vi må bygge selv + +1. **Dokument-konvertering** (PDF/Word/e-post/tekst → markdown). Finnes ikke i repoet. De + eneste fil-verktøyene (`fileskb`/`md-fileset`) antar markdown og er read-only. +2. **Konsept-ekstraksjon + frontmatter-tilordning** for vilkårlige dokumenter (splitte til + «konsepter», sette `type/resource/title/description/tags/timestamp`). reference_agent gjør + dette kun for BQ-tabeller. +3. **Generalisert relasjons-oppdagelse** — dagens relasjons-prompt er BQ/web-spesifikk og må + generaliseres til vilkårlige dokumenter. +4. **Innboks-orkestrering** — oppdage nye filer, beholde original + markdown-peker, idempotent + re-kjøring, plassering i riktig nivå av treet. + +## 6. Gjenbrukbart (lener oss på, bygger ikke fra null) + +- **Index-generering:** Vi har allerede `scripts/okf-index.mjs` (Step 6, OKF-fasen) som speiler + Googles `regenerate_indexes` — kildeagnostisk, idempotent. Innboks-pipelinen kan kalle den + etter skriv (samme mønster som `oppsett.md`-tre-skriverne i Step 8). +- **Frontmatter:** `lib/frontmatter.mjs` (parse/skrive) + `scripts/okf-check.mjs` (validering) + fra OKF-fasen dekker skrive- og verifiserings-siden. +- **Kilde-mønster:** Googles `Source`-ABC viser et rent adapter-mønster — en «innboks-kilde» + som lister konsepter fra en mappe kunne mate eksisterende skrive-/index-/kryss-lenke-maskineri. + +## 7. Forbehold / ikke verifisert (verifiseringsplikt) + +- **`kb-search/SKILL.md` ble IKKE funnet** i repoet, til tross for at våre egne planer + (`plan.md:26`, `brief.md:168`) refererer den. Skillene som finnes heter `fileset-source` + (retrieval) og `knowledge_catalog_discovery_agent` (søk). Mulig repoet har endret seg, eller + referansen var unøyaktig. **Ikke kritisk for OKF-fasen** (vår retrieval-SKILL er ferdig og + testet), men korrigerer en navne-antakelse for innboks-løpet. +- Ikke fullt lest: `okf/src/reference_agent/{agent.py, runner.py, tools/*.py}` og + `toolbox/enrichment/src/agent/*`. Kilde-/ingestion-konklusjonen er likevel entydig fra + `cli.py` (`_SOURCES = ("bq",)`), `sources/`-innholdet, og enrichment-README/`enrich.py`. + +## 8. Sekvens / scope + +- **Start: etter OKF-fasen er stengt** (Session 5 release → 1.6.0). Scope-guard: innboks bygger + på OKF-leveransen og er bevisst utsatt — **ikke start før operatør sier fra**. +- Eget Voyage-løp (brief → plan → execute), egen **minor-bump** (1.7.0-kandidat). +- Delt mønster med `ms-ai-architect` — vurder felles abstraksjon før dobbel-implementasjon. + +## 9. Kilder (faktisk lest, etterprøvbart) + +GitHub API-mappelistinger: repo-rot, `okf/`, `samples/`, `toolbox/`, +`okf/src/reference_agent/{prompts,tools,sources,bundle}`, `samples/enrichment/src/{enrichment,tools}`, +`toolbox/enrichment/src/{agent,tools}`, `toolbox/mdcode/{docs,demo}`. + +Rå filer (`raw.githubusercontent.com/GoogleCloudPlatform/knowledge-catalog/main/`): +`README.md`, `samples/README.md`, `okf/README.md`, `okf/SPEC.md`, +`okf/src/reference_agent/cli.py`, `.../sources/base.py`, `.../prompts/reference_instruction.md`, +`.../prompts/web_ingestion_instruction.md`, `.../bundle/synthesizer.py`, `.../bundle/index.py`, +`samples/enrichment/README.md`, `samples/enrichment/src/enrichment/enrich.py`, +`samples/enrichment/src/tools/fileskb/README.md`, `toolbox/enrichment/README.md`, +`samples/discovery/SKILL.md`, `toolbox/mdcode/docs/spec.md`, `toolbox/mdcode/docs/concept.md`, +`toolbox/mdcode/README.md`. + +Undersøkelse utført 2026-06-26 (Opus-subagent, web-verifisert). 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 new file mode 100644 index 0000000..c82c662 --- /dev/null +++ b/docs/okf-second-brain-note-2026-06.md @@ -0,0 +1,57 @@ +# Retningsnotat — Google OKF for brukerens «second brain» (LLM-wiki) i okr-pluginen + +_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`. + +## Scope-grense (ufravikelig — speiler architects operatør-bekreftede grense) +- **OKF gjelder KUN brukerens egen kontekst/data** — okr-pluginens «second brain»: i dag org-profilen `~/.claude/okr/org/profil.md` (maskin-global, skrevet av `scripts/write-org-profile.mjs` i Fase 3) + kontekst-treet `.claude/okr/` (`strategisk-kontekst/`, `syklus/[id]/`, `historikk/`, `dokumenter/`) + prosjekt-lokal `.claude/okr.local.md`. +- **OKF gjelder IKKE pluginens 17 domene-referansefiler** (`skills/okr-offentlig-sektor/references/*`). De forblir Claude Code **skill-references** (native, Anthropic-anbefalt progressiv-disclosure-mekanisme). Avgjørende skille (architects begrunnelse, overføres): ikke «er det en LLM-wiki» (begge er det), men **«finnes det allerede en native, anbefalt mekanisme?»** — for skill-refs JA (skills + references + grep), for second brain NEI (bor i dag i ad-hoc `org/`-/`.claude/okr/`-filer uten retrieval-mekanisme under chat). OKF fyller et reelt tomrom kun for second brain. + +## Lastemodell-skiftet (kjernen i hvorfor dette er ikke-trivielt) +- **I dag (Fase 3):** `hooks/scripts/inject-okr-context.mjs` (UserPromptSubmit) **for-injiserer** org-profil + syklus-sammendrag når prompten er OKR-relevant (topic-guard regex). Hybrid resolusjon: prosjekt-lokal `.claude/okr.local.md` → hjem `~/.claude/okr/org/profil.md` (mest-spesifikk-vinner). +- **Mål (OKF):** kontekst hentes **smart/selektivt on-demand** — fordi en rik second-brain-wiki er for stor til å for-injisere i sin helhet, og fordi brukeren oftest **chatter** med pluginen lastet uten å kjøre kommandoer. Trenger en **retrieval-skill** (list → search → read) aktiv i fri chat, + en **vedlikeholds-mekanisme** som holder wikien oppdatert når brukeren tilfører kontekst OG når OKF-standarden bumpes. +- For-injeksjons-hooken og on-demand-retrieval er komplementære, ikke konkurrerende: hooken kan beholde et lite «alltid-relevant» kjerne-sammendrag; retrieval-skillen dekker dybden. + +## OKF v0.1 — kjernekontrakt (verifisert mot SPEC 2026-06-26) +- Kilde: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md (v0.1, 12. juni 2026 — «starting point, not finished standard»). +- **Bundle** = katalogtre av markdown-filer, **ett konsept per fil**. **Concept ID** = filsti uten `.md` (f.eks. `strategi/tillitsreform`). +- **Frontmatter:** påkrevd `type` (fri streng, f.eks. «Strategidokument», «Tildelingsbrev», «Org-enhet»). Anbefalt: `title`, `description`, `resource` (kanonisk kilde-URI), `tags`, `timestamp`. Konsumenter MÅ tolerere/bevare ukjente felt og ukjente `type`-verdier. +- **Reserverte filnavn:** `index.md` (katalog-enumerasjon, ingen frontmatter, bærer progressiv disclosure) og `log.md` (endringslogg). +- **Kryss-lenking:** markdown-lenker (bundle-relative `/...` eller relative); relasjonstype utledes av prosa. Konsumenter må **tolerere brutte lenker**. +- **Agent-konsum:** «parseable by agents without bespoke SDKs» — traverser katalogen, parse frontmatter for ruting/filtrering, følg lenker. I Claude Code dekker **Grep/Glob/Read** allerede list/search/read — egen MCP-server er trolig unødvendig; den bærende mekanismen er en SKILL-instruks («søk wikien først, åpne kun relevant»). + +## Hvor det plugger inn i okr (forankringspunkter — verifiser i `/trekbrief`) +- **Lese-sømmen finnes:** `inject-okr-context.mjs` + den hybride org-sti-resolusjonen (Fase 3) er allerede on-disk. OKF-retrieval bygger på/ved siden av denne. +- **Skrive-sømmen finnes:** `scripts/write-org-profile.mjs` (atomisk temp+rename) skriver org-profilen i dag — naturlig sted å la onboarding skrive **OKF-konform** frontmatter (`org/*.md` mangler i praksis bare `type:` for å være nær-OKF). +- **Onboarding:** `/okr:oppsett full/mvp` samler org-konteksten — kandidat for å produsere OKF-bundle-struktur i stedet for (eller i tillegg til) dagens flate profil. + +## Hva som må bygges (fremtidige faser — ikke nå; planlegges i `/trekplan`) +1. **Frontmatter-/struktur-konvensjon** for okr-second-brain: minimal OKF-konform (`type` påkrevd + `resource`/`timestamp`), `index.md` per nivå. Migrer/utvid dagens `~/.claude/okr/org/` + `.claude/okr/`-tre. +2. **Retrieval-skill** (f.eks. «okr-second-brain-search»): list → search → read over wikien, **aktiv i fri chat** (ikke bare kommandoer). Avgjør: ren SKILL + native Grep/Glob/Read vs. dedikert MCP-server. +3. **Vedlikeholds-mekanisme:** hvordan wikien oppdateres når brukeren tilfører kontekst (onboarding/oppsett skriver OKF-konform), + rutine når OKF-standarden bumpes (`okf_version` i rot-`index.md`). + +## Åpne valg (avklares i `/trekbrief` / måling) +- **Retrieval-mekanisme:** SKILL + native Grep/Glob/Read vs. dedikert fileskb-MCP-server. Ekte tvil → kandidat for «implementer begge, mål hvilken gir best kontekst-treff» (operatørs faktabasert-prinsipp). I Claude Code lener det mot ren SKILL (Grep/Glob/Read dekker OKFs list/search/read). +- **Grad av OKF-formalisme:** full v0.1-konformitet vs. «OKF-kompatibel form» (frontmatter + `index.md` uten resten). Lén mot det letteste som gir smart retrieval. +- **For-injeksjon vs. on-demand:** hva hooken beholder som «alltid-på kjerne» vs. hva retrieval-skillen henter ved behov. +- **Oppdaterings-kadens mot standarden:** hvordan fange OKF-versjonsbump uten manuell polling. + +## Suksesskriterium (per operatør, overført fra architect-sporet) +Det viktigste er **ikke teknologien/OKF-konformitet i seg selv**, men at second brain blir **så bra som mulig for brukeren** (henter pluginen riktig personlig/org-kontekst i chat og kommandoer?) og at **vedlikeholds-/oppdateringsmekanismene fungerer veldig bra**. Mål mot brukerverdi + vedlikeholds-pålitelighet. + +## Voyage-vurdering (operatør ba om dette eksplisitt) +**Anbefaling: JA — bruk Voyage,** med ett forbehold om research-fasen. +- **For:** Hele okr-referansegrad-løftet (Fase 1–3, nettopp fullført) kjørte ende-til-ende på Voyage (`/trekbrief` → `/trekresearch` → `/trekplan` → `/trekexecute`) over 4+ sesjoner. OKF-integrasjon treffer Voyages kjerneformål: fler-sesjons, krever grundig adversarial-revidert plan, krever deterministisk kontekst mellom sesjoner (STATE-bootstrap), og egner seg for session-dekomponering. `ms-ai-architect` kjører samme mønster (brief → plan → faser). +- **Forbehold (ikke bare bekreftelse):** Research-delen (lese OKF `samples/` + `toolbox/`) er **allerede gjort og digerert** i architect-briefens «Økosystem-digest» (kanoniske filer markert: `samples/enrichment/.../kb-search/SKILL.md` = retrieval-mønsteret; `okf/bundles/ga4/...` = frontmatter-mal; `okf/src/reference_agent/prompts/*` = vedlikeholds-mal). → Neste sesjons `/trekresearch` kan være **lett eller hoppes over** ved å arve den digesten; ikke betal for full research på nytt. +- **Hvis scope viser seg lite** (f.eks. kun `type:`-frontmatter + én retrieval-SKILL): vurder i `/trekbrief` om full Voyage-maskineri er overkill — men architect-briefen viser at dette genuint er fler-delt (konvensjon + retrieval + vedlikehold + mål-MCP-vs-native + standard-tracking), så Voyage passer. +- **Anbefalt pipeline neste sesjon:** `/trekbrief` (avgrens scope + de åpne valgene over) → arve architect-digest (lett/ingen `/trekresearch`) → `/trekplan` (fler-sesjons med `.session-state`, proaktiv kutting før compaction) → `/trekexecute --session N` foreground. Ny fase = minor-bump **1.5.0 → 1.6.0**. + +## Referanser (les ved planlegging) +- **OKF SPEC v0.1:** https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md +- **Samples / toolbox:** https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/samples · `.../tree/main/toolbox` +- **Architects referansedesign (fyldigere, les FØRST):** `../ms-ai-architect/docs/okf-second-brain-brief-2026-06.md` (+ `docs/ref-kb-direction-note-2026-06.md` for skill-ref-vs-second-brain-skillet). Architect-memory: `okf-scope-second-brain-only`. +- **okr-sømmer:** `hooks/scripts/inject-okr-context.mjs`, `scripts/write-org-profile.mjs`, `commands/oppsett.md`, CLAUDE.md «State Management». diff --git a/hooks/scripts/coaching-hook.mjs b/hooks/scripts/coaching-hook.mjs index 9f98df2..0c186ff 100644 --- a/hooks/scripts/coaching-hook.mjs +++ b/hooks/scripts/coaching-hook.mjs @@ -7,6 +7,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { parseFrontmatter } from '../../lib/frontmatter.mjs'; const cwd = process.cwd(); const configPath = join(cwd, '.claude', 'okr.local.md'); @@ -17,14 +18,8 @@ if (!existsSync(configPath)) { try { const content = readFileSync(configPath, 'utf8'); - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) process.exit(0); - - const fm = match[1]; - const get = (key) => { - const m = fm.match(new RegExp(`${key}:\\s*["']?([^"'\\n]+)["']?`)); - return m ? m[1].trim() : null; - }; + const { raw, get } = parseFrontmatter(content); + if (raw === null) process.exit(0); const cycleId = get('id'); const fase = get('fase'); @@ -50,7 +45,9 @@ try { totalWeeks = 13; } - const now = new Date(); + // Testable clock seam: OKR_NOW (ISO date) overrides the wall clock so the + // phase branches (early/mid/late) can be asserted deterministically. + const now = process.env.OKR_NOW ? new Date(process.env.OKR_NOW) : new Date(); const cycleStart = new Date(cycleYear, startMonth, 1); const cycleEnd = new Date(cycleYear, endMonth + 1, 0); // last day of end month diff --git a/hooks/scripts/inject-okr-context.mjs b/hooks/scripts/inject-okr-context.mjs index 69e8e47..f97e771 100644 --- a/hooks/scripts/inject-okr-context.mjs +++ b/hooks/scripts/inject-okr-context.mjs @@ -2,35 +2,72 @@ // inject-okr-context.mjs // Event: UserPromptSubmit -// Purpose: Inject OKR organization context from .claude/okr.local.md and .claude/okr/ tree. -// Zero npm dependencies. Target execution: <50ms. +// Purpose: Inject the core OKR organization context from .claude/okr.local.md +// (or the home org profile) plus ONE pointer to the second-brain index.md. +// Cycle/history/context retrieval is on-demand via the okr-second-brain-search +// skill — no longer pre-injected here. Zero npm dependencies. Target: <50ms. -import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { parseFrontmatter } from '../../lib/frontmatter.mjs'; const cwd = process.cwd(); -const configPath = join(cwd, '.claude', 'okr.local.md'); +const projectConfigPath = join(cwd, '.claude', 'okr.local.md'); +const homeConfigPath = join(homedir(), '.claude', 'okr', 'org', 'profil.md'); -if (!existsSync(configPath)) { +// Topic guard (UserPromptSubmit): only inject OKR context when the user's +// prompt is plausibly OKR-related. The stdin read is non-blocking here -- under +// execFileSync (Claude Code's hook call and the tests) stdin is a closed pipe +// so EOF is immediate; the isTTY guard avoids blocking in an interactive shell. +// Default to PASS (inject) on any doubt -- empty/unparseable stdin, missing +// prompt field, or TTY -- so we never drop a legitimate OKR prompt. Only an +// explicit non-matching prompt is suppressed. No network. +let rawPrompt = ''; +try { + if (!process.stdin.isTTY) rawPrompt = readFileSync(0, 'utf8'); +} catch { + rawPrompt = ''; +} +if (rawPrompt) { + let promptText = null; + try { + promptText = JSON.parse(rawPrompt).prompt; + } catch { + promptText = null; + } + if (typeof promptText === 'string' && promptText.length > 0) { + const okrPattern = /\bokr\b|objective|key result|n[oø]kkelresultat|noekkelresultat|\bkr\b|\bm[aå]l\b|\bmaal\b|tildelingsbrev|kaskade|syklus|tertial|kvartal/i; + if (!okrPattern.test(promptText)) { + process.exit(0); + } + } +} + +// Hybrid org-profile resolution (most-specific-wins): project-local overrides +// the machine-global home profile. The home path is the Fase 3 migration target; +// this read is forward-compatible and stays inert until that file exists. +// NOTE: only the org PROFILE resolves to home. Cycle/work data stays cwd-bound +// and is no longer pre-injected -- it is retrieved on-demand via the +// okr-second-brain-search skill. +const configPath = existsSync(projectConfigPath) + ? projectConfigPath + : (existsSync(homeConfigPath) ? homeConfigPath : null); + +if (!configPath) { process.exit(0); } try { const content = readFileSync(configPath, 'utf8'); - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) process.exit(0); - - const fm = match[1]; - const get = (key) => { - const m = fm.match(new RegExp(`${key}:\\s*["']?([^"'\\n]+)["']?`)); - return m ? m[1].trim() : null; - }; + const { raw, get } = parseFrontmatter(content); + if (raw === null) process.exit(0); // Core fields (backwards-compatible with old 4-field format) const org = get('navn') || get('name'); const syklus = get('gjeldende') || get('id') || get('current_cycle'); const sektor = get('sektor') || get('sector') || get('domene'); - const linear = fm.includes('aktivert: true') || fm.includes('enabled: true'); + const linear = raw.includes('aktivert: true') || raw.includes('enabled: true'); // New v1.1 fields (silently skipped if absent) const modenhet = get('modenhetsnivaa'); @@ -50,105 +87,23 @@ try { if (trygghet) parts.push(`Psykologisk trygghet: ${trygghet}`); if (linear) parts.push('Linear: aktivert'); - // Scan .claude/okr/ directory tree (cap at 50 files) - const okrDir = join(cwd, '.claude', 'okr'); - const dirParts = []; - let totalFiles = 0; - - if (existsSync(okrDir)) { - try { - const topEntries = readdirSync(okrDir, { withFileTypes: true }); - for (const entry of topEntries) { - if (!entry.isDirectory()) continue; - if (totalFiles >= 50) break; - try { - const subEntries = readdirSync(join(okrDir, entry.name), { withFileTypes: true }); - const mdFiles = []; - const subDirs = []; - for (const sub of subEntries) { - if (totalFiles >= 50) break; - if (sub.isFile() && sub.name.endsWith('.md')) { - mdFiles.push(sub.name); - totalFiles++; - } else if (sub.isDirectory()) { - subDirs.push(sub.name); - } - } - // Enumerate nested subdirectories (e.g. syklus/T1-2026/) - for (const sd of subDirs) { - if (totalFiles >= 50) break; - try { - const nested = readdirSync(join(okrDir, entry.name, sd), { withFileTypes: true }); - const nestedMd = []; - for (const n of nested) { - if (totalFiles >= 50) break; - if (n.isFile() && n.name.endsWith('.md')) { - nestedMd.push(n.name); - totalFiles++; - } - } - if (nestedMd.length > 0) { - dirParts.push(`${entry.name}/${sd}/ (${nestedMd.length} fil${nestedMd.length > 1 ? 'er' : ''}: ${nestedMd.join(', ')})`); - } - } catch { /* skip unreadable nested dirs */ } - } - if (mdFiles.length > 0) { - dirParts.push(`${entry.name}/ (${mdFiles.length} fil${mdFiles.length > 1 ? 'er' : ''}: ${mdFiles.join(', ')})`); - } - } catch { /* skip unreadable dirs */ } - } - } catch { /* .claude/okr/ scan failed — continue without */ } - } - - // Scan historikk/ for archived cycle count - const histDir = join(okrDir, 'historikk'); - const archivedCycles = []; - if (existsSync(histDir)) { - try { - const histEntries = readdirSync(histDir, { withFileTypes: true }); - for (const entry of histEntries) { - if (entry.isDirectory()) { - archivedCycles.push(entry.name); - } - } - } catch { /* skip */ } - } - - // List active cycle files - const cycleId = syklus; - const cycleParts = []; - if (cycleId) { - const cyclePath = join(okrDir, 'syklus', cycleId); - if (existsSync(cyclePath)) { - try { - const cycleEntries = readdirSync(cyclePath, { withFileTypes: true }); - for (const e of cycleEntries) { - if (e.isFile() && e.name.endsWith('.md')) { - cycleParts.push(e.name); - } - } - } catch { /* skip */ } - } - } - - // Build rich systemMessage + // Base context line from the core profile fields only. The directory-tree + // enumeration that used to live here (the :92-191 MOVE block) is removed: + // retrieval is now on-demand via the okr-second-brain-search skill, not + // pre-injected. The payload is therefore independent of file count. (SC5) let msg = `OKR-kontekst (fra .claude/okr.local.md): ${parts.join(', ')}.`; - if (dirParts.length > 0) { - msg += `\nTilgjengelige kontekstfiler: ${dirParts.join('; ')}.`; - } + // Resolve ONE pointer to the second-brain index.md, reusing most-specific-wins + // (project bundle preferred, else home bundle). Project root is cwd-bound + // (.claude/okr/); the home bundle root is ~/.claude/okr/org/ (two-root model). + const projectIndex = join(cwd, '.claude', 'okr', 'index.md'); + const homeIndex = join(homedir(), '.claude', 'okr', 'org', 'index.md'); + const indexPath = existsSync(projectIndex) + ? projectIndex + : (existsSync(homeIndex) ? homeIndex : null); - if (cycleParts.length > 0) { - msg += `\nAktive OKR-filer i syklus ${cycleId}: ${cycleParts.join(', ')}.`; - } - - if (archivedCycles.length > 0) { - msg += `\nArkiverte sykluser (${archivedCycles.length}): ${archivedCycles.sort().join(', ')}.`; - msg += ' Bruk /okr:analyse for trendanalyse.'; - } - - if (dirParts.length > 0 || cycleParts.length > 0) { - msg += '\nBruk disse filene automatisk nar relevant — ikke be brukeren om a lime inn innhold som allerede finnes.'; + if (indexPath) { + msg += `\nPersonlig OKF-kontekst finnes - sok wikien (skill: okr-second-brain-search) eller se ${indexPath}.`; } process.stdout.write(JSON.stringify({ systemMessage: msg })); diff --git a/lib/frontmatter.mjs b/lib/frontmatter.mjs new file mode 100644 index 0000000..1acb336 --- /dev/null +++ b/lib/frontmatter.mjs @@ -0,0 +1,67 @@ +// frontmatter.mjs +// Delt frontmatter-parse/skrive for okr-pluginen. Zero npm dependencies. +// +// Konsoliderer den dupliserte flate `get()`-parseren fra inject-okr-context.mjs +// (:59-66) og coaching-hook.mjs (:20-27) til EN modul, med to korreksjoner mot +// den gamle atferden: +// 1. Linjeanker (`^\s*key:`, multiline) — key matcher kun ved linjestart +// (modulo innrykk), aldri som substring midt i en annen key/verdi. Bevarer +// first-match og nestet (innrykket) oppslag (load-bearing: inject:69). +// 2. Trailing " #kommentar" strippes KUN fra USITERTE verdier. Siterte verdier +// beholder en intern '#' ("A #B" -> A #B). Retter comment-leak-bugen der +// `okr_frikoblet_fra_loenn: true # ...` lakk kommentaren inn i verdien. +// +// Tolererer fler-linje OKF-list-verdier (f.eks. `tags:`) uten krasj: get() paa +// en list-key returnerer null (rest-av-linja er tom); list-elementer paa +// foelgende linjer konsumeres aldri (ingen konsument leser tre-filenes `tags`). + +const FM_RE = /^---\n([\s\S]*?)\n---/; + +export function parseFrontmatter(content) { + const match = String(content).match(FM_RE); + const raw = match ? match[1] : null; + + const get = (key) => { + if (raw === null) return null; + const m = raw.match(new RegExp(`^\\s*${key}:\\s*(.*)$`, 'm')); + if (!m) return null; + let v = m[1].trim(); + if (v === '') return null; + const q = v[0]; + if (q === '"' || q === "'") { + const end = v.indexOf(q, 1); + if (end !== -1) return v.slice(1, end); // intern '#' bevart + v = v.slice(1); // uavsluttet quote: fall tilbake til resten + } else { + v = v.replace(/\s+#.*$/, '').trim(); // usitert: strip trailing kommentar + } + return v === '' ? null : v; + }; + + 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)) { + // 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/compose-org-profile.mjs b/scripts/compose-org-profile.mjs new file mode 100644 index 0000000..332bcbb --- /dev/null +++ b/scripts/compose-org-profile.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +// compose-org-profile.mjs +// Purpose: wrap the NESTED org-profile YAML body (organisasjon:/program: ..., +// read from stdin) in an OKF-compatible frontmatter document, prepending the +// three top-level OKF keys the second-brain "Documents/kb" layout expects on a +// concept file: type / resource / timestamp. Emits the full document to stdout, +// meant to be piped into write-org-profile.mjs (which writes it atomically to +// the reinstall-surviving home path ~/.claude/okr/org/profil.md). +// +// Why a dedicated composer (not lib/frontmatter.mjs writeFrontmatter): the org +// profile is NESTED (organisasjon: { navn, type }, program: { ... }). The flat +// writeFrontmatter would flatten/destroy that structure. We instead keep the +// caller's nested YAML verbatim and only PREPEND OKF keys above it. +// +// Why ONLY type/resource/timestamp top-level: the home profile is read by the +// flat parser (lib/frontmatter.mjs) in inject-okr-context.mjs via +// get('navn')/get('id')/get('domene')/... A NEW top-level key colliding with +// any of those (navn/name/id/fase/domene/sektor) would be matched FIRST and +// shadow the nested value, breaking org/cycle resolution (the Critical risk). +// type/resource/timestamp are read by NO consumer, so they are safe to prepend. +// The nested organisasjon.type ("offentlig") is untouched and still unread. +// +// Single-block invariant: the OKF keys and the nested body MUST live in ONE +// frontmatter block. A stray intermediate '---' would truncate the block the +// flat parser reads (dropping organisasjon: -> get('navn') === null -> no +// injection), so any fences the caller already put around the body are stripped +// before re-wrapping. +// +// Testable clock seam: OKR_NOW (ISO-8601) overrides the wall clock so the +// emitted timestamp is deterministic under test. +// +// Zero npm dependencies (node: builtins only). ASCII-clean identifiers. + +import { readFileSync } from 'node:fs'; + +// OKF resource pointer: where the canonical org profile lives. A bare string +// pointer (OKF: "URL/path to the source"); no consumer reads it. +const RESOURCE = '~/.claude/okr/org/profil.md'; + +let body = ''; +try { + body = readFileSync(0, 'utf8'); +} catch { + body = ''; +} + +// Strip a leading/trailing frontmatter fence if the caller already wrapped the +// body, so the result is exactly one frontmatter block (see single-block note). +body = body.replace(/^\s*---\s*\r?\n/, ''); +body = body.replace(/\r?\n---\s*\r?\n?\s*$/, '\n'); +body = body.replace(/\s+$/, ''); + +const timestamp = process.env.OKR_NOW || new Date().toISOString(); + +const lines = [ + '---', + 'type: Organisasjonsprofil', + `resource: ${RESOURCE}`, + `timestamp: '${timestamp}'`, +]; +if (body) lines.push(body); +lines.push('---', ''); + +process.stdout.write(lines.join('\n')); diff --git a/scripts/export-pdf.py b/scripts/export-pdf.py new file mode 100644 index 0000000..42efc7a --- /dev/null +++ b/scripts/export-pdf.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Render an OKR Markdown document to a print-ready A4 PDF. + +Usage: + python3 export-pdf.py + +Dependencies (documented prerequisite, not bundled): + pip install markdown weasyprint + # native libs on macOS: brew install pango + +The script degrades gracefully: a missing Python package exits 1 with a pip +hint; a missing native library (Pango/cairo/gdk-pixbuf) surfaces at render +time as an OSError and exits 1 with a brew hint. Neither path leaks a traceback. +""" + +import sys + +PIP_HINT = ( + "Mangler Python-avhengigheter for PDF-eksport.\n" + "Installer dem (dokumentert forutsetning, ikke bundlet):\n" + " pip install markdown weasyprint\n" +) + +PANGO_HINT = ( + "Klarte ikke aa rendre PDF: native bibliotek mangler " + "(Pango/cairo/gdk-pixbuf).\n" + "Installer dem (macOS):\n" + " brew install pango\n" + "Se: https://doc.courtbouillon.org/weasyprint/stable/first_steps.html\n" +) + +try: + import markdown + import weasyprint +except ImportError: + sys.stderr.write(PIP_HINT) + sys.exit(1) + +# A4 page geometry + table striping + traffic-light score classes. +# attr_list lets the Markdown author tag cells: `{: .score-green}` etc. +PAGE_CSS = """ +@page { + size: A4; + margin: 20mm 18mm; + @bottom-right { content: counter(page) " / " counter(pages); font-size: 9pt; color: #666; } +} +body { + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 10.5pt; + line-height: 1.45; + color: #1a1a1a; +} +h1 { font-size: 20pt; border-bottom: 2px solid #2a4d69; padding-bottom: 4px; } +h2 { font-size: 15pt; color: #2a4d69; margin-top: 1.4em; } +h3 { font-size: 12.5pt; color: #4a6d89; } +table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 9.5pt; } +th, td { border: 1px solid #ccc; padding: 5px 8px; text-align: left; vertical-align: top; } +th { background: #2a4d69; color: #fff; font-weight: 600; } +tbody tr:nth-child(even) { background: #f3f6f9; } +code { font-family: "SF Mono", Menlo, monospace; font-size: 9pt; background: #f0f0f0; padding: 1px 4px; border-radius: 3px; } +pre { background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 10px; overflow-x: auto; } +pre code { background: none; padding: 0; } +.score-green { background: #d6efd6 !important; color: #1a5c1a; font-weight: 600; } +.score-yellow { background: #fbf3cf !important; color: #7a5c00; font-weight: 600; } +.score-red { background: #f6d6d6 !important; color: #8c1a1a; font-weight: 600; } +""" + + +def build_html(markdown_text): + """Convert Markdown to a full standalone HTML document with embedded CSS.""" + body = markdown.markdown( + markdown_text, + extensions=["tables", "fenced_code", "codehilite", "toc", "attr_list"], + ) + return ( + "" + "" + body + "" + ) + + +def main(): + if len(sys.argv) != 3: + sys.stderr.write("Bruk: python3 export-pdf.py \n") + sys.exit(2) + + in_path, out_path = sys.argv[1], sys.argv[2] + + try: + with open(in_path, "r", encoding="utf-8") as handle: + markdown_text = handle.read() + except OSError as err: + sys.stderr.write("Kunne ikke lese inputfil '%s': %s\n" % (in_path, err)) + sys.exit(1) + + html_doc = build_html(markdown_text) + + try: + # base_url anchors relative asset paths to the input file's directory. + weasyprint.HTML(string=html_doc, base_url=in_path).write_pdf(out_path) + except OSError: + sys.stderr.write(PANGO_HINT) + sys.exit(1) + + sys.stderr.write("Skrev PDF: %s\n" % out_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/okf-check.mjs b/scripts/okf-check.mjs new file mode 100644 index 0000000..877c720 --- /dev/null +++ b/scripts/okf-check.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// okf-check.mjs +// Validerer en OKF-bundle-rot. Kontrakt (de-facto OKF: kun `type` paakrevd): +// - Hver konsept-fil (.md unntatt index.md) MAA ha `type:` i frontmatter. +// - >= 1 fil uten type -> exit 1 + teller + navngir filene. +// - 0 filer uten type -> exit 0. +// Anbefalte felt (resource/title/description/timestamp) rapporteres som ADVARSEL, +// ikke feil. Rotens `okf_version` ekkoes for menneskelig sammenligning mot +// gjeldende standard (ingen auto-fetch — hooks/scripts er no-network; SC7 myket). +// +// Kjoeres PER ROT (prosjekt `.claude/okr/` + home `~/.claude/okr/org/`). +// Zero npm dependencies (node:-builtins). + +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()) { + if (isWalkableDir(e.name)) walk(p); + } else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') { + out.push(p); + } + } + }; + walk(root); + return out; +} + +// Les rotens okf_version (markdown-tekst i index.md, ikke frontmatter). null hvis fravaerende. +function rootOkfVersion(root) { + const idx = join(root, 'index.md'); + if (!existsSync(idx)) return null; + const m = readFileSync(idx, 'utf8').match(/^okf_version:\s*(.+)$/m); + return m ? m[1].trim() : null; +} + +// 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 raw = readFileSync(f, 'utf8'); + const { get } = parseFrontmatter(raw); + const rel = relative(root, f); + 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}»`); + } + } + return { + scanned: concepts.length, + missingType, + warnings, + strictErrors, + strictIngest, + okfVersion: rootOkfVersion(root), + }; +} + +// --- CLI --- +const isMain = process.argv[1] + && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + 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 [--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, { strictIngest }); + const out = []; + 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}`); + 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(failed ? 1 : 0); +} diff --git a/scripts/okf-index.mjs b/scripts/okf-index.mjs new file mode 100644 index 0000000..5a79657 --- /dev/null +++ b/scripts/okf-index.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// okf-index.mjs +// Genererer OKF-kompatible `index.md` per nivaa i en bundle-rot (prosjekt +// `.claude/okr/` eller home `~/.claude/okr/org/`). Verbatim OKF-«Documents/kb +// Layout»-index-form: +// # Overskrift +// +// okf_version: (KUN rot-index) +// +// * [title](relativ.md) - description +// Ingen frontmatter paa index.md (OKF-reservert). Konsept-filers `title`/ +// `description` leses via lib/frontmatter.mjs. Underkataloger faar en peker til +// sin egen index.md. Kjoeres PER ROT (de to bundlene har ulik livssyklus). +// +// Idempotens / vedlikehold (NFR): en eksisterende index.md sin `# overskrift`, +// rotens `okf_version`-verdi, og menneske-skrevne beskrivelser for underkatalog- +// pekere bevares; konsept-entries regenereres alltid fra frontmatter (autoritativ +// kilde). Skriving er atomisk (temp + renameSync), jf. write-org-profile.mjs. +// +// Zero npm dependencies (node:-builtins). + +import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +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(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +// Parse en eksisterende index.md for bevaring: overskrift, okf_version, og +// beskrivelser pr. lenke (for underkatalog-pekere). Kaster aldri. +function parseExistingIndex(path) { + const result = { heading: null, okfVersion: null, descByLink: {} }; + if (!existsSync(path)) return result; + for (const line of readFileSync(path, 'utf8').split('\n')) { + if (result.heading === null && line.startsWith('# ')) { + result.heading = line.slice(2).trim(); + } + const ver = line.match(/^okf_version:\s*(.+)$/); + if (ver) result.okfVersion = ver[1].trim(); + const entry = line.match(/^\*\s*\[([^\]]*)\]\(([^)]+)\)(?:\s*-\s*(.*))?$/); + if (entry) result.descByLink[entry[2]] = { title: entry[1], desc: (entry[3] || '').trim() }; + } + return result; +} + +// 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) { + 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() && 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) + .sort(); + + const heading = existing.heading + || (isRoot ? 'OKF second brain' : titleFromName(basename(dir))); + + const lines = [`# ${heading}`, '']; + if (isRoot) { + lines.push(`okf_version: ${existing.okfVersion || okfVersion}`, ''); + } + + for (const sd of subdirs) { + const link = `${sd}/index.md`; + const prev = existing.descByLink[link]; + lines.push(entryLine(prev?.title || titleFromName(sd), link, prev?.desc || 'Underkatalog.')); + } + for (const c of concepts) { + const { get } = parseFrontmatter(readFileSync(join(dir, c), 'utf8')); + const title = get('title') || titleFromName(basename(c, '.md')); + lines.push(entryLine(title, c, get('description') || '')); + } + + const content = `${lines.join('\n')}\n`; + const tmp = join(dir, `.index.md.${process.pid}.tmp`); + writeFileSync(tmp, content); + renameSync(tmp, join(dir, 'index.md')); +} + +// Generer index.md for rot + alle underkataloger, rekursivt. +export function generateIndexes(root, opts = {}) { + const okfVersion = opts.okfVersion || OKF_VERSION; + const walk = (dir, isRoot) => { + writeIndexFor(dir, isRoot, okfVersion); + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false); + } + }; + if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`); + walk(root, true); +} + +// --- CLI --- +const isMain = process.argv[1] + && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const root = process.argv[2]; + if (!root) { + process.stderr.write('Bruk: node okf-index.mjs [--okf-version ]\n'); + process.exit(2); + } + const vi = process.argv.indexOf('--okf-version'); + const okfVersion = vi !== -1 ? process.argv[vi + 1] : undefined; + generateIndexes(root, { okfVersion }); + process.stdout.write(`OKF-index generert for ${root}\n`); +} diff --git a/scripts/write-org-profile.mjs b/scripts/write-org-profile.mjs new file mode 100644 index 0000000..ae72dea --- /dev/null +++ b/scripts/write-org-profile.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +// write-org-profile.mjs +// Purpose: atomically write the OKR org profile to the reinstall-surviving +// home path (~/.claude/okr/org/profil.md). On ANY write failure +// (EACCES/EROFS/ENOSPC/pathguard-block) it circuit-breaks to the +// project-local, gitignored .claude/okr.local.md so internal org state never +// reaches a public mirror. Never exits non-zero -- the calling command must +// not be blocked. Reads the full profile content from stdin (fd 0). +// Zero npm dependencies (node: builtins only). ASCII-clean identifiers. +// +// Mirrors the canonical home path defined in +// hooks/scripts/inject-okr-context.mjs:14 (the most-specific-wins read side). + +import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +// Read the entire profile content from stdin. Under execFileSync (both Claude +// Code's command call and the tests) the child's stdin is a closed pipe, so +// EOF arrives immediately -- no hang. +let content = ''; +try { + content = readFileSync(0, 'utf8'); +} catch { + content = ''; +} + +const homeTarget = join(homedir(), '.claude', 'okr', 'org', 'profil.md'); + +// Atomic write: temp file in the SAME directory, then renameSync over the +// target. rename is atomic on the same filesystem -- a crash mid-write can +// never leave a half-written profil.md. +function writeAtomic(target, data) { + const dir = dirname(target); + mkdirSync(dir, { recursive: true }); + const tmp = join(dir, `profil.md.${process.pid}.tmp`); + writeFileSync(tmp, data); + renameSync(tmp, target); +} + +try { + writeAtomic(homeTarget, content); + process.stdout.write(homeTarget); + process.exit(0); +} catch (err) { + // Circuit-breaker: the home write failed. Fall back to the project-local, + // gitignored config so org state stays off any public mirror. The cycle / + // historikk tree remains cwd-bound regardless; only the profile migrates. + const fallback = join(process.cwd(), '.claude', 'okr.local.md'); + try { + writeAtomic(fallback, content); + process.stderr.write( + `notice: kunne ikke skrive hjem-profil (${err.code || err.message}); ` + + `falt tilbake til prosjektlokal ${fallback}\n`, + ); + process.stdout.write(fallback); + } catch (err2) { + process.stderr.write( + `notice: kunne ikke skrive verken hjem-profil eller prosjektlokal fallback ` + + `(${err2.code || err2.message})\n`, + ); + } + process.exit(0); +} diff --git a/skills/okr-offentlig-sektor/SKILL.md b/skills/okr-offentlig-sektor/SKILL.md index 5b67185..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.3.2" +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 @@ -129,6 +129,7 @@ All reference material is in `references/`: ### Methodology - `okr-framework.md` — Core methodology, scoring, cycle management +- `okr-quality-rubrics.md` — Anchored quality rubric (5 anchors per dimension) for `/okr:kvalitet` - `okr-examples.md` — Good and bad examples from public sector - `okr-antipatterns.md` — 19 common OKR mistakes - `okr-sources.md` — Bibliographic evidence base diff --git a/skills/okr-offentlig-sektor/references/metrics-library.md b/skills/okr-offentlig-sektor/references/metrics-library.md index be3aa6f..6d3d4a2 100644 --- a/skills/okr-offentlig-sektor/references/metrics-library.md +++ b/skills/okr-offentlig-sektor/references/metrics-library.md @@ -222,3 +222,82 @@ Ikke alle mål kan være tall. Eksempler på kvalitative KR: ✅ **Vær realistisk om datainnsamling** – ikke velg KR du ikke kan måle pålitelig ✅ **Balansér mellom input, output, og outcome** – men prioriter outcome (f.eks. "færre ulykker" > "antall fartshumper bygget") ❌ **Unngå vanity metrics** – tall som ser imponerende ut men ikke driver reell verdi (f.eks. "antall møter holdt") + +--- + +## Offentlig digitalisering og tjenestekvalitet (kryss-sektor) + +> Denne seksjonen gjelder **alle forvaltningsområder** (ikke bare transport/vei). Hver faktapåstand bærer en datert, lenket kilde; tall som ikke er primærkilde-verifisert er eksplisitt merket **«Ikke verifisert»** (verifiseringsplikt — dette biblioteket deles med ledelse/Riksrevisjon). Alt regelverk under er i bevegelse 2025–2026; se **Review-kadens** nederst og bruk `/okr:freshen-references` for currency-polling. + +### KR-styrkemerking + +Hver metrikk under er tagget med ett av tre nivåer, slik at man velger med åpne øyne: + +- **sterk-KR** — outcome-nær, ekstern/uavhengig datakilde, vanskelig å gamifisere. +- **brukbar** — gyldig KR, men krever forsiktig baseline/target eller har en metodisk forutsetning. +- **vanity-felle** — ser imponerende ut, men driver lite reell verdi (ofte selv-deklarert eller binær). Unngå som primær-KR. + +### Statlig vs kommunalt anvendelses-skille (les først) + +Styringsregimet avgjør **hvordan** en metrikk kan settes — ikke bare hvilken: + +- **Statlig etat:** mål- og resultatstyring (MRS), hjemlet i økonomireglementet §4. Instrument: **tildelingsbrev** (årlig styringsbrev dept→etat). Implikasjon: mål/metrikker **kan pålegges top-down** som styringsparametere og kaskaderes inn i Objectives. Kilde: [DFØ – krav til MRS i staten](https://www.dfo.no/fagomrader/styring-i-staten/mal-og-resultatstyring/hvilke-krav-gjelder-mal-og-resultatstyring-i-staten) (offisiell). +- **Kommune:** kommuneloven §§2-1/2-2 (LOV-2018-06-22-83) — selvstyre, hver kommune er eget rettssubjekt, **ingen tildelingsbrev**. Implikasjon: metrikker **kan ikke pålegges** kommuner top-down; de er **lokalt eid**. Nasjonale organer kan anbefale/benchmarke/finansiere/sette lovpålagte minstekrav — ikke styre via brev. Kilde: [Regjeringen – veileder om statlig styring av kommuner](https://www.regjeringen.no/no/dokumenter/veileder-om-statlig-styring-av-kommuner-og-fylkeskommuner/id2791598) (offisiell/juridisk). +- **Nyanse for governance/Riksrevisjon:** ren to-boks-modell er upresis. Bruk minst **tre nivåer** (+ fylkeskommune; Oslo er begge) **+ org-form-lag** (forvaltningsorgan, statsforetak (SF), helseforetak/RHF, interkommunale selskap (IKS), kommunale foretak (KF)) — compliance-plikt avhenger av org-form, ikke grov dikotomi. Kilde: [SNL – offentlig sektor](https://snl.no/offentlig_sektor); [DFØ/Difi-rapport 2018:8 – organisasjonsformer](https://www.dfo.no/sites/default/files/fagomr%C3%A5der/Rapporter/Rapporter-Difi/difi-rapport_2018-8_organisasjonsformer_i_offentlig_sektor._en_kartlegging.pdf) (2018; org-form-tall kan være utdaterte). *Provenans: offisiell + community.* + +### Digitaliseringsmodenhet + +Det finnes **ikke ett samlet, nasjonalt indekstall** for offentlig digitalisering bredt — bruk de tre eksternt eide internasjonale indeksene + Digdirs egne indikatorsett. (Difi er oppløst og innlemmet i Digdir; mål med gammelt Difi-navn er institusjonelt utdatert.) + +- **UN EGDI (E-Government Development Index):** Norge = **0,9602** («Very High»), UN E-Government Survey 2024; oppdateres biennalt. Global rang ~15 er **Ikke verifisert** (tvetydig i sekundærkilde — bekreft mot primær UN DESA-rapport før publisering). Kilde: [UN E-Government Survey 2024](https://www.unescap.org/sites/default/d8files/event-documents/UN%20DESA%20E-gov%20Survey%202024%20Insights_for%20ESCAP%20event_24092024-ak.pdf). **brukbar** (ekstern indeks; måler land, ikke ett team). +- **EU eGov Benchmark (eGovernment Benchmark):** Norge score **82** (EU27-snitt 76), Capgemini for Kommisjonen 2024; under Danmark/Finland/Estland. Standalone EU DESI er avviklet siden 2023 (foldet inn i «State of the Digital Decade»). Kilde: [eGovernment Benchmark 2024 (Capgemini)](https://www.capgemini.com/wp-content/uploads/2024/07/eGovernment-Report-2024.pdf) (vendor/EU). **brukbar**. +- **OECD DGI (Digital Government Index):** Norge **5. av 34** OECD-land (2025-runde), best i Norden/Baltikum. Kilde: [Regjeringen – ny måling av digitalisering (OECD DGI 5/34)](https://www.regjeringen.no/no/aktuelt/ny-maling-av-digitalisering-i-offentlig-sektor-norge-i-toppen/id3149478/) (offisiell). **brukbar**. +- **Digdir «Rikets digitale tilstand»** (nærmeste nasjonale scorecard): **57 indikatorer** under 6 mål (skår 1,00–3,00). Nasjonal digitaliseringsstrategi 2024–2030 «Fremtidens digitale Norge» definerer **23 nøkkelindikatorer** (14 med konkrete 2030-måltall); nullpunktmåling 2024, første statusrapportering høsten 2025. Kilde: [Digdir – Rikets digitale tilstand (2024)](https://www.digdir.no/rikets-digitale-tilstand/samlet-vurdering-av-maloppnaelse-i-rikets-digitale-tilstand-2024/5859); [Fremtidens digitale Norge (strategi-PDF)](https://www.regjeringen.no/contentassets/c499c3b6c93740bd989c43d886f65924/no/pdfs/nasjonal-digitaliseringsstrategi_ny.pdf) (offisiell). **brukbar** (org kan tracke egne av de 57/23-indikatorene). +- Nasjonal indeks for digital inkludering (Digdir + Nkom, lansert 31. okt 2025) — avgrenset til digital inkludering, ikke e-forvaltning bredt; dimensjoner/metodikk **Ikke verifisert**. + +### Universell utforming (WCAG) + +- **WCAG 2.1 nivå A + AA** (IKKE 2.0) er lovpålagt for offentlig sektor: må møte **48 av 78** suksesskriterier (privat: 35 fra WCAG 2.0). Hjemmel: forskrift om universell utforming av IKT **FOR-2013-06-21-732 §4b**, via EN 301 549 (WAD-direktivet 2016/2102). Kilde: [Lovdata – forskrift uu IKT](https://lovdata.no/dokument/SF/forskrift/2013-06-21-732); [uutilsynet – EUs webdirektiv (WAD)](https://www.uutilsynet.no/webdirektivet-wad/eus-webdirektiv-wad/265); [NAV uu – hva gjelder (WCAG 2.1)](https://navikt.github.io/uu/hva-gjelder/) (offisiell/juridisk). +- **Tilgjengelighetserklæring publisert + korrekt** (maskinlesbar, obligatorisk) — **sterk-KR** (binær, kryss-sjekkes av uutilsynets statusmåling fra 2024–2025). Kilde: [uutilsynet – status tilgjengelegheitserklæringar](https://www.uutilsynet.no/innsikt-og-analyse/status-tilgjengelegheitserklaeringar/1800) (tilsyn). +- **uutilsynet statusmåling-score** (forenklet, automatisert kontroll av ~250–500 nettsteder/år) — **brukbar** (ekstern, men ikke formelt tilsyn med pålegg). +- **«WCAG-compliant» selv-deklarert** uten uutilsynet-kryss-sjekk — **vanity-felle** (~78 % oppgir selv «delvis»). Kilde: [uutilsynet – status](https://www.uutilsynet.no/innsikt-og-analyse/status-tilgjengelegheitserklaeringar/1800). +- **EAA (European Accessibility Act, 2019/882): IKKE i kraft i Norge** (gjelder i EU fra 28. juni 2025, ikke innlemmet i EØS-avtalen ennå; EFTA-tidspunkt uklart). Virksomheter som selger inn i EU-markedet må likevel etterleve EAA for markedstilgang. WCAG 2.2 (okt 2023) er praktiker-anbefalt, men **ikke norsk lov**. Kilde: [Lov & Data 2025-03 – EAA-status Norden](https://lod.lovdata.no/asset/issue/2025/03/Lovogdata-2025-03.pdf) (juridisk; medium). + +### Saksbehandling og klage + +- Det finnes **ingen enkelt statutær «klagebehandlingsfrist»** (resolusjonsfrist) — en vanlig forveksling. +- **§11a (saksbehandlingstid)** = «uten ugrunnet opphold»; **foreløpig svar** kreves hvis et enkeltvedtak ikke kan besvares innen **1 måned**. Foreløpig svar er ikke et vedtak og kan ikke påklages. Kilde: [Jusinfo – saksbehandlingstid/§11a](https://jusinfo.no/forvaltningsrett/saksbehandlingstid-og-forelopig-svar/saksbehandlingstid/) (community; medium). +- **Klagefrist (å fremme klage), §29:** generelt **3 uker** fra underretning; **varierer per sektor (NAV: 6 uker)**. For vedtak som gir andre en rett: maks 3 måneder. Kilde: [NAV – klagerettigheter](https://www.nav.no/klagerettigheter) (offisiell). +- **Median saksbehandlingstid + % saker innen lovpålagt frist** — **sterk-KR** (publiseres jevnlig av f.eks. UDI/Skatteetaten/NAV; ekstern, outcome-nær). Kilde: [Skatteetaten – saksbehandlingstid](https://www.skatteetaten.no/en/contact/case-processing-time/) (offisiell). +- 🔴 **Currency-flagg (pin loven):** operativ lov nå (juni 2026) = **forvaltningsloven 1967 (LOV-1967-02-10)**. **Ny forvaltningslov LOV-2025-06-20-81** ble vedtatt 20. juni 2025, men er **«Ikke i kraft»** (Kongen bestemmer ikrafttredelse; ingen dato satt). Ekvivalenter i ny lov: §17 (saksbehandlingstid), §63 (3-ukers klagefrist). Sitater **må pinnes til riktig lov + paragraf og re-verifiseres før hver release**. Kilde: [Lovdata – forvaltningsloven 1967](https://lovdata.no/lov/1967-02-10); [Lovdata – ny forvaltningslov 2025 (ikke i kraft)](https://lovdata.no/dokument/NL/lov/2025-06-20-81) (juridisk). +- Sektorspesifikke frister (byggesak/plan- og bygningsloven, helselovgivning) finnes utenfor forvaltningsloven — **Ikke verifisert**, sjekkes per sektorlov. + +### Sikker meldingsutveksling + +- **SvarUt/Fiks (KS):** andel forsendelser **printet til papir falt 48 % (2017) → 23 % (2023)**; brukt av alle kommuner/fylkeskommuner. % digital vs printet — **sterk-KR** (KS eier data; ren effektivitet). Kilde: [KS – Status Kommune 2024](https://www.ks.no/globalassets/24095-KS-Status-kommune-2024-WEB.pdf) (KS). +- **eFormidling:** status **anbefalt («bør»), ikke påbudt** — Digitaliseringsrundskrivet **D-2/25** «...bør eFormidling benyttes», og binder **kun statlige organer** (ikke kommuner; utvidelse vurderes, ikke gjort). SLA-mål **99,90 % oppetid** (månedlig). Kilde: [Digitaliseringsrundskrivet D-2/25](https://www.regjeringen.no/no/dokumenter/digitaliseringsrundskrivet/id3103320); [Digdir docs – eFormidling](https://docs.digdir.no/docs/eFormidling/Introduksjon/) (offisiell). **brukbar** (statlig anvendelse; «bør», ikke compliance-krav). + - **Mandatory («skal») til kontrast** (ikke eFormidling): ID-porten, Altinn, Meldingsboksen/DPV, Kontakt- og reservasjonsregisteret (eForvaltningsforskriften §8), eInnsyn (statlige organer, offentleglova §6). + - Integrasjonspunkt 4.0-migrasjon pågår ~2025–2026; **eksakt frist Ikke verifisert** (agent-diskrepans 31. mai 2025 vs 2026 — verifiser mot Digdirs stegvis-guide). Kilde: [eFormidling – integrasjonspunkt 4.0-guide](https://samarbeid.digdir.no/eformidling/eformidling-stegvis-guide-overgang-til-integrasjonspunkt-40/3573). +- eFormidling adopsjonstall — **Ikke verifisert** (kun interaktive grafer; Digdir servicedesk). +- **% konfidensiell korrespondanse via sikre kanaler** (egen-kontrollert routing) — **brukbar** (reframe fra rå SLA-oppetid, som org ikke eier). + +### Sikkerhet og personvern (bonus-domene) + +- **NSM grunnprinsipper for IKT-sikkerhet v2.1 (juni 2024):** 4 kategorier, 21 prinsipper, **118 tiltak** i 3 prioritetsgrupper (~15/~20/~83). Implementeringsgrad (implementert/118, eller /15 i PG1) — **brukbar** (NB: NSM publiserer ikke fast modenhetsstige 1–5; implementeringsgrad er org-konstruert, ikke sertifisert score). Kilde: [NSM – grunnprinsipper IKT-sikkerhet v2.1](https://nsm.no/aktuelt/ny-versjon-av-nsms-grunnprinsipper-for-ikt-sikkerhet-klar) (offisiell). +- **GDPR Art. 33 — 72-timersregelen:** brudd meldes Datatilsynet «uten ugrunnet opphold og senest 72 timer». **% brudd meldt innen 72t** (eller median deteksjon→melding-tid) — **sterk-KR**. Kilde: [GDPR Art. 33 (Lovdata)](https://lovdata.no/dokument/NL/lov/2018-06-15-38/gdpr/ARTIKKEL_33) (juridisk). +- **Internkontroll (eForvaltningsforskriften §15 / ISO 27001):** kontroll-dekning %, % planlagte risikovurderinger eller ledelsens gjennomgang gjennomført — **brukbar** (dekomponert fra binært krav). Kilde: [eForvaltningsforskriften §15 (Lovdata)](https://lovdata.no/dokument/SF/forskrift/2004-06-25-988); [Digdir – internkontroll/ISO 27001](https://www.digdir.no/standarder/internkontroll-styringssystem-ledelsessystem-informasjonssikkerhet/1490) (offisiell). +- Generisk «§15-compliant» (binær) eller rå Digdir-SLA-oppetid (ikke eid av konsumerende organ) — **vanity-felle** (reframe til egen-kontrollert metrikk). + +### Vanity-feller spesifikt for offentlig digitalisering + +Disse ser imponerende ut, men Riksrevisjonen/tilsyn har dokumentert svak reell effekt — bruk dem aldri som primær-KR: + +- **«WCAG-compliant»** selv-deklarert uten uutilsynet-kryss-sjekk (se over). +- **Gevinstrealisering** selv-rapportert — sektoren feiler ~15 % (synkende, Riksrevisjon-bekreftet). +- **«Sammenhengende tjenester»** uten brukereffekt-måling — Riksrevisjonen 2025: lite reell brukereffekt. Kilde: [Riksrevisjonen – sammenhengende digitale tjenester (2025)](https://riksrevisjonen.no/kommende-rapporter/sammenhengende-digitale-tjenester) (audit). + +### Review-kadens + +Alt regelverk i denne seksjonen er et **bevegelig mål 2025–2026** (ny forvaltningslov fases inn, EAA pending, eFormidling 4.0-migrasjon, årlige indeksrunder, ny DI-indeks). Biblioteket kan derfor ikke være statisk. Før hver deling/release: re-verifiser kilde + dato per metrikk. `/okr:freshen-references` bør prioritere de volatile primærkildene: Lovdata (forvaltningslov-status), uutilsynet (WCAG/EAA), Digdir (eFormidling/indeks). Full provenans og confidence-vurdering: `research/01-norsk-offentlig-metrikker.md` (trekresearch 2026-06-24, confidence 0,85). + +*Sist oppdatert: Juni 2026* 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 new file mode 100644 index 0000000..81325d1 --- /dev/null +++ b/skills/okr-offentlig-sektor/references/okr-quality-rubrics.md @@ -0,0 +1,105 @@ +# OKR-kvalitetsrubrikker — ankret scoringsguide + +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 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 +Måler om Objective-et motiverer teamet og kommuniserer hvorfor arbeidet betyr noe. + +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 +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 før arbeid kan starte. +3. **Anker 3** — Noe vagt, men hovedretningen anes. +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 +Måler om Objective-et beskriver en ønsket tilstand (resultat) heller enn en aktivitet. + +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 gjenværende aktivitetsrest. +5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnå, ikke veien dit. + +### Scope +Måler om Objective-et er riktig dimensjonert for én tertial (fire måneder). + +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 håndterbart. +4. **Anker 4** — Passer tertialen med rimelig stretch. +5. **Anker 5 (sterkest)** — Perfekt dimensjonert for én tertial; ambisiøst men oppnåelig i perioden. + +### Alignment +Måler om Objective-et er koblet oppover til org-OKR, tildelingsbrev eller overordnet strategi. + +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 + +### Målbarhet +Måler om Key Result-et har konkrete tall med baseline og target. + +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 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 +Måler om Key Result-et fanger reell effekt heller enn output/aktivitet. + +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)** — Måler reell effekt eller resultat for bruker/samfunn. + +### Ambisjon +Måler om Key Result-et har riktig stretch — ambisiøst, men ikke urealistisk eller sandbagget. + +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 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 måloppnåelse, ambisiøst men mulig. + +### 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 målefrekvens eller tilgang. +5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent målefrekvens. + +### Uavhengighet +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 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 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 new file mode 100644 index 0000000..421bd6a --- /dev/null +++ b/skills/okr-second-brain-search/SKILL.md @@ -0,0 +1,149 @@ +--- +name: okr-second-brain-search +description: >- + Search the user's personal OKR/organization "second brain" — an OKF-compatible + markdown wiki under .claude/okr/ (project) and ~/.claude/okr/org/ (home) — to + retrieve the right strategic, governance, or cycle context on demand, in free + chat and during /okr:* commands, without pre-injecting everything. Use whenever + the user refers to their own goals, tildelingsbrev, strategy, steering signals, + or a previous cycle and the answer likely lives in their wiki rather than the + 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.1" +--- + +# OKR Second-Brain Search + +Retrieve the *right* personal/organizational OKR context at the *right* moment by +searching a user-owned, OKF-compatible markdown wiki with native tools only — +**Glob, Read, Grep**. No MCP, no search engine, no pre-injection. This is the +on-demand counterpart to the `inject-okr-context` hook: the hook emits only a +tiny pointer; this skill does the actual retrieval when the conversation needs it. + +## When to use + +Activate when the user references their own goals, governance documents, strategy, +or prior cycles — in free chat **or** under an `/okr:*` command — and the answer +plausibly lives in their wiki rather than in the prompt. Typical Norwegian cues +are listed in `Triggers on:` above ("våre mål", "tildelingsbrev", "forrige +syklus", "styringssignaler", …). Do **not** activate for generic OKR methodology +questions — those belong to the `okr-offentlig-sektor` skill. + +## The two bundle roots + +The second brain lives in **two roots with different lifecycles**; always search +both, project first: + +1. **Project bundle** — `.claude/okr/` in the current working directory + (cycle/work data, cwd-bound): `strategisk-kontekst/`, `syklus//`, + `historikk/`, `dokumenter/`. +2. **Home bundle** — `~/.claude/okr/org/` (organization identity, survives + reinstall). + +Each root carries its own `index.md` per level and an `okf_version` marker on its +root `index.md`. Project content overrides home content on conflict +(most-specific-wins, mirroring the hook's resolution). + +## OKF layout (what you are searching) + +The wiki follows the Knowledge Catalog **"Documents / `kb` Layout"** ("Metadata as +Code"). Each **concept file** carries YAML frontmatter in this field order: + +```yaml +--- +type: Tildelingsbrev # Title-Case human string; the only de-facto required field +resource: https://… # URL/path to the source +title: … +description: … +tags: # YAML list (multi-line) — optional +- styring +timestamp: '2026-01-15T09:00:00+00:00' # ISO-8601, quoted +--- +# Heading +…body… +``` + +`type` values you will encounter: `Tildelingsbrev`, `OKR`, `Retrospektiv`, +`Organisasjonsprofil`, `Virksomhetsplan`, `Status` (and occasionally others — +treat unknown types as valid, never error on them). + +Each level has an **`index.md`** with **no frontmatter**, formatted as a heading +plus one bullet per concept file: + +``` +# Heading +* [title](relative.md) - description +``` + +The `index.md` is the curated table of contents for its level — use it to +navigate and to break ranking ties (below). + +## Retrieval procedure + +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 — 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 + (e.g. "mål" → "mål", "objective", "OKR"). + - **(ii) Domain translation** — map everyday phrasing to the domain/governance + vocabulary actually written in the files (e.g. "hva må vi levere" → + "tildelingsbrev", "styringssignal"; "forrige runde" → "retrospektiv", + "historikk"). + - **(iii) Broader category** — fall back to the `type:` or the containing level + (e.g. search `type: OKR` under `syklus/`, or `type: Tildelingsbrev` under + `strategisk-kontekst/`). +3. **Grep** each variation across the concept files (token in body/frontmatter, + or `type:` for category queries). Exclude `index.md` from concept matches. +4. **Merge, dedup, and rank.** Combine the hits from all variations, dedup by + path, then rank by this **deterministic tie-break**: + - **+2** if the token appears in the file's **frontmatter** (`title`/`type`/`tags`). + - **+1** if the token appears in the file's entry in the nearest **`index.md`**. + - On equal score, the file listed **earlier in its `index.md`** wins; final ties + break by path order. + This is exactly the ranking the regression test (`tests/okf-retrieval.test.mjs`) + pins: a frontmatter/index match outranks a body-only match, so a shared token + resolves to the concept that *names* it rather than one that merely mentions it. +5. **Read only the top concept file(s)** — usually one. Do not bulk-read the tree; + the point is selective retrieval. Cite the file path you used. + +### Robustness + +- **Dangling links** in an `index.md` (a bullet pointing at a missing file): skip + silently, never fail. +- **Unknown `type:`**: accept it; rank and read as normal. +- **Empty/greenfield wiki**: if neither root exists or has concept files, say so + briefly and proceed from the prompt — never fabricate wiki content. + +## Trigger smoke-test (Assumption 1 — manual) + +Skill activation is model-judged, not unit-testable. Verify it manually: + +- **`/doctor`** — confirm `okr-second-brain-search` is listed and its + `description` is not trimmed by the skill-listing budget. If trimmed, raise + `skillListingBudgetFraction` (the `Triggers on:` phrases are placed early in the + description precisely so the highest-signal cues survive trimming). +- **Free-chat probe** — with a populated `.claude/okr/`, send a prompt with **no** + `/okr:*` command, e.g. *"Hva sier tildelingsbrevet vårt om mål dette tertialet?"* + The skill should activate and retrieve from `strategisk-kontekst/` and + `syklus//` without being told where to look. +- **Fallback** — if activation does not fire, the `inject-okr-context` hook still + emits a deterministic pointer to the resolved `index.md`, and any `/okr:*` + command can invoke this skill explicitly. Retrieval degrades, it does not break. + +## Resources + +- **Wiki data (project):** `.claude/okr/` — `strategisk-kontekst/`, `syklus//`, + `historikk/`, `dokumenter/`, each with an `index.md`. +- **Wiki data (home):** `~/.claude/okr/org/` — organization profile bundle. +- **Index/check tooling:** `scripts/okf-index.mjs` (regenerate `index.md` per root) + and `scripts/okf-check.mjs` (validate every concept file has `type:`). +- **Companion skill:** `okr-offentlig-sektor` for OKR methodology (writing, + scoring, cascading) — this skill only *retrieves* the user's own context. diff --git a/templates/okr.local.md.template b/templates/okr.local.md.template index 511b74e..e5e844d 100644 --- a/templates/okr.local.md.template +++ b/templates/okr.local.md.template @@ -2,27 +2,57 @@ # OKR Plugin - Lokal Konfigurasjon # Plasser denne filen som .claude/okr.local.md i ditt prosjekt # Kjør /okr:oppsett for automatisk konfigurasjon - +# Skjemaet under speiler /okr:oppsett mvp — feltnavn er i paritet med onboarding og hook +# Org-identitet (organisasjon:/program:) kan ogsaa bo i en reinstall-overlevende +# hjem-profil: ~/.claude/okr/org/profil.md (skrevet av /okr:oppsett). En prosjektlokal +# fil her vinner over hjem-profilen (mest-spesifikk-vinner); syklus-/kontekstdata er +# alltid prosjektlokalt. +# OKF-frontmatter (second-brain-kompatibel): hjem-profilen faar disse tre top-level- +# noeklene av /okr:oppsett via compose-org-profile.mjs. Kun type/resource/timestamp er +# trygge top-level (leses ikke av hooks); legg ALDRI til top-level navn/id/fase/domene/ +# sektor -- de ville skygge de nestede verdiene under organisasjon:/gjeldende_syklus:. +type: Organisasjonsprofil +resource: ~/.claude/okr/org/profil.md +timestamp: '' # ISO-8601, settes ved skriv +onboarding_status: partial # partial | fullfort organisasjon: navn: "Din organisasjon" + kortform: "" # kort forkortelse, f.eks. SVV type: "offentlig" # offentlig | privat - sektor: "transport" # transport | helse | justis | digitalisering | annet - -syklus: - modell: "tertial" # tertial (4-mnd) | kvartal (3-mnd) - gjeldende: "T1-2026" # Format: T[1-3]-YYYY eller Q[1-4]-YYYY - startdato: "2026-01-01" - + departement: "" # overordnet departement + ansatte_i_okr_program: 0 + domene: "transport" # transport | helse | justis | digitalisering | annet + geografi: "nasjonal" # nasjonal | regional | lokal +program: + modenhetsnivaa: "utforsker" # ikke-startet | utforsker | pilot | skalering | moden + sykluser_gjennomfort: 0 + sponsor: "" + champion: "" + okr_frikoblet_fra_loenn: true # true = OKR ikke koblet til lønnssamtale (anbefalt) + alignment_tilnaerming: "bidireksjonell" # top-down | bottom-up | bidireksjonell +gjeldende_syklus: + id: "T1-2026" # Format: T[1-3]-YYYY eller Q[1-4]-YYYY + periode: "2026-01-01 til 2026-04-30" + fase: "planlegging" # planlegging | gjennomforing | review | avslutning + antall_team: 1 +verktoy: + oppgavestyring: "" + okr_tracking: "" + moeteverktoy: "" + leveransemetodikk: "" +kultur: + sjekk_inn_rytme: "annenhver uke" + psykologisk_trygghet: "middels" # lav | middels | hoey + kjente_utfordringer: [] integrasjoner: linear: aktivert: false team_id: "" # Linear team ID project_id: "" # Linear project ID for OKR - preferanser: - språk: "no" # no | en + spraak: "no" # no | en vis_eksempler: true - ambisjonsnivå: "balansert" # konservativ | balansert | ambisiøs + ambisjonsnivaa: "balansert" # konservativ | balansert | ambisiøs --- # Notater diff --git a/tests/coaching-hook.test.mjs b/tests/coaching-hook.test.mjs new file mode 100644 index 0000000..aa45bcf --- /dev/null +++ b/tests/coaching-hook.test.mjs @@ -0,0 +1,99 @@ +// coaching-hook.test.mjs +// Tester coaching-hook (SC4): graceful-exit-grener + gyldig-syklus-emisjon + +// deterministisk fase via OKR_NOW klokke-seam. Spawner hooken som subprosess +// med kontrollert cwd + env. Zero npm deps. Coaching leser kun cwd, saa én +// temp-dir per test holder. Moenster: tests/inject-okr-context.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'coaching-hook.mjs', +); + +function writeConfig(workDir, body) { + const p = join(workDir, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'okr.local.md'), body); +} + +function runHook(cwd, okrNow) { + // execFileSync returnerer stdout; hooken avslutter alltid med exit 0. + const env = { ...process.env }; + if (okrNow) env.OKR_NOW = okrNow; + else delete env.OKR_NOW; + return execFileSync('node', [HOOK], { cwd, env, encoding: 'utf8' }); +} + +function withWork(fn) { + const work = mkdtempSync(join(tmpdir(), 'okrcoach-')); + try { + fn(work); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +test('ingen config: exit 0, tom stdout (graceful)', () => { + withWork((work) => { + const out = runHook(work); + assert.equal(out.trim(), '', 'ingen config -> tom stdout, ingen blokkering'); + }); +}); + +test('config uten id: exit 0, tom stdout', () => { + withWork((work) => { + writeConfig(work, '---\nnavn: "Org"\n---\n'); + const out = runHook(work); + assert.equal(out.trim(), '', 'manglende id -> tom stdout'); + }); +}); + +test('ugyldig id: exit 0, tom stdout', () => { + withWork((work) => { + writeConfig(work, '---\nid: "ugyldig"\n---\n'); + const out = runHook(work); + assert.equal(out.trim(), '', 'id som ikke matcher T/Q-moenster -> tom stdout'); + }); +}); + +test('gyldig syklus: emitterer systemMessage', () => { + withWork((work) => { + writeConfig(work, '---\nid: "T2-2026"\n---\n'); + const out = runHook(work); + assert.match(out, /OKR coaching/, 'gyldig syklus skal emittere coaching-melding'); + }); +}); + +test('OKR_NOW tidlig fase: early-coaching', () => { + withWork((work) => { + writeConfig(work, '---\nid: "T2-2026"\n---\n'); + const out = runHook(work, '2026-05-05'); + assert.match(out, /Tidlig i syklusen/, 'uke 1 av T2 -> tidlig fase'); + }); +}); + +test('OKR_NOW midtveis fase: mid-coaching', () => { + withWork((work) => { + writeConfig(work, '---\nid: "T2-2026"\n---\n'); + const out = runHook(work, '2026-06-20'); + assert.match(out, /Midtveis i syklusen/, 'uke ~8 av T2 -> midtveis fase'); + }); +}); + +test('OKR_NOW sen fase: late-coaching', () => { + withWork((work) => { + writeConfig(work, '---\nid: "T2-2026"\n---\n'); + const out = runHook(work, '2026-08-25'); + assert.match(out, /sluttspurt/, 'uke ~16 av T2 -> sen fase'); + }); +}); 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/fixtures/okf-minimal/index.md b/tests/fixtures/okf-minimal/index.md new file mode 100644 index 0000000..1294af6 --- /dev/null +++ b/tests/fixtures/okf-minimal/index.md @@ -0,0 +1,5 @@ +# OKF second brain (minimal testfixtur) + +okf_version: kb-layout-2026-06 + +* [Tildelingsbrev](tildelingsbrev.md) - Styringssignal med unik testtoken. diff --git a/tests/fixtures/okf-minimal/tildelingsbrev.md b/tests/fixtures/okf-minimal/tildelingsbrev.md new file mode 100644 index 0000000..552f230 --- /dev/null +++ b/tests/fixtures/okf-minimal/tildelingsbrev.md @@ -0,0 +1,12 @@ +--- +type: Tildelingsbrev +resource: https://example.no/min/tildelingsbrev +title: Minimalt tildelingsbrev +description: Et minimalt OKF-konsept med en unik token. +tags: +- styring +timestamp: '2026-01-10T08:00:00+00:00' +--- +# Tildelingsbrev + +Dette dokumentet inneholder den unike testtokenen kvikkleireskred. diff --git a/tests/fixtures/okf-realistic/dokumenter/index.md b/tests/fixtures/okf-realistic/dokumenter/index.md new file mode 100644 index 0000000..0acb5b3 --- /dev/null +++ b/tests/fixtures/okf-realistic/dokumenter/index.md @@ -0,0 +1,3 @@ +# Dokumenter + +* [Arbeidsnotat](notat.md) - Internt arbeidsnotat med ukjent type. diff --git a/tests/fixtures/okf-realistic/dokumenter/notat.md b/tests/fixtures/okf-realistic/dokumenter/notat.md new file mode 100644 index 0000000..444b712 --- /dev/null +++ b/tests/fixtures/okf-realistic/dokumenter/notat.md @@ -0,0 +1,12 @@ +--- +type: Notat +resource: local +title: Arbeidsnotat +description: Internt arbeidsnotat (ukjent OKF-type for robusthetstest). +tags: +- internt +timestamp: '2026-03-01T12:00:00+00:00' +--- +# Arbeidsnotat + +Dette arbeidsnotat har en type som ikke er i det kjente OKR-settet. diff --git a/tests/fixtures/okf-realistic/historikk/index.md b/tests/fixtures/okf-realistic/historikk/index.md new file mode 100644 index 0000000..a65fc07 --- /dev/null +++ b/tests/fixtures/okf-realistic/historikk/index.md @@ -0,0 +1,4 @@ +# Historikk + +* [Retrospektiv T3-2025](retrospektiv-T3-2025.md) - Laeringssloeyfe fra forrige syklus. +* [Retrospektiv T2-2025](retrospektiv-T2-2025.md) - Mangler med vilje (dangling-test). diff --git a/tests/fixtures/okf-realistic/historikk/retrospektiv-T3-2025.md b/tests/fixtures/okf-realistic/historikk/retrospektiv-T3-2025.md new file mode 100644 index 0000000..6d9fedd --- /dev/null +++ b/tests/fixtures/okf-realistic/historikk/retrospektiv-T3-2025.md @@ -0,0 +1,12 @@ +--- +type: Retrospektiv +resource: local +title: Retrospektiv T3-2025 +description: Laering fra forrige syklus. +tags: +- retro +timestamp: '2025-12-20T14:00:00+00:00' +--- +# Retrospektiv T3-2025 + +Viktigste laeringssloeyfe: tettere oppfoelging av KR-data. diff --git a/tests/fixtures/okf-realistic/index.md b/tests/fixtures/okf-realistic/index.md new file mode 100644 index 0000000..5198037 --- /dev/null +++ b/tests/fixtures/okf-realistic/index.md @@ -0,0 +1,8 @@ +# OKF second brain - Vegdirektoratet (realistisk testfixtur) + +okf_version: kb-layout-2026-06 + +* [Strategisk kontekst](strategisk-kontekst/index.md) - Tildelingsbrev, virksomhetsplan og profil. +* [Syklus T1-2026](syklus/T1-2026/index.md) - Aktive OKR og statusrapport. +* [Historikk](historikk/index.md) - Arkiverte sykluser og retrospektiv. +* [Dokumenter](dokumenter/index.md) - Lose arbeidsdokumenter. diff --git a/tests/fixtures/okf-realistic/strategisk-kontekst/index.md b/tests/fixtures/okf-realistic/strategisk-kontekst/index.md new file mode 100644 index 0000000..da01fb4 --- /dev/null +++ b/tests/fixtures/okf-realistic/strategisk-kontekst/index.md @@ -0,0 +1,5 @@ +# Strategisk kontekst + +* [Tildelingsbrev 2026](tildelingsbrev-2026.md) - Klimaomstilling og sikkerhet. +* [Virksomhetsplan 2026](virksomhetsplan.md) - Kompetanseloeft og effektivisering. +* [Organisasjonsprofil](organisasjonsprofil.md) - Vegdirektoratet, offentlig sektor. diff --git a/tests/fixtures/okf-realistic/strategisk-kontekst/organisasjonsprofil.md b/tests/fixtures/okf-realistic/strategisk-kontekst/organisasjonsprofil.md new file mode 100644 index 0000000..7ca506b --- /dev/null +++ b/tests/fixtures/okf-realistic/strategisk-kontekst/organisasjonsprofil.md @@ -0,0 +1,12 @@ +--- +type: Organisasjonsprofil +resource: local +title: Organisasjonsprofil +description: Identitet og sektor for organisasjonen. +tags: +- profil +timestamp: '2026-01-05T08:00:00+00:00' +--- +# Organisasjonsprofil + +Organisasjonen er Vegdirektoratet, en etat i offentlig sektor. diff --git a/tests/fixtures/okf-realistic/strategisk-kontekst/tildelingsbrev-2026.md b/tests/fixtures/okf-realistic/strategisk-kontekst/tildelingsbrev-2026.md new file mode 100644 index 0000000..9eeb1dc --- /dev/null +++ b/tests/fixtures/okf-realistic/strategisk-kontekst/tildelingsbrev-2026.md @@ -0,0 +1,13 @@ +--- +type: Tildelingsbrev +resource: https://example.no/tildelingsbrev-2026 +title: Tildelingsbrev 2026 +description: Departementets styringssignaler for budsjettaaret 2026. +tags: +- styring +- klima +timestamp: '2026-01-15T09:00:00+00:00' +--- +# Tildelingsbrev 2026 + +Hovedprioriteten for 2026 er klimaomstilling i transportsektoren. diff --git a/tests/fixtures/okf-realistic/strategisk-kontekst/virksomhetsplan.md b/tests/fixtures/okf-realistic/strategisk-kontekst/virksomhetsplan.md new file mode 100644 index 0000000..2e754c1 --- /dev/null +++ b/tests/fixtures/okf-realistic/strategisk-kontekst/virksomhetsplan.md @@ -0,0 +1,12 @@ +--- +type: Virksomhetsplan +resource: https://example.no/virksomhetsplan-2026 +title: Virksomhetsplan 2026 +description: Intern operasjonalisering av tildelingsbrevet. +tags: +- plan +timestamp: '2026-01-20T10:00:00+00:00' +--- +# Virksomhetsplan 2026 + +Et sentralt tiltak er et kompetanseloeft for digital forvaltning. diff --git a/tests/fixtures/okf-realistic/syklus/T1-2026/index.md b/tests/fixtures/okf-realistic/syklus/T1-2026/index.md new file mode 100644 index 0000000..9a38f21 --- /dev/null +++ b/tests/fixtures/okf-realistic/syklus/T1-2026/index.md @@ -0,0 +1,5 @@ +# Syklus T1-2026 + +* [Trafikksikkerhet og tunnelsikkerhet](okr-trafikksikkerhet.md) - Maal om nullvisjon i tunneler. +* [Digitalisering av tjenester](okr-digitalisering.md) - Oekt selvbetjeningsgrad. +* [Statusrapport T1](status.md) - Fremdriftsindikator for tertialet. diff --git a/tests/fixtures/okf-realistic/syklus/T1-2026/okr-digitalisering.md b/tests/fixtures/okf-realistic/syklus/T1-2026/okr-digitalisering.md new file mode 100644 index 0000000..9673590 --- /dev/null +++ b/tests/fixtures/okf-realistic/syklus/T1-2026/okr-digitalisering.md @@ -0,0 +1,12 @@ +--- +type: OKR +resource: local +title: Digitalisering av tjenester +description: Tertialmaal for digital selvbetjening. +tags: +- digital +timestamp: '2026-02-01T09:05:00+00:00' +--- +# Digitalisering av tjenester + +Objective: Oeke selvbetjeningsgrad i publikumstjenester. diff --git a/tests/fixtures/okf-realistic/syklus/T1-2026/okr-trafikksikkerhet.md b/tests/fixtures/okf-realistic/syklus/T1-2026/okr-trafikksikkerhet.md new file mode 100644 index 0000000..b5ca7f9 --- /dev/null +++ b/tests/fixtures/okf-realistic/syklus/T1-2026/okr-trafikksikkerhet.md @@ -0,0 +1,14 @@ +--- +type: OKR +resource: local +title: Trafikksikkerhet og tunnelsikkerhet +description: Tertialmaal for sikrere veier og tunneler. +tags: +- sikkerhet +- tunnel +timestamp: '2026-02-01T09:00:00+00:00' +--- +# Trafikksikkerhet og tunnelsikkerhet + +Objective: Styrke tunnelsikkerhet i tertialet. + KR1: Naa nullvisjon for alvorlige tunnelhendelser. diff --git a/tests/fixtures/okf-realistic/syklus/T1-2026/status.md b/tests/fixtures/okf-realistic/syklus/T1-2026/status.md new file mode 100644 index 0000000..7bab58d --- /dev/null +++ b/tests/fixtures/okf-realistic/syklus/T1-2026/status.md @@ -0,0 +1,12 @@ +--- +type: Status +resource: local +title: Statusrapport T1-2026 +description: Fremdrift for innevaerende tertial. +tags: +- status +timestamp: '2026-04-30T16:00:00+00:00' +--- +# Statusrapport T1-2026 + +Fremdriftsindikator viser god progresjon. Arbeidet med tunnelsikkerhet ligger noe bak skjema. diff --git a/tests/frontmatter-compat.test.mjs b/tests/frontmatter-compat.test.mjs new file mode 100644 index 0000000..34517ef --- /dev/null +++ b/tests/frontmatter-compat.test.mjs @@ -0,0 +1,109 @@ +// frontmatter-compat.test.mjs +// Karakteriserings-/kompatibilitetstest (SC2): beviser at ruting av +// inject-okr-context.mjs gjennom den delte lib/frontmatter.mjs er +// ATFERDS-BEVARENDE for de 9 leste noeklene, med EN tilsiktet endring: +// comment-leak paa usitert `okr_frikoblet_fra_loenn`-linje forsvinner. +// Spawner hooken som subprosess (full 9-felts frontmatter, rikere enn +// inject-okr-context.test.mjs sin makeProjectConfig som kun skriver navn). +// Zero npm deps. Moenster: tests/inject-okr-context.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'inject-okr-context.mjs', +); + +// Bygger en full 9-felts okr.local.md. okr_frikoblet_fra_loenn skrives USITERT +// MED en trailing "# ..."-kommentar — det er denne lekkasjen den delte +// parseren skal fjerne. +function makeFullConfig(workDir, fields = {}) { + const f = { + navn: 'TestOrg', + kortform: 'TO', + gjeldende: 'T2-2026', + sektor: 'transport', + modenhetsnivaa: 'nivaa-2', + fase: 'utforsker', + okr_frikoblet_fra_loenn: 'true', + psykologisk_trygghet: 'hoy', + linear: true, + ...fields, + }; + const body = + [ + '---', + `navn: "${f.navn}"`, + `kortform: "${f.kortform}"`, + `gjeldende: "${f.gjeldende}"`, + `sektor: "${f.sektor}"`, + `modenhetsnivaa: "${f.modenhetsnivaa}"`, + `fase: "${f.fase}"`, + `okr_frikoblet_fra_loenn: ${f.okr_frikoblet_fra_loenn} # frikoblet fra lonn`, + `psykologisk_trygghet: "${f.psykologisk_trygghet}"`, + 'linear:', + ` aktivert: ${f.linear}`, + '---', + '', + ].join('\n'); + const p = join(workDir, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'okr.local.md'), body); +} + +const OKR_PROMPT = JSON.stringify({ prompt: 'hjelp meg skrive OKR' }); + +function runHook(cwd, home, input = OKR_PROMPT) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('full config: alle 9 noekler resolver likt som foer', () => { + withDirs((home, work) => { + makeFullConfig(work); + const out = runHook(work, home); + assert.match(out, /Organisasjon: TestOrg \(TO\)/, 'navn + kortform'); + assert.match(out, /Syklus: T2-2026 \[utforsker\]/, 'gjeldende + fase'); + assert.match(out, /Sektor: transport/, 'sektor'); + assert.match(out, /Modenhet: nivaa-2/, 'modenhetsnivaa'); + assert.match(out, /OKR frikoblet fra lonn: true/, 'frikoblet-verdi'); + assert.match(out, /Psykologisk trygghet: hoy/, 'psykologisk_trygghet'); + assert.match(out, /Linear: aktivert/, 'linear aktivert: true'); + }); +}); + +test('comment-leak fjernet: ingen "#" i injisert melding', () => { + withDirs((home, work) => { + makeFullConfig(work); + const out = runHook(work, home); + assert.doesNotMatch( + out, + /#/, + 'usitert frikoblet-linjes "# ..."-kommentar skal ikke lekke inn i meldingen', + ); + }); +}); diff --git a/tests/frontmatter.test.mjs b/tests/frontmatter.test.mjs new file mode 100644 index 0000000..5fb92cf --- /dev/null +++ b/tests/frontmatter.test.mjs @@ -0,0 +1,135 @@ +// frontmatter.test.mjs +// Tester den delte frontmatter-modulen (SC2): parseFrontmatter/get reproduserer +// hookenes flate first-match-parser MEN (1) strip trailing " #..." KUN fra +// USITERTE verdier, (2) anker key-match til linjestart (ingen midt-i-linje +// falske treff), og (3) tolererer fler-linje OKF-list-verdier (`tags:`) uten +// krasj. writeFrontmatter round-tripper og siterer `#`. Importerer modulen +// direkte (zero npm deps). Moenster: tests/coaching-hook.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseFrontmatter, writeFrontmatter } from '../lib/frontmatter.mjs'; + +test('basic: sitert skalar leses', () => { + const { get } = parseFrontmatter('---\nnavn: "Org"\n---\n'); + assert.equal(get('navn'), 'Org'); +}); + +test('basic: usitert skalar leses', () => { + const { get } = parseFrontmatter('---\nnavn: Org\n---\n'); + assert.equal(get('navn'), 'Org'); +}); + +test('usitert verdi: trailing "# kommentar" strippes (comment-leak-fix)', () => { + const { get } = parseFrontmatter( + '---\nokr_frikoblet_fra_loenn: true # frikoblet fra lonn\n---\n', + ); + assert.equal(get('okr_frikoblet_fra_loenn'), 'true', 'usitert: # strippes'); +}); + +test('sitert verdi: intern "#" bevares (space-hash i quotes)', () => { + const { get } = parseFrontmatter('---\nkortform: "A #B"\n---\n'); + assert.equal(get('kortform'), 'A #B', 'sitert: # er en del av verdien, ikke kommentar'); +}); + +test('sitert verdi med kommentar UTENFOR quotes: kommentar ignoreres', () => { + const { get } = parseFrontmatter('---\nkortform: "A #B" # en note\n---\n'); + assert.equal(get('kortform'), 'A #B'); +}); + +test('first-match: forste forekomst vinner', () => { + const { get } = parseFrontmatter('---\nnavn: "Forste"\nnavn: "Andre"\n---\n'); + assert.equal(get('navn'), 'Forste'); +}); + +test('nestet: innrykket key resolver (load-bearing inject:69)', () => { + const { get } = parseFrontmatter( + '---\norganisasjon:\n navn: "NestetOrg"\n type: "offentlig"\n---\n', + ); + assert.equal(get('navn'), 'NestetOrg', 'innrykket navn under organisasjon: skal resolve'); +}); + +test('linjeanker: key matcher IKKE som substring av annen key', () => { + const { get } = parseFrontmatter('---\netternavn: "Hansen"\n---\n'); + assert.equal(get('navn'), null, 'navn skal ikke matche etternavn: (anker linjestart)'); +}); + +test('OKF type: Title-Case enkeltlinje', () => { + const { get } = parseFrontmatter('---\ntype: Organisasjonsprofil\n---\n'); + assert.equal(get('type'), 'Organisasjonsprofil'); +}); + +test('OKF resource: URL med ":" bevares', () => { + const { get } = parseFrontmatter('---\nresource: https://example.com/path\n---\n'); + assert.equal(get('resource'), 'https://example.com/path'); +}); + +test('OKF timestamp: sitert ISO-8601 med ":" og "+" bevares', () => { + const { get } = parseFrontmatter("---\ntimestamp: '2026-05-28T22:49:59+00:00'\n---\n"); + assert.equal(get('timestamp'), '2026-05-28T22:49:59+00:00'); +}); + +test('OKF fler-linje tags-liste: krasjer ikke + folgende skalar resolver', () => { + const content = + '---\n' + + 'type: BigQuery Dataset\n' + + 'tags:\n' + + '- ecommerce\n' + + '- web analytics\n' + + "timestamp: '2026-05-28T22:49:59+00:00'\n" + + '---\n# body\n'; + const { get } = parseFrontmatter(content); + assert.doesNotThrow(() => get('tags'), 'get paa list-key skal ikke krasje'); + assert.equal(get('type'), 'BigQuery Dataset'); + assert.equal( + get('timestamp'), + '2026-05-28T22:49:59+00:00', + 'skalar etter list-blokk skal fortsatt resolve', + ); +}); + +test('ingen frontmatter: raw null, get returnerer null', () => { + const { raw, get } = parseFrontmatter('ingen frontmatter her\n'); + assert.equal(raw, null); + assert.equal(get('navn'), null); +}); + +test('writeFrontmatter: round-trip via parseFrontmatter', () => { + const block = writeFrontmatter({ navn: 'Org', type: 'Organisasjonsprofil' }); + const { get } = parseFrontmatter(block); + assert.equal(get('navn'), 'Org'); + assert.equal(get('type'), 'Organisasjonsprofil'); +}); + +test('writeFrontmatter: verdi med "#" siteres og round-tripper', () => { + const block = writeFrontmatter({ kortform: 'A #B' }); + assert.match(block, /kortform: "A #B"/, 'verdi med # skal siteres'); + 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/inject-core-cap.test.mjs b/tests/inject-core-cap.test.mjs new file mode 100644 index 0000000..c84e98e --- /dev/null +++ b/tests/inject-core-cap.test.mjs @@ -0,0 +1,138 @@ +// inject-core-cap.test.mjs +// Verifiserer hook-splitten (SC5): inject-okr-context emitterer kun kjerne- +// kontekst + EN resolvert index.md-peker, ALDRI tre-enumerering. Beviser: +// (a) byte-cap < 512; (b) payload uavhengig av filantall (lite vs stort tre); +// (c) ingen konsept-filnavn lekker; (d) home-only -> home-bundle-sti i peker; +// (e) ingen '#'. +// Spawner hooken som subprosess m/ kontrollert cwd + HOME. Zero npm deps. +// Monster: tests/inject-okr-context.test.mjs + tre-bygger-helper. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'inject-okr-context.mjs', +); + +const OKR_PROMPT = JSON.stringify({ prompt: 'hjelp meg skrive OKR' }); + +function makeProjectConfig(work, navn = 'CapTestOrg') { + const p = join(work, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync( + join(p, 'okr.local.md'), + `---\nnavn: "${navn}"\ngjeldende: "T1-2026"\nsektor: "transport"\n---\n`, + ); +} + +// Idempotent paa index.md, additiv paa konsept-filer (0..count-1). Distinkte +// filnavn (konseptfil-N) for lekkasje-testen. +function makeProjectTree(work, count) { + const lvl = join(work, '.claude', 'okr', 'strategisk-kontekst'); + mkdirSync(lvl, { recursive: true }); + writeFileSync( + join(work, '.claude', 'okr', 'index.md'), + '# OKR-rot\n\nokf_version: kb-layout-2026-06\n', + ); + for (let i = 0; i < count; i++) { + writeFileSync(join(lvl, `konseptfil-${i}.md`), `---\ntype: OKR\n---\n# K${i}\n`); + } +} + +function makeHomeBundle(home, navn = 'HomeCapOrg') { + const org = join(home, '.claude', 'okr', 'org'); + mkdirSync(org, { recursive: true }); + writeFileSync(join(org, 'profil.md'), `---\nnavn: "${navn}"\n---\n`); + writeFileSync(join(org, 'index.md'), '# Org-rot\n\nokf_version: kb-layout-2026-06\n'); +} + +function runHook(cwd, home, input = OKR_PROMPT) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function systemMessage(out) { + return JSON.parse(out).systemMessage; +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +// (a) byte-cap < 512 +test('cap: systemMessage < 512 byte (utf8) selv med 50 konsept-filer', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 50); + const msg = systemMessage(runHook(work, home)); + const bytes = Buffer.byteLength(msg, 'utf8'); + assert.ok(bytes < 512, `payload skal vaere < 512 B, var ${bytes}`); + }); +}); + +// (b) invariant: lite (index+3) vs stort (index+50) tre -> byte-identisk payload. +// SAMME work-dir i begge maalinger -> peker-stien er konstant; eneste variabel +// er filantallet, som splitten skal gjore irrelevant. +test('invariant: payload byte-identisk for lite og stort tre (ingen enumerering)', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 3); + const small = systemMessage(runHook(work, home)); + makeProjectTree(work, 50); + const large = systemMessage(runHook(work, home)); + assert.equal(small, large, 'filantall skal ikke paavirke payload'); + }); +}); + +// (c) ingen konsept-filnavn enumerert +test('ingen lekkasje: konsept-filnavn opptrer ikke i payload; peker bruker index.md', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 5); + const msg = systemMessage(runHook(work, home)); + assert.doesNotMatch(msg, /konseptfil-/, 'konsept-filnavn skal ikke enumereres'); + assert.match(msg, /index\.md/, 'peker skal referere index.md'); + }); +}); + +// (d) home-only -> home-bundle index.md i peker +test('home-only: peker bruker home-bundle index.md (~/.claude/okr/org/index.md)', () => { + withDirs((home, work) => { + makeHomeBundle(home); // tomt prosjekt, kun home + const msg = systemMessage(runHook(work, home)); + assert.match(msg, /HomeCapOrg/, 'org leses fra home-profil'); + assert.match(msg, /okr-second-brain-search/, 'peker nevner skillen'); + const expected = join(home, '.claude', 'okr', 'org', 'index.md'); + assert.ok(msg.includes(expected), `peker skal inneholde home-index-sti: ${expected}`); + }); +}); + +// (e) ingen '#' +test('ingen "#" i payload (peker + sti ASCII-rent)', () => { + withDirs((home, work) => { + makeProjectConfig(work); + makeProjectTree(work, 3); + const msg = systemMessage(runHook(work, home)); + assert.doesNotMatch(msg, /#/, 'payload skal ikke inneholde "#"'); + }); +}); diff --git a/tests/inject-okr-context.test.mjs b/tests/inject-okr-context.test.mjs new file mode 100644 index 0000000..ce7326b --- /dev/null +++ b/tests/inject-okr-context.test.mjs @@ -0,0 +1,84 @@ +// inject-okr-context.test.mjs +// Tester hybrid org-profil-resolusjon (SC6): prosjekt-lokal -> hjem-profil. +// Spawner hooken som subprosess med kontrollert cwd + HOME. Zero npm deps. +// Plassert i tests/ (ikke hooks/scripts/) pga. pathguard-vern av hook-mappa. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'inject-okr-context.mjs', +); + +function makeHomeProfil(homeDir, navn) { + const p = join(homeDir, '.claude', 'okr', 'org'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'profil.md'), `---\nnavn: "${navn}"\n---\n`); +} + +function makeProjectConfig(workDir, navn) { + const p = join(workDir, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'okr.local.md'), `---\nnavn: "${navn}"\n---\n`); +} + +function runHook(cwd, home, input = '') { + // execFileSync returns stdout; the hook always exits 0. + // input pipes a UserPromptSubmit payload through stdin so the emne-guard + // (topic guard) sees an explicit OKR-relevant prompt instead of suppressing. + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +// OKR-relevant prompt slik at emne-guarden injiserer i stedet for aa suppresse. +const OKR_PROMPT = JSON.stringify({ prompt: 'hjelp meg skrive OKR' }); + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('kun-hjem: leser org fra hjem-profil, ingen syklus-kontekst', () => { + withDirs((home, work) => { + makeHomeProfil(home, 'HjemOrg'); + const out = runHook(work, home, OKR_PROMPT); + assert.match(out, /HjemOrg/, 'org skal komme fra hjem-profil naar prosjekt-config mangler'); + assert.doesNotMatch(out, /Tilgjengelige kontekstfiler/, 'kun-hjem har ingen prosjekt-lokalt syklus-tre (okrDir er cwd-bundet)'); + }); +}); + +test('begge: prosjekt-lokal vinner over hjem (mest-spesifikk-vinner)', () => { + withDirs((home, work) => { + makeHomeProfil(home, 'HjemOrg'); + makeProjectConfig(work, 'ProsjektOrg'); + const out = runHook(work, home, OKR_PROMPT); + assert.match(out, /ProsjektOrg/, 'prosjekt-lokal skal vinne'); + assert.doesNotMatch(out, /HjemOrg/, 'hjem skal ikke leses naar prosjekt-config finnes'); + }); +}); + +test('ingen: exit 0, tom output (graceful, regresjonsvakt)', () => { + withDirs((home, work) => { + const out = runHook(work, home); + assert.equal(out.trim(), '', 'ingen config -> tom stdout, ingen blokkering'); + }); +}); 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 new file mode 100644 index 0000000..887fac5 --- /dev/null +++ b/tests/okf-check.test.mjs @@ -0,0 +1,312 @@ +// okf-check.test.mjs +// Tester OKF-mekanismen (Step 6): okf-index genererer index.md per nivaa i ekte +// OKF-form, og okf-check validerer at hver konsept-fil baerer `type:`. +// okf-index kjoeres mot en TEMP-KOPI av okf-realistic (cpSync) saa de committede +// fixturene (lest read-only av okf-retrieval.test.mjs, m/ en bevisst dangling-link) +// forblir uroert. okf-check kjoeres som subprosess for aa fange exit-koden (kontrakt). +// Zero npm deps. Plassert i tests/ (samme moenster som org-profile-write.test.mjs). + +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, mkdirSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateIndexes } from '../scripts/okf-index.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const CHECK = join(ROOT, 'scripts', 'okf-check.mjs'); +const REALISTIC = join(ROOT, 'tests', 'fixtures', 'okf-realistic'); + +function tmpRoot() { + return mkdtempSync(join(tmpdir(), 'okf-')); +} + +// Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da). +// extra: ekstra CLI-flagg (f.eks. '--strict-ingest'). +function runCheck(root, ...extra) { + try { + 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]; + for (const e of readdirSync(root, { withFileTypes: true })) { + if (e.isDirectory()) out.push(...allDirs(join(root, e.name))); + } + 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', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + for (const d of allDirs(dir)) { + assert.ok(existsSync(join(d, 'index.md')), `index.md skal finnes i ${d}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: entry-linjer matcher OKF-form `* [t](l) - d`, uten frontmatter', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + const sk = readFileSync(join(dir, 'strategisk-kontekst', 'index.md'), 'utf8'); + assert.ok(!sk.startsWith('---'), 'index.md skal IKKE ha frontmatter'); + const entries = sk.split('\n').filter((l) => l.startsWith('* ')); + assert.ok(entries.length >= 3, 'strategisk-kontekst skal liste sine 3 konsept-filer'); + for (const l of entries) { + assert.match(l, /^\* \[[^\]]+\]\([^)]+\) - .+$/, `OKF-form: ${l}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: rot-index baerer okf_version, undernivaa gjoer ikke', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + const root = readFileSync(join(dir, 'index.md'), 'utf8'); + assert.match(root, /^okf_version:\s*\S+/m, 'rot-index skal ha okf_version'); + const sub = readFileSync(join(dir, 'strategisk-kontekst', 'index.md'), 'utf8'); + assert.ok(!/^okf_version:/m.test(sub), 'undernivaa skal IKKE ha okf_version'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('okf-index: konsept-tittel/beskrivelse hentes fra frontmatter', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + generateIndexes(dir); + const sk = readFileSync(join(dir, 'strategisk-kontekst', 'index.md'), 'utf8'); + assert.match( + sk, + /\* \[Tildelingsbrev 2026\]\(tildelingsbrev-2026\.md\) - Departementets/, + 'entry skal bruke frontmatter-title + -description + lenke til konsept-fil', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- okf-check --- + +test('okf-check: gyldig bundle (alle konsept har type:) -> exit 0, «0 filer uten type:»', () => { + const { status, stdout } = runCheck(REALISTIC); + assert.equal(status, 0, 'gyldig bundle skal gi exit 0'); + assert.match(stdout, /0 filer uten type:/, 'skal rapportere null manglende type'); +}); + +test('okf-check: konsept-fil uten type: -> exit != 0 + teller > 0 + navngir filen', () => { + const dir = tmpRoot(); + try { + cpSync(REALISTIC, dir, { recursive: true }); + writeFileSync( + join(dir, 'strategisk-kontekst', 'mangler-type.md'), + '---\ntitle: Uten type\ndescription: En konsept-fil uten paakrevd type.\n---\n# Uten type\n', + ); + const { status, stdout } = runCheck(dir); + assert.notEqual(status, 0, 'type-loes fil skal gi exit != 0'); + assert.match(stdout, /[1-9]\d* filer uten type:/, 'teller skal vaere > 0'); + assert.match(stdout, /mangler-type\.md/, 'skal navngi den feilende filen'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +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-retrieval.test.mjs b/tests/okf-retrieval.test.mjs new file mode 100644 index 0000000..ae90de3 --- /dev/null +++ b/tests/okf-retrieval.test.mjs @@ -0,0 +1,131 @@ +// okf-retrieval.test.mjs +// Beviser retrieval-MEKANISMEN for okr-second-brain-search SKILLen: native +// Glob/Read/Grep-seamen mot et OKF-kompatibelt bundle (fixture = parameterisert +// bundle-rot). Tester IKKE modell-trigger (Assumption 1 -> royktest i SKILL-body). +// Referanse-implementasjonen speiler SKILLens dokumenterte retrieval-algoritme; +// semantisk dekomponering (synonym-bridging) er modell-jobb og utelatt her. +// Zero npm deps. Plassert i tests/ (samme monster som org-profile-write.test.mjs). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, dirname, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MINIMAL = join(HERE, 'fixtures', 'okf-minimal'); +const REALISTIC = join(HERE, 'fixtures', 'okf-realistic'); + +// --- Reference retrieval (native-tool seam: Glob + Read + Grep + rank) --- + +// Glob: alle konsept-filer (.md unntatt index.md) under en bundle-rot, rekursivt. +function globConcepts(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); + } + }; + walk(root); + return out; +} + +// Naermeste index.md-linje som lenker til , ellers null. Kaster aldri. +function indexEntry(file) { + const idx = join(dirname(file), 'index.md'); + if (!existsSync(idx)) return { line: null, order: Number.MAX_SAFE_INTEGER }; + const base = basename(file); + const lines = readFileSync(idx, 'utf8').split('\n').filter((l) => l.trimStart().startsWith('*')); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(`](${base})`)) return { line: lines[i], order: i }; + } + return { line: null, order: Number.MAX_SAFE_INTEGER }; +} + +// Grep + rank: konsept-filer som inneholder token, rangert etter dokumentert +// tie-break: +2 token i frontmatter, +1 token i nivaaets index.md-entry; ekte +// tie -> index-rekkefolge; siste tie -> sti-sortering (deterministisk). +function retrieve(root, token) { + const lc = token.toLowerCase(); + const hits = []; + for (const f of globConcepts(root)) { + const content = readFileSync(f, 'utf8'); + if (!content.toLowerCase().includes(lc)) continue; + const { raw } = parseFrontmatter(content); + const inFm = raw !== null && raw.toLowerCase().includes(lc); + const { line, order } = indexEntry(f); + const inIdx = line !== null && line.toLowerCase().includes(lc); + hits.push({ path: f, score: (inFm ? 2 : 0) + (inIdx ? 1 : 0), order }); + } + hits.sort((a, b) => b.score - a.score || a.order - b.order || a.path.localeCompare(b.path)); + return hits; +} + +// Les index.md-lenker, returner kun de som finnes paa disk (dangling droppes, kaster ikke). +function resolveIndexLinks(indexPath) { + const dir = dirname(indexPath); + const links = []; + for (const l of readFileSync(indexPath, 'utf8').split('\n')) { + const m = l.match(/\]\(([^)]+)\)/); + if (m) links.push(m[1]); + } + return links.filter((relPath) => existsSync(join(dir, relPath))); +} + +const rel = (root, p) => p.slice(root.length + 1).split('\\').join('/'); + +// (a) MEKANISME: unik token (excl. index.md) -> forventet sti. +test('mekanisme: unik token resolver til forventet konsept-fil (okf-minimal)', () => { + const hits = retrieve(MINIMAL, 'kvikkleireskred'); + assert.equal(hits.length, 1, 'unik token skal treffe noyaktig en konsept-fil'); + assert.equal(rel(MINIMAL, hits[0].path), 'tildelingsbrev.md'); +}); + +// (b) BRUKERVERDI-PROXY: query -> token -> konsept-tabell mot okf-realistic. +test('brukerverdi: token-tabell resolver hver til forventet konsept (okf-realistic)', () => { + const table = [ + { token: 'klimaomstilling', expect: 'strategisk-kontekst/tildelingsbrev-2026.md' }, + { token: 'kompetanseloeft', expect: 'strategisk-kontekst/virksomhetsplan.md' }, + { token: 'Vegdirektoratet', expect: 'strategisk-kontekst/organisasjonsprofil.md' }, + { token: 'selvbetjeningsgrad', expect: 'syklus/T1-2026/okr-digitalisering.md' }, + { token: 'nullvisjon', expect: 'syklus/T1-2026/okr-trafikksikkerhet.md' }, + { token: 'fremdriftsindikator', expect: 'syklus/T1-2026/status.md' }, + { token: 'laeringssloeyfe', expect: 'historikk/retrospektiv-T3-2025.md' }, + { token: 'arbeidsnotat', expect: 'dokumenter/notat.md' }, + ]; + for (const { token, expect } of table) { + const hits = retrieve(REALISTIC, token); + assert.ok(hits.length >= 1, `token «${token}» skal gi minst ett treff`); + assert.equal(rel(REALISTIC, hits[0].path), expect, `token «${token}» -> ${expect}`); + } +}); + +// (c) DISAMBIGUERING (Pass 2): delt token mellom 2 filer -> index.md-routing/ranking +// velger riktig topp-treff (frontmatter+index-entry tie-break). +test('disambiguering: delt token rangerer frontmatter/index-treff over body-treff', () => { + const hits = retrieve(REALISTIC, 'tunnelsikkerhet'); + assert.equal(hits.length, 2, 'delt token skal treffe begge filer'); + assert.equal(rel(REALISTIC, hits[0].path), 'syklus/T1-2026/okr-trafikksikkerhet.md', + 'frontmatter+index-entry-treff skal rangeres over body-only-treff'); + assert.equal(rel(REALISTIC, hits[1].path), 'syklus/T1-2026/status.md'); + assert.ok(hits[0].score > hits[1].score, 'topp-treff skal ha hoyere score (deterministisk tie-break)'); +}); + +// (d) ROBUSTHET: dangling-link + ukjent-type kaster ikke. +test('robusthet: dangling index-link droppes uten kast', () => { + const histIdx = join(REALISTIC, 'historikk', 'index.md'); + const resolved = resolveIndexLinks(histIdx); + assert.ok(resolved.includes('retrospektiv-T3-2025.md'), 'eksisterende lenke beholdes'); + assert.ok(!resolved.some((r) => r.includes('T2-2025')), 'dangling lenke (T2-2025) droppes'); +}); + +test('robusthet: ukjent type parses uten kast, retrieval treffer fortsatt', () => { + const notat = join(REALISTIC, 'dokumenter', 'notat.md'); + const { get } = parseFrontmatter(readFileSync(notat, 'utf8')); + assert.equal(get('type'), 'Notat', 'ukjent type leses raatt uten kast'); + const hits = retrieve(REALISTIC, 'arbeidsnotat'); + assert.equal(rel(REALISTIC, hits[0].path), 'dokumenter/notat.md'); +}); 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'); +}); diff --git a/tests/oppsett-okf-write.test.mjs b/tests/oppsett-okf-write.test.mjs new file mode 100644 index 0000000..e5fc63e --- /dev/null +++ b/tests/oppsett-okf-write.test.mjs @@ -0,0 +1,143 @@ +// oppsett-okf-write.test.mjs +// Step 7 (SC6): onboarding skriver OKF-frontmatter paa org-profilen. +// Profilen er NESTET (organisasjon: { navn, type }), saa flat writeFrontmatter +// er feil verktoey. compose-org-profile.mjs bygger den fulle nestede YAML-en og +// PREPENDER tre top-level OKF-noekler (type/resource/timestamp) FOER organisasjon:. +// Pipes til write-org-profile.mjs (uendret) som skriver atomisk til hjem. +// +// Kritisk invariant: kun type/resource/timestamp top-level (leses ikke av hooks). +// En NY top-level navn/id/fase/domene/sektor ville matches FOERST av den flate +// parseren og skygge den nestede verdien -> brutt org-/syklus-resolusjon. +// Zero npm deps. Moenster: tests/org-profile-write.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseFrontmatter } from '../lib/frontmatter.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const COMPOSE = join(ROOT, 'scripts', 'compose-org-profile.mjs'); +const WRITER = join(ROOT, 'scripts', 'write-org-profile.mjs'); +const HOOK = join(ROOT, 'hooks', 'scripts', 'inject-okr-context.mjs'); + +// Nestet profil-body slik /okr:oppsett produserer den (organisasjon: + program:). +// Bevisst UTEN '#'-kommentarer her -- comment-strip dekkes av frontmatter-testen. +const NESTED_BODY = [ + 'onboarding_status: fullfort', + 'organisasjon:', + ' navn: "Testdirektoratet"', + ' kortform: "TDIR"', + ' type: "offentlig"', + ' domene: "digitalisering"', + 'program:', + ' modenhetsnivaa: "pilot"', + ' okr_frikoblet_fra_loenn: true', + '', +].join('\n'); + +const NOW = '2026-06-26T10:00:00+00:00'; + +function compose(input, env = {}) { + return execFileSync('node', [COMPOSE], { + env: { ...process.env, ...env }, + input, + encoding: 'utf8', + }); +} + +function write(cwd, home, input) { + return execFileSync('node', [WRITER], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function runHook(cwd, home) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('compose: prepender top-level type/resource/timestamp, bevarer nestet organisasjon/navn', () => { + const composed = compose(NESTED_BODY, { OKR_NOW: NOW }); + + // Top-level OKF-noekler (linjestart): + assert.match(composed, /^type: Organisasjonsprofil$/m, 'top-level type'); + assert.match(composed, /^resource: /m, 'top-level resource'); + assert.match(composed, /^timestamp: '2026-06-26T10:00:00\+00:00'$/m, 'top-level timestamp (ISO, sitert)'); + + // Nestet struktur bevart: + assert.match(composed, /^organisasjon:$/m, 'nestet organisasjon: bevart'); + assert.match(composed, /^ {2}navn: "Testdirektoratet"$/m, 'nestet navn bevart'); + + // ETT samlet frontmatter-blokk: parseFrontmatter ser BAADE OKF-noekler OG nestet navn. + const { get } = parseFrontmatter(composed); + assert.equal(get('navn'), 'Testdirektoratet', 'navn resolver (ingen intern --- som trunkerer blokken)'); + assert.equal(get('type'), 'Organisasjonsprofil', 'type resolver til OKF-verdi'); + + // FORBID-regel (Critical risk): ingen NY top-level noekkel som skygger nestet verdi. + assert.doesNotMatch(composed, /^navn:/m, 'ingen top-level navn (ville skygge nestet org-navn)'); + assert.doesNotMatch(composed, /^id:/m, 'ingen top-level id (ville skygge nestet cycle-id)'); + assert.doesNotMatch(composed, /^fase:/m, 'ingen top-level fase'); + assert.doesNotMatch(composed, /^domene:/m, 'ingen top-level domene'); + assert.doesNotMatch(composed, /^sektor:/m, 'ingen top-level sektor'); +}); + +test('compose: tolererer body som allerede baerer --- fences (samler til ett blokk)', () => { + const fenced = `---\n${NESTED_BODY}---\n`; + const composed = compose(fenced, { OKR_NOW: NOW }); + const { get } = parseFrontmatter(composed); + assert.equal(get('navn'), 'Testdirektoratet', 'navn resolver (ingen dobbel-fence trunkerer blokken)'); + assert.equal(get('type'), 'Organisasjonsprofil'); + const fences = composed.match(/^---$/gm) || []; + assert.equal(fences.length, 2, 'noeyaktig to frontmatter-fences'); +}); + +test('compose -> write-org-profile: hjem-profil faar OKF-frontmatter + bevart nestet navn', () => { + withDirs((home, work) => { + const composed = compose(NESTED_BODY, { OKR_NOW: NOW }); + const out = write(work, home, composed); + const target = join(home, '.claude', 'okr', 'org', 'profil.md'); + assert.ok(existsSync(target), 'hjem-profil skal finnes'); + const written = readFileSync(target, 'utf8'); + assert.match(written, /^type: Organisasjonsprofil$/m, 'skrevet profil har top-level type'); + assert.match(written, /^resource: /m, 'skrevet profil har resource'); + assert.match(written, /^timestamp: /m, 'skrevet profil har timestamp'); + assert.match(written, /navn: "Testdirektoratet"/, 'skrevet profil bevarer nestet navn'); + assert.equal(written, composed, 'write-org-profile er byte-passthrough (skriver stdin uendret)'); + assert.ok(out.trim().length > 0, 'helper rapporterer brukt sti paa stdout'); + }); +}); + +test('inject-roundtrip: get(navn) resolver org-navn (ikke "Organisasjonsprofil"), ingen #, cap < 512 B', () => { + withDirs((home, work) => { + const composed = compose(NESTED_BODY, { OKR_NOW: NOW }); + write(work, home, composed); + // Tomt prosjekt-cwd -> hooken faller til hjem-profil (mest-spesifikk-vinner). + const injected = runHook(work, home); + const { systemMessage } = JSON.parse(injected); + assert.match(systemMessage, /Testdirektoratet/, 'org-navn injiseres'); + assert.doesNotMatch(systemMessage, /Organisasjonsprofil/, 'top-level type skal IKKE skygge nestet navn'); + assert.ok(!systemMessage.includes('#'), 'ingen # i payload'); + assert.ok(Buffer.byteLength(systemMessage, 'utf8') < 512, 'kjerne-payload < 512 B (SC5)'); + }); +}); diff --git a/tests/org-profile-write.test.mjs b/tests/org-profile-write.test.mjs new file mode 100644 index 0000000..c72678b --- /dev/null +++ b/tests/org-profile-write.test.mjs @@ -0,0 +1,83 @@ +// org-profile-write.test.mjs +// Tester atomisk org-profil-skrivehelper (SC1): hjem-skriv, circuit-breaker +// fallback til prosjektlokal, og round-trip mot inject-okr-context. +// Spawner helperen som subprosess med kontrollert HOME + cwd + stdin. +// Zero npm deps. Plassert i tests/ (samme moenster som inject-okr-context.test.mjs). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const HELPER = join(ROOT, 'scripts', 'write-org-profile.mjs'); +const HOOK = join(ROOT, 'hooks', 'scripts', 'inject-okr-context.mjs'); + +function runHelper(cwd, home, input) { + // execFileSync returnerer stdout; helperen avslutter alltid med exit 0. + return execFileSync('node', [HELPER], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function runHook(cwd, home) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('hjem-skriv: helper skriver org-profil til ~/.claude/okr/org/profil.md', () => { + withDirs((home, work) => { + const profil = '---\nnavn: "HjemskrivOrg"\n---\n'; + const out = runHelper(work, home, profil); + const target = join(home, '.claude', 'okr', 'org', 'profil.md'); + assert.ok(existsSync(target), 'hjem-profil skal finnes etter skriv'); + assert.equal(readFileSync(target, 'utf8'), profil, 'innhold skal matche stdin'); + // realpathSync normaliserer macOS /var -> /private/var-symlink paa begge sider. + assert.equal(realpathSync(out.trim()), realpathSync(target), 'stdout skal rapportere faktisk brukt sti (hjem)'); + }); +}); + +test('circuit-breaker: uskrivbart hjem -> fallback til prosjektlokal uten error', () => { + withDirs((home, work) => { + // Gjoer hjem-skriv umulig: ~/.claude er en FIL, ikke katalog -> mkdirSync + // (recursive) under den feiler med ENOTDIR (rot-uavhengig, deterministisk). + writeFileSync(join(home, '.claude'), 'not a directory\n'); + const profil = '---\nnavn: "FallbackOrg"\n---\n'; + // execFileSync kaster hvis exit != 0; at dette IKKE kaster beviser exit 0. + const out = runHelper(work, home, profil); + const fallback = join(work, '.claude', 'okr.local.md'); + assert.ok(existsSync(fallback), 'fallback-profil skal finnes i prosjektlokal sti'); + assert.equal(readFileSync(fallback, 'utf8'), profil, 'fallback-innhold skal matche stdin'); + assert.equal(realpathSync(out.trim()), realpathSync(fallback), 'stdout skal rapportere fallback-sti'); + }); +}); + +test('round-trip: hjem-skrevet org reflekteres av inject-okr-context', () => { + withDirs((home, work) => { + const profil = '---\nnavn: "RoundTripOrg"\n---\n'; + runHelper(work, home, profil); + // Tom prosjektkatalog (work) -> hooken faller til hjem-profil (mest-spesifikk-vinner). + const injected = runHook(work, home); + assert.match(injected, /RoundTripOrg/, 'inject skal lese hjem-profilen helperen skrev'); + }); +}); diff --git a/tests/topic-guard.test.mjs b/tests/topic-guard.test.mjs new file mode 100644 index 0000000..f937126 --- /dev/null +++ b/tests/topic-guard.test.mjs @@ -0,0 +1,79 @@ +// topic-guard.test.mjs +// Tester UserPromptSubmit emne-guard (SC6) i inject-okr-context: kun +// OKR-relevante prompter injiserer kontekst; en eksplisitt ikke-matchende +// prompt suppresses; tvil (tomt felt / ingen stdin) -> default-inject. +// Spawner hooken som subprosess med kontrollert cwd + stdin. Zero npm deps. +// Moenster: tests/inject-okr-context.test.mjs (begge-casen + input-opsjon). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'inject-okr-context.mjs', +); + +function makeProjectConfig(workDir, navn) { + const p = join(workDir, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'okr.local.md'), `---\nnavn: "${navn}"\n---\n`); +} + +function runHook(cwd, input = '') { + // execFileSync returnerer stdout; hooken avslutter alltid med exit 0. + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env }, + input: input, + encoding: 'utf8', + }); +} + +function withWork(fn) { + const work = mkdtempSync(join(tmpdir(), 'okrtopic-')); + try { + fn(work); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +test('irrelevant prompt: suppresses injeksjon (tom stdout)', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work, JSON.stringify({ prompt: 'hva er vaeret i dag' })); + assert.equal(out.trim(), '', 'ikke-OKR-prompt skal suppresses selv med gyldig config'); + }); +}); + +test('relevant prompt: injiserer OKR-kontekst', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work, JSON.stringify({ prompt: 'hjelp meg skrive en OKR for neste tertial' })); + assert.match(out, /OKR-kontekst/, 'OKR-relevant prompt skal injisere kontekst'); + }); +}); + +test('tomt prompt-felt: bevarer inject-default (tvil -> injiser)', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work, JSON.stringify({ prompt: '' })); + assert.match(out, /OKR-kontekst/, 'tomt prompt-felt -> tvil -> injiser'); + }); +}); + +test('ingen stdin: bevarer inject-default (regresjonsvakt)', () => { + withWork((work) => { + makeProjectConfig(work, 'TopicOrg'); + const out = runHook(work); // input utelatt -> tom stdin + assert.match(out, /OKR-kontekst/, 'ingen stdin -> tvil -> injiser'); + }); +});