feat(linkedin-studio): deterministic §B/§C1 contract binding-gate (fix #1 slice 1)

Adds scripts/contract-gate/ — a deterministic, AI-free gate that enforces the
mechanical half of the Maskinrommet writing contract (§B red flags + §C1
regelsjekk) BEFORE operator handoff, so the same mechanical correction never
reaches KTG twice. First slice of fix #1 (binding + accumulating); target
re-scoped to close all 4 diagnosis gaps = sustainable newsletter workflow.

- Structured ruleset as DATA (src/rules.ts) so accumulation (slice 2) appends.
- 3 BLOCK (modell-strawman, series-thesis "er ikke modellen", _underscore_)
  + 7 WARN (judgment-needed). Block only on provable, ~zero-FP matches.
- Code/frontmatter masking; line-accurate reporting; CLI receipt + exit code.
- 21/21 tests (node:test), tsc clean.
- Validated on 6 real editions: 0 BLOCK on published Del 2-5; caught a real
  Rule-10 violation in Del 6 (WIP) automatically. Versal-tic uses an
  emphasis-word denylist after real-data FPs (NAV/DFØ/NPM).

Finding: the plugin already mirrors §A + §C2; §C1 was the unhomed gap this fills.
Plugin structure lint unchanged (81/0/0); no new agents/commands/refs/skills.
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 17:17:51 +02:00
commit 58aebf05a9
10 changed files with 1329 additions and 0 deletions

View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* CLI for the §B/§C1 mechanical binding-gate.
*
* node --import tsx src/cli.ts <utkast.md> [--json]
*
* Exit code: 0 if the gate passes (no BLOCK violations handoff permitted),
* 1 if BLOCKED, 2 on usage error. WARN violations never fail the gate; they are
* surfaced for KTG's judgment. The text output is the "kvittering" the writing
* contract asks for when a draft is delivered.
*/
import { runGateFile } from "./gate.js";
import type { GateResult, Violation } from "./types.js";
function printViolation(v: Violation): void {
console.log(` [${v.ref}] ${v.ruleId}`);
if (v.observed !== undefined) {
const t = v.threshold !== undefined ? ` (terskel: ${v.threshold})` : "";
console.log(` observert: ${v.observed}${t}`);
}
console.log(`${v.message}`);
const shown = v.matches.slice(0, 5);
for (const m of shown) console.log(` · L${m.line}: ${m.excerpt}`);
if (v.matches.length > shown.length) {
console.log(` · … +${v.matches.length - shown.length} til`);
}
}
function report(r: GateResult): void {
console.log("§B/§C1 regelsjekk-gate");
console.log(`Utkast: ${r.draftPath ?? "(stdin)"} · ${r.wordCount} ord\n`);
if (r.blocks.length) {
console.log(`BLOCK (${r.blocks.length}) — må fikses før handoff:`);
r.blocks.forEach(printViolation);
console.log("");
}
if (r.warns.length) {
console.log(`WARN (${r.warns.length}) — krever din dom (blokkerer ikke):`);
r.warns.forEach(printViolation);
console.log("");
}
if (r.passed) {
console.log(`✓ Gate passert — 0 block, ${r.warns.length} warn til vurdering.`);
} else {
console.log(
`✗ BLOKKERT — ${r.blocks.length} block, ${r.warns.length} warn. Fiks block-ene og kjør på nytt.`,
);
}
}
function main(): void {
const args = process.argv.slice(2);
const jsonOut = args.includes("--json");
const path = args.find((a) => !a.startsWith("--"));
if (!path) {
console.error("Bruk: contract-gate <utkast.md> [--json]");
process.exit(2);
}
const result = runGateFile(path);
if (jsonOut) console.log(JSON.stringify(result, null, 2));
else report(result);
process.exit(result.passed ? 0 : 1);
}
main();

View file

@ -0,0 +1,207 @@
/**
* Deterministic engine for the §B/§C1 mechanical binding-gate.
*
* Pure string/regex evaluation of a draft against a structured ruleset. No AI,
* no network, no external state so it is genuinely BINDING: it cannot be
* skipped or talked around, and it runs in milliseconds. Mechanical violations
* are caught here BEFORE the draft reaches KTG, so the same correction never
* reaches him twice.
*
* Matching runs on a MASKED copy of the draft (frontmatter + code stripped to
* spaces, line positions preserved) so identifiers/code never false-positive.
*/
import { readFileSync } from "node:fs";
import type { GateResult, Match, Rule, Violation } from "./types.js";
import { RULES } from "./rules.js";
export { RULES } from "./rules.js";
/**
* Replace YAML frontmatter, fenced code blocks, and inline code spans with
* spaces preserving newlines, length, and therefore every offset/line number.
*/
export function mask(text: string): string {
const chars = text.split("");
const blank = (start: number, end: number) => {
for (let i = start; i < end && i < chars.length; i++) {
if (chars[i] !== "\n") chars[i] = " ";
}
};
// YAML frontmatter at the very top: ---\n … \n---
if (text.startsWith("---\n")) {
const close = text.indexOf("\n---", 3);
if (close !== -1) {
const lineEnd = text.indexOf("\n", close + 1);
blank(0, lineEnd === -1 ? text.length : lineEnd);
}
}
let m: RegExpExecArray | null;
const fence = /```[\s\S]*?```/g;
while ((m = fence.exec(text)) !== null) blank(m.index, m.index + m[0].length);
const inline = /`[^`\n]*`/g;
while ((m = inline.exec(text)) !== null) blank(m.index, m.index + m[0].length);
return chars.join("");
}
function lineStarts(text: string): number[] {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text[i] === "\n") starts.push(i + 1);
}
return starts;
}
/** 1-based line of a character index. */
function lineOf(starts: number[], index: number): number {
let lo = 0;
let hi = starts.length - 1;
let ans = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (starts[mid] <= index) {
ans = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return ans + 1;
}
function excerptAt(original: string, index: number, len: number): string {
const start = Math.max(0, index - 20);
const end = Math.min(original.length, index + len + 20);
return original.slice(start, end).replace(/\s+/g, " ").trim();
}
function countWords(masked: string): number {
const m = masked.match(/[A-Za-zÆØÅæøå0-9]+/g);
return m ? m.length : 0;
}
interface Ctx {
masked: string;
original: string;
starts: number[];
}
function literalMatches(rule: Rule, ctx: Ctx): Match[] {
const hay = ctx.masked.toLowerCase();
const out: Match[] = [];
for (const pat of rule.patterns) {
const needle = pat.toLowerCase();
if (!needle) continue;
let from = 0;
let idx: number;
while ((idx = hay.indexOf(needle, from)) !== -1) {
out.push({
line: lineOf(ctx.starts, idx),
excerpt: excerptAt(ctx.original, idx, needle.length),
});
from = idx + needle.length;
}
}
return out;
}
/** Run a rule's regex patterns; returns ALL matches (after the allow-filter). */
function regexMatches(rule: Rule, ctx: Ctx, cap = Infinity): { matches: Match[]; count: number } {
const baseFlags = rule.flags ?? "gi";
const flags = baseFlags.includes("g") ? baseFlags : baseFlags + "g";
const matches: Match[] = [];
let count = 0;
for (const pat of rule.patterns) {
const re = new RegExp(pat, flags);
let m: RegExpExecArray | null;
while ((m = re.exec(ctx.masked)) !== null) {
if (m[0].length === 0) {
re.lastIndex++;
continue;
}
const trimmed = m[0].trim();
count++;
if (matches.length < cap) {
const lead = m[0].length - m[0].trimStart().length;
const at = m.index + lead;
matches.push({
line: lineOf(ctx.starts, at),
excerpt: excerptAt(ctx.original, at, trimmed.length),
});
}
}
}
return { matches, count };
}
function evalRule(rule: Rule, ctx: Ctx, wordCount: number): Violation | null {
let matches: Match[] = [];
let observed: number | undefined;
let threshold: number | undefined;
switch (rule.kind) {
case "literal":
matches = literalMatches(rule, ctx);
break;
case "regex":
matches = regexMatches(rule, ctx).matches;
break;
case "count": {
const { matches: ms, count } = regexMatches(rule, ctx, 8);
observed = count;
threshold = rule.threshold ?? 0;
if (count > threshold) matches = ms;
break;
}
case "density": {
const { matches: ms, count } = regexMatches(rule, ctx, 8);
const maxAllowed = Math.floor((wordCount / (rule.per ?? 50)) * (rule.threshold ?? 1));
observed = count;
threshold = maxAllowed;
if (count > maxAllowed) matches = ms;
break;
}
}
if (matches.length === 0) return null;
return {
ruleId: rule.id,
ref: rule.ref,
severity: rule.severity,
message: rule.message,
matches,
observed,
threshold,
};
}
/** Evaluate raw draft text against a ruleset. */
export function evaluate(
text: string,
rules: Rule[] = RULES,
draftPath: string | null = null,
): GateResult {
const masked = mask(text);
const ctx: Ctx = { masked, original: text, starts: lineStarts(text) };
const wordCount = countWords(masked);
const blocks: Violation[] = [];
const warns: Violation[] = [];
for (const rule of rules) {
const v = evalRule(rule, ctx, wordCount);
if (!v) continue;
(v.severity === "block" ? blocks : warns).push(v);
}
return { draftPath, wordCount, blocks, warns, passed: blocks.length === 0 };
}
/** Read a draft file from disk and evaluate it. */
export function runGateFile(path: string, rules: Rule[] = RULES): GateResult {
const text = readFileSync(path, "utf8");
return evaluate(text, rules, path);
}

View file

@ -0,0 +1,141 @@
/**
* Seed ruleset for the §B/§C1 mechanical binding-gate.
*
* Each rule transcribes a mechanically-checkable item from the Maskinrommet
* writing contract (maskinrommet/docs/skrivekontrakt.md). The classification
* principle (slice 1): BLOCK only what the gate can PROVE with ~zero false
* positives (exact forbidden phrases, markdown syntax); WARN on grep-flags that
* still need human/AI judgment (a word that *might* be a metaphor, a bare
* "modell" that *might* be neutral). Judgment-only / external-data rules
* (research/ traceability, term definitions, narrative architecture, the full
* AI-tell list) are intentionally NOT here they stay with voice-scrubber
* (tier 2) and editorial-reviewer (tier 3).
*
* Deferred to a later slice (documented gaps, not silent omissions):
* - §C2 "postulerte tall" (numbers without hedge/source) needs a
* hedge-proximity heuristic with non-trivial false-positive risk.
* - §C2 "ordrette gjentakelser" (any n-gram repeated >2×) needs n-gram
* analysis; the contract's named phrases are edition-specific.
*/
import type { Rule } from "./types.js";
export const RULES: Rule[] = [
// ── BLOCK: provable, ~zero false positives ─────────────────────────────
{
id: "B3-modell-referansepunkt",
ref: "§B-3 / §C1",
severity: "block",
kind: "literal",
patterns: [
"det er ikke modellen som",
"ikke en bedre modell",
"modellen er god nok",
"uten å bytte modell",
],
message:
"Modell-stråmann (regel 3, KTGs mest gjentatte korreksjon): ingen påstand om at modellen er problemet/for svak. Skriv om til vår evne, vilje og kompetanse til å hente ut potensialet.",
},
{
id: "B10-serie-tese",
ref: "§B-10 / §C1",
severity: "block",
kind: "literal",
patterns: ["er ikke modellen"],
message:
"Serie-tesen / meta-tese (regel 10, korollar til regel 3): «… er ikke modellen» er den tause linsen, aldri en setning vi leverer. Positiv konkret landing («menneskene … er den knappe ressursen») er OK — den abstrakte meta-tesen er ikke.",
},
{
id: "understrek-emfase",
ref: "§C1 / hygiene",
severity: "block",
kind: "regex",
// Markdown underscore-emphasis: opening _ at a boundary, closing _ before
// whitespace/punctuation. snake_case (underscores between word chars) is
// NOT matched, so identifiers in prose don't false-positive.
patterns: ["(^|[\\s(])_[^_\\n]+_(?=[\\s).,!?:;]|$)"],
message:
"Emfase med _understrek_ rendrer rått (hygiene). Bruk **fet** (eller *kursiv* med stjerne).",
},
// ── WARN: grep-flag, needs human/AI judgment ───────────────────────────
{
id: "B2-metafor",
ref: "§B-2",
severity: "warn",
kind: "regex",
patterns: ["\\b(tak|lodd|reise|landskap)\\b"],
message:
"Mulig metafor som bærer argumentet (regel 2). Bekreft at bruken er konkret/nøytral, ikke utvidet bildespråk («et usynlig tak» → «KI-en når ikke dataene»).",
},
{
id: "B3-modell-bar",
ref: "§B-3",
severity: "warn",
kind: "count",
threshold: 0,
patterns: ["\\bmodell"],
message:
"«modell» nevnt — sjekk at HVERT treff er nøytral faktabruk («en KI-modell er trent på …»), ikke modellen brukt som referansepunkt/stråmann (regel 3).",
},
{
id: "KI-en-subjekt",
ref: "hygiene ★",
severity: "warn",
kind: "count",
threshold: 3,
patterns: ["\\bKI-en\\b"],
message:
"«KI-en» gjentatt som handlende subjekt er dårlig norsk (språk-hygiene). Varier: «KI», «verktøyet», eller omskriv («data KI har tilgang til»).",
},
{
id: "B9-PS",
ref: "§B-9 / §C1",
severity: "warn",
kind: "regex",
patterns: ["(^|\\n)\\s*P\\.?\\s?S\\.?\\b"],
message:
"PS/etter-closing-blokk (regel 9). Sjekk at den ikke åpner nytt begrep/rammeverk etter handlingskallet — leseren skal lukke på ett konkret handlingsvalg.",
},
{
id: "C2-tankestrek-tetthet",
ref: "§C2",
severity: "warn",
kind: "density",
threshold: 1,
per: 50,
patterns: ["\\u2014"], // em dash —
message:
"Tankestrek-tetthet over ~1 per 50 ord (§C2). Bytt halvparten med komma eller punktum; innskudd-formelen «X — Y — Z» bør være sjelden.",
},
{
id: "C2-versal-tic",
ref: "§C2",
severity: "warn",
kind: "regex",
// Case-sensitive, Unicode-aware (handles MÅ/NÅ/SÅ). DENYLIST, not allowlist:
// flag only UPPERCASE forms of known Norwegian emphasis words — NOT every
// 2+-uppercase run. Acronyms (NPM, DFØ, NAV) are not emphasis words, so they
// never flag. (Real-data finding on Del 4/5: an acronym allowlist can't keep
// up; the emphasis-word set is small and stable.)
flags: "gu",
patterns: [
"(?<!\\p{L})(OG|IKKE|IKKJE|MEN|MÅ|SKAL|ALDRI|ALLTID|ALLE|INGEN|BARE|KUN|NÅ|SÅ|HELE|ENHVER)(?!\\p{L})",
],
message:
"Versal-tic (§C2): emfase-ord i VERSALER midt i prosa («OG», «IKKE», «MEN») bryter kronikk-stilen. Bruk *kursiv* eller omformuler.",
},
{
id: "B8-ai-tells",
ref: "§B-8",
severity: "warn",
kind: "regex",
patterns: [
"la meg være ærlig",
"i en verden der",
"det handler ikke om .{1,40}? det handler om",
],
message:
"AI-tell (regel 8). Fjern/omformuler. Full liste: auto-memory `no-ai-tell-phrases`.",
},
];

View file

@ -0,0 +1,64 @@
/**
* Types for the deterministic §B/§C1 mechanical binding-gate.
*
* The gate consumes a *structured* ruleset (data, not hardcoded logic) so that
* the accumulation half (slice 2) can add a rule by appending one entry here +
* one row to the live skrivekontrakt.md the gate then enforces it next run.
*
* Source of truth for the seed rules: maskinrommet/docs/skrivekontrakt.md
* (§B De ti reglene, §C1 Regelsjekk-gate, §C2 prosa-håndverk).
*/
export type Severity = "block" | "warn";
export type RuleKind =
| "literal" // case-insensitive substring(s) — robust for æøå phrases
| "regex" // forbidden regex pattern
| "count" // count regex occurrences; flag when total > threshold (default 0)
| "density"; // count occurrences; flag when rate exceeds threshold per `per` words
export interface Rule {
/** Stable id, e.g. "B3-modell-referansepunkt". */
id: string;
/** Contract reference for the report, e.g. "§B-3 / §C1". */
ref: string;
severity: Severity;
kind: RuleKind;
/** Literal substrings (kind=literal) or regex sources (regex/count/density). */
patterns: string[];
/** Operator-facing direction: what the violation is + what to do. */
message: string;
/** count: max allowed before flagging (default 0). density: max per `per` words. */
threshold?: number;
/** density window size in words (default 50). */
per?: number;
/** Regex flags for regex/count/density (default "gi"). */
flags?: string;
}
export interface Match {
/** 1-based line in the ORIGINAL draft. */
line: number;
/** Short single-line excerpt around the match (from the original text). */
excerpt: string;
}
export interface Violation {
ruleId: string;
ref: string;
severity: Severity;
message: string;
matches: Match[];
/** Observed count/rate for count|density rules. */
observed?: number;
threshold?: number;
}
export interface GateResult {
draftPath: string | null;
wordCount: number;
blocks: Violation[];
warns: Violation[];
/** true iff there are no BLOCK violations — i.e. handoff is permitted. */
passed: boolean;
}