feat(linkedin-studio): N8 — stemme-kjeden (chronicle-voice-profile + drift-logg + language-resolve + auto-gull) [skip-docs]

Bind the long-form VOICE before prose — the discipline the line already
applies to SOURCES (validate-binding), its strongest link.

- C-1/A2-F1: voice-trainer distils a chronicle-voice-profile from the
  published gold corpus (per language/format); newsletter Step 0/3a/3b bind
  drafting to it, with an explicit never-silent fallback to the short-form
  corpus (which the engine itself calls degrading for chronicle voice).
- C-2: Step 3a Pass 0 READS the chronicle-voice-drift-log as a negative list
  (it was append-only, never consumed); Step 3b re-reads it in expansion.
- C-3: Step 1 resolves + persists the top-level edition-state.language, making
  the template doc-string's "Resolved at Step 1" claim true; the Norwegian
  review axis (language-reviewer / voice-scrubber) now fires when the edition
  IS Norwegian instead of being silently off by default. Step 6.5 already
  consumed the field — resolving it closes the axis end-to-end.
- A2-F2: Step 8 lock appends the locked edition to ingest/published
  (provenance=published), so the gold corpus the profile distils from grows
  one edition at a time — never the engine's drafts.

Domain-general throughout (languages/formats from the user's own corpus).
test-runner 140 -> 146 (Section 16o: 6 unconditional voice-chain greps +
non-vacuity self-test; anti-erosion floor 123 -> 129). All six suites green
(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-23 22:58:38 +02:00
commit b30b0e208f
4 changed files with 197 additions and 13 deletions

View file

@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`arcs` CLI verb + `/linkedin:trends --demand` mode** — render the §4 arc map (`node --import tsx src/cli.ts arcs`), reachable via the command's new demand mode (delegates to `demand-spotter`, keeps commands at 30). The morning brief also shows the per-candidate demand signal alongside the existing reader fields.
- Trends suite 276 → 300; test-runner 139 → 140; `tsc --noEmit` clean.
### Added — voice chain: bind voice BEFORE prose (N8 / C-1, C-2, C-3, A2-F1, A2-F2)
- **`chronicle-voice-profile` artifact (C-1/A2-F1)** — `voice-trainer` now distils a dedicated long-form voice profile (`${DATA}/profile/chronicle-voice-profile.md`) from the **published gold corpus only** (`ingest/published/`, `provenance=published`), one section per `(language, format)` pair present in the corpus — never from the English short-form `authentic-voice-samples.md` the engine itself calls degrading for chronicle voice, never from `provenance=ai-draft`. `/linkedin:newsletter` binds long-form drafting (Step 0 / 3a / 3b) to THAT profile as the primary reference, with an **explicit, never-silent fallback** to the short-form corpus when it is absent.
- **Drift log is READ, not just written (C-2)** — Step 3a gains a **Pass 0**: read `chronicle-voice-drift-log.md` as a *negative list* of the recurring AI-tells/drifts `voice-scrubber` logged on prior editions, so drafting avoids them BEFORE the scrub (the log was append-only, never consumed); Step 3b re-reads it during expansion.
- **Review language resolved + persisted at Step 1 (C-3)** — the calibration now **resolves the review `language`** (detect/ask) and persists the top-level `edition-state.language` at the Step 1.5 checkpoint, making the template's "Resolved at Step 1" doc-string true. Previously the default `"en"` was never set, so `language-reviewer`'s Norwegian checks and `voice-scrubber`'s gold standard silently never fired even for a Norwegian edition; Step 6.5 already consumed the field, so resolving it closes the axis end-to-end.
- **Auto-gull — the gold corpus grows on its own (A2-F2)** — Step 8 lock now appends the locked edition to `ingest/published/` tagged `provenance=published`, so the corpus the chronicle profile is distilled from grows one edition at a time (the author's own locked editions, never the engine's drafts). No manual curation.
- Domain-general throughout: languages/formats come from the user's own corpus, never hardcoded. test-runner 140 → 146 (Section 16o: 6 unconditional voice-chain greps + self-test; anti-erosion floor 123 → 129).
### Built feedback (awaiting consumer-side proof)
- **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).

View file

@ -152,6 +152,39 @@ Architecture: [prose/sectioned/framework]
Flag if: fewer than 5 samples (low confidence), single time period (temporal bias), or contradictory patterns (possible ghostwriting).
### Long-form chronicle voice profile (per language / format)
Short-form feed posts and long-form editions (newsletters, essays, chronicles) are
**different registers of the same author** — a device the author bans in a punchy
short post (e.g. the em-dash) can be load-bearing rhythm in a long-form chronicle.
Drafting long-form against the short-form corpus therefore *degrades* the chronicle
voice (this is `voice-scrubber`'s canonical rule). So the long-form pipeline binds
to its own distilled artifact, which this agent generates:
- **Artifact:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`
— a distilled long-form voice profile built with the same six-dimension
framework as the general profile, but from **only the long-form gold corpus**.
- **Corpus (published gold only):** the approved/published long-form editions under
`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/published/`
carrying `provenance=published`. The same model-collapse fence applies —
**never distil chronicle voice from `provenance=ai-draft`**, and never from the
short-form `voice-samples/authentic-voice-samples.md` corpus (wrong register).
- **Per language AND format — domain-general.** Emit one profile section per
`(language, format)` pair actually present in the corpus (e.g. `no` / newsletter),
keyed by those axes; the author's languages and formats come from their OWN
corpus, never hardcoded. If a `(language, format)` pair has no published editions
yet, say so plainly and emit no section for it — do not invent a chronicle voice
from short-form samples or from assumptions.
- **Grows automatically.** Each locked edition is appended to the gold corpus at
`/linkedin:newsletter` Step 8 (`provenance=published`), so re-running this
distillation sharpens the chronicle profile over editions without manual curation.
Trigger this distillation on the same events as the general profile (a new
published edition, quarterly audit, explicit request). The long-form pipeline
(`/linkedin:newsletter` Step 0 / 3a / 3b) reads this artifact; when it is missing,
that pipeline warns and falls back — it never silently drafts against the wrong
corpus.
## Voice Drift Detection
### Drift Scoring

View file

@ -176,9 +176,24 @@ the edition left off before doing anything.
Step 2. Do not confuse `<serie>/STATE.md` (this edition's production state)
with the plugin's own `STATE.md` / `docs/BUILD-HANDOVER.local.md` (which govern
building the plugin itself).
4. **Read the voice profile**`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md`
and anything else under `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`. Long-form must match the
author's voice; this is the reference for every drafting and review phase.
4. **Read the long-form voice profile** — the **chronicle-voice-profile**
(`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`),
the distilled chronicle voice for THIS edition's language/format, built by
`voice-trainer` from the published gold corpus. This is the **primary reference
for every long-form drafting and review phase**. Short-form feed posts and
long-form editions are different registers of the same author, so long-form must
**not** be drafted against the English short-form corpus
(`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md`) —
that corpus encodes rules (e.g. no em-dash) that are wrong for chronicle voice
and would degrade it. Read the `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`
corpus as a **secondary tributary** (register cues), never as the primary
long-form reference.
**Fallback (never silent).** If `chronicle-voice-profile.md` is absent — or has
no section for this edition's language/format — say so plainly, note the
chronicle voice is uncalibrated, and fall back to today's behavior (draft against
`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`), while
flagging the gap so the operator can seed the gold corpus (Step 8 auto-gull grows
it from each locked edition).
5. **Resolve the active personas (per-artifact).** Personas are configured **per
edition**, not from one fixed global file. Resolve the set for
`articles.<currentArticle>` in this order (Step 1 finalizes + persists it):
@ -258,12 +273,12 @@ Then display a short status:
Edition: <series title> — article <currentArticle> "<title>"
Last completed phase: <currentPhase> (or: NEW edition)
Resuming at: Step <N> — <step name> (from the resumption table above)
Voice profile: loaded | MISSING
Chronicle voice profile: loaded (<language>/<format>) | MISSING → fallback to short-form corpus
Persona library: <N> personas loaded (active set chosen in Step 1)
```
If the voice profile or persona library is missing, say so plainly and continue —
do not fabricate either.
If the chronicle voice profile or persona library is missing, say so plainly and
continue on the Step-0 fallback — do not fabricate either.
## Step 1: Brief + calibration
@ -321,6 +336,16 @@ Settle these dimensions (most should come from context, not questions):
- **Tone** — respected-peer vs. teaching-down; calibrated to the primær.
- **Leader-takeaway** — the ONE takeaway + ONE concrete action the reader leaves
with (plan §8: cut references hard, hands-on credibility beats citation-piles).
- **Language****Resolve the review language** for this edition: detect it from
the series brief / prior editions / the drafting language, else ask once. Persist
it to `edition-state.json``language` (e.g. `"no"` for Norwegian, `"en"` for
English) at the **Step 1.5 checkpoint**. This is not cosmetic — it gates the
language-dependent review agents: `language-reviewer`'s Norwegian-idiom /
«kanselli-stil» checks and `voice-scrubber`'s gold standard fire against THIS
language, and never grade prose against Norwegian idiom when the edition is not
Norwegian. Left unresolved, the template default `"en"` silently disables the
Norwegian axis even for a Norwegian edition (the C-3 bug this closes) — so resolve
it explicitly, do not assume the default.
Use `AskUserQuestion` only for the genuinely open dimensions (cap ≈3). Good
candidates: which personas are in scope + which is primær; the angle if the
@ -446,6 +471,11 @@ 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 **`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
right language from here on. This is the write that makes the template
doc-string's "Resolved at Step 1" claim true.
- Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`).
- Set `currentPhase: "lived-specifics"` in `edition-state.json`.
- Write a "lived-specifics bound → next: research (scoped by the binding)" line
@ -771,9 +801,21 @@ Typically ~2030 % of the edition's final length.
**Procedure:**
1. **Re-read the voice profile** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) before writing a
single sentence — this is the existing LTL rule and it is not optional for
long-form. Voice match starts at the spine, not at expansion.
1. **Pass 0 — load the voice reference + the drift negatives** before writing a
single sentence (voice match starts at the spine, not at expansion — the
existing LTL rule, not optional for long-form):
- Re-read the **chronicle-voice-profile**
(`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`)
for this edition's language/format — the reference the spine must match
(fall back to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`
per Step 0 if it is missing).
- Read the **chronicle-voice-drift-log**
(`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/chronicle-voice-drift-log.md`)
as a **negative list**: the recurring AI-tells and voice drifts `voice-scrubber`
logged on prior editions. Do NOT reproduce them in the spine — the log is no
longer write-only; it steers drafting BEFORE the scrub, so the Step-4/persona
passes and the scrubber find less each edition. If the log is absent, note it
and proceed.
2. **For each section in `NN-skjelett.md`, write ONE paragraph that delivers
that section's pitch.** No examples yet, no anecdotes, no research citations
@ -865,8 +907,11 @@ turning-points the spine already named.
**Procedure:**
1. **Re-read the voice profile** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) before expanding —
the voice was set at the spine; do not lose it in expansion.
1. **Re-read the chronicle-voice-profile**
(`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`,
fallback `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`)
and the drift-log negatives (Step 3a Pass 0) before expanding — the voice was set
at the spine; do not lose it, and do not let expansion reintroduce a logged drift.
2. **Expand section by section, against the spine.** Each section's paragraph
from Step 3a is the *contract* for that section: the expansion must
@ -1707,6 +1752,19 @@ produces the editor's single delivery artifact — `POST.html`, the
body is frozen — Step 9 may only adjust the distribution hook, never the
locked edition text.
5. **Auto-gull — grow the gold corpus (A2-F2).** A locked edition is now
published-grade voice signal, so append it to the gold corpus the chronicle
voice profile is distilled from:
- Record the locked body (`NN-utkast.md`) as a new record under
`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/published/`,
tagged **`provenance=published`** (never `ai-draft`) with this edition's
`language` and format in its frontmatter, so `voice-trainer`'s
chronicle-voice-profile distillation (per language/format) picks it up.
- This closes the loop the model-collapse fence protects: the corpus the
chronicle voice is learned from is the author's OWN locked editions, growing
one edition at a time — never the engine's drafts. The next edition's Step 0
therefore binds to a sharper profile without any manual corpus curation.
```
LOCK → delivery.
- Preconditions: factcheck 🔴 = none, persona resonans primær = JA (else: STOP)

View file

@ -1512,6 +1512,88 @@ fi
echo ""
# --- Section 16o: Voice Chain — chronicle-voice-profile binding (N8 / C-1,C-2,C-3,A2-F1,A2-F2) ---
echo "--- Voice Chain (N8) ---"
# N8 binds the long-form VOICE before prose — the same discipline the line already applies to
# SOURCES (validate-binding), its strongest link. Five invariants must hold in the .md/.json surfaces:
# (C-1/A2-F1) voice-trainer.md distils a chronicle-voice-profile from the PUBLISHED gold corpus, and
# newsletter.md binds drafting (Step 0/3a/3b) to THAT profile — not to the English short-form
# authentic-voice-samples corpus the engine itself calls degrading for chronicle voice.
# (C-2) Step 3a READS the chronicle-voice-drift-log as a Pass-0 negative list (today write-only).
# (C-3) Step 1 RESOLVES + persists the review `language`, making the edition-state doc-string's
# "Resolved at Step 1" claim true, so the Norwegian axis fires when content IS Norwegian.
# (A2-F2) Step 8 lock appends the locked edition to the gold corpus (ingest/published,
# provenance=published) so the corpus the profile distils from grows automatically.
# All are UNCONDITIONAL .md/.json greps (no TS deps → they raise the deps-absent floor). Auto-gull needs
# BOTH gold-corpus literals; a non-vacuity self-test guards that compound predicate (mirrors Section 16c).
CVP_LIT='chronicle-voice-profile'
DRIFTLOG_LIT='chronicle-voice-drift-log'
LANG_RESOLVE_LIT='Resolve the review language'
GOLD_PATH_LIT='ingest/published'
GOLD_PROV_LIT='provenance=published'
autogull_wired() { # $1 = text; wired iff BOTH gold-corpus literals present (echo twice — grep consumes stdin)
echo "$1" | grep -qF "$GOLD_PATH_LIT" && echo "$1" | grep -qF "$GOLD_PROV_LIT"
}
VC_SELFTEST_OK=1
if ! autogull_wired "lock appends the edition to ingest/published with provenance=published"; then
VC_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired auto-gull probe was not detected"
fi
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if autogull_wired "$probe"; then
VC_SELFTEST_OK=0; echo " false-positive FAIL: under-wired auto-gull probe accepted -> $probe"
fi
done <<'NEGATIVE16O'
lock appends the edition to ingest/published/ but never tags provenance
marks provenance=published but never names the gold-corpus path
NEGATIVE16O
if [ "$VC_SELFTEST_OK" -eq 1 ]; then
pass "voice-chain self-test: auto-gull predicate needs BOTH gold-corpus literals (1 accepted, 2 under-wired rejected)"
else
fail "voice-chain self-test failed — the auto-gull lint is vacuous or over-eager"
fi
NL="commands/newsletter.md"
VT_N8="agents/voice-trainer.md"
# (C-1/A2-F1) chronicle-voice-profile artifact generated + bound
if grep -qF "$CVP_LIT" "$VT_N8"; then
pass "voice-trainer.md distils the $CVP_LIT (per-language gold-corpus voice artifact)"
else
fail "voice-trainer.md missing '$CVP_LIT' — the chronicle voice artifact is never generated (C-1/A2-F1)"
fi
if grep -qF "$CVP_LIT" "$NL"; then
pass "newsletter.md binds long-form drafting to the $CVP_LIT (C-1)"
else
fail "newsletter.md missing '$CVP_LIT' — drafting still binds only to the short-form corpus (C-1)"
fi
# (C-2) drift-log READ (Pass 0 negative list), not just written
if grep -qF "$DRIFTLOG_LIT" "$NL"; then
pass "newsletter.md reads the $DRIFTLOG_LIT as a Pass-0 negative list (C-2)"
else
fail "newsletter.md missing '$DRIFTLOG_LIT' — the drift log is still write-only (C-2)"
fi
# (C-3) language resolved + persisted at Step 1
if grep -qF "$LANG_RESOLVE_LIT" "$NL"; then
pass "newsletter.md Step 1 resolves + persists the review language ('$LANG_RESOLVE_LIT', C-3)"
else
fail "newsletter.md missing the Step 1 language-resolution instruction ('$LANG_RESOLVE_LIT', C-3)"
fi
# (A2-F2) auto-gull at Step 8 lock — the gold corpus grows on its own
if autogull_wired "$(cat "$NL")"; then
pass "newsletter.md Step 8 auto-gull: locked edition appended to the gold corpus ('$GOLD_PATH_LIT' + '$GOLD_PROV_LIT', A2-F2)"
else
fail "newsletter.md missing auto-gull — lock does not grow the gold corpus (needs '$GOLD_PATH_LIT' + '$GOLD_PROV_LIT', A2-F2)"
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
@ -1540,12 +1622,15 @@ echo ""
# cli.ts saturation-at flag grep) = 117; +6 for RE-R3e's six UNCONDITIONAL Section-16n checks
# (brief-diff self-test + brief.ts diffSurfaced grep + brief.ts parseSurfacedFrontmatter grep +
# brief.ts 'Nytt siden sist' section grep + cli.ts selectPriorBriefFile wiring grep + brief.ts
# 'surfaced: ' frontmatter grep) = 123.
# 'surfaced: ' frontmatter grep) = 123; +6 for N8's six UNCONDITIONAL Section-16o checks (voice-chain
# self-test + voice-trainer chronicle-voice-profile grep + newsletter chronicle-voice-profile grep +
# newsletter chronicle-voice-drift-log read grep + newsletter Step-1 language-resolve grep + newsletter
# Step-8 auto-gull compound grep) = 129.
# 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=123
ASSERT_BASELINE_FLOOR=129
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"