fix(linkedin): close dogfood friction (S14)

Close all 9 friction points from the S13 newsletter dogfood (operator
elected to fix F6-F9 rather than defer):

- F1: namespace all subagent_type calls in newsletter.md to
  linkedin-thought-leadership:<name> (4 sites + canonical note)
- F2: document agent invocation form + reload requirement in CLAUDE.md
  + README.md (reload itself is an operator action)
- F3: add edition-config / edition-delingstekst / edition-HANDOVER
  templates under config/ + wire into Steps 0 and 8 + footer
- F4: reconcile draft path to <serie>/NN-utkast.md (series root)
- F5: de-hardcode series root (explicit arg / LTL_SERIES_ROOT / default)
- F6: config-derive carousel editions (remove Seres CAROUSEL set);
  correct samle comment
- F7: build-html.mjs exits non-zero when zero HTML produced
- F8: guard parseDelingstekst (graceful ENOENT) + correct Step 8 wording
- F9: relocate agents/README.md -> docs/agents-capability-matrix.md

Re-tested: 87/87 plugin tests pass; build-html/build-linkedin behavior
re-verified live. Per-item outcomes logged in dogfood-S13-friction.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-27 23:37:39 +02:00
commit 92e0a0b4f5
11 changed files with 339 additions and 54 deletions

View file

@ -31,8 +31,10 @@ This command is **fundamentally different** from the short-form commands:
phases* (fact-check sweep + persona sweep + hook-gate), NOT by the short-form
`PreToolUse` content-gatekeeper/voice-guardian hooks (those stay short-form-only).
- **State lives in the series folder, not the plugin.** Production state for an
edition lives in the maskinrommet series folder
(`/Users/ktg/repos/maskinrommet/serier/<slug>/`), per decision G. The plugin
edition lives in the resolved **series root** (Step 0) — by default the
maskinrommet series folder
(`${LTL_SERIES_ROOT:-/Users/ktg/repos/maskinrommet/serier}/<slug>/`), per
decision G, but any explicit path works (Step 0 resolution order). The plugin
ships the *schema* (`config/edition-state.template.json`) and this command;
the edition's actual state + drafts live with the series.
- **Multi-session by design.** A single edition spans several sessions. Every
@ -45,6 +47,12 @@ This command is **fundamentally different** from the short-form commands:
(Step 6) — is launched directly from THIS command, in the foreground.** Never
delegate the fan-out to a nested background agent.
> **Agent invocation form (required).** Plugin agents resolve only under their
> namespaced type — `subagent_type: linkedin-thought-leadership:<name>` (e.g.
> `linkedin-thought-leadership:fact-checker`), never the bare `<name>`. A bare
> `subagent_type` does not resolve and the `Task` call fails. Every
> `subagent_type` below is written in the namespaced form for this reason.
> **Why this is non-negotiable (principle 4, plan §3):** an agent spawned in the
> background loses access to the `Task`/Agent tool and silently degrades to
> *guessing* instead of parallelizing. The command layer (this session) is the
@ -84,9 +92,19 @@ single most important correction from the Seres process (plan §0.4, principle 5
Resume state first — this command is multi-session, so always reconstruct where
the edition left off before doing anything.
1. **Locate the series folder.** If the user named a series/edition, use it.
Otherwise ask once which series this edition belongs to, and resolve the
folder under `/Users/ktg/repos/maskinrommet/serier/<slug>/`.
1. **Locate the series folder.** Resolve a **series root** — the folder that
holds this edition. Resolution order:
- If the operator passed an explicit path (e.g. `/linkedin:newsletter
<path-to-serie>`), use it verbatim. This is how the edition is produced for
any repo, a throwaway fixture, or a non-default location.
- Otherwise derive it from the series slug under the **default series base**,
`${LTL_SERIES_ROOT:-/Users/ktg/repos/maskinrommet/serier}/<slug>/`. The
`LTL_SERIES_ROOT` env-var overrides the base without editing this command;
the maskinrommet path is the default, not the only path.
- If neither a path nor a resolvable slug is available, ask once which series
(or series-root path) this edition belongs to.
All later steps treat `<serie>` as this resolved series root; nothing below
re-hardcodes the maskinrommet path.
2. **Read edition-state** (`<serie>/linkedin/edition-state.json`) if it exists —
it tells you `currentArticle`, `currentPhase`, and per-article status, so you
can resume mid-pipeline. The schema is documented in
@ -95,10 +113,11 @@ the edition left off before doing anything.
you will create one at the end of Step 2.
3. **Read the edition-HANDOVER** (`<serie>/HANDOVER.md` or
`<serie>/linkedin/edition-HANDOVER.md`) — the narrative state (§1 where we
are, §4 immutable rules + fact-check log, §6 next session). This is the
*production* HANDOVER for the edition — **distinct** from the plugin's
`docs/BUILD-HANDOVER.local.md`, which governs building the plugin itself.
Do not confuse or merge them.
are, §4 immutable rules + fact-check log, §6 next session). The structure is
defined by `${CLAUDE_PLUGIN_ROOT}/config/edition-HANDOVER.template.md` (copy +
fill it when starting a new edition). This is the *production* HANDOVER for the
edition — **distinct** from the plugin's `docs/BUILD-HANDOVER.local.md`, which
governs building the plugin itself. Do not confuse or merge them.
4. **Read the voice profile**`assets/voice-samples/authentic-voice-samples.md`
and anything else under `assets/voice-samples/`. Long-form must match the
author's voice; this is the reference for every drafting and review phase.
@ -275,7 +294,8 @@ Turn the verified research notes (Step 2) into a full draft. This is a
> **This phase may span multiple sessions.** A long edition can exceed a single
> session's context budget. If you approach the budget mid-draft, stop cleanly,
> write the partial draft to the edition folder, record `currentPhase: "draft"`
> write the partial draft to the series root as `<serie>/NN-utkast.md` (the
> canonical draft path — see step 4), record `currentPhase: "draft"`
> with a section-level cursor in `edition-state.json`, and append a precise
> "draft resumes at section <X>" pointer to the edition-HANDOVER §6. The next
> session re-reads Step 0, picks up the cursor, and continues. Never start the
@ -298,22 +318,28 @@ Turn the verified research notes (Step 2) into a full draft. This is a
3. **Draft with the `content-repurposer` muscle.** Reuse `agents/content-repurposer.md`
(its article→long-form conversion discipline) for the section-to-prose work —
invoke it via `Task` for individual sections when useful, *from this command
layer* (foreground, principle 4). The command owns assembly and voice; the
invoke it via `Task` (`subagent_type: linkedin-thought-leadership:content-repurposer`)
for individual sections when useful, *from this command layer* (foreground,
principle 4). The command owns assembly and voice; the
agent assists with conversion. The draft is voice-matched by THIS session, not
self-certified for voice — voice-match remains an `[OPERATØR]` / `[GATE:
voice-trainer]` judgment, never auto-passed (plan §10.0).
4. **Write the draft** to the edition folder (`<serie>/linkedin/<article>.draft.md`),
set `currentPhase: "draft"` in `edition-state.json`, and append a
"draft complete → next: consistency/quality" pointer to the HANDOVER §6.
4. **Write the draft** to the **series root** as `<serie>/NN-utkast.md` (NN =
zero-padded edition number — the SAME filename Steps 7 and 8 render from).
This is the single canonical draft path: `render/build-html.mjs` (Step 7) and
`render/build-linkedin.mjs` (Step 8) both consume `NN-utkast.md` from cwd, and
the renderer **silently skips** any draft without an `NN` prefix. Do NOT write
to `linkedin/<article>.draft.md` — that path is skipped at render. Set
`currentPhase: "draft"` in `edition-state.json`, and append a "draft complete →
next: consistency/quality" pointer to the HANDOVER §6.
```
Draft complete (or: partial — resumes at section <X>).
- Premise established: <one line>
- Key points drafted: <N>/<N>
- Voice-match: [OPERATØR]/[GATE: voice-trainer] — NOT self-certified
Draft written: <serie>/linkedin/<article>.draft.md
Draft written: <serie>/NN-utkast.md (series root, NN-prefixed — Steps 7/8 render this exact file)
Next: Step 4 — Consistency + quality.
```
@ -396,7 +422,7 @@ because it "feels" right or because it sits in your own research notes.
block can be verified independently without overlap.
3. **Fan out in parallel — issue all N `fact-checker` calls in a SINGLE message**
(multiple `Task` tool-uses in one turn, `subagent_type: fact-checker`) so they
(multiple `Task` tool-uses in one turn, `subagent_type: linkedin-thought-leadership:fact-checker`) so they
run concurrently, from THIS command layer in the foreground (principle 4, plan
§3). Each call gets one claim-block and returns the agent's standard
verification log + risk-sort (🔴/🟡/🟢) + gate decision (PASS/REWORK/BLOCK).
@ -466,7 +492,8 @@ reopening locked texts — the biggest single process error of the series (plan
2. **Fan out one `persona-reviewer` call per persona, in parallel** — issue them
in a SINGLE message (multiple `Task` tool-uses, `subagent_type:
persona-reviewer`), from THIS command layer in the foreground (principle 4).
linkedin-thought-leadership:persona-reviewer`), from THIS command layer in the
foreground (principle 4).
Pass each call its persona name and **`mode: resonans`** (the before-lock mode
— all six axes, ≤5 flags as direction). This is NOT conversion mode, which is
the post-lock hook-gate in Step 9. One persona per run — never mix two.
@ -528,10 +555,13 @@ editor is satisfied with the in-session draft. It does not gate lock.
cd <serie-mappe> && node "${CLAUDE_PLUGIN_ROOT}/render/build-html.mjs" NN-utkast.md
```
**Check the exit code (N3 — do not assume success).** A non-zero exit (e.g.
missing file → the script prints `Fant ikke:` and continues, or no-args →
exit 1) means no review HTML was produced. Report the failure and the
`build-html.mjs` stderr; do NOT advance the phase on a silent failure.
**Check the exit code (N3 — do not assume success).** As of S14/F7 the exit
code is authoritative: `build-html.mjs` exits **non-zero when zero HTML files
were produced** (e.g. a typo'd/missing filename — it prints `Fant ikke:` then
`Ingen HTML produsert …`), and exits 0 only when at least one file was written.
Still confirm the expected output file exists — verify
`<serie-mappe>/review/NN-utkast.html` is present, not just exit 0. Report the
stderr and do NOT advance the phase if the file is missing.
3. **Hand off the link.** On success the script prints `Skrev <path> (<KB>)`.
Surface `<serie-mappe>/review/NN-utkast.html` as a `file://` link for the
@ -573,14 +603,20 @@ produces the editor's single delivery artifact — `POST.html`, the
JA. If either is missing, STOP — return to Step 5/6. Do not lock past an
open gate.
2. **Confirm the delivery inputs exist in the series folder.**
`render/build-linkedin.mjs` reads, relative to cwd:
- `linkedin/edition-config.json` — calendar, freshness, cover credit/caption
- `linkedin/edition-delingstekst.md` — the per-edition distribution text
(and the `samle` post, built unconditionally)
2. **Confirm the delivery inputs in the series folder.**
`render/build-linkedin.mjs` reads, relative to cwd (`<serie>/linkedin/`):
- `linkedin/edition-config.json` — calendar, freshness, cover credit/caption.
Template + schema: `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json`.
- `linkedin/edition-delingstekst.md` — the per-edition distribution text (and
the `samle` post). Template: `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md`,
whose header documents the exact `## Del N —` / `## Samle` section grammar the
renderer parses.
If either file is absent the script throws on read — verify both are present
before invoking.
Both inputs are **optional and graceful** (renderer degrades, does not throw):
a missing or malformed `edition-config.json` falls back to empty defaults, and a
missing `edition-delingstekst.md` yields no distribution copy while the article
`POST.html` still builds. Provide both for a complete delivery — the
distribution hook is what Step 9 gates.
3. **Render POST.html.** Run with **cwd = the series folder** (the script
resolves `linkedin/` from cwd and writes `linkedin/NN/POST.html`). The draft
@ -635,7 +671,8 @@ the post-lock conversion sweep, distinct from the pre-lock resonance sweep
in `linkedin/edition-delingstekst.md` (and the `samle` hook, if shipping the
collected post). This is what the reader sees before "…see more".
2. **Run `persona-reviewer` in conversion mode** for the **primær** persona
2. **Run `persona-reviewer` in conversion mode** (`subagent_type:
linkedin-thought-leadership:persona-reviewer`) for the **primær** persona
only, from THIS command layer in the foreground. Pass
**`mode: konverter`** (the after-lock, hook-only mode — NOT resonans). The
agent returns a single binary verdict, **JA / NEI**, on «would YOU click?» —
@ -712,6 +749,9 @@ Edition complete. Visible in /linkedin:calendar; mark live with /linkedin:publis
## Reference Files
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (11 phases)
- `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8
- `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9
- `${CLAUDE_PLUGIN_ROOT}/config/edition-HANDOVER.template.md` — narrative production-state structure (§1§6) — Step 0
- `${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)
- `${CLAUDE_PLUGIN_ROOT}/agents/persona-reviewer.md` — Step 6/9 reader jury (resonance + conversion modes)