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.
**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
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`.
> **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?
| 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
`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)

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)
| # | 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 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: {},
coverCredit: "",
captions: {},
carousel: [],
});
// editionPost still renders without throwing (uses "—" fallbacks)
const html = editionPost("01", meta, body, share, cfg);

View file

@ -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 <fil.md> [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);
}

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 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)
? `<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>.
Caption = delingstekstens premiss-linje.</div></div>`
@ -308,7 +317,7 @@ export function editionPost(nn, meta, body, share, config = EMPTY_CONFIG) {
${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">
${copyZone}
</div>
@ -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 });