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>
71 KiB
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
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
.mdwith 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 viatsx). 214 source files (medium). - Key patterns: commands invoke agents ONLY via
Taskwithsubagent_type: linkedin-studio:<name>(namespaced; bare fails); hooks compiled fromhooks.template.json+prompts/*.mdviacompile-hooks.py(never edithooks.json); content gates are advisory (always exit 0) viacontent-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 instate-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(getAnalyticsRootdepth 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:350render/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 wrongcommands/linkedin:NAME.mdlayout), theskills/<name>.mdpath check (real:skills/<name>/SKILL.md), thepersonalization-scorerreference, the fabricatedauto_discoverplugin.json field, and the missingdocs/DEVELOPMENT-LOG.mdassert. 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); globcommands/*.md,references/*.md,skills/*/SKILL.md; assert each command/agent hasname:/description:frontmatter; callpython3 hooks/scripts/compile-hooks.py --checkfor hook drift. bash 3.2-safe (plain arrays, nodeclare -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_ROOTskeleton intest-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'sset -e). - On failure: revert —
git checkout -- scripts/test-runner.sh - Checkpoint:
git commit -m "fix(linkedin-studio): rebuild dead structural lint to real v3.1 layout" - Manifest:
manifest: expected_paths: - scripts/test-runner.sh min_file_count: 1 commit_message_pattern: "^fix\\(linkedin-studio\\): rebuild dead structural lint" bash_syntax_check: - scripts/test-runner.sh forbidden_paths: [] must_contain: - path: scripts/test-runner.sh pattern: "ls agents"
Step 2: Rebuild the algorithm-signals substrate (canonical, sourced)
- Files:
references/algorithm-signals-reference.md - Changes: Make this the single source of truth. Add a per-claim Source + Confidence column to the signal tables (house style: keep the
| Signal | Weight | … |table genre, widen rows; keep the closing*Sources:*footer as bibliography). Apply research/01: (a) engagement order saves > shares > quality-comments > reactions, comment ≈ 2x like (medium), drop the bare "15x/5x" framing → state as ordering + sourced; (b) reconcile the intra-file carousel contradiction (:721.92% PDF vs:736.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.mdprovenance-blockquote style; research/01 §Recommendation as the source-of-truth content. - Test first:
- File: n/a (content doc) — verification is the Step 1 lint's stat-consistency grep
- Verifies: no intra-file contradiction; every numeric claim has a Source/Confidence cell
- Pattern: research/01 reconciliation table
- Verify:
grep -nE '1\.92%|15x more reach|January 2026|360Brew|-40-60%' references/algorithm-signals-reference.md→ expected: no matches (all downgraded);grep -c 'Confidence' references/algorithm-signals-reference.md→ ≥ 1. - On failure: revert —
git checkout -- references/algorithm-signals-reference.md - Checkpoint:
git commit -m "fix(linkedin-studio): reconcile algorithm-signals to one sourced statement" - Manifest:
manifest: expected_paths: - references/algorithm-signals-reference.md min_file_count: 1 commit_message_pattern: "^fix\\(linkedin-studio\\): reconcile algorithm-signals" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: references/algorithm-signals-reference.md pattern: "Confidence"
Step 3: Propagate the reconciled numbers to all citers
- Files: ENUMERATE AT EXECUTION by running the two greps below first, then edit every hit. Known set (from exploration + plan-critic Blocker 2):
commands/carousel.md,commands/profile.md,commands/react.md,commands/analyze.md,commands/monetize.md,commands/audit.md,commands/strategy.md,commands/linkedin.md,references/linkedin-formats.md,references/linkedin-visual-style.md,references/glossary.md,references/first-comment-strategy.md,references/troubleshooting-guide.md,references/linkedin-monetization-strategies.md,references/engagement-frameworks.md,agents/content-optimizer.md,hooks/prompts/content-quality-gate.md,hooks/prompts/topic-rotation-gate.md,CLAUDE.md,README.md,skills/linkedin-studio/SKILL.md,skills/linkedin-analytics/SKILL.md,assets/templates/carousel-templates.md - Changes: First enumerate the real citer set:
grep -rlnE '40-50%|25-40%|6\.6%|1\.92%|15x more reach|January 2026|360Brew|-40-60%' references/ commands/ agents/ skills/ hooks/prompts/ CLAUDE.md README.md— edit EVERY hit (do not work from a stale hand-list; plan-critic Blocker 2 foundaudit.md/strategy.md/linkedin.md/topic-rotation-gate.mdwere missing). Replace every restated number with the reconciled value AND a citation toalgorithm-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.mdandtopic-rotation-gate.mdare loaded at RUNTIME bycontent-gatekeeper.mjs— editing their prose does NOT requirecompile-hooks.pyand does NOT changehooks.json. No recompile in this step. Then (gemini Pass-2) add the stat-consistency grep toscripts/test-runner.sh— the external-link penalty % and carousel % must each appear as ONE value acrossreferences/ commands/ skills/ hooks/prompts/(fail if ≥2 distinct magnitudes; exclude commented lines). Adding it HERE, after reconciliation, means the lint goes green instead of failing its own gate. - Reuses: Step 2's canonical file as the cite target; the Step 1 lint skeleton.
- Test first:
- File: n/a — the Step 1 lint stat-consistency grep is the regression check
- Verifies: one magnitude per effect across the tree
- Pattern: the lint's penalty-% grep
- Verify:
grep -rnE '40-50%|25-40%|1\.92%' references/ commands/ skills/ hooks/prompts/ CLAUDE.md README.md | grep -v algorithm-signals-reference→ no matches;grep -rnE 'January 2026|360Brew' commands/ README.md skills/ hooks/prompts/ CLAUDE.md→ no matches (or only inside a sourced "research model, not deployed" note); spot-check 3 random citers actually cite the reference (cite-not-restate, not just a consistent bare number);bash scripts/test-runner.sh→ exit 0 (the newly-added stat-grep is green). - On failure: revert —
git checkout -- <enumerated files> scripts/test-runner.sh - Checkpoint:
git commit -m "fix(linkedin-studio): propagate reconciled algorithm numbers, cite-not-restate" - Manifest:
manifest: expected_paths: - commands/carousel.md - commands/profile.md - commands/strategy.md - commands/audit.md - scripts/test-runner.sh min_file_count: 5 commit_message_pattern: "^fix\\(linkedin-studio\\): propagate reconciled algorithm numbers" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/carousel.md pattern: "algorithm-signals-reference"
Step 4: Fix the analytics CLI root-resolution + surface install
- Files:
scripts/analytics/src/utils/storage.ts,commands/report.md,commands/import.md,scripts/analytics/src/cli.ts - Changes: PRIMARY fix (the actual fresh-clone crash):
tsx+csv-parseare absent on a fresh clone (gitignorednode_modules), sonode --import tsxthrowsERR_MODULE_NOT_FOUND— surface the install at point-of-use inreport.md/import.md: prependcd "${CLAUDE_PLUGIN_ROOT}/scripts/analytics" && npm install --silent(idempotent) before thenode --import tsxinvocation, and fix the troubleshooting line "Verify tsx is available" → the actual install command. Reconcilecli.tsusage text (node build/cli.js→ the realtsx src/cli.tsruntime). SECONDARY fix (latent correctness bug —getAnalyticsRoot()depth): the../../../../in storage.ts:17-22 is calibrated forbuild/utils/but runs under tsx fromsrc/utils/, so the fallback root is wrong. The shipped commands mask it by always passingANALYTICS_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 theANALYTICS_ROOToverride as the test seam. - Reuses: existing
ANALYTICS_ROOTenv handling in storage.ts;scripts/analytics/tests/storage.test.tstmpdir/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_ROOToverride returns the resolved env path (red against current code, green after fix) - Pattern:
scripts/analytics/tests/storage.test.ts
- File:
- 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→ noERR_MODULE_NOT_FOUND;cd scripts/analytics && npm install --silent && npm test 2>&1 | tail -3→ storage-root test passes. - On failure: revert —
git checkout -- scripts/analytics/ commands/report.md commands/import.md - Checkpoint:
git commit -m "fix(linkedin-studio): anchor analytics root on plugin marker + surface npm install" - Manifest:
manifest: expected_paths: - scripts/analytics/src/utils/storage.ts - scripts/analytics/tests/storage-root.test.ts - commands/report.md - commands/import.md min_file_count: 4 commit_message_pattern: "^fix\\(linkedin-studio\\): anchor analytics root" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: scripts/analytics/src/utils/storage.ts pattern: "plugin.json"
Step 5: Voice-leak + placeholder-detection (coupled criticals)
- Files:
assets/voice-samples/authentic-voice-samples.template.md(new),assets/voice-samples/authentic-voice-samples.md(→ becomes placeholder),hooks/scripts/personalization-score.mjs,commands/setup.md,commands/onboarding.md,.claude-plugin/plugin.json,.gitignore, plus the exact-filename readers (hooks/scripts/user-prompt-context.mjs,hooks/prompts/voice-guardian.md,hooks/prompts/state-update-reminder.md) - Changes: Ship a PII-free placeholder at
authentic-voice-samples.mdcarrying an explicit sentinel (e.g.<!-- VOICE_PLACEHOLDER -->); move the author's real profile to a gitignoredauthentic-voice-samples.local.mdand add that glob to.gitignore; add a.template.mdfor adopters. Fixpersonalization-score.mjs:23to 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): fixsetup.md:99(merge→overwrite) ANDcommands/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 atsetup.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.jsonauthor field; the author name inLICENSEis 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):.gitignoredoes 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 inLICENSE(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.mdever 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
- File:
- 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 .gitignoreand restore the real file from the local copy. - Checkpoint:
git commit -m "fix(linkedin-studio): ship placeholder voice profile, gitignore real, sentinel detection" - Manifest:
manifest: expected_paths: - assets/voice-samples/authentic-voice-samples.md - assets/voice-samples/authentic-voice-samples.template.md - hooks/scripts/personalization-score.mjs - hooks/scripts/__tests__/personalization-score.test.mjs - .gitignore min_file_count: 5 commit_message_pattern: "^fix\\(linkedin-studio\\): ship placeholder voice profile" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: hooks/scripts/personalization-score.mjs pattern: "VOICE_PLACEHOLDER"
Step 6: Fix the A/B significance claim
- Files:
commands/ab-test.md - Changes: Remove the literal
Significant? Yes/Nocolumn (:301-306); rename toDirectional?; cap confidence at "directional only" below ~50 conversions/variant; drop the "20% significance rule" wording (:277,310-313) and the "3 = Medium, 5+ = High" ladder (:315-317) — replace with a plain "organic personal-post volume rarely reaches statistical significance; treat results as directional." - Reuses: existing ab-test.md structure.
- Test first:
- File: n/a (content doc)
- Verifies: no "Significant?" column, no "20% significance rule"
- Pattern: grep check
- Verify:
grep -nE 'Significant\?|20% significance' commands/ab-test.md→ no matches;grep -c 'irectional' commands/ab-test.md→ ≥ 1. - On failure: revert —
git checkout -- commands/ab-test.md - Checkpoint:
git commit -m "fix(linkedin-studio): downgrade A/B significance claim to directional" - Manifest:
manifest: expected_paths: - commands/ab-test.md min_file_count: 1 commit_message_pattern: "^fix\\(linkedin-studio\\): downgrade A/B significance" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/ab-test.md pattern: "irectional"
Step 7: Saves/dwell honesty-fix
- Files:
commands/report.md,scripts/analytics/src/models/types.ts,commands/strategy.md - Changes: Apply research/02 D2/D4. Reframe
report.md:223("Saves (10x weight) … highest-impact") → honest dated wording: "saves are visible in your native LinkedIn post analytics (since ~Sept 2025, count-only) but there is no self-serve API to pull them, so this tool does not auto-track them — read them in LinkedIn directly; dwell is internal-only for organic posts." Add a code comment intypes.tsdocumenting whysaves/dwellare intentionally absent (no self-serve source). Reconcile any strategy-layer copy that tells the user to "optimize saves" the tool can't read. No new metric field, no manual-entry feature (operator Q3). - Reuses: research/02 D2 wording; existing report.md structure.
- Test first:
- File: n/a (content/comment)
- Verifies: no claim the tool measures saves/dwell
- Pattern: grep check
- Verify:
grep -nE 'Saves \(10x|highest-impact signals' commands/report.md→ no matches;grep -n 'no self-serve API' commands/report.md→ ≥ 1 match. - On failure: revert —
git checkout -- commands/report.md scripts/analytics/src/models/types.ts commands/strategy.md - Checkpoint:
git commit -m "fix(linkedin-studio): honest saves/dwell wording, no false tracking claim" - Manifest:
manifest: expected_paths: - commands/report.md - scripts/analytics/src/models/types.ts min_file_count: 2 commit_message_pattern: "^fix\\(linkedin-studio\\): honest saves/dwell wording" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/report.md pattern: "no self-serve API"
Step 8: Parameterize the series path + de-brand render output
- Files:
config/edition-state.template.json,commands/newsletter.md,render/build-linkedin.mjs,render/build-carousel.mjs,config/image-credit-caption.template.md - Changes: Change the
${LTL_SERIES_ROOT:-…}default from the private/Users/ktg/repos/maskinrommet/serierto a documented neutral default (e.g.${LTL_SERIES_ROOT:-$HOME/linkedin-series}), and scrub the hardcoded private path fromedition-state.template.json:4prose. De-brand the render output: make the "Maskinrommet" footer/eyebrow/title strings inbuild-linkedin.mjs:350+build-carousel.mjs:7,232,282configurable (env var or config field, defaulting to a neutral/empty brand). Preserve the env-var + explicit-path-arg contract. - Reuses: existing
LTL_SERIES_ROOTenv mechanism; config-field pattern. - Test first:
- File: n/a (config/render) — verification is the Phase-1 grep
- Verifies: no
/Users/ktgin shipped files; brand configurable - Pattern: grep check
- Verify:
grep -rIn '/Users/ktg' config/ commands/ render/ | grep -v '.local'→ no matches;grep -rn 'Maskinrommet' render/→ only inside a configurable default, not a hardcoded literal. - On failure: revert —
git checkout -- config/ commands/newsletter.md render/ - Checkpoint:
git commit -m "feat(linkedin-studio): parameterize series path + de-brand render output" - Manifest:
manifest: expected_paths: - config/edition-state.template.json - commands/newsletter.md - render/build-linkedin.mjs min_file_count: 3 commit_message_pattern: "^feat\\(linkedin-studio\\): parameterize series path" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/newsletter.md pattern: "LTL_SERIES_ROOT"
Step 9: Parameterize the Norwegian language-lock
- Files:
agents/language-reviewer.md,agents/voice-scrubber.md,agents/content-reviewer.md,agents/fact-reviewer.md,agents/editorial-reviewer.md,commands/newsletter.md,config/edition-state.template.json - Changes: Make the review language a configurable input rather than a hardcoded Norwegian contract. Add a
languagefield to edition-state (additive, default e.g.en), thread it into the long-form agent prompts solanguage-reviewergrades against the configured language's rules (Norwegian anglicism/kanselli checks fire only whenlanguage: no), andvoice-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
languageinput; Norwegian path intact; §C2 has an in-tree fallback - Pattern:
agents/__tests__/language-reviewer-fixture.test.mjsif present
- Verify:
grep -ni 'language' config/edition-state.template.json→ ≥ 1 (positive: the field exists and is threaded — confirm alanguage ==/language:reference appears inagents/language-reviewer.md);grep -niE 'unconditional|always Norwegian|norsk draft' agents/language-reviewer.md→ no unconditional-Norwegian assertion remains;grep -ni 'C2' agents/editorial-reviewer.md→ reference now points to an in-tree fallback, not only the unshipped contract. - On failure: revert —
git checkout -- agents/ commands/newsletter.md config/edition-state.template.json - Checkpoint:
git commit -m "feat(linkedin-studio): make long-form review language configurable" - Manifest:
manifest: expected_paths: - agents/language-reviewer.md - config/edition-state.template.json min_file_count: 2 commit_message_pattern: "^feat\\(linkedin-studio\\): make long-form review language configurable" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: config/edition-state.template.json pattern: "language"
Step 10: Honest, dated boundary statements
- Files:
README.md,commands/report.md,commands/calendar.md,commands/import.md - Changes: Apply research/02. Add a dated ("as of 2026-05") boundaries section: (a) post-level analytics API exists but is partner-gated (verified org + LinkedIn Page) — not self-serve; CSV is the practical floor; (b) auto-publish to a personal profile is technically possible self-serve but deliberately not built (OAuth/token overhead + LinkedIn Terms on automated posting) — a choice, NOT "cannot"; (c) dwell internal-only for organic. Reconcile the
calendar.md"publish action" / queue wording so it never implies the tool auto-posts (it marks a manually-posted item as published). - Reuses: research/02 §Recommendation wording.
- Test first:
- File: n/a (docs)
- Verifies: no "cannot auto-publish" / no "CSV is the only way"; dated
- Pattern: grep check
- Verify:
grep -niE 'cannot auto-publish|only way to (get|access)' README.md commands/*.md→ no matches;grep -ni 'as of 2026' README.md→ ≥ 1. - On failure: revert —
git checkout -- README.md commands/report.md commands/calendar.md commands/import.md - Checkpoint:
git commit -m "docs(linkedin-studio): honest dated API/auto-publish/analytics boundaries" - Manifest:
manifest: expected_paths: - README.md - commands/calendar.md min_file_count: 2 commit_message_pattern: "^docs\\(linkedin-studio\\): honest dated" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: README.md pattern: "as of 2026"
Step 11: Remove the README "independently reviewed" claim + honest reframe
- Files:
README.md - Changes: Remove the "the version that ships is the version that's actually been independently reviewed" string (~L128) and the matching pipeline-diagram "independent re-read" line (~L120). Replace with an honest framing: the long-form pipeline runs cold/headless review gates before lock (describe the capability), without claiming every shipped edition was independently re-reviewed. Locate by content, not line number (lines have drifted).
- Reuses: existing README pipeline section.
- Test first:
- File: n/a (docs)
- Verifies: the exact claim string is gone
- Pattern: grep check
- Verify:
grep -ni 'independently reviewed' README.md→ no matches. - On failure: revert —
git checkout -- README.md - Checkpoint:
git commit -m "docs(linkedin-studio): remove independently-reviewed claim, honest reframe" - Manifest:
manifest: expected_paths: - README.md min_file_count: 1 commit_message_pattern: "^docs\\(linkedin-studio\\): remove independently-reviewed claim" bash_syntax_check: [] forbidden_paths: [] must_contain: []
Step 12: Reconcile discoverability surfaces
- Files:
skills/linkedin-studio/SKILL.md,skills/linkedin-analytics/SKILL.md,skills/linkedin-content-creation/SKILL.md,skills/linkedin-strategy/SKILL.md,skills/linkedin-networking/SKILL.md,skills/linkedin-voice/SKILL.md,commands/onboarding.md,commands/setup.md,commands/linkedin.md - Changes: In
skills/linkedin-studio/SKILL.md: rename "thought leadership plugin" → "LinkedIn Studio"; correct the agent table to the real count and route tonewsletter/headless-review/pivot/react. Fixonboarding.md"25 commands" → 26; reconcile the pillar-count disagreement (onboarding.md3-5 vssetup.md5) to one number; addheadless-review/pivotto thelinkedin.mdrouter 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.mdshows one consistent number. - On failure: revert —
git checkout -- skills/ commands/onboarding.md commands/setup.md commands/linkedin.md - Checkpoint:
git commit -m "docs(linkedin-studio): reconcile discoverability surfaces + skill naming" - Manifest:
manifest: expected_paths: - skills/linkedin-studio/SKILL.md - commands/onboarding.md - commands/linkedin.md min_file_count: 3 commit_message_pattern: "^docs\\(linkedin-studio\\): reconcile discoverability" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/linkedin.md pattern: "headless-review"
Step 13: Resolve the 11 orphan agents (wire-or-delete, case-by-case)
-
Files:
commands/video.md,commands/post.md,commands/ab-test.md,commands/calendar.md,commands/report.md,commands/analyze.md,commands/batch.md,commands/pipeline.md,commands/strategy.md,commands/outreach.md,commands/setup.md, plusCLAUDE.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
Taskto the command'sallowed-toolsAND asubagent_type: linkedin-studio:<name>call. The 11 orphans + disposition:# Orphan agent Disposition Target command 1 video-scripterwire video.md(already says "delegate")2 content-optimizerwire post.md+ab-test.md3 analytics-interpreterwire report.md+analyze.md4 content-plannerwire batch.md+pipeline.md(already holdTask)5 trend-spotterwire batch.md+pipeline.md6 network-builderwire outreach.md7 strategy-advisorwire strategy.md8 voice-trainerwire setup.md(voice-profile building)9 post-feedback-monitorwire calendar.md(publish action / 48h monitor)10 differentiation-checkerwire (Step 14) post/quick/react/carousel/video 11 engagement-coachwire (Step 16) firsthour.mdAgents 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_typecontract; existingTaskgrants 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
- File: n/a — Step 1 lint asserts agent count ==
-
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(andgit restoreany deleted agent) -
Checkpoint:
git commit -m "refactor(linkedin-studio): wire or delete 11 orphan agents (case-by-case)" -
Manifest:
manifest: expected_paths: - commands/video.md - CLAUDE.md min_file_count: 2 commit_message_pattern: "^refactor\\(linkedin-studio\\): wire or delete 11 orphan agents" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/video.md pattern: "subagent_type: linkedin-studio:video-scripter"
Step 14: Short-form de-AI / differentiation gate
- Files:
commands/post.md,commands/quick.md,commands/react.md,commands/carousel.md,commands/video.md,hooks/prompts/voice-guardian.md,hooks/hooks.json(regenerated) - Changes: Wire the existing orphan
differentiation-checkerinto the five short-form creation commands (addTasktoallowed-tools+ asubagent_type: linkedin-studio:differentiation-checkercall). 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.mdis loaded at runtime bycontent-gatekeeper.mjs— extending its prose changes nothing inhooks.json;compile-hooks.pyis 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) — verifiesisLinkedInContentfires on the short-form content paths the gate guards - Verifies: gate scope correct; differentiation-checker wired
- Pattern:
hooks/scripts/__tests__/state-updater.test.mjs
- File:
- Verify:
grep -rl "subagent_type: linkedin-studio:differentiation-checker" commands/→ ≥1;node --test hooks/scripts/__tests__/linkedin-content-filter.test.mjs→ passes;python3 hooks/scripts/compile-hooks.py --check→ no drift. - On failure: revert —
git checkout -- commands/ hooks/ - Checkpoint:
git commit -m "feat(linkedin-studio): short-form de-AI gate via differentiation-checker + voice-guardian" - Manifest:
manifest: expected_paths: - commands/post.md - hooks/prompts/voice-guardian.md - hooks/scripts/__tests__/linkedin-content-filter.test.mjs min_file_count: 3 commit_message_pattern: "^feat\\(linkedin-studio\\): short-form de-AI gate" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/post.md pattern: "differentiation-checker"
Step 15: Video quality gate (captions + aspect guidance, not 9:16-mandatory)
- Files:
commands/video.md,references/linkedin-formats.md,references/algorithm-signals-reference.md - Changes: Apply research/03 D1-D3. In
video.md: MP4 default (warn-only on MOV/AVI), within official upload limits; enforce/strongly-recommend captions (SRT or native auto-captions, labelled best-practice not "required"); aspect ratio as guidance — 4:5 / 1:1 preferred for broad distribution, 9:16 mobile-only opt-in. Fix the contradiction inlinkedin-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 inalgorithm-signals-reference.md:75/video.md:198; add a one-line "per-video reach declining; documents out-engage video" note. - Reuses: research/03 §Recommendation; the orphan
video-scripter(wired in Step 13). - Test first:
- File: n/a (content) — grep check
- Verifies: no "9:16 required", no "3-second hook"; captions present
- Pattern: grep check
- Verify:
grep -niE 'must be 9:16|9:16 \(1080|3-second hook' commands/video.md references/linkedin-formats.md→ no matches;grep -ni 'captions' commands/video.md→ ≥1;grep -ni 'deprioritized' references/linkedin-formats.md→ no 4:5-deprioritized line. - On failure: revert —
git checkout -- commands/video.md references/linkedin-formats.md references/algorithm-signals-reference.md - Checkpoint:
git commit -m "feat(linkedin-studio): video quality gate (captions + aspect guidance, drop 9:16 mandate)" - Manifest:
manifest: expected_paths: - commands/video.md - references/linkedin-formats.md min_file_count: 2 commit_message_pattern: "^feat\\(linkedin-studio\\): video quality gate" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/video.md pattern: "4:5"
Step 16: First-hour / reply-loop command with tracked state
- Files:
commands/firsthour.md(new),config/state-file.template.md,hooks/scripts/state-updater.mjs,hooks/scripts/__tests__/state-updater.test.mjs,CLAUDE.md - Changes: Add a wired first-hour/reply-loop command that invokes
engagement-coach(+post-feedback-monitor) with tracked state: a target list, draft comments, and a timestamped first-hour plan persisted in~/.claude/linkedin-studio.local.md. Add additive scalar fields / a non-R-initial section toconfig/state-file.template.mdand a pure mutation function instate-updater.mjsmirroringupdatePostTracking. Register the command in CLAUDE.md. - Reuses:
commands/react.mdstructure;agents/engagement-coach.md;state-updater.mjsupdatePostTrackingpattern; 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
- File:
- Verify:
grep -rl "subagent_type: linkedin-studio:engagement-coach" commands/→ ≥1;node --test hooks/scripts/__tests__/state-updater.test.mjs→ passes;bash scripts/test-runner.sh→ exit 0 (command count updated). - On failure: revert —
git checkout -- commands/firsthour.md config/state-file.template.md hooks/scripts/ CLAUDE.md - Checkpoint:
git commit -m "feat(linkedin-studio): first-hour/reply-loop command with tracked state" - Manifest:
manifest: expected_paths: - commands/firsthour.md - config/state-file.template.md - hooks/scripts/state-updater.mjs min_file_count: 3 commit_message_pattern: "^feat\\(linkedin-studio\\): first-hour/reply-loop command" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/firsthour.md pattern: "engagement-coach"
Step 17: Honest newsletter-distribution + profile-SEO + outreach pipeline surfaces
- Files:
commands/newsletter.md,commands/profile.md,commands/outreach.md,references/linkedin-growth-playbook-2025-2026.md - Changes: Apply research/03 D5. Newsletter distribution surface = the honest version: "bypasses organic feed ranking via ONE deduplicated notification per subscriber per edition (NOT triple)"; one-time launch-blast + a ~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 tooutreach.md(or reference the state added in Step 16's pattern). - Reuses: research/03 D5; the state pattern from Step 16.
- Test first:
- File: n/a (content) — grep check
- Verifies: no "triple notification"; follower floor present
- Pattern: grep check
- Verify:
grep -niE 'triple.notification' commands/newsletter.md→ no matches;grep -niE 'deduplicated|follower floor|1[–-]?2K|bypasses.*feed' commands/newsletter.md→ ≥1;grep -ni 'headline' commands/profile.md→ ≥1. - On failure: revert —
git checkout -- commands/newsletter.md commands/profile.md commands/outreach.md references/linkedin-growth-playbook-2025-2026.md - Checkpoint:
git commit -m "feat(linkedin-studio): honest newsletter distribution + profile-SEO + outreach pipeline" - Manifest:
manifest: expected_paths: - commands/newsletter.md - commands/profile.md min_file_count: 2 commit_message_pattern: "^feat\\(linkedin-studio\\): honest newsletter distribution" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/newsletter.md pattern: "deduplicated"
Step 18: Promote post-feedback-monitor off Haiku
- Files:
agents/post-feedback-monitor.md,CLAUDE.md - Changes: Change
model: 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.mdshows Opus. - On failure: revert —
git checkout -- agents/post-feedback-monitor.md CLAUDE.md - Checkpoint:
git commit -m "fix(linkedin-studio): promote post-feedback-monitor to Opus (Opus-default)" - Manifest:
manifest: expected_paths: - agents/post-feedback-monitor.md - CLAUDE.md min_file_count: 2 commit_message_pattern: "^fix\\(linkedin-studio\\): promote post-feedback-monitor to Opus" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: agents/post-feedback-monitor.md pattern: "model: opus"
Step 19: Newsletter multi-session/effort banner
- Files:
commands/newsletter.md - Changes: Add a banner at the top of the newsletter command: "This is a multi-session, multi-gate, ~N-hour process (16 phases)." Set realistic expectations before the user starts.
- Reuses: existing newsletter.md header.
- Test first:
- File: n/a (content) — grep check
- Verifies: banner present
- Pattern: grep check
- Verify:
grep -niE 'multi-session|multi-gate|16 phases|~[0-9].*hour' commands/newsletter.md→ ≥1 near the top. - On failure: revert —
git checkout -- commands/newsletter.md - Checkpoint:
git commit -m "docs(linkedin-studio): add multi-session/effort banner to newsletter" - Manifest:
manifest: expected_paths: - commands/newsletter.md min_file_count: 1 commit_message_pattern: "^docs\\(linkedin-studio\\): add multi-session/effort banner" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: commands/newsletter.md pattern: "multi-session"
Step 20: Measure long-form review-pass overlap + trim
- Files:
docs/remediation/overlap-measurement.md(new, committed evidence), and any agent/command trimmed as a result (e.g.commands/newsletter.md, an agent merged/removed) - Changes: On an in-repo fixture edition (NOT a read of
maskinrommet/serier— cross-repo needs explicit instruction), run the long-form review agents and record what each catches in a committed comparison table (per the operator's "trim-for-quality, measured-not-assumed" decision). Trim redundancy ONLY where the measurement shows it (merge/remove a gate that catches nothing the others don't); if the measurement justifies the redundancy, record that and keep it. Commit the measurement as evidence. - Reuses: the existing
agents/fixtures/*-cases.mdfasit edition as the fixture; the long-form review agents. - Test first:
- File: n/a (measurement artifact)
- Verifies: measurement committed; any trim is justified by it
- Pattern: n/a
- Verify:
test -f docs/remediation/overlap-measurement.md→ exists; the file contains a per-reviewer catch table; if any gate was removed,bash scripts/test-runner.sh→ exit 0 (counts consistent). - On failure: skip — if the fixture is insufficient to measure, record "measurement inconclusive; redundancy retained pending a real edition" and do not trim.
- Checkpoint:
git commit -m "docs(linkedin-studio): measure long-form review-pass overlap, trim where unjustified" - Manifest:
manifest: expected_paths: - docs/remediation/overlap-measurement.md min_file_count: 1 commit_message_pattern: "^docs\\(linkedin-studio\\): measure long-form review-pass overlap" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: docs/remediation/overlap-measurement.md pattern: "catch"
Step 21: Version bump + counts + three-doc + CHANGELOG reconciliation
- Files:
.claude-plugin/plugin.json,README.md,CLAUDE.md,CHANGELOG.md,../../README.md(root marketplace),../../.claude-plugin/marketplace.json,STATE.md - Changes: Bump the version → 4.0.0 (breaking: reinstall for new state/wired agents). Counts are now determinable (plan-critic Major 7): Step 13 wires all 11 orphans with no deletions → 19 agents; Step 16 adds
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 fromlsat 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.grepthe old version → 0 stale. The Step 1 lint asserts version + count consistency. - Reuses: the version-sync memory (grep old version, update all); the lint's version/count asserts.
- Test first:
- File: n/a — Step 1 lint is the consistency check
- Verifies: version consistent everywhere; counts match
ls - Pattern: the lint version-grep
- Verify:
grep -rn "3\\.1\\.0" --include=*.json --include=*.md . | grep -v CHANGELOG | grep -v docs/remediation→ no stale hits;bash scripts/test-runner.sh→ exit 0; counts in README == CLAUDE.md == root README. - On failure: revert —
git checkout -- .claude-plugin/ README.md CLAUDE.md CHANGELOG.md ../../README.md ../../.claude-plugin/marketplace.json STATE.md - Checkpoint:
git commit -m "release(linkedin-studio): v4.0.0 — audit remediation (Phase 0-3), counts + three-doc sync" - Manifest:
manifest: expected_paths: - .claude-plugin/plugin.json - README.md - CLAUDE.md - CHANGELOG.md min_file_count: 4 commit_message_pattern: "^release\\(linkedin-studio\\): v4\\.0\\.0" bash_syntax_check: [] forbidden_paths: [] must_contain: - path: CHANGELOG.md pattern: "4.0.0"
Alternatives Considered
| Approach | Pros | Cons | Why rejected |
|---|---|---|---|
| Build features as the audit sketched them (enforce 9:16; "triple-notification" surface; "cannot auto-publish" boundary; saves untrackable) | Less research; matches the audit | Research refuted all four premises — would ship NEW false claims, the exact disease being treated | Rejected — built on corrected premises instead |
| New Opus de-AI agent (per convention-scanner) | Matches the 6-gate Opus cluster | voice-guardian.md already does AI-detection + the brief's success-criterion greps differentiation-checker → a new agent duplicates logic and misses the criterion |
Rejected — wire the existing orphan + extend voice-guardian |
| Single mega-release vs phased | One reinstall | Huge unreviewable diff; harder to bisect | Phased per §9, one Voyage session per phase, lint guards each |
| Reconcile stats by editing each file independently | Simpler per-file | 40 files restate numbers → guaranteed to miss one | Rejected — substrate-first + enumerate-by-grep + cite-not-restate; the lint stat-grep guards against contradiction (it cannot prove every citer cites-vs-restates, so completeness is also spot-checked at Step 3) |
Test Strategy
- Framework:
node:test(.test.mjs/.test.ts, glob formnode --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 rebuilttest-runner.shis the cross-cutting drift gate.
Tests to write
| Type | File | Verifies | Model test |
|---|---|---|---|
| Unit | scripts/analytics/tests/storage-root.test.ts |
default root anchors on plugin marker; env override | scripts/analytics/tests/storage.test.ts |
| Unit | hooks/scripts/__tests__/personalization-score.test.mjs |
placeholder→0 voice pts; real→pts | state-updater.test.mjs |
| Unit | hooks/scripts/__tests__/linkedin-content-filter.test.mjs |
gate fires on short-form content paths only | state-updater.test.mjs |
| Lint | scripts/test-runner.sh |
exit 0 healthy; exit 1 on agent add/remove | existing sections |
Risks and Mitigations
| Priority | Risk | Location | Impact | Mitigation |
|---|---|---|---|---|
| Critical | Voice move breaks ~15 readers if not atomic | assets/voice-samples/ + 15 readers |
content commands break | Step 5 lands placeholder + sentinel-detector + all readers in one commit |
| Critical | Placeholder mis-detected → false "Voice ✓" | personalization-score.mjs:23 |
adopter writes in stock voice, told done | sentinel check (not [Your Name]); test both cases |
| Critical | Stat reconciliation misses a file | ~40 citers | contradiction persists | substrate-first + cite-not-restate + Step 1 lint stat-grep proves completeness |
| High | Lint unguarded until rebuilt | scripts/test-runner.sh |
later steps ship unguarded | Step 1 is FIRST |
| High | CWD bug writes data to wrong dir even with deps | storage.ts:17-22 |
analytics silently wrong | anchor on .claude-plugin/; red-first test |
| High | Orphan wire uses bare type / no reload | commands/* |
Task fails at runtime | namespaced linkedin-studio:<name>; note reload; lint asserts count |
| Medium | Hook edit without recompile → stale runtime | hooks/hooks.json |
gate doesn't fire | always run compile-hooks.py; lint wires --check |
| Medium | State change non-additive → orphans 2 shipped editions | state-updater.mjs, templates |
editions break | additive scalar/non-R section only; preserve `extractField |
| Medium | Series-default change mis-routes new editions | newsletter.md, edition-state.template.json |
wrong write path | keep ${LTL_SERIES_ROOT:-…} override; neutral default; shipped editions are outside the repo |
| Low | Auto-publish boundary re-states a new false claim | README.md |
dishonest again | research/02 wording: "possible, deliberately not built" — never "cannot" |
Assumptions
| # | Assumption | Why unverifiable | Impact if wrong |
|---|---|---|---|
| 1 | Final command count after Steps 13/16 = 26 ± deletions + 1 new (firsthour) |
depends on per-agent wire/delete decisions made at execution | counts in Step 21 adjust; lint catches drift |
| 2 | The in-repo fasit fixture is rich enough to measure review-pass overlap (Step 20) | the fixture is Del-4-scoped, not a full edition | Step 20 on-failure: record "inconclusive, redundancy retained" |
| 3 | ToS permits documenting auto-publish as "possible but not built" | LinkedIn Terms language not re-read first-hand | finalize-time check before Step 10 wording locks |
| 4 | Each phase = one Voyage /trekcontinue session |
execution cadence | adjust session grouping in the Execution Strategy |
Verification
(Per-step manifests verify each step during execution. These are the end-to-end integration checks.)
bash scripts/test-runner.sh; echo $?→0(lint green on final state)grep -rnE '40-50%|25-40%|1\.92%|360Brew|January 2026|-40-60%' references/ commands/ skills/ hooks/prompts/ CLAUDE.md README.md | grep -v algorithm-signals-reference→ no stale contradictiongrep -rIn '/Users/ktg' config/ commands/ render/ agents/ | grep -v '.local'→ no private path in shipped filesgrep -rIn 'Kjell Tore' assets/ .claude-plugin/plugin.json→ no PII in shipped filesgrep -rni 'independently reviewed\|thought leadership\|cannot auto-publish\|triple.notification' README.md skills/ commands/→ no matches- from
/tmp: analytics CLI runs withoutERR_MODULE_NOT_FOUNDand 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.mjsandcd scripts/analytics && npm test→ all passgrep -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.mdcounts (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):
- Git history ≠ gitignore (Decision 3).
.gitignoredoes 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). - 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.