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

@ -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 });