feat(linkedin-studio): make long-form review language configurable [skip-docs]

Wave 3 / Step 9 of the remediation plan (Phase 1 — usable by a non-author).

The long-form review layer shipped Norwegian-locked: language-reviewer graded
unconditional Norwegian, voice-scrubber's gold standard was 'approved Norwegian
editions', and the editorial/content craft gates pointed at a 'skrivekontrakt §C2'
that does not ship. A non-Norwegian adopter would get English prose graded against
Norwegian idiom and a gate that depends on an unshipped contract.

- config/edition-state.template.json: add additive 'language' field (top-level,
  default 'en') + a _doc entry. Threads into the language-dependent agents.
- agents/language-reviewer.md: new 'Language parameter' section — Norwegian-specific
  checks (anglicism->Norwegian idiom, kanselli-stil) apply only when language=='no';
  any other value grades that language's equivalents and never flags idiomatic
  English as an anglicism. Default 'en'.
- agents/voice-scrubber.md: gold standard reframed to 'approved editions in the
  configured language'; the Norwegian-chronicle calibration is the language=='no'
  instantiation.
- agents/editorial-reviewer.md + agents/content-reviewer.md: the in-tree checklist
  is now the operative, self-contained source of truth; Maskinrommet §C2 is an
  optional upstream contract that does NOT ship (available only on the author's
  runs). The gates work for an adopter without it.
- commands/newsletter.md: thread 'language' through the Step 6.5 cold-inputs and the
  per-reviewer call inputs; the writing contract is now 'if it ships'.

Norwegian remains fully working when language: no (the author's case).

fact-reviewer.md was in the plan's file list but needed no change on inspection:
its F1-F4 checks (claims/quotes/numbers/sources) are language-agnostic; its
'Norwegian' mentions are boundary notes vs language-reviewer, which stay correct.

[skip-docs]: three-doc + version reconciliation is Step 21 (pre-review-gate); these
intermediate Wave commits are not pushed before the /trekreview gate.

Verify: edition-state JSON parses + has top-level language 'en'; language-reviewer
has 'language ==' references and no unconditional-Norwegian assertion; editorial
§C2 reframed to in-tree fallback ('operative source', 'does not ship'); agent
fixtures 35/35 pass; structural lint exit 0 (61 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-30 00:55:04 +02:00
commit 8d2968a482
6 changed files with 68 additions and 29 deletions

View file

@ -58,8 +58,10 @@ sentence is not. If you ever hand back edited prose, you have failed the role.
## Context isolation — you are a COLD reader (cardinal) ## Context isolation — you are a COLD reader (cardinal)
> You are an **adversarial, independent** reviewer, run in a **cold context**. > You are an **adversarial, independent** reviewer, run in a **cold context**.
> Your entire input is: this prompt, the path to a **frozen draft**, and the > Your entire input is: this prompt (with its self-contained C1C5 checklist) and
> writing contract. You have **no** access to — and must **refuse to act on** > the path to a **frozen draft** — no external writing contract ships or is
> required to run this gate. You have **no** access to — and must **refuse to act
> on** —
> any of: > any of:
> - the drafting session's conversation history; > - the drafting session's conversation history;
> - prior versions, version numbers, or a changelog; > - prior versions, version numbers, or a changelog;

View file

@ -80,23 +80,23 @@ Two different roles, both necessary, neither sufficient alone. A persona PASS is
**not** "ready for the editor" — it is "lands for the reader." This gate exists **not** "ready for the editor" — it is "lands for the reader." This gate exists
because those are not the same thing. because those are not the same thing.
## Truth source — the Maskinrommet writing-contract §C2 ## Truth source — the in-tree craft checklist (mirrors Maskinrommet §C2 when available)
The checklist below is the **operationalized mirror of Maskinrommet The **operative source of truth is the two-axis checklist below.** It ships
skrivekontrakt §C2.** §C2 documents the rule-set at the article-production level in-tree, is self-contained, and is everything you need to judge. It is the
(what a human editor checks); this agent operationalizes it as the automated operationalized mirror of the author's **Maskinrommet skrivekontrakt §C2**, which
pre-persona gate inside the plugin. documents the same rule-set at the article-production level (what a human editor
checks). **§C2 itself does not ship with the plugin** (it lives in the author's
series repo), so for any adopter the in-tree checklist *is* the contract — you do
**not** need §C2 to run this gate.
> **Mirror rule (bidirectional, cardinal).** §C2 is the source of truth. A change > **Mirror rule (only when you have §C2).** If — and only if — you are run with
> on either side MUST be mirrored to the other: if §C2 gains, drops, or reworks > access to the live §C2 text (the author's own runs), read it and reconcile any
> a check, this agent's checklist follows, and vice-versa. The two must never > drift before judging: §C2 is the upstream contract, this checklist its in-tree
> drift. This is the same relationship `references/longform-quality-rules.md` > transcription, and the two should not diverge. This mirrors the relationship
> rule 8 has with §A (skeleton-before-prose) — the pipeline satisfies the > `references/longform-quality-rules.md` rule 8 has with §A (skeleton-before-prose).
> writing contract *by construction*, and §C2 is the craft half of that contract. > **Absent §C2 (the default for any adopter), judge against the in-tree checklist
> as the complete, authoritative source — its absence is not a gap.**
If you are run with access to the live §C2 text, read it and reconcile any drift
before judging; if not, the two axes below are the faithful transcription of §C2
as of the agent's authorship (v2.4.0) and you judge against them.
## The Two Axes ## The Two Axes
@ -236,9 +236,11 @@ before the Step 6 persona sweep. Operator decides fold-in; this is [OPERATØR].
7. **The operator gates, you recommend.** Your output is a report for KTG, not a 7. **The operator gates, you recommend.** Your output is a report for KTG, not a
pipeline stop. BLOCK is your strongest recommendation, not a hard halt — the pipeline stop. BLOCK is your strongest recommendation, not a hard halt — the
gate is `[OPERATØR]`. gate is `[OPERATØR]`.
8. **§C2 is the source of truth.** If §C2 and this checklist disagree, §C2 wins 8. **The in-tree checklist is the operative source of truth.** It ships and is
and the checklist must be reconciled. Flag the drift; do not judge against a self-contained — judge against it. §C2 (the upstream contract) is available
stale checklist. only on the author's own runs; *if* you have it and it disagrees with this
checklist, flag the drift so the two can be reconciled. Absent §C2, its
absence is not a gap.
## Anti-Patterns ## Anti-Patterns
@ -261,9 +263,11 @@ before the Step 6 persona sweep. Operator decides fold-in; this is [OPERATØR].
## References ## References
Read these for the contract and the pipeline position: Read these for the contract and the pipeline position:
- **Maskinrommet skrivekontrakt §C2** — the source of truth this checklist - **Maskinrommet skrivekontrakt §C2** — the author's upstream contract this
mirrors (craft + architecture half of the writing contract; §A is the in-tree checklist transcribes (craft + architecture half; §A is the
skeleton-before-prose half codified in `longform-quality-rules.md` rule 8). skeleton-before-prose half codified in `longform-quality-rules.md` rule 8).
**Does not ship** — available only on the author's own runs; the in-tree
checklist above is the self-contained source for everyone else.
- `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — the broad Step 4 - `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — the broad Step 4
quality pass; this agent is the *finer* craft+architecture gate that runs after quality pass; this agent is the *finer* craft+architecture gate that runs after
it (rule 1 ≈ A5 overload; rule 3 ≈ some prose nits — defer the AI-tell face to it (rule 1 ≈ A5 overload; rule 3 ≈ some prose nits — defer the AI-tell face to

View file

@ -38,6 +38,24 @@ You run at **Step 6.5** of the `/linkedin:newsletter` pipeline — *after* the
in-session persona resonance sweep (Step 6), on a **frozen** draft, *before* in-session persona resonance sweep (Step 6), on a **frozen** draft, *before*
lock — and you are invocable standalone via `/linkedin:headless-review`. lock — and you are invocable standalone via `/linkedin:headless-review`.
## Language parameter — what language you grade against (configurable)
You receive a **`language`** input from the edition-state
(`config/edition-state.template.json`, default `"en"`). It tells you which
language's rules to grade against — the review language is **not** hardcoded:
- **`language == "no"` (Norwegian — the author's case):** the five
Norwegian-specific checks below apply in full — anglicisms flagged toward the
Norwegian idiom, «kanselli-stil», Norwegian clang/rhythm. This is the original
instantiation of the gate.
- **`language: "en"` (default) or any other value:** apply the *equivalent* checks
for THAT language (calques into that language, that language's stiff/bureaucratic
register, that language's rhythm) and **never** grade the prose against Norwegian
idiom — do not flag idiomatic English as an "anglicism."
If no `language` is supplied, assume `"en"`. Where the checks below say
"Norwegian", read it as "the configured language" unless `language == "no"`.
## Pipeline position ## Pipeline position
You are one of three **cold, headless re-readers** in the Step 6.5 package (with You are one of three **cold, headless re-readers** in the Step 6.5 package (with

View file

@ -41,6 +41,14 @@ editions.
This is the single most important rule of this agent. This is the single most important rule of this agent.
> **Language parameter (configurable).** The review `language` is an input from
> the edition-state (default `"en"`). The gold standard is the approved editions
> **in the configured language**. The Norwegian-chronicle calibration below is the
> `language == "no"` instantiation (the author's case); for any other language,
> substitute that language's approved editions and read the Norwegian-specific
> notes (the em-dash habit, «kanselli-stil») as that language's equivalents. Never
> calibrate one language's voice against another's.
- The gold standard for Norwegian chronicle voice is the **approved Norwegian - The gold standard for Norwegian chronicle voice is the **approved Norwegian
editions** (e.g. the series' approved Del 1 + Del 2). The caller passes the editions** (e.g. the series' approved Del 1 + Del 2). The caller passes the
path(s); read them as the corpus before scrubbing. path(s); read them as the corpus before scrubbing.

View file

@ -1041,21 +1041,26 @@ command runs — see `commands/headless-review.md` for the full cold contract):
``` ```
Record the frozen path; pass *that* path (not the live draft) to every reviewer. Record the frozen path; pass *that* path (not the live draft) to every reviewer.
2. **Resolve the cold inputs.** The writing contract (`<serie>/../../docs/skrivekontrakt.md` 2. **Resolve the cold inputs.** The **review language** (`edition-state.language`,
→ plugin mirror → `references/longform-quality-rules.md`) and the active default `en` — tells `language-reviewer` and `voice-scrubber` which language's
rules to grade against; Norwegian-specific checks fire only when `language: no`),
the writing contract *if it ships* (`<serie>/../../docs/skrivekontrakt.md`
plugin mirror → `references/longform-quality-rules.md`; absent for an adopter,
the craft agents' in-tree checklists are self-contained), and the active
personas (`articles.NN.personas`, primær identified). Nothing else. personas (`articles.NN.personas`, primær identified). Nothing else.
3. **Fan out the five archetypes in parallel** — issue them in a SINGLE message 3. **Fan out the five archetypes in parallel** — issue them in a SINGLE message
(multiple `Task` tool-uses) from THIS command layer in the foreground (multiple `Task` tool-uses) from THIS command layer in the foreground
(principle 4), `subagent_type` namespaced: (principle 4), `subagent_type` namespaced:
- `linkedin-studio:content-reviewer` — argument integrity (C1C5) - `linkedin-studio:content-reviewer` — argument integrity (C1C5)
- `linkedin-studio:language-reviewer`Norwegian language (L1L5) - `linkedin-studio:language-reviewer`language quality (L1L5; grades against `edition-state.language`, Norwegian-specific rules when `language: no`)
- `linkedin-studio:fact-reviewer` — cold re-verification (F1F4, 🔴/🟡/🟢, incl. pivot premises) - `linkedin-studio:fact-reviewer` — cold re-verification (F1F4, 🔴/🟡/🟢, incl. pivot premises)
- `linkedin-studio:persona-reviewer` `mode: resonans` — **one call per active persona** - `linkedin-studio:persona-reviewer` `mode: resonans` — **one call per active persona**
- `linkedin-studio:persona-reviewer` `mode: konverter`**primær only** (hook) - `linkedin-studio:persona-reviewer` `mode: konverter`**primær only** (hook)
Each call's prompt carries ONLY the cold-contract inputs (frozen draft path, Each call's prompt carries ONLY the cold-contract inputs (frozen draft path,
contract path, persona for the persona modes) + the instruction to ignore any `language`, contract path if it ships, persona for the persona modes) + the
instruction to ignore any
framing about prior versions / cuts / pivots. **Never** paste history or framing about prior versions / cuts / pivots. **Never** paste history or
summarize "what we changed" into a reviewer prompt — that is the context summarize "what we changed" into a reviewer prompt — that is the context
pollution the package exists to eliminate. pollution the package exists to eliminate.

View file

@ -27,13 +27,15 @@
"visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v<N>-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v<N>-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig<N>.png (inline). credit + caption are recorded in <serie>/linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].", "visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v<N>-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v<N>-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig<N>.png (inline). credit + caption are recorded in <serie>/linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].",
"personas": "Per-article resolved reader-persona set (input config), written/confirmed in Step 1. This makes personas configurable PER ARTIFACT, not just from one global plugin library: Step 1 resolves them in order — (1) already present here → use as-is; (2) <serie>/linkedin/personas.md (per-series file) → load; (3) plugin config/personas.local.md (or personas.template.md) library → select a subset; (4) none/insufficient → DEFINE interactively via AskUserQuestion. Exactly one entry has tier \"primær\" (the rest \"sekundær\"); «primær trumfer» on conflict. This set feeds BOTH the in-session sweep (Step 6) and the headless package (Step 6.5 / persona-reviewer). Each entry: { name, tier: \"primær\" | \"sekundær\", rolle, avkobler, overbeviser, ekspertise, sjargong, source: \"edition-state\" | \"series-file\" | \"plugin-library\" | \"interactive\" }. Default []: resolved on first Step 1.", "personas": "Per-article resolved reader-persona set (input config), written/confirmed in Step 1. This makes personas configurable PER ARTIFACT, not just from one global plugin library: Step 1 resolves them in order — (1) already present here → use as-is; (2) <serie>/linkedin/personas.md (per-series file) → load; (3) plugin config/personas.local.md (or personas.template.md) library → select a subset; (4) none/insufficient → DEFINE interactively via AskUserQuestion. Exactly one entry has tier \"primær\" (the rest \"sekundær\"); «primær trumfer» on conflict. This set feeds BOTH the in-session sweep (Step 6) and the headless package (Step 6.5 / persona-reviewer). Each entry: { name, tier: \"primær\" | \"sekundær\", rolle, avkobler, overbeviser, ekspertise, sjargong, source: \"edition-state\" | \"series-file\" | \"plugin-library\" | \"interactive\" }. Default []: resolved on first Step 1.",
"headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.", "headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.",
"pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 56.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default []." "pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 56.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default [].",
"language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents."
}, },
"schemaVersion": 1, "schemaVersion": 1,
"series": { "series": {
"slug": "<series-slug>", "slug": "<series-slug>",
"title": "<Series title>" "title": "<Series title>"
}, },
"language": "en",
"currentArticle": "01", "currentArticle": "01",
"currentPhase": "load-context", "currentPhase": "load-context",
"updatedAt": "<ISO-8601 timestamp>", "updatedAt": "<ISO-8601 timestamp>",