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

@ -89,6 +89,15 @@ All content commands (post, quick, react, pipeline, first-post, video, multiplat
**Rule:** Always read `assets/voice-samples/` before generating content. **Rule:** Always read `assets/voice-samples/` before generating content.
**Invocation form:** Commands invoke plugin agents by their **namespaced** type —
`subagent_type: linkedin-thought-leadership:<name>` — never the bare `<name>` (a bare
type does not resolve and the `Task` call fails).
**Reload requirement:** Adding a NEW agent file under `agents/` registers it only after
a Claude Code **session reload** — the plugin agent set is built at session start, so a
freshly-added agent (e.g. `fact-checker`, `persona-reviewer` when first added) is not
invokable until the session reloads. After adding an agent, reload before invoking it.
## Content Quality Rules ## Content Quality Rules
1. Hook: 110-140 characters (mobile cutoff) 1. Hook: 110-140 characters (mobile cutoff)

View file

@ -217,6 +217,12 @@ trend-spotter --> content-planner --> differentiation-checker --> content-optimi
Parallel support agents: `strategy-advisor`, `analytics-interpreter`, `network-builder`, `content-repurposer`, `video-scripter`. Parallel support agents: `strategy-advisor`, `analytics-interpreter`, `network-builder`, `content-repurposer`, `video-scripter`.
> **Note (agent invocation + reload):** Commands invoke agents by their **namespaced**
> type — `subagent_type: linkedin-thought-leadership:<name>`, never the bare name. And a
> **newly added** agent file under `agents/` only becomes invokable after a Claude Code
> **session reload** (the plugin agent set is built at session start). Add the agent, then
> reload before invoking it.
### Which Agent Do I Need? ### Which Agent Do I Need?
| Scenario | Agent | | Scenario | Agent |

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 phases* (fact-check sweep + persona sweep + hook-gate), NOT by the short-form
`PreToolUse` content-gatekeeper/voice-guardian hooks (those stay short-form-only). `PreToolUse` content-gatekeeper/voice-guardian hooks (those stay short-form-only).
- **State lives in the series folder, not the plugin.** Production state for an - **State lives in the series folder, not the plugin.** Production state for an
edition lives in the maskinrommet series folder edition lives in the resolved **series root** (Step 0) — by default the
(`/Users/ktg/repos/maskinrommet/serier/<slug>/`), per decision G. The plugin 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; ships the *schema* (`config/edition-state.template.json`) and this command;
the edition's actual state + drafts live with the series. the edition's actual state + drafts live with the series.
- **Multi-session by design.** A single edition spans several sessions. Every - **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 (Step 6) — is launched directly from THIS command, in the foreground.** Never
delegate the fan-out to a nested background agent. 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 > **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 > background loses access to the `Task`/Agent tool and silently degrades to
> *guessing* instead of parallelizing. The command layer (this session) is the > *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 Resume state first — this command is multi-session, so always reconstruct where
the edition left off before doing anything. the edition left off before doing anything.
1. **Locate the series folder.** If the user named a series/edition, use it. 1. **Locate the series folder.** Resolve a **series root** — the folder that
Otherwise ask once which series this edition belongs to, and resolve the holds this edition. Resolution order:
folder under `/Users/ktg/repos/maskinrommet/serier/<slug>/`. - 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 — 2. **Read edition-state** (`<serie>/linkedin/edition-state.json`) if it exists —
it tells you `currentArticle`, `currentPhase`, and per-article status, so you it tells you `currentArticle`, `currentPhase`, and per-article status, so you
can resume mid-pipeline. The schema is documented in 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. you will create one at the end of Step 2.
3. **Read the edition-HANDOVER** (`<serie>/HANDOVER.md` or 3. **Read the edition-HANDOVER** (`<serie>/HANDOVER.md` or
`<serie>/linkedin/edition-HANDOVER.md`) — the narrative state (§1 where we `<serie>/linkedin/edition-HANDOVER.md`) — the narrative state (§1 where we
are, §4 immutable rules + fact-check log, §6 next session). This is the are, §4 immutable rules + fact-check log, §6 next session). The structure is
*production* HANDOVER for the edition — **distinct** from the plugin's defined by `${CLAUDE_PLUGIN_ROOT}/config/edition-HANDOVER.template.md` (copy +
`docs/BUILD-HANDOVER.local.md`, which governs building the plugin itself. fill it when starting a new edition). This is the *production* HANDOVER for the
Do not confuse or merge them. 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` 4. **Read the voice profile**`assets/voice-samples/authentic-voice-samples.md`
and anything else under `assets/voice-samples/`. Long-form must match the 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. 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 > **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, > 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 > 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 > "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 > 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` 3. **Draft with the `content-repurposer` muscle.** Reuse `agents/content-repurposer.md`
(its article→long-form conversion discipline) for the section-to-prose work — (its article→long-form conversion discipline) for the section-to-prose work —
invoke it via `Task` for individual sections when useful, *from this command invoke it via `Task` (`subagent_type: linkedin-thought-leadership:content-repurposer`)
layer* (foreground, principle 4). The command owns assembly and voice; the 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 agent assists with conversion. The draft is voice-matched by THIS session, not
self-certified for voice — voice-match remains an `[OPERATØR]` / `[GATE: self-certified for voice — voice-match remains an `[OPERATØR]` / `[GATE:
voice-trainer]` judgment, never auto-passed (plan §10.0). voice-trainer]` judgment, never auto-passed (plan §10.0).
4. **Write the draft** to the edition folder (`<serie>/linkedin/<article>.draft.md`), 4. **Write the draft** to the **series root** as `<serie>/NN-utkast.md` (NN =
set `currentPhase: "draft"` in `edition-state.json`, and append a zero-padded edition number — the SAME filename Steps 7 and 8 render from).
"draft complete → next: consistency/quality" pointer to the HANDOVER §6. 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>). Draft complete (or: partial — resumes at section <X>).
- Premise established: <one line> - Premise established: <one line>
- Key points drafted: <N>/<N> - Key points drafted: <N>/<N>
- Voice-match: [OPERATØR]/[GATE: voice-trainer] — NOT self-certified - 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. 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. block can be verified independently without overlap.
3. **Fan out in parallel — issue all N `fact-checker` calls in a SINGLE message** 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 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 §3). Each call gets one claim-block and returns the agent's standard
verification log + risk-sort (🔴/🟡/🟢) + gate decision (PASS/REWORK/BLOCK). 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 2. **Fan out one `persona-reviewer` call per persona, in parallel** — issue them
in a SINGLE message (multiple `Task` tool-uses, `subagent_type: 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 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 — 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. 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 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. **Check the exit code (N3 — do not assume success).** As of S14/F7 the exit
missing file → the script prints `Fant ikke:` and continues, or no-args → code is authoritative: `build-html.mjs` exits **non-zero when zero HTML files
exit 1) means no review HTML was produced. Report the failure and the were produced** (e.g. a typo'd/missing filename — it prints `Fant ikke:` then
`build-html.mjs` stderr; do NOT advance the phase on a silent failure. `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>)`. 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 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 JA. If either is missing, STOP — return to Step 5/6. Do not lock past an
open gate. open gate.
2. **Confirm the delivery inputs exist in the series folder.** 2. **Confirm the delivery inputs in the series folder.**
`render/build-linkedin.mjs` reads, relative to cwd: `render/build-linkedin.mjs` reads, relative to cwd (`<serie>/linkedin/`):
- `linkedin/edition-config.json` — calendar, freshness, cover credit/caption - `linkedin/edition-config.json` — calendar, freshness, cover credit/caption.
- `linkedin/edition-delingstekst.md` — the per-edition distribution text Template + schema: `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json`.
(and the `samle` post, built unconditionally) - `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 Both inputs are **optional and graceful** (renderer degrades, does not throw):
before invoking. 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 3. **Render POST.html.** Run with **cwd = the series folder** (the script
resolves `linkedin/` from cwd and writes `linkedin/NN/POST.html`). The draft 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 in `linkedin/edition-delingstekst.md` (and the `samle` hook, if shipping the
collected post). This is what the reader sees before "…see more". 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 only, from THIS command layer in the foreground. Pass
**`mode: konverter`** (the after-lock, hook-only mode — NOT resonans). The **`mode: konverter`** (the after-lock, hook-only mode — NOT resonans). The
agent returns a single binary verdict, **JA / NEI**, on «would YOU click?» — 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 ## Reference Files
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (11 phases) - `${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}/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/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) - `${CLAUDE_PLUGIN_ROOT}/agents/persona-reviewer.md` — Step 6/9 reader jury (resonance + conversion modes)

View file

@ -0,0 +1,56 @@
<!--
TEMPLATE — edition-HANDOVER.md (narrative production state for one edition)
Purpose : the human-readable narrative companion to edition-state.json. Where
edition-state.json is the machine-readable resumption state (currentPhase,
per-article status), this HANDOVER carries the *narrative* state a human
(or the next session) reads to understand where the edition is, what is
immutable, and what happens next. /linkedin:newsletter Step 0 reads it;
every phase appends to §6.
Decision: G — production lives in the series root, NOT the plugin. Copy this to
<serie>/linkedin/edition-HANDOVER.md (or <serie>/HANDOVER.md) and fill in.
DISTINCT from the plugin's own docs/BUILD-HANDOVER.local.md (which governs building
the plugin). Never merge the two.
Section numbering is referenced by name in commands/newsletter.md:
§1 where we are · §3§5 brief/rules/calibration · §4 immutable rules + fact-check
log · §5 persona calibration + conversion verdict · §6 next-session pointer.
-->
# Edition HANDOVER — <series title>, article <NN> "<edition title>"
## §1 — Where we are
- Current phase: <currentPhase, mirrors edition-state.json>
- Article status: <pending | in-progress | locked | scheduled>
- One-line state: <e.g. "draft complete, consistency pass next" or "locked, awaiting hook gate">
## §2 — Premise & angle
- Premise (one clear claim): <…>
- Angle / dramaturgical spine: <…>
- Leader-takeaway (the one thing a busy reader keeps): <…>
## §3 — Brief & scope
- Audience personas (active set; mark primær): <persona A (PRIMÆR), persona B, …>
- Key points (24): <…>
- Tone / voice anchor: <reference to assets/voice-samples + any edition-specific note>
- Out of scope for this edition: <…>
## §4 — Immutable rules + fact-check log
> Rules locked for this edition (do not relitigate mid-pipeline):
- <immutable rule 1 — e.g. "no vendor names in the hook">
- <immutable rule 2>
> Fact-check log (Step 5 — guilty-until-disproven; 🔴 must be empty before lock):
| Claim | Risk | Source / verification | Status |
|-------|------|-----------------------|--------|
| <claim> | 🔴/🟡/🟢 | <primary source> | open / resolved |
## §5 — Persona calibration + verdicts
- Pre-lock resonance sweep (Step 6): primær <name> → <JA / NEI + one-line reason>
- Method/persona calibration notes: <any axis tuning, secondary-NO signals>
- Post-lock conversion sweep (Step 9): primær <name> mode konverter → <JA / NEI>
## §6 — Next session
- Next step: Step <N> — <name> (per the Step 0 resumption table)
- Precise pointer: <e.g. "draft resumes at section 3" or "render POST.html, then hook gate">
- Open questions for the operator: <…, or "none">

View file

@ -0,0 +1,28 @@
{
"_doc": {
"purpose": "Schema + starter for edition-config.json — the STATIC per-edition delivery metadata that render/build-linkedin.mjs reads (calendar, freshness, cover credit, captions). Complements edition-state.json (machine resumption state) and edition-delingstekst.md (distribution copy).",
"decision": "G — production lives in the series root, NOT in the plugin. Copy this template to <serie>/linkedin/edition-config.json and fill it in. This file is the schema-defining TEMPLATE only.",
"location": "<serie>/linkedin/edition-config.json (read relative to cwd = series root; OUT_ROOT = <cwd>/linkedin)",
"graceful": "render/build-linkedin.mjs loadEditionConfig() falls back to empty defaults if this file is missing or malformed — every field below is optional. Provide it for a complete delivery page (calendar slot, freshness banner, cover credit, alt-text caption).",
"keys": "Article keys are zero-padded strings mirroring edition-state.json + the NN-prefix of each NN-utkast.md draft: \"01\", \"02\", ..., plus \"samle\" for the collected post.",
"fields": {
"calendar[NN]": "{ dag: human date label e.g. \"Mandag 02.06\", klokke: \"HH:MM\" } — the scheduled slot shown on POST.html. Default if absent: { dag: \"—\", klokke: \"08:00\" }.",
"freshness[NN]": "string — a freshness/recency note rendered in the amber banner (e.g. \"Tall fra Q1 2026; sjekk før publisering etter 01.07\"). Omit for no banner.",
"coverCredit": "string — global cover-image credit line (\"Add credit and caption\" field). One value for the whole edition.",
"captions[NN]": "string — per-article cover-image caption / alt text. Default if absent: \"—\".",
"carousel": "list of zero-padded NN strings (e.g. [\"03\",\"06\"]) — the editions that ship an optional carousel/document post. POST.html shows a carousel block only for these NN. Empty/absent → no carousel block. (S14/F6: replaces the old hardcoded Seres set.)"
}
},
"calendar": {
"01": { "dag": "<Ukedag DD.MM>", "klokke": "08:00" },
"samle": { "dag": "<Ukedag DD.MM>", "klokke": "08:00" }
},
"freshness": {
"01": "<optional freshness note shown in the banner — omit the key for no banner>"
},
"coverCredit": "<cover-image credit line, or empty string>",
"captions": {
"01": "<cover-image caption / alt text for article 01>"
},
"carousel": []
}

View file

@ -0,0 +1,55 @@
<!--
TEMPLATE — edition-delingstekst.md (distribution copy for a newsletter edition)
Purpose : the per-edition LinkedIn distribution text that render/build-linkedin.mjs
folds into each POST.html "all-in-one-place" deliverable. This is the
feed copy the reader sees BEFORE "…see more" — the hook that earns the
click (gated in /linkedin:newsletter Step 9).
Decision: G — production lives in the series root, NOT the plugin. Copy this to
<serie>/linkedin/edition-delingstekst.md and fill it in.
Location: <serie>/linkedin/edition-delingstekst.md (cwd = series root).
Graceful: render/build-linkedin.mjs degrades if this file is missing (no
distribution copy is folded in; the article POST.html still builds).
Provide it for a complete delivery.
GRAMMAR (exactly what parseDelingstekst() recognizes — do not improvise):
- A section starts with a heading: "## Del N — <title>" (N = article number,
mapped to zero-padded key "0N") OR "## Samle <…>" (the collected post,
key "samle").
- "## SYSTEM …" headings are ignored.
- Inside a section, until the next "## " heading or a "---" line:
* "**Første kommentar:** <text>" → first-comment text (one line).
* a line beginning with "#" + non-space (e.g. "#KI #offentligsektor")
→ the hashtag line.
* a "> …" blockquote line → ignored (use it for NB/notes to yourself).
* every other line → part of the share text (the hook + body shown in feed).
Keys MUST match the NN-prefix of the draft (NN-utkast.md) and edition-config.json.
-->
## Del 1 — <edition title>
<First line = the krok/hook: the single line that must stop the scroll. Keep the
strongest claim or tension here; this is what shows before "…see more".>
<Then 24 short lines that pay off the hook and point at the article. Tighten,
never pad — this is feed copy, not the article.>
**Første kommentar:** <the first-comment text — e.g. a link, a question to seed
discussion, or the "full edition here" pointer. LinkedIn suppresses links in the
body, so the link belongs here.>
#hashtag1 #hashtag2 #hashtag3
> NB to self (ignored by the renderer): note any freshness caveat or A/B variant
> you want to remember for this edition.
---
## Samle <collected-post title, if shipping a roundup of the series>
<Hook for the collected/summary post. Same grammar. Omit this whole section if the
edition has no samle post.>
**Første kommentar:** <first comment for the samle post>
#hashtag1 #hashtag2

View file

@ -177,19 +177,85 @@ missing delingstekst kills the whole build rather than degrading.
## Friction summary for Step 15 (revert/fix targets) ## Friction summary for Step 15 (revert/fix targets)
| # | Severity | Implicated file | Step 15 disposition | | # | Severity | Implicated file | Step 15 disposition | Status |
|---|----------|-----------------|---------------------| |---|----------|-----------------|---------------------|--------|
| F1 | BLOCKER | `commands/newsletter.md` | namespace agent calls | | F1 | BLOCKER | `commands/newsletter.md` | namespace agent calls | ✅ |
| F2 | BLOCKER/env | env + `CLAUDE.md`/`README.md` | reload + document | | F2 | BLOCKER/env | env + `CLAUDE.md`/`README.md` | reload + document | ✅ (doc) / 🔶 (reload = operator) |
| F3 | MAJOR | `config/` + `commands/newsletter.md` | add 3 templates | | F3 | MAJOR | `config/` + `commands/newsletter.md` | add 3 templates | ✅ |
| F4 | MAJOR | `commands/newsletter.md` | reconcile draft path/name | | F4 | MAJOR | `commands/newsletter.md` | reconcile draft path/name | ✅ |
| F5 | MAJOR | `commands/newsletter.md` | de-hardcode series root | | F5 | MAJOR | `commands/newsletter.md` | de-hardcode series root | ✅ |
| F6 | MINOR | `render/build-linkedin.mjs` | defer (generalization debt) | | F6 | MINOR | `render/build-linkedin.mjs` | config-derive carousel; fix samle comment | ✅ |
| F7 | MINOR | `render/build-html.mjs` (+ Step 7) | exit non-zero on no output | | F7 | MINOR | `render/build-html.mjs` (+ Step 7) | exit non-zero on no output | ✅ |
| F8 | MINOR | `render/build-linkedin.mjs` (+ Step 8) | guard delingstekst + fix wording | | F8 | MINOR | `render/build-linkedin.mjs` (+ Step 8) | guard delingstekst + fix wording | ✅ |
| F9 | MINOR | `agents/README.md` | relocate / defer | | F9 | MINOR | `agents/README.md` | relocate out of `agents/` | ✅ (relocated) / 🔶 (de-register on reload) |
**Headline:** the long-form pipeline's deterministic backbone is sound, but its **Headline:** the long-form pipeline's deterministic backbone is sound, but its
**gate layer is currently un-runnable** (F1 + F2). Step 15 must close F1 (a concrete **gate layer is currently un-runnable** (F1 + F2). Step 15 must close F1 (a concrete
one-line-per-call edit) and F2 (reload + a doc note) before the pre-lock persona one-line-per-call edit) and F2 (reload + a doc note) before the pre-lock persona
sweep can actually execute — the order is correctly wired, it just cannot fire yet. sweep can actually execute — the order is correctly wired, it just cannot fire yet.
---
## Step 15 (S14) — re-test outcomes
All nine friction points were closed (operator elected to fix F6F9 rather than
defer). Each was re-tested with a concrete check, not a self-asserted "fixed."
- **F1 — ✅ closed.** All four `Task` call sites in `commands/newsletter.md`
(content-repurposer Step 3, fact-checker Step 5, persona-reviewer Steps 6 + 9)
now use the namespaced `subagent_type: linkedin-thought-leadership:<name>`, plus a
canonical "Agent invocation form (required)" note near the foreground principle.
**Check:** `grep -nE 'subagent_type: (fact-checker|persona-reviewer|content-repurposer)' commands/newsletter.md`
→ zero bare names; `grep -nE 'linkedin-thought-leadership:(fact-checker|persona-reviewer|content-repurposer)'`
→ all 4 sites namespaced.
- **F2 — ✅ doc / 🔶 reload.** Documented in `CLAUDE.md` (Agents section: invocation
form + reload requirement) and `README.md` (Agent Architecture note). The
environmental half — registering a newly-added agent — inherently requires a
Claude Code **session reload**; that is an operator action, not a code change.
Confirmed F2 persists across `/clear`: `fact-checker`/`persona-reviewer` were
still absent from this fresh session's agent registry (only the 15 older agents +
README appeared), proving it is a reload gap, not a per-session fluke.
- **F3 — ✅ closed.** Added `config/edition-config.template.json`,
`config/edition-delingstekst.template.md`, `config/edition-HANDOVER.template.md`
(formats reverse-engineered from the render scripts, now shipped as reference).
Wired into `newsletter.md` Step 0 (HANDOVER), Step 8 (config + delingstekst), and
the Reference Files footer. **Check:** all three exist under `config/`;
`edition-config.template.json` parses as valid JSON.
- **F4 — ✅ closed.** Step 3 now writes the canonical `<serie>/NN-utkast.md` in the
series root (the exact file Steps 7/8 render), with an explicit "do NOT write
`linkedin/<article>.draft.md`" warning. **Check:** no `.draft.md`/`<article>` path
refs remain except the intentional anti-pattern warning.
- **F5 — ✅ closed.** Step 0 resolves a **series root** via an order (explicit path
arg → `${LTL_SERIES_ROOT:-…/maskinrommet/serier}/<slug>/` → ask once); maskinrommet
is the default, not the only path. The architecture preamble was aligned to match.
- **F6 — ✅ closed.** `CAROUSEL = new Set(["03","06"])` removed; carousel editions are
now config-derived (`config.carousel`, a list of NN strings) via `loadEditionConfig`
+ `EMPTY_CONFIG` + the new config template. Misleading "samle bygges alltid" comment
corrected (build has always been gated on `shareMap.samle`). **Check:** `grep -n
CAROUSEL render/build-linkedin.mjs` → none; 20/20 render tests pass (one assertion
updated to include the `carousel: []` default).
- **F7 — ✅ closed.** `build-html.mjs main()` now counts files written, prints
`Ingen HTML produsert …` and the CLI guard exits non-zero when zero files are
produced (no more silent exit-0 on a typo'd filename). Step 7 wording updated to
rely on the exit code AND verify the output file. **Re-tested live:** missing input
→ exit 1; valid input → exit 0 + `review/NN-utkast.html` written.
- **F8 — ✅ closed.** `parseDelingstekst()` wrapped in try/catch returning `{}` on
ENOENT, matching `loadEditionConfig`'s fail-soft contract; Step 8 wording corrected
("both inputs optional and graceful," not "either file throws"). **Re-tested live:**
no delingstekst → `build-linkedin.mjs` exit 0 + `linkedin/01/POST.html` still built.
- **F9 — ✅ relocated / 🔶 de-register on reload.** `git mv agents/README.md
docs/agents-capability-matrix.md` — the only reliable fix, since the file registers
by filename (it had no frontmatter yet still appeared as
`linkedin-thought-leadership:README`). The stale registration clears on the next
Claude Code reload (env, same as F2). **Check:** no README in `agents/`;
`docs/agents-capability-matrix.md` present.
### Finding (out of Step 15 scope — recorded, not actioned)
`plan.md` Steps 16, 17, 18 hard-code `agents/README.md` as an explicit `grep` search
path (plan.md:635, 727, 849). After F9's relocation that path no longer exists. The
explicit arg is **redundant** with the recursive `agents/` search those greps already
include, so dropping it (or repointing to `docs/agents-capability-matrix.md`) is
safe — but `plan.md` is outside Step 15's Files list (Hard Rule 2), so this is logged
for the operator/those steps rather than edited here. **Action for Steps 1618:** drop
the dangling `agents/README.md` arg from their Verify greps, or repoint to the new path.

View file

@ -65,6 +65,7 @@ describe("build-linkedin edition-config", () => {
freshness: {}, freshness: {},
coverCredit: "", coverCredit: "",
captions: {}, captions: {},
carousel: [],
}); });
// editionPost still renders without throwing (uses "—" fallbacks) // editionPost still renders without throwing (uses "—" fallbacks)
const html = editionPost("01", meta, body, share, cfg); const html = editionPost("01", meta, body, share, cfg);

View file

@ -1030,16 +1030,20 @@ ${CLIENT_JS}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Returnerer antall HTML-filer skrevet. Eksitkoden settes av CLI-guarden under
// (S14/F7): main() kaller aldri process.exit() selv, slik at modulen kan
// importeres/testes uten å drepe prosessen.
export function main() { export function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (!args.length) { if (!args.length) {
console.error("Bruk: node build-html.mjs <fil.md> [flere.md ...]"); console.error("Bruk: node build-html.mjs <fil.md> [flere.md ...]");
process.exit(1); return 0;
} }
// Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/. // Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/.
const outDir = path.join(process.cwd(), "review"); const outDir = path.join(process.cwd(), "review");
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
let written = 0;
for (const arg of args) { for (const arg of args) {
const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg); const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg);
if (!fs.existsSync(inPath)) { if (!fs.existsSync(inPath)) {
@ -1054,11 +1058,20 @@ export function main() {
const outPath = path.join(outDir, base + ".html"); const outPath = path.join(outDir, base + ".html");
fs.writeFileSync(outPath, html, "utf8"); fs.writeFileSync(outPath, html, "utf8");
console.log(`Skrev ${outPath} (${(html.length / 1024).toFixed(1)} KB)`); console.log(`Skrev ${outPath} (${(html.length / 1024).toFixed(1)} KB)`);
written++;
} }
// S14/F7: en typo'd/manglende input-fil ga tidligere exit 0 uten HTML (stille
// footgun). Skrev vi ingenting, er det en feil — rapporter og la CLI-guarden
// sette ikke-null exit.
if (written === 0) {
console.error(`Ingen HTML produsert (0 av ${args.length} input-fil(er) funnet) — sjekk filnavn og sti.`);
}
return written;
} }
// CLI-guard: kjør kun når scriptet startes direkte, ikke ved import // CLI-guard: kjør kun når scriptet startes direkte, ikke ved import
// (mønster fra hooks/scripts/state-updater.mjs). // (mønster fra hooks/scripts/state-updater.mjs). Exit non-zero hvis ingen HTML.
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
main(); process.exit(main() > 0 ? 0 : 1);
} }

View file

@ -38,7 +38,7 @@ const DELINGSTEKST_FILE = path.join(OUT_ROOT, "edition-delingstekst.md");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const CONFIG_FILE = path.join(OUT_ROOT, "edition-config.json"); const CONFIG_FILE = path.join(OUT_ROOT, "edition-config.json");
const EMPTY_CONFIG = { calendar: {}, freshness: {}, coverCredit: "", captions: {} }; const EMPTY_CONFIG = { calendar: {}, freshness: {}, coverCredit: "", captions: {}, carousel: [] };
// Les edition-config.json fra rootDir (serie-mappas linkedin/). Normaliser alle // Les edition-config.json fra rootDir (serie-mappas linkedin/). Normaliser alle
// felt til kjente former; manglende/ugyldig fil → tomme standarder (graceful). // felt til kjente former; manglende/ugyldig fil → tomme standarder (graceful).
@ -55,13 +55,13 @@ export function loadEditionConfig(rootDir = OUT_ROOT) {
freshness: cfg.freshness && typeof cfg.freshness === "object" ? cfg.freshness : {}, freshness: cfg.freshness && typeof cfg.freshness === "object" ? cfg.freshness : {},
coverCredit: typeof cfg.coverCredit === "string" ? cfg.coverCredit : "", coverCredit: typeof cfg.coverCredit === "string" ? cfg.coverCredit : "",
captions: cfg.captions && typeof cfg.captions === "object" ? cfg.captions : {}, captions: cfg.captions && typeof cfg.captions === "object" ? cfg.captions : {},
// S14/F6: carousel editions are config-derived, not Seres-hardcoded. A list of
// zero-padded NN strings ("03","06"); empty/absent → no carousel block for any
// edition. Generalizes away the old `new Set(["03","06"])` Seres assumption.
carousel: Array.isArray(cfg.carousel) ? cfg.carousel.map(String) : [],
}; };
} }
// CAROUSEL-settet er ikke en del av S2-konfig-scope (kun CALENDAR/FRESHNESS/
// CAPTIONS/COVER_CREDIT generaliseres) — beholdes hardkodet inntil videre.
const CAROUSEL = new Set(["03", "06"]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// YAML front matter (flate key: "value"-par mellom --- ... ---) // YAML front matter (flate key: "value"-par mellom --- ... ---)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -177,7 +177,16 @@ function seoTitle(title) {
// En seksjon = «## Del N — …» eller «## Samle…». «## SYSTEM …» ignoreres. // En seksjon = «## Del N — …» eller «## Samle…». «## SYSTEM …» ignoreres.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function parseDelingstekst() { function parseDelingstekst() {
const raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n"); // Graceful (S14/F8): missing or unreadable delingstekst → no distribution copy.
// Matches loadEditionConfig's fail-soft contract — the article POST.html still
// builds; only the share text is absent. Previously this threw ENOENT before any
// POST.html was written, killing the whole build incl. article posts.
let raw;
try {
raw = fs.readFileSync(DELINGSTEKST_FILE, "utf8").replace(/\r\n/g, "\n");
} catch {
return {};
}
const lines = raw.split("\n"); const lines = raw.split("\n");
const out = {}; const out = {};
let i = 0; let i = 0;
@ -266,7 +275,7 @@ export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) {
const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n "); const copyZone = [subtitle, ...blocks].filter(Boolean).join("\n ");
const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—"; const shareField = share ? `${share.share}\n\n${share.hashtags}` : "—";
const carouselBlock = CAROUSEL.has(nn) const carouselBlock = (config.carousel || []).includes(nn)
? `<div class="fld"><h2>6 · Carousel (valgfritt rekkevidde-tillegg)</h2> ? `<div class="fld"><h2>6 · Carousel (valgfritt rekkevidde-tillegg)</h2>
<div class="val">Egen dokument-post, helst egen dag: last opp <code>linkedin/${nn}/carousel.pdf</code>. <div class="val">Egen dokument-post, helst egen dag: last opp <code>linkedin/${nn}/carousel.pdf</code>.
Caption = delingstekstens premiss-linje.</div></div>` Caption = delingstekstens premiss-linje.</div></div>`
@ -308,7 +317,7 @@ export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) {
${carouselBlock} ${carouselBlock}
<div class="marker"> ${CAROUSEL.has(nn) ? "7" : "6"} · BRØDTEKST merk alt herfra, kopier (C), lim i editoren </div> <div class="marker"> ${(config.carousel || []).includes(nn) ? "7" : "6"} · BRØDTEKST merk alt herfra, kopier (C), lim i editoren </div>
<div class="copyzone"> <div class="copyzone">
${copyZone} ${copyZone}
</div> </div>
@ -361,7 +370,9 @@ export function main(files = process.argv.slice(2)) {
console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`); console.log(`✓ linkedin/${nn}/POST.html (${meta.title || base})`);
} }
// Samle bygges alltid (innhold er uavhengig av utkast-filene) // Samle bygges KUN når delingsteksten deklarerer en «## Samle»-seksjon (S14/F6:
// tidligere kommentar sa «alltid», men bygget har alltid vært betinget av
// shareMap.samle — innholdet er uavhengig av utkast-filene, men ikke av delingstekst).
if (shareMap.samle) { if (shareMap.samle) {
const dir = path.join(OUT_ROOT, "samle"); const dir = path.join(OUT_ROOT, "samle");
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });