# 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 D1–D6 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 (S1–S26) 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 ```mermaid 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
getDataRoot(subdir) · getDataRoot() root · getStateFile()
NEW"] TS["scripts/analytics/src/utils/storage.ts
getAnalyticsRoot → getDataRoot('analytics')
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
(dataRoot vs pluginRoot split)"] UPC["user-prompt-context.mjs (voice read)"] SS["session-start.mjs
(REMEMBER fix + migration wire-in)"] FILT["linkedin-content-filter.mjs
(recognize external content dirs)"] end subgraph prose["Prose pins repointed (B2)"] IMP["commands/import.md + report.md
(drop explicit ANALYTICS_ROOT= pins)"] SUR["hooks/prompts/state-update-reminder.md
(content-history → external)"] end subgraph migration["Migration (NEW, atomic + locked + idempotent)"] MIG["migrate-data.mjs
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-50` — `getAnalyticsRoot()` (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 12** — *after* every reader (code seams Steps 5–10 + 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 5–12). Execute > Sessions 2–3 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:** ```yaml 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:** ```yaml 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 1–2. - **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:** ```yaml 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:** ```yaml 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 `/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:** ```yaml 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="/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:** ```yaml 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:** ```yaml 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:** ```yaml 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:** ```yaml 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:** ```yaml 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:** ```yaml 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 5–11), 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:** ```yaml 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:** ```yaml 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:** ```yaml 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 -- `; re-apply per category - **Checkpoint:** `git commit -m "docs(linkedin-studio): M0-15 — repoint remaining prose families + route ab-tests/plans"` - **Manifest:** ```yaml 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=25` → `26` (`: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:** ```yaml 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 SC1–SC7 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 1–16. - **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 (SC1–SC7 verified)"` *(--allow-empty: this step may produce no file change if no fixture gap surfaces — m4)* - **Manifest:** ```yaml 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:** ```yaml 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 ` 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=` 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 5–12 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.mjs` ↔ `storage.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 `|| ''` (Step 1) | | 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 SC1–SC7 / §12).* - [ ] `node --test hooks/scripts/__tests__/data-root.test.mjs` → `fail 0`; default = `~/.claude/linkedin-studio/`, root form, override, twin-consistency pass (SC1, R2) - [ ] `cd scripts/analytics && npm test` → `pass 116+`, `fail 0`, tsc clean; external default (HOME-stubbed) + `ANALYTICS_ROOT` alias (SC1, SC5) - [ ] `node --test hooks/scripts/__tests__/migrate-data.test.mjs` → `fail 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.mjs` → `fail 0`; voice sentinel→0, profile-absent→0, scaffold fallback (SC4) - [ ] `node --test hooks/scripts/__tests__/user-prompt-context.test.mjs` → `fail 0`; real external voice → context, placeholder-only → degrade (SC7) - [ ] `bash scripts/test-runner.sh` → `Failed: 0`; `EXPECT_REFS=26`, refs delta +1, Section-13 no-bare-path + no-`ANALYTICS_ROOT=`-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 1–2 (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 1–4 - **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 1–4) ### 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 5–13) now closes the B2/B3 gaps; resolver-first/release-last | | Step quality | 0.20 | 90 | Each step 1–3 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 + D1–D6 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 SC1–SC7 + D1–D6 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=`-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 | Step 14 verify uses `xargs -r` |