# 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 ~1–2K follower floor). Delivered phased per audit §9.

## Architecture Diagram

```mermaid
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 60–90 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 → ~1–2K 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 4–21. 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:**
  ```yaml
  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 ~19–60%), LinkedIn denies intent, value-first > location; (d) golden window 60–90 min + evergreen resurfacing (drop "24–72h" 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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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 10–11 are wired in their dedicated steps; this step wires 1–9. 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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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 **~1–2K follower floor** (frame: wait until you can spend the blast); realistic cold-start floors (0–100 subs months 1–3); 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:**
  ```yaml
  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: haiku` → `model: 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:**
  ```yaml
  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:**
  ```yaml
  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:**
  ```yaml
  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 `firsthour` → **27 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 0–3), 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:**
  ```yaml
  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||default` |
| 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:** ~55–60 (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 1–4
- **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 1–4
- **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 2–3)
- **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.
