linkedin-studio/docs/m0/plan.md
Kjell Tore Guttormsen 4a60abec7f docs(linkedin-studio): M0 implementation plan (18 steps, adversarially reviewed + revised)
/trekplan on docs/m0/brief.md -> docs/m0/plan.md. 8 exploration agents.
plan-critic REPLAN on v1 (3 blockers + 5 majors + 5 minors); all 13 closed.
scope-guardian ALIGNED (SC1-SC7 + D1-D6 covered). Validator 18 steps / 0 errors.
Local only - not pushed (planning push-policy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:46:28 +02:00

66 KiB
Raw Blame History

M0 — Per-User Data-Dir Migration · Implementation Plan

Plan quality: A (90/100) — APPROVE_WITH_NOTES (post-revision; 3 blockers + 5 majors + 5 minors from adversarial review resolved)

Generated by trekplan v5.1.1 on 2026-06-01 — plan_version: 1.7

Input: docs/m0/brief.md (RATIFIED 2026-06-01, decisions D1D6 locked). This plan turns the brief's Goal (§3) + Success Criteria (§4) + Decisions (§9) into ordered, testable steps. It does not re-litigate any ratified decision.

Context

The hardening workstream (S1S26) was paused at S2 because two architecture findings surfaced that no hardening can paper over (brief §1):

  1. Grounded simulation must read REAL voice/profile artefacts, not reconstruct them. The real voice profile lives in assets/voice-samples/authentic-voice-samples.local.md (227 lines, 7.7 KB), but every consumer reads the placeholder authentic-voice-samples.md (sentinel → score 0), and config/user-profile.local.md never exists at all.
  2. User data lives INSIDE the plugin tree (assets/, config/), defended only by .gitignore — the wrong location for data that must survive a reinstall and must never risk being committed.

M0 relocates all per-user data to a single external data dir (~/.claude/linkedin-studio/, mirroring the state file) behind one path-resolution seam per runtime, with automatic idempotent migration and every graceful-degradation behavior preserved (brief §3). Hardening, command-testing, and the GUI then build on the correct foundation. This is the architecture workstream on the path v0.4.0 → v1.0.0.

Architecture Diagram

graph TD
    subgraph external["~/.claude/linkedin-studio/ (NEW external data root)"]
        VOICE["voice-samples/authentic-voice-samples.md (REAL profile, D6)"]
        ANA["analytics/ exports posts weekly-reports monthly-reports ab-tests content-history.md"]
        DRAFTS["drafts/ queue.json week-*"]
        PROF["profile/user-profile.md (D1)"]
        SCAF["frameworks/ case-studies/ examples/ audience-insights/ templates/ (D2 instances)"]
        PLANS["plans/"]
        REMEM["REMEMBER.md (moved out of plugin)"]
        MARK[".migrated marker + lockdir"]
    end

    subgraph resolverlayer["Resolver layer (two byte-equivalent twins, R2)"]
        MJS["hooks/scripts/data-root.mjs<br/>getDataRoot(subdir) · getDataRoot() root · getStateFile()<br/>NEW"]
        TS["scripts/analytics/src/utils/storage.ts<br/>getAnalyticsRoot → getDataRoot('analytics')<br/>default FLIPPED to external"]
    end

    subgraph hooks["Hook seams (repointed to data-root.mjs)"]
        QM["queue-manager.mjs"]
        QI["quick-import.mjs (gains override)"]
        PS["personalization-score.mjs<br/>(dataRoot vs pluginRoot split)"]
        UPC["user-prompt-context.mjs (voice read)"]
        SS["session-start.mjs<br/>(REMEMBER fix + migration wire-in)"]
        FILT["linkedin-content-filter.mjs<br/>(recognize external content dirs)"]
    end

    subgraph prose["Prose pins repointed (B2)"]
        IMP["commands/import.md + report.md<br/>(drop explicit ANALYTICS_ROOT=<plugin> pins)"]
        SUR["hooks/prompts/state-update-reminder.md<br/>(content-history → external)"]
    end

    subgraph migration["Migration (NEW, atomic + locked + idempotent)"]
        MIG["migrate-data.mjs<br/>copy→fsync→verify→unlink, lockdir, external-canonical"]
    end

    subgraph bundled["In-plugin (read-only seed, STAYS)"]
        TMPL["config/*.template.* · assets/**/*-template.md · render/fonts"]
        MARKER[".claude-plugin/plugin.json (findPluginRoot marker, PRESERVED)"]
    end

    MJS --> external
    TS --> external
    QM --> MJS
    QI --> MJS
    PS --> MJS
    UPC --> MJS
    SS --> MJS
    SS --> MIG
    IMP -.external default.-> external
    SUR -.external default.-> external
    MIG --> external
    PS -.fallback.-> TMPL
    TS -.bundled assets.-> MARKER
    FILT -.classifies.-> DRAFTS

Codebase Analysis

  • Tech stack: Dual-runtime. (a) Hooks — Node.js ESM .mjs, zero npm deps (marketplace hard rule), node:-builtins only, run directly by Node 25. (b) Analytics CLI — TypeScript via tsx, one dep (csv-parse), node --import tsx --test.
  • Key patterns:
    • Template→instance (the one correct pattern, to generalize): read-only template in plugin (config/state-file.template.md), instance external (~/.claude/), auto-init copies template→instance with mkdirSync(...,{recursive:true}) on first run — hooks/scripts/session-start.mjs:388-404.
    • HOME || USERPROFILE idiom (5 sites — brief said 4): session-start.mjs:14, state-updater.mjs:11, quick-import.mjs:12, user-prompt-context.mjs:21, posting-reminder.mjs:12.
    • PLUGIN_ROOT derivation join(__dirname,'..','..') (9 literal sites; ~7 data-relevant) — the duplication data-root.mjs consolidates.
    • Named exports only (no export default in hooks/scripts/); standalone-guard if (process.argv[1] === fileURLToPath(import.meta.url)) (personalization-score.mjs:120).
    • findPluginRoot / .claude-plugin/plugin.json marker (storage.ts:18-33) — locates the plugin root by walking up to the marker; must be preserved for bundled read-only assets (commit 798484b, the fresh-clone crash fix).
  • Relevant files (all verified to exist, file:line):
    • scripts/analytics/src/utils/storage.ts:40-50getAnalyticsRoot() (the TS seam; sole executable caller cli.ts:444; all storage fns already take root param → blast radius = one function body, VERIFIED). But prose pins it: commands/import.md:126,243,244 and commands/report.md:101,111,121,126,154,168,172,359,362,365 set ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" explicitly (~14 pins).
    • hooks/scripts/queue-manager.mjs:11-12, quick-import.mjs:11-13 (no env override) :66 (stale printed path), personalization-score.mjs:13,23-114,120-122, user-prompt-context.mjs:20,102-105, session-start.mjs:13-15,190,388-404,408-417, linkedin-content-filter.mjs:20,23-24,27,30,34.
    • hooks/prompts/state-update-reminder.md:71 — Claude-prose instruction to init/write assets/analytics/content-history.md from config/content-history.template.md (679 B, exists). A real in-plugin user-data write with NO executable seam (analogous to ab-tests).
    • scripts/analytics/tests/storage-root.test.ts (77 lines, the regression-lock template), hooks/scripts/__tests__/personalization-score.test.mjs (makePluginRoot():18-22), scripts/analytics/tests/storage.test.ts (empty-data clean-exit evidence).
    • scripts/test-runner.sh:44-47 (EXPECT_AGENTS=19 EXPECT_COMMANDS=29 EXPECT_REFS=25 EXPECT_SKILLS=6), refs glob :73, refs assert :87, version reads :315, version consistency greps :320 (README badge version-${VERSION}-blue), :325 (plugin CLAUDE.md header LinkedIn Studio Plugin (v${VERSION})), :330 (CHANGELOG ## [${VERSION}]), self-test non-vacuity :255-295,391-419, pass/fail :37-38,461-476.
    • .gitignore:7,11,14,37,38,41-44,54,55 — the user-data class manifest (incl. content-history.md at :44).
  • Reusable code: the HOME||USERPROFILE one-liner (state-updater.mjs:11), mkdirSync recursive auto-init (session-start.mjs:392), findPluginRoot (storage.ts:18), storage-root.test.ts (env save/restore afterEach :14-26, default+override assertions :50-75), the atomic tmp+rename write in state-updater.mjs:353-355.
  • External tech (researched): none — Node fs/path/url builtins + TypeScript + tsx, all already in the codebase. No research-scout needed.
  • Recent git activity: single author, single branch main, clean working tree for all M0 code surface. The 4 cold seams (queue-manager, quick-import, session-start, user-prompt-context) are untouched since the v3.0.0 rename → low-conflict edit surface. test-runner.sh is the hottest M0 file (12 changes). state-updater.mjs is volatile (6 changes) but is the read-only reference pattern, not a move target — touching it = scope creep (brief §13).

Implementation Plan

Ordering invariant (the spine of this revised plan, post-review B2/B3): the migration RUNS exactly once at Step 12after every reader (code seams Steps 510 + the analytics CLI prose pins + content-history prose, Step 11) already points at the external root, and before any in-plugin file is overwritten (the D2 scrub, Step 13). This closes the data-availability gap (a reader pointing external while data is still in-plugin, or a reader pointing in-plugin after data moved out) and guarantees the leaked real post is externalized before it is scrubbed.

Transient-execution note: between Step 5 (first seam flipped to external) and Step 12 (migration runs), the plugin's content/analytics data paths resolve to the empty external dir while data still sits in-plugin. This is safe — no data loss (sources untouched until Step 12; degradation is clean-empty, never crash) but means do not run /linkedin content/analytics commands while M0 is mid-execution (Steps 512). Execute Sessions 23 without exercising the plugin's data features in between.

Commit-type discipline (verified against the docs-gate hook + feedback_feat_commit_docs_gate): intermediate steps use refactor / test / docs / chore(linkedin-studio): so the feat:-only 3-doc gate does NOT fire per-step. The single feat(linkedin-studio): M0 commit is the release step (Step 18). All checkpoints are local commits — push only on explicit operator OK (STATE.md push-policy).

Step 1: Create the data-root.mjs resolver twin

  • Files: hooks/scripts/data-root.mjs (new file), hooks/scripts/__tests__/data-root.test.mjs (new file)
  • Changes: Create the .mjs resolver. Export named getDataRoot(subdir = '') returning subdir ? join(dataBase, subdir) : dataBase (the no-arg/empty form returns the root itself — needed by Step 9's REMEMBER write) and getStateFile(). dataBase resolves as: process.env.LINKEDIN_STUDIO_DATA else join(HOME, '.claude', 'linkedin-studio'), where HOME = process.env.HOME || process.env.USERPROFILE; if BOTH are empty, fall back to os.homedir() (NOT silent || '' relative-path trap — risk-assessor Low #2). getStateFile(): process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md') (mirrors state-updater.mjs:12). node:-builtins only, named exports, no default export. Add the "CANONICAL — twin of storage.ts:getDataRoot, must stay in sync" header (model: plugins/llm-security/scripts/lib/report-renderers.mjs:1-18). (new file)
  • Reuses: HOME||USERPROFILE idiom (state-updater.mjs:11), state-file derivation (session-start.mjs:15), fileURLToPath(import.meta.url), named-export convention (queue-manager.mjs:43).
  • Test first:
    • File: hooks/scripts/__tests__/data-root.test.mjs (new)
    • Verifies: default getDataRoot('analytics') === join(HOME,'.claude','linkedin-studio','analytics'); getDataRoot() (no arg) returns the root ~/.claude/linkedin-studio (M5); LINKEDIN_STUDIO_DATA override wins; getStateFile() default + STATE_FILE override; empty-HOME → os.homedir() not ''. Stubs HOME/LINKEDIN_STUDIO_DATA/STATE_FILE via env save/restore in afterEach.
    • Pattern: scripts/analytics/tests/storage-root.test.ts:14-75, expressed with node:test + node:assert/strict
  • Verify: node --test hooks/scripts/__tests__/data-root.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/data-root.mjs hooks/scripts/__tests__/data-root.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-1 — data-root.mjs resolver twin + test"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/data-root.mjs
        - hooks/scripts/__tests__/data-root.test.mjs
      min_file_count: 2
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-1"
      bash_syntax_check: []
      forbidden_paths:
        - hooks/scripts/state-updater.mjs
      must_contain:
        - path: hooks/scripts/data-root.mjs
          pattern: "getDataRoot"
        - path: hooks/scripts/data-root.mjs
          pattern: "LINKEDIN_STUDIO_DATA"
    

Step 2: Generalize getAnalyticsRoot to getDataRoot in storage.ts and flip the default

  • Files: scripts/analytics/src/utils/storage.ts, scripts/analytics/tests/storage-root.test.ts
  • Changes: Add export function getDataRoot(subdir: string): string whose default is join(HOME, '.claude', 'linkedin-studio', subdir) (external), honoring process.env.LINKEDIN_STUDIO_DATA. Redefine getAnalyticsRoot() as getDataRoot('analytics') but keep honoring the deprecated ANALYTICS_ROOT alias (D4) so cli.ts:444 is unchanged. Preserve findPluginRoot + the .claude-plugin/plugin.json marker (:18-33) — it now locates bundled read-only assets only (brief §7.2; commit 798484b). Keep the empty-on-absence contract (:76-78). Add the CANONICAL twin header. Adapt storage-root.test.ts: change the default assertion (:57-75) to the external ~/.claude/linkedin-studio/analytics, stubbing HOME + LINKEDIN_STUDIO_DATA with save/restore exactly as the .mjs test does (M4 — the default must not couple to the dev's real $HOME); add an LINKEDIN_STUDIO_DATA override test
    • an ANALYTICS_ROOT back-compat-alias test; keep the findPluginRoot marker tests unchanged.
  • Reuses: existing getAnalyticsRoot body (:40-50), findPluginRoot (:18-33), savedEnv save/restore idiom (storage-root.test.ts:14-26).
  • Test first:
    • File: scripts/analytics/tests/storage-root.test.ts (existing — adapt)
    • Verifies: external default (HOME-stubbed); LINKEDIN_STUDIO_DATA override; ANALYTICS_ROOT alias; findPluginRoot marker-walk unchanged
    • Pattern: itself (:49-75)
  • Verify: cd scripts/analytics && npm test → expected: pass 116+, fail 0, tsc clean
  • On failure: revert — git checkout -- scripts/analytics/src/utils/storage.ts scripts/analytics/tests/storage-root.test.ts
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-2 — storage.ts getDataRoot + external default (ANALYTICS_ROOT alias kept)"
  • Manifest:
    manifest:
      expected_paths:
        - scripts/analytics/src/utils/storage.ts
        - scripts/analytics/tests/storage-root.test.ts
      min_file_count: 2
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-2"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: scripts/analytics/src/utils/storage.ts
          pattern: "getDataRoot"
        - path: scripts/analytics/src/utils/storage.ts
          pattern: "findPluginRoot"
    

Step 3: Add the twin-consistency test

  • Files: hooks/scripts/__tests__/data-root.test.mjs (extend)
  • Changes: Add a describe('twin consistency') block asserting the .mjs resolver produces the SAME default + override semantics documented for the TS twin (the TS twin cannot be imported into a no-tsx .mjs run, so assert each twin against the same literal strings with a shared comment naming the other — the behavioral-consistency model, since llm-security's "bit-identical" claim is header-convention-only, not a test). R2's mitigation.
  • Reuses: the assertions from Step 1; the CANONICAL header convention added in Steps 12.
  • Test first:
    • File: hooks/scripts/__tests__/data-root.test.mjs (existing from Step 1)
    • Verifies: .mjs default + LINKEDIN_STUDIO_DATA override match the TS twin's documented contract literals
    • Pattern: the env save/restore block from Step 1
  • Verify: node --test hooks/scripts/__tests__/data-root.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/__tests__/data-root.test.mjs
  • Checkpoint: git commit -m "test(linkedin-studio): M0-3 — resolver twin-consistency test (R2)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/__tests__/data-root.test.mjs
      min_file_count: 1
      commit_message_pattern: "^test\\(linkedin-studio\\): M0-3"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/__tests__/data-root.test.mjs
          pattern: "twin"
    

Step 4: Create migrate-data.mjs (atomic, locked, idempotent, external-canonical)

  • Files: hooks/scripts/migrate-data.mjs (new), hooks/scripts/__tests__/migrate-data.test.mjs (new)
  • Changes: Create an importable + standalone-runnable migrateData() that relocates per-user data from the plugin tree to the external root. Two move-classes (risk-assessor HIGH #2): (a) the gitignored runtime files — authentic-voice-samples.local.md (move its 227-line REAL content → external voice-samples/authentic-voice-samples.md, D6, NOT the placeholder), assets/drafts/queue.json, the 3 analytics files, and assets/analytics/content-history.md if present (B1) — atomic MOVE (copy→fsync→verify size+content→renameSync temp→final→unlinkSync source); (b) the 6 tracked D2 scaffold instances — COPY their current content to the external instance (preserves user edits) WITHOUT deleting the tracked file (Step 13 templatizes it). Safety (3 CRITICALs): lock via mkdirSync(lockDir) (atomic, throws if exists) before any op, loser no-ops; external is canonical — never overwrite an existing external file from the in-plugin tree; write the .migrated marker LAST so the whole run is idempotently skippable. Handle the expected-but-absent set (config/user-profile.local.md, empty monthly-reports/, drafts/week-*) as clean no-ops. node:-builtins only.
  • Reuses: atomic tmp+rename (state-updater.mjs:353-355), mkdirSync recursive (session-start.mjs:392), getDataRoot (Step 1).
  • Test first:
    • File: hooks/scripts/__tests__/migrate-data.test.mjs (new)
    • Verifies: (a) present→moved (gitignored sources gone, dest byte-equal, .local.md 227-line content at authentic-voice-samples.md, content-history moved); (b) scaffolds→COPIED (external instance written, tracked source still present); (c) re-run→no-op (content + mtime unchanged, no throw, .migrated short-circuits); (d) empty fixture→no error; (e) collision→external kept, in-plugin source not clobbered. All against mkdtempSync fixtures with LINKEDIN_STUDIO_DATA stubbed — never the real $HOME.
    • Pattern: scripts/analytics/tests/storage.test.ts:87-96 (tempdir + afterEach)
  • Verify: node --test hooks/scripts/__tests__/migrate-data.test.mjs → expected: pass, fail 0
  • On failure: escalate — migration safety is load-bearing; do not proceed until green
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-4 — migrate-data.mjs atomic+locked+idempotent (created, not wired)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/migrate-data.mjs
        - hooks/scripts/__tests__/migrate-data.test.mjs
      min_file_count: 2
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-4"
      bash_syntax_check: []
      forbidden_paths:
        - hooks/scripts/state-updater.mjs
      must_contain:
        - path: hooks/scripts/migrate-data.mjs
          pattern: "migrated"
        - path: hooks/scripts/migrate-data.mjs
          pattern: "content-history"
    

Step 5: Repoint queue-manager.mjs to the resolver

  • Files: hooks/scripts/queue-manager.mjs, hooks/scripts/__tests__/queue-manager.test.mjs (new)
  • Changes: Replace process.env.PLUGIN_ROOT || join(__dirname,'..','..')assets/drafts/queue.json (:11-12) with import { getDataRoot } from './data-root.mjs' and QUEUE_FILE = join(getDataRoot('drafts'), 'queue.json'). Keep the mkdirSync(...,{recursive:true}) first-write (:16).
  • Reuses: getDataRoot (Step 1), existing mkdirSync auto-init (:16).
  • Test first:
    • File: hooks/scripts/__tests__/queue-manager.test.mjs (new)
    • Verifies: with LINKEDIN_STUDIO_DATA set to a tempdir, queue read/write lands under <tempdir>/drafts/queue.json, not the plugin tree
    • Pattern: personalization-score.test.mjs:18-22
  • Verify: node --test hooks/scripts/__tests__/queue-manager.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/queue-manager.mjs hooks/scripts/__tests__/queue-manager.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-5 — queue-manager.mjs via getDataRoot('drafts')"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/queue-manager.mjs
      min_file_count: 1
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-5"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/queue-manager.mjs
          pattern: "getDataRoot"
    

Step 6: Repoint quick-import.mjs and fix the printed example command

  • Files: hooks/scripts/quick-import.mjs, hooks/scripts/__tests__/quick-import.test.mjs (new)
  • Changes: Replace the no-override EXPORTS_DIR (:11-13) with getDataRoot('analytics') + 'exports' (the hard-bypass that desyncs the instant Step 2 lands). Fix the printed example command (:66) that teaches the OLD ANALYTICS_ROOT="<plugin>/assets/analytics" path → emit the external/LINKEDIN_STUDIO_DATA form. Keep eager mkdirSync(EXPORTS_DIR) (:18).
  • Reuses: getDataRoot (Step 1).
  • Test first:
    • File: hooks/scripts/__tests__/quick-import.test.mjs (new)
    • Verifies: exports dir resolves under external root; the printed command no longer contains a bare in-plugin assets/analytics path
    • Pattern: personalization-score.test.mjs:18-22
  • Verify: node --test hooks/scripts/__tests__/quick-import.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/quick-import.mjs hooks/scripts/__tests__/quick-import.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-6 — quick-import.mjs via getDataRoot + fix printed path"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/quick-import.mjs
      min_file_count: 1
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-6"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/quick-import.mjs
          pattern: "getDataRoot"
    

Step 7: Split personalization-score.mjs lookups into dataRoot vs pluginRoot

  • Files: hooks/scripts/personalization-score.mjs, hooks/scripts/session-start.mjs, hooks/scripts/__tests__/personalization-score.test.mjs
  • Changes: The scorer reads 8 category paths (:23,34,45,60,75,84,95,106) relative to a single pluginRoot param (:13). Split the concerns: instance data (voice, profile, scaffolds) reads from getDataRoot(...); template fallback still reads from the plugin tree. Resolve dataRoot internally via getDataRoot, keep a pluginRoot param for template lookups. Update both callers: session-start.mjs:190 and the standalone block (:121-122). Update the fixture makePluginRoot() (personalization-score.test.mjs:18-22) to seed instance data under the external location and drive the scorer through the resolver. risk-assessor HIGH #3 — both call sites + fixture change in this one step.
  • Reuses: getDataRoot (Step 1); the existsSync-guarded read pattern (preserves degradation invariant 3); the fixture shape.
  • Test first:
    • File: hooks/scripts/__tests__/personalization-score.test.mjs (existing — adapt + extend)
    • Verifies: placeholder-with-sentinel (external voice-samples/) → 0; real voice → 25; profile absent at external profile/user-profile.md → 0 not crash (NEW case, brief §8 inv.3)
    • Pattern: :38-56
  • Verify: node --test hooks/scripts/__tests__/personalization-score.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/personalization-score.mjs hooks/scripts/session-start.mjs hooks/scripts/__tests__/personalization-score.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-7 — scorer dataRoot/pluginRoot split + both callers (HIGH #3)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/personalization-score.mjs
        - hooks/scripts/session-start.mjs
        - hooks/scripts/__tests__/personalization-score.test.mjs
      min_file_count: 3
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-7"
      bash_syntax_check: []
      forbidden_paths:
        - hooks/scripts/state-updater.mjs
      must_contain:
        - path: hooks/scripts/personalization-score.mjs
          pattern: "getDataRoot"
    

Step 8: Repoint user-prompt-context.mjs voice read to the resolver

  • Files: hooks/scripts/user-prompt-context.mjs, hooks/scripts/__tests__/user-prompt-context.test.mjs (new)
  • Changes: Replace the in-plugin voice-file existence check (:102) with getDataRoot('voice-samples')
    • 'authentic-voice-samples.md', existence-guarded as today (:103). Move the state-file read (:108) onto getStateFile() (read-only, guarded). Delivers SC7 (real-artefact read).
  • Reuses: getDataRoot/getStateFile (Step 1); existing existsSync guard (:103).
  • Test first:
    • File: hooks/scripts/__tests__/user-prompt-context.test.mjs (new)
    • Verifies: real external voice present → context contains the voice reference; only in-plugin placeholder (external absent) → degradation. SC7.
    • Pattern: personalization-score.test.mjs:18-22
  • Verify: node --test hooks/scripts/__tests__/user-prompt-context.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/user-prompt-context.mjs hooks/scripts/__tests__/user-prompt-context.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-8 — voice read via getDataRoot (SC7)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/user-prompt-context.mjs
        - hooks/scripts/__tests__/user-prompt-context.test.mjs
      min_file_count: 2
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-8"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/user-prompt-context.mjs
          pattern: "getDataRoot"
    

Step 9: Move the REMEMBER.md write out of the plugin tree

  • Files: hooks/scripts/session-start.mjs, hooks/scripts/__tests__/session-start-remember.test.mjs (new)
  • Changes: Fix the anti-pattern at :408-417: rememberFile = join(PLUGIN_ROOT,'REMEMBER.md') writes inside the plugin. Repoint to join(getDataRoot(), 'REMEMBER.md') (the no-arg root form from Step 1) while still seeding from in-plugin config/REMEMBER.template.md. Use the same mkdirSync(...,{recursive:true}) + copy pattern the state-file init uses 16 lines above (:392-393). Leave the state auto-init (:388-404) unchanged.
  • Reuses: getDataRoot() root form (Step 1); the state-file template→instance flow (:388-404).
  • Test first:
    • File: hooks/scripts/__tests__/session-start-remember.test.mjs (new) — exercise the extracted REMEMBER-init helper if feasible; else a tempdir-LINKEDIN_STUDIO_DATA integration check
    • Verifies: REMEMBER.md materializes under the external root, not PLUGIN_ROOT
    • Pattern: personalization-score.test.mjs:18-22
  • Verify: node --test hooks/scripts/__tests__/session-start-remember.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/session-start.mjs hooks/scripts/__tests__/session-start-remember.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-9 — REMEMBER.md materializes external (anti-pattern fix)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/session-start.mjs
      min_file_count: 1
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-9"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/session-start.mjs
          pattern: "getDataRoot"
    

Step 10: Teach linkedin-content-filter.mjs to recognize external content dirs

  • Files: hooks/scripts/linkedin-content-filter.mjs, hooks/scripts/__tests__/linkedin-content-filter.test.mjs
  • Changes: Today .claude is in infraDirs (:27) and :30 returns false for any path containing /.claude/ — firing BEFORE the positive assets/drafts/ check (:34), so relocated drafts at ~/.claude/linkedin-studio/drafts/ mis-classify as non-content and the PreToolUse quality gate + voice guardian stop firing (break confirmed by direct read). Fix (M1 — exact placement): insert a positive branch recognizing the external content subdir /.claude/linkedin-studio/drafts/ (the only data class the Write/Edit quality gate guards; voice-samples/analytics/profile are NOT editor-gated content) after the .local.md + nonContent-basename negatives (:23-24, which already exclude the state file + REMEMBER.md) and before the infraDirs loop (:27-31). Keep the existing in-plugin assets/drafts/ positive (:34) for back-compat during the migration window.
  • Reuses: existing positive/negative structure (:14-39); the .local.md exclusion (:23).
  • Test first:
    • File: hooks/scripts/__tests__/linkedin-content-filter.test.mjs (existing — extend)
    • Verifies: ~/.claude/linkedin-studio/drafts/x.md → true; ~/.claude/linkedin-studio.local.md → false (state); ~/.claude/linkedin-studio/REMEMBER.md → false; ~/.claude/settings.json → false; in-plugin assets/drafts/x.md → still true
    • Pattern: existing cases (:13-22)
  • Verify: node --test hooks/scripts/__tests__/linkedin-content-filter.test.mjs → expected: pass, fail 0
  • On failure: revert — git checkout -- hooks/scripts/linkedin-content-filter.mjs hooks/scripts/__tests__/linkedin-content-filter.test.mjs
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-10 — content-filter recognizes external drafts (after negatives, before infraDirs)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/linkedin-content-filter.mjs
        - hooks/scripts/__tests__/linkedin-content-filter.test.mjs
      min_file_count: 2
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-10"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/linkedin-content-filter.mjs
          pattern: "linkedin-studio/drafts"
    

Step 11: Drop the in-plugin ANALYTICS_ROOT prose pins and repoint content-history

  • Files: commands/import.md, commands/report.md, hooks/prompts/state-update-reminder.md
  • Changes: (B2) Remove the explicit ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" prefix from the ~14 CLI invocations in commands/import.md (:126,243,244) and commands/report.md (:101,111,121,126,154,168,172,359,362,365) so the Step-2 external default applies (the CLI resolves to external automatically; keep the ${CLAUDE_PLUGIN_ROOT}/scripts/analytics/... tooling paths — those are in-plugin executables, not data). (B1) Repoint state-update-reminder.md:71 so content-history.md initializes/writes under the external analytics subdir (referencing the Step-13 path convention or ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/content-history.md). These land in the SAME session as Step 12 so no committed state has prose pointing in-plugin after the data moves out.
  • Reuses: the external default from Step 2; the ${LTL_SERIES_ROOT:-...} prose pattern (newsletter.md:46,148-149).
  • Test first: (prose — verified by the Step 16 lint assertions, not a unit test)
  • Verify: ! grep -rn 'ANALYTICS_ROOT="\${CLAUDE_PLUGIN_ROOT}/assets/analytics"' commands/ && ! grep -n 'assets/analytics/content-history.md' hooks/prompts/state-update-reminder.md → expected: no matches (all pins dropped, content-history repointed)
  • On failure: revert — git checkout -- commands/import.md commands/report.md hooks/prompts/state-update-reminder.md
  • Checkpoint: git commit -m "docs(linkedin-studio): M0-11 — drop in-plugin ANALYTICS_ROOT pins + repoint content-history (B1/B2)"
  • Manifest:
    manifest:
      expected_paths:
        - commands/import.md
        - commands/report.md
        - hooks/prompts/state-update-reminder.md
      min_file_count: 3
      commit_message_pattern: "^docs\\(linkedin-studio\\): M0-11"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain: []
    

Step 12: Wire migrateData into session-start and run the migration once

  • Files: hooks/scripts/session-start.mjs
  • Changes: Import migrateData (Step 4) and call it early in session-start.mjs — before the score/status computation that reads moved paths — logging once on a real move, silent no-op otherwise (D5). Then run the migration once now as part of execution (node hooks/scripts/migrate-data.mjs) so the operator's real data (the 5 gitignored files + content-history + the 6 scaffold copies; the 227-line voice file → external .md) is externalized immediately. This is the ordering hinge: every reader already points external (Steps 511), so the move makes the whole system consistent; and it runs BEFORE the Step-13 scrub, so the real post is externalized before its in-plugin copy is replaced (B3).
  • Reuses: migrateData (Step 4), getDataRoot (Step 1).
  • Test first: (integration — the migrate-data unit tests cover the move; this step's check is the live run)
  • Verify: node hooks/scripts/migrate-data.mjs && test -f "$HOME/.claude/linkedin-studio/voice-samples/authentic-voice-samples.md" && grep -q migrateData hooks/scripts/session-start.mjs → expected: marker present, voice file externalized (227-line real content), wire-in present
  • On failure: escalate — if the live migration errors, do NOT proceed to Step 13 (scrub); the real content must be safely external first
  • Checkpoint: git commit -m "refactor(linkedin-studio): M0-12 — wire + run migrateData (data externalized before scrub)"
  • Manifest:
    manifest:
      expected_paths:
        - hooks/scripts/session-start.mjs
      min_file_count: 1
      commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-12"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/session-start.mjs
          pattern: "migrateData"
    

Step 13: Create the 4 missing D2 templates, scrub the leak, finalize scaffold fallback

  • Files: assets/examples/high-engagement-posts-template.md (new), assets/audience-insights/demographics-template.md (new), assets/audience-insights/engagement-patterns-template.md (new), assets/templates/my-post-templates-template.md (new), assets/examples/high-engagement-posts.md, assets/audience-insights/demographics.md, assets/audience-insights/engagement-patterns.md, assets/templates/my-post-templates.md, hooks/scripts/personalization-score.mjs
  • Changes: D2: each of the 6 scaffolds = *-template.* (read-only seed) + external instance. Two templates already ship (case-study-template.md, framework-template.md); CREATE the 4 missing templates (generic seed, no real user data) and replace the 4 tracked editable files' content with the template/placeholder. Safe now (B3): Step 12 already copied each scaffold's real content to the external instance, so replacing the tracked working-tree file loses nothing. Leak (risk-assessor HIGH #2): high-engagement-posts.md held the operator's real post at HEAD — its working-tree content becomes the generic placeholder; git-history scrub (filter-repo/BFG) is OUT of M0 scope, logged separately in docs/m0/log.md. Finalize the scorer's external-instance-with-template-fallback for the 6 scaffolds (personalization-score.mjs:45-114), now that both instances (Step 12) and templates (this step) exist.
  • Reuses: the shape of case-study-template.md / framework-template.md; the scorer split (Step 7).
  • Test first:
    • File: hooks/scripts/__tests__/personalization-score.test.mjs (extend)
    • Verifies: scaffold category scores from the external instance when present; falls back to template (no crash) when absent
    • Pattern: existing scorer cases
  • Verify: ls assets/examples/high-engagement-posts-template.md assets/audience-insights/demographics-template.md assets/audience-insights/engagement-patterns-template.md assets/templates/my-post-templates-template.md && ! grep -q "Ralph Wiggum" assets/examples/high-engagement-posts.md && node --test hooks/scripts/__tests__/personalization-score.test.mjs → expected: 4 templates listed, no leaked content, tests pass
  • On failure: revert — git checkout -- assets/examples/ assets/audience-insights/ assets/templates/ hooks/scripts/personalization-score.mjs
  • Checkpoint: git commit -m "chore(linkedin-studio): M0-13 — 4 D2 templates + scrub leak + scaffold fallback"
  • Manifest:
    manifest:
      expected_paths:
        - assets/examples/high-engagement-posts-template.md
        - assets/audience-insights/demographics-template.md
        - assets/audience-insights/engagement-patterns-template.md
        - assets/templates/my-post-templates-template.md
      min_file_count: 4
      commit_message_pattern: "^chore\\(linkedin-studio\\): M0-13"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: hooks/scripts/personalization-score.mjs
          pattern: "getDataRoot"
    

Step 14: Prototype the D3 path convention on the voice-readers family

  • Files: references/data-path-convention.md (new), plus the voice-readers prose family (10 commands + 4 agents + 2 skills + 2 hook prompts + 1 reference = 19 files, 38 refs — e.g. commands/setup.md:84, commands/onboarding.md, agents/voice-trainer.md, hooks/prompts/voice-guardian.md:31)
  • Changes: Author a short reference doc defining the path convention — generalize the proven ${LTL_SERIES_ROOT:-$HOME/linkedin-series} prose pattern (commands/newsletter.md:46,148-149) to ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}. Apply it to the voice-readers family ONLY (smallest well-bounded, single-canonical-file family). Measure whether the convention reduces edits or whether commands need literal paths (brief D3 + open-question 2; brief-reviewer assumption 2). GATE — its outcome decides Step 15's edit volume. If the convention works, proceed; if not, Step 15 falls back to batched literal edits (D3 alternative a — NOT a re-decision).
  • Reuses: the newsletter.md external-data prose pattern; the voice canonical path from Step 8.
  • Test first: (prose — verified by the Step 16 lint assertion)
  • Verify: grep -rl "authentic-voice-samples" commands/ agents/ skills/ hooks/prompts/ references/ | xargs -r grep -L "LINKEDIN_STUDIO_DATA\|data-path-convention" → expected: empty (every voice reader routes through the convention) OR a documented literal-edit list if the convention was rejected (xargs -r guards the empty-input case — m5)
  • On failure: skip — record the prototype outcome in docs/m0/log.md; Step 15 adapts
  • Checkpoint: git commit -m "docs(linkedin-studio): M0-14 — D3 path-convention + voice-readers prototype"
  • Manifest:
    manifest:
      expected_paths:
        - references/data-path-convention.md
      min_file_count: 1
      commit_message_pattern: "^docs\\(linkedin-studio\\): M0-14"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: references/data-path-convention.md
          pattern: "LINKEDIN_STUDIO_DATA"
    

Step 15: Apply the convention to the remaining prose families and route ab-tests + plans

  • Files: the remaining prose families across commands/, agents/, skills/, references/ — analytics (61 refs, incl. commands/report.md, import.md, ab-test.md), drafts/queue (24), profile/D1 (15), audience-insights (14), examples (11), frameworks (6), case-studies (5), my-post-templates (4)
  • Changes: Apply the Step-14 convention (or batched literal edits if D3 was rejected) to every remaining family. Route the code-invisible data classes (dependency-tracer + risk-assessor): ab-tests/ (only in ab-test.md/measure.md/linkedin.md prose) and plans/ (in audit.md, batch.md, analytics-interpreter.md, content-planner.md prose) — assign each an external subdir and repoint their prose so they don't silently orphan when the default flips. Keep Style-A ${CLAUDE_PLUGIN_ROOT} for in-plugin read-only assets; repoint only Style-B bare data paths.
  • Reuses: the convention from Step 14.
  • Test first: (prose — verified by Step 16 lint)
  • Verify: bash scripts/test-runner.sh (with the Step-16 R1 assertions in place) → expected: Failed: 0, no command prose references a bare in-plugin user-data path
  • On failure: revert the offending family — git checkout -- <family files>; re-apply per category
  • Checkpoint: git commit -m "docs(linkedin-studio): M0-15 — repoint remaining prose families + route ab-tests/plans"
  • Manifest:
    manifest:
      expected_paths:
        - commands/ab-test.md
        - commands/report.md
      min_file_count: 2
      commit_message_pattern: "^docs\\(linkedin-studio\\): M0-15"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain: []
    

Step 16: Extend test-runner.sh with the layout, no-bare-path, and no-pin assertions

  • Files: scripts/test-runner.sh
  • Changes: (a) Bump EXPECT_REFS=2526 (:46) for references/data-path-convention.md (the :73 glob count must match or the lint fails :87); assert the references/ delta is exactly +1 so an incidental extra ref doc cannot pass silently (m3). (b) Add Section 13 asserting no command prose references a bare in-plugin user-data path (R1, brief §12.6) — model the non-vacuity self-test (:255-295,391-419). (c) Add a sibling assertion that no prose pins ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" (M3 — the no-bare-path grep does NOT catch alias pins). (d) Add the SC2 dry-run: after a representative content + analytics flow, git status --porcelain filtered to user-data path classes (from .gitignore, incl. content-history.md) must be empty. (e) Record pre-M0 assertion count N as baseline; require post-M0 N ≥ baseline (brief-reviewer assumption 3). Keep Passed/Failed = N/0.
  • Reuses: the self-test pattern (:255-295), the STAT_HITS grep-and-assert-empty idiom (:297-303), the EXPECT_* contract (:44-47).
  • Test first: (the lint IS the test — its Section-13 self-test is the red→green)
  • Verify: bash scripts/test-runner.sh → expected: Passed: N (≥ baseline), Failed: 0, Section 13 self-test passes
  • On failure: revert — git checkout -- scripts/test-runner.sh
  • Checkpoint: git commit -m "test(linkedin-studio): M0-16 — lint: EXPECT_REFS=26 + no-bare-path + no-pin + SC2 dry-run (R1)"
  • Manifest:
    manifest:
      expected_paths:
        - scripts/test-runner.sh
      min_file_count: 1
      commit_message_pattern: "^test\\(linkedin-studio\\): M0-16"
      bash_syntax_check:
        - scripts/test-runner.sh
      forbidden_paths: []
      must_contain:
        - path: scripts/test-runner.sh
          pattern: "EXPECT_REFS=26"
    

Step 17: Run the full green gate across all three test surfaces

  • Files: (no production changes — verification + any fixture gaps surfaced)
  • Changes: Run the complete SC4 degradation + SC5 analytics + SC6 lint + all hook tests together; close any fixture gap surfaced. Confirm SC1SC7 each have a passing check. The voice-guardian <5-sample silent-skip is prompt logic (voice-guardian.md:53), not unit-testable — verify by (1) confirming the prompt points at the resolved external path after Step 14, and (2) the read-path resolution check in data-root.test.mjs; do NOT author a behavioral assertion of Claude's skip (test-strategist).
  • Reuses: all tests from Steps 116.
  • Test first: (this step is the integration gate)
  • Verify: run all three — node --test hooks/scripts/__tests__/*.test.mjs (expect fail 0) · cd scripts/analytics && npm test (expect pass 116+, fail 0, tsc clean) · bash scripts/test-runner.sh (expect Failed: 0)
  • On failure: escalate — do not release until all three surfaces are green
  • Checkpoint: git commit --allow-empty -m "test(linkedin-studio): M0-17 — full green gate (SC1SC7 verified)" (--allow-empty: this step may produce no file change if no fixture gap surfaces — m4)
  • Manifest:
    manifest:
      expected_paths: []
      min_file_count: 0
      commit_message_pattern: "^test\\(linkedin-studio\\): M0-17"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain: []
    

Step 18: Release — version bump, three-doc update, CHANGELOG

  • Files: .claude-plugin/plugin.json, README.md (plugin), CLAUDE.md (plugin), CHANGELOG.md (plugin), /Users/ktg/.claude/plugins/marketplaces/ktg-plugin-marketplace/README.md (root), /Users/ktg/.claude/plugins/marketplaces/ktg-plugin-marketplace/CLAUDE.md (root)
  • Changes: Bump version 0.4.0 → 0.5.0 (D5 minor, non-breaking — auto-migration + back-compat aliases; no !). Update the three doc levels (plugin README + plugin CLAUDE.md + root README, per the docs-gate) describing the external data dir, resolver twins, migration, D2 split, D3 convention. Add the CHANGELOG entry. Version-sync — update every 0.4.0 hit, and specifically the three literal strings the lint greps (m1/m2): the plugin README badge version-0.5.0-blue (test-runner.sh:320), the plugin CLAUDE.md header # LinkedIn Studio Plugin (v0.5.0) (currently (v0.4.0), :325), and the CHANGELOG entry ## [0.5.0] (:330). The version source-of-truth is .claude-plugin/plugin.json (test-runner.sh:315 reads it, does not declare it). git add MUST be a separate Bash call before git commit so the docs-gate hook sees staged state (feedback_feat_commit_docs_gate).
  • Reuses: the commit shape of baca30f/1fa2cc9 (5-doc + plugin.json in one commit).
  • Test first: (release metadata — verified by the lint version-consistency guard)
  • Verify: bash scripts/test-runner.sh → expected: Failed: 0 (version/count consistency guards pass) AND grep -rn "0\\.4\\.0" --include=*.md --include=*.json . | grep -v CHANGELOG → expected: no stale version refs
  • On failure: revert — git checkout -- .claude-plugin/plugin.json README.md CLAUDE.md CHANGELOG.md + the two root docs
  • Checkpoint: git commit -m "feat(linkedin-studio): M0 — per-user external data dir (v0.5.0)"

    Push is NOT part of this step. Per STATE.md push-policy, product/version commits push only on explicit operator OK. Stop after the local commit and report.

  • Manifest:
    manifest:
      expected_paths:
        - .claude-plugin/plugin.json
        - README.md
        - CLAUDE.md
        - CHANGELOG.md
      min_file_count: 4
      commit_message_pattern: "^feat\\(linkedin-studio\\): M0"
      bash_syntax_check: []
      forbidden_paths: []
      must_contain:
        - path: .claude-plugin/plugin.json
          pattern: "0.5.0"
    

Alternatives Considered

Approach Pros Cons Why rejected
One shared resolver module for both runtimes DRY, single source Hooks are zero-npm-dep .mjs; analytics is tsx-TS with dual src//build/ location → a hook importing .ts needs tsx at hook runtime (breaks the zero-dep + fast-hook contract) Forced by the runtime split; brief §7.2 mandates two twins + a consistency test (Step 3). Not a preference.
Migration COPY-then-flip-then-cleanup (two-phase) instead of move-at-Step-12 Zero transient-empty window during developer execution Adds a separate source-cleanup step; dual-mode migrate-data (copy for dev, move for end-user) complicates the function the brief wants idempotent-move Rejected — the single move-at-Step-12 (after all readers flipped) + the documented "don't use content commands mid-execution" note achieves the same safety with a simpler function (see Ordering invariant).
Compat symlink/shim from old in-plugin paths → external Prose needs no change Hides the real location, fragile cross-OS, violates "data not in plugin" even if symlinked Brief D3 alternative (b), rejected as primary.
Tag M0 breaking + require manual /linkedin:migrate Explicit, no auto-migration risk Friction for a solo user-base; adds a command (changes EXPECT_COMMANDS) Brief D5 alternative, rejected unless auto-migration proves unsafe.
Leave the 6 D2 scaffolds in place (move only 5 files) Smaller M0 Clobber-risk + "data in plugin" persists for 6 files; the leak in high-engagement-posts.md stays in the working tree Brief D2 alternative (a), rejected — D2 ratified to full restructure.
Defer all prose repointing (D3) to a later release Smaller code-only M0 SC2/SC7 require prose+code consistency or commands write the new dir while Claude follows stale prose (R1) Cannot defer entirely; the prototype-first GATE (Step 14) is the de-risk instead.

Test Strategy

  • Framework: node:test + node:assert/strict everywhere. Hooks: zero-npm-dep .mjs under hooks/scripts/__tests__/, run node --test hooks/scripts/__tests__/*.test.mjs (glob form — node --test <dir> fails MODULE_NOT_FOUND on Node 25). Analytics: TS via tsx, cd scripts/analytics && npm test. Structure lint: bash scripts/test-runner.sh.
  • Existing patterns: storage-root.test.ts (env save/restore afterEach + default/override, :14-75); personalization-score.test.mjs (makePluginRoot() tempdir fixture :18-22); storage.test.ts (empty-data [] clean-exit :185-191,296-302,417-423).
  • New tests in this plan: data-root.test.mjs (SC1 + R2 + root case), queue-manager.test.mjs, quick-import.test.mjs, user-prompt-context.test.mjs (SC7), session-start-remember.test.mjs, migrate-data.test.mjs (SC3 — move/copy/idempotent/collision + content-history), extended personalization-score.test.mjs (profile-absent + scaffold fallback), extended linkedin-content-filter.test.mjs (external content dirs), adapted storage-root.test.ts.

Tests to write

Type File Verifies Model test
Unit hooks/scripts/__tests__/data-root.test.mjs resolver default+override+root, twin consistency, empty-HOME fallback (SC1/R2) storage-root.test.ts:49-75
Unit scripts/analytics/tests/storage-root.test.ts (adapt) external default (HOME-stubbed), LINKEDIN_STUDIO_DATA + ANALYTICS_ROOT alias (SC1/SC5) itself
Unit hooks/scripts/__tests__/migrate-data.test.mjs move/copy/idempotent/collision; .local.md 227-line → external .md; content-history moved (SC3/R3/B1) storage.test.ts:87-96
Unit hooks/scripts/__tests__/personalization-score.test.mjs (extend) profile-absent→0, scaffold fallback, voice via external (SC4) :38-56
Unit hooks/scripts/__tests__/user-prompt-context.test.mjs real external voice → context; placeholder-only → degrade (SC7) personalization-score.test.mjs:18-22
Unit hooks/scripts/__tests__/linkedin-content-filter.test.mjs (extend) external drafts→content; state file + REMEMBER→not (Step 10) :13-22
Lint scripts/test-runner.sh Section 13 no bare in-plugin path; no ANALYTICS_ROOT=<plugin> pin; SC2 git-status empty; refs delta +1 (R1/SC2/SC6) self-test :255-295

The voice-guardian <5-sample silent-skip is prompt logic — verified by prompt-path resolution + code review, not a behavioral assertion (test-strategist).

Risks and Mitigations

Priority Risk Location Impact Mitigation
Critical Auto-migration not atomic — partial move loses the 7.7 KB voice file migrate-data.mjs (Step 4) Irreversible loss of the user's real profile copy→fsync→verify→rename→unlink; .migrated marker LAST (Step 4 + SC3 test)
Critical Concurrent SessionStart (multi-window) race on the same move migrate-data.mjs + wire-in (Steps 4, 12) Half-moved tree, corruption Lockdir via atomic mkdirSync; loser no-ops (Step 4)
Critical Destination-collision on reinstall overwrites newer external data migrate-data.mjs (Step 4) Stale in-plugin scaffold clobbers external data External is canonical — never overwrite external from in-plugin; .migrated short-circuit (Step 4)
Critical Ordering gap — reader points external while data in-plugin (or vice-versa) Steps 512 sequencing CLI/hook reads empty silently (storage.ts:76-78) Migration RUNS at Step 12 after ALL readers (code + analytics pins + content-history) point external; documented "no content commands mid-execution" window (Ordering invariant)
High Real post overwritten before migration externalizes it Steps 12→13 order Real content lost to git history only Step 12 runs migration (copies scaffold content external) BEFORE Step 13 scrub (B3)
High Scorer signature silently flips plugin-root→data-root personalization-score.mjs:13, callers :190,121 Half-wired → every category scores 0 forever Split lookups + update both call sites + fixture in ONE step (Step 7)
High Migration moves the placeholder, not the real voice file migrate-data.mjs (Step 4) Real 227-line profile lost; score stays 0 Move .local.md CONTENT → external .md; SC7 + migrate-data test assert the 227-line file (Steps 4, 8)
High content-history.md is a prose-driven in-plugin write missed by code audits state-update-reminder.md:71, .gitignore:44 Plugin keeps writing user data in-plugin → SC2 fails Migrate it (Step 4) + repoint the prose (Step 11) + SC2 dry-run catches it (Step 16) — B1
High Content gate stops firing on relocated drafts linkedin-content-filter.mjs:27-34 Quality gate + voice guardian silently skip external drafts Positive branch for external drafts inserted after negatives, before infraDirs (Step 10 + test) — M1
Medium Analytics prose pins override the flipped default import.md, report.md (~14 pins) CLI reads in-plugin (empty after move) Drop the pins in lockstep with migration (Step 11) + lint no-pin assertion (Step 16) — B2/M3
Medium quick-import.mjs no env override → desyncs from CLI on default-flip quick-import.mjs:11-13,66 quick-import writes in-plugin, CLI reads external Repoint to getDataRoot + fix printed path (Step 6)
Medium ab-tests/ + plans/ are prose-only data classes, no code seam ab-test.md, measure.md, audit.md, batch.md prose Silently orphan when default flips Assign external subdir + repoint prose (Step 15)
Medium Twin divergence (R2) data-root.mjsstorage.ts Resolvers drift apart Behavioral consistency test (Step 3) + CANONICAL headers
Low Empty HOME → relative data root resolver (Step 1) Writes land relative to cwd os.homedir() fallback, never silent `
Low Adapted TS default test couples to dev $HOME storage-root.test.ts (Step 2) Flaky/false-green on unusual $HOME Stub HOME/LINKEDIN_STUDIO_DATA as the .mjs test does (Step 2) — M4

Assumptions

# Assumption Why unverifiable Impact if wrong
1 D2 = full 6-file restructure; the 4 missing templates can be authored as generic seed Ratified decision; "right" template content is editorial Step 13 grows if seed content needs care; no logic impact
2 The D3 convention reduces ~180 prose edits (vs literal edits) Empirical — must prototype on voice-readers (Step 14) before scaling Step 15 falls back to batched literal edits (D3 alt. a) — more churn, same outcome, NOT a re-decision
3 The lint's assertion count must not net-decrease across M0 The lint self-modifies its own assertions (SC6) A green lint could mask a dropped check — Step 16 records baseline N, requires post-M0 ≥ baseline
4 content-history.md has two surfaces: the state-file ## Recent Posts section (already external) AND a separate prose-driven assets/analytics/content-history.md file (state-update-reminder.md:71) — the FILE is migrated (B1) Verified: .gitignore:44 + config/content-history.template.md exist If a third writer exists, the SC2 dry-run (Step 16) catches the in-plugin write
5 Auto-migration on session-start is acceptable latency for a solo user Runs once then .migrated short-circuits If slow on large data, gate behind a one-time prompt (D5 alt.)
6 The operator does not run /linkedin content/analytics commands between Steps 5 and 12 Operationally controlled (interactive, focused M0 execution) Reads return empty (degradation, no loss); resolved when Step 12 migration runs

Verification

Per-step manifests are checked automatically during execution. These are the end-to-end, cross-step integration checks (mapped to the brief's SC1SC7 / §12).

  • node --test hooks/scripts/__tests__/data-root.test.mjsfail 0; default = ~/.claude/linkedin-studio/<subdir>, root form, override, twin-consistency pass (SC1, R2)
  • cd scripts/analytics && npm testpass 116+, fail 0, tsc clean; external default (HOME-stubbed) + ANALYTICS_ROOT alias (SC1, SC5)
  • node --test hooks/scripts/__tests__/migrate-data.test.mjsfail 0; move/copy/idempotent/collision + content-history + 227-line voice (SC3, R3, B1)
  • On a clean checkout: run a content command + analytics import + report (AFTER Step 12), then git status --porcelain filtered to user-data paths (incl. content-history.md) is empty; files exist under the external dir (SC2)
  • node --test hooks/scripts/__tests__/personalization-score.test.mjsfail 0; voice sentinel→0, profile-absent→0, scaffold fallback (SC4)
  • node --test hooks/scripts/__tests__/user-prompt-context.test.mjsfail 0; real external voice → context, placeholder-only → degrade (SC7)
  • bash scripts/test-runner.shFailed: 0; EXPECT_REFS=26, refs delta +1, Section-13 no-bare-path + no-ANALYTICS_ROOT=<plugin>-pin + SC2 dry-run, baseline N not net-decreased (SC6, R1, B2)
  • grep -rn "0\.4\.0" across .md/.json (excl. CHANGELOG) → no stale version refs after Step 18

Estimated Scope

  • Files to modify: ~15 code/config (storage.ts, 6 hook scripts, linkedin-content-filter.mjs, test-runner.sh, 4 tracked scaffolds, import.md, report.md, state-update-reminder.md, plugin.json) + ~180 prose lines across ~50 markdown files (D3)
  • Files to create: ~13 (data-root.mjs, migrate-data.mjs, 6 new test files, 4 D2 templates, references/data-path-convention.md)
  • Complexity: high (dual-runtime resolver + atomic/concurrent/idempotent migration with strict ordering + a ~180-ref prose surface). The code core is small and well-bounded; migration safety, the reader-flip ordering, and prose volume are where the risk concentrates.

Execution Strategy

Driftsmodell note: the operator works interactively (Claude drives, stops at each "enige"-point; operator is truth source) and pushes only on explicit OK. Sessions run sequentially with operator gates via /trekcontinue, NOT as parallel worktrees. "Wave" = dependency ordering. Each session ends green + local-committed.

Session 1: Resolver foundation + migration tool

  • Steps: 1, 2, 3, 4
  • Wave: 1
  • Depends on: none
  • Scope fence:
    • Touch: data-root.mjs, storage.ts, migrate-data.mjs, their tests, storage-root.test.ts
    • Never touch: state-updater.mjs (read-only reference), prose, the seams (later)

Session 2: Repoint the hook seams

  • Steps: 5, 6, 7, 8, 9, 10
  • Wave: 2
  • Depends on: Session 1 (data-root.mjs)
  • Scope fence:
    • Touch: queue-manager.mjs, quick-import.mjs, personalization-score.mjs, user-prompt-context.mjs, session-start.mjs (REMEMBER + scorer caller), linkedin-content-filter.mjs, their tests
    • Never touch: state-updater.mjs, storage.ts (done), migration wire-in, prose
    • Note: ends with all hook seams pointing external (empty until Session 3 migration). Do NOT run /linkedin content/analytics commands until Session 3 completes.

Session 3: Analytics pins → migrate-run → scrub (MUST stay together)

  • Steps: 11, 12, 13
  • Wave: 3
  • Depends on: Sessions 12 (resolver + all seams flipped)
  • Scope fence:
    • Touch: import.md, report.md, state-update-reminder.md (pins/content-history), session-start.mjs (migration wire-in), the 4 new templates + 4 tracked scaffolds, personalization-score.mjs (scaffold fallback)
    • Never touch: resolver internals (frozen), other prose families
    • Critical adjacency: Step 11 (pins external) → Step 12 (run migration) → Step 13 (scrub) execute in order without interruption — this is the B2/B3 fix. Do NOT reorder or split across the 12→13 boundary.

Session 4: Prose repointing (D3)

  • Steps: 14, 15
  • Wave: 4
  • Depends on: Session 2 (seams stable) — Step 14 GATES Step 15
  • Scope fence:
    • Touch: references/data-path-convention.md, prose in commands/, agents/, skills/, references/, hooks/prompts/
    • Never touch: any .mjs/.ts code, any test

Session 5: Verify + release

  • Steps: 16, 17, 18
  • Wave: 5
  • Depends on: Sessions 14
  • Scope fence:
    • Touch: scripts/test-runner.sh, .claude-plugin/plugin.json, plugin + root docs, CHANGELOG
    • Never touch: production code logic (verification only); do NOT push (operator-gated)

Execution Order

  • Wave 1: Session 1
  • Wave 2: Session 2 (after 1)
  • Wave 3: Session 3 (after 1+2) — internal step order 11→12→13 is load-bearing
  • Wave 4: Session 4 (after 2)
  • Wave 5: Session 5 (after 14)

Grouping rules applied

  • Resolver + migration tool built first (Session 1) so every later session builds on stable seams
  • All readers flipped (Session 2) before the migration RUNS (Session 3) — the B2/B3 ordering fix
  • The pins→migrate→scrub triple kept in one uninterrupted session (Session 3)
  • The D3 prototype (14) gates the bulk prose edit (15) — same session, sequential
  • Release isolated last so the single feat + 3-doc + version bump is atomic

Plan Quality Score

Dimension Weight Score Notes
Structural integrity 0.15 90 18 steps dependency-ordered; the reader-flip→migrate→scrub spine (Steps 513) now closes the B2/B3 gaps; resolver-first/release-last
Step quality 0.20 90 Each step 13 files (Step 11/13 deliberately multi-file for atomicity), TDD where code, real reuse refs; insertion point pinned (Step 10)
Coverage completeness 0.20 92 All 7 SCs + D1D6 mapped; content-history (B1) + analytics pins (B2) + ab-tests/plans now covered
Specification quality 0.15 90 Concrete paths/commands; commit-types pinned (no deferred (a)/(b)); a few prose steps necessarily judgment-bearing (D3 outcome)
Risk & pre-mortem 0.15 93 15 risks incl. 4 migration criticals + the ordering gap; each mapped to a step
Headless readiness 0.10 86 On-failure + Checkpoint per step; --allow-empty on the gate; driftsmodell is interactive so headless decomposition is advisory
Manifest quality 0.05 88 Every step has a checkable manifest; commit_message_pattern pinned per step (no (feat|refactor) ambiguity)
Weighted total 1.00 90 Grade: A

Adversarial review:

  • Plan critic: REPLAN on v1 — 3 blockers (content-history unmigrated; analytics-pin/migration data gap; scrub-before-migration), 5 majors, 5 minors. All resolved in this revision (see Revisions).
  • Scope guardian: ALIGNED — all SC1SC7 + D1D6 covered, 0 hard scope-creep, 0 gaps, ~30 file:line anchors verified; plans/ routing low-confidence-creep accepted (brief §7.1 target tree).

Revisions

# Finding Severity Resolution
1 content-history.md is an in-plugin user-data file (state-update-reminder.md:71 + .gitignore:44 + template), unmigrated; Assumption 4 false; breaks SC2 blocker migrate-data now moves it (Step 4); prose repointed (Step 11); SC2 dry-run catches it (Step 16); Assumption 4 rewritten
2 Data-availability gap: import.md/report.md explicit ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" (~14 pins) keep the CLI on in-plugin after the move blocker New Step 11 drops the pins in lockstep with the Step-12 migration-run; lint no-pin assertion added (Step 16); Risk row + M3
3 Scrub (old Step 10) ran before migration (old Step 11), overwriting the real post before migrate-data existed blocker Reordered: migrate-data created Step 4, RUN at Step 12, scrub at Step 13 (after externalization); Ordering invariant added
4 Step 9 content-filter fix underspecified (insertion point + which subdirs) major Step 10 pins insertion after :23-24 negatives, before :27 infraDirs; scopes positive match to drafts/ only; test asserts state-file/REMEMBER stay excluded
5 Migration step feat checkpoint + (feat|refactor) regex risked tripping the 3-doc gate mid-plan major Migration step pinned to refactor (Step 4/12); the (a)/(b) deferral removed; single feat only at Step 18
6 import.md pins not flagged top-priority; no-bare-path lint wouldn't catch alias pins major Step 11 prioritizes them; Step 16 adds a dedicated no-ANALYTICS_ROOT=<plugin>-pin lint assertion
7 Adapted storage-root.test.ts default assertion $HOME-dependent without stubbing major Step 2 now stubs HOME+LINKEDIN_STUDIO_DATA as the .mjs test does; Risk Low row
8 getDataRoot('') root semantics used (Step 8) but never specified/tested (Step 1) major Step 1 defines getDataRoot(subdir='') root form + tests it; Step 9 uses the explicit no-arg form
9 Step 17 version source-of-truth ref wrong (test-runner.sh:315 reads, not declares) minor Step 18 corrected: source of truth is .claude-plugin/plugin.json; :315 reads it
10 Step 17 didn't enumerate the 3 version-consistency grep targets minor Step 18 names them: README badge version-0.5.0-blue (:320), CLAUDE.md header (v0.5.0) (:325), CHANGELOG ## [0.5.0] (:330)
11 EXPECT_REFS=26 assumed refs delta +1, unasserted minor Step 16 adds an explicit "references/ delta = +1" assertion
12 Green-gate checkpoint commit could be empty minor Step 17 uses git commit --allow-empty
13 Step 13 verify `grep xargs grep -L` empty-input fragile minor