fix(okr): B2 ingestion-kode-hygiene + bump 1.7.1 (patch-lane)

8 hygiene-fikser fra review 2026-07-16 par.4 kode-lista, TDD roed-groenn
(suite 138 -> 149):
- innboks-split: slugify translittererer ae/oe (datatap-fiks)
- innboks-frontmatter: beskrivende feil ved manglende sourceMtime
- okf-index: --okf-version bumper eksisterende rot, flagg-tolerant CLI
  (exit 2 ved manglende verdi), sanitizeEntry strip C1/zero-width/bidi/
  Unicode-tag
- write-org-profile: circuit-breaker MERGER i stedet for aa overskrive
  full config (M4); test beviser at eksisterende config overlever
- compose-org-profile: intern ----linje trunkerer ikke blokken
- coaching-hook: at-risk teller status-markerte tabellrader (M1/m1)
- inject-okr-context: topic-guard treffer boeyningsformer (maalene)
- frontmatter: BOM/CRLF-toleranse (falsk mangler-type-fiks)

Versjonsflater bumpet til 1.7.1 (package/plugin/lock/CLAUDE/README/
SKILL x2/package-shape-test) + CHANGELOG 1.7.1-seksjon (B1+B2).
Release-tag + katalog-ref venter paa [G-B] operatoer-go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-17 04:02:10 +02:00
commit 482effbad1
25 changed files with 289 additions and 29 deletions

View file

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

View file

@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.1] - 2026-07-17
Patch-lane-release: restsanering av MINOR/hygiene-funn fra dyp review 2026-07-16 (B1 + B2). Ingen nye features.
### Added
- **Referanse-integritetstest** (`tests/reference-integrity.test.mjs`) — globber alle `${CLAUDE_PLUGIN_ROOT}`-stier i commands/agents og asserter at målet finnes og er shippbart (aldri `.claude/`). Fanger død-referanse-klassen permanent (B1).
- **Testsuite 135 → 149 cases** — nye cases per B2-fiks (rød-grønn TDD), inkl. bevis for at eksisterende config OVERLEVER circuit-breaker-fallback.
### Fixed
- **KB/doc-hygiene (B1)**: 2 døde referanser lukket, kryssreferanse-seksjoner i de 5 isolerte KB-filene, «Sist oppdatert»-markør på alle 17 referanser, «19 → 20 antipatterns» rettet 4 steder, «15. oktober» → «tidlig oktober» (3 steder), garblet prognoseformel rettet mot kalkulator-kanon, typonits, README badge/hooks-tabell. BACKLOG.md foldet inn i `docs/roadmap.md`.
- **Slugify translittererer æ → ae / ø → oe** (`lib/innboks-split.mjs`) — «Økonomi» ga tidligere `konomi` (datatap i filnavn).
- **Beskrivende feil ved manglende `sourceMtime`** (`lib/innboks-frontmatter.mjs`) — navngir opsjonen i stedet for naken `RangeError`; faller aldri tilbake til veggklokka (idempotens bevart).
- **`okf-index` CLI**: eksplisitt `--okf-version` bumper nå eksisterende rot-index (før vant alltid eksisterende verdi); flagget godtas før eller etter rot-argumentet; manglende flagg-verdi gir bruksfeil (exit 2) i stedet for krasj.
- **Ikke-destruktiv circuit-breaker (M4)** (`scripts/write-org-profile.mjs`) — fallback til `.claude/okr.local.md` MERGER inn i eksisterende fil (ny profil først i samme frontmatter-blokk, first-match vinner) i stedet for å overskrive full prosjekt-config (syklus-id, onboarding, Linear).
- **Intern `---`-sanering** (`scripts/compose-org-profile.mjs`) — en fence-linje inne i profil-bodyen trunkerer ikke lenger frontmatter-blokken den flate parseren leser.
- **Presis at-risk-telling (M1/m1)** (`hooks/scripts/coaching-hook.mjs`) — teller status-markerte tabellrader («I fare»/«Blokkert»), ikke råforekomster i forklaringstekst og prosa.
- **Topic-guard bøyningsformer** (`hooks/scripts/inject-okr-context.mjs`) — «målene»/«målet»/«måla» treffer nå OKR-mønsteret.
- **BOM/CRLF-toleranse** (`lib/frontmatter.mjs`) — Windows-produserte filer (UTF-8 BOM + CRLF) rapporteres ikke lenger falskt som «mangler type».
### Security
- **`sanitizeEntry`-herding** (`scripts/okf-index.mjs`) — stripper nå også C1-kontrolltegn, zero-width (ZWSP/ZWNJ/ZWJ/LRM/RLM), bidi-embedding/-override/-isolater og Unicode tag-blokken (usynlig smugle-kanal for instruksjonstekst) fra index-entries.
## [1.7.0] - 2026-07-17
### Added

View file

@ -1,4 +1,4 @@
# OKR Offentlig Sektor v1.7.0
# OKR Offentlig Sektor v1.7.1
Expert OKR guidance for Norwegian public sector. Google/Doerr methodology adapted for 4-month tertial cycles.

View file

@ -6,7 +6,7 @@
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
![Version](https://img.shields.io/badge/version-1.7.0-blue)
![Version](https://img.shields.io/badge/version-1.7.1-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Agents](https://img.shields.io/badge/agents-7-orange)
![Commands](https://img.shields.io/badge/commands-14-blue)
@ -301,6 +301,7 @@ The plugin core is zero-dependency (`node:` builtins only). The inbox-ingestion
| Version | Date | Highlights |
|---------|------|------------|
| **1.7.1** | 2026-07-17 | Patch: restsanering etter dyp review — KB/doc-hygiene med referanse-integritetstest (B1) + ingestion-kode-hygiene (B2): æ/ø-translitterering i slugify, ikke-destruktiv circuit-breaker (merge, config overlever), sanitizeEntry mot bidi/zero-width/Unicode-tag, `--okf-version`-bump, BOM/CRLF-toleranse, presis at-risk-telling, bøyningsformer i topic-guard |
| **1.7.0** | 2026-07-17 | Innboks-ingestion: `/okr:innboks` med deterministisk pipeline (convert/split/frontmatter/gate/relasjoner/indeks), per-dokument sikkerhetsgate mot RAG-poisoning, `kilde: innboks`-provenans + untrusted-envelope i retrieval-skillen, 4 exact-pinnede pure-JS-avhengigheter (eneste zero-dep-unntak) |
| **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 |

View file

@ -74,9 +74,14 @@ try {
const statusPath = join(okrDir, 'syklus', cycleId, 'status.md');
if (existsSync(statusPath)) {
try {
// M1/m1 (B2): tell status-MARKERTE tabellrader, ikke raaforekomster --
// markoer-ord i forklaringstekst/prosa skal ikke inflatere telleren.
// En KR-rad i statusrapporten er en markdown-tabellrad (`| ... |`).
const statusContent = readFileSync(statusPath, 'utf8');
const riskMatches = statusContent.match(/[Ii] fare|[Bb]lokkert|risk/gi);
if (riskMatches) atRiskCount = riskMatches.length;
atRiskCount = statusContent
.split('\n')
.filter((line) => /^\s*\|.*\|\s*$/.test(line) && /i fare|blokkert/i.test(line))
.length;
} catch { /* skip */ }
}

View file

@ -37,7 +37,8 @@ if (rawPrompt) {
promptText = null;
}
if (typeof promptText === 'string' && promptText.length > 0) {
const okrPattern = /\bokr\b|objective|key result|n[oø]kkelresultat|noekkelresultat|\bkr\b|\bm[aå]l\b|\bmaal\b|tildelingsbrev|kaskade|syklus|tertial|kvartal/i;
// B2: m(aa)l godtar boeyningsformene -et/-ene/-a ("maalene" bommet foer).
const okrPattern = /\bokr\b|objective|key result|n[oø]kkelresultat|noekkelresultat|\bkr\b|\bm(?:[aå]|aa)l(?:et|ene|a)?\b|tildelingsbrev|kaskade|syklus|tertial|kvartal/i;
if (!okrPattern.test(promptText)) {
process.exit(0);
}

View file

@ -18,7 +18,10 @@
const FM_RE = /^---\n([\s\S]*?)\n---/;
export function parseFrontmatter(content) {
const match = String(content).match(FM_RE);
// B2: toler UTF-8 BOM foran forste fence + CRLF-linjeskift (Windows-produserte
// filer) -- ellers bommer FM_RE og fila rapporteres falskt som "mangler type".
const normalized = String(content).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
const match = normalized.match(FM_RE);
const raw = match ? match[1] : null;
const get = (key) => {

View file

@ -57,11 +57,20 @@ function deriveDescription(body) {
// concept (fra splitConcepts) -> beriket konsept med OKF-frontmatter + destRel.
export function projectFrontmatter(concept, { sourcePath, sourceMtime } = {}) {
// B2: timestamp er mtime-basert (idempotens by construction) -- en manglende/
// ugyldig sourceMtime skal feile beskrivende her, ikke som naken RangeError
// fra toISOString (og ALDRI falle tilbake til veggklokka).
const mtime = new Date(sourceMtime ?? NaN);
if (Number.isNaN(mtime.getTime())) {
throw new TypeError(
`projectFrontmatter: opts.sourceMtime maa vaere gyldig Date/ms-epoch (fikk: ${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 timestamp = mtime.toISOString();
const destRel = `${routeLevel(type)}/${concept.slug}.md`;
// Kanonisk noekkel-rekkefolge; description/tags utelates naar tomme.

View file

@ -8,11 +8,15 @@
// ett konsept". Innhold foer foerste heading bevares som ledende konsept (ingen
// datatap). Zero npm dependencies.
// Kebab-slug: lowercase, strip diakritika defensivt, ikke-alfanum -> '-', trim.
// Kebab-slug: lowercase, translitterer ae/oe (B2 -- NFKD dekomponerer ikke
// disse, saa uten dette droppes bokstaven: "OEkonomi" -> "konomi"), strip
// diakritika defensivt, ikke-alfanum -> '-', trim.
// Never-empty fallback 'konsept' (deterministisk; aldri tomt filnavn).
function slugify(text) {
const s = String(text)
.toLowerCase()
.replace(/\u00e6/g, 'ae')
.replace(/\u00f8/g, 'oe')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "okr-offentlig-sektor",
"version": "1.7.0",
"version": "1.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "okr-offentlig-sektor",
"version": "1.7.0",
"version": "1.7.1",
"dependencies": {
"mammoth": "1.12.0",
"postal-mime": "2.7.5",

View file

@ -1,6 +1,6 @@
{
"name": "okr-offentlig-sektor",
"version": "1.7.0",
"version": "1.7.1",
"private": true,
"type": "module",
"description": "OKR-plugin for norsk offentlig sektor. Pure-JS konverterings-deps for innboks-ingestion (bevisst zero-dep-brudd, se README).",

View file

@ -49,6 +49,13 @@ try {
// body, so the result is exactly one frontmatter block (see single-block note).
body = body.replace(/^\s*---\s*\r?\n/, '');
body = body.replace(/\r?\n---\s*\r?\n?\s*$/, '\n');
// B2: drop any REMAINING '---'-prefixed line inside the body -- the flat
// parser's block regex stops at the first such line, so an internal fence
// would silently truncate everything below it (see single-block note).
body = body
.split(/\r?\n/)
.filter((line) => !line.startsWith('---'))
.join('\n');
body = body.replace(/\s+$/, '');
const timestamp = process.env.OKR_NOW || new Date().toISOString();

View file

@ -63,8 +63,12 @@ function parseExistingIndex(path) {
function sanitizeEntry(s) {
if (!s) return '';
return String(s)
.replace(/[\x00-\x1f\x7f]/g, ' ') // kontrolltegn -> mellomrom
.replace(/[\x00-\x1f\x7f\u0080-\u009f]/g, ' ') // C0 + C1 kontrolltegn -> mellomrom
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // noytraliser markdown-lenker (behold tekst)
// B2: fjern usynlige styringstegn -- zero-width (ZWSP/ZWNJ/ZWJ/LRM/RLM),
// bidi-embedding/-override/-isolater (spoofing av leseretning), BOM, og
// Unicode tag-blokken (usynlig smugle-kanal for instruksjonstekst).
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/gu, '')
.replace(/[\])]/g, '') // strip strooe ] ) som brekker round-trip
.replace(/\s+/g, ' ')
.trim()
@ -80,7 +84,9 @@ function entryLine(title, link, desc) {
}
// Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer okf_version.
function writeIndexFor(dir, isRoot, okfVersion) {
// explicitVersion (B2): en eksplisitt oppgitt versjon VINNER over eksisterende
// rot-verdi (bump-mekanisme); ellers bevares eksisterende (idempotent vedlikehold).
function writeIndexFor(dir, isRoot, okfVersion, explicitVersion) {
const existing = parseExistingIndex(join(dir, 'index.md'));
const dirents = readdirSync(dir, { withFileTypes: true });
const subdirs = dirents
@ -97,7 +103,7 @@ function writeIndexFor(dir, isRoot, okfVersion) {
const lines = [`# ${heading}`, ''];
if (isRoot) {
lines.push(`okf_version: ${existing.okfVersion || okfVersion}`, '');
lines.push(`okf_version: ${explicitVersion ? okfVersion : (existing.okfVersion || okfVersion)}`, '');
}
for (const sd of subdirs) {
@ -119,9 +125,10 @@ function writeIndexFor(dir, isRoot, okfVersion) {
// Generer index.md for rot + alle underkataloger, rekursivt.
export function generateIndexes(root, opts = {}) {
const okfVersion = opts.okfVersion || OKF_VERSION;
const explicitVersion = typeof opts.okfVersion === 'string' && opts.okfVersion !== '';
const okfVersion = explicitVersion ? opts.okfVersion : OKF_VERSION;
const walk = (dir, isRoot) => {
writeIndexFor(dir, isRoot, okfVersion);
writeIndexFor(dir, isRoot, okfVersion, explicitVersion);
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false);
}
@ -134,13 +141,22 @@ export function generateIndexes(root, opts = {}) {
const isMain = process.argv[1]
&& fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
const root = process.argv[2];
if (!root) {
// B2: flagg-tolerant parsing -- `--okf-version <ver>` godtas foer ELLER etter
// rot-argumentet, og manglende flagg-verdi er en bruksfeil (exit 2), ikke krasj.
const usage = () => {
process.stderr.write('Bruk: node okf-index.mjs <bundle-rot> [--okf-version <ver>]\n');
process.exit(2);
};
const args = process.argv.slice(2);
let okfVersion;
const vi = args.indexOf('--okf-version');
if (vi !== -1) {
okfVersion = args[vi + 1];
if (!okfVersion || okfVersion.startsWith('--')) usage();
args.splice(vi, 2);
}
const vi = process.argv.indexOf('--okf-version');
const okfVersion = vi !== -1 ? process.argv[vi + 1] : undefined;
const root = args[0];
if (!root) usage();
generateIndexes(root, { okfVersion });
process.stdout.write(`OKF-index generert for ${root}\n`);
}

View file

@ -12,7 +12,7 @@
// Mirrors the canonical home path defined in
// hooks/scripts/inject-okr-context.mjs:14 (the most-specific-wins read side).
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
@ -39,6 +39,22 @@ function writeAtomic(target, data) {
renameSync(tmp, target);
}
// M4 (B2): the fallback target (.claude/okr.local.md) may already carry the
// FULL project config (cycle id, fase, onboarding, Linear). The circuit-breaker
// must never overwrite it -- merge instead: the incoming profile's frontmatter
// lines go FIRST inside ONE block (the flat parser is first-match, so the new
// profile wins per key), the existing frontmatter lines and body follow intact.
function mergeIntoExisting(target, incoming) {
const prior = readFileSync(target, 'utf8');
const incomingInner = (incoming.match(/^---\r?\n([\s\S]*?)\r?\n---/) || [])[1] ?? incoming.trim();
const m = prior.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) {
// Existing file without frontmatter: preserve it verbatim as body.
return `---\n${incomingInner}\n---\n${prior}`;
}
return `---\n${incomingInner}\n${m[1]}\n---\n${m[2]}`;
}
try {
writeAtomic(homeTarget, content);
process.stdout.write(homeTarget);
@ -49,7 +65,7 @@ try {
// historikk tree remains cwd-bound regardless; only the profile migrates.
const fallback = join(process.cwd(), '.claude', 'okr.local.md');
try {
writeAtomic(fallback, content);
writeAtomic(fallback, existsSync(fallback) ? mergeIntoExisting(fallback, content) : content);
process.stderr.write(
`notice: kunne ikke skrive hjem-profil (${err.code || err.message}); ` +
`falt tilbake til prosjektlokal ${fallback}\n`,

View file

@ -2,7 +2,7 @@
name: okr-offentlig-sektor
description: >-
OKR (Objectives and Key Results) for Norwegian public sector: writing OKR, reviewing OKR quality, cascading OKR from strategy to team, tracking progress, running OKR meetings, translating tildelingsbrev to OKR. Also CFR, OKR antipatterns, scoring, Oboard. Triggers on: "OKR", "skriv OKR", "vurder OKR", "OKR-scoring", "kaskadere OKR", "tildelingsbrev til OKR", "OKR for offentlig sektor".
version: "1.7.0"
version: "1.7.1"
---
# OKR Skill for Offentlig Sektor (Norge)

View file

@ -10,7 +10,7 @@ description: >-
prompt. Triggers on: "våre mål", "overordnede mål", "mål dette tertialet",
"tertialmål", "tildelingsbrev", "hva sier OKR-ene våre", "forrige syklus",
"strategi", "styringssignaler".
version: "1.7.0"
version: "1.7.1"
---
# OKR Second-Brain Search

View file

@ -90,6 +90,31 @@ test('OKR_NOW midtveis fase: mid-coaching', () => {
});
});
test('at-risk telles per status-RAD (tabell), ikke raaforekomster i prosa (B2/M1)', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');
const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026');
mkdirSync(statusDir, { recursive: true });
// 2 markerte rader + markoer-ord i forklaring OG prosa: kun radene skal telle.
writeFileSync(join(statusDir, 'status.md'), [
'# Status T2-2026',
'',
'Merk: "I fare" betyr flat trend; "Blokkert" betyr ingen fremgang.',
'',
'| KR | Baseline | Maal | Naa | Score | Status |',
'|----|----------|------|-----|-------|--------|',
'| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | I fare |',
'| KR2: Oppetid | 10 | 25 | 22 | 0.80 | Paa sporet |',
'| KR3: Tilfredshet | 60% | 90% | 65% | 0.17 | Blokkert |',
'',
'KR1 er i fare fordi trenden er flat. KR3 er blokkert av leverandoer.',
'',
].join('\n'));
const out = runHook(work, '2026-06-20'); // midtveis -> at-risk rapporteres
assert.match(out, /OBS: 2 KR er merket/, '2 markerte rader -> teller noeyaktig 2 (ikke 6)');
});
});
test('OKR_NOW sen fase: late-coaching', () => {
withWork((work) => {
writeConfig(work, '---\nid: "T2-2026"\n---\n');

View file

@ -88,6 +88,15 @@ test('OKF fler-linje tags-liste: krasjer ikke + folgende skalar resolver', () =>
);
});
test('BOM + CRLF: frontmatter parses (falsk "mangler type"-fiks, B2)', () => {
// Windows-produsert fil: UTF-8 BOM foran forste fence + CRLF-linjeskift.
const content = '\uFEFF---\r\ntype: Notat\r\ntitle: "X"\r\n---\r\n# body\r\n';
const { raw, get } = parseFrontmatter(content);
assert.notEqual(raw, null, 'BOM/CRLF skal ikke gi raw null');
assert.equal(get('type'), 'Notat');
assert.equal(get('title'), 'X');
});
test('ingen frontmatter: raw null, get returnerer null', () => {
const { raw, get } = parseFrontmatter('ingen frontmatter her\n');
assert.equal(raw, null);

View file

@ -146,6 +146,25 @@ test('projectFrontmatter: kompound-ord i title matcher ikke vokab-term (Statusno
);
});
// --- B2 (1.7.1): manglende sourceMtime skal feile beskrivende, ikke RangeError ---
test('projectFrontmatter: manglende sourceMtime -> beskrivende feil som navngir opsjonen', () => {
const concept = { sourceSlug: 'x', title: 'Notat', slug: 'notat', level: 1, body: 'x' };
assert.throws(
() => projectFrontmatter(concept, { sourcePath: 'innboks/x.txt' }),
/sourceMtime/,
'feilen skal navngi sourceMtime (ikke en naken RangeError fra toISOString)',
);
});
test('projectFrontmatter: ugyldig sourceMtime (Invalid Date) -> samme beskrivende feil', () => {
const concept = { sourceSlug: 'x', title: 'Notat', slug: 'notat', level: 1, body: 'x' };
assert.throws(
() => projectFrontmatter(concept, { sourcePath: 'innboks/x.txt', sourceMtime: new Date('ugyldig') }),
/sourceMtime/,
);
});
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 });

View file

@ -67,6 +67,13 @@ test('splitConcepts: slug-kollisjon -> stabil numerisk disambiguering', () => {
assert.equal(concepts[1].slug, 'samme-tittel-2', 'andre forekomst faar -2 suffiks');
});
test('splitConcepts: slug translittererer ae/oe -- norske bokstaver dropper ikke (B2)', () => {
// "Økonomi og ærlighet" -- foer B2 ga slugify 'konomi-og-rlighet' (datatap).
const md = '# Økonomi og ærlighet\n\nInnhold.';
const concepts = splitConcepts(md, { sourceSlug: 'norsk' });
assert.equal(concepts[0].slug, 'oekonomi-og-aerlighet', 'OE->oe, ae->ae (translitterert, ikke strippet)');
});
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' });

View file

@ -312,6 +312,74 @@ test('okf-index: innboks/ + dot-katalog -> ingen egen index.md OG ikke listet i
}
});
// --- B2 (1.7.1): okf-index CLI-versjonering + sanitizeEntry-herding ---
const INDEX_CLI = join(ROOT, 'scripts', 'okf-index.mjs');
test('okf-index CLI: eksplisitt --okf-version bumper eksisterende rot-index (flagg foer rot)', () => {
const dir = tmpRoot();
try {
buildCleanIngest(dir); // rot-index har allerede okf_version: kb-layout-2026-06
execFileSync('node', [INDEX_CLI, '--okf-version', 'kb-layout-2027-01', dir], { encoding: 'utf8' });
const root = readFileSync(join(dir, 'index.md'), 'utf8');
assert.match(root, /^okf_version: kb-layout-2027-01$/m, 'eksplisitt versjon skal vinne over eksisterende');
// Uten flagg bevares den bumpede versjonen (idempotent vedlikehold, som foer).
execFileSync('node', [INDEX_CLI, dir], { encoding: 'utf8' });
const root2 = readFileSync(join(dir, 'index.md'), 'utf8');
assert.match(root2, /^okf_version: kb-layout-2027-01$/m, 'implisitt kjoering bevarer eksisterende versjon');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('okf-index CLI: --okf-version uten verdi -> bruksfeil exit 2, ingen skriving', () => {
const dir = tmpRoot();
try {
buildCleanIngest(dir);
const before = readFileSync(join(dir, 'index.md'), 'utf8');
let status = 0;
try {
execFileSync('node', [INDEX_CLI, dir, '--okf-version'], { encoding: 'utf8', stdio: 'pipe' });
} catch (e) {
status = e.status;
}
assert.equal(status, 2, 'manglende flagg-verdi skal gi bruksfeil exit 2');
assert.equal(readFileSync(join(dir, 'index.md'), 'utf8'), before, 'rot-index skal vaere uendret');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('okf-index: RTL/ZWSP/C1/Unicode-tag i title/description strippes fra index-entry', () => {
const dir = tmpRoot();
try {
mkdirSync(join(dir, 'dokumenter'), { recursive: true });
writeFileSync(join(dir, 'index.md'), '# Bundle\n\nokf_version: kb-layout-2026-06\n');
// RLO (u202E), ZWSP (u200B), C1 NEL (u0085), Unicode tag (U+E0041), ZWJ (u200D)
// -- alle som eksplisitte escapes (ASCII-ren testkilde, ingen usynlige bytes).
writeFileSync(
join(dir, 'dokumenter', 'usynlig.md'),
'---\ntype: Notat\ntitle: "Rap\u202Eport\u200B nr\u0085 1\u{E0041}"\n'
+ 'description: "Se\u200D vedlegg"\n---\n# U\n',
);
generateIndexes(dir);
const idx = readFileSync(join(dir, 'dokumenter', 'index.md'), 'utf8');
const entry = idx.split('\n').find((l) => l.startsWith('* '));
assert.ok(entry, 'usynlig.md skal ha en entry');
assert.doesNotMatch(
entry,
/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\u0080-\u009F\uFEFF]|[\u{E0000}-\u{E007F}]/u,
'ingen bidi-/zero-width-/C1-/tag-tegn i entry',
);
assert.match(entry, /Rapport nr 1/, 'synlig tekst bevart etter stripping');
assert.match(entry, /Se vedlegg/, 'description-tekst bevart');
generateIndexes(dir);
assert.equal(readFileSync(join(dir, 'dokumenter', 'index.md'), 'utf8'), idx, 'sanering er idempotent');
} 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)', () => {

View file

@ -112,6 +112,18 @@ test('compose: tolererer body som allerede baerer --- fences (samler til ett blo
assert.equal(fences.length, 2, 'noeyaktig to frontmatter-fences');
});
test('compose: intern --- i body saneres (trunkerer ikke blokken) (B2)', () => {
// En intern fence-linje midt i bodyen ville trunkert blokken den flate
// parseren leser -- alt etter fencen (her `ekstra:`) ville forsvunnet.
const withInternalFence = `${NESTED_BODY}---\nekstra: "verdi"\n`;
const composed = compose(withInternalFence, { OKR_NOW: NOW });
const { get } = parseFrontmatter(composed);
assert.equal(get('navn'), 'Testdirektoratet', 'navn resolver fortsatt');
assert.equal(get('ekstra'), 'verdi', 'innhold ETTER intern fence overlever i samme blokk');
const fences = composed.match(/^---$/gm) || [];
assert.equal(fences.length, 2, 'noeyaktig to frontmatter-fences');
});
test('compose -> write-org-profile: hjem-profil faar OKF-frontmatter + bevart nestet navn', () => {
withDirs((home, work) => {
const composed = compose(NESTED_BODY, { OKR_NOW: NOW });

View file

@ -7,7 +7,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, realpathSync, rmSync } from 'node:fs';
import {
mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpathSync, rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -72,6 +74,31 @@ test('circuit-breaker: uskrivbart hjem -> fallback til prosjektlokal uten error'
});
});
test('circuit-breaker M4: eksisterende okr.local.md OVERLEVER fallback (merge, ikke overskriv)', () => {
withDirs((home, work) => {
writeFileSync(join(home, '.claude'), 'not a directory\n');
// Pre-eksisterende FULL config (syklus + onboarding + body) i prosjektlokal fil
// -- foer B2 ble denne overskrevet i sin helhet av profil-fallbacken (M4).
mkdirSync(join(work, '.claude'), { recursive: true });
writeFileSync(
join(work, '.claude', 'okr.local.md'),
'---\nnavn: "GammelOrg"\nid: "T2-2026"\nonboarding_status: fullfort\n---\nNotater under frontmatter.\n',
);
const profil = '---\nnavn: "NyOrg"\n---\n';
const out = runHelper(work, home, profil);
const fallback = join(work, '.claude', 'okr.local.md');
assert.equal(realpathSync(out.trim()), realpathSync(fallback), 'stdout rapporterer fallback-sti');
const merged = readFileSync(fallback, 'utf8');
assert.match(merged, /id: "T2-2026"/, 'syklus-id overlever fallback');
assert.match(merged, /onboarding_status: fullfort/, 'onboarding-state overlever fallback');
assert.match(merged, /Notater under frontmatter\./, 'body under frontmatter overlever');
// Round-trip: hooken resolver NY org (first-match) OG GAMMEL syklus fra samme fil.
const injected = runHook(work, home);
assert.match(injected, /NyOrg/, 'ny profil er effektiv (first-match foran gammel blokk)');
assert.match(injected, /T2-2026/, 'gammel syklus-config resolver fortsatt');
});
});
test('round-trip: hjem-skrevet org reflekteres av inject-okr-context', () => {
withDirs((home, work) => {
const profil = '---\nnavn: "RoundTripOrg"\n---\n';

View file

@ -2,7 +2,7 @@
// 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
// er 1.7.1 (patch-lane B1+B2), 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).
@ -24,10 +24,10 @@ function readPkg() {
return JSON.parse(readFileSync(PKG, 'utf8'));
}
test('package.json: type module + version 1.7.0 (direkte maal-versjon)', () => {
test('package.json: type module + version 1.7.1 (patch-lane)', () => {
const pkg = readPkg();
assert.equal(pkg.type, 'module');
assert.equal(pkg.version, '1.7.0');
assert.equal(pkg.version, '1.7.1');
});
test('package.json: engines.node-gulv satt (unpdf krever >= 22)', () => {

View file

@ -62,6 +62,15 @@ test('relevant prompt: injiserer OKR-kontekst', () => {
});
});
test('boeyningsform: "målene" treffer topic-guarden (B2)', () => {
withWork((work) => {
makeProjectConfig(work, 'TopicOrg');
// Bestemt flertall av maal -- \bm[aa]l\b bommet paa denne foer B2.
const out = runHook(work, JSON.stringify({ prompt: 'hvordan ligger vi an mot målene i høst' }));
assert.match(out, /OKR-kontekst/, 'boeyningsformen maalene skal injisere kontekst');
});
});
test('tomt prompt-felt: bevarer inject-default (tvil -> injiser)', () => {
withWork((work) => {
makeProjectConfig(work, 'TopicOrg');