fix(linkedin-studio): N10 — konfigurerbart målnivå-spenn [skip-docs]

C-8: gjør «praktisk anvendbar på alle nivå» til en konfigurerbar gate-akse og
fjerner den hardkodede primær-personaen. 6-fils scope (KTG-valgt) — gjør
targetLevel til en EKTE ende-til-ende-dimensjon i stedet for et fantomfelt:

- references/longform-quality-rules.md: «non-technical line manager» → brukerens
  konfigurerte target-level span (domene-generelt).
- config/personas.template.md: «primær trumfer» krever nå obligatorisk sekundær i
  MOTSATT ende av spennet; sekundær-NEI kan kun avskrives som «signal, ikke svikt»
  med eksplisitt ceiling-begrunnelse. Ny tekniker-ende-seed (Persona 4,
  Løsningsutvikler/AI-ingeniør) — tidligere kun 3 leder-personas.
- config/edition-state.template.json: additivt articles.NN.targetLevel-slot
  (default null, INGEN schemaVersion-bump — samme mønster som sourceTrendId/language).
- commands/newsletter.md: persister resolvert targetLevel ved Step 1.5-checkpointet.
- agents/persona-reviewer.md: leser articles.NN.targetLevel og plasserer personaene
  på spennet (fallback til persona-ekspertise om feltet er null).

Premiss-korreksjon: planens 4-fils scope antok targetLevel fantes i edition-state
via N7-broen; det stemte ikke (fantes kun i trend-store + fritekst-mapping). +2 filer
(edition-state-slot + newsletter-persist) gjør konsumet reelt.

TDD: ny Section 16q i test-runner (9 UNCOND. .md/.json-greps + non-vacuity self-test);
en vacuity-bug i egen edition-state-check fanget og skjerpet (grep slot-literal
"targetLevel": null, ikke bare ordet — sourceTrendId-docstringen inneholder allerede
"targetLevel"). test-runner 154→163/0, anti-erosion floor 137→146. Øvrige fem suiter
uendret grønne (trends 300 · brain 134 · hooks 140 · tests 35 · render 60).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
This commit is contained in:
Kjell Tore Guttormsen 2026-07-24 20:16:41 +02:00
commit 08106317db
6 changed files with 186 additions and 17 deletions

View file

@ -98,6 +98,18 @@ Read the named persona from `config/personas.template.md` (or the project's
**overbeviser**, **ekspertise**, **sjargong**. Judge as that reader — not as
yourself, not as a generic audience. One run = one persona.
Then read `articles.NN.targetLevel` from the edition-state (set at Step 1 of
`/linkedin:newsletter` — the N7 trend bridge / N6 proposal layer pre-fills it,
the operator confirms it during calibration). It fixes where this persona sits on
**the edition's configured target-level span**: the primær anchors the
reader-facing end, and the active set MUST include a mandatory secondary at the
OPPOSITE end of that span. Place this persona on the span accordingly — a
secondary NO from a reader at the far end is a SIGNAL only when the persona
genuinely sits at that end, and only with an explicit ceiling justification;
otherwise it is a real flag. If `articles.NN.targetLevel` is absent (a
manually-started edition that never resolved one), fall back to the persona's own
**ekspertise** field to locate it on the span.
### Step 2: Read the input as that reader
Read top to bottom, read-only, once, the way this reader actually would.

View file

@ -300,7 +300,10 @@ what genuinely changes the work.
> point the operator confirms or edits* in the dimensions below, not a bypass of
> calibration:
> - `angle` → the edition **Angle**.
> - `targetLevel` → the audience/tone calibration (**Audience** / **Tone**).
> - `targetLevel` → the audience/tone calibration (**Audience** / **Tone**); the
> resolved value is also persisted to `articles.NN.targetLevel` at the Step 1.5
> checkpoint (below) so persona-reviewer can place the personas on the configured
> target-level span (which end is primær, which is the mandatory secondary).
> - `summary` + `rationale` + `readerQuestion` / `painPoint` → seed the **Key
> points** and the **Leader-takeaway** (the reader-side framing this bridge
> exists to carry: what the reader would DO with it).
@ -471,6 +474,12 @@ Edition brief
- If Step 1 ran the **trend-intake bridge**, persist `articles.NN.sourceTrendId`
(the trend id) here too — the durable provenance link Step 10 reads to auto-`act`
the trend. Omit it (leave `null`) for a manually-started edition.
- Persist the resolved **`targetLevel`** (Step 1's Audience/Tone calibration; the
trend bridge pre-fills it from the trend record) to `articles.NN.targetLevel` — the
per-article position on the configured target-level span that persona-reviewer reads
to place the personas (which end is primær, which is the mandatory secondary). Leave
`null` if the operator did not resolve a level; the reader agents then fall back to
each persona's own `ekspertise` field.
- Persist the resolved **`language`** (Step 1) — write it to the **top-level**
`language` (the series/edition-level review language; there is no per-article
`language` field) so the language-dependent review agents grade against the

View file

@ -33,7 +33,8 @@
"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 [].",
"foldIns": "Per-article accumulation queue (slice 2 of fix #1 — «rettelser fester seg»). Each correction KTG makes during an edition that is NOT yet a contract rule is captured here, then routed by the JA-promoter (maskinrommet/docs/skrivekontrakt.md §E) to a permanent home so it never has to be re-discovered: a MECHANICAL correction → a rules.ts gate rule (BLOCK/WARN) + a §B-row/§C1/§C2-box + a §E-manifest row (the contract-gate `ratify` check then asserts rules.ts ↔ §E-manifest stay in bijection); a JUDGMENT correction → a §C2-box only (stays with editorial-reviewer, no gate rule). Capture is per-article (provenance = which article surfaced it); promotion is series/contract-wide. Each entry: { id, date (ISO-8601), correction (what KTG corrected, near-verbatim), trigger (where/why it surfaced), classification: \"mechanical-block\" | \"mechanical-warn\" | \"judgment\" | null (set at the classify step), decision: \"pending\" | \"promoted\" | \"rejected\" (the JA-promoter outcome), ruleId: <rules.ts id> | null (set on promote for mechanical), note?: where a judgment/rejected fold-in landed }. Default []. Rejected fold-ins are kept for traceability, never deleted.",
"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.",
"sourceTrendId": "Per-article provenance link to the trend this edition was started from (N7 trend→newsletter bridge, MR-F3). Additive-optional (default null, NO schemaVersion bump — an edition started manually never sets it, and Step 10 reads its absence as \"no source trend\"). Set at the Step 1.5 checkpoint when Step 1's trend-intake read a candidate from the trends store (scripts/trends) to prefill the brief (angle / targetLevel / key-points / source-URLs). Its ONE runtime consumer is Step 10 (scheduling): when the article reaches scheduling AND sourceTrendId is set, the command flips that trend's store status to \"acted\" (trends CLI `act --id <sourceTrendId>`), closing the discovery→production loop deterministically instead of by hand. Value = the trend's store id (normalized title+url, as shown in the /linkedin:trends brief and `CLI list --json`)."
"sourceTrendId": "Per-article provenance link to the trend this edition was started from (N7 trend→newsletter bridge, MR-F3). Additive-optional (default null, NO schemaVersion bump — an edition started manually never sets it, and Step 10 reads its absence as \"no source trend\"). Set at the Step 1.5 checkpoint when Step 1's trend-intake read a candidate from the trends store (scripts/trends) to prefill the brief (angle / targetLevel / key-points / source-URLs). Its ONE runtime consumer is Step 10 (scheduling): when the article reaches scheduling AND sourceTrendId is set, the command flips that trend's store status to \"acted\" (trends CLI `act --id <sourceTrendId>`), closing the discovery→production loop deterministically instead of by hand. Value = the trend's store id (normalized title+url, as shown in the /linkedin:trends brief and `CLI list --json`).",
"targetLevel": "Per-article target-level for the edition (N10 / C-8). Additive-optional (default null, NO schemaVersion bump — same pattern as sourceTrendId/language; an edition that never resolves one leaves it null and the reader agents fall back to each persona's own `ekspertise` field). Fixes WHERE this edition sits on the configured target-level span (references/longform-quality-rules.md + config/personas.template.md): it tells persona-reviewer which end is the reader-facing (primær) end and which is the mandatory-secondary end, so «practically usable at all levels» is judged from both ends rather than one baked-in reader role. Set at Step 1 of /linkedin:newsletter — the N7 trend bridge / N6 proposal layer pre-fills it from the trend record's `targetLevel` (scripts/trends), the operator confirms or edits it during calibration, and it is persisted here at the Step 1.5 checkpoint (alongside sourceTrendId). Free-text level descriptor (e.g. \"line leader → solution engineer\", \"beginner → practitioner\"), NOT an enum — the span is domain-general and defined by the operator's persona set, never hardcoded."
},
"schemaVersion": 1,
"series": {
@ -92,7 +93,8 @@
"foldIns": [],
"locked": false,
"scheduled": null,
"sourceTrendId": null
"sourceTrendId": null,
"targetLevel": null
}
}
}

View file

@ -13,7 +13,7 @@ cp config/personas.template.md config/personas.local.md
```
`personas.local.md` is gitignored (via `*.local.md`) so your active overrides
stay local. The template ships the three Seres seed personas below; clone,
stay local. The template ships the four Seres seed personas below; clone,
trim, or extend them per series.
---
@ -23,11 +23,16 @@ trim, or extend them per series.
- **Per-project selection.** `/linkedin:newsletter` (Step 1) picks the relevant
personas from this library and marks the primary in the edition brief.
- **«primær trumfer».** Exactly one persona is the **primær** reader. On
conflict between personas, the primær weighs highest. A *secondary* NO caused
by role mismatch or an expertise ceiling («this I already know cold») is a
SIGNAL that the gate works — accept it, do not distort the text to chase it.
A *primær* NO is **not** accepted: the text is revised until the primær
reaches a clean YES.
conflict between personas, the primær weighs highest. But the active set MUST
include a **mandatory secondary at the opposite end of the target-level span**
from the primær (a technical reader when the primær is non-technical, and vice
versa) — one reader alone cannot certify a text «practically usable at all
levels», so a lone primær JA is not enough. A *secondary* NO caused by role
mismatch or an expertise ceiling («this I already know cold») is a SIGNAL that
the gate works — but it may be waived as «signal, not failure» **only with an
explicit ceiling justification** that names which end of the span hit its
ceiling; an unexplained secondary NO is a real flag, not a free pass. A *primær*
NO is **not** accepted: the text is revised until the primær reaches a clean YES.
- **Two sweep modes** (same `persona-reviewer` agent): resonance mode (Step 6,
BEFORE lock — «does the point land for this reader?») and conversion mode
(Step 9, after lock — binary «would YOU click?» on the hook only).
@ -64,8 +69,9 @@ can be promoted to a reusable block by pasting them into `personas.local.md`
The persona sweep is not advisory — it returns a **blocking verdict**
(PASS / REWORK / BLOCK), and the bar is the **primær reader's genuine, unqualified
JA**. The three Seres seed personas are the canonical set: **A = IT-divisjonsdirektør**
(sekundær), **B = KI-seksjonsleder** (sekundær), **C = Linjeleder** (PRIMÆR — trumfer).
JA**. The four Seres seed personas are the canonical set: **A = IT-divisjonsdirektør**
(sekundær), **B = KI-seksjonsleder** (sekundær), **C = Linjeleder** (PRIMÆR — trumfer),
**D = Løsningsutvikler/AI-ingeniør** (sekundær — the mandatory technical end opposite C).
- **Bar = C ekte JA.** A clean, unqualified yes from the primær. **«JA med store
forbehold» = NEI.**
@ -143,6 +149,27 @@ pipeline and the structural check key off them:
- **sjargong** — Lav toleranse for teknisk sjargong; setter pris på presise,
hverdagsnære formuleringer.
### Persona 4 — Løsningsutvikler/AI-ingeniør (sekundær)
> **Technical end of the target-level span** (opposite the primær persona). The
> mandatory secondary the «primær trumfer» rule requires: if the primær (Linjeleder)
> anchors the reader-facing end, this persona anchors the technical end, so a text
> that claims to work «at all levels» is judged from both ends, not just one.
- **rolle** — Bygger og drifter AI-løsningene i praksis (utvikler, ML-ingeniør eller
løsningsarkitekt); skriver koden, velger modellene, eier integrasjonene.
- **avkobler** — Ledelses-abstraksjoner uten teknisk substans; «AI-magi» uten hvordan;
påstander om hva som er mulig som ikke tåler et implementasjons-blikk; nedlatende
forenkling av ting hen kan fra innsiden.
- **overbeviser** — Konkret hvordan-det-faktisk-virker, ærlige begrensninger og
feilmodi, reelle avveininger (latency/kost/kvalitet), og respekt for at
dømmekraften i systemdesign er ekte fag.
- **ekspertise** — Høy teknisk. Ekspertise-tak på grunnleggende forklaringer: en
«hva er en API»-passasje faller flatt (sekundær-NEI her er et signal — men bare
med eksplisitt ceiling-begrunnelse, jf. «primær trumfer»).
- **sjargong** — Høy toleranse for teknisk/kode-sjargong; lav for lederspråk og
konsulent-buzzwords.
---
## Adding a persona

View file

@ -31,8 +31,10 @@ they will do about it.
- If the reader cannot state the takeaway in one sentence, the text is not done —
tighten until they can.
- **Serve what the reader can DO, not what the author knows.** The text exists to
give the *primær* reader (the non-technical line manager) something they can act
on from their own chair — not to demonstrate the author's breadth. Completeness
give the *primær* reader (whoever sits at the reader-facing end of the edition's
configured **target-level span** — see `config/personas.template.md`) something
they can act on from their own chair — not to demonstrate the author's breadth.
Completeness
is not a virtue: if a passage exists to be thorough rather than to move the
reader's decision forward, cut it. Choose ONE concrete, verifiable (preferably
Norwegian) case over an exhaustive list.

View file

@ -67,8 +67,13 @@
# non-vacuity self-test) in Section 16n; the randsone-gates guard (N9: newsletter.md Step 9 runs a scoped
# fact-reviewer + language-reviewer pass over the carousel slide text AND edition title, gates title-strength,
# declares the deterministic 'numbers, names, or quotes' recheck trigger, and both fact agents carry WebFetch,
# with a non-vacuity self-test) in Section 16p; the assertion-count anti-erosion floor (SC6) in Section 18. All
# are live below (Sections 818).
# with a non-vacuity self-test) in Section 16p; the configurable target-level span guard (N10: the hardcoded
# 'non-technical line manager' is gone from references/ + config/, references/longform-quality-rules.md names
# the 'target-level span', config/personas.template.md requires a mandatory secondary at the opposite end +
# an explicit ceiling justification + seeds a technical-end persona, config/edition-state.template.json carries
# the additive articles.NN.targetLevel slot, commands/newsletter.md persists it, agents/persona-reviewer.md
# consumes it onto the span, with a non-vacuity self-test) in Section 16q; the assertion-count anti-erosion
# floor (SC6) in Section 18. All are live below (Sections 818).
#
# Usage: bash scripts/test-runner.sh
# bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`.
@ -1691,6 +1696,114 @@ fi
echo ""
# --- Section 16q: Configurable Target-Level Span (N10 / C-8) ---
echo "--- Configurable Target-Level Span (N10) ---"
# N10 turns "practically usable at all levels" into a CONFIGURABLE gate axis and removes the
# hardcoded primary persona. Six .md/.json surfaces must hold the invariants (all UNCONDITIONAL
# greps -> they raise the deps-absent floor):
# (C-8 absence) the hardcoded primary "non-technical line manager" is GONE from references/ + config/
# — the quality-rule now points at the edition's CONFIGURED target-level span instead.
# (C-8 span-ref) references/longform-quality-rules.md names the "target-level span" (domain-general),
# not a single baked-in reader role.
# (C-8 obligatorisk-sekundær) config/personas.template.md's «primær trumfer» rule now REQUIRES a
# mandatory secondary at the opposite end of the span; a secondary NO may be waived
# as «signal, not failure» ONLY with an explicit ceiling justification.
# (C-8 tekniker-seed) the seed library gains a technical-end persona (today all three seeds are leaders).
# (C-8 edition-state) config/edition-state.template.json carries an additive per-article targetLevel slot.
# (C-8 persist) commands/newsletter.md persists the resolved targetLevel to articles.NN.targetLevel.
# (C-8 consume) agents/persona-reviewer.md READS articles.NN.targetLevel and places personas on the span
# — a compound predicate (targetLevel field + edition-state read + span placement), so a
# non-vacuity self-test guards it (mirrors Section 16p).
PR_N10="agents/persona-reviewer.md"
targetlevel_consumed() { # $1 = text; wired iff it reads targetLevel FROM edition-state AND places on the span
echo "$1" | grep -qF "targetLevel" \
&& echo "$1" | grep -qF "articles." \
&& echo "$1" | grep -qF "target-level span"
}
TL_SELFTEST_OK=1
if ! targetlevel_consumed "read articles.NN.targetLevel and place the personas on the target-level span"; then
TL_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired targetLevel-consumption probe was not detected"
fi
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if targetlevel_consumed "$probe"; then
TL_SELFTEST_OK=0; echo " false-positive FAIL: under-wired targetLevel probe accepted -> $probe"
fi
done <<'NEGATIVE16Q'
place the personas on the target-level span but never read the field
read articles.NN.targetLevel but do not mention the span placement here
the targetLevel matters for the target-level span without an edition-state read
NEGATIVE16Q
if [ "$TL_SELFTEST_OK" -eq 1 ]; then
pass "targetLevel-consumption self-test: predicate needs targetLevel + edition-state read (articles.) + span placement (1 accepted, 3 under-wired rejected)"
else
fail "targetLevel-consumption self-test failed - the N10 consumption lint is vacuous or over-eager"
fi
# (C-8 absence) the hardcoded primary persona is gone from the shippable surfaces
if grep -rn "non-technical line manager" references/ config/ >/dev/null 2>&1; then
fail "N10: hardcoded 'non-technical line manager' still present in references/ or config/ — the primary persona is not de-hardcoded (C-8)"
else
pass "N10: no hardcoded 'non-technical line manager' in references/ or config/ (C-8 absence)"
fi
# (C-8 span-ref) the quality rule points at the configured span, not a baked-in role
if grep -qF "target-level span" references/longform-quality-rules.md; then
pass "references/longform-quality-rules.md names the configured 'target-level span' (C-8 span-ref)"
else
fail "references/longform-quality-rules.md missing 'target-level span' — the served-reader rule is still role-hardcoded (C-8)"
fi
# (C-8 obligatorisk-sekundær) the «primær trumfer» rule requires a mandatory secondary at the opposite end
if grep -qF "opposite end of the target-level span" config/personas.template.md; then
pass "config/personas.template.md requires a mandatory secondary at the opposite end of the span (C-8 obligatorisk-sekundær)"
else
fail "config/personas.template.md missing the 'opposite end of the target-level span' requirement — one reader can still self-certify all levels (C-8)"
fi
# (C-8 ceiling-begrunnelse) a secondary NO may only be waived with an explicit ceiling justification
if grep -qF "explicit ceiling justification" config/personas.template.md; then
pass "config/personas.template.md gates the secondary-NO waiver behind an explicit ceiling justification (C-8 ceiling-begrunnelse)"
else
fail "config/personas.template.md missing 'explicit ceiling justification' — a secondary NO can still be waved away as 'signal, not failure' unproven (C-8)"
fi
# (C-8 tekniker-seed) the seed library gains a technical-end persona (today all three seeds are leaders)
if grep -qF "Technical end of the target-level span" config/personas.template.md; then
pass "config/personas.template.md seeds a technical-end persona opposite the leader personas (C-8 tekniker-seed)"
else
fail "config/personas.template.md missing a 'Technical end of the target-level span' seed — the library is still leader-only (C-8)"
fi
# (C-8 edition-state) the per-article targetLevel SLOT exists (additive, no schemaVersion bump).
# Grep the slot's default literal `"targetLevel": null`, NOT the bare word — the latter already occurs
# inside the sourceTrendId doc-string ("angle / targetLevel / key-points"), which would match vacuously.
if grep -qF '"targetLevel": null' config/edition-state.template.json; then
pass "config/edition-state.template.json carries the additive articles.NN.targetLevel slot (default null, C-8 edition-state)"
else
fail "config/edition-state.template.json missing the '\"targetLevel\": null' slot — persona-reviewer would consume a phantom field (C-8)"
fi
# (C-8 persist) newsletter.md persists the resolved targetLevel to the per-article slot
if grep -qF "articles.NN.targetLevel" commands/newsletter.md; then
pass "commands/newsletter.md persists the resolved targetLevel to articles.NN.targetLevel (C-8 persist)"
else
fail "commands/newsletter.md never persists articles.NN.targetLevel — the slot stays null and consumption is vacuous (C-8)"
fi
# (C-8 consume) persona-reviewer reads targetLevel from edition-state and places personas on the span
PR_CONTENT=$(cat "$PR_N10")
if targetlevel_consumed "$PR_CONTENT"; then
pass "agents/persona-reviewer.md reads articles.NN.targetLevel and places personas on the target-level span (C-8 consume)"
else
fail "agents/persona-reviewer.md does not consume articles.NN.targetLevel onto the span — targetLevel is set but never read (C-8)"
fi
echo ""
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
@ -1725,12 +1838,16 @@ echo ""
# Step-8 auto-gull compound grep) = 129; +8 for N9's eight UNCONDITIONAL Section-16p checks (randsone-gate
# self-test + Step-9 randsone_gated compound grep + Step-9 title-strength grep + newsletter C-6 trigger-nouns
# grep + newsletter C-6 scoped-fact-reviewer grep + newsletter C-6 determinism-marker grep + fact-checker
# WebFetch grep + fact-reviewer WebFetch grep) = 137.
# WebFetch grep + fact-reviewer WebFetch grep) = 137; +9 for N10's nine UNCONDITIONAL Section-16q checks
# (targetLevel-consumption self-test + no-hardcoded-line-manager absence grep + references target-level-span
# grep + personas opposite-end obligatorisk-sekundær grep + personas explicit-ceiling-justification grep +
# personas technical-end seed grep + edition-state targetLevel-slot grep + newsletter articles.NN.targetLevel
# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
# clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=137
ASSERT_BASELINE_FLOOR=146
TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"