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:
parent
adfa2085fc
commit
92e0a0b4f5
11 changed files with 339 additions and 54 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (2–4): <…>
|
||||
- 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">
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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 2–4 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
|
||||
|
|
@ -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:<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 16–18:** drop
|
||||
the dangling `agents/README.md` arg from their Verify greps, or repoint to the new path.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue