Compare commits

...

25 commits

Author SHA1 Message Date
dec8a13952 fix(okr): okf-index saner title/desc + escape klammer + skip innboks/dot (SC index-integritet)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y
2026-06-30 14:01:19 +02:00
3b45be70be feat(okr): okf-check++ strictIngest (vokab+lenker) + skip innboks/dot (SC konformitet) [skip-docs]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y
2026-06-30 13:55:34 +02:00
935c1d0ae9 feat(okr): konsept-skriver m/ type-til-nivaa-ruting + original-bevaring (SC original, riktig nivaa) [skip-docs]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y
2026-06-30 13:33:38 +02:00
2b0c520efd feat(okr): delt bundle-root-relativ lenke-resolver + zero-dangling relasjoner (SC relasjoner) [skip-docs] 2026-06-30 13:01:19 +02:00
d011fb1922 feat(okr): deterministisk frontmatter-projeksjon + vokab-snap + mtime-timestamp (SC idempotens) [skip-docs] 2026-06-30 12:58:26 +02:00
136093da38 feat(okr): deterministisk heading-split for ingestion (SC idempotens) [skip-docs] 2026-06-30 12:54:57 +02:00
ca6c89e7be feat(okr): writeFrontmatter multi-linje tags-liste (additiv array-gren) [skip-docs] 2026-06-30 11:04:29 +02:00
a9d9c45192 feat(okr): lukket OKF type/tags-vokabular for ingestion-snap [skip-docs] 2026-06-30 11:00:16 +02:00
abfcc6b48e test(okr): innboks-ingestion fixtures (inbox + pre-converted) 2026-06-30 10:57:54 +02:00
f0ef6c1abc docs(claude-md): tighten State-Management prose (okr CLAUDE.md near-optimal)
CLAUDE.md loads every turn while working in this repo (measured 1,732
always-loaded tokens — the whole per-repo delta; no .claude/rules or .mcp.json).
Unlike the larger plugins, this file is already information-dense (tables + precise
design contracts), and the OKF Knowledge Layout content is under active development
(the "OKF second-brain spec" work), so it is left untouched.

The only safe, fact-preserving compression is the org-profile State-Management
paragraph (stable infra, not OKF-spec): same facts, tighter wording. 1,732→1,717
tok. The one remaining lever — the command→agent Architecture routing diagram
(~230 tok) — is a deliberate wiring reference and is kept; it can move to
/okr:help on request. Docs-only — no version bump, no catalog ref change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-29 10:22:57 +02:00
75bfc9b47d docs(okr): ratify OKF second-brain spec v0.1 + adapt innboks-plan
Handoff catalog/docs/okf-second-brain/handoff-2026-06-29.md §3 (operator relay
fra linkedin-studio-sesjonen). okr ratifiserer den delte konvensjonen (spec.md
v0.1, katalog-eid single source of truth) etter ground-truth-verifisering:

- okf-check.mjs-semantikken bekreftet = referansekontrakt (spec §3/§7): kun
  type paakrevd (okf-check.mjs:50), anbefalte felt -> warnings (:19,:55),
  okf_version-ekko uten auto-fetch (:36-41,:85). 91/55 linjer, zero npm-deps.
- okr bruker resource (ikke source): alle emittere (okf-check RECOMMENDED,
  compose-org-profile:59, template:15, second-brain SKILL:56). Eneste source-
  treff er prosa, ikke felt.
- Ingen feltgap: profil/config-noekler baeres som extension keys (spec §5).

Adaptert: veivalg-doc + okf-note refererer naa spec-en (redefinerer ikke);
premiss-korreksjonene (§2) kanonisert i spec §9. Delings-scope for evt.
Stage-3-skill = okr + ms-ai-architect (ikke linkedin-studio). Ingen kode-/
versjonsendring (okr forblir 1.6.1); separat go for selve innboks-byggingen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HzGPGJ81k1BkUC6UvgX4Y
2026-06-29 09:46:57 +02:00
30545b982e fix(okr): credibility-sanering fra 360-re-evaluering + bump 1.6.1
Lukker de 5 credibility-kritiske funnene fra docs/evaluering-360-2026-06-26.md:
1. okr-quality-rubrics.md omskrevet til korrekt norsk (var ASCII-strippet, 0
   aa/oe/ae; lastes brukervendt av /okr:kvalitet + kvalitetssjekker +
   freshen-references). Regresjon i samme klasse som v1.3.0-showstopperen.
2. SKILL.md flaggskip-eksempel: strippet norsk rettet (per aar/hoyrisiko/
   spoerreundersoekelse/Gjennomfoere moeter).
3. okr-second-brain-search ~-glob: literal ~ ekspanderes ikke av Glob ->
   hjemme-roten ble aldri soekt. Naa eksplisitt absolutt hjemme-sti.
4. Uverifisert kb-search-sitat fjernet (motsagt av innboks-ingestion-funn);
   erstattet med funksjonell Glob/Read/Grep + discovery-moenster.
5. okr-oboard-guide.md KR1-regnefeil 52% -> 65% (per filens egen formel).

Bump alle flater 1.6.0 -> 1.6.1 (plugin.json, CLAUDE.md, README badge+hist,
begge SKILL.md, CHANGELOG). Suite 55/55 groenn. C+ -> A-; systemiske funn
(KB-kryssref, Sist-oppdatert-markoerer) bevisst utsatt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 21:31:42 +02:00
e2d5ddb85f docs(okr): 360-re-evaluering v1.6.0 (C+ -> A-) + skills/KB-scoring
Tiltak #2 (full re-evaluering) levert. 5 Opus-agenter: 2 KB-ref-scorere
(16 domene-filer mot freshen-rubrikken), 2 skill-evaluatorer, 1 360-delta-
verifikator mot alle v1.3.0-funn. Lead reviewer ground-truth-verifiserte
de baerende funnene mot fil.

Resultat: alle KRITISKE/HOEYE 360-funn (DDT, scoringsterskel, UTF-8,
find-replace, doc-motsigelser) bevist lukket; hele ms-ai-architect-gapet
levert (org-katalog, /help, rubrikker, 55 groenne tester, PDF, org-lesende
agenter). C+ -> A-.

Holder igjen A: EN regresjon i samme klasse som showstopperen --
okr-quality-rubrics.md er ASCII-strippet (0 aa/oe/ae) og lastes brukervendt
av /okr:kvalitet. + SKILL.md flaggskip-eksempel strippet; second-brain
~-glob soeker aldri hjemme-roten; kb-search-sitat uverifisert; oboard-guide
regnefeil 52->65%. Prioritert retteliste = kandidat 1.6.1.

Skills: okr-offentlig-sektor B+, okr-second-brain-search B (foerstegangs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 21:23:45 +02:00
6a595bf69e docs(okr): innboks-ingestion veivalg + avstemming mot convergence-brief
Operatør-låst posisjon (2026-06-26): innboks-ingestion er OBLIGATORISK for okr (ikke 'defer'), krever grundig planlegging, eget Voyage-løp. linkedin-studio er IKKE en mal (annen tilnærming) -> omstøter convergence-briefens 'rise to the reference design' for okr. Skiller verifiserte tekniske premisser (beholdes: ingen Google inbox->OKF-pipeline, mdcode ikke OKF, relasjoner=md-lenker, OKF v0.1 paa okf/SPEC.md) fra strategisk framing (avvises for okr). Lister gap som maa bygges + gjenbruk fra 1.6.0 + aapne /trekbrief-spoersmaal (zero-dep-konflikt for dok-konvertering flagget). STATE.md peker hit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 20:47:49 +02:00
7fdbf9bcbd chore(okr): bump 1.6.0 (OKF second brain) + CLAUDE.md/README/CHANGELOG docs
Bump 1.5.0 -> 1.6.0 across plugin.json, CLAUDE.md title, okr-offentlig-sektor SKILL.md, README badge. README version-history row + Under-the-Hood Skills table; CHANGELOG 1.6.0 entry. CLAUDE.md gains OKF Knowledge Layout section (two-root model, okf-index/okf-check/compose-org-profile, lib/frontmatter), second skill in Skills table, and two architecture lines.

Ref-count: kept '16 domain reference files' (correct -- freshen-references scores 16, excludes okr-quality-rubrics.md as the scoring instrument; 17 is the directory total). Tightened CLAUDE.md:20 wording to '16 domain' for clarity; did NOT apply the plan's 16->17 (would contradict the command). References dir untouched (SC9). Verify: node --test 55/55; all version surfaces 1.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 20:02:42 +02:00
80d96898bc docs(okr): innboks-ingestion funn-grunnlag (web-verifisert)
Self-bearing grunnlag for den koeede innboks-ingestion-oppgaven (start etter
OKF-fasen). Web-verifisert undersoekelse av GoogleCloudPlatform/knowledge-catalog:
ingen ferdig innboks->OKF-pipeline finnes (verken Google eller oss). Dokumenterer
hva som finnes (reference_agent/index-generator/Source-ABC/fileskb), OKF-relasjons-
modellen (markdown-lenker i body), gap (dok-konvertering + konsept-ekstraksjon +
generalisert relasjons-oppdagelse), gjenbrukbare byggeklosser, og kilder for
etterproevbarhet. STATE I-koe-peker oppdatert (local-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 18:03:55 +02:00
77280ac319 feat(okr): tre-skrivere emitterer OKF-frontmatter + index-regenerering (SC2,SC3)
oppsett.md (eneste tre-skriver m/ Write+Bash): strategisk-kontekst-filer
faar OKF-frontmatter (type: Tildelingsbrev/Virksomhetsplan/Overordnede OKR),
retro -> type: Retrospektiv, ny-syklus status.md -> type: Status; regenererer
.claude/okr-indeksen via okf-index.mjs etter tre-skriv (2 punkter). skriv.md/
sporing.md har ikke Write -> dokumenterer OKF-frontmatter (type: OKR / Status)
i eksempel-output (malen brukeren lagrer). Lukker SC2-gapet: alle konsept-
filer paa disk baerer type:. Verify: okf-check fixture exit 0; suite 55/55.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 17:24:20 +02:00
5219d50d4b feat(okr): onboarding skriver OKF-frontmatter paa org-profil (SC6)
compose-org-profile.mjs prepender top-level OKF-noekler (type:
Organisasjonsprofil/resource/timestamp) over den nestede org-profilen i
ETT frontmatter-blokk, pipes til write-org-profile.mjs (uendret). Kun de
tre trygge noeklene top-level; FORBID nye top-level navn/id/fase/domene/
sektor som ville skygge nestede verdier hooken leser. oppsett.md (begge
profil-skriv-sites) + template oppdatert. Test: compose->pip->readback +
inject-roundtrip (4), full suite 51->55.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 17:19:14 +02:00
9701c81158 feat(okr): okf-index (ekte format)/okf-check + okf_version per rot (SC2,SC3,SC7) [skip-docs]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 15:12:48 +02:00
615542d064 fix(okr): analyse leser historikk direkte etter hook-splitt (SC5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFW5scLL7oEwWWTv1fPQG6
2026-06-26 15:07:19 +02:00
ea2bd444ea feat(okr): splitt inject-hook ved 91/92 - kjerne + resolvert index-peker (SC5) [skip-docs] 2026-06-26 14:45:34 +02:00
bf0e463b4a feat(okr): retrieval-SKILL m/ OKF-mønster + fixtures (SC1,SC4) [skip-docs] 2026-06-26 14:39:46 +02:00
9b85c0b436 refactor(okr): begge hooks bruker delt frontmatter-modul (SC2) [skip-docs] 2026-06-26 14:21:58 +02:00
d9f2848d90 feat(okr): delt frontmatter-parse/skrive-modul + #-strip-fix (SC2) [skip-docs] 2026-06-26 14:19:20 +02:00
274d833e64 docs(okr): OKF second-brain retningsnotat (neste initiativ -> 1.6.0) 2026-06-26 10:48:48 +02:00
59 changed files with 3349 additions and 192 deletions

View file

@ -1,6 +1,6 @@
{
"name": "okr",
"version": "1.5.0",
"version": "1.6.1",
"description": "Expert OKR guidance for Norwegian public sector. Write, review, cascade, track and govern OKR based on Google/Doerr methodology adapted for 4-month tertial cycles.",
"author": {
"name": "Kjell Tore Guttormsen"

View file

@ -5,6 +5,38 @@ 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 `(startcurrent)/(starttarget)`.
### 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

View file

@ -1,4 +1,4 @@
# OKR Offentlig Sektor v1.5.0
# OKR Offentlig Sektor v1.6.1
Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapted for 4-month tertial cycles.
@ -17,7 +17,7 @@ Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapte
| `/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 reference files against an anchored rubric + currency-poll public sources |
| `/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
@ -40,22 +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` |
| 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` the machine-global org identity (`organisasjon:`/`program:`), written atomically (temp + `renameSync`) by `scripts/write-org-profile.mjs` during `/okr:oppsett`. Resolution is most-specific-wins: a project-local `.claude/okr.local.md` overrides the home profile; the home profile is the backwards-compatible fallback when no project config exists (read side: `hooks/scripts/inject-okr-context.mjs`). On any home-write failure the helper circuit-breaks to the gitignored project-local `.claude/okr.local.md`. Only the org *profile* migrates to home — cycle/`historikk` data stays cwd-bound.
Org profile (reinstall-surviving): `~/.claude/okr/org/profil.md` — machine-global org identity (`organisasjon:`/`program:`), written atomically (temp + `renameSync`) by `scripts/write-org-profile.mjs` during `/okr:oppsett`. Resolution is most-specific-wins: project-local `.claude/okr.local.md` overrides the home profile (the backwards-compatible fallback when no project config exists; read side `hooks/scripts/inject-okr-context.mjs`). Home-write failure → circuit-break to the gitignored project-local file. Only the org *profile* migrates to home; cycle/`historikk` data stays cwd-bound.
Template: `templates/okr.local.md.template`
Context tree: `.claude/okr/``strategisk-kontekst/`, `syklus/[id]/`, `historikk/`, `dokumenter/`
Onboarding state: `onboarding_status` field in okr.local.md (`partial` | `fullfort`)
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
@ -80,4 +96,6 @@ Cycle archival: `/okr:oppsett arkiver` — moves `syklus/` to `historikk/`, gene
/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)
```

View file

@ -6,7 +6,7 @@
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
![Version](https://img.shields.io/badge/version-1.5.0-blue)
![Version](https://img.shields.io/badge/version-1.6.1-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Agents](https://img.shields.io/badge/agents-7-orange)
![Commands](https://img.shields.io/badge/commands-13-blue)
@ -252,6 +252,13 @@ 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
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.
@ -283,6 +290,8 @@ 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 |

View file

@ -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

View file

@ -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)
@ -225,15 +253,20 @@ Etter alle 6 faser:
2. Sett `onboarding_status: fullfort` og `Sist oppdatert: [dato]`
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 til en temp-fil og
sendes gjennom skrivehelperen via Bash:
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/write-org-profile.mjs < /tmp/okr-profil.tmp)
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"
```
Helperen skriver atomisk (temp + `renameSync`) til `~/.claude/okr/org/profil.md` og
`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
@ -337,16 +370,20 @@ preferanser:
```
**Skriv også reinstall-overlevende org-profil til hjem-stien.** Skriv profil-YAML-en
(`organisasjon:` + `program:`) til en temp-fil og kjør skrivehelperen via Bash:
(`organisasjon:` + `program:`, **uten `---`-fences**) til en temp-fil og kjør OKF-komponisten
+ skrivehelperen via Bash:
```bash
OUT=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/write-org-profile.mjs < /tmp/okr-profil.tmp)
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"
```
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`.
`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.
@ -441,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]
@ -503,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:
```

View file

@ -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

View file

@ -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

View file

@ -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 (4532)/(4525)=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`).

View file

@ -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/<a>__<b>.md`, `references/metrics/<slug>.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).

View file

@ -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**`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`).

View file

@ -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 13, 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».

View file

@ -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');

View file

@ -2,12 +2,15 @@
// 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 projectConfigPath = join(cwd, '.claude', 'okr.local.md');
@ -44,8 +47,9 @@ if (rawPrompt) {
// 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. The cycle/work tree scan
// (okrDir, below) stays cwd-bound -- syklus data is always project-local.
// 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);
@ -56,20 +60,14 @@ if (!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);
// 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');
@ -89,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 }));

67
lib/frontmatter.mjs Normal file
View file

@ -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';
}

View file

@ -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,
};
}

49
lib/innboks-relations.mjs Normal file
View file

@ -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 };
});
}

96
lib/innboks-split.mjs Normal file
View file

@ -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;
}

122
lib/innboks-write.mjs Normal file
View file

@ -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 };
}

39
lib/okf-links.mjs Normal file
View file

@ -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;
}

73
lib/okf-vocab.mjs Normal file
View file

@ -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;
}

View file

@ -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'));

145
scripts/okf-check.mjs Normal file
View file

@ -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 <bundle-rot> [--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);
}

146
scripts/okf-index.mjs Normal file
View file

@ -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: <ver> (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 <bundle-rot> [--okf-version <ver>]\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`);
}

View file

@ -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.5.0"
version: "1.6.1"
---
# OKR Skill for Offentlig Sektor (Norge)
@ -29,16 +29,16 @@ Objective: [Verb] + [clear outcome/improvement]
**Example**:
```
Objective: Forbedre trafikksikkerhet i skolesoner
KR1: Redusere ulykker i skolesoner med 25% (fra 40 til 30 per ar)
KR2: 100% av hoyrisiko-skolesoner far nye fartshumper innen august
KR3: 90% av foreldre vurderer skolesoner som trygge (via sporreundersokelse)
KR1: Redusere ulykker i skolesoner med 25% (fra 40 til 30 per år)
KR2: 100% av høyrisiko-skolesoner får nye fartshumper innen august
KR3: 90% av foreldre vurderer skolesoner som trygge (via spørreundersøkelse)
```
### 2. Review OKR Quality
When users present existing OKR, evaluate against these criteria and provide concrete rewrites:
- **Good**: Outcome-focused, measurable, ambitious but achievable, clear strategy link
- **Common errors**: Activity-oriented ("Gjennomfore 5 moter"), vague ("Forbedre kundeservice"), sandbagging, not measurable, no link to higher goals
- **Common errors**: Activity-oriented ("Gjennomføre 5 møter"), vague ("Forbedre kundeservice"), sandbagging, not measurable, no link to higher goals
### 3. Track Progress

View file

@ -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

View file

@ -1,105 +1,105 @@
# OKR-kvalitetsrubrikker — ankret scoringsguide
Single sannhetskilde for OKR-kvalitetsvurdering i norsk offentlig sektor (Google/Doerr-metodikk tilpasset tertialsyklus). Hver kvalitetsdimensjon har **fem ankere** fra svakest til sterkest med konkret nivaabeskrivelse, slik at scoring blir deterministisk og reproduserbar paa tvers av vurderinger og vurderere.
Felles sannhetskilde for OKR-kvalitetsvurdering i norsk offentlig sektor (Google/Doerr-metodikk tilpasset tertialsyklus). Hver kvalitetsdimensjon har **fem ankere** fra svakest til sterkest med konkret nivåbeskrivelse, slik at scoring blir deterministisk og reproduserbar på tvers av vurderinger og vurderere.
**Bruk:** les ankerbeskrivelsene per dimensjon, finn det nivaaet OKR-en faktisk treffer, og scor dimensjonen 1-5. Skaler til 0-10 der en ti-skala kreves: anker 1 -> 1-2, anker 2 -> 3-4, anker 3 -> 5-6, anker 4 -> 7-8, anker 5 -> 9-10 (jf. scoring-guiden i `kvalitetssjekker`-agenten og samlet-scoring-tabellen i `/okr:kvalitet`). Denne fila erstatter den inline-rubrikken som tidligere laa i `commands/kvalitet.md` og forkortet i `agents/kvalitetssjekker-agent.md` — kommandoen og agenten refererer hit i stedet for aa baere ankrene selv.
**Bruk:** les ankerbeskrivelsene per dimensjon, finn det nivået OKR-en faktisk treffer, og scor dimensjonen 1-5. Skaler til 0-10 der en ti-skala kreves: anker 1 -> 1-2, anker 2 -> 3-4, anker 3 -> 5-6, anker 4 -> 7-8, anker 5 -> 9-10 (jf. scoring-guiden i `kvalitetssjekker`-agenten og samlet-scoring-tabellen i `/okr:kvalitet`). Denne fila erstatter den inline-rubrikken som tidligere lå i `commands/kvalitet.md` og forkortet i `agents/kvalitetssjekker-agent.md` — kommandoen og agenten refererer hit i stedet for å bære ankrene selv.
## Objective-dimensjoner
### Inspirerende
Maaler om Objective-et motiverer teamet og kommuniserer hvorfor arbeidet betyr noe.
Måler om Objective-et motiverer teamet og kommuniserer hvorfor arbeidet betyr noe.
1. **Anker 1 (svakest)** — Kjedelig eller rent byraakratisk; ingen blir engasjert av formuleringen.
2. **Anker 2** — Toert og oppgavepreget; saklig, men vekker ikke eierskap.
3. **Anker 3** — Noeytralt; akseptabelt, men hverken loefter eller demotiverer.
1. **Anker 1 (svakest)** — Kjedelig eller rent byråkratisk; ingen blir engasjert av formuleringen.
2. **Anker 2** — Tørt og oppgavepreget; saklig, men vekker ikke eierskap.
3. **Anker 3** — Nøytralt; akseptabelt, men hverken løfter eller demotiverer.
4. **Anker 4** — Engasjerende for de fleste; tydelig retning og en antydning av hvorfor.
5. **Anker 5 (sterkest)** — Motiverer hele teamet; kommuniserer mening og ambisjon, og folk husker det.
### Klarhet
Maaler om Objective-et gir en entydig retning som alle tolker likt.
Måler om Objective-et gir en entydig retning som alle tolker likt.
1. **Anker 1 (svakest)** — Flertydig; kan tolkes i flere ulike retninger.
2. **Anker 2** — Et kjernebegrep er uklart og krever oppklaring foer arbeid kan starte.
2. **Anker 2** — Et kjernebegrep er uklart og krever oppklaring før arbeid kan starte.
3. **Anker 3** — Noe vagt, men hovedretningen anes.
4. **Anker 4** — Stort sett entydig; kun smaa tolkningsrom igjen.
5. **Anker 5 (sterkest)** — Entydig retning; alle som leser det forstaar det samme.
4. **Anker 4** — Stort sett entydig; kun små tolkningsrom igjen.
5. **Anker 5 (sterkest)** — Entydig retning; alle som leser det forstår det samme.
### Outcome-fokus
Maaler om Objective-et beskriver en oensket tilstand (resultat) heller enn en aktivitet.
Måler om Objective-et beskriver en ønsket tilstand (resultat) heller enn en aktivitet.
1. **Anker 1 (svakest)** — Ren aktivitet ("gjennomfoere", "lage", "innfoere"); ingen resultat.
1. **Anker 1 (svakest)** — Ren aktivitet ("gjennomføre", "lage", "innføre"); ingen resultat.
2. **Anker 2** — Overveiende aktivitet med en vag henvisning til effekt.
3. **Anker 3** — Blanding av aktivitet og resultat.
4. **Anker 4** — Overveiende resultat, med en gjenvaerende aktivitetsrest.
5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnaa, ikke veien dit.
4. **Anker 4** — Overveiende resultat, med en gjenværende aktivitetsrest.
5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnå, ikke veien dit.
### Scope
Maaler om Objective-et er riktig dimensjonert for én tertial (fire maaneder).
Måler om Objective-et er riktig dimensjonert for én tertial (fire måneder).
1. **Anker 1 (svakest)** — Helt feil scope; en fleraarig visjon eller en triviell enkeltoppgave.
1. **Anker 1 (svakest)** — Helt feil scope; en flerårig visjon eller en triviell enkeltoppgave.
2. **Anker 2** — Klart for stort eller for lite for én tertial.
3. **Anker 3** — Litt for stort eller for lite, men haandterbart.
3. **Anker 3** — Litt for stort eller for lite, men håndterbart.
4. **Anker 4** — Passer tertialen med rimelig stretch.
5. **Anker 5 (sterkest)** — Perfekt dimensjonert for én tertial; ambisioest men oppnaaelig i perioden.
5. **Anker 5 (sterkest)** — Perfekt dimensjonert for én tertial; ambisiøst men oppnåelig i perioden.
### Alignment
Maaler om Objective-et er koblet oppover til org-OKR, tildelingsbrev eller overordnet strategi.
Måler om Objective-et er koblet oppover til org-OKR, tildelingsbrev eller overordnet strategi.
1. **Anker 1 (svakest)** — Ingen kobling til overordnet maal eller tildelingsbrev.
2. **Anker 2** — Kobling paastaaes, men kan ikke spores til et konkret overordnet maal.
3. **Anker 3** — Implisitt kobling oppover; leseren maa selv slutte sammenhengen.
1. **Anker 1 (svakest)** — Ingen kobling til overordnet mål eller tildelingsbrev.
2. **Anker 2** — Kobling påstås, men kan ikke spores til et konkret overordnet mål.
3. **Anker 3** — Implisitt kobling oppover; leseren må selv slutte sammenhengen.
4. **Anker 4** — Tydelig koblet, men ikke eksplisitt sitert.
5. **Anker 5 (sterkest)** — Tydelig og eksplisitt koblet til navngitt org-OKR eller tildelingsbrevspunkt.
## Key Result-dimensjoner
### Maalbarhet
Maaler om Key Result-et har konkrete tall med baseline og target.
### Målbarhet
Måler om Key Result-et har konkrete tall med baseline og target.
1. **Anker 1 (svakest)** — Ikke maalbart; ingen tall, kun kvalitativ paastand.
1. **Anker 1 (svakest)** — Ikke målbart; ingen tall, kun kvalitativ påstand.
2. **Anker 2** — Et tall er nevnt, men uten baseline eller uten target.
3. **Anker 3** — Delvis maalbart (target uten baseline, eller omvendt).
3. **Anker 3** — Delvis målbart (target uten baseline, eller omvendt).
4. **Anker 4** — Tall med baseline og target, men et mindre presisjonshull (uklar enhet/avgrensning).
5. **Anker 5 (sterkest)** — Tall med tydelig baseline -> target og entydig enhet.
### Outcome
Maaler om Key Result-et fanger reell effekt heller enn output/aktivitet.
Måler om Key Result-et fanger reell effekt heller enn output/aktivitet.
1. **Anker 1 (svakest)** — Ren output/aktivitet (antall moeter, leveranser, kurs).
1. **Anker 1 (svakest)** — Ren output/aktivitet (antall møter, leveranser, kurs).
2. **Anker 2** — Output brukt som svak proxy for et udokumentert resultat.
3. **Anker 3** — Blanding av output og outcome.
4. **Anker 4** — Overveiende outcome, med en mindre output-rest.
5. **Anker 5 (sterkest)** — Maaler reell effekt eller resultat for bruker/samfunn.
5. **Anker 5 (sterkest)** — Måler reell effekt eller resultat for bruker/samfunn.
### Ambisjon
Maaler om Key Result-et har riktig stretch — ambisioest, men ikke urealistisk eller sandbagget.
Måler om Key Result-et har riktig stretch — ambisiøst, men ikke urealistisk eller sandbagget.
1. **Anker 1 (svakest)** — Urealistisk (praktisk umulig) eller aapenbar sandbagging (garantert 1.0).
1. **Anker 1 (svakest)** — Urealistisk (praktisk umulig) eller åpenbar sandbagging (garantert 1.0).
2. **Anker 2** — Tydelig for lett eller for hardt for perioden.
3. **Anker 3** — For lett eller for vanskelig, men i naerheten av riktig nivaa.
3. **Anker 3** — For lett eller for vanskelig, men i nærheten av riktig nivå.
4. **Anker 4** — Rimelig stretch, men litt for konservativ eller litt for aggressiv.
5. **Anker 5 (sterkest)** — Riktig stretch; om lag 70 % forventet maaloppnaaelse, ambisioest men mulig.
5. **Anker 5 (sterkest)** — Riktig stretch; om lag 70 % forventet måloppnåelse, ambisiøst men mulig.
### Datakilde
Maaler om Key Result-et har en spesifisert og faktisk tilgjengelig datakilde.
Måler om Key Result-et har en spesifisert og faktisk tilgjengelig datakilde.
1. **Anker 1 (svakest)** — Ukjent datakilde; ingen vet hvor tallet skal hentes fra.
2. **Anker 2** — Datakilde antydet, men ikke bekreftet tilgjengelig.
3. **Anker 3** — Datakilde antas tilgjengelig, men er ikke verifisert.
4. **Anker 4** — Spesifisert kilde med mindre usikkerhet om maalefrekvens eller tilgang.
5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent maalefrekvens.
4. **Anker 4** — Spesifisert kilde med mindre usikkerhet om målefrekvens eller tilgang.
5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent målefrekvens.
### Uavhengighet
Maaler i hvilken grad teamet selv kontrollerer utfallet av Key Result-et.
Måler i hvilken grad teamet selv kontrollerer utfallet av Key Result-et.
1. **Anker 1 (svakest)** — Utfallet ligger helt utenfor teamets kontroll.
2. **Anker 2** — Sterkt avhengig av andre enheter eller eksterne aktoerer.
2. **Anker 2** — Sterkt avhengig av andre enheter eller eksterne aktører.
3. **Anker 3** — Delvis avhengig av andre.
4. **Anker 4** — Stort sett innenfor teamets kontroll, med en mindre ekstern avhengighet.
5. **Anker 5 (sterkest)** — Teamet kontrollerer utfallet direkte.
---
*Kilde: Google re:Work OKR-rubrikk + Doerr "Measure What Matters", tilpasset norsk offentlig tertialsyklus. Ankrene konsoliderer scoringsbaandene fra `/okr:kvalitet` og `kvalitetssjekker`-agenten til én delt sannhetskilde.*
*Kilde: Google re:Work OKR-rubrikk + Doerr "Measure What Matters", tilpasset norsk offentlig tertialsyklus. Ankrene konsoliderer scoringsbåndene fra `/okr:kvalitet` og `kvalitetssjekker`-agenten til én delt sannhetskilde.*
*Sist oppdatert: Juni 2026*

View file

@ -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/<id>/`,
`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 `<HOME>/.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/<id>/` 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/<id>/`,
`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.

View file

@ -7,6 +7,13 @@
# 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"

View file

@ -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.

View file

@ -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.

7
tests/fixtures/inbox-sample/dok-a.txt vendored Normal file
View file

@ -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.

7
tests/fixtures/inbox-sample/dok-b.txt vendored Normal file
View file

@ -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.

5
tests/fixtures/okf-minimal/index.md vendored Normal file
View file

@ -0,0 +1,5 @@
# OKF second brain (minimal testfixtur)
okf_version: kb-layout-2026-06
* [Tildelingsbrev](tildelingsbrev.md) - Styringssignal med unik testtoken.

View file

@ -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.

View file

@ -0,0 +1,3 @@
# Dokumenter
* [Arbeidsnotat](notat.md) - Internt arbeidsnotat med ukjent type.

View file

@ -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.

View file

@ -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).

View file

@ -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.

8
tests/fixtures/okf-realistic/index.md vendored Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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',
);
});
});

135
tests/frontmatter.test.mjs Normal file
View file

@ -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');
});

View file

@ -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 "#"');
});
});

View file

@ -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');
});

View file

@ -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);
});

View file

@ -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');
});

View file

@ -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: <tmp>/.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');
});
});

312
tests/okf-check.test.mjs Normal file
View file

@ -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 });
}
});

View file

@ -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 <file>, 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');
});

65
tests/okf-vocab.test.mjs Normal file
View file

@ -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');
});

View file

@ -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)');
});
});