diff --git a/agents/persona-reviewer.md b/agents/persona-reviewer.md index e413e8d..0da7fd7 100644 --- a/agents/persona-reviewer.md +++ b/agents/persona-reviewer.md @@ -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. diff --git a/commands/newsletter.md b/commands/newsletter.md index 5f9d900..c0470b6 100644 --- a/commands/newsletter.md +++ b/commands/newsletter.md @@ -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 diff --git a/config/edition-state.template.json b/config/edition-state.template.json index bfa3262..69aab86 100644 --- a/config/edition-state.template.json +++ b/config/edition-state.template.json @@ -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 5–6.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: | 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 `), 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 `), 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 } } } diff --git a/config/personas.template.md b/config/personas.template.md index 881403f..984acce 100644 --- a/config/personas.template.md +++ b/config/personas.template.md @@ -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 diff --git a/references/longform-quality-rules.md b/references/longform-quality-rules.md index f14d1e8..db0369c 100644 --- a/references/longform-quality-rules.md +++ b/references/longform-quality-rules.md @@ -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. diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 0583d4f..64c860a 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -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 8–18). +# 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 8–18). # # 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"