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

908 lines
71 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```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 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:**
```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 ~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:**
```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 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:**
```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 **~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:**
```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 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:**
```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:** ~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.