From 92e0a0b4f51eb734c3cc1197b4682b23781e6d6e Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 27 May 2026 23:37:39 +0200 Subject: [PATCH] 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: (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 /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 --- plugins/linkedin-thought-leadership/CLAUDE.md | 9 ++ plugins/linkedin-thought-leadership/README.md | 6 ++ .../commands/newsletter.md | 100 ++++++++++++------ .../config/edition-HANDOVER.template.md | 56 ++++++++++ .../config/edition-config.template.json | 28 +++++ .../config/edition-delingstekst.template.md | 55 ++++++++++ .../agents-capability-matrix.md} | 0 .../docs/voyage-build/dogfood-S13-friction.md | 88 +++++++++++++-- .../render/__tests__/build-linkedin.test.mjs | 1 + .../render/build-html.mjs | 19 +++- .../render/build-linkedin.mjs | 29 +++-- 11 files changed, 338 insertions(+), 53 deletions(-) create mode 100644 plugins/linkedin-thought-leadership/config/edition-HANDOVER.template.md create mode 100644 plugins/linkedin-thought-leadership/config/edition-config.template.json create mode 100644 plugins/linkedin-thought-leadership/config/edition-delingstekst.template.md rename plugins/linkedin-thought-leadership/{agents/README.md => docs/agents-capability-matrix.md} (100%) diff --git a/plugins/linkedin-thought-leadership/CLAUDE.md b/plugins/linkedin-thought-leadership/CLAUDE.md index 7ebc1c0..a389031 100644 --- a/plugins/linkedin-thought-leadership/CLAUDE.md +++ b/plugins/linkedin-thought-leadership/CLAUDE.md @@ -89,6 +89,15 @@ All content commands (post, quick, react, pipeline, first-post, video, multiplat **Rule:** Always read `assets/voice-samples/` before generating content. +**Invocation form:** Commands invoke plugin agents by their **namespaced** type — +`subagent_type: linkedin-thought-leadership:` — never the bare `` (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 1. Hook: 110-140 characters (mobile cutoff) diff --git a/plugins/linkedin-thought-leadership/README.md b/plugins/linkedin-thought-leadership/README.md index e814708..e0aab74 100644 --- a/plugins/linkedin-thought-leadership/README.md +++ b/plugins/linkedin-thought-leadership/README.md @@ -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`. +> **Note (agent invocation + reload):** Commands invoke agents by their **namespaced** +> type — `subagent_type: linkedin-thought-leadership:`, 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? | Scenario | Agent | diff --git a/plugins/linkedin-thought-leadership/commands/newsletter.md b/plugins/linkedin-thought-leadership/commands/newsletter.md index e844e93..ca86c6b 100644 --- a/plugins/linkedin-thought-leadership/commands/newsletter.md +++ b/plugins/linkedin-thought-leadership/commands/newsletter.md @@ -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//`), 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}//`), 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:` (e.g. +> `linkedin-thought-leadership:fact-checker`), never the bare ``. 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//`. +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 + `), 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}//`. 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 `` as this resolved series root; nothing below + re-hardcodes the maskinrommet path. 2. **Read edition-state** (`/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** (`/HANDOVER.md` or `/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 `/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 " 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 (`/linkedin/
.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 `/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/
.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 ). - Premise established: - Key points drafted: / - Voice-match: [OPERATØR]/[GATE: voice-trainer] — NOT self-certified -Draft written: /linkedin/
.draft.md +Draft written: /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 && 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 + `/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 ()`. Surface `/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 (`/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) diff --git a/plugins/linkedin-thought-leadership/config/edition-HANDOVER.template.md b/plugins/linkedin-thought-leadership/config/edition-HANDOVER.template.md new file mode 100644 index 0000000..46217cb --- /dev/null +++ b/plugins/linkedin-thought-leadership/config/edition-HANDOVER.template.md @@ -0,0 +1,56 @@ + + +# Edition HANDOVER — , article "" + +## §1 — Where we are +- Current phase: +- Article status: +- One-line state: + +## §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): +- Key points (2–4): <…> +- Tone / voice anchor: +- Out of scope for this edition: <…> + +## §4 — Immutable rules + fact-check log +> Rules locked for this edition (do not relitigate mid-pipeline): +- +- + +> Fact-check log (Step 5 — guilty-until-disproven; 🔴 must be empty before lock): +| Claim | Risk | Source / verification | Status | +|-------|------|-----------------------|--------| +| | 🔴/🟡/🟢 | | open / resolved | + +## §5 — Persona calibration + verdicts +- Pre-lock resonance sweep (Step 6): primær +- Method/persona calibration notes: +- Post-lock conversion sweep (Step 9): primær mode konverter → + +## §6 — Next session +- Next step: Step (per the Step 0 resumption table) +- Precise pointer: +- Open questions for the operator: <…, or "none"> diff --git a/plugins/linkedin-thought-leadership/config/edition-config.template.json b/plugins/linkedin-thought-leadership/config/edition-config.template.json new file mode 100644 index 0000000..0cd1de7 --- /dev/null +++ b/plugins/linkedin-thought-leadership/config/edition-config.template.json @@ -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 /linkedin/edition-config.json and fill it in. This file is the schema-defining TEMPLATE only.", + "location": "/linkedin/edition-config.json (read relative to cwd = series root; OUT_ROOT = /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": "", "klokke": "08:00" }, + "samle": { "dag": "", "klokke": "08:00" } + }, + "freshness": { + "01": "" + }, + "coverCredit": "", + "captions": { + "01": "" + }, + "carousel": [] +} diff --git a/plugins/linkedin-thought-leadership/config/edition-delingstekst.template.md b/plugins/linkedin-thought-leadership/config/edition-delingstekst.template.md new file mode 100644 index 0000000..22e0d5d --- /dev/null +++ b/plugins/linkedin-thought-leadership/config/edition-delingstekst.template.md @@ -0,0 +1,55 @@ + + +## Del 1 — + + + + + +**Første kommentar:** + +#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 + + + +**Første kommentar:** + +#hashtag1 #hashtag2 diff --git a/plugins/linkedin-thought-leadership/agents/README.md b/plugins/linkedin-thought-leadership/docs/agents-capability-matrix.md similarity index 100% rename from plugins/linkedin-thought-leadership/agents/README.md rename to plugins/linkedin-thought-leadership/docs/agents-capability-matrix.md diff --git a/plugins/linkedin-thought-leadership/docs/voyage-build/dogfood-S13-friction.md b/plugins/linkedin-thought-leadership/docs/voyage-build/dogfood-S13-friction.md index e4ddaa5..348949c 100644 --- a/plugins/linkedin-thought-leadership/docs/voyage-build/dogfood-S13-friction.md +++ b/plugins/linkedin-thought-leadership/docs/voyage-build/dogfood-S13-friction.md @@ -177,19 +177,85 @@ missing delingstekst kills the whole build rather than degrading. ## Friction summary for Step 15 (revert/fix targets) -| # | Severity | Implicated file | Step 15 disposition | -|---|----------|-----------------|---------------------| -| F1 | BLOCKER | `commands/newsletter.md` | namespace agent calls | -| F2 | BLOCKER/env | env + `CLAUDE.md`/`README.md` | reload + document | -| F3 | MAJOR | `config/` + `commands/newsletter.md` | add 3 templates | -| F4 | MAJOR | `commands/newsletter.md` | reconcile draft path/name | -| F5 | MAJOR | `commands/newsletter.md` | de-hardcode series root | -| F6 | MINOR | `render/build-linkedin.mjs` | defer (generalization debt) | -| 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 | -| F9 | MINOR | `agents/README.md` | relocate / defer | +| # | Severity | Implicated file | Step 15 disposition | Status | +|---|----------|-----------------|---------------------|--------| +| F1 | BLOCKER | `commands/newsletter.md` | namespace agent calls | ✅ | +| F2 | BLOCKER/env | env + `CLAUDE.md`/`README.md` | reload + document | ✅ (doc) / 🔶 (reload = operator) | +| F3 | MAJOR | `config/` + `commands/newsletter.md` | add 3 templates | ✅ | +| F4 | MAJOR | `commands/newsletter.md` | reconcile draft path/name | ✅ | +| F5 | MAJOR | `commands/newsletter.md` | de-hardcode series root | ✅ | +| 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 | ✅ | +| F8 | MINOR | `render/build-linkedin.mjs` (+ Step 8) | guard delingstekst + fix wording | ✅ | +| 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 **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 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 F6–F9 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:`, 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 `/NN-utkast.md` in the + series root (the exact file Steps 7/8 render), with an explicit "do NOT write + `linkedin/
.draft.md`" warning. **Check:** no `.draft.md`/`
` 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}//` → 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 16–18:** drop +the dangling `agents/README.md` arg from their Verify greps, or repoint to the new path. diff --git a/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs b/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs index b07dd71..c5dab61 100644 --- a/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs +++ b/plugins/linkedin-thought-leadership/render/__tests__/build-linkedin.test.mjs @@ -65,6 +65,7 @@ describe("build-linkedin edition-config", () => { freshness: {}, coverCredit: "", captions: {}, + carousel: [], }); // editionPost still renders without throwing (uses "—" fallbacks) const html = editionPost("01", meta, body, share, cfg); diff --git a/plugins/linkedin-thought-leadership/render/build-html.mjs b/plugins/linkedin-thought-leadership/render/build-html.mjs index 9523994..dcad980 100644 --- a/plugins/linkedin-thought-leadership/render/build-html.mjs +++ b/plugins/linkedin-thought-leadership/render/build-html.mjs @@ -1030,16 +1030,20 @@ ${CLIENT_JS} // --------------------------------------------------------------------------- // 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() { const args = process.argv.slice(2); if (!args.length) { console.error("Bruk: node build-html.mjs [flere.md ...]"); - process.exit(1); + return 0; } // Output følger serien (kjøres fra serie-mappa), ikke scriptet i tools/. const outDir = path.join(process.cwd(), "review"); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + let written = 0; for (const arg of args) { const inPath = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg); if (!fs.existsSync(inPath)) { @@ -1054,11 +1058,20 @@ export function main() { const outPath = path.join(outDir, base + ".html"); fs.writeFileSync(outPath, html, "utf8"); 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 -// (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]}`) { - main(); + process.exit(main() > 0 ? 0 : 1); } diff --git a/plugins/linkedin-thought-leadership/render/build-linkedin.mjs b/plugins/linkedin-thought-leadership/render/build-linkedin.mjs index 369aa10..90c3205 100644 --- a/plugins/linkedin-thought-leadership/render/build-linkedin.mjs +++ b/plugins/linkedin-thought-leadership/render/build-linkedin.mjs @@ -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 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 // 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 : {}, coverCredit: typeof cfg.coverCredit === "string" ? cfg.coverCredit : "", 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 --- ... ---) // --------------------------------------------------------------------------- @@ -177,7 +177,16 @@ function seoTitle(title) { // En seksjon = «## Del N — …» eller «## Samle…». «## SYSTEM …» ignoreres. // --------------------------------------------------------------------------- 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 out = {}; 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 shareField = share ? `${share.share}\n\n${share.hashtags}` : "—"; - const carouselBlock = CAROUSEL.has(nn) + const carouselBlock = (config.carousel || []).includes(nn) ? `

6 · Carousel (valgfritt rekkevidde-tillegg)

Egen dokument-post, helst egen dag: last opp linkedin/${nn}/carousel.pdf. Caption = delingstekstens premiss-linje.
` @@ -308,7 +317,7 @@ export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) { ${carouselBlock} -
⬇︎ ${CAROUSEL.has(nn) ? "7" : "6"} · BRØDTEKST — merk alt herfra, kopier (⌘C), lim i editoren ⬇︎
+
⬇︎ ${(config.carousel || []).includes(nn) ? "7" : "6"} · BRØDTEKST — merk alt herfra, kopier (⌘C), lim i editoren ⬇︎
${copyZone}
@@ -361,7 +370,9 @@ export function main(files = process.argv.slice(2)) { 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) { const dir = path.join(OUT_ROOT, "samle"); fs.mkdirSync(dir, { recursive: true });