feat(linkedin): v2.3.0 — Step 7.5 visual-assets phase in /linkedin:newsletter

Endring 7 from the change-spec: make visual assets an explicit pipeline
phase. New Step 7.5 (visual-assets) between annotation (Step 7) and lock
(Step 8): cover (+ optional inline figures) or carousel deck, generated and
operator-gated BEFORE lock so build-linkedin.mjs picks up cover.png at lock
without a post-lock re-render. Pipeline 13 → 14 phases.

- commands/newsletter.md: Step 7.5 section, pipeline overview + build-status,
  resumption table (annotation → 7.5; new visual-assets → 8), Step 8
  precondition, reference-file list.
- config/edition-state.template.json: visual-assets phase + additive
  articles.NN.visualAssets schema (format / cover / figures / carousel).
- config/image-credit-caption.template.md (new): motif + credit + caption
  table, honest-about-AI credit, naming convention.
- Two generation routes, no lock-in: default mcp-image (cover-v<N>-kandidat.png)
  or external cover-raw.png. Operator-gate via SendUserFile → cp to cover.png.
  Carousel branch reuses render/build-carousel.mjs.
- Doc/orchestration-only — no new code. Commands (24) + agents (15) unchanged.
- Version sync 2.2.0 → 2.3.0 across plugin.json, CHANGELOG, README, CLAUDE.md,
  root README + root CLAUDE.md.

Correction: spec claimed build-linkedin.mjs handles fig1-4; verified it does
NOT — it embeds only cover.png by fixed name; figures are referenced in the
draft markdown and uploaded manually. Step 7.5 documents actual behavior.

All 8 acceptance criteria met. JSON valid (14 phases); 20/20 render tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-28 22:49:19 +02:00
commit 7ebd28cb0b
9 changed files with 304 additions and 29 deletions

View file

@ -71,7 +71,7 @@ delegate the fan-out to a nested background agent.
> only layer that can reliably spawn parallel sub-agents. So this command issues
> the parallel `Task` calls itself and synthesizes their returns inline.
## Pipeline overview (13 phases)
## Pipeline overview (14 phases)
The phase order is fixed. Two gates run **BEFORE prose** (skeleton + spine
prose), and the persona resonance sweep runs **BEFORE lock** — these are the
@ -90,17 +90,19 @@ single most important corrections from the Seres process (plan §0.4, principle
| 5 | **Fact-check sweep** | risk-sorted (🔴/🟡/🟢), guilty-until-disproven, verification log | **`fact-checker` (parallel)** |
| 6 | **Persona sweep — BEFORE lock** | reader jury, primær wins, convergence to clean YES | **`persona-reviewer`** (resonance mode) |
| 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 (mcp-image default / external `cover-raw.png`) → operator-gate (`SendUserFile`) → approve to `cover.png` → credit/caption. Runs before lock so the renderer picks the cover up. | `mcp__mcp-image__generate_image` + `SendUserFile` + (carousel) `render/build-carousel.mjs` |
| 8 | **LOCK → delivery** | POST.html "all in one place" | `render/build-linkedin.mjs` |
| 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` |
> **Build status:** all 13 phases (Steps 02.5, 3a, 3b, 410) are implemented
> below. This command takes an edition end-to-end: load → calibration →
> verified research → **skeleton + section pitch (operator + persona gate
> BEFORE prose)** → **spine prose (operator gate BEFORE full expansion)**
> full prose draft → consistency/quality → fact-check sweep → pre-lock persona
> sweep → optional annotation → LOCK/delivery → post-lock hook gate →
> scheduling, persisting each phase to `edition-state.json` (machine) and
> **Build status:** all 14 phases (Steps 02.5, 3a, 3b, 47, 7.5, 810) are
> implemented below. This command takes an edition end-to-end: load →
> calibration → verified research → **skeleton + section pitch (operator +
> persona gate BEFORE prose)** → **spine prose (operator gate BEFORE full
> expansion)** → full prose draft → consistency/quality → fact-check sweep →
> pre-lock persona sweep → optional annotation → **visual assets (cover/figures
> or carousel, operator-gated BEFORE lock)** → LOCK/delivery → post-lock hook
> gate → scheduling, persisting each phase to `edition-state.json` (machine) and
> `<serie>/STATE.md` (narrative) and stopping cleanly between sessions.
> **Why two gates BEFORE prose (v2.1).** Spine errors are the dearest failure
@ -181,8 +183,9 @@ Look up `edition-state.json` → `articles.<currentArticle>` (and the top-level
| `draft` | Step 4 — Consistency + quality *(see draft-cursor note)* |
| `consistency-quality` | Step 5 — Fact-check sweep |
| `factcheck-sweep` | Step 6 — Persona sweep (pre-lock) |
| `persona-sweep-prelock` | Step 7 — Annotation (optional) → Step 8 |
| `annotation` | Step 8 — LOCK → delivery |
| `persona-sweep-prelock` | Step 7 — Annotation (optional) → Step 7.5 |
| `annotation` | Step 7.5 — Visual assets *(cover/figures or carousel deck, BEFORE lock)* |
| `visual-assets` | Step 8 — LOCK → delivery |
| `lock-delivery` | Step 9 — Hook / conversion gate |
| `hook-conversion-gate` | Step 10 — Scheduling |
| `scheduling` | **Edition complete** — nothing to resume (start the next article or edition) |
@ -883,13 +886,164 @@ editor is satisfied with the in-session draft. It does not gate lock.
substantive, re-run the affected sweep (Step 5 or 6) before proceeding.
4. **Persist.** Set `currentPhase: "annotation"` in `edition-state.json` and
write an "annotation rendered (optional) → next: lock/delivery" line to
write an "annotation rendered (optional) → next: visual assets" line to
`<serie>/STATE.md` (overwrite). If skipped, note "annotation skipped" and move on.
```
Annotation (optional).
- Rendered: <serie-mappe>/review/NN-utkast.html (or: skipped)
- build-html exit: 0 (else: non-zero — review HTML NOT produced, see stderr)
Next: Step 7.5 — Visual assets (cover/figures or carousel, BEFORE lock).
```
---
## Step 7.5: Visual assets — cover (+ inline figures) or carousel deck, BEFORE lock
The edition needs at least a **cover** (mandatory per the KTG cover-directive:
TLDR on top + at least one figure per article) and, for method-heavy editions,
one or two inline figures. This is a real pipeline phase — not an ad-hoc step
outside the pipeline — because the cover and its credit/caption coordinate with
the text, and because Step 8's renderer **picks them up**.
> **Why BEFORE lock (enforced).** `render/build-linkedin.mjs` (Step 8) reads
> `linkedin/NN/cover.png` (fixed filename) and the edition-config
> `coverCredit` + `captions[NN]` when it builds `POST.html`. If images were
> generated *after* lock, `POST.html` would have to be re-rendered — so the
> edition would no longer be locked in practice. Visual assets are therefore
> resolved here, between the pre-lock persona sweep (Step 6) / optional
> annotation (Step 7) and the lock (Step 8). `[GATE]`
> **What the renderer does and does NOT do.** `build-linkedin.mjs` embeds the
> **cover** by *filename reference* in the POST.html cover field (`linkedin/NN/cover.png`
> + credit + caption) — it does not inline the image bytes, and it does **not**
> embed `fig<N>.png` at all. Inline figures are referenced in the draft markdown
> (`![alt](linkedin/NN/figN.png)`) for the author's reference and **uploaded
> manually** in the LinkedIn editor. So "visual assets" here means: produce and
> approve the image *files* + record their credit/caption, not auto-embed them.
**Format branch (decide first).** An edition is either `standard` (cover +
optional inline figures) or `carousel` (a typografisk slide-deck instead of
cover+inline). It is `carousel` when its `NN` is in `edition-config.json`
`carousel` (the list of editions that ship a document post), or when the
operator declares carousel format for it. Branch accordingly:
- **`standard`** → run steps 15 below (cover, optional figures, credit/caption).
- **`carousel`** → skip cover/figures; jump to **step 6 (carousel branch)** below.
**Procedure (`standard` format):**
1. **Decide image needs from the article type.** Use a light heuristic on the
article's skeleton (Step 2.5) + writing contract, or just ask the operator:
- **Cover** — always mandatory: one hero illustration for the edition.
- **Inline figures** — article-dependent. *Method-heavy* articles (a model
diagram, a relationship map, before/after) usually want 12 figures;
*diagnosis-heavy* articles often need only the cover. Propose a count and
let the operator confirm or override.
2. **Write a brief per image.** For each image (cover + any figures): a short
text brief — motif, mood, format, aspect ratio (cover target is **1920×1080**,
per the renderer's cover block). Generate a first proposal from the skeleton +
writing contract; the operator overrides freely. Record each per-image brief
in `edition-state.json``articles.NN.visualAssets.cover.brief` and
`…figures[].brief`.
3. **Generate — two routes, no lock-in.** The interface is pluggable (path-in /
path-out); `mcp-image` is the default, not a hard dependency:
- **Default route — `mcp__mcp-image__generate_image`** (Nano Banana Pro /
Gemini 3 Pro Image). Write candidates to
`linkedin/NN/cover-v<N>-kandidat.png` (and `fig<N>-kandidat.png` for
figures). Candidate naming lets several attempts sit side by side without
overwriting an approved file. Record route `"mcp-image"`.
- **External route** — DALL·E, Midjourney, a photographer, a hand-built SVG.
The plugin accepts a `linkedin/NN/cover-raw.png` the operator drops in; no
tool is mandated. Record route `"external"`. (The raw file may then be
cropped/retouched into a candidate, or approved directly.)
4. **Operator-gate (render + approve — the Step 2.5/3a pattern, for images).**
Surface **every candidate** to the operator with `SendUserFile` (the image
equivalent of the render+annotate gate the write deliverables use):
1. Collect the candidate paths (`cover-v<N>-kandidat.png`, any external
`cover-raw.png`).
2. `SendUserFile` them with a one-line caption tying each to its brief, so
the operator can compare side by side.
3. The operator either **approves one** or **asks for more attempts** (loop
back to step 3 — generate the next `cover-v<N+1>-kandidat.png`).
4. On approval, **copy the approved candidate to the fixed name** that
`build-linkedin.mjs` reads — `cover.png`:
```bash
cd <serie-mappe> && cp "linkedin/NN/cover-v<N>-kandidat.png" "linkedin/NN/cover.png"
```
(Same pattern for figures: approved → `linkedin/NN/fig<N>.png`.) Confirm
`linkedin/NN/cover.png` exists before advancing.
Do not advance to Step 8 without an approved `cover.png`. `[OPERATØR]`
5. **Credit + caption (prep for Step 8).** Read
`<serie>/linkedin/image-credit-caption.md` (template:
`${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — copy it in
for a new series) and add/update the row for this edition: **motif** (one
line) + **caption** (one line, encoding the article's signal). The credit
must be **honest about AI generation** when the image is AI-made
(verification duty). Then fold these into `<serie>/linkedin/edition-config.json`:
- `coverCredit` — the global cover credit line (one value for the edition/series).
- `captions[NN]` — this article's cover caption / alt text.
These are exactly the fields `build-linkedin.mjs` already reads in Step 8.
**Procedure (`carousel` format — branch):**
6. **Render the carousel deck instead of cover+inline.** A carousel edition's
visual asset is the slide-deck, not a hero cover. Author the slides in
`<serie>/linkedin/NN/carousel.md` (the `## SLIDE N — …` grammar
`build-carousel.mjs` parses), then render with the plugin-owned renderer
(cwd = series folder):
```bash
cd <serie-mappe> && node "${CLAUDE_PLUGIN_ROOT}/render/build-carousel.mjs" linkedin/NN/carousel.md
```
`build-carousel.mjs` writes `linkedin/NN/carousel.html` always and
`linkedin/NN/carousel.pdf` when `weasyprint` is on PATH (it degrades with an
install hint instead of throwing — check the output and surface the hint if
the PDF was skipped). Surface the rendered deck (`carousel.pdf`, else
`carousel.html`) to the operator via `SendUserFile` for the same approve /
regenerate gate as step 4. Record this under
`articles.NN.visualAssets.carousel = { source, pdf, status }` and set
`format: "carousel"`. No `cover.png` is required for a pure carousel edition
(its `POST.html` carousel block references `linkedin/NN/carousel.pdf`); a
carousel edition that *also* posts a feed cover runs both branches. `[OPERATØR]`
**Naming convention (documented — consistent with existing series use):**
| File | Meaning |
|------|---------|
| `cover.png` | **Approved, fixed name** — the only cover filename `build-linkedin.mjs` reads. |
| `cover-v<N>-kandidat.png` | Generation attempts (mcp-image or post-processed). Several may coexist. |
| `cover-raw.png` | Optional external pre-edit source (DALL·E / Midjourney / photographer). |
| `fig<N>.png` | Inline figure (`fig1.png`, `fig2.png`, …), referenced from the draft markdown, uploaded manually. |
| `carousel.md` / `carousel.pdf` | Carousel deck source + rendered PDF (carousel-format editions). |
Descriptive variant suffixes (e.g. `cover-foto-kandidat-v2.png`) are fine for
parallel exploration as long as the **approved** image always lands at the fixed
`cover.png` name.
**Persist + checkpoint state.** Once the cover is approved (or, for carousel,
the deck is approved) and credit/caption are recorded:
- Set `articles.NN.visualAssets` (format, cover.status `approved`,
cover.approved `"cover.png"`, candidates list, figures, carousel) in
`edition-state.json`.
- Set `currentPhase: "visual-assets"` in `edition-state.json` (the marker that
Step 7.5 is complete and the gate has passed).
- Write a "visual assets approved (cover/figures or carousel) → next:
lock/delivery" line to `<serie>/STATE.md` (overwrite).
```
Visual assets (BEFORE lock).
- Format: standard (cover + <N> figures) (or: carousel deck)
- Cover: linkedin/NN/cover.png approved (after <N> candidates) (or: N/A — carousel)
- Figures: <N> approved → linkedin/NN/figN.png (or: none)
- Carousel deck: linkedin/NN/carousel.pdf rendered + approved (or: N/A — standard)
- Route: mcp-image | external Credit/caption: recorded in image-credit-caption.md + edition-config.json
- Operator gate: approved (candidates surfaced via SendUserFile) [OPERATØR]
Next: Step 8 — LOCK → delivery.
```
@ -912,9 +1066,11 @@ produces the editor's single delivery artifact — `POST.html`, the
**Procedure:**
1. **Confirm lock preconditions.** In `edition-state.json`: the article's
`factcheckLog` has no open 🔴 and `personaSweep.resonance` recorded a primær
JA. If either is missing, STOP — return to Step 5/6. Do not lock past an
open gate.
`factcheckLog` has no open 🔴, `personaSweep.resonance` recorded a primær JA,
and `visualAssets` is gated — for `standard` format the approved
`linkedin/NN/cover.png` exists (Step 7.5); for `carousel` format the approved
`carousel.pdf`/`carousel.html` exists. If any is missing, STOP — return to the
relevant step (5/6/7.5). Do not lock past an open gate.
2. **Confirm the delivery inputs in the series folder.**
`render/build-linkedin.mjs` reads, relative to cwd (`<serie>/linkedin/`):
@ -1062,8 +1218,9 @@ Edition complete. Visible in /linkedin:calendar; mark live via /linkedin:calenda
## Reference Files
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (13 phases including v2.1 skeleton + spine-prose gates)
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (14 phases including v2.1 skeleton + spine-prose gates and v2.3 visual-assets)
- `${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
- `${CLAUDE_PLUGIN_ROOT}/config/personas.template.md` — reusable reader personas + "primær trumfer" rule
- `${CLAUDE_PLUGIN_ROOT}/agents/fact-checker.md` — Step 5 fact-check agent (risk-sorted, guilty-until-disproven)
@ -1072,5 +1229,6 @@ Edition complete. Visible in /linkedin:calendar; mark live via /linkedin:calenda
- `${CLAUDE_PLUGIN_ROOT}/commands/react.md` — multi-source synthesis discipline (reused in Step 2)
- `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/authentic-voice-samples.md` — voice matching
- `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — canonical long-form rules (Steps 2.5, 3a, 3b, 49 all reference)
- `${CLAUDE_PLUGIN_ROOT}/render/build-linkedin.mjs` — POST.html delivery (Step 8)
- `${CLAUDE_PLUGIN_ROOT}/render/build-linkedin.mjs` — POST.html delivery; reads `linkedin/NN/cover.png` + credit/caption (Step 8)
- `${CLAUDE_PLUGIN_ROOT}/render/build-html.mjs` — annotatable review renderer (Step 7)
- `${CLAUDE_PLUGIN_ROOT}/render/build-carousel.mjs` — carousel deck renderer (`## SLIDE N —` → PDF via weasyprint) — Step 7.5 carousel branch