feat(linkedin-studio): N11 — serie-destillat + skjelett-sjekk + specifics-bank bruks-logging [skip-docs]

Every long-form gate agent sees ONE edition, so «the reader has heard this
before» was structurally invisible — and the specifics-bank dedupe actively
encouraged re-surfacing the same material. At series cadence that is the
fastest-growing defect class. N11 makes it visible BEFORE the skeleton is
approved, at two grains:

- Series grain — new scripts/editions package: each locked edition's spent
  anecdotes/arguments/hooks are written to <serie>/linkedin/series-distillate.json
  at Step 8 lock (distil-append), and the next skeleton is checked against it at
  Step 2.5 (distil-check) with the finding folded into the annotation gate the
  operator already reads. Advisory, never blocking: a deliberate callback is a
  legitimate move, an unnoticed retread is not.
- Material grain — specifics-bank usedIn log: record-usage stamps «used in
  edition NN» on the specifics an edition actually consumed (read from the bound
  slot-map, so abstrakt/ekstern stamp nothing). At lock, so it means published;
  idempotent under a pivot re-lock. Additive-optional, schema stays v1.

Placement deviates from the plan text (${DATA}) after premise-verification:
per-series state belongs in the series root beside edition-state.json, where
Step 0 already resolves the path and no slug→path map is needed. Operator
approved. The distillate module also lands in scripts/editions now rather than
at N12, since the AC required a testable roundtrip and N12 planned that package
anyway — N12 extends it instead of creating it.

Similarity is character-trigram Jaccard, not word overlap: the plugin is
language-general and inflection (migrere/migreringen) breaks word tokens.
Calibrated on real paraphrase pairs — retellings 0.44-0.55, unrelated 0.04-0.06,
same-topic-different-story 0.24 — so the default threshold sits in the gap at
0.40. Word-Jaccard scored a shortened hook paraphrase at 0.11.

TDD (Iron Law): tests written first and verified red before implementation, for
both the new package and the bank logging.

Suites: editions 27/0 (new suite line + guard, floor 27) · specifics-bank
28 -> 45 · test-runner 163 -> 173 (Section 16r: 9 unconditional greps +
non-vacuity self-test; anti-erosion floor 146 -> 155) · trends 300/0 ·
brain 134/0 · hooks 140/0 · tests 35/0 · render 60/0. tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 06:29:01 +02:00
commit b54e450c3e
20 changed files with 2152 additions and 19 deletions

View file

@ -99,7 +99,7 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias).
| 1 | **Brief + calibration** | angle, voice, audience personas (mark primær), key points, tone, leader-takeaway. ≤3 questions | `AskUserQuestion` |
| 1.5 | **Lived-specifics extraction — BEFORE research** | elicitation interview (35 sharp questions, **vagueness refused**) → `add` lived material to the specifics-bank + populate `articles.NN.livedSpecifics` slot-map (each load-bearing key-point bound specific/abstrakt/ekstern/unresolved) → render `NN-kilder.md`. The grounded-sourcing layer (Fix #2, kilde-så-draft; never fabricated — invariant 3). | `AskUserQuestion` + `scripts/specifics-bank` (`node --import tsx src/cli.ts`) |
| 2 | **Research — scoped by the binding** | parallel scoped mandates → verified notes; triangulation. Fills the `ekstern`/`unresolved` slots + verifies your own numbers (regel 6/7) | **`Task` fan-out (foreground)** |
| 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + **`persona-reviewer`** (skjelett mode) |
| 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. **Serie-destillat-sjekk** (has the reader heard this anecdote/argument/hook before? advisory, into the annotation gate). Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + `scripts/editions` (`distil-check`) + **`persona-reviewer`** (skjelett mode) |
| 3a | **Spine prose — BEFORE full expansion** | one paragraph per section carrying that section's pitch, nothing more. ~2030 % of final length. Operator-gate on whether the axis is right now that there is prose on it. | inline drafting + `content-repurposer` |
| 3b | **Full prose expansion** | expand each section with argument, examples, anchors from research; may span sessions | `content-repurposer` + `Task` |
| 4 | **Consistency + quality** | threads, premise→conclusion arc, leader-takeaway, AI-slop removal, de-AI/voice scrub, formatting dose | inline + `references/longform-quality-rules.md` + **`voice-scrubber`** |
@ -110,7 +110,7 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias).
| 6.5 | **Headless adversarial review — BEFORE lock** | COLD review package on a frozen draft, no drafting-session context: content-reviewer (argument) + language-reviewer (Norwegian) + fact-reviewer (cold re-verification incl. pivot premises) + persona-reviewer resonance/conversion. Consolidated, operator-gated via `SendUserFile`. The independence layer the in-session gates can't be. | **`content-reviewer` + `language-reviewer` + `fact-reviewer` + `persona-reviewer`** (parallel) + `SendUserFile` |
| 7 | **Annotation (optional)** | render annotatable review HTML for a manual pass | `render/build-html.mjs` |
| 7.5 | **Visual assets — BEFORE lock** | cover (+ optional inline figures) or carousel deck: behov → per-image brief → generate (coded `build-figur.mjs` primary for data figures / mcp-image for illustrative / external `cover-raw.png`) → operator-gate (`SendUserFile`) → approve to `cover.png` → credit/caption. Runs before lock so the renderer picks the cover up. | `render/build-figur.mjs` (data figures) + `mcp__mcp-image__generate_image` + `SendUserFile` + (carousel) `render/build-carousel.mjs` |
| 8 | **LOCK → delivery** | POST.html "all in one place" | `render/build-linkedin.mjs` |
| 8 | **LOCK → delivery** | POST.html "all in one place"; auto-gull (gold corpus) + **serie-minne** (series distillate + specifics-bank usage log) | `render/build-linkedin.mjs` + `scripts/editions` (`distil-append`) + `scripts/specifics-bank` (`record-usage`) |
| 9 | **Hook / conversion gate** | persona gate on the distribution text post-lock: "would YOU click?" | **`persona-reviewer`** (conversion mode) |
| 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling | `hooks/scripts/queue-manager.mjs` |
@ -666,7 +666,36 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
2. …
```
4. **Operator-gate (render + annotate — primary flow).** The operator review is
4. **Serie-destillat-sjekk — «har leseren hørt dette før?» (deterministic).**
Every other gate in this pipeline sees ONE edition. At series cadence the
fastest-growing defect is not a bad edition but a **repeated** one — the
anecdote, the argument or the hook the reader already met three editions ago.
Check the proposed skeleton against everything the series has locked, while
changing course is still cheap. Write the units the spine + pitches actually
propose to a small JSON file (`{anecdotes, arguments, hooks}` — the operator's
phrasing, not a summary), then run it from the package dir with **absolute**
paths:
```bash
cd "${CLAUDE_PLUGIN_ROOT}/scripts/editions" && \
node --import tsx src/cli.ts distil-check \
--distillate "<abs>/linkedin/series-distillate.json" \
--skeleton "<abs>/skjelett-units.json"
```
- **CLEAR** → nothing here echoes an earlier edition. Carry the one-liner into
the annotation page as a receipt.
- **REUSE** → the output names each unit, the edition it echoes, and how close
it is. This is **advisory, never a block** (exit 0 either way): a deliberate
callback is a legitimate move — an *unnoticed* retread is not. Fold the
finding into the annotation page (step 5) so the operator decides with the
re-use in view: keep it, sharpen it into an explicit callback, or replace
the unit.
- No distillate yet (a series' first edition, or an adopter who has not locked
one since this gate existed) → CLEAR against 0 editions; say so and move on.
Deps absent → skip with a one-line note, like the binding-gate.
5. **Operator-gate (render + annotate — primary flow).** The operator review is
**HTML annotation**, not a multiple-choice prompt — and this holds for *every*
write deliverable, the skeleton included, not just the final POST.html. Render
the skeleton to an annotatable page and let the operator annotate in the browser:
@ -680,7 +709,9 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
`<serie-mappe>/review/NN-skjelett.html` exists before handing it off.
3. Surface `<serie-mappe>/review/NN-skjelett.html` to the operator as a
`file://` link (use `SendUserFile` if available, else a markdown `file://`
link) for the annotation pass.
link) for the annotation pass. If the destillat-sjekk (step 4) returned
**REUSE**, state each echoed unit + the edition it came from alongside the
link — the operator annotates with the re-use visible, not after the fact.
4. The operator annotates in the browser and pastes the annotated markdown
back. Fold the notes into `<serie>/NN-skjelett.md` by tightening (rule 6).
5. **Receipt, not gate.** After folding in the annotations, use
@ -688,8 +719,8 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
JA proceed, or another round?». `AskUserQuestion` (JA / REVIDER / NEI) is
also the **fallback** gate when rendering is unavailable; it is not the
primary flow:
- **JA** — proceed to the binding-gate (step 5), then the
persona-skjelett-sweep (step 6).
- **JA** — proceed to the binding-gate (step 6), then the
persona-skjelett-sweep (step 7).
- **REVIDER** — another annotation round; revise and re-render.
- **NEI** — the skeleton is wrong at a load-bearing level (premise unsound,
argument-line incoherent). Return to brief calibration (Step 1) or
@ -699,7 +730,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
advance to Step 3a (spine prose) until this operator-gate, the deterministic
binding-gate, AND the persona-skjelett-sweep below all pass. `[OPERATØR]`
5. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona
6. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona
sweep).** Now that the spine is visible, enforce the Step 1.5 binding: every
load-bearing slot must resolve to a real specific, a justified `abstrakt`, or an
`ekstern` source — no `unresolved` slot, no `specific` pointing at a missing bank
@ -728,7 +759,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
When the gate passes, set `articles.NN.livedSpecifics.status: "bound"` in
`edition-state.json`.
6. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.**
7. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.**
Issue one `persona-reviewer` call per active persona in parallel — a SINGLE
message with multiple `Task` tool-uses, `subagent_type:
linkedin-studio:persona-reviewer`, from THIS command layer in
@ -738,7 +769,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
This is NOT resonans mode (Step 6 — that runs on full prose) and NOT
konverter mode (Step 9 — that judges the hook only).
7. **Collect skjelett verdicts and gate.** Each call returns per-axis flags
8. **Collect skjelett verdicts and gate.** Each call returns per-axis flags
(HOLDER/TVILER/MANGLER), ≤3 direction-only flags, a section-pitch check
(any pitch that does not pay in), a per-persona verdict (JA/NEI), and a
gate decision. Aggregate per the agent's rule:
@ -757,19 +788,19 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
the editor (this session) holds the pen; never paste a persona's rewritten
skeleton. `[GATE]`
8. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the
9. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the
skeleton + pitches (or, on BLOCK, return upstream) and re-run the same
`persona-reviewer` calls against the revision. Loop until the primær
returns a clean JA. This loop is **cheap and frequent at this stage**
every round saved here is hours saved at the prose stage.
9. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the
10. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the
binding-gate passed, AND the persona-skjelett-sweep returns a clean primær JA,
record:
- The final skeleton + pitches in `<serie>/NN-skjelett.md` (already written
in step 3, with any in-loop revisions applied).
- `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 5).
- `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 6).
- Per-persona skjelett verdicts in
`edition-state.json``articles.NN.personaSweep.skeleton` (or alongside
resonance/conversion under the same `personaSweep` object).
@ -782,6 +813,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
Skeleton + section pitch (BEFORE prose) — complete.
- Skeleton: 5 lines (premiss / problem / anbefaling / gevinst / vei videre)
- Section pitches: <N> sections, all paying into the spine (else: pitches reworked, see flags)
- Series distillate: CLEAR against <N> locked edition(s) (else REUSE: <N> unit(s) — kept as explicit callback / replaced)
- Operator gate: JA (after <N> revision rounds)
- Binding-gate (vaghet avvises): PASS — all load-bearing slots bound; livedSpecifics → "bound" (else BLOCK: <N> unresolved/dangling)
- Persona-skjelett-sweep: primær JA (else: still NEI — loop open, NOT ready for prose)
@ -1797,12 +1829,56 @@ produces the editor's single delivery artifact — `POST.html`, the
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.
6. **Serie-minne — record what this edition spent.** The reader-facing twin of
auto-gull: auto-gull grows the *voice* corpus, this grows the *series memory*
that the next edition's Step 2.5 destillat-sjekk is checked against. Both
writes happen at LOCK, so they mean **published**, never merely considered —
an abandoned draft leaves no trace.
- **Series distillate.** Extract the narrative units this edition actually
spent — the anecdotes told, the arguments argued, the hooks used — **in the
operator's own phrasing**, not as a summary (the check compares text, so a
paraphrased summary would compare the summariser's words, not the reader's
experience). Write them to a JSON extract and fold it in:
```bash
cd "${CLAUDE_PLUGIN_ROOT}/scripts/editions" && \
node --import tsx src/cli.ts distil-append \
--distillate "<abs>/linkedin/series-distillate.json" \
--series "<slug>" --extract "<abs>/NN-destillat.json"
```
Extract shape: `{editionId, title, lockedAt, anecdotes[], arguments[],
hooks[]}`. A re-lock after `/linkedin:pivot` **replaces** that edition's
entry rather than duplicating it — the reader still only met it once.
- **Specifics-bank usage log.** Stamp the lived material this edition consumed
with «used in edition NN», so the next Step 1.5 query surfaces re-use
instead of silently encouraging it (the bank's dedupe unions tags across
editions by design — without this log, re-surfacing the same specific is
invisible):
```bash
cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \
node --import tsx src/cli.ts record-usage \
--edition "<abs>/linkedin/edition-state.json" --edition-id "<slug>/NN"
```
The ids are read from the article's bound slot-map, so only material the
edition actually leaned on is stamped; `abstrakt`/`ekstern` slots consume no
bank material. Idempotent — a pivot re-lock does not double-count.
- Deps/contract absent (adopter — Step 1.5 was a no-op, or `npm install` never
run) → skip both with a one-line note, like the Step 2.5 gates. The lock
itself never depends on this bookkeeping.
```
LOCK → delivery.
- Preconditions: factcheck 🔴 = none, persona resonans primær = JA (else: STOP)
- Delivered: <serie-mappe>/linkedin/NN/POST.html
- build-linkedin exit: 0 + POST.html present (else: non-zero / skip — NOT locked)
- Article status: locked
- Serie-minne: distillate +1 edition (<N> units) · bank usage stamped on <N> specific(s) (else: skipped — deps absent)
Next: Step 9 — Hook / conversion gate (post-lock).
```
@ -2041,7 +2117,8 @@ the honest decision surface; it sells nothing.
## Reference Files
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (18 phases including Fix #2 lived-specifics extraction (Step 1.5) + `articles.NN.livedSpecifics`, the deterministic §B/§C1 contract-gate (Step 4.5), v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`)
- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger). Design record: `docs/fix2/slice2-binding.md`
- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger), `record-usage` (Step 8 lock — «used in edition NN»). Design record: `docs/fix2/slice2-binding.md`
- `${CLAUDE_PLUGIN_ROOT}/scripts/editions/` — series-level memory (N11): `distil-append` writes what a locked edition spent to `<serie>/linkedin/series-distillate.json` (Step 8), `distil-check` gates the next skeleton against it (Step 2.5). Deterministic character-trigram similarity; advisory, never blocking
- `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8
- `${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — cover motif + credit + caption table (honest-about-AI credit) — Step 7.5
- `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9