ktg-plugin-marketplace/plugins/linkedin-studio/docs/remediation/plan.md
Kjell Tore Guttormsen a61b818578 docs(linkedin-studio): Voyage remediation setup — brief + research + plan (Phase 0-3)
Audit-remediation Voyage project authored end-to-end this session:
- brief.md (reviewer PROCEED; validator pass) — full Phase 0-3 scope, phased,
  with success criteria refined by research
- research/01-03 — high-effort external swarm + Gemini (Topic 1); reconciled the
  external bar and corrected several audit feature-premises (no publishable model
  name/date; saves UI-visible not API-pullable; auto-publish possible-not-built;
  9:16 not mandatory; newsletter notifications deduplicated not triple; CLI crash
  = missing npm install, depth-bug latent)
- plan.md (21 steps, 7 sessions, 5 waves; validator pass; A- 88/100) — plan-critic
  REVISE (3 blockers + majors) addressed; scope-guardian ALIGNED; gemini Pass-2
  folded in 2 blind spots (git-history decision; lint stat-grep sequencing)

Execution is future sessions (one wave each) via /trekexecute, /trekreview as the
release gate. Audit report stays local until the article ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:49:27 +02:00

71 KiB
Raw Blame History

LinkedIn Studio — Baseline-Audit Remediation

Plan quality: A (88/100, post-revision) — APPROVE_WITH_NOTES (plan-critic: REVISE → 3 blockers + actionable majors fixed; scope-guardian: ALIGNED)

Generated by trekplan v5.1.1 on 2026-05-29 plan_version: 1.7 Project: docs/remediation/ · Brief: docs/remediation/brief.md · Research: research/01..03

Context

The baseline audit (docs/critical-review-2026-05-29.local.md, cold hostile read + Gemini triangulation) found file-reproducible correctness and honesty defects in linkedin-studio, and a flagship engine shipped bespoke-as-general (Norwegian-locked, private series path, non-shipping contract). The operator's correction refuted the audit's "long-form never ran" premise (two editions shipped via /linkedin:newsletter), so this is not "prove the pipeline runs." It is: fix what is file-reproducibly broken, make the plugin honest about what it knows and what it cannot do, and make it usable by someone who is not the author. The stakes are trust — the operator writes long-form regularly, will share the plugin actively, and will publish a Maskinrommet article about it, so every algorithm claim that ships is a public claim.

Three research briefs (run cold, this session) reconciled the external bar and corrected several of the audit's own feature premises: there is no publishable name/date for the deployed ranking model; the engagement order is saves > shares > quality-comments > reactions with dwell + topic-relevance the only officially-named signals; documents (~7%) lead video (declining); the link effect is correlational (LinkedIn denies intent) and the first-comment "fix" is contested; auto-publish IS self-serve-possible (so "cannot auto-publish" would be a new false claim); saves ARE visible in the native UI (so "untrackable" is stale); a hard 9:16 video gate is wrong (4:5/1:1 preferred; captions are the enforceable spec); and the newsletter "triple-notification" is deduplicated (the honest benefit is "bypasses feed ranking", with a ~12K follower floor). Delivered phased per audit §9.

Architecture Diagram

graph TD
    subgraph "Phase 0 — guard + substrate + leaks"
        LINT[test-runner.sh rebuilt<br/>dynamic counts + drift greps]
        SUB[algorithm-signals-reference.md<br/>canonical + source/confidence]
        CLI[analytics getAnalyticsRoot<br/>anchor on .claude-plugin/]
        VOICE[voice placeholder + sentinel<br/>real → gitignored .local.md]
        SUB --> CITERS[~40 citing files reconciled]
        LINT -.guards.-> CITERS
        LINT -.guards.-> VOICE
    end
    subgraph "Phase 1 — usable by non-author"
        PATH[series path default + de-brand render]
        LANG[language-lock → configurable]
        BOUND[honest dated boundaries]
        COUNTS1[discoverability counts + SKILL rename]
    end
    subgraph "Phase 2 — coverage gaps"
        ORPH[11 orphans wire/delete]
        DEAI[de-AI: wire differentiation-checker<br/>+ extend voice-guardian]
        VID[video gate: captions + aspect guidance]
        ENG[first-hour/reply loop + state]
        DIST[honest newsletter distribution]
        ORPH --> DEAI
        ORPH --> ENG
    end
    subgraph "Phase 3 — long-form earn"
        BANNER[newsletter time/effort banner]
        OVERLAP[review-pass overlap measured + trimmed]
    end
    FINAL[version bump + counts + three-doc + CHANGELOG]
    CITERS --> FINAL
    COUNTS1 --> FINAL
    DIST --> FINAL
    OVERLAP --> FINAL
    LINT -.asserts.-> FINAL

Codebase Analysis

  • Tech stack: Markdown-first Claude Code plugin (commands/agents/references as .md with YAML frontmatter) + Node.js ESM (.mjs) hooks (zero npm deps, node:test) + one Python hook-compiler + one bash 3.2 structural lint + a TypeScript analytics CLI (scripts/analytics/, run via tsx). 214 source files (medium).
  • Key patterns: commands invoke agents ONLY via Task with subagent_type: linkedin-studio:<name> (namespaced; bare fails); hooks compiled from hooks.template.json + prompts/*.md via compile-hooks.py (never edit hooks.json); content gates are advisory (always exit 0) via content-gatekeeper.mjs + isLinkedInContent; reference docs cite algorithm facts by path but restate numbers inline (so a substrate fix only propagates if citers are converted to cite-not-restate); state mutations via pure functions in state-updater.mjs; agents Sonnet except the 6 Opus long-form gates + 1 Haiku (post-feedback-monitor, the lone deviation).
  • Relevant files: scripts/test-runner.sh (dead lint); references/algorithm-signals-reference.md (the substrate, cited by 16 files); scripts/analytics/src/utils/storage.ts:17-22 (getAnalyticsRoot depth bug); assets/voice-samples/authentic-voice-samples.md + hooks/scripts/personalization-score.mjs:23 + .claude-plugin/plugin.json:6 (voice/PII); commands/ab-test.md:301-317; commands/report.md/import.md (CLI invocation); config/edition-state.template.json:4 + commands/newsletter.md:36,138 + render/build-linkedin.mjs:350
    • render/build-carousel.mjs (series path + brand); agents/language-reviewer.md (lang lock); the 11 orphan agents; commands/video.md + references/linkedin-formats.md (aspect contradiction); skills/*/SKILL.md + commands/onboarding.md/setup.md/linkedin.md (counts); README.md:120,128.
  • Reusable code: hooks/scripts/state-updater.mjs (add pure mutation fns for new tracked state); hooks/prompts/voice-guardian.md (already has AI-pattern detection — extend, don't duplicate); agents/differentiation-checker.md (the orphan to wire as the de-AI gate); agents/__tests__/*-fixture.test.mjs (shape-test pattern); scripts/analytics/tests/storage.test.ts (tmpdir + env-override pattern for the CWD-fix test); agents/content-reviewer.md (Opus gate-agent template, if any new agent is needed); compile-hooks.py --check (drift guard to wire into the lint).
  • Recent git activity: solo project; six versions in ~48h (the audit's accretion finding) — v3.1.0 added the cold-review trio. No CI runs the lint.

Research Sources

Topic Source(s) Key findings Confidence
Algorithm signals (research/01) LinkedIn Eng blog, arXiv 2501.16450, Socialinsider 1.3M, Ordinal 900K, Entrepreneur (Lorenzetti), van der Blom 1.8M + Gemini No publishable model name/date; order saves>shares>comments>reactions; dwell+topic-relevance the only named signals; docs ~7% top, video declining; link ~38% correlational, intent disputed; golden window 6090 min; AI-slop down-rank officially confirmed high (direction) / medium (magnitude)
Analytics + publish boundaries (research/02) Microsoft Learn (li-lms-2026-05), LinkedIn Help, Marcus Noble, GitHub #35 Analytics API exists but partner-gated (not self-serve); auto-publish IS self-serve via w_member_social (clipboard-stop is a choice/ToS, not a wall); saves visible in UI since ~Sept 2025 + API POST_SAVE (gated); dwell internal-only for organic high
Coverage-gap specs (research/03) LinkedIn Help, Socialinsider, aspectratiocalculator, LinkedIn newsletter FAQ, The Science Marketer 9:16 not a clean win (4:5/1:1 preferred, desktop crop); captions the enforceable spec; "3-sec hook" folklore; video reach 36% YoY; newsletter notifications deduplicated (not triple); one-time launch invite → ~12K floor; non-exportable lock-in high (mechanics) / medium (cold-start)

Implementation Plan

Ordering rationale (from risk-assessor): the lint is rebuilt FIRST because it is the tripwire every later step relies on (it already fails 29 checks against the real layout, so nothing is guarded today). The substrate stat file is reconciled SECOND so all citers inherit one source. Voice-leak + placeholder-detection are sequenced TOGETHER (coupled criticals). The version/counts/three-doc reconciliation is LAST so it captures everything.

Step 1: Rebuild the dead structural lint

  • Files: scripts/test-runner.sh
  • Changes: Replace the stale hardcoded EXPECTED_AGENTS (14), EXPECTED_COMMANDS (lists 4 deleted commands, uses wrong commands/linkedin:NAME.md layout), the skills/<name>.md path check (real: skills/<name>/SKILL.md), the personalization-scorer reference, the fabricated auto_discover plugin.json field, and the missing docs/DEVELOPMENT-LOG.md assert. Derive counts dynamically: AGENTS=$(ls agents/*.md | wc -l) with a length-equality assert against the CLAUDE.md "Telling" block (19/26/25/6/9); glob commands/*.md, references/*.md, skills/*/SKILL.md; assert each command/agent has name:/description: frontmatter; call python3 hooks/scripts/compile-hooks.py --check for hook drift. bash 3.2-safe (plain arrays, no declare -A). Scope note (gemini Pass-2): this step rebuilds the STRUCTURAL lint only (counts/layout/frontmatter/hook-drift) — all of which are already broken today, so the lint can go green immediately and guard Steps 421. The stat-consistency grep is added to the lint in Step 3 (after reconciliation makes it pass — adding it here would fail this step's own Verify, since the contradictions still exist) and the version-consistency grep in Step 21. This avoids the chicken-and-egg the lint-first ordering otherwise risks.
  • Reuses: existing pass()/fail()/warn() + PLUGIN_ROOT skeleton in test-runner.sh; compile-hooks.py --check (already works).
  • Test first:
    • File: manual red/green (bash validator, not node:test)
    • Verifies: exits 0 on healthy repo; exits 1 when an agent file is removed
    • Pattern: the existing section structure in scripts/test-runner.sh
  • Verify: bash scripts/test-runner.sh; echo "exit=$?" → expected: exit=0; then prove the registration guard bites with a self-restoring one-liner: git mv agents/trend-spotter.md /tmp/x; bash scripts/test-runner.sh; rc=$?; git mv /tmp/x agents/trend-spotter.md; echo "removed-agent exit=$rc"removed-agent exit=1 (restore runs regardless of the lint's set -e).
  • On failure: revert — git checkout -- scripts/test-runner.sh
  • Checkpoint: git commit -m "fix(linkedin-studio): rebuild dead structural lint to real v3.1 layout"
  • Manifest:
    manifest:
      expected_paths:
        - scripts/test-runner.sh
      min_file_count: 1
      commit_message_pattern: "^fix\\(linkedin-studio\\): rebuild dead structural lint"
      bash_syntax_check:
        - scripts/test-runner.sh
      forbidden_paths: []
      must_contain:
        - path: scripts/test-runner.sh
          pattern: "ls agents"
    

Step 2: Rebuild the algorithm-signals substrate (canonical, sourced)

  • Files: references/algorithm-signals-reference.md
  • Changes: Make this the single source of truth. Add a per-claim Source + Confidence column to the signal tables (house style: keep the | Signal | Weight | … | table genre, widen rows; keep the closing *Sources:* footer as bibliography). Apply research/01: (a) engagement order saves > shares > quality-comments > reactions, comment ≈ 2x like (medium), drop the bare "15x/5x" framing → state as ordering + sourced; (b) reconcile the intra-file carousel contradiction (:72 1.92% PDF vs :73 6.60% multi-image) → documents/carousels top format ~7% (Socialinsider, company-page per-impression), note 1.92% was a personal-profile baseline; (c) link effect ~38% correlational 2026 (band ~1960%), LinkedIn denies intent, value-first > location; (d) golden window 6090 min + evergreen resurfacing (drop "2472h" precision); (e) the model: "an LLM relevance-ranking system is live in 2026" — no name, no date; (f) drop the "40-60% off-topic" figure, keep "profile/topic alignment is a ranking input"; (g) dwell + topic-relevance flagged as the only officially-named signals; (h) buzzwords = editorial guidance, not a reach mechanic.
  • Reuses: existing table format; references/longform-quality-rules.md provenance-blockquote style; research/01 §Recommendation as the source-of-truth content.
  • Test first:
    • File: n/a (content doc) — verification is the Step 1 lint's stat-consistency grep
    • Verifies: no intra-file contradiction; every numeric claim has a Source/Confidence cell
    • Pattern: research/01 reconciliation table
  • Verify: grep -nE '1\.92%|15x more reach|January 2026|360Brew|-40-60%' references/algorithm-signals-reference.md → expected: no matches (all downgraded); grep -c 'Confidence' references/algorithm-signals-reference.md → ≥ 1.
  • On failure: revert — git checkout -- references/algorithm-signals-reference.md
  • Checkpoint: git commit -m "fix(linkedin-studio): reconcile algorithm-signals to one sourced statement"
  • Manifest:
    manifest:
      expected_paths:
        - references/algorithm-signals-reference.md
      min_file_count: 1
      commit_message_pattern: "^fix\\(linkedin-studio\\): reconcile algorithm-signals"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: references/algorithm-signals-reference.md
          pattern: "Confidence"
    

Step 3: Propagate the reconciled numbers to all citers

  • Files: ENUMERATE AT EXECUTION by running the two greps below first, then edit every hit. Known set (from exploration + plan-critic Blocker 2): commands/carousel.md, commands/profile.md, commands/react.md, commands/analyze.md, commands/monetize.md, commands/audit.md, commands/strategy.md, commands/linkedin.md, references/linkedin-formats.md, references/linkedin-visual-style.md, references/glossary.md, references/first-comment-strategy.md, references/troubleshooting-guide.md, references/linkedin-monetization-strategies.md, references/engagement-frameworks.md, agents/content-optimizer.md, hooks/prompts/content-quality-gate.md, hooks/prompts/topic-rotation-gate.md, CLAUDE.md, README.md, skills/linkedin-studio/SKILL.md, skills/linkedin-analytics/SKILL.md, assets/templates/carousel-templates.md
  • Changes: First enumerate the real citer set: grep -rlnE '40-50%|25-40%|6\.6%|1\.92%|15x more reach|January 2026|360Brew|-40-60%' references/ commands/ agents/ skills/ hooks/prompts/ CLAUDE.md README.md — edit EVERY hit (do not work from a stale hand-list; plan-critic Blocker 2 found audit.md/strategy.md/linkedin.md/topic-rotation-gate.md were missing). Replace every restated number with the reconciled value AND a citation to algorithm-signals-reference.md (cite-not-restate). Carousel 6.6%/1.92% → "documents/carousels top format (~7%); see algorithm-signals-reference". Link penalty 40-50% / 25-40% → one correlational statement. 360Brew / "January 2026" / "40-60%" → sourced direction only. Note (plan-critic Blocker 3): content-quality-gate.md and topic-rotation-gate.md are loaded at RUNTIME by content-gatekeeper.mjs — editing their prose does NOT require compile-hooks.py and does NOT change hooks.json. No recompile in this step. Then (gemini Pass-2) add the stat-consistency grep to scripts/test-runner.sh — the external-link penalty % and carousel % must each appear as ONE value across references/ commands/ skills/ hooks/prompts/ (fail if ≥2 distinct magnitudes; exclude commented lines). Adding it HERE, after reconciliation, means the lint goes green instead of failing its own gate.
  • Reuses: Step 2's canonical file as the cite target; the Step 1 lint skeleton.
  • Test first:
    • File: n/a — the Step 1 lint stat-consistency grep is the regression check
    • Verifies: one magnitude per effect across the tree
    • Pattern: the lint's penalty-% grep
  • Verify: grep -rnE '40-50%|25-40%|1\.92%' references/ commands/ skills/ hooks/prompts/ CLAUDE.md README.md | grep -v algorithm-signals-reference → no matches; grep -rnE 'January 2026|360Brew' commands/ README.md skills/ hooks/prompts/ CLAUDE.md → no matches (or only inside a sourced "research model, not deployed" note); spot-check 3 random citers actually cite the reference (cite-not-restate, not just a consistent bare number); bash scripts/test-runner.sh → exit 0 (the newly-added stat-grep is green).
  • On failure: revert — git checkout -- <enumerated files> scripts/test-runner.sh
  • Checkpoint: git commit -m "fix(linkedin-studio): propagate reconciled algorithm numbers, cite-not-restate"
  • Manifest:
    manifest:
      expected_paths:
        - commands/carousel.md
        - commands/profile.md
        - commands/strategy.md
        - commands/audit.md
        - scripts/test-runner.sh
      min_file_count: 5
      commit_message_pattern: "^fix\\(linkedin-studio\\): propagate reconciled algorithm numbers"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/carousel.md
          pattern: "algorithm-signals-reference"
    

Step 4: Fix the analytics CLI root-resolution + surface install

  • Files: scripts/analytics/src/utils/storage.ts, commands/report.md, commands/import.md, scripts/analytics/src/cli.ts
  • Changes: PRIMARY fix (the actual fresh-clone crash): tsx + csv-parse are absent on a fresh clone (gitignored node_modules), so node --import tsx throws ERR_MODULE_NOT_FOUND — surface the install at point-of-use in report.md/import.md: prepend cd "${CLAUDE_PLUGIN_ROOT}/scripts/analytics" && npm install --silent (idempotent) before the node --import tsx invocation, and fix the troubleshooting line "Verify tsx is available" → the actual install command. Reconcile cli.ts usage text (node build/cli.js → the real tsx src/cli.ts runtime). SECONDARY fix (latent correctness bug — getAnalyticsRoot() depth): the ../../../../ in storage.ts:17-22 is calibrated for build/utils/ but runs under tsx from src/utils/, so the fallback root is wrong. The shipped commands mask it by always passing ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" (so it is latent, NOT the crash cause — per plan-critic Blocker 1), but it is wrong for any direct/test invocation. Anchor it on the dir containing .claude-plugin/plugin.json. Keep the ANALYTICS_ROOT override as the test seam.
  • Reuses: existing ANALYTICS_ROOT env handling in storage.ts; scripts/analytics/tests/storage.test.ts tmpdir/afterEach pattern.
  • Test first:
    • File: scripts/analytics/tests/storage-root.test.ts (new)
    • Verifies: default root (no env) anchors on the plugin dir, NOT scripts/analytics/assets; ANALYTICS_ROOT override returns the resolved env path (red against current code, green after fix)
    • Pattern: scripts/analytics/tests/storage.test.ts
  • Verify: install first, then run from a foreign CWD — cd scripts/analytics && npm install --silent && cd /tmp && node --import tsx "${CLAUDE_PLUGIN_ROOT:-$OLDPWD}/scripts/analytics/src/cli.ts" report 2>&1 | head → no ERR_MODULE_NOT_FOUND; cd scripts/analytics && npm install --silent && npm test 2>&1 | tail -3 → storage-root test passes.
  • On failure: revert — git checkout -- scripts/analytics/ commands/report.md commands/import.md
  • Checkpoint: git commit -m "fix(linkedin-studio): anchor analytics root on plugin marker + surface npm install"
  • Manifest:
    manifest:
      expected_paths:
        - scripts/analytics/src/utils/storage.ts
        - scripts/analytics/tests/storage-root.test.ts
        - commands/report.md
        - commands/import.md
      min_file_count: 4
      commit_message_pattern: "^fix\\(linkedin-studio\\): anchor analytics root"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: scripts/analytics/src/utils/storage.ts
          pattern: "plugin.json"
    

Step 5: Voice-leak + placeholder-detection (coupled criticals)

  • Files: assets/voice-samples/authentic-voice-samples.template.md (new), assets/voice-samples/authentic-voice-samples.md (→ becomes placeholder), hooks/scripts/personalization-score.mjs, commands/setup.md, commands/onboarding.md, .claude-plugin/plugin.json, .gitignore, plus the exact-filename readers (hooks/scripts/user-prompt-context.mjs, hooks/prompts/voice-guardian.md, hooks/prompts/state-update-reminder.md)
  • Changes: Ship a PII-free placeholder at authentic-voice-samples.md carrying an explicit sentinel (e.g. <!-- VOICE_PLACEHOLDER -->); move the author's real profile to a gitignored authentic-voice-samples.local.md and add that glob to .gitignore; add a .template.md for adopters. Fix personalization-score.mjs:23 to detect the sentinel (not the [Your Name] heuristic) so the placeholder scores 0 voice points. Both voice writers must replace-not-append (plan-critic Major 1): fix setup.md:99 (merge→overwrite) AND commands/onboarding.md:142 (append→overwrite) so a populated profile removes the sentinel — otherwise the sentinel survives below appended content and the score stays 0 after the user fills it in. Also update the stale detection-heuristic table at setup.md:28 ("Check for [Your Name] or <50 lines") to describe the sentinel. Scrub the author name from .claude-plugin/plugin.json:6 (author field → neutral/org). PII NFR scope (plan-critic Major 2): the leak is the voice profile + plugin.json author field; the author name in LICENSE is the legally-required MIT copyright holder and is an intentional, correct exception (do NOT scrub it). Verify the ~15 readers tolerate the placeholder (most read a dir glob; the exact-filename readers keep working since the filename persists). Git-history decision (gemini Pass-2 Decision 3): .gitignore does NOT remove the already-committed real profile from past commits — it stays retrievable from history. Decision (proportionate to the threat model): this is the author's own attributed open-source plugin — his name is already public by design in LICENSE (MIT copyright), the README, and his Forgejo account, so the historical voice file is attributed authorship, not a leaked secret. The bug being fixed is the adopter-default (a fork inheriting the author's voice + being told "Voice ✓"), which the placeholder+gitignore-going-forward fully resolves. Do NOT force-push a history rewrite (git-filter-repo) — it is disruptive for an open-source plugin with existing clones/forks and is disproportionate for non-secret attributed content. Record this as a conscious decision in the commit body (so it is a documented choice, not an oversight). If the real .local.md ever contains genuine secrets (it should not — it is styling), that would change the calculus.
  • Reuses: the <!-- ... --> sentinel convention; existing dir-glob reads.
  • Test first:
    • File: hooks/scripts/__tests__/personalization-score.test.mjs (new)
    • Verifies: placeholder (with sentinel) scores 0 voice points; a real-looking profile (no sentinel, >50 lines) scores the voice points
    • Pattern: hooks/scripts/__tests__/state-updater.test.mjs
  • Verify: grep -rIn "Kjell Tore" assets/voice-samples/authentic-voice-samples.md .claude-plugin/plugin.json → no matches; git check-ignore assets/voice-samples/authentic-voice-samples.local.md → prints the path; node --test hooks/scripts/__tests__/personalization-score.test.mjs → passes.
  • On failure: revert — git checkout -- assets/voice-samples/ hooks/scripts/personalization-score.mjs commands/setup.md .claude-plugin/plugin.json .gitignore and restore the real file from the local copy.
  • Checkpoint: git commit -m "fix(linkedin-studio): ship placeholder voice profile, gitignore real, sentinel detection"
  • Manifest:
    manifest:
      expected_paths:
        - assets/voice-samples/authentic-voice-samples.md
        - assets/voice-samples/authentic-voice-samples.template.md
        - hooks/scripts/personalization-score.mjs
        - hooks/scripts/__tests__/personalization-score.test.mjs
        - .gitignore
      min_file_count: 5
      commit_message_pattern: "^fix\\(linkedin-studio\\): ship placeholder voice profile"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/personalization-score.mjs
          pattern: "VOICE_PLACEHOLDER"
    

Step 6: Fix the A/B significance claim

  • Files: commands/ab-test.md
  • Changes: Remove the literal Significant? Yes/No column (:301-306); rename to Directional?; cap confidence at "directional only" below ~50 conversions/variant; drop the "20% significance rule" wording (:277,310-313) and the "3 = Medium, 5+ = High" ladder (:315-317) — replace with a plain "organic personal-post volume rarely reaches statistical significance; treat results as directional."
  • Reuses: existing ab-test.md structure.
  • Test first:
    • File: n/a (content doc)
    • Verifies: no "Significant?" column, no "20% significance rule"
    • Pattern: grep check
  • Verify: grep -nE 'Significant\?|20% significance' commands/ab-test.md → no matches; grep -c 'irectional' commands/ab-test.md → ≥ 1.
  • On failure: revert — git checkout -- commands/ab-test.md
  • Checkpoint: git commit -m "fix(linkedin-studio): downgrade A/B significance claim to directional"
  • Manifest:
    manifest:
      expected_paths:
        - commands/ab-test.md
      min_file_count: 1
      commit_message_pattern: "^fix\\(linkedin-studio\\): downgrade A/B significance"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/ab-test.md
          pattern: "irectional"
    

Step 7: Saves/dwell honesty-fix

  • Files: commands/report.md, scripts/analytics/src/models/types.ts, commands/strategy.md
  • Changes: Apply research/02 D2/D4. Reframe report.md:223 ("Saves (10x weight) … highest-impact") → honest dated wording: "saves are visible in your native LinkedIn post analytics (since ~Sept 2025, count-only) but there is no self-serve API to pull them, so this tool does not auto-track them — read them in LinkedIn directly; dwell is internal-only for organic posts." Add a code comment in types.ts documenting why saves/dwell are intentionally absent (no self-serve source). Reconcile any strategy-layer copy that tells the user to "optimize saves" the tool can't read. No new metric field, no manual-entry feature (operator Q3).
  • Reuses: research/02 D2 wording; existing report.md structure.
  • Test first:
    • File: n/a (content/comment)
    • Verifies: no claim the tool measures saves/dwell
    • Pattern: grep check
  • Verify: grep -nE 'Saves \(10x|highest-impact signals' commands/report.md → no matches; grep -n 'no self-serve API' commands/report.md → ≥ 1 match.
  • On failure: revert — git checkout -- commands/report.md scripts/analytics/src/models/types.ts commands/strategy.md
  • Checkpoint: git commit -m "fix(linkedin-studio): honest saves/dwell wording, no false tracking claim"
  • Manifest:
    manifest:
      expected_paths:
        - commands/report.md
        - scripts/analytics/src/models/types.ts
      min_file_count: 2
      commit_message_pattern: "^fix\\(linkedin-studio\\): honest saves/dwell wording"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/report.md
          pattern: "no self-serve API"
    

Step 8: Parameterize the series path + de-brand render output

  • Files: config/edition-state.template.json, commands/newsletter.md, render/build-linkedin.mjs, render/build-carousel.mjs, config/image-credit-caption.template.md
  • Changes: Change the ${LTL_SERIES_ROOT:-…} default from the private /Users/ktg/repos/maskinrommet/serier to a documented neutral default (e.g. ${LTL_SERIES_ROOT:-$HOME/linkedin-series}), and scrub the hardcoded private path from edition-state.template.json:4 prose. De-brand the render output: make the "Maskinrommet" footer/eyebrow/title strings in build-linkedin.mjs:350 + build-carousel.mjs:7,232,282 configurable (env var or config field, defaulting to a neutral/empty brand). Preserve the env-var + explicit-path-arg contract.
  • Reuses: existing LTL_SERIES_ROOT env mechanism; config-field pattern.
  • Test first:
    • File: n/a (config/render) — verification is the Phase-1 grep
    • Verifies: no /Users/ktg in shipped files; brand configurable
    • Pattern: grep check
  • Verify: grep -rIn '/Users/ktg' config/ commands/ render/ | grep -v '.local' → no matches; grep -rn 'Maskinrommet' render/ → only inside a configurable default, not a hardcoded literal.
  • On failure: revert — git checkout -- config/ commands/newsletter.md render/
  • Checkpoint: git commit -m "feat(linkedin-studio): parameterize series path + de-brand render output"
  • Manifest:
    manifest:
      expected_paths:
        - config/edition-state.template.json
        - commands/newsletter.md
        - render/build-linkedin.mjs
      min_file_count: 3
      commit_message_pattern: "^feat\\(linkedin-studio\\): parameterize series path"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/newsletter.md
          pattern: "LTL_SERIES_ROOT"
    

Step 9: Parameterize the Norwegian language-lock

  • Files: agents/language-reviewer.md, agents/voice-scrubber.md, agents/content-reviewer.md, agents/fact-reviewer.md, agents/editorial-reviewer.md, commands/newsletter.md, config/edition-state.template.json
  • Changes: Make the review language a configurable input rather than a hardcoded Norwegian contract. Add a language field to edition-state (additive, default e.g. en), thread it into the long-form agent prompts so language-reviewer grades against the configured language's rules (Norwegian anglicism/kanselli checks fire only when language: no), and voice-scrubber's "gold standard" references the configured language's approved editions. Keep Norwegian fully working when configured. Also resolve the "skrivekontrakt §C2 does not ship" generalization defect (plan-critic minor 2): make the §C2 reference in the craft-gate agents (editorial-reviewer, content-reviewer) non-blocking for a non-author — point to the in-tree fallback checklist the audit noted exists, so the gate works without the unshipped Maskinrommet contract.
  • Reuses: the additive edition-state schema; the namespaced agent-invocation contract; the in-tree fallback craft checklist.
  • Test first:
    • File: n/a (agent prose) — fixture-shape test if a fixture changes
    • Verifies: agents read a language input; Norwegian path intact; §C2 has an in-tree fallback
    • Pattern: agents/__tests__/language-reviewer-fixture.test.mjs if present
  • Verify: grep -ni 'language' config/edition-state.template.json → ≥ 1 (positive: the field exists and is threaded — confirm a language ==/language: reference appears in agents/language-reviewer.md); grep -niE 'unconditional|always Norwegian|norsk draft' agents/language-reviewer.md → no unconditional-Norwegian assertion remains; grep -ni 'C2' agents/editorial-reviewer.md → reference now points to an in-tree fallback, not only the unshipped contract.
  • On failure: revert — git checkout -- agents/ commands/newsletter.md config/edition-state.template.json
  • Checkpoint: git commit -m "feat(linkedin-studio): make long-form review language configurable"
  • Manifest:
    manifest:
      expected_paths:
        - agents/language-reviewer.md
        - config/edition-state.template.json
      min_file_count: 2
      commit_message_pattern: "^feat\\(linkedin-studio\\): make long-form review language configurable"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: config/edition-state.template.json
          pattern: "language"
    

Step 10: Honest, dated boundary statements

  • Files: README.md, commands/report.md, commands/calendar.md, commands/import.md
  • Changes: Apply research/02. Add a dated ("as of 2026-05") boundaries section: (a) post-level analytics API exists but is partner-gated (verified org + LinkedIn Page) — not self-serve; CSV is the practical floor; (b) auto-publish to a personal profile is technically possible self-serve but deliberately not built (OAuth/token overhead + LinkedIn Terms on automated posting) — a choice, NOT "cannot"; (c) dwell internal-only for organic. Reconcile the calendar.md "publish action" / queue wording so it never implies the tool auto-posts (it marks a manually-posted item as published).
  • Reuses: research/02 §Recommendation wording.
  • Test first:
    • File: n/a (docs)
    • Verifies: no "cannot auto-publish" / no "CSV is the only way"; dated
    • Pattern: grep check
  • Verify: grep -niE 'cannot auto-publish|only way to (get|access)' README.md commands/*.md → no matches; grep -ni 'as of 2026' README.md → ≥ 1.
  • On failure: revert — git checkout -- README.md commands/report.md commands/calendar.md commands/import.md
  • Checkpoint: git commit -m "docs(linkedin-studio): honest dated API/auto-publish/analytics boundaries"
  • Manifest:
    manifest:
      expected_paths:
        - README.md
        - commands/calendar.md
      min_file_count: 2
      commit_message_pattern: "^docs\\(linkedin-studio\\): honest dated"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: README.md
          pattern: "as of 2026"
    

Step 11: Remove the README "independently reviewed" claim + honest reframe

  • Files: README.md
  • Changes: Remove the "the version that ships is the version that's actually been independently reviewed" string (~L128) and the matching pipeline-diagram "independent re-read" line (~L120). Replace with an honest framing: the long-form pipeline runs cold/headless review gates before lock (describe the capability), without claiming every shipped edition was independently re-reviewed. Locate by content, not line number (lines have drifted).
  • Reuses: existing README pipeline section.
  • Test first:
    • File: n/a (docs)
    • Verifies: the exact claim string is gone
    • Pattern: grep check
  • Verify: grep -ni 'independently reviewed' README.md → no matches.
  • On failure: revert — git checkout -- README.md
  • Checkpoint: git commit -m "docs(linkedin-studio): remove independently-reviewed claim, honest reframe"
  • Manifest:
    manifest:
      expected_paths:
        - README.md
      min_file_count: 1
      commit_message_pattern: "^docs\\(linkedin-studio\\): remove independently-reviewed claim"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain: []
    

Step 12: Reconcile discoverability surfaces

  • Files: skills/linkedin-studio/SKILL.md, skills/linkedin-analytics/SKILL.md, skills/linkedin-content-creation/SKILL.md, skills/linkedin-strategy/SKILL.md, skills/linkedin-networking/SKILL.md, skills/linkedin-voice/SKILL.md, commands/onboarding.md, commands/setup.md, commands/linkedin.md
  • Changes: In skills/linkedin-studio/SKILL.md: rename "thought leadership plugin" → "LinkedIn Studio"; correct the agent table to the real count and route to newsletter/headless-review/pivot/react. Fix onboarding.md "25 commands" → 26; reconcile the pillar-count disagreement (onboarding.md 3-5 vs setup.md 5) to one number; add headless-review/pivot to the linkedin.md router tables + option list. (Final exact counts are set in Step 21; here, fix names/routing/contradictions.)
  • Reuses: existing SKILL.md table format.
  • Test first:
    • File: n/a (docs) — Step 1 lint asserts agent/command counts
    • Verifies: no "thought leadership"; router lists all commands
    • Pattern: grep check
  • Verify: grep -rni 'thought leadership' skills/ → no matches; grep -nc 'headless-review' commands/linkedin.md → ≥ 1; pillar count consistent: grep -rnE 'pillars|expertise areas' commands/onboarding.md commands/setup.md shows one consistent number.
  • On failure: revert — git checkout -- skills/ commands/onboarding.md commands/setup.md commands/linkedin.md
  • Checkpoint: git commit -m "docs(linkedin-studio): reconcile discoverability surfaces + skill naming"
  • Manifest:
    manifest:
      expected_paths:
        - skills/linkedin-studio/SKILL.md
        - commands/onboarding.md
        - commands/linkedin.md
      min_file_count: 3
      commit_message_pattern: "^docs\\(linkedin-studio\\): reconcile discoverability"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/linkedin.md
          pattern: "headless-review"
    

Step 13: Resolve the 11 orphan agents (wire-or-delete, case-by-case)

  • Files: commands/video.md, commands/post.md, commands/ab-test.md, commands/calendar.md, commands/report.md, commands/analyze.md, commands/batch.md, commands/pipeline.md, commands/strategy.md, commands/outreach.md, commands/setup.md, plus CLAUDE.md

  • Changes: DEFAULT: wire all 11 (no deletions) — each has a clear home, so the agent count stays 19 (plan-critic Major 3: the dispositions are fixed here, not deferred). Per agent, add Task to the command's allowed-tools AND a subagent_type: linkedin-studio:<name> call. The 11 orphans + disposition:

    # Orphan agent Disposition Target command
    1 video-scripter wire video.md (already says "delegate")
    2 content-optimizer wire post.md + ab-test.md
    3 analytics-interpreter wire report.md + analyze.md
    4 content-planner wire batch.md + pipeline.md (already hold Task)
    5 trend-spotter wire batch.md + pipeline.md
    6 network-builder wire outreach.md
    7 strategy-advisor wire strategy.md
    8 voice-trainer wire setup.md (voice-profile building)
    9 post-feedback-monitor wire calendar.md (publish action / 48h monitor)
    10 differentiation-checker wire (Step 14) post/quick/react/carousel/video
    11 engagement-coach wire (Step 16) firsthour.md

    Agents 1011 are wired in their dedicated steps; this step wires 19. If a wiring proves genuinely awkward at execution, the fallback is delete-that-agent (document it + decrement the count in Step 21) — but the default is wire-all → 19 agents. Use the namespaced linkedin-studio:<name> form; note the session-reload requirement.

  • Reuses: the namespaced subagent_type contract; existing Task grants in batch/pipeline.

  • Test first:

    • File: n/a — Step 1 lint asserts agent count == ls agents/*.md
    • Verifies: each remaining orphan is invoked ≥1; deleted ones removed from counts
    • Pattern: the grep success-criterion
  • Verify: for each agent NOT deleted: grep -rl "subagent_type: linkedin-studio:<name>" commands/ → ≥1; bash scripts/test-runner.sh → exit 0 (counts consistent).

  • On failure: revert — git checkout -- commands/ agents/ CLAUDE.md (and git restore any deleted agent)

  • Checkpoint: git commit -m "refactor(linkedin-studio): wire or delete 11 orphan agents (case-by-case)"

  • Manifest:

    manifest:
      expected_paths:
        - commands/video.md
        - CLAUDE.md
      min_file_count: 2
      commit_message_pattern: "^refactor\\(linkedin-studio\\): wire or delete 11 orphan agents"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/video.md
          pattern: "subagent_type: linkedin-studio:video-scripter"
    

Step 14: Short-form de-AI / differentiation gate

  • Files: commands/post.md, commands/quick.md, commands/react.md, commands/carousel.md, commands/video.md, hooks/prompts/voice-guardian.md, hooks/hooks.json (regenerated)
  • Changes: Wire the existing orphan differentiation-checker into the five short-form creation commands (add Task to allowed-tools + a subagent_type: linkedin-studio:differentiation-checker call). EXTEND (do not duplicate) hooks/prompts/voice-guardian.md's existing AI-pattern section with the LinkedIn-named signals from research/01 D8 + research/03 D4 (personal substance, original thinking, concrete specifics, genuine voice; soft engagement-bait check: block mechanical-response CTAs, allow genuine questions). No recompile (plan-critic Blocker 3): voice-guardian.md is loaded at runtime by content-gatekeeper.mjs — extending its prose changes nothing in hooks.json; compile-hooks.py is only needed when ADDING a new hook entry to the template, which this step does not do. (No new agent — reuse the orphan; avoids the voice-guardian overlap the architecture-mapper flagged.)
  • Reuses: agents/differentiation-checker.md (the orphan); hooks/prompts/voice-guardian.md (existing AI detection, runtime-loaded).
  • Test first:
    • File: hooks/scripts/__tests__/linkedin-content-filter.test.mjs (new) — verifies isLinkedInContent fires on the short-form content paths the gate guards
    • Verifies: gate scope correct; differentiation-checker wired
    • Pattern: hooks/scripts/__tests__/state-updater.test.mjs
  • Verify: grep -rl "subagent_type: linkedin-studio:differentiation-checker" commands/ → ≥1; node --test hooks/scripts/__tests__/linkedin-content-filter.test.mjs → passes; python3 hooks/scripts/compile-hooks.py --check → no drift.
  • On failure: revert — git checkout -- commands/ hooks/
  • Checkpoint: git commit -m "feat(linkedin-studio): short-form de-AI gate via differentiation-checker + voice-guardian"
  • Manifest:
    manifest:
      expected_paths:
        - commands/post.md
        - hooks/prompts/voice-guardian.md
        - hooks/scripts/__tests__/linkedin-content-filter.test.mjs
      min_file_count: 3
      commit_message_pattern: "^feat\\(linkedin-studio\\): short-form de-AI gate"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/post.md
          pattern: "differentiation-checker"
    

Step 15: Video quality gate (captions + aspect guidance, not 9:16-mandatory)

  • Files: commands/video.md, references/linkedin-formats.md, references/algorithm-signals-reference.md
  • Changes: Apply research/03 D1-D3. In video.md: MP4 default (warn-only on MOV/AVI), within official upload limits; enforce/strongly-recommend captions (SRT or native auto-captions, labelled best-practice not "required"); aspect ratio as guidance — 4:5 / 1:1 preferred for broad distribution, 9:16 mobile-only opt-in. Fix the contradiction in linkedin-formats.md (the "4:5 deprioritized" line → "4:5 preferred"; remove "3-second hook" → "front-load value for muted autoplay"). Remove the "Vertical 9:16 gets distribution boost" + any "video maximizes reach" copy in algorithm-signals-reference.md:75/video.md:198; add a one-line "per-video reach declining; documents out-engage video" note.
  • Reuses: research/03 §Recommendation; the orphan video-scripter (wired in Step 13).
  • Test first:
    • File: n/a (content) — grep check
    • Verifies: no "9:16 required", no "3-second hook"; captions present
    • Pattern: grep check
  • Verify: grep -niE 'must be 9:16|9:16 \(1080|3-second hook' commands/video.md references/linkedin-formats.md → no matches; grep -ni 'captions' commands/video.md → ≥1; grep -ni 'deprioritized' references/linkedin-formats.md → no 4:5-deprioritized line.
  • On failure: revert — git checkout -- commands/video.md references/linkedin-formats.md references/algorithm-signals-reference.md
  • Checkpoint: git commit -m "feat(linkedin-studio): video quality gate (captions + aspect guidance, drop 9:16 mandate)"
  • Manifest:
    manifest:
      expected_paths:
        - commands/video.md
        - references/linkedin-formats.md
      min_file_count: 2
      commit_message_pattern: "^feat\\(linkedin-studio\\): video quality gate"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/video.md
          pattern: "4:5"
    

Step 16: First-hour / reply-loop command with tracked state

  • Files: commands/firsthour.md (new), config/state-file.template.md, hooks/scripts/state-updater.mjs, hooks/scripts/__tests__/state-updater.test.mjs, CLAUDE.md
  • Changes: Add a wired first-hour/reply-loop command that invokes engagement-coach (+ post-feedback-monitor) with tracked state: a target list, draft comments, and a timestamped first-hour plan persisted in ~/.claude/linkedin-studio.local.md. Add additive scalar fields / a non-R-initial section to config/state-file.template.md and a pure mutation function in state-updater.mjs mirroring updatePostTracking. Register the command in CLAUDE.md.
  • Reuses: commands/react.md structure; agents/engagement-coach.md; state-updater.mjs updatePostTracking pattern; the additive-state contract.
  • Test first:
    • File: hooks/scripts/__tests__/state-updater.test.mjs (extend) — verifies the new mutation fn is additive (missing field → graceful default)
    • Verifies: first-hour state round-trips; existing fields untouched
    • Pattern: existing state-updater tests
  • Verify: grep -rl "subagent_type: linkedin-studio:engagement-coach" commands/ → ≥1; node --test hooks/scripts/__tests__/state-updater.test.mjs → passes; bash scripts/test-runner.sh → exit 0 (command count updated).
  • On failure: revert — git checkout -- commands/firsthour.md config/state-file.template.md hooks/scripts/ CLAUDE.md
  • Checkpoint: git commit -m "feat(linkedin-studio): first-hour/reply-loop command with tracked state"
  • Manifest:
    manifest:
      expected_paths:
        - commands/firsthour.md
        - config/state-file.template.md
        - hooks/scripts/state-updater.mjs
      min_file_count: 3
      commit_message_pattern: "^feat\\(linkedin-studio\\): first-hour/reply-loop command"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/firsthour.md
          pattern: "engagement-coach"
    

Step 17: Honest newsletter-distribution + profile-SEO + outreach pipeline surfaces

  • Files: commands/newsletter.md, commands/profile.md, commands/outreach.md, references/linkedin-growth-playbook-2025-2026.md
  • Changes: Apply research/03 D5. Newsletter distribution surface = the honest version: "bypasses organic feed ranking via ONE deduplicated notification per subscriber per edition (NOT triple)"; one-time launch-blast + a ~12K follower floor (frame: wait until you can spend the blast); realistic cold-start floors (0100 subs months 13); disclose non-export / no-canonical / no-read-analytics / per-subscriber decay. Add profile-SEO fields to profile.md (headline-as-search-field, per-section keyword targets). Add tracked contact/pipeline state to outreach.md (or reference the state added in Step 16's pattern).
  • Reuses: research/03 D5; the state pattern from Step 16.
  • Test first:
    • File: n/a (content) — grep check
    • Verifies: no "triple notification"; follower floor present
    • Pattern: grep check
  • Verify: grep -niE 'triple.notification' commands/newsletter.md → no matches; grep -niE 'deduplicated|follower floor|1[-]?2K|bypasses.*feed' commands/newsletter.md → ≥1; grep -ni 'headline' commands/profile.md → ≥1.
  • On failure: revert — git checkout -- commands/newsletter.md commands/profile.md commands/outreach.md references/linkedin-growth-playbook-2025-2026.md
  • Checkpoint: git commit -m "feat(linkedin-studio): honest newsletter distribution + profile-SEO + outreach pipeline"
  • Manifest:
    manifest:
      expected_paths:
        - commands/newsletter.md
        - commands/profile.md
      min_file_count: 2
      commit_message_pattern: "^feat\\(linkedin-studio\\): honest newsletter distribution"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/newsletter.md
          pattern: "deduplicated"
    

Step 18: Promote post-feedback-monitor off Haiku

  • Files: agents/post-feedback-monitor.md, CLAUDE.md
  • Changes: Change model: haikumodel: opus (the lone non-Opus/Sonnet deviation; human-facing real-time coaching; contradicts the standing Opus-default). Update the model column in the CLAUDE.md agent table.
  • Reuses: existing agent frontmatter.
  • Test first:
    • File: n/a (frontmatter) — grep check
    • Verifies: no Haiku agent remains
    • Pattern: grep check
  • Verify: grep -rl 'model: haiku' agents/ → no matches; grep -n 'post-feedback-monitor' CLAUDE.md shows Opus.
  • On failure: revert — git checkout -- agents/post-feedback-monitor.md CLAUDE.md
  • Checkpoint: git commit -m "fix(linkedin-studio): promote post-feedback-monitor to Opus (Opus-default)"
  • Manifest:
    manifest:
      expected_paths:
        - agents/post-feedback-monitor.md
        - CLAUDE.md
      min_file_count: 2
      commit_message_pattern: "^fix\\(linkedin-studio\\): promote post-feedback-monitor to Opus"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: agents/post-feedback-monitor.md
          pattern: "model: opus"
    

Step 19: Newsletter multi-session/effort banner

  • Files: commands/newsletter.md
  • Changes: Add a banner at the top of the newsletter command: "This is a multi-session, multi-gate, ~N-hour process (16 phases)." Set realistic expectations before the user starts.
  • Reuses: existing newsletter.md header.
  • Test first:
    • File: n/a (content) — grep check
    • Verifies: banner present
    • Pattern: grep check
  • Verify: grep -niE 'multi-session|multi-gate|16 phases|~[0-9].*hour' commands/newsletter.md → ≥1 near the top.
  • On failure: revert — git checkout -- commands/newsletter.md
  • Checkpoint: git commit -m "docs(linkedin-studio): add multi-session/effort banner to newsletter"
  • Manifest:
    manifest:
      expected_paths:
        - commands/newsletter.md
      min_file_count: 1
      commit_message_pattern: "^docs\\(linkedin-studio\\): add multi-session/effort banner"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: commands/newsletter.md
          pattern: "multi-session"
    

Step 20: Measure long-form review-pass overlap + trim

  • Files: docs/remediation/overlap-measurement.md (new, committed evidence), and any agent/command trimmed as a result (e.g. commands/newsletter.md, an agent merged/removed)
  • Changes: On an in-repo fixture edition (NOT a read of maskinrommet/serier — cross-repo needs explicit instruction), run the long-form review agents and record what each catches in a committed comparison table (per the operator's "trim-for-quality, measured-not-assumed" decision). Trim redundancy ONLY where the measurement shows it (merge/remove a gate that catches nothing the others don't); if the measurement justifies the redundancy, record that and keep it. Commit the measurement as evidence.
  • Reuses: the existing agents/fixtures/*-cases.md fasit edition as the fixture; the long-form review agents.
  • Test first:
    • File: n/a (measurement artifact)
    • Verifies: measurement committed; any trim is justified by it
    • Pattern: n/a
  • Verify: test -f docs/remediation/overlap-measurement.md → exists; the file contains a per-reviewer catch table; if any gate was removed, bash scripts/test-runner.sh → exit 0 (counts consistent).
  • On failure: skip — if the fixture is insufficient to measure, record "measurement inconclusive; redundancy retained pending a real edition" and do not trim.
  • Checkpoint: git commit -m "docs(linkedin-studio): measure long-form review-pass overlap, trim where unjustified"
  • Manifest:
    manifest:
      expected_paths:
        - docs/remediation/overlap-measurement.md
      min_file_count: 1
      commit_message_pattern: "^docs\\(linkedin-studio\\): measure long-form review-pass overlap"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: docs/remediation/overlap-measurement.md
          pattern: "catch"
    

Step 21: Version bump + counts + three-doc + CHANGELOG reconciliation

  • Files: .claude-plugin/plugin.json, README.md, CLAUDE.md, CHANGELOG.md, ../../README.md (root marketplace), ../../.claude-plugin/marketplace.json, STATE.md
  • Changes: Bump the version → 4.0.0 (breaking: reinstall for new state/wired agents). Counts are now determinable (plan-critic Major 7): Step 13 wires all 11 orphans with no deletions → 19 agents; Step 16 adds firsthour27 commands (26 + 1); 25 reference docs · 6 skills · 9 hooks · 16 newsletter phases. (If execution deletes any orphan per Step 13's documented fallback, decrement here — but the default is the fixed set above.) Always recompute from ls at execution as the source of truth (ls agents/*.md | wc -l, etc.) and reconcile to that. Update the three doc levels in the same change (plugin README, plugin CLAUDE.md, root README), the README badges, CHANGELOG (Keep-a-Changelog entry summarizing Phase 03), and the marketplace.json. grep the old version → 0 stale. The Step 1 lint asserts version + count consistency.
  • Reuses: the version-sync memory (grep old version, update all); the lint's version/count asserts.
  • Test first:
    • File: n/a — Step 1 lint is the consistency check
    • Verifies: version consistent everywhere; counts match ls
    • Pattern: the lint version-grep
  • Verify: grep -rn "3\\.1\\.0" --include=*.json --include=*.md . | grep -v CHANGELOG | grep -v docs/remediation → no stale hits; bash scripts/test-runner.sh → exit 0; counts in README == CLAUDE.md == root README.
  • On failure: revert — git checkout -- .claude-plugin/ README.md CLAUDE.md CHANGELOG.md ../../README.md ../../.claude-plugin/marketplace.json STATE.md
  • Checkpoint: git commit -m "release(linkedin-studio): v4.0.0 — audit remediation (Phase 0-3), counts + three-doc sync"
  • Manifest:
    manifest:
      expected_paths:
        - .claude-plugin/plugin.json
        - README.md
        - CLAUDE.md
        - CHANGELOG.md
      min_file_count: 4
      commit_message_pattern: "^release\\(linkedin-studio\\): v4\\.0\\.0"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: CHANGELOG.md
          pattern: "4.0.0"
    

Alternatives Considered

Approach Pros Cons Why rejected
Build features as the audit sketched them (enforce 9:16; "triple-notification" surface; "cannot auto-publish" boundary; saves untrackable) Less research; matches the audit Research refuted all four premises — would ship NEW false claims, the exact disease being treated Rejected — built on corrected premises instead
New Opus de-AI agent (per convention-scanner) Matches the 6-gate Opus cluster voice-guardian.md already does AI-detection + the brief's success-criterion greps differentiation-checker → a new agent duplicates logic and misses the criterion Rejected — wire the existing orphan + extend voice-guardian
Single mega-release vs phased One reinstall Huge unreviewable diff; harder to bisect Phased per §9, one Voyage session per phase, lint guards each
Reconcile stats by editing each file independently Simpler per-file 40 files restate numbers → guaranteed to miss one Rejected — substrate-first + enumerate-by-grep + cite-not-restate; the lint stat-grep guards against contradiction (it cannot prove every citer cites-vs-restates, so completeness is also spot-checked at Step 3)

Test Strategy

  • Framework: node:test (.test.mjs / .test.ts, glob form node --test path/*.test.mjs — Node 25 directory mode is broken); bash for the structural lint; fixture-shape tests for agents.
  • Existing patterns: hooks/scripts/__tests__/state-updater.test.mjs (pure-module), scripts/analytics/tests/storage.test.ts (tmpdir + env override), agents/__tests__/*-fixture.test.mjs (shape-only, never self-certify live output).
  • New tests in this plan: scripts/analytics/tests/storage-root.test.ts (CWD-fix, red-first), hooks/scripts/__tests__/personalization-score.test.mjs (placeholder sentinel), hooks/scripts/__tests__/linkedin-content-filter.test.mjs (gate scope), state-updater extension (first-hour additive state). The rebuilt test-runner.sh is the cross-cutting drift gate.

Tests to write

Type File Verifies Model test
Unit scripts/analytics/tests/storage-root.test.ts default root anchors on plugin marker; env override scripts/analytics/tests/storage.test.ts
Unit hooks/scripts/__tests__/personalization-score.test.mjs placeholder→0 voice pts; real→pts state-updater.test.mjs
Unit hooks/scripts/__tests__/linkedin-content-filter.test.mjs gate fires on short-form content paths only state-updater.test.mjs
Lint scripts/test-runner.sh exit 0 healthy; exit 1 on agent add/remove existing sections

Risks and Mitigations

Priority Risk Location Impact Mitigation
Critical Voice move breaks ~15 readers if not atomic assets/voice-samples/ + 15 readers content commands break Step 5 lands placeholder + sentinel-detector + all readers in one commit
Critical Placeholder mis-detected → false "Voice ✓" personalization-score.mjs:23 adopter writes in stock voice, told done sentinel check (not [Your Name]); test both cases
Critical Stat reconciliation misses a file ~40 citers contradiction persists substrate-first + cite-not-restate + Step 1 lint stat-grep proves completeness
High Lint unguarded until rebuilt scripts/test-runner.sh later steps ship unguarded Step 1 is FIRST
High CWD bug writes data to wrong dir even with deps storage.ts:17-22 analytics silently wrong anchor on .claude-plugin/; red-first test
High Orphan wire uses bare type / no reload commands/* Task fails at runtime namespaced linkedin-studio:<name>; note reload; lint asserts count
Medium Hook edit without recompile → stale runtime hooks/hooks.json gate doesn't fire always run compile-hooks.py; lint wires --check
Medium State change non-additive → orphans 2 shipped editions state-updater.mjs, templates editions break additive scalar/non-R section only; preserve `extractField
Medium Series-default change mis-routes new editions newsletter.md, edition-state.template.json wrong write path keep ${LTL_SERIES_ROOT:-…} override; neutral default; shipped editions are outside the repo
Low Auto-publish boundary re-states a new false claim README.md dishonest again research/02 wording: "possible, deliberately not built" — never "cannot"

Assumptions

# Assumption Why unverifiable Impact if wrong
1 Final command count after Steps 13/16 = 26 ± deletions + 1 new (firsthour) depends on per-agent wire/delete decisions made at execution counts in Step 21 adjust; lint catches drift
2 The in-repo fasit fixture is rich enough to measure review-pass overlap (Step 20) the fixture is Del-4-scoped, not a full edition Step 20 on-failure: record "inconclusive, redundancy retained"
3 ToS permits documenting auto-publish as "possible but not built" LinkedIn Terms language not re-read first-hand finalize-time check before Step 10 wording locks
4 Each phase = one Voyage /trekcontinue session execution cadence adjust session grouping in the Execution Strategy

Verification

(Per-step manifests verify each step during execution. These are the end-to-end integration checks.)

  • bash scripts/test-runner.sh; echo $?0 (lint green on final state)
  • grep -rnE '40-50%|25-40%|1\.92%|360Brew|January 2026|-40-60%' references/ commands/ skills/ hooks/prompts/ CLAUDE.md README.md | grep -v algorithm-signals-reference → no stale contradiction
  • grep -rIn '/Users/ktg' config/ commands/ render/ agents/ | grep -v '.local' → no private path in shipped files
  • grep -rIn 'Kjell Tore' assets/ .claude-plugin/plugin.json → no PII in shipped files
  • grep -rni 'independently reviewed\|thought leadership\|cannot auto-publish\|triple.notification' README.md skills/ commands/ → no matches
  • from /tmp: analytics CLI runs without ERR_MODULE_NOT_FOUND and writes to <plugin>/assets/analytics
  • for each non-deleted orphan: grep -rl "subagent_type: linkedin-studio:<name>" commands/ → ≥1
  • node --test hooks/scripts/__tests__/*.test.mjs and cd scripts/analytics && npm test → all pass
  • grep -rn "3\.1\.0" (excluding CHANGELOG + docs/remediation) → no stale version
  • counts identical across plugin README, plugin CLAUDE.md, root README

Estimated Scope

  • Files to modify: ~5560 (heavy on references/ + commands/ propagation in Step 3)
  • Files to create: ~7 (placeholder template, 3 test files, firsthour command, overlap-measurement, voice .template)
  • Complexity: high (breadth + coupled criticals + 4 corrected feature premises), but each step is small and lint-guarded

Execution Strategy

21 steps grouped into 7 sessions across 5 waves. One phase ≈ one Voyage /trekcontinue session.

Session 1: Guard + substrate (Phase 0a)

  • Steps: 1, 2, 3
  • Wave: 1
  • Depends on: none
  • Scope fence: Touch: scripts/test-runner.sh, references/, the Step-3 citers, hooks/prompts/content-quality-gate.md. Never touch: scripts/analytics/, assets/voice-samples/.

Session 2: CLI + voice + correctness (Phase 0b)

  • Steps: 4, 5, 6, 7
  • Wave: 2
  • Depends on: Session 1 (lint must guard)
  • Scope fence: Touch: scripts/analytics/, assets/voice-samples/, personalization-score.mjs, commands/ab-test.md, commands/report.md, commands/setup.md, .claude-plugin/plugin.json. Never touch: references/algorithm-signals-reference.md.

Session 3: Generalize (Phase 1a)

  • Steps: 8, 9
  • Wave: 3
  • Depends on: Session 1
  • Scope fence: Touch: config/, render/, the long-form agents, commands/newsletter.md. Never touch: short-form commands.

Session 4: Honesty + discoverability (Phase 1b)

  • Steps: 10, 11, 12
  • Wave: 3 (parallel with Session 3 — disjoint files)
  • Depends on: Session 1
  • Scope fence: Touch: README.md, skills/, commands/onboarding.md/setup.md/linkedin.md/calendar.md/report.md/import.md. Never touch: config/, render/, agents/.

Session 5: Orphans + gates (Phase 2a)

  • Steps: 13, 14, 15, 18
  • Wave: 4
  • Depends on: Sessions 14
  • Scope fence: Touch: commands/ (short-form + video), hooks/prompts/voice-guardian.md, agents/ (wire/delete + post-feedback-monitor), references/linkedin-formats.md. Never touch: README.md counts (Step 21 owns).

Session 6: Feature surfaces (Phase 2b)

  • Steps: 16, 17
  • Wave: 4 (parallel with Session 5 — mostly disjoint; coordinate commands/newsletter.md/profile.md)
  • Depends on: Sessions 14
  • Scope fence: Touch: commands/firsthour.md, config/state-file.template.md, state-updater.mjs, commands/newsletter.md/profile.md/outreach.md. Never touch: short-form gate files.

Session 7: Long-form earn + release (Phase 3 + cross-cutting)

  • Steps: 19, 20, 21
  • Wave: 5
  • Depends on: all prior
  • Scope fence: Touch: commands/newsletter.md, docs/remediation/overlap-measurement.md, all version/count/doc files. Never touch: anything not yet committed by earlier sessions.

Execution Order

  • Wave 1: Session 1
  • Wave 2: Session 2 (after Wave 1)
  • Wave 3: Sessions 3 + 4 (parallel, after Wave 1)
  • Wave 4: Sessions 5 + 6 (parallel, after Waves 23)
  • Wave 5: Session 7 (after all)

Grouping rules applied

  • Steps sharing files → same session (the algorithm-stat propagation is all in Session 1; newsletter touches coordinated across Sessions 6/7).
  • Independent modules → separate sessions (CLI vs voice vs generalize).
  • The lint (Step 1) gates everything → Wave 1 alone.
  • Release reconciliation (Step 21) → last, captures all prior counts.

Plan Quality Score

Dimension Weight Score Notes
Structural integrity 0.15 90 dependency-ordered; lint-first; release-last; Step 13/21 count-determinism fixed
Step quality 0.20 86 real paths+lines; Step 4 re-diagnosed, Step 3 enumerate-by-grep; doc-heavy Step 3/13 inherently broad
Coverage completeness 0.20 90 every brief criterion mapped + in Verification (scope-guardian ALIGNED); missing 360Brew citers added
Specification quality 0.15 88 concrete greps; manifests on all steps; "decide at execution" removed from Step 13
Risk & pre-mortem 0.15 90 coupled criticals sequenced; second voice-writer + analytics-misdiagnosis now caught
Headless readiness 0.10 88 On-failure + Checkpoint per step; Step 13 dispositions fixed; Step 20 honest on-failure
Manifest quality 0.05 86 all steps have manifests; false hooks.json entry removed from Step 3
Weighted total 1.00 88 Grade: A (post-revision)

Adversarial review:

  • Plan critic: REVISE → addressed. Initial pass found 3 blockers + 7 majors (Grade C, 73); all 3 blockers and the actionable majors fixed in the Revisions below. The three blockers were genuine and load-bearing (analytics misdiagnosis, incomplete stat-propagation file-list, false hook-recompile premise) — exactly the value an independent pass adds.
  • Scope guardian: ALIGNED — all 21 brief success criteria map to a step and appear in Verification; all four refined-by-research criteria honored (video, newsletter, boundaries, saves); all non-goals respected; every cited path verified to exist. 2 low-risk creep items (Step 18 Opus-promotion, Step 8 de-branding), both justified by Constraints/Intent.

Revisions

Added by adversarial review (Phase 9). Plan-critic REVISE → addressed.

# Finding Severity Resolution
1 Step 4 misdiagnosed the analytics crash: getAnalyticsRoot() is __dirname-based (CWD-independent) and bypassed by the commands' explicit ANALYTICS_ROOT override; the real fresh-clone crash is missing tsx/csv-parse blocker Step 4 reframed: PRIMARY = npm install surfacing (the actual crash); the depth-anchor is now a SECONDARY latent-correctness fix; Verify installs before running
2 Step 3's Verify greps all of commands/ for 360Brew/penalty but the file-list omitted audit.md/strategy.md/linkedin.md/topic-rotation-gate.md → step fails its own gate; propagation incomplete blocker Step 3 now enumerates the citer set by grep at execution (not a stale hand-list) + added the four missing files; Verify excludes the substrate file from the no-match grep
3 Step 3 & 14 claimed editing a runtime-loaded prompt requires compile-hooks.py + regenerates hooks.json — false (those prompts are read at runtime; only ADDING a template entry needs recompile) blocker Removed the recompile/hooks.json claims from Step 3 and Step 14 (+ dropped hooks.json from Step 3's manifest)
4 Step 5 not atomic: onboarding.md:142 APPENDS the user's voice below the placeholder → sentinel survives, score stays 0 after population; setup.md:28 detection table left stale major Step 5 now fixes BOTH writers (setup.md:99 + onboarding.md:142 → overwrite) and the setup.md:28 table
5 "No-PII" NFR ambiguous re: LICENSE / historical docs major Step 5 clarified: LICENSE author name is the required MIT copyright holder (intentional, not scrubbed); the leak scope is the voice profile + plugin.json author field
6 Step 13 deferred 3 agents' wire/delete "to execution"; 11 orphans never enumerated; non-determinism propagated to Step 21 counts major Step 13 now has an explicit 11-row disposition table (DEFAULT: wire all → 19 agents, no deletions); Step 21 counts are consequently determinable (19 agents · 27 commands)
7 Step 4 Verify ran npm test without npm install first → fails on the fresh-clone scenario major Verify now installs first
8 Step 1 over-claimed the stat-grep "proves propagation completeness" (it only detects contradiction); restore step not failure-safe major→minor Softened the Alternatives claim (guards contradiction; completeness spot-checked) + made the Step 1 Verify restore self-running regardless of set -e
9 "skrivekontrakt §C2 does not ship" neither fixed nor dropped; Step 9 verify weak minor Step 9 now makes the §C2 reference fall back to an in-tree checklist + adds a positive language-threading assertion
commands/report.md edited in Steps 7/10/13 minor (note) Execution note: later edits to report.md must preserve the Step 7 saves-honesty wording (the cross-cutting Verify greps for it)

Adversarial Pass 2 (gemini-bridge, v5.1.1 high-effort)

An independent Gemini Deep Research pass (~16 min, 24 sources) stress-tested the five load-bearing decisions. It surfaced two genuine blind spots the local reviewers and the author shared, both now folded in, plus three points already substantially covered.

Folded in (real):

  1. Git history ≠ gitignore (Decision 3). .gitignore does not remove the already-committed real voice profile from past commits. → Step 5 now records an explicit, proportionate decision: this is the author's own attributed open-source plugin (name already public in LICENSE/README/Forgejo), so the historical file is attributed authorship, not a leaked secret; the bug is the adopter-default, which placeholder+gitignore fixes; no force-push history rewrite (disproportionate + disruptive for an open-source plugin with forks).
  2. Lint stat-grep chicken-and-egg (Decision 1). Putting the stat-consistency grep in the Step-1 lint would make Step 1 fail its own Verify (contradictions persist until Step 3). → Step 1 now rebuilds the STRUCTURAL lint only (already-broken, goes green immediately); the stat-grep moves to Step 3 (after reconciliation), the version-grep to Step 21.

Already covered (Gemini's caveats map to existing plan content): 3. Downgrade-to-direction still asserts a mechanism (Decision 2) → the per-claim Source + Confidence column (Step 2) IS the "Unverified/Illustrative" labeling Gemini prescribes; honest low/unverified confidence values satisfy the integrity concern. First-party benchmarking is out of scope (operator runs no benchmarks). 4. Prompt-bloat / single-responsibility on the de-AI gate (Decision 4) → the PRIMARY de-AI mechanism is already an isolated agent (differentiation-checker, invoked via Task = fresh context window — exactly Gemini's "isolated subagent" prescription); the voice-guardian touch is a minimal prose extension to an existing runtime prompt, kept small. 5. i18n scope (Decision 5) → the goal is removing the Norwegian lock so one other operator can run it in their language, NOT a multi-language-output i18n framework. Step 9 keeps the lighter touch (language as a configurable input to the review agents) and avoids "configuration as the new hardcoding" by defaulting cleanly; full i18n/pseudo-localization is deliberately out of scope (no second-language output requirement).

Verdict: Pass 2 strengthened the plan on two real axes (history decision + lint sequencing) without expanding scope. No finding overturned the approach; the phasing and decisions hold.