diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b28402..d7fc10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/agents/voice-trainer.md b/agents/voice-trainer.md index 998ed71..fb6c760 100644 --- a/agents/voice-trainer.md +++ b/agents/voice-trainer.md @@ -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 diff --git a/commands/newsletter.md b/commands/newsletter.md index 109e994..e063190 100644 --- a/commands/newsletter.md +++ b/commands/newsletter.md @@ -176,9 +176,24 @@ the edition left off before doing anything. Step 2. Do not confuse `/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.` in this order (Step 1 finalizes + persists it): @@ -258,12 +273,12 @@ Then display a short status: Edition: — article "" 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 ~20–30 % 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) diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 33a8f78..d227a82 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -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"