Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79d3c1ee64 | |||
| 7643304177 | |||
| 188b534a3b | |||
| 7f9c790f43 | |||
| a807ee2b79 | |||
| 249de8fb2d | |||
| dec8a13952 | |||
| 3b45be70be | |||
| 935c1d0ae9 | |||
| 2b0c520efd | |||
| d011fb1922 | |||
| 136093da38 | |||
| ca6c89e7be | |||
| a9d9c45192 | |||
| abfcc6b48e | |||
| f0ef6c1abc | |||
| 75bfc9b47d | |||
| 30545b982e | |||
| e2d5ddb85f | |||
| 6a595bf69e |
44 changed files with 3184 additions and 81 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "okr",
|
"name": "okr",
|
||||||
"version": "1.6.0",
|
"version": "1.6.1",
|
||||||
"description": "Expert OKR guidance for Norwegian public sector. Write, review, cascade, track and govern OKR based on Google/Doerr methodology adapted for 4-month tertial cycles.",
|
"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": {
|
"author": {
|
||||||
"name": "Kjell Tore Guttormsen"
|
"name": "Kjell Tore Guttormsen"
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,6 +24,9 @@ Thumbs.db
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# npm (deps for innboks-ingestion; aldri i repo, aldri i bundle-roeter)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# --- session/local state (gitignored per ~/.claude polyrepo-konvensjon) ---
|
# --- session/local state (gitignored per ~/.claude polyrepo-konvensjon) ---
|
||||||
STATE.md
|
STATE.md
|
||||||
REMEMBER.md
|
REMEMBER.md
|
||||||
|
|
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Supply-chain-vern (Shai-Hulud): install-scripts kjoeres ALDRI.
|
||||||
|
ignore-scripts=true
|
||||||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **OKF second-brain spec v0.1 ratifisert (cross-plugin koordinering)** — den delte konvensjonen er kanonisert i `catalog/docs/okf-second-brain/spec.md` (katalog-eid, single source of truth). okr ratifiserer den per handoff §3/§4: `okf-check.mjs`-semantikken (kun `type` påkrevd; anbefalte felt → advarsler; `okf_version`-ekko) står som referansekontrakt (spec §7); okr bruker `resource` (ikke `source`); ingen feltgap (profil/config-nøkler bæres som extension keys, spec §5). Innboks-ingestion-planen (`docs/innboks-ingestion-veivalg-2026-06.md`) bygger nå mot spec-en; delings-scope for en eventuell Stage-3-skill = okr + ms-ai-architect (ikke linkedin-studio). Ingen kode- eller versjonsendring.
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-06-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`okr-quality-rubrics.md` omskrevet til korrekt norsk** — den ankrede kvalitetsrubrikken var fullstendig ASCII-strippet (0 å/ø/æ) til tross for at den lastes brukervendt av `/okr:kvalitet`, `kvalitetssjekker`-agenten og `/okr:freshen-references`. Samme tekstkvalitetsklasse som 360-en flagget KRITISK i v1.3.0 — en regresjon innført med selve rubrikk-fila.
|
||||||
|
- **`SKILL.md` flaggskip-eksempel** — strippet norsk i det første konkrete OKR-eksemplet (per år / høyrisiko / spørreundersøkelse / Gjennomføre møter).
|
||||||
|
- **`okr-second-brain-search` `~`-glob** — retrieval-instruksjonen brukte literal `~/.claude/okr/org/`, som Glob-verktøyet ikke ekspanderer → hjemme-roten (org-identitet) ble stilltiende aldri søkt, mot skillens eget «search both roots»-premiss. Nå eksplisitt absolutt hjemme-sti.
|
||||||
|
- **Uverifisert `kb-search`-sitat fjernet** — `okr-second-brain-search` hevdet en OKF `kb-search`-triad som ikke finnes (motsagt av `docs/innboks-ingestion-funn-2026-06.md`); erstattet med funksjonell Glob/Read/Grep-beskrivelse + `discovery`-mønsteret.
|
||||||
|
- **Regnefeil i `okr-oboard-guide.md`** — KR1-progresjon viste 52 %; korrekt er 65 % per filens egen formel `(start−current)/(start−target)`.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Patch utløst av `docs/evaluering-360-2026-06-26.md` (360-re-evaluering på v1.6.0): C+ → A−. Systemiske funn (KB-kryssreferanser, «Sist oppdatert»-markører) er bevisst utsatt.
|
||||||
|
|
||||||
## [1.6.0] - 2026-06-26
|
## [1.6.0] - 2026-06-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# OKR Offentlig Sektor v1.6.0
|
# OKR Offentlig Sektor v1.6.1
|
||||||
|
|
||||||
Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapted for 4-month tertial cycles.
|
Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapted for 4-month tertial cycles.
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ The second skill (`okr-second-brain-search`) does on-demand retrieval over the u
|
||||||
## State Management
|
## State Management
|
||||||
|
|
||||||
User configuration: `.claude/okr.local.md` in the project directory (not in plugin root).
|
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`
|
Template: `templates/okr.local.md.template`
|
||||||
Context tree: `.claude/okr/` — `strategisk-kontekst/`, `syklus/[id]/`, `historikk/`, `dokumenter/`
|
Context tree: `.claude/okr/` — `strategisk-kontekst/`, `syklus/[id]/`, `historikk/`, `dokumenter/`
|
||||||
Onboarding state: `onboarding_status` field in okr.local.md (`partial` | `fullfort`)
|
Onboarding state: `onboarding_status` field in okr.local.md (`partial` | `fullfort`)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
@ -259,6 +259,10 @@ The plugin understands this hierarchy and helps you maintain alignment at every
|
||||||
| okr-offentlig-sektor | Core OKR methodology and Norwegian public-sector domain knowledge |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
The plugin core is zero-dependency (`node:` builtins only). The inbox-ingestion pipeline (`/okr:innboks`, v1.7.0) is the one deliberate exception: it needs four pure-JS conversion libraries, exact-pinned in `package.json` (no `^`/`~` ranges) — `mammoth` (docx), `turndown` (HTML→markdown), `postal-mime` (eml), `unpdf` (PDF). Install with `npm install --ignore-scripts` (the repo `.npmrc` enforces `ignore-scripts=true` as supply-chain protection); requires Node >= 22. Everything else in the plugin runs without `node_modules/`.
|
||||||
|
|
||||||
### Knowledge Base
|
### 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.
|
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.
|
||||||
|
|
@ -290,6 +294,7 @@ The plugin understands this hierarchy and helps you maintain alignment at every
|
||||||
|
|
||||||
| Version | Date | Highlights |
|
| 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.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.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.4.0** | 2026-06-24 | Domenedybde + kildeintegritet (Fase 1), UX-/kommando-/agent-modning (Fase 2), hybrid org-kontekst-lesing |
|
||||||
|
|
|
||||||
57
commands/innboks.md
Normal file
57
commands/innboks.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
name: okr:innboks
|
||||||
|
description: Ingest dokumenter fra innboksen (.claude/okr/innboks/) til OKF-treet - deterministisk konvertering, splitting og indeksering
|
||||||
|
allowed-tools: Read, Bash, Glob
|
||||||
|
argument-hint: "(ingen argumenter - kommandoen leser hele innboksen)"
|
||||||
|
---
|
||||||
|
|
||||||
|
# OKR Innboks - Ingest droppede dokumenter til kunnskapstreet
|
||||||
|
|
||||||
|
Kjoer dokumenter droppet i `.claude/okr/innboks/` gjennom den deterministiske
|
||||||
|
ingestion-pipelinen: konvertering (txt/md/docx/eml/pdf), heading-splitting,
|
||||||
|
OKF-frontmatter, relasjoner og indeksering. Originalene bevares alltid i
|
||||||
|
innboksen (ikke-destruktivt); hvert dokument valideres mot en streng
|
||||||
|
sikkerhetsgate og rulles tilbake alene hvis det feiler.
|
||||||
|
|
||||||
|
"Auto-oppdage" betyr her: kommandoen walker hele drop-zonen naar den kjoeres og
|
||||||
|
tar alle droppede filer i ett sveip (ingen bakgrunns-daemon). Ukjente filtyper
|
||||||
|
skippes med beskjed, de feller aldri kjoeringen.
|
||||||
|
|
||||||
|
## Forutsetninger
|
||||||
|
|
||||||
|
- Node.js 22 eller nyere.
|
||||||
|
- For binaerformater (.docx/.eml/.pdf) trengs npm-avhengighetene: kjoer
|
||||||
|
`npm install --ignore-scripts` i plugin-rota FOERST. Rapporter
|
||||||
|
installasjonshintet til brukeren hvis skriptet melder at en avhengighet
|
||||||
|
mangler - installer ALDRI automatisk.
|
||||||
|
- Rene tekstfiler (.txt/.md) trenger ingen npm-avhengigheter.
|
||||||
|
|
||||||
|
## Arbeidsflyt
|
||||||
|
|
||||||
|
1. **Sjekk innboksen** - list filene med Glob (`.claude/okr/innboks/*`). Er den
|
||||||
|
tom, si det og stopp (ingen grunn til aa kjoere pipelinen).
|
||||||
|
|
||||||
|
2. **Kjoer ingestion** via Bash:
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scripts/innboks-ingest.mjs .claude/okr/innboks .claude/okr
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Tolk exit-koden**:
|
||||||
|
- `0` - alle dokumenter ingested (eventuelle skip av ukjente filtyper er OK).
|
||||||
|
Oppsummer for brukeren: antall dokumenter, hvor konseptene havnet
|
||||||
|
(`strategisk-kontekst/`, `dokumenter/`, ...), og at originalene ligger
|
||||||
|
igjen i innboksen.
|
||||||
|
- `1` - minst ett dokument ble avvist av sikkerhetsgaten og rullet tilbake
|
||||||
|
(staar listet i output med aarsak), eller en skrivekollisjon mot en
|
||||||
|
haandkuratert fil ble avvist. Rapporter aarsaken per dokument. De oevrige
|
||||||
|
dokumentene er ingested som normalt.
|
||||||
|
- `2` - bruksfeil (innboks eller bundle-rot finnes ikke). Sjekk at
|
||||||
|
`.claude/okr/` er satt opp (`/okr:oppsett`).
|
||||||
|
|
||||||
|
4. **Vis resultatet** - les rot-indeksen (`.claude/okr/index.md`) og nevn de
|
||||||
|
nye oppfoeringene. Ved behov kan brukeren finne igjen innholdet med
|
||||||
|
soeke-skillen (okr-second-brain-search).
|
||||||
|
|
||||||
|
**Merk (v1-begrensninger):** PDF-er konverteres flatt (ett konsept per PDF,
|
||||||
|
ingen heading-splitting); vedlegg i .eml ignoreres. Beriket splitting kommer i
|
||||||
|
en senere versjon.
|
||||||
155
docs/evaluering-360-2026-06-26.md
Normal file
155
docs/evaluering-360-2026-06-26.md
Normal 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 (45−32)/(45−25)=65 % per formel `:47` |
|
||||||
|
| DDT fjernet fra README | **Bekreftet** | `grep DDT README.md = 0`; README:23 lister kun NAV/FINN.no |
|
||||||
|
| Scoringsterskel konsistent | **Bekreftet** | `0.7-0.9`/`70-80%`/`0.6-0.8` = 0 treff; kanon `0.7 / 0.6-0.7` på tvers |
|
||||||
|
| UTF-8 i `individual-vs-team-okr.md` | **Bekreftet fikset** | 13 å / 6 ø / 1 æ, 0 strippede ord |
|
||||||
|
| Find-replace-artefakter | **Bekreftet 0** | hele vedlegg-A-mønsteret = 0 treff |
|
||||||
|
| Testsuite | **Bekreftet** | `node --test tests/*.test.mjs` → 55 passerer, 0 feiler |
|
||||||
|
| Reinstall-overlevende org-katalog | **Bekreftet** | `~/.claude/okr/org/profil.md` via `write-org-profile.mjs`, lest av `inject-okr-context.mjs`, testet |
|
||||||
|
|
||||||
|
### Metode
|
||||||
|
|
||||||
|
5 Opus-agenter (xhigh): Ref-scorer A (filer 1-8) + B (9-16) mot freshen-rubrikken; skill-evaluator × 2 (én per skill); 360-delta-verifikator mot alle v1.3.0-funn. Lead reviewer syntetiserte + ground-truth-verifiserte de bærende funnene. Gemini-triangulering ikke kjørt (bridge nede — deprecated SDK, jf. memory `gemini-mcp-sdk-outage`).
|
||||||
106
docs/innboks-ingestion-veivalg-2026-06.md
Normal file
106
docs/innboks-ingestion-veivalg-2026-06.md
Normal 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** på `okf/SPEC.md` (distinkt fra `mdcode`s «Metadata as Code» for Dataplex).
|
||||||
|
Kun `type` påkrevd; `index.md` reservert (ingen frontmatter); `okf_version` i rot-`index.md`.
|
||||||
|
*(NB: okr 1.6.0 pinnet bevisst til «Documents/kb Layout» og kalte det IKKE en formell standard —
|
||||||
|
ingestion-løpet bør re-verifisere `okf/SPEC.md` ved brief-tid og avgjøre hvor tett vi konformer.)*
|
||||||
|
|
||||||
|
**AVVIS for okr — strategisk framing som IKKE styrer okr-ingestion:**
|
||||||
|
- «Defer auto-classify/convert; build only on demonstrated need» → **avvist** (beslutning 1: skal bygges).
|
||||||
|
- «linkedin-studio is the reference design; siblings rise to it» → **avvist for okr** (beslutning 2).
|
||||||
|
- «Inbox forblir en manuell drop-zone uten auto-klassifiserer» → det var linkedin-studios stance;
|
||||||
|
okr-målet er det motsatte: **auto-oppdage + konvertere + tilordne OKF**.
|
||||||
|
|
||||||
|
## 3. Hva som faktisk skal bygges (gap, fra funn-grunnlaget §5)
|
||||||
|
|
||||||
|
1. **Dokument-konvertering** — PDF/Word/e-post/tekst → markdown. Finnes ikke i Google-repoet
|
||||||
|
(`fileskb`/`md-fileset` antar markdown og er read-only).
|
||||||
|
2. **Konsept-ekstraksjon + frontmatter-tilordning** — splitte vilkårlige dokumenter til «konsepter»,
|
||||||
|
sette `type`/`resource`/`title`/`description`/`tags`/`timestamp`.
|
||||||
|
3. **Generalisert relasjons-oppdagelse** — Googles relasjons-prompt er BQ-joins/web-spesifikk; må
|
||||||
|
generaliseres til vilkårlig dokumentkorpus (relasjoner som markdown-lenker i body).
|
||||||
|
4. **Innboks-orkestrering** — oppdage nye filer, **beholde original + plassere markdown-peker**,
|
||||||
|
idempotent re-kjøring, plassering i riktig nivå av treet, regenerere berørt `index.md`.
|
||||||
|
|
||||||
|
## 4. Gjenbrukbart fra OKF-fasen (1.6.0 — bygger ikke fra null)
|
||||||
|
|
||||||
|
- `scripts/okf-index.mjs` — kildeagnostisk, idempotent `index.md`-regen (speiler Googles
|
||||||
|
`regenerate_indexes`). Innboks-pipelinen kaller den etter skriv (som tre-skriverne i `oppsett.md`).
|
||||||
|
- `lib/frontmatter.mjs` (parse/skrive) + `scripts/okf-check.mjs` (validering) dekker skrive- og
|
||||||
|
verifiserings-siden.
|
||||||
|
- Googles `Source`-ABC viser et rent adapter-mønster — en «innboks-kilde» som lister konsepter fra en
|
||||||
|
mappe kan mate eksisterende skrive-/index-/kryss-lenke-maskineri.
|
||||||
|
|
||||||
|
## 5. Åpne spørsmål til `/trekbrief` (avgjøres da, ikke nå)
|
||||||
|
|
||||||
|
- **Konverterings-motor:** okr er i dag **zero-dependency Node ESM** (kun `node:`-builtins). PDF/Word→md
|
||||||
|
krever realistisk enten et eksternt verktøy (pandoc/libreoffice/markitdown) eller en avhengighet —
|
||||||
|
**bryter zero-dep-invarianten**. Dette er et reelt arkitekturvalg (dokumentert prerequisite à la
|
||||||
|
`export-pdf.py`/weasyprint? egen avhengighet? hvilke formater i v1?).
|
||||||
|
- **Konsept-ekstraksjon:** LLM-drevet (Claude i kommando) vs. heuristisk splitting — og hvor deterministisk.
|
||||||
|
- **Relasjons-oppdagelse:** hvor generalisert; hvordan holde testbart.
|
||||||
|
- **Innboks-plassering:** prosjekt-`.claude/okr/innboks/`? home? begge røtter? Original-bevaring + peker-layout.
|
||||||
|
- **Mål-format:** full OKF v0.1 (`okf/SPEC.md`) vs. dagens lettere «OKF-kompatible form» okr emitterer.
|
||||||
|
- **Idempotens + sikkerhet:** re-kjøring uten dubletter; ikke ødelegge brukerens originaler.
|
||||||
|
|
||||||
|
## 6. Forhold til søsken-pluginer
|
||||||
|
|
||||||
|
- **Delings-scope (operatør-låst 2026-06-29):** en eventuell delt Stage-3-skill deles mellom
|
||||||
|
**okr + ms-ai-architect** — **IKKE linkedin-studio**. linkedin-studio har sin egen mekanisme
|
||||||
|
(rikere ikke-OKF-brain, reference-design — «not levelled down to bare OKF», brief §1) og leveres
|
||||||
|
ikke ned til en delt skill. Låsen endrer kun *hvem* som deler, ikke *når*: Stage 3 er fortsatt
|
||||||
|
betinget på Stage-2-måling og utsatt.
|
||||||
|
- `ms-ai-architect` har samme oppgave (designet, ikke bygget). **Vurder felles abstraksjon før
|
||||||
|
dobbel-implementasjon** — men felles ≠ kopiere linkedin-studio. Eventuell deling skjer på okr-egne
|
||||||
|
premisser.
|
||||||
|
- En delt OKF-**spec** (convergence-briefens Stage 1, katalog-nivå) er grei interop og ikke i konflikt
|
||||||
|
med dette løpet; okr-ingestion er ikke blokkert på den.
|
||||||
|
|
||||||
|
## 7. Kilder
|
||||||
|
|
||||||
|
- **Delt konvensjon (single source of truth):** `catalog/docs/okf-second-brain/spec.md` v0.1 + koordinerings-`log.md` + `handoff-2026-06-29.md` (samme mappe, katalog-repo).
|
||||||
|
- `docs/innboks-ingestion-funn-2026-06.md` — web-verifisert Google-repo-analyse (kilder i §9 der).
|
||||||
|
- `linkedin-studio/docs/okf-convergence-brief.md` — cross-cutting convergence (avstemt over; nå foldet inn i spec-en).
|
||||||
|
- OKF v0.1: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md`.
|
||||||
|
- Leveranse vi bygger på: okr 1.6.0 (`scripts/okf-*`, `lib/frontmatter.mjs`, skill `okr-second-brain-search`).
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
_Notert 2026-06-26. **Fremtidig initiativ — IKKE implementer før plan er laget og godkjent.** Dette notatet er grunnlagsmateriale for neste sesjons `/trekbrief` → `/trekplan`. Bygger på (a) verifisert lesning av OKF SPEC v0.1 og (b) det ferdige referansedesignet i søsken-pluginen `ms-ai-architect` (`docs/okf-second-brain-brief-2026-06.md`, operatør-bekreftet 2026-06-26). State-of-play i `STATE.md`._
|
_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)
|
## 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`.
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
132
lib/convert/index.mjs
Normal file
132
lib/convert/index.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// convert/index.mjs
|
||||||
|
// Step 11 (SC format): konverterings-adaptere for innboks-ingestion.
|
||||||
|
// Dispatch paa extension: .txt/.md -> node:-builtins (les direkte);
|
||||||
|
// .docx -> mammoth(->HTML)->turndown; .eml -> postal-mime (kun body --
|
||||||
|
// vedlegg = dokumentert v1-non-goal); .pdf -> unpdf tekst (flat, ingen
|
||||||
|
// headings -- dokumentert v1-caveat M5). Ukjent extension -> skip + norsk
|
||||||
|
// notice (returner null), IKKE feil -- en enkelt ukjent fil skal aldri felle
|
||||||
|
// hele kjoeringen.
|
||||||
|
//
|
||||||
|
// Sikkerhet (innboks-dok = fiendtlig):
|
||||||
|
// - INGEN nettverk: all input leses fra disk og mates som buffer/streng.
|
||||||
|
// - CVE-2024-4367: unpdf kalles med eksplisitt { isEvalSupported: false }
|
||||||
|
// (default i unpdf 1.6.2, A0-verifisert -- settes likevel, belte+seler).
|
||||||
|
// - Eksterne lenker NOEYTRALISERES ved konvertering (B3/F2): lenketekst
|
||||||
|
// bevares, scheme-/ikke-bundle-maal droppes -- i ALLE lenkeformer
|
||||||
|
// (inline, referanse-definisjon, autolink, raa HTML-anker, bilde). Kun
|
||||||
|
// trygge bundle-root-relative .md-lenker (isSafeBundleLink, den delte
|
||||||
|
// allow-listen) overlever. Legitim konvertert output kan dermed aldri
|
||||||
|
// felles av strict-gaten pga. medbrakte eksterne lenker.
|
||||||
|
// - M5/F4: turndown pinnes til { headingStyle: 'atx' } -- default er setext,
|
||||||
|
// som heading-splitten (innboks-split) aldri ser.
|
||||||
|
//
|
||||||
|
// npm-deps (mammoth/turndown/postal-mime/unpdf) er dokumentert prerequisite
|
||||||
|
// (exact-pinnet i package.json, Step 10) -- mangler de, kastes en klar norsk
|
||||||
|
// installasjonshint (samme moenster som scripts/export-pdf.py), aldri traceback.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { isSafeBundleLink } from '../okf-links.mjs';
|
||||||
|
|
||||||
|
const INSTALL_HINT = 'kjoer «npm install --ignore-scripts» i plugin-rota (krever Node >= 22)';
|
||||||
|
|
||||||
|
// Lazy-import av en konverterings-motor; mangler den -> klar norsk hint.
|
||||||
|
async function loadEngine(name) {
|
||||||
|
try {
|
||||||
|
return await import(name);
|
||||||
|
} catch (e) {
|
||||||
|
if (e && (e.code === 'ERR_MODULE_NOT_FOUND' || e.code === 'MODULE_NOT_FOUND')) {
|
||||||
|
throw new Error(`innboks: mangler npm-avhengighet «${name}» -- ${INSTALL_HINT}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M5/F4-pinning: atx ('#') er OBLIGATORISK for at heading-splitten skal virke.
|
||||||
|
async function newTurndown() {
|
||||||
|
const TurndownService = (await loadEngine('turndown')).default;
|
||||||
|
return new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noeytraliser alle lenkeformer med ikke-bundle-maal; behold lenketeksten.
|
||||||
|
// Rekkefoelgen er semantisk: HTML-anker foer autolink (begge bruker <...>),
|
||||||
|
// ref-definisjoner foer ref-bruk-kollaps, bilder foer inline ( baerer
|
||||||
|
// samme hale som [..](..)).
|
||||||
|
export function neutralizeExternalLinks(markdown) {
|
||||||
|
let out = String(markdown);
|
||||||
|
// 1. Raa HTML-anker -> indre tekst (file://-href o.l. droppes med taggen).
|
||||||
|
out = out.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
||||||
|
out = out.replace(/<\/?a\b[^>]*>/gi, '');
|
||||||
|
// 2. Autolink <scheme:...> -> ren tekst (URL mister lenke-formen).
|
||||||
|
out = out.replace(/<([a-z][a-z0-9+.-]*:[^<>\s]*)>/gi, '$1');
|
||||||
|
// 3. Referanse-definisjoner droppes linjevis; ref-bruk kollapses til tekst.
|
||||||
|
out = out.replace(/^[ \t]{0,3}\[[^\]]+\]:[ \t]*\S.*$/gm, '');
|
||||||
|
out = out.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1');
|
||||||
|
// 4. Bilder -> alt-tekst (EchoLeak-stil exfil-piksel har ingen plass i bundlen).
|
||||||
|
out = out.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, '$1');
|
||||||
|
// 5. Inline-lenker: KUN trygge bundle-root-relative .md-maal beholdes.
|
||||||
|
out = out.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (whole, text, target) => {
|
||||||
|
const dest = target.trim().split(/\s+/)[0];
|
||||||
|
return isSafeBundleLink(dest) ? whole : text;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function docxToMarkdown(filePath) {
|
||||||
|
const mammothMod = await loadEngine('mammoth');
|
||||||
|
const mammoth = mammothMod.default ?? mammothMod;
|
||||||
|
const { value: html } = await mammoth.convertToHtml({ buffer: readFileSync(filePath) });
|
||||||
|
return (await newTurndown()).turndown(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emlToMarkdown(filePath) {
|
||||||
|
const PostalMime = (await loadEngine('postal-mime')).default;
|
||||||
|
const parsed = await new PostalMime().parse(readFileSync(filePath, 'utf8'));
|
||||||
|
const parts = [];
|
||||||
|
if (parsed.subject) parts.push(`# ${parsed.subject}`);
|
||||||
|
if (parsed.text) {
|
||||||
|
parts.push(parsed.text.trim());
|
||||||
|
} else if (parsed.html) {
|
||||||
|
parts.push((await newTurndown()).turndown(parsed.html));
|
||||||
|
}
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfToMarkdown(filePath) {
|
||||||
|
const { getDocumentProxy, extractText } = await loadEngine('unpdf');
|
||||||
|
const data = new Uint8Array(readFileSync(filePath));
|
||||||
|
const proxy = await getDocumentProxy(data, { isEvalSupported: false });
|
||||||
|
const { text } = await extractText(proxy, { mergePages: true });
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// filsti -> markdown-streng (neoytralisert, garantert trailing newline),
|
||||||
|
// eller null for ukjent extension (skip + norsk notice via onNotice).
|
||||||
|
export async function convert(
|
||||||
|
filePath,
|
||||||
|
{ onNotice = (msg) => process.stderr.write(`${msg}\n`) } = {},
|
||||||
|
) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
let markdown;
|
||||||
|
switch (ext) {
|
||||||
|
case '.md':
|
||||||
|
case '.txt':
|
||||||
|
markdown = readFileSync(filePath, 'utf8');
|
||||||
|
break;
|
||||||
|
case '.docx':
|
||||||
|
markdown = await docxToMarkdown(filePath);
|
||||||
|
break;
|
||||||
|
case '.eml':
|
||||||
|
markdown = await emlToMarkdown(filePath);
|
||||||
|
break;
|
||||||
|
case '.pdf':
|
||||||
|
markdown = await pdfToMarkdown(filePath);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onNotice(`innboks: hopper over ukjent filtype «${ext || '(ingen)'}»: ${path.basename(filePath)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const neutralized = neutralizeExternalLinks(markdown);
|
||||||
|
return neutralized.endsWith('\n') ? neutralized : `${neutralized}\n`;
|
||||||
|
}
|
||||||
|
|
@ -41,14 +41,26 @@ export function parseFrontmatter(content) {
|
||||||
return { raw, get };
|
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) {
|
export function writeFrontmatter(fields) {
|
||||||
const lines = ['---'];
|
const lines = ['---'];
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
const s = String(value);
|
// Additiv array-gren (Step 3): emit OKF multi-linje list-verdi (f.eks.
|
||||||
// Siter naar verdien inneholder '#'/':' eller har kant-whitespace, slik at
|
// `tags`) som `key:\n - item`. Lese-siden er uendret -- parseFrontmatter.
|
||||||
// round-trip via parseFrontmatter bevarer den eksakt (jf. siter-#-regelen).
|
// get() returnerer fortsatt null for list-keys. Skalar-grenen under er
|
||||||
const needsQuote = /[#:]/.test(s) || /^\s|\s$/.test(s) || /^["']/.test(s);
|
// bevart bit-for-bit (kun loftet ut til quoteIfNeeded, samme regel).
|
||||||
lines.push(`${key}: ${needsQuote ? JSON.stringify(s) : s}`);
|
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('---');
|
lines.push('---');
|
||||||
return lines.join('\n') + '\n';
|
return lines.join('\n') + '\n';
|
||||||
|
|
|
||||||
87
lib/innboks-frontmatter.mjs
Normal file
87
lib/innboks-frontmatter.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// 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 path from 'node:path';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// M3 (A1): vokab-term matcher kun som HELT ord (ikke substring -- kompound som
|
||||||
|
// «Statusnotat» skal ikke snappe til Status). Ord-grense = ikke-bokstav/-siffer
|
||||||
|
// paa begge sider (\b haandterer ikke ae/oe/aa -- derfor \p{L}\p{N}-lookaround).
|
||||||
|
function termMatches(hay, term) {
|
||||||
|
const esc = term.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return new RegExp(`(?<![\\p{L}\\p{N}])${esc}(?![\\p{L}\\p{N}])`, 'u').test(hay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regel-utledning: foerste vokab-term (lengst foerst) som forekommer som helt
|
||||||
|
// ord i title + BASENAME av kilden -> kanonisk type. Full sti deltar ALDRI
|
||||||
|
// (M3: et /okr/-katalogsegment skal ikke forgifte routeLevel-rutingen).
|
||||||
|
function deriveType(title, sourcePath) {
|
||||||
|
const hay = `${title} ${path.basename(String(sourcePath))}`.toLowerCase();
|
||||||
|
for (const t of TYPE_BY_LENGTH) {
|
||||||
|
if (termMatches(hay, t)) 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
49
lib/innboks-relations.mjs
Normal 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
96
lib/innboks-split.mjs
Normal 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;
|
||||||
|
}
|
||||||
186
lib/innboks-write.mjs
Normal file
186
lib/innboks-write.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
// 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.
|
||||||
|
// (5) B5-kollisjons-guards (A2) -- treet selv er skjermet, ikke bare originalene:
|
||||||
|
// - reservert navn: destRel med basename index.md avvises (indeksering
|
||||||
|
// eier index.md; et konsept-slug «index» ville blitt destruert ved
|
||||||
|
// neste generateIndexes).
|
||||||
|
// - kuratert-fil-vern: eksisterende maal-fil UTEN `kilde: innboks` i
|
||||||
|
// frontmatter er haandkuratert -> skriv avvises (aldri stille datatap).
|
||||||
|
// Med `kilde: innboks` er fila ingestion-eid -> re-skriv OK (idempotent
|
||||||
|
// re-ingest av samme drop-zone).
|
||||||
|
// - kryss-kilde-kollisjon: opts.claimed (Map maal -> sourceSlug, delt av
|
||||||
|
// orkestratoren PAA TVERS av per-dokument-kall) avviser at to KILDER
|
||||||
|
// skriver samme destRel i samme kjoering (stille last-wins var B5);
|
||||||
|
// samme kilde kan re-skrive (relasjons-fase 2).
|
||||||
|
//
|
||||||
|
// Reuses: atomisk-skriv-moenster (scripts/write-org-profile.mjs:34-40);
|
||||||
|
// writeFrontmatter (lib/frontmatter.mjs) for peker-frontmatter. Zero npm deps.
|
||||||
|
|
||||||
|
import { writeFileSync, readFileSync, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
import { writeFrontmatter, parseFrontmatter } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// M2 (A1): den leksikalske sjekken over slipper symlinks -- en symlinket
|
||||||
|
// katalog/original INNE i bundlen kan peke UT av den. realpathSync paa den
|
||||||
|
// faktiske noden (destinasjons-parent etter mkdir / original foer peker-skriv)
|
||||||
|
// maa ogsaa lande under bundle-rotas realpath, ellers avvises skrivet.
|
||||||
|
function assertRealUnderBundle(realBundle, p, what) {
|
||||||
|
const real = realpathSync(p);
|
||||||
|
if (real !== realBundle && !real.startsWith(realBundle + path.sep)) {
|
||||||
|
throw new Error(`innboks-write: ${what} resolverer utenfor bundle-rot (symlink-escape avvist): ${p}`);
|
||||||
|
}
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destinasjons-parent opprettes, realpath-sjekkes, DERETTER skrives det atomisk.
|
||||||
|
function writeConfined(realBundle, target, data, what) {
|
||||||
|
const dir = path.dirname(target);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
assertRealUnderBundle(realBundle, dir, what);
|
||||||
|
writeAtomic(target, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// B5-guards foer skriv (se header (5)). claimed: Map<absolutt maal, sourceSlug>.
|
||||||
|
function guardTarget(target, sourceSlug, claimed, rel) {
|
||||||
|
if (path.basename(target) === 'index.md') {
|
||||||
|
throw new Error(`innboks-write: reservert navn avvist (index.md eies av indekseringen): ${rel}`);
|
||||||
|
}
|
||||||
|
const owner = claimed.get(target);
|
||||||
|
if (owner !== undefined && owner !== sourceSlug) {
|
||||||
|
throw new Error(
|
||||||
|
`innboks-write: kryss-kilde destRel-kollisjon: ${rel} alt skrevet av kilde «${owner}» i denne kjoeringen (naa: «${sourceSlug}»)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (owner === undefined && existsSync(target)) {
|
||||||
|
const { get } = parseFrontmatter(readFileSync(target, 'utf8'));
|
||||||
|
if (get('kilde') !== 'innboks') {
|
||||||
|
throw new Error(`innboks-write: nekter aa overskrive kuratert (ikke-ingestion) fil: ${rel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
});
|
||||||
|
// B1: INGEN lenkeform i body -- en md-lenke til en ikke-.md-original feller
|
||||||
|
// strict-gaten (okf-links krever .md). resource: over baerer stien; body
|
||||||
|
// nevner den kun som ren tekst (grep-bar, aldri lenke).
|
||||||
|
const body = `Peker til bevart original i drop-zonen (ikke-destruktiv ingestion): ${link.replace(/^\/+/, '')}\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 = [], claimed = new Map() } = {}) {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle-rotas realpath er ankeret for alle M2-symlink-sjekker under.
|
||||||
|
const realBundle = realpathSync(resolvedBundle);
|
||||||
|
|
||||||
|
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);
|
||||||
|
guardTarget(target, concept.sourceSlug, claimed, concept.destRel);
|
||||||
|
writeConfined(realBundle, target, fileContent(concept), `destinasjons-katalog for ${concept.destRel}`);
|
||||||
|
claimed.set(target, concept.sourceSlug);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
// M2: en symlink-original i drop-zonen passerer den leksikalske sjekken
|
||||||
|
// over, men realpathen kan peke UT av bundlen -> avvis.
|
||||||
|
assertRealUnderBundle(realBundle, resolvedOriginal, `original ${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);
|
||||||
|
guardTarget(pointerTarget, original.sourceSlug, claimed, pointerRel);
|
||||||
|
writeConfined(realBundle, pointerTarget, pointerContent(original, link), `peker-katalog for ${pointerRel}`);
|
||||||
|
claimed.set(pointerTarget, original.sourceSlug);
|
||||||
|
writtenPointers.push(pointerTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { concepts: writtenConcepts, pointers: writtenPointers };
|
||||||
|
}
|
||||||
39
lib/okf-links.mjs
Normal file
39
lib/okf-links.mjs
Normal 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
73
lib/okf-vocab.mjs
Normal 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;
|
||||||
|
}
|
||||||
289
package-lock.json
generated
Normal file
289
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
{
|
||||||
|
"name": "okr-offentlig-sektor",
|
||||||
|
"version": "1.7.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "okr-offentlig-sektor",
|
||||||
|
"version": "1.7.0",
|
||||||
|
"dependencies": {
|
||||||
|
"mammoth": "1.12.0",
|
||||||
|
"postal-mime": "2.7.5",
|
||||||
|
"turndown": "7.2.4",
|
||||||
|
"unpdf": "1.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mixmark-io/domino": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||||
|
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/dingbat-to-unicode": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/duck": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||||
|
"license": "BSD",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lop": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"duck": "^0.1.12",
|
||||||
|
"option": "~0.2.1",
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mammoth": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.6",
|
||||||
|
"argparse": "~1.0.3",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"bluebird": "~3.4.0",
|
||||||
|
"dingbat-to-unicode": "^1.0.1",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
|
"lop": "^0.4.2",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"underscore": "^1.13.1",
|
||||||
|
"xmlbuilder": "^10.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mammoth": "bin/mammoth"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/option": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz",
|
||||||
|
"integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/turndown": {
|
||||||
|
"version": "7.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||||
|
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mixmark-io/domino": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"npm": ">=9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||||
|
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/unpdf": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.69"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@napi-rs/canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "okr-offentlig-sektor",
|
||||||
|
"version": "1.7.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "OKR-plugin for norsk offentlig sektor. Pure-JS konverterings-deps for innboks-ingestion (bevisst zero-dep-brudd, se README).",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mammoth": "1.12.0",
|
||||||
|
"postal-mime": "2.7.5",
|
||||||
|
"turndown": "7.2.4",
|
||||||
|
"unpdf": "1.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
240
scripts/innboks-ingest.mjs
Normal file
240
scripts/innboks-ingest.mjs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// innboks-ingest.mjs
|
||||||
|
// Step 12 (A2): pipeline-orkestrator for innboks-ingestion. Kjoerer drop-zonen
|
||||||
|
// (.claude/okr/innboks/) gjennom den deterministiske kjeden og persisterer
|
||||||
|
// konsepter i prosjekt-bundlen. INGEN LLM i v1 (--enrich = v1.1-soem, ingen flagg).
|
||||||
|
//
|
||||||
|
// Faserekkefoelge (A2-redesign av plan.md Step 12 -- begrunnelse under):
|
||||||
|
// 1. discover: flat walk av drop-zonen (kun filer, dotfiler skippes), sortert
|
||||||
|
// (deterministisk rekkefoelge = stabil disambiguering). Ukjent extension ->
|
||||||
|
// skip + norsk notice (via convert), aldri feil.
|
||||||
|
// 2. PER DOKUMENT (staging + gate, laast A0): convert -> splitConcepts ->
|
||||||
|
// projectFrontmatter (timestamp = original-mtime, IKKE veggklokke) ->
|
||||||
|
// kryss-kilde destRel-disambiguering (B5: kolliderende destRel fra annen
|
||||||
|
// kilde faar --<sourceSlug>-suffiks, stabilt gitt sortert rekkefoelge) ->
|
||||||
|
// writeConcepts (delt claimed-register) -> per-dokument-gate
|
||||||
|
// checkBundle(root, {strictIngest: true, files: <dokumentets skrevne filer>})
|
||||||
|
// -- strict scopes til KUN kjoeringens skrevne filer, ALDRI hele roten
|
||||||
|
// (B2: kuratert innhold med lovlige eksterne lenker/out-of-vocab-type kan
|
||||||
|
// ikke felle ingestion). Gate-feil -> discard-on-fail: KUN det dokumentets
|
||||||
|
// skrivinger rulles tilbake (Map-Reduce-isolasjon -- ett fiendtlig dok
|
||||||
|
// forgifter ikke andre).
|
||||||
|
// 3. resolveRelations over ALLE overlevende dokumenters konsepter, DERETTER
|
||||||
|
// relasjons-omskriv av konsepter som fikk relasjoner.
|
||||||
|
// A2-REDESIGN (avvik fra plan-kjedens "relations foer write"): relasjoner
|
||||||
|
// per dokument alene kan aldri emittere kryss-dokument-lenker (fixture-SC
|
||||||
|
// dok-a -> dok-b), og relasjoner FOER gaten ville latt et discardet
|
||||||
|
// dokument etterlate dangling relasjonslenker i overlevende dokumenter
|
||||||
|
// (isolasjonsbrudd). Gate foerst, relasjoner blant overlevende etterpaa:
|
||||||
|
// hvert relasjonsmaal er da garantert paa disk.
|
||||||
|
// 4. generateIndexes(root) over det overlevende settet.
|
||||||
|
// 5. Belte+seler: scoped strict-sjekk av ALLE overlevende skrevne filer --
|
||||||
|
// feil her er et internt invariant-brudd (skal aldri skje), ikke dok-feil.
|
||||||
|
//
|
||||||
|
// Idempotens by construction: fil-settet er en funksjon av drop-zonens innhold
|
||||||
|
// + mtime alene (run1 vs run2 byte-identisk, testet via sha256-manifest).
|
||||||
|
// Project-root-only: writeConcepts avviser home-org-rota; ingen cross-repo.
|
||||||
|
//
|
||||||
|
// Bruk: node scripts/innboks-ingest.mjs <innboks-katalog> <bundle-rot>
|
||||||
|
// Exit: 0 = alle dokumenter OK (skip av ukjent filtype er OK), 1 = minst ett
|
||||||
|
// dokument feilet/discardet (eller internt invariant-brudd), 2 = bruksfeil.
|
||||||
|
|
||||||
|
import { readdirSync, statSync, unlinkSync, rmdirSync, existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { convert } from '../lib/convert/index.mjs';
|
||||||
|
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';
|
||||||
|
import { generateIndexes } from './okf-index.mjs';
|
||||||
|
import { checkBundle } from './okf-check.mjs';
|
||||||
|
|
||||||
|
// Kebab-slug -- speiler innboks-split.mjs:13-21 (frosset modul, eksporterer
|
||||||
|
// ikke slugify; semantikken MAA vaere identisk med konsept-slugging).
|
||||||
|
function slugify(text) {
|
||||||
|
const s = String(text)
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return s || 'konsept';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat, sortert discover av drop-zonen: kun regulaere filer, dotfiler skippes.
|
||||||
|
// (Underkataloger i drop-zonen er udefinert i v1 -- de ignoreres stille.)
|
||||||
|
function discover(inboxDir) {
|
||||||
|
return readdirSync(inboxDir, { withFileTypes: true })
|
||||||
|
.filter((e) => !e.name.startsWith('.') && !e.isDirectory())
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unik sourceSlug per fil: basename uten extension; kollisjon (dok.docx vs
|
||||||
|
// dok.pdf) -> extension-suffiks; deretter teller. Deterministisk i sortert orden.
|
||||||
|
function assignSourceSlug(name, taken) {
|
||||||
|
const ext = path.extname(name);
|
||||||
|
const base = slugify(path.basename(name, ext));
|
||||||
|
const candidates = [base, `${base}-${slugify(ext)}`];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (!taken.has(c)) {
|
||||||
|
taken.add(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 2; ; i += 1) {
|
||||||
|
const c = `${candidates[1]}-${i}`;
|
||||||
|
if (!taken.has(c)) {
|
||||||
|
taken.add(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B5: destRel-disambiguering PAA TVERS av kilder. claimed = det delte registeret
|
||||||
|
// (absolutt maal -> sourceSlug) som writeConcepts ogsaa haandhever. Kolliderende
|
||||||
|
// destRel fra en ANNEN kilde faar --<sourceSlug>-suffiks (stabilt: avhenger kun
|
||||||
|
// av input-settet + sortert rekkefoelge, aldri veggklokke).
|
||||||
|
function disambiguate(concepts, resolvedBundle, claimed) {
|
||||||
|
return concepts.map((c) => {
|
||||||
|
let destRel = c.destRel;
|
||||||
|
const owner = claimed.get(path.resolve(resolvedBundle, destRel));
|
||||||
|
if (owner !== undefined && owner !== c.sourceSlug) {
|
||||||
|
const dir = path.dirname(destRel);
|
||||||
|
const base = path.basename(destRel, '.md');
|
||||||
|
destRel = `${dir}/${base}--${c.sourceSlug}.md`;
|
||||||
|
for (let i = 2; claimed.has(path.resolve(resolvedBundle, destRel)); i += 1) {
|
||||||
|
destRel = `${dir}/${base}--${c.sourceSlug}-${i}.md`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destRel === c.destRel ? c : { ...c, destRel };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard-on-fail: fjern dokumentets skrevne filer + toemte foreldre-kataloger
|
||||||
|
// (opp til bundle-rota), og slipp claimed-registreringene.
|
||||||
|
function discard(written, resolvedBundle, claimed) {
|
||||||
|
for (const file of written) {
|
||||||
|
if (existsSync(file)) unlinkSync(file);
|
||||||
|
claimed.delete(file);
|
||||||
|
let dir = path.dirname(file);
|
||||||
|
while (dir !== resolvedBundle && dir.startsWith(resolvedBundle + path.sep)) {
|
||||||
|
try {
|
||||||
|
rmdirSync(dir); // kaster hvis ikke tom -> ferdig aa rydde
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dir = path.dirname(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orkestrer hele ingest-kjoeringen. Returnerer
|
||||||
|
// { ingested: [sourceSlug], skipped: [filnavn], failed: [{source, reason}], written: [absolutt sti] }
|
||||||
|
export async function ingestInbox(inboxDir, bundleRoot, { onNotice = (msg) => process.stderr.write(`${msg}\n`) } = {}) {
|
||||||
|
const resolvedBundle = path.resolve(bundleRoot);
|
||||||
|
const claimed = new Map(); // absolutt maal -> sourceSlug (delt med writeConcepts, B5)
|
||||||
|
const takenSlugs = new Set();
|
||||||
|
const ingested = [];
|
||||||
|
const skipped = [];
|
||||||
|
const failed = [];
|
||||||
|
const survivors = []; // { source, concepts (disambiguert), written }
|
||||||
|
|
||||||
|
for (const name of discover(inboxDir)) {
|
||||||
|
const filePath = path.join(inboxDir, name);
|
||||||
|
const sourceSlug = assignSourceSlug(name, takenSlugs);
|
||||||
|
let written = null;
|
||||||
|
try {
|
||||||
|
const markdown = await convert(filePath, { onNotice });
|
||||||
|
if (markdown === null) {
|
||||||
|
skipped.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mtime = statSync(filePath).mtime;
|
||||||
|
const sourcePath = path.relative(resolvedBundle, path.resolve(filePath)).split(path.sep).join('/');
|
||||||
|
const raw = splitConcepts(markdown, { sourceSlug });
|
||||||
|
const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: mtime }));
|
||||||
|
const concepts = disambiguate(projected, resolvedBundle, claimed);
|
||||||
|
|
||||||
|
const result = writeConcepts(concepts, {
|
||||||
|
bundleRoot: resolvedBundle,
|
||||||
|
originals: [{ sourceSlug, path: filePath }],
|
||||||
|
claimed,
|
||||||
|
});
|
||||||
|
written = [...result.concepts, ...result.pointers];
|
||||||
|
|
||||||
|
// Per-dokument-gate, scoped til KUN dette dokumentets skrevne filer (B2).
|
||||||
|
const gate = checkBundle(resolvedBundle, { strictIngest: true, files: written });
|
||||||
|
const errors = [...gate.missingType.map((f) => `mangler type: ${f}`), ...gate.strictErrors];
|
||||||
|
if (errors.length > 0) {
|
||||||
|
discard(written, resolvedBundle, claimed);
|
||||||
|
failed.push({ source: name, reason: errors.join('; ') });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
survivors.push({ source: name, sourceSlug, concepts, written });
|
||||||
|
ingested.push(sourceSlug);
|
||||||
|
} catch (e) {
|
||||||
|
// Skrive-/konverteringsfeil (kuratert-kollisjon, symlink, manglende dep):
|
||||||
|
// discard det som maatte vaere skrevet, kjoeringen fortsetter (isolasjon).
|
||||||
|
if (written) discard(written, resolvedBundle, claimed);
|
||||||
|
failed.push({ source: name, reason: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fase 3: relasjoner blant OVERLEVENDE dokumenters konsepter (kryss-dokument;
|
||||||
|
// hvert maal er garantert paa disk). Omskriv kun konsepter som fikk relasjoner.
|
||||||
|
const allConcepts = survivors.flatMap((s) => s.concepts);
|
||||||
|
const related = resolveRelations(allConcepts);
|
||||||
|
const withRelations = related.filter((c) => c.relations.length > 0);
|
||||||
|
if (withRelations.length > 0) {
|
||||||
|
writeConcepts(withRelations, { bundleRoot: resolvedBundle, claimed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fase 4: indekser det overlevende settet (rot + alle nivaaer).
|
||||||
|
generateIndexes(resolvedBundle);
|
||||||
|
|
||||||
|
// Fase 5 (belte+seler): alle overlevende skrevne filer maa passere scoped
|
||||||
|
// strict. Feil her er et internt invariant-brudd, ikke en dokument-feil.
|
||||||
|
const allWritten = survivors.flatMap((s) => s.written);
|
||||||
|
if (allWritten.length > 0) {
|
||||||
|
const final = checkBundle(resolvedBundle, { strictIngest: true, files: allWritten });
|
||||||
|
const finalErrors = [...final.missingType, ...final.strictErrors];
|
||||||
|
if (finalErrors.length > 0) {
|
||||||
|
throw new Error(`innboks-ingest: intern invariant brutt etter relasjons-fasen: ${finalErrors.join('; ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ingested, skipped, failed, written: allWritten };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI ---
|
||||||
|
const isMain = process.argv[1]
|
||||||
|
&& fileURLToPath(import.meta.url) === process.argv[1];
|
||||||
|
if (isMain) {
|
||||||
|
const [inboxDir, bundleRoot] = process.argv.slice(2);
|
||||||
|
if (!inboxDir || !bundleRoot) {
|
||||||
|
process.stderr.write('Bruk: node innboks-ingest.mjs <innboks-katalog> <bundle-rot>\n');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (!existsSync(inboxDir) || !existsSync(bundleRoot)) {
|
||||||
|
process.stderr.write(`Finnes ikke: ${existsSync(inboxDir) ? bundleRoot : inboxDir}\n`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await ingestInbox(inboxDir, bundleRoot);
|
||||||
|
const lines = [`Innboks-ingest: ${inboxDir} -> ${bundleRoot}`];
|
||||||
|
lines.push(` Ingested: ${r.ingested.length} dokument(er)${r.ingested.length ? ` (${r.ingested.join(', ')})` : ''}`);
|
||||||
|
lines.push(` Skippet (ukjent filtype): ${r.skipped.length}`);
|
||||||
|
lines.push(` Feilet/discardet: ${r.failed.length}`);
|
||||||
|
for (const f of r.failed) lines.push(` x ${f.source}: ${f.reason}`);
|
||||||
|
lines.push(r.failed.length === 0 ? 'OK: alle dokumenter ingested' : 'FEIL: se discardede dokumenter over');
|
||||||
|
process.stdout.write(`${lines.join('\n')}\n`);
|
||||||
|
process.exit(r.failed.length === 0 ? 0 : 1);
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(`innboks-ingest: ${e.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,20 +12,43 @@
|
||||||
// Zero npm dependencies (node:-builtins).
|
// Zero npm dependencies (node:-builtins).
|
||||||
|
|
||||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
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'];
|
const RECOMMENDED = ['resource', 'title', 'description', 'timestamp'];
|
||||||
|
|
||||||
|
// Lenke-deteksjon for --strict-ingest: hvert lenke-maal maa vaere en trygg,
|
||||||
|
// on-disk bundle-root-relativ .md (anti-RAG-poison). B3 (A1): ALLE fire
|
||||||
|
// standard lenkeformer fanges -- inline, referanse-definisjon, autolink og
|
||||||
|
// raa HTML-anker -- ikke bare inline (de tre siste lakk foer A1).
|
||||||
|
const MD_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g;
|
||||||
|
const REF_DEF_RE = /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(\S+)/gm;
|
||||||
|
const AUTOLINK_RE = /<([a-z][a-z0-9+.-]*:[^<>\s]*)>/gi;
|
||||||
|
const HTML_HREF_RE = /<a\b[^>]*\bhref\s*=\s*["']?([^"'\s>]+)/gi;
|
||||||
|
const LINK_FORMS = [MD_LINK_RE, REF_DEF_RE, AUTOLINK_RE, HTML_HREF_RE];
|
||||||
|
// 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.
|
// Alle konsept-filer (.md unntatt index.md) under root, rekursivt.
|
||||||
function walkConcepts(root) {
|
function walkConcepts(root) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const walk = (dir) => {
|
const walk = (dir) => {
|
||||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
const p = join(dir, e.name);
|
const p = join(dir, e.name);
|
||||||
if (e.isDirectory()) walk(p);
|
if (e.isDirectory()) {
|
||||||
else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') out.push(p);
|
if (isWalkableDir(e.name)) walk(p);
|
||||||
|
} else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') {
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(root);
|
walk(root);
|
||||||
|
|
@ -40,17 +63,50 @@ function rootOkfVersion(root) {
|
||||||
return m ? m[1].trim() : null;
|
return m ? m[1].trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkBundle(root) {
|
// strictIngest (default AV): paa skrivestien handheves det lukkede vokabularet
|
||||||
const concepts = walkConcepts(root);
|
// + lenke-allow-listen. Lese-siden (default) forblir tolerant (exit 0/1 kun paa
|
||||||
|
// manglende type) -- denne porten er aktiv KUN ved ingestion (--strict-ingest).
|
||||||
|
// files (B2, A1): orkestratoren scoper strict til KUN kjoeringens skrevne filer
|
||||||
|
// (writeConcepts-returens {concepts, pointers}) -- ALDRI hele roten, som ville
|
||||||
|
// felt legitimt haandkuratert innhold (lovlig out-of-vocab/eksterne lenker paa
|
||||||
|
// lese-siden). Uten files: full-root-walk som foer (CLI/lese-siden).
|
||||||
|
export function checkBundle(root, { strictIngest = false, files } = {}) {
|
||||||
|
const concepts = Array.isArray(files)
|
||||||
|
? files.map((f) => resolve(root, f))
|
||||||
|
: walkConcepts(root);
|
||||||
const missingType = [];
|
const missingType = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
const strictErrors = [];
|
||||||
for (const f of concepts) {
|
for (const f of concepts) {
|
||||||
const { get } = parseFrontmatter(readFileSync(f, 'utf8'));
|
const raw = readFileSync(f, 'utf8');
|
||||||
|
const { get } = parseFrontmatter(raw);
|
||||||
const rel = relative(root, f);
|
const rel = relative(root, f);
|
||||||
if (!get('type')) {
|
const type = get('type');
|
||||||
|
if (!type) {
|
||||||
missingType.push(rel);
|
missingType.push(rel);
|
||||||
continue;
|
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. hvert lenke-maal (alle fire lenkeformer, B3) maa resolvere til en
|
||||||
|
// trygg, on-disk bundle-fil.
|
||||||
|
for (const re of LINK_FORMS) {
|
||||||
|
for (const m of raw.matchAll(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) {
|
for (const field of RECOMMENDED) {
|
||||||
if (!get(field)) warnings.push(`${rel}: mangler anbefalt felt «${field}»`);
|
if (!get(field)) warnings.push(`${rel}: mangler anbefalt felt «${field}»`);
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +115,8 @@ export function checkBundle(root) {
|
||||||
scanned: concepts.length,
|
scanned: concepts.length,
|
||||||
missingType,
|
missingType,
|
||||||
warnings,
|
warnings,
|
||||||
|
strictErrors,
|
||||||
|
strictIngest,
|
||||||
okfVersion: rootOkfVersion(root),
|
okfVersion: rootOkfVersion(root),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -67,25 +125,36 @@ export function checkBundle(root) {
|
||||||
const isMain = process.argv[1]
|
const isMain = process.argv[1]
|
||||||
&& fileURLToPath(import.meta.url) === process.argv[1];
|
&& fileURLToPath(import.meta.url) === process.argv[1];
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
const root = process.argv[2];
|
const args = process.argv.slice(2);
|
||||||
|
const strictIngest = args.includes('--strict-ingest');
|
||||||
|
const root = args.find((a) => !a.startsWith('--'));
|
||||||
if (!root) {
|
if (!root) {
|
||||||
process.stderr.write('Bruk: node okf-check.mjs <bundle-rot>\n');
|
process.stderr.write('Bruk: node okf-check.mjs <bundle-rot> [--strict-ingest]\n');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
if (!existsSync(root)) {
|
if (!existsSync(root)) {
|
||||||
process.stderr.write(`Bundle-rot finnes ikke: ${root}\n`);
|
process.stderr.write(`Bundle-rot finnes ikke: ${root}\n`);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const r = checkBundle(root);
|
const r = checkBundle(root, { strictIngest });
|
||||||
const out = [];
|
const out = [];
|
||||||
out.push(`OKF-sjekk: ${root}`);
|
out.push(`OKF-sjekk: ${root}${strictIngest ? ' (strict-ingest)' : ''}`);
|
||||||
out.push(` Konsept-filer skannet: ${r.scanned}`);
|
out.push(` Konsept-filer skannet: ${r.scanned}`);
|
||||||
out.push(` ${r.missingType.length} filer uten type:`);
|
out.push(` ${r.missingType.length} filer uten type:`);
|
||||||
for (const f of r.missingType) out.push(` - ${f}`);
|
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(` okf_version: ${r.okfVersion || 'MANGLER (rot-index uten okf_version)'}`);
|
||||||
out.push(` Advarsler (anbefalte felt): ${r.warnings.length}`);
|
out.push(` Advarsler (anbefalte felt): ${r.warnings.length}`);
|
||||||
for (const w of r.warnings) out.push(` ! ${w}`);
|
for (const w of r.warnings) out.push(` ! ${w}`);
|
||||||
out.push(r.missingType.length === 0 ? 'OK: gyldig OKF-bundle' : `FEIL: ${r.missingType.length} fil(er) mangler type:`);
|
const failed = r.missingType.length > 0 || (strictIngest && r.strictErrors.length > 0);
|
||||||
|
let verdict;
|
||||||
|
if (!failed) verdict = 'OK: gyldig OKF-bundle';
|
||||||
|
else if (r.missingType.length > 0) verdict = `FEIL: ${r.missingType.length} fil(er) mangler type:`;
|
||||||
|
else verdict = `FEIL: ${r.strictErrors.length} strict-ingest-feil`;
|
||||||
|
out.push(verdict);
|
||||||
process.stdout.write(`${out.join('\n')}\n`);
|
process.stdout.write(`${out.join('\n')}\n`);
|
||||||
process.exit(r.missingType.length === 0 ? 0 : 1);
|
process.exit(failed ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
||||||
|
|
||||||
export const OKF_VERSION = 'kb-layout-2026-06';
|
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"
|
// "strategisk-kontekst" -> "Strategisk kontekst"
|
||||||
function titleFromName(name) {
|
function titleFromName(name) {
|
||||||
const spaced = name.replace(/[-_]+/g, ' ').trim();
|
const spaced = name.replace(/[-_]+/g, ' ').trim();
|
||||||
|
|
@ -49,16 +56,37 @@ function parseExistingIndex(path) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bygg en enkelt entry-linje paa OKF-form. Tom beskrivelse -> dropp ` - d`.
|
// Saner en frontmatter-avledet tittel/beskrivelse for trygg, idempotent emit:
|
||||||
|
// noytraliser markdown-lenker (RAG-injeksjon), strip kontrolltegn + strooe ]/)
|
||||||
|
// som ville korrumpert round-trip-parsen (parseExistingIndex), kollaps whitespace,
|
||||||
|
// og cap lengden. Idempotent: sanitizeEntry(sanitizeEntry(x)) === sanitizeEntry(x).
|
||||||
|
function sanitizeEntry(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/[\x00-\x1f\x7f]/g, ' ') // kontrolltegn -> mellomrom
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // noytraliser markdown-lenker (behold tekst)
|
||||||
|
.replace(/[\])]/g, '') // strip strooe ] ) som brekker round-trip
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bygg en enkelt entry-linje paa OKF-form. Tittel/beskrivelse saneres (link
|
||||||
|
// forblir uroert -- kontrollert filnavn). Tom beskrivelse -> dropp ` - d`.
|
||||||
function entryLine(title, link, desc) {
|
function entryLine(title, link, desc) {
|
||||||
return desc ? `* [${title}](${link}) - ${desc}` : `* [${title}](${link})`;
|
const t = sanitizeEntry(title);
|
||||||
|
const d = sanitizeEntry(desc);
|
||||||
|
return d ? `* [${t}](${link}) - ${d}` : `* [${t}](${link})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer okf_version.
|
// Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer okf_version.
|
||||||
function writeIndexFor(dir, isRoot, okfVersion) {
|
function writeIndexFor(dir, isRoot, okfVersion) {
|
||||||
const existing = parseExistingIndex(join(dir, 'index.md'));
|
const existing = parseExistingIndex(join(dir, 'index.md'));
|
||||||
const dirents = readdirSync(dir, { withFileTypes: true });
|
const dirents = readdirSync(dir, { withFileTypes: true });
|
||||||
const subdirs = dirents.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
const subdirs = dirents
|
||||||
|
.filter((e) => e.isDirectory() && isWalkableDir(e.name))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort();
|
||||||
const concepts = dirents
|
const concepts = dirents
|
||||||
.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md')
|
.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md')
|
||||||
.map((e) => e.name)
|
.map((e) => e.name)
|
||||||
|
|
@ -95,7 +123,7 @@ export function generateIndexes(root, opts = {}) {
|
||||||
const walk = (dir, isRoot) => {
|
const walk = (dir, isRoot) => {
|
||||||
writeIndexFor(dir, isRoot, okfVersion);
|
writeIndexFor(dir, isRoot, okfVersion);
|
||||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
if (e.isDirectory()) walk(join(dir, e.name), false);
|
if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`);
|
if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: okr-offentlig-sektor
|
name: okr-offentlig-sektor
|
||||||
description: >-
|
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".
|
OKR (Objectives and Key Results) for Norwegian public sector: writing OKR, reviewing OKR quality, cascading OKR from strategy to team, tracking progress, running OKR meetings, translating tildelingsbrev to OKR. Also CFR, OKR antipatterns, scoring, Oboard. Triggers on: "OKR", "skriv OKR", "vurder OKR", "OKR-scoring", "kaskadere OKR", "tildelingsbrev til OKR", "OKR for offentlig sektor".
|
||||||
version: "1.6.0"
|
version: "1.6.1"
|
||||||
---
|
---
|
||||||
|
|
||||||
# OKR Skill for Offentlig Sektor (Norge)
|
# OKR Skill for Offentlig Sektor (Norge)
|
||||||
|
|
@ -29,16 +29,16 @@ Objective: [Verb] + [clear outcome/improvement]
|
||||||
**Example**:
|
**Example**:
|
||||||
```
|
```
|
||||||
Objective: Forbedre trafikksikkerhet i skolesoner
|
Objective: Forbedre trafikksikkerhet i skolesoner
|
||||||
KR1: Redusere ulykker i skolesoner med 25% (fra 40 til 30 per ar)
|
KR1: Redusere ulykker i skolesoner med 25% (fra 40 til 30 per år)
|
||||||
KR2: 100% av hoyrisiko-skolesoner far nye fartshumper innen august
|
KR2: 100% av høyrisiko-skolesoner får nye fartshumper innen august
|
||||||
KR3: 90% av foreldre vurderer skolesoner som trygge (via sporreundersokelse)
|
KR3: 90% av foreldre vurderer skolesoner som trygge (via spørreundersøkelse)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Review OKR Quality
|
### 2. Review OKR Quality
|
||||||
|
|
||||||
When users present existing OKR, evaluate against these criteria and provide concrete rewrites:
|
When users present existing OKR, evaluate against these criteria and provide concrete rewrites:
|
||||||
- **Good**: Outcome-focused, measurable, ambitious but achievable, clear strategy link
|
- **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
|
### 3. Track Progress
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ Key Results:
|
||||||
├─ KR1: Reduser ulykker med personskade
|
├─ KR1: Reduser ulykker med personskade
|
||||||
│ ├─ Start: 45 ulykker/år
|
│ ├─ Start: 45 ulykker/år
|
||||||
│ ├─ Target: 25 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
|
│ ├─ Confidence: 🟡 At Risk
|
||||||
│ └─ Owner: Lena Hansen
|
│ └─ Owner: Lena Hansen
|
||||||
│
|
│
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,105 @@
|
||||||
# OKR-kvalitetsrubrikker — ankret scoringsguide
|
# 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
|
## Objective-dimensjoner
|
||||||
|
|
||||||
### Inspirerende
|
### 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.
|
1. **Anker 1 (svakest)** — Kjedelig eller rent byråkratisk; ingen blir engasjert av formuleringen.
|
||||||
2. **Anker 2** — Toert og oppgavepreget; saklig, men vekker ikke eierskap.
|
2. **Anker 2** — Tørt og oppgavepreget; saklig, men vekker ikke eierskap.
|
||||||
3. **Anker 3** — Noeytralt; akseptabelt, men hverken loefter eller demotiverer.
|
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.
|
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.
|
5. **Anker 5 (sterkest)** — Motiverer hele teamet; kommuniserer mening og ambisjon, og folk husker det.
|
||||||
|
|
||||||
### Klarhet
|
### 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.
|
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.
|
3. **Anker 3** — Noe vagt, men hovedretningen anes.
|
||||||
4. **Anker 4** — Stort sett entydig; kun smaa tolkningsrom igjen.
|
4. **Anker 4** — Stort sett entydig; kun små tolkningsrom igjen.
|
||||||
5. **Anker 5 (sterkest)** — Entydig retning; alle som leser det forstaar det samme.
|
5. **Anker 5 (sterkest)** — Entydig retning; alle som leser det forstår det samme.
|
||||||
|
|
||||||
### Outcome-fokus
|
### 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.
|
2. **Anker 2** — Overveiende aktivitet med en vag henvisning til effekt.
|
||||||
3. **Anker 3** — Blanding av aktivitet og resultat.
|
3. **Anker 3** — Blanding av aktivitet og resultat.
|
||||||
4. **Anker 4** — Overveiende resultat, med en gjenvaerende aktivitetsrest.
|
4. **Anker 4** — Overveiende resultat, med en gjenværende aktivitetsrest.
|
||||||
5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnaa, ikke veien dit.
|
5. **Anker 5 (sterkest)** — Rent outcome; beskriver tilstanden vi vil oppnå, ikke veien dit.
|
||||||
|
|
||||||
### Scope
|
### 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.
|
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.
|
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
|
### 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.
|
1. **Anker 1 (svakest)** — Ingen kobling til overordnet mål eller tildelingsbrev.
|
||||||
2. **Anker 2** — Kobling paastaaes, men kan ikke spores til et konkret overordnet maal.
|
2. **Anker 2** — Kobling påstås, men kan ikke spores til et konkret overordnet mål.
|
||||||
3. **Anker 3** — Implisitt kobling oppover; leseren maa selv slutte sammenhengen.
|
3. **Anker 3** — Implisitt kobling oppover; leseren må selv slutte sammenhengen.
|
||||||
4. **Anker 4** — Tydelig koblet, men ikke eksplisitt sitert.
|
4. **Anker 4** — Tydelig koblet, men ikke eksplisitt sitert.
|
||||||
5. **Anker 5 (sterkest)** — Tydelig og eksplisitt koblet til navngitt org-OKR eller tildelingsbrevspunkt.
|
5. **Anker 5 (sterkest)** — Tydelig og eksplisitt koblet til navngitt org-OKR eller tildelingsbrevspunkt.
|
||||||
|
|
||||||
## Key Result-dimensjoner
|
## Key Result-dimensjoner
|
||||||
|
|
||||||
### Maalbarhet
|
### Målbarhet
|
||||||
Maaler om Key Result-et har konkrete tall med baseline og target.
|
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.
|
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).
|
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.
|
5. **Anker 5 (sterkest)** — Tall med tydelig baseline -> target og entydig enhet.
|
||||||
|
|
||||||
### Outcome
|
### 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.
|
2. **Anker 2** — Output brukt som svak proxy for et udokumentert resultat.
|
||||||
3. **Anker 3** — Blanding av output og outcome.
|
3. **Anker 3** — Blanding av output og outcome.
|
||||||
4. **Anker 4** — Overveiende outcome, med en mindre output-rest.
|
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
|
### 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.
|
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.
|
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
|
### 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.
|
1. **Anker 1 (svakest)** — Ukjent datakilde; ingen vet hvor tallet skal hentes fra.
|
||||||
2. **Anker 2** — Datakilde antydet, men ikke bekreftet tilgjengelig.
|
2. **Anker 2** — Datakilde antydet, men ikke bekreftet tilgjengelig.
|
||||||
3. **Anker 3** — Datakilde antas tilgjengelig, men er ikke verifisert.
|
3. **Anker 3** — Datakilde antas tilgjengelig, men er ikke verifisert.
|
||||||
4. **Anker 4** — Spesifisert kilde med mindre usikkerhet om maalefrekvens eller tilgang.
|
4. **Anker 4** — Spesifisert kilde med mindre usikkerhet om målefrekvens eller tilgang.
|
||||||
5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent maalefrekvens.
|
5. **Anker 5 (sterkest)** — Spesifisert OG tilgjengelig kilde med kjent målefrekvens.
|
||||||
|
|
||||||
### Uavhengighet
|
### 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.
|
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.
|
3. **Anker 3** — Delvis avhengig av andre.
|
||||||
4. **Anker 4** — Stort sett innenfor teamets kontroll, med en mindre ekstern avhengighet.
|
4. **Anker 4** — Stort sett innenfor teamets kontroll, med en mindre ekstern avhengighet.
|
||||||
5. **Anker 5 (sterkest)** — Teamet kontrollerer utfallet direkte.
|
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*
|
*Sist oppdatert: Juni 2026*
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ description: >-
|
||||||
prompt. Triggers on: "våre mål", "overordnede mål", "mål dette tertialet",
|
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",
|
"tertialmål", "tildelingsbrev", "hva sier OKR-ene våre", "forrige syklus",
|
||||||
"strategi", "styringssignaler".
|
"strategi", "styringssignaler".
|
||||||
version: "1.6.0"
|
version: "1.6.1"
|
||||||
---
|
---
|
||||||
|
|
||||||
# OKR Second-Brain Search
|
# OKR Second-Brain Search
|
||||||
|
|
@ -81,13 +81,14 @@ navigate and to break ranking ties (below).
|
||||||
|
|
||||||
## Retrieval procedure
|
## Retrieval procedure
|
||||||
|
|
||||||
Adopt the OKF `kb-search` triad (`list_contents` / `read_file` / `search_content`
|
Use the native file tools as a retrieval triad — **Glob** (list) / **Read** (open)
|
||||||
≡ Glob / Read / Grep) together with the `discovery` quality pattern (semantic
|
/ **Grep** (search) — together with semantic decomposition (the `discovery`
|
||||||
decomposition). Concretely:
|
pattern: expand the query before searching rather than grepping verbatim). Concretely:
|
||||||
|
|
||||||
1. **Locate & navigate.** Glob both roots — `.claude/okr/**/*.md` and
|
1. **Locate & navigate.** Glob both roots — the project root `.claude/okr/**/*.md`,
|
||||||
`~/.claude/okr/org/**/*.md`. Read each level's `index.md` first to understand
|
and the home root `<HOME>/.claude/okr/org/**/*.md` (expand `~` to the absolute
|
||||||
what exists before reading any concept file.
|
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
|
2. **Semantic decomposition** — *do not* grep the user's words verbatim. Generate
|
||||||
**up to 3 query variations** and search all of them:
|
**up to 3 query variations** and search all of them:
|
||||||
- **(i) Direct + synonyms** — the literal term plus close synonyms
|
- **(i) Direct + synonyms** — the literal term plus close synonyms
|
||||||
|
|
|
||||||
7
tests/fixtures/inbox-converted/dok-a.md
vendored
Normal file
7
tests/fixtures/inbox-converted/dok-a.md
vendored
Normal 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-converted/dok-b.md
vendored
Normal file
7
tests/fixtures/inbox-converted/dok-b.md
vendored
Normal 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
7
tests/fixtures/inbox-sample/dok-a.txt
vendored
Normal 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
7
tests/fixtures/inbox-sample/dok-b.txt
vendored
Normal 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.
|
||||||
BIN
tests/fixtures/inbox-sample/dok.docx
vendored
Normal file
BIN
tests/fixtures/inbox-sample/dok.docx
vendored
Normal file
Binary file not shown.
11
tests/fixtures/inbox-sample/dok.eml
vendored
Normal file
11
tests/fixtures/inbox-sample/dok.eml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
From: styringsstab@etat.no
|
||||||
|
To: okr-team@etat.no
|
||||||
|
Subject: Statusoppdatering T2
|
||||||
|
Date: Mon, 18 May 2026 09:00:00 +0200
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Fremdrift paa maaltallene for andre tertial.
|
||||||
|
|
||||||
|
KR1 ligger paa 0.6 etter maai. KR2 er i rute. Vi maa diskutere
|
||||||
|
ambisjonsnivaaet for KR3 paa neste check-in.
|
||||||
10
tests/fixtures/inbox-sample/dok.pdf
vendored
Normal file
10
tests/fixtures/inbox-sample/dok.pdf
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
%PDF-1.4
|
||||||
|
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||||
|
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||||
|
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
|
||||||
|
4 0 obj<</Length 55>>stream
|
||||||
|
BT /F1 12 Tf 72 720 Td (Tildelingsbrev 2026 for etaten) Tj ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||||
|
trailer<</Root 1 0 R/Size 6>>
|
||||||
|
|
@ -107,3 +107,29 @@ test('writeFrontmatter: verdi med "#" siteres og round-tripper', () => {
|
||||||
const { get } = parseFrontmatter(block);
|
const { get } = parseFrontmatter(block);
|
||||||
assert.equal(get('kortform'), 'A #B', 'sitert # round-tripper');
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
140
tests/innboks-convert.test.mjs
Normal file
140
tests/innboks-convert.test.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// innboks-convert.test.mjs
|
||||||
|
// Step 11 (SC format): konverterings-adaptere txt/md/docx/eml/pdf -> markdown.
|
||||||
|
// Verifiserer:
|
||||||
|
// - .txt/.md -> markdown alltid (zero-dep-sti, node:-builtins)
|
||||||
|
// - eksterne lenker NOEYTRALISERES ved konvertering (inline/ref-def/autolink/
|
||||||
|
// HTML-anker/bilde): lenketeksten bevares, scheme-maalet droppes -- slik at
|
||||||
|
// strict-gaten (B3/F2) aldri felles av legitim konvertert output
|
||||||
|
// - .docx/.eml -> heading-STRUKTUR bevart ('# '-linjer, atx -- IKKE byte-snapshot);
|
||||||
|
// .pdf -> tekst ekstrahert (flat, dokumentert v1-caveat M5)
|
||||||
|
// - convert-twice-identical: samme binaerfil konvertert 2x gir byte-identisk
|
||||||
|
// markdown (konverterings-determinismen idempotensen hviler paa)
|
||||||
|
// - ukjent extension (.xlsx) -> null + norsk notice, INGEN throw
|
||||||
|
// Binaer-adapterne kjoerer conditional { skip: !engines } (plugin kan vaere
|
||||||
|
// installert uten node_modules -- npm-deps er dokumentert prerequisite).
|
||||||
|
// Moenster: node:test conditional skip; fixtures under tests/fixtures/inbox-sample/.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { convert } from '../lib/convert/index.mjs';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const SAMPLE = join(ROOT, 'tests', 'fixtures', 'inbox-sample');
|
||||||
|
|
||||||
|
// Er binaer-konverterings-motorene installert? (npm-deps = dokumentert prerequisite.)
|
||||||
|
async function enginesAvailable() {
|
||||||
|
try {
|
||||||
|
await import('mammoth');
|
||||||
|
await import('turndown');
|
||||||
|
await import('postal-mime');
|
||||||
|
await import('unpdf');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const engines = await enginesAvailable();
|
||||||
|
|
||||||
|
// Temp-katalog for haandlagde input-filer. Ryddes alltid.
|
||||||
|
function withTmp(fn) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'innboks-convert-'));
|
||||||
|
try {
|
||||||
|
return fn(dir);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('.txt konverteres alltid (zero-dep): innhold bevart som markdown', async () => {
|
||||||
|
const md = await convert(join(SAMPLE, 'dok-a.txt'));
|
||||||
|
assert.equal(typeof md, 'string');
|
||||||
|
assert.ok(md.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.md passthrough: markdown-struktur bevart', async () => {
|
||||||
|
await withTmp(async (dir) => {
|
||||||
|
const p = join(dir, 'notat.md');
|
||||||
|
writeFileSync(p, '# Tittel\n\nAvsnitt.\n\n## Under\n\nMer.\n');
|
||||||
|
const md = await convert(p);
|
||||||
|
assert.match(md, /^# Tittel$/m);
|
||||||
|
assert.match(md, /^## Under$/m);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eksterne lenker noeytraliseres: inline/ref-def/autolink/HTML/bilde', async () => {
|
||||||
|
await withTmp(async (dir) => {
|
||||||
|
const p = join(dir, 'fiendtlig.txt');
|
||||||
|
writeFileSync(
|
||||||
|
p,
|
||||||
|
'# Notat\n\n'
|
||||||
|
+ 'Se [rapporten](https://evil.example/exfil) og [vedlegget][r].\n\n'
|
||||||
|
+ '[r]: https://evil.example/ref\n\n'
|
||||||
|
+ '<https://evil.example/auto>\n\n'
|
||||||
|
+ '<a href="file:///etc/passwd">passord</a>\n\n'
|
||||||
|
+ '\n\n'
|
||||||
|
+ 'Intern relasjon: [Notat](/dokumenter/notat.md) beholdes.\n',
|
||||||
|
);
|
||||||
|
const md = await convert(p);
|
||||||
|
// Lenketekst bevart:
|
||||||
|
assert.match(md, /rapporten/);
|
||||||
|
assert.match(md, /vedlegget/);
|
||||||
|
assert.match(md, /passord/);
|
||||||
|
assert.match(md, /skjermbilde/);
|
||||||
|
// Ingen lenke-FORM med eksterne maal igjen (inline, ref-def, autolink, HTML):
|
||||||
|
assert.doesNotMatch(md, /\]\(https?:/);
|
||||||
|
assert.doesNotMatch(md, /^\s{0,3}\[[^\]]+\]:/m);
|
||||||
|
assert.doesNotMatch(md, /<https?:/);
|
||||||
|
assert.doesNotMatch(md, /<a\s/i);
|
||||||
|
assert.doesNotMatch(md, /!\[/);
|
||||||
|
assert.doesNotMatch(md, /file:\/\//);
|
||||||
|
// Trygg bundle-root-relativ .md-lenke beholdes (relasjons-formatet):
|
||||||
|
assert.match(md, /\[Notat\]\(\/dokumenter\/notat\.md\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.docx -> markdown med atx-heading-struktur (ikke byte-snapshot)', { skip: !engines }, async () => {
|
||||||
|
const md = await convert(join(SAMPLE, 'dok.docx'));
|
||||||
|
assert.equal(typeof md, 'string');
|
||||||
|
// M5/F4: {headingStyle:'atx'} er pinnet -> '#'-headings, aldri setext.
|
||||||
|
assert.match(md, /^# /m);
|
||||||
|
assert.match(md, /^## /m);
|
||||||
|
assert.doesNotMatch(md, /^=+$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.eml -> markdown: subject som heading + body-tekst (kun body, vedlegg = non-goal)', { skip: !engines }, async () => {
|
||||||
|
const md = await convert(join(SAMPLE, 'dok.eml'));
|
||||||
|
assert.match(md, /^# Statusoppdatering T2/m);
|
||||||
|
assert.match(md, /Fremdrift paa maaltallene/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.pdf -> tekst ekstrahert (flat markdown, isEvalSupported:false-sti)', { skip: !engines }, async () => {
|
||||||
|
const md = await convert(join(SAMPLE, 'dok.pdf'));
|
||||||
|
assert.equal(typeof md, 'string');
|
||||||
|
assert.match(md, /Tildelingsbrev 2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert-twice-identical: docx og pdf gir byte-identisk markdown', { skip: !engines }, async () => {
|
||||||
|
for (const f of ['dok.docx', 'dok.pdf']) {
|
||||||
|
const first = await convert(join(SAMPLE, f));
|
||||||
|
const second = await convert(join(SAMPLE, f));
|
||||||
|
assert.equal(first, second, `ikke-deterministisk konvertering: ${f}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ukjent extension (.xlsx) -> null + norsk notice, ingen throw', async () => {
|
||||||
|
await withTmp(async (dir) => {
|
||||||
|
const p = join(dir, 'regneark.xlsx');
|
||||||
|
writeFileSync(p, 'ikke egentlig xlsx');
|
||||||
|
const notices = [];
|
||||||
|
const md = await convert(p, { onNotice: (msg) => notices.push(msg) });
|
||||||
|
assert.equal(md, null);
|
||||||
|
assert.equal(notices.length, 1);
|
||||||
|
assert.match(notices[0], /hopper over/);
|
||||||
|
assert.match(notices[0], /regneark\.xlsx/);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
tests/innboks-frontmatter.test.mjs
Normal file
153
tests/innboks-frontmatter.test.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- M3 (A1): deriveType matcher kun basename + ord-grenser, aldri full sti ---
|
||||||
|
|
||||||
|
test('projectFrontmatter: /okr/-segment i full sti gir IKKE type OKR (M3)', () => {
|
||||||
|
const concept = { sourceSlug: 'notat', title: 'Handlingsplan', slug: 'handlingsplan', level: 1, body: 'x' };
|
||||||
|
const e = projectFrontmatter(concept, {
|
||||||
|
sourcePath: '/x/.claude/okr/y/notat.md',
|
||||||
|
sourceMtime: MTIME,
|
||||||
|
});
|
||||||
|
const type = parseFrontmatter(e.frontmatter).get('type');
|
||||||
|
assert.notEqual(type, 'OKR', 'sti-segmentet /okr/ skal ikke forgifte type-utledningen');
|
||||||
|
assert.equal(type, 'Notat', 'basename notat.md gir Notat (ord-grense mot punktum)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projectFrontmatter: kompound-ord i title matcher ikke vokab-term (Statusnotat != Status)', () => {
|
||||||
|
const concept = { sourceSlug: 'opps', title: 'Statusnotat mai', slug: 'statusnotat-mai', level: 1, body: 'x' };
|
||||||
|
const e = projectFrontmatter(concept, { sourcePath: 'innboks/oppsummering.txt', sourceMtime: MTIME });
|
||||||
|
assert.equal(
|
||||||
|
parseFrontmatter(e.frontmatter).get('type'),
|
||||||
|
'Dokument',
|
||||||
|
'kompound-treff skal ikke snappe til Status -- safe default Dokument',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projectFrontmatter: helt ord i title matcher fortsatt (Status for KR -> Status)', () => {
|
||||||
|
const concept = { sourceSlug: 's', title: 'Status for KR', slug: 'status-for-kr', level: 1, body: 'x' };
|
||||||
|
const e = projectFrontmatter(concept, { sourcePath: 'innboks/s.txt', sourceMtime: MTIME });
|
||||||
|
assert.equal(parseFrontmatter(e.frontmatter).get('type'), 'Status');
|
||||||
|
});
|
||||||
246
tests/innboks-ingest.test.mjs
Normal file
246
tests/innboks-ingest.test.mjs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
// innboks-ingest.test.mjs
|
||||||
|
// Step 12 (A2): end-to-end-test av pipeline-orkestratoren scripts/innboks-ingest.mjs.
|
||||||
|
// Kjoeres i mkdtempSync UTENFOR .claude/ med innboksen NESTET under bundle-rota
|
||||||
|
// (<tmp>/innboks inni <tmp>) saa walk-skippet ekserseres end-to-end. Verifiserer
|
||||||
|
// (plan.md Step 12, verifies 1-8):
|
||||||
|
// (1) idempotens: 2 kjoeringer med ULIK veggklokke -> sha256-manifest deepEqual
|
||||||
|
// (by-construction-determinisme via original-mtime, ikke cache)
|
||||||
|
// (2) konformitet: okf-check subprosess BAADE default OG --strict-ingest exit 0
|
||||||
|
// (3) index-integritet: generateIndexes 2x byte-identisk OG rot-index lister
|
||||||
|
// ikke innboks/
|
||||||
|
// (4) original-bevaring: drop-zone-originalene er byte-uendret (sha256)
|
||||||
|
// (5) >= 1 relasjon: kryss-dokument tittel-omtale (dok-a -> dok-b) emitteres
|
||||||
|
// som bundle-root-relativ .md-lenke (SC-regex)
|
||||||
|
// (6) .txt-only full-pipeline kjoerer groent zero-dep (ingen npm-deps)
|
||||||
|
// (7) strict-scoping (B2, laast A0): pre-eksisterende kuratert fil med ekstern
|
||||||
|
// lenke + out-of-vocab-type feller IKKE kjoeringen; kuratert fil uroert
|
||||||
|
// (8) per-dokument-staging/gate (laast A0): multi-doc-drop der ett dok feiler
|
||||||
|
// gaten -> KUN det dokumentet discardes (ingen spor i tre/index), det
|
||||||
|
// andre bestaar
|
||||||
|
// Binaer-originaler (.docx/.pdf) testes conditional { skip: !engines } -- B1-
|
||||||
|
// aksept: en ren kjoering med ikke-.md-original passerer strict.
|
||||||
|
// Moenster: tests/okf-check.test.mjs (subprosess-exit) + innboks-write.test.mjs.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync,
|
||||||
|
existsSync, rmSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { ingestInbox } from '../scripts/innboks-ingest.mjs';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const CHECK = join(ROOT, 'scripts', 'okf-check.mjs');
|
||||||
|
const SAMPLE = join(ROOT, 'tests', 'fixtures', 'inbox-sample');
|
||||||
|
|
||||||
|
// Er binaer-konverterings-motorene installert? (npm-deps = dokumentert prerequisite.)
|
||||||
|
async function enginesAvailable() {
|
||||||
|
try {
|
||||||
|
await import('mammoth');
|
||||||
|
await import('turndown');
|
||||||
|
await import('unpdf');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const engines = await enginesAvailable();
|
||||||
|
|
||||||
|
// Temp-bundle: bundle-rot = <tmp>, drop-zone = <tmp>/innboks (nestet, som prod).
|
||||||
|
async function withBundle(fn) {
|
||||||
|
const bundleRoot = mkdtempSync(join(tmpdir(), 'okringest-'));
|
||||||
|
const inbox = join(bundleRoot, 'innboks');
|
||||||
|
mkdirSync(inbox, { recursive: true });
|
||||||
|
try {
|
||||||
|
await fn({ bundleRoot, inbox });
|
||||||
|
} finally {
|
||||||
|
rmSync(bundleRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buf) {
|
||||||
|
return createHash('sha256').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rekursivt sha256-manifest av bundlen: relativ sti -> hash (deterministisk sortert).
|
||||||
|
function manifest(root) {
|
||||||
|
const out = {};
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const p = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) walk(p);
|
||||||
|
else out[relative(root, p)] = sha256(readFileSync(p));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle .md-filer under root (rekursivt), relativ sti.
|
||||||
|
function mdFiles(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.name.endsWith('.md')) out.push(relative(root, p));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || ''}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropTxtFixtures(inbox) {
|
||||||
|
copyFileSync(join(SAMPLE, 'dok-a.txt'), join(inbox, 'dok-a.txt'));
|
||||||
|
copyFileSync(join(SAMPLE, 'dok-b.txt'), join(inbox, 'dok-b.txt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ingest: .txt-only full pipeline zero-dep + konformitet + relasjon + original-bevaring (1,2,4,5,6)', async () => {
|
||||||
|
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||||
|
dropTxtFixtures(inbox);
|
||||||
|
const originalsBefore = {
|
||||||
|
a: sha256(readFileSync(join(inbox, 'dok-a.txt'))),
|
||||||
|
b: sha256(readFileSync(join(inbox, 'dok-b.txt'))),
|
||||||
|
};
|
||||||
|
|
||||||
|
const run1 = await ingestInbox(inbox, bundleRoot);
|
||||||
|
assert.equal(run1.failed.length, 0, `ingen feilede dokumenter: ${JSON.stringify(run1.failed)}`);
|
||||||
|
assert.equal(run1.ingested.length, 2, 'begge .txt-dokumenter ingested');
|
||||||
|
const m1 = manifest(bundleRoot);
|
||||||
|
|
||||||
|
// (1) Idempotens: run2 paa ULIK veggklokke -> byte-identisk bundle.
|
||||||
|
const run2 = await ingestInbox(inbox, bundleRoot);
|
||||||
|
assert.equal(run2.failed.length, 0, 'run2 feiler ikke');
|
||||||
|
assert.deepEqual(manifest(bundleRoot), m1, 'sha256-manifest run1 vs run2 identisk (idempotens by construction)');
|
||||||
|
|
||||||
|
// (2) Konformitet: default OG strict-ingest exit 0 (subprosess, kontrakt).
|
||||||
|
assert.equal(runCheck(bundleRoot).status, 0, 'okf-check default exit 0');
|
||||||
|
assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'okf-check --strict-ingest exit 0');
|
||||||
|
|
||||||
|
// (4) Original-bevaring: drop-zonen er byte-uendret.
|
||||||
|
assert.equal(sha256(readFileSync(join(inbox, 'dok-a.txt'))), originalsBefore.a, 'dok-a.txt uendret');
|
||||||
|
assert.equal(sha256(readFileSync(join(inbox, 'dok-b.txt'))), originalsBefore.b, 'dok-b.txt uendret');
|
||||||
|
|
||||||
|
// (5) >= 1 relasjon: kryss-dokument tittel-omtale emitteres som trygg lenke.
|
||||||
|
const all = mdFiles(bundleRoot)
|
||||||
|
.map((f) => readFileSync(join(bundleRoot, f), 'utf8'))
|
||||||
|
.join('\n');
|
||||||
|
assert.match(all, /\]\(\/[^)]*\.md\)/, 'minst en bundle-root-relativ .md-relasjon emittert');
|
||||||
|
assert.match(all, /## Relaterte dokumenter/, 'relasjons-seksjon skrevet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingest: index-integritet -- regen byte-identisk + rot-index lister ikke innboks/ (3)', async () => {
|
||||||
|
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||||
|
dropTxtFixtures(inbox);
|
||||||
|
await ingestInbox(inbox, bundleRoot);
|
||||||
|
|
||||||
|
const rootIndex = readFileSync(join(bundleRoot, 'index.md'), 'utf8');
|
||||||
|
assert.doesNotMatch(rootIndex, /innboks/, 'rot-index lister ikke drop-zonen');
|
||||||
|
|
||||||
|
const { generateIndexes } = await import('../scripts/okf-index.mjs');
|
||||||
|
const before = manifest(bundleRoot);
|
||||||
|
generateIndexes(bundleRoot);
|
||||||
|
assert.deepEqual(manifest(bundleRoot), before, 'generateIndexes re-kjoert er byte-identisk');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingest: strict-gaten scopes til kjoeringens filer -- kuratert innhold feller ikke (7)', async () => {
|
||||||
|
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||||
|
// Pre-eksisterende kuratert fil: out-of-vocab-type + ekstern lenke er LOVLIG
|
||||||
|
// paa lese-siden -- den skal aldri felle ingestion (B2).
|
||||||
|
const curatedPath = join(bundleRoot, 'syklus', 'kuratert.md');
|
||||||
|
mkdirSync(dirname(curatedPath), { recursive: true });
|
||||||
|
const curated = '---\ntype: Egendefinert\ntitle: Kuratert notat\n---\n'
|
||||||
|
+ 'Se [veilederen](https://www.regjeringen.no/veileder) for detaljer.\n';
|
||||||
|
writeFileSync(curatedPath, curated);
|
||||||
|
|
||||||
|
dropTxtFixtures(inbox);
|
||||||
|
const run = await ingestInbox(inbox, bundleRoot);
|
||||||
|
assert.equal(run.failed.length, 0, 'kuratert fil feller ikke kjoeringen (scoped strict)');
|
||||||
|
assert.equal(run.ingested.length, 2, 'begge dokumenter ingested');
|
||||||
|
assert.equal(readFileSync(curatedPath, 'utf8'), curated, 'kuratert fil byte-uroert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingest: per-dokument-staging/gate -- fiendtlig dok discardes alene, uten spor (8)', async () => {
|
||||||
|
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||||
|
dropTxtFixtures(inbox);
|
||||||
|
// Dangling bundle-lenke overlever noeytralisering (trygg FORM, mangler paa
|
||||||
|
// disk) -> strict-gaten feller dokumentet deterministisk.
|
||||||
|
writeFileSync(
|
||||||
|
join(inbox, 'fiendtlig.txt'),
|
||||||
|
'# Fiendtlig notat\n\nSe [detaljer](/dokumenter/finnes-ikke.md) for mer.\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = await ingestInbox(inbox, bundleRoot);
|
||||||
|
assert.equal(run.failed.length, 1, 'noeyaktig ett dokument discardet');
|
||||||
|
assert.match(run.failed[0].source, /fiendtlig/, 'det fiendtlige dokumentet');
|
||||||
|
assert.equal(run.ingested.length, 2, 'de to legitime dokumentene bestaar (Map-Reduce-isolasjon)');
|
||||||
|
|
||||||
|
// Ingen spor: verken konsept-fil, peker-fil eller index-oppfoering.
|
||||||
|
const rest = mdFiles(bundleRoot).map((f) => `${f}\n${readFileSync(join(bundleRoot, f), 'utf8')}`).join('\n');
|
||||||
|
assert.doesNotMatch(rest, /fiendtlig/i, 'ingen spor av discardet dokument i tre/index');
|
||||||
|
|
||||||
|
// Treet som bestaar er fortsatt konformt (default + strict).
|
||||||
|
assert.equal(runCheck(bundleRoot).status, 0, 'okf-check default exit 0 etter discard');
|
||||||
|
assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'strict exit 0 etter discard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingest: ukjent extension skippes med notice, feller ikke kjoeringen', async () => {
|
||||||
|
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||||
|
dropTxtFixtures(inbox);
|
||||||
|
writeFileSync(join(inbox, 'regneark.xlsx'), 'ikke-stoettet');
|
||||||
|
|
||||||
|
const notices = [];
|
||||||
|
const run = await ingestInbox(inbox, bundleRoot, { onNotice: (m) => notices.push(m) });
|
||||||
|
assert.equal(run.skipped.length, 1, 'xlsx skippet');
|
||||||
|
assert.equal(run.failed.length, 0, 'skip er ikke feil');
|
||||||
|
assert.equal(run.ingested.length, 2, 'txt-dokumentene ingested');
|
||||||
|
assert.ok(notices.some((n) => n.includes('xlsx')), 'norsk notice om ukjent filtype');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingest: binaer-original (.docx/.pdf) passerer strict -- pekerfil uten lenkeform (B1-aksept)', { skip: !engines }, async () => {
|
||||||
|
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||||
|
copyFileSync(join(SAMPLE, 'dok.docx'), join(inbox, 'dok.docx'));
|
||||||
|
copyFileSync(join(SAMPLE, 'dok.pdf'), join(inbox, 'dok.pdf'));
|
||||||
|
|
||||||
|
const run = await ingestInbox(inbox, bundleRoot);
|
||||||
|
assert.equal(run.failed.length, 0, `binaer-ingest feiler ikke: ${JSON.stringify(run.failed)}`);
|
||||||
|
assert.equal(run.ingested.length, 2, 'begge binaer-dokumenter ingested');
|
||||||
|
|
||||||
|
// B1-aksept: hele bundlen passerer strict med ikke-.md-originaler.
|
||||||
|
assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'strict exit 0 med .docx/.pdf-originaler');
|
||||||
|
|
||||||
|
// Pekerfiler finnes og baerer original-stien i resource (uten lenkeform).
|
||||||
|
const pointers = mdFiles(bundleRoot).filter((f) => f.endsWith('.kilde.md'));
|
||||||
|
assert.equal(pointers.length, 2, 'en pekerfil per original');
|
||||||
|
for (const p of pointers) {
|
||||||
|
const content = readFileSync(join(bundleRoot, p), 'utf8');
|
||||||
|
assert.match(content, /^resource: innboks\/dok[^\n]*$/m, 'resource baerer original-stien');
|
||||||
|
assert.doesNotMatch(content, /\]\(/, 'ingen lenkeform i pekerfil (B1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotens gjelder ogsaa binaer-stien (convert-twice-identical + mtime).
|
||||||
|
const m1 = manifest(bundleRoot);
|
||||||
|
await ingestInbox(inbox, bundleRoot);
|
||||||
|
assert.deepEqual(manifest(bundleRoot), m1, 'binaer-ingest idempotent');
|
||||||
|
});
|
||||||
|
});
|
||||||
86
tests/innboks-relations.test.mjs
Normal file
86
tests/innboks-relations.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
77
tests/innboks-split.test.mjs
Normal file
77
tests/innboks-split.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
272
tests/innboks-write.test.mjs
Normal file
272
tests/innboks-write.test.mjs
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
// 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 baerer original-stien i resource: UTEN noen
|
||||||
|
// lenkeform i body (B1: en body-lenke til ikke-.md-original feller strict)
|
||||||
|
// - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist
|
||||||
|
// - ingen .tmp lekker (atomisk temp+renameSync)
|
||||||
|
// - B5-guards: nekter overskriving av kuratert (ikke-ingestion) fil; tillater
|
||||||
|
// idempotent re-skriv av egen kilde:innboks-output; avviser reservert navn
|
||||||
|
// (index.md); avviser kryss-kilde destRel-kollisjon via claimed-registeret
|
||||||
|
// 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, symlinkSync } 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 uten lenkeform (B1)', () => {
|
||||||
|
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: resource baerer stien, body har INGEN
|
||||||
|
// lenkeform (B1: `- [x.txt](/innboks/x.txt)` er ikke .md -> feller strict).
|
||||||
|
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 pointerContent = readFileSync(pointer, 'utf8');
|
||||||
|
assert.match(pointerContent, /^resource: innboks\/tildeling\.txt$/m, 'resource baerer original-stien');
|
||||||
|
assert.match(pointerContent, /innboks\/tildeling\.txt/, 'original-stien er grep-bar');
|
||||||
|
assert.doesNotMatch(pointerContent, /\]\(/, 'ingen inline/bilde-lenke i pekerfila (B1)');
|
||||||
|
assert.doesNotMatch(pointerContent, /^[ \t]{0,3}\[[^\]]+\]:/m, 'ingen referanse-definisjon i pekerfila (B1)');
|
||||||
|
assert.doesNotMatch(pointerContent, /<a\b|<[a-z][a-z0-9+.-]*:/i, 'ingen HTML-anker/autolink i pekerfila (B1)');
|
||||||
|
assert.match(pointerContent, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)');
|
||||||
|
assert.match(pointerContent, /^kilde: innboks$/m, 'peker baerer kilde:innboks (provenans + B5-eierskap)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- B5 (A2): kollisjons-guards -- treet selv skal vaere uskjermet aldri mer ---
|
||||||
|
|
||||||
|
const CURATED = '---\ntype: Status\ntitle: Kuratert status\n---\nHaandskrevet innhold som ALDRI skal overskrives.\n';
|
||||||
|
|
||||||
|
function concept(slug, destRel, sourceSlug = 'kilde-a') {
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
sourceSlug,
|
||||||
|
destRel,
|
||||||
|
frontmatter: '---\ntype: Dokument\nkilde: innboks\n---\n',
|
||||||
|
body: 'innhold',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('writeConcepts: nekter aa overskrive pre-eksisterende kuratert fil (B5)', () => {
|
||||||
|
withBundle(({ bundleRoot }) => {
|
||||||
|
const curatedPath = join(bundleRoot, 'dokumenter', 'status.md');
|
||||||
|
mkdirSync(dirname(curatedPath), { recursive: true });
|
||||||
|
writeFileSync(curatedPath, CURATED);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => writeConcepts([concept('status', 'dokumenter/status.md')], { bundleRoot }),
|
||||||
|
/kuratert|ikke-ingestion|overskriv/i,
|
||||||
|
'skriv over kuratert fil (uten kilde:innboks) skal kaste',
|
||||||
|
);
|
||||||
|
assert.equal(readFileSync(curatedPath, 'utf8'), CURATED, 'kuratert fil er byte-uendret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeConcepts: tillater idempotent re-skriv av egen kilde:innboks-fil (B5)', () => {
|
||||||
|
withBundle(({ bundleRoot }) => {
|
||||||
|
const c = concept('notat', 'dokumenter/notat.md');
|
||||||
|
writeConcepts([c], { bundleRoot });
|
||||||
|
// Re-ingest (run2): samme fil skrives igjen -- skal IKKE kaste.
|
||||||
|
writeConcepts([c], { bundleRoot });
|
||||||
|
const written = readFileSync(join(bundleRoot, 'dokumenter', 'notat.md'), 'utf8');
|
||||||
|
assert.ok(written.includes('kilde: innboks'), 'ingestion-eierskap staar i skrevet fil');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeConcepts: reservert navn index.md avvises (B5)', () => {
|
||||||
|
withBundle(({ bundleRoot }) => {
|
||||||
|
assert.throws(
|
||||||
|
() => writeConcepts([concept('index', 'dokumenter/index.md')], { bundleRoot }),
|
||||||
|
/reservert|index/i,
|
||||||
|
'destRel med basename index.md skal kaste (indeksering eier index.md)',
|
||||||
|
);
|
||||||
|
assert.ok(!existsSync(join(bundleRoot, 'dokumenter', 'index.md')), 'ingenting skrevet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeConcepts: kryss-kilde destRel-kollisjon avvises via claimed-register (B5)', () => {
|
||||||
|
withBundle(({ bundleRoot }) => {
|
||||||
|
const claimed = new Map();
|
||||||
|
writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-a')], { bundleRoot, claimed });
|
||||||
|
// Annen kilde, samme destRel -> kollisjon (stille last-wins var B5-datatapet).
|
||||||
|
assert.throws(
|
||||||
|
() => writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-b')], { bundleRoot, claimed }),
|
||||||
|
/kollisjon|kilde/i,
|
||||||
|
'samme destRel fra annen kilde i samme kjoering skal kaste',
|
||||||
|
);
|
||||||
|
// Samme kilde igjen (relasjons-omskriv i fase 2) -> OK.
|
||||||
|
writeConcepts([concept('status', 'dokumenter/status.md', 'kilde-a')], { bundleRoot, claimed });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- M2 (A1): realpathSync-confinement -- leksikalsk sjekk alene slipper symlinks ---
|
||||||
|
|
||||||
|
test('writeConcepts: symlink-original som peker UT av bundlen avvises (M2)', () => {
|
||||||
|
withBundle(({ tmp, bundleRoot, inbox }) => {
|
||||||
|
// Fil UTENFOR bundle-rota; symlink i drop-zonen passerer den leksikalske sjekken.
|
||||||
|
const outside = join(tmp, 'utenfor.txt');
|
||||||
|
writeFileSync(outside, 'sensitivt innhold utenfor bundlen');
|
||||||
|
symlinkSync(outside, join(inbox, 'lenket.txt'));
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => writeConcepts([], {
|
||||||
|
bundleRoot,
|
||||||
|
originals: [{ sourceSlug: 'lenket', path: join(inbox, 'lenket.txt') }],
|
||||||
|
}),
|
||||||
|
/symlink|utenfor bundle/,
|
||||||
|
'symlink-original skal avvises via realpathSync',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writeConcepts: symlinket destinasjons-katalog som peker UT av bundlen avvises (M2)', () => {
|
||||||
|
withBundle(({ tmp, bundleRoot }) => {
|
||||||
|
// dokumenter/ er en symlink til en katalog utenfor bundlen -> skriv gjennom
|
||||||
|
// den ville landet utenfor tross leksikalsk '..'-sjekk paa destRel.
|
||||||
|
const outsideDir = join(tmp, 'ute');
|
||||||
|
mkdirSync(outsideDir, { recursive: true });
|
||||||
|
symlinkSync(outsideDir, join(bundleRoot, 'dokumenter'));
|
||||||
|
|
||||||
|
const concept = {
|
||||||
|
slug: 'x',
|
||||||
|
sourceSlug: 'x',
|
||||||
|
destRel: 'dokumenter/x.md',
|
||||||
|
frontmatter: '---\ntype: Dokument\n---\n',
|
||||||
|
body: 'innhold',
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => writeConcepts([concept], { bundleRoot }),
|
||||||
|
/symlink|utenfor bundle/,
|
||||||
|
'destinasjons-parent skal realpath-sjekkes foer skriv',
|
||||||
|
);
|
||||||
|
assert.deepEqual(readdirSync(outsideDir), [], 'ingenting skrevet utenfor bundlen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,12 +10,13 @@ import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync,
|
mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, mkdirSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { generateIndexes } from '../scripts/okf-index.mjs';
|
import { generateIndexes } from '../scripts/okf-index.mjs';
|
||||||
|
import { checkBundle } from '../scripts/okf-check.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const CHECK = join(ROOT, 'scripts', 'okf-check.mjs');
|
const CHECK = join(ROOT, 'scripts', 'okf-check.mjs');
|
||||||
|
|
@ -26,15 +27,36 @@ function tmpRoot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da).
|
// Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da).
|
||||||
function runCheck(root) {
|
// extra: ekstra CLI-flagg (f.eks. '--strict-ingest').
|
||||||
|
function runCheck(root, ...extra) {
|
||||||
try {
|
try {
|
||||||
const stdout = execFileSync('node', [CHECK, root], { encoding: 'utf8' });
|
const stdout = execFileSync('node', [CHECK, root, ...extra], { encoding: 'utf8' });
|
||||||
return { status: 0, stdout };
|
return { status: 0, stdout };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { status: e.status ?? 1, stdout: `${e.stdout || ''}${e.stderr || ''}` };
|
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.
|
// Alle kataloger under root (inkl. root selv), rekursivt.
|
||||||
function allDirs(root) {
|
function allDirs(root) {
|
||||||
const out = [root];
|
const out = [root];
|
||||||
|
|
@ -44,6 +66,16 @@ function allDirs(root) {
|
||||||
return out;
|
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 ---
|
// --- okf-index ---
|
||||||
|
|
||||||
test('okf-index: genererer index.md per nivaa (hver katalog) i temp-kopi', () => {
|
test('okf-index: genererer index.md per nivaa (hver katalog) i temp-kopi', () => {
|
||||||
|
|
@ -135,3 +167,224 @@ test('okf-check: rapporterer okf_version fra rot-index', () => {
|
||||||
const { stdout } = runCheck(REALISTIC);
|
const { stdout } = runCheck(REALISTIC);
|
||||||
assert.match(stdout, /okf_version:\s*kb-layout-2026-06/, 'skal ekko okf_version for menneskelig sammenligning');
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- B3 (A1): strict-gaten fanger ALLE standard lenkeformer, ikke bare inline ---
|
||||||
|
|
||||||
|
test('okf-check --strict-ingest: referanse-def/autolink/HTML-anker fanges (B3)', () => {
|
||||||
|
const dir = tmpRoot();
|
||||||
|
try {
|
||||||
|
buildCleanIngest(dir);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'dokumenter', 'refdef.md'),
|
||||||
|
'---\ntype: Notat\ntitle: Refdef\ndescription: x\n---\n# Refdef\n\n'
|
||||||
|
+ 'Se [rapporten][r].\n\n[r]: https://evil.example/refdef-exfil\n',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'dokumenter', 'autolenke.md'),
|
||||||
|
'---\ntype: Notat\ntitle: Autolenke\ndescription: x\n---\n# Autolenke\n\n'
|
||||||
|
+ '<https://evil.example/auto-exfil>\n',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'dokumenter', 'anker.md'),
|
||||||
|
'---\ntype: Notat\ntitle: Anker\ndescription: x\n---\n# Anker\n\n'
|
||||||
|
+ '<a href="file:///etc/passwd">passord</a>\n',
|
||||||
|
);
|
||||||
|
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||||
|
assert.equal(status, 1, `alle tre lenkeformene skal felle strict:\n${stdout}`);
|
||||||
|
assert.match(stdout, /refdef-exfil/, 'referanse-definisjon fanges');
|
||||||
|
assert.match(stdout, /auto-exfil/, 'autolink fanges');
|
||||||
|
assert.match(stdout, /file:\/\/\/etc\/passwd/, 'raa HTML-anker fanges');
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('okf-check --strict-ingest: referanse-def med trygt on-disk bundle-maal passerer', () => {
|
||||||
|
const dir = tmpRoot();
|
||||||
|
try {
|
||||||
|
buildCleanIngest(dir);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'dokumenter', 'trygg-ref.md'),
|
||||||
|
'---\ntype: Notat\ntitle: Trygg ref\ndescription: x\n---\n# Trygg ref\n\n'
|
||||||
|
+ 'Se [notatet][n].\n\n[n]: /dokumenter/notat.md\n',
|
||||||
|
);
|
||||||
|
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||||
|
assert.equal(status, 0, `trygg bundle-ref-def skal passere:\n${stdout}`);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- B2 (A1): strict scopes til KUN kjoeringens skrevne filer via { files } ---
|
||||||
|
|
||||||
|
test('checkBundle { files }: strict validerer KUN oppgitte filer, aldri hele roten (B2)', () => {
|
||||||
|
const dir = tmpRoot();
|
||||||
|
try {
|
||||||
|
buildCleanIngest(dir);
|
||||||
|
// Haandkuratert pre-eksisterende fil: out-of-vocab type + ekstern lenke er
|
||||||
|
// LOVLIG paa lese-siden (okf-vocab haandheves kun paa skrivestien).
|
||||||
|
mkdirSync(join(dir, 'syklus'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'syklus', 'kuratert.md'),
|
||||||
|
'---\ntype: Egne notater\ntitle: Kuratert\ndescription: x\n---\n# Kuratert\n\n'
|
||||||
|
+ 'Se [foeringene](https://regjeringen.no/dok).\n',
|
||||||
|
);
|
||||||
|
// Full-root strict feller den kuraterte fila (dokumentert B2-problem)...
|
||||||
|
const full = checkBundle(dir, { strictIngest: true });
|
||||||
|
assert.ok(full.strictErrors.length > 0, 'full-root strict feller kuratert innhold');
|
||||||
|
// ...men scoped strict (kjoeringens skrevne filer) er GROENN og teller kun dem.
|
||||||
|
const scoped = checkBundle(dir, {
|
||||||
|
strictIngest: true,
|
||||||
|
files: [join(dir, 'dokumenter', 'notat.md'), join(dir, 'strategisk-kontekst', 'tildelingsbrev.md')],
|
||||||
|
});
|
||||||
|
assert.equal(scoped.scanned, 2, 'scoped walk teller kun oppgitte filer');
|
||||||
|
assert.deepEqual(scoped.strictErrors, [], 'skrevne filer er rene -> ingen strict-feil');
|
||||||
|
assert.deepEqual(scoped.missingType, [], 'type-sjekk gjelder samme scope');
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
65
tests/okf-vocab.test.mjs
Normal file
65
tests/okf-vocab.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
69
tests/package-shape.test.mjs
Normal file
69
tests/package-shape.test.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// package-shape.test.mjs
|
||||||
|
// Step 10 (A1): package.json-kontrakten for det bevisste zero-dep-bruddet.
|
||||||
|
// Verifiserer at dep-laget er EXACT-pinnet (ingen ^/~/*), at engines-gulvet er
|
||||||
|
// satt (unpdf krever node >= 22), at pakken er ESM (type: module), at versjonen
|
||||||
|
// er 1.7.0 (direkte maal-versjon, bumpes ikke separat), og at .npmrc slaar av
|
||||||
|
// install-scripts (Shai-Hulud / supply-chain). Zero npm deps i selve testen.
|
||||||
|
// Moenster: tests/frontmatter.test.mjs (les fil, assert struktur).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const PKG = join(ROOT, 'package.json');
|
||||||
|
const NPMRC = join(ROOT, '.npmrc');
|
||||||
|
const LOCK = join(ROOT, 'package-lock.json');
|
||||||
|
|
||||||
|
// De fire pure-JS-konverterings-deps fra A0 pre-flight (audit 0 vulnerabilities).
|
||||||
|
const EXPECTED_DEPS = ['mammoth', 'turndown', 'postal-mime', 'unpdf'];
|
||||||
|
|
||||||
|
function readPkg() {
|
||||||
|
return JSON.parse(readFileSync(PKG, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('package.json: type module + version 1.7.0 (direkte maal-versjon)', () => {
|
||||||
|
const pkg = readPkg();
|
||||||
|
assert.equal(pkg.type, 'module');
|
||||||
|
assert.equal(pkg.version, '1.7.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package.json: engines.node-gulv satt (unpdf krever >= 22)', () => {
|
||||||
|
const pkg = readPkg();
|
||||||
|
assert.ok(pkg.engines && pkg.engines.node, 'engines.node mangler');
|
||||||
|
assert.equal(pkg.engines.node, '>=22');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package.json: alle dependencies EXACT-pinnet (ingen ^/~/*)', () => {
|
||||||
|
const pkg = readPkg();
|
||||||
|
assert.ok(pkg.dependencies, 'dependencies mangler');
|
||||||
|
for (const dep of EXPECTED_DEPS) {
|
||||||
|
assert.ok(pkg.dependencies[dep], `dep mangler: ${dep}`);
|
||||||
|
}
|
||||||
|
for (const [name, version] of Object.entries(pkg.dependencies)) {
|
||||||
|
assert.match(
|
||||||
|
version,
|
||||||
|
/^\d+\.\d+\.\d+$/,
|
||||||
|
`dep ${name} er ikke exact-pinnet: ${version}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.npmrc: install-scripts avslaatt (supply-chain-vern)', () => {
|
||||||
|
const npmrc = readFileSync(NPMRC, 'utf8');
|
||||||
|
assert.match(npmrc, /^ignore-scripts\s*=\s*true$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package-lock.json: finnes og pinner transitive deps med integrity', () => {
|
||||||
|
assert.ok(existsSync(LOCK), 'package-lock.json mangler');
|
||||||
|
const lock = JSON.parse(readFileSync(LOCK, 'utf8'));
|
||||||
|
const entries = Object.entries(lock.packages ?? {}).filter(([k]) => k !== '');
|
||||||
|
assert.ok(entries.length >= EXPECTED_DEPS.length, 'lockfile uten pakke-oppfoeringer');
|
||||||
|
for (const [name, meta] of entries) {
|
||||||
|
if (meta.link) continue;
|
||||||
|
assert.ok(meta.integrity, `lockfile-oppfoering uten integrity: ${name}`);
|
||||||
|
assert.match(meta.version ?? '', /^\d/, `lockfile-oppfoering uten versjon: ${name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue