# M0 — Per-User Data-Dir Migration · Brief > **Voyage `/trekbrief`-style task brief.** Foundation document for the **architecture** workstream > on the path `v0.4.0 → v1.0.0`. This brief defines *what M0 is and how we verify it* — it does > **not** implement it. Decisions are surfaced as ratify-by-annotation `DECISION` blocks. > > - **Written:** 2026-06-01 > - **Status:** RATIFIED 2026-06-01 — all `DECISION` blocks locked; ready for the `/trekplan` phase (separate session) > - **Workstream:** Architecture (M0). Parallel/blocked workstreams: Hardening (PARKED until M0), Command-testing, GUI. > - **Grounded by:** three read-only mapping passes (access-surface, resolution-seams, on-disk inventory) run 2026-06-01. Counts/paths below are tool-verified, not recalled. --- ## 1. Context & motivation The hardening workstream (S1–S26) was **paused at S2** when two architecture findings surfaced that no amount of hardening can paper over: 1. **Grounded simulation must read REAL voice/profile artefacts, not reconstruct them.** During S2 the simulation node verified character counts but ran against a *reconstructed* voice — because the real profile lives in a file (`assets/voice-samples/authentic-voice-samples.local.md`, 7.7 KB) that the command prose does not consistently point at, while `config/user-profile.local.md` **does not exist at all** (only `config/user-profile.template.md` ships). 2. **User data lives INSIDE the plugin tree.** Per-user content sits under `assets/` and `config/`, defended only by `.gitignore`. That is the wrong location for data that must survive a plugin reinstall/update and must never risk being committed. The **one thing done right** is the state file (`~/.claude/linkedin-studio.local.md`, external via `$HOME`). The seam meant to generalize this — `getAnalyticsRoot()` — is half-built: it honors an `ANALYTICS_ROOT` env override but **defaults to the in-plugin path**, and every caller passes the in-plugin value. Hardening on top of an in-plugin data layout would bake the wrong layout into 138+ command instructions. **Architecture first.** M0 relocates user data to a per-user external dir and gives the plugin a single path-resolution seam, so the subsequent hardening, command-testing, and GUI workstreams build on the correct foundation. --- ## 2. Problem statement (grounded) **The honest numbers** (correcting STATE.md's stale "108"): | Surface | In-plugin user-data references | |---|---| | `commands/*.md` (prose instructions) | ~138 | | `agents/*.md` (agent prompts) | 34 | | `hooks/` (prompts + executable `.mjs`) | 15 | | `scripts/` (analytics TS + tests) | 4 | | `skills/*/SKILL.md` | 11 | | `references/` (doc cross-refs) | 4 | | **Total** | **195** | **The shape that matters:** ~186 of the 195 are **Markdown prose** — instructions telling Claude *where* to read/write. They are operative (a command that says "read `assets/voice-samples/…`" will read the wrong place after a move) but they are **not code**. Only a small set of **executable seams** actually perform filesystem I/O and hardcode the in-plugin root: | Executable seam | What it does | Current root | |---|---|---| | `scripts/analytics/src/utils/storage.ts` → `getAnalyticsRoot()` (`:40-50`) | All analytics read/write | env `ANALYTICS_ROOT` **else** `/assets/analytics` | | `hooks/scripts/queue-manager.mjs` (`:11-12`) | Draft queue read/write | env `PLUGIN_ROOT` **else** `join(__dirname,'..','..')` → `assets/drafts/queue.json` | | `hooks/scripts/quick-import.mjs` (`:11-13`) | CSV export staging | `/assets/analytics/exports` — **no env override** | | `hooks/scripts/personalization-score.mjs` (`:23,34,45,60,75,84,95,106`) | 8 category read paths | `pluginRoot`-relative | | `hooks/scripts/session-start.mjs` (`:408-409`) | `REMEMBER.md` auto-init | **writes inside the plugin tree** (the anti-pattern) | | `hooks/scripts/user-prompt-context.mjs` (`:102`) | Voice-file read for context | `${pluginRoot}/assets/voice-samples/…` | **Inconsistencies the migration must resolve, not inherit:** - **Voice canonical-file split.** Consumers read the tracked placeholder `authentic-voice-samples.md` (carries ``, scores 0), while the *real* profile lives in the gitignored `authentic-voice-samples.local.md`. This is finding 1 above. M0 must declare a single canonical external voice file. - **`config/user-profile.local.md` never exists.** Only the template ships; the personalization scorer reads `config/user-profile.local.md` (`personalization-score.mjs:34`) and therefore always scores that category 0. M0 must define where the profile *instance* lives. - **REMEMBER.md is auto-initialized into the plugin tree** (`session-start.mjs:408`) — the exact anti-pattern M0 exists to fix; a clean before/after exemplar. - **In-place editable scaffolds clobber-risk.** Six personalization categories (case-studies, frameworks, `examples/high-engagement-posts.md`, `audience-insights/demographics.md`, `…/engagement-patterns.md`, `templates/my-post-templates.md`) ship as scaffold files the user edits **in place**, and are **not gitignored** → a plugin update can clobber user edits today. --- ## 3. Goal (definition of done) **All per-user data lives in a single external data dir that mirrors the state file, resolved through one seam per runtime, with existing data auto-migrated and every graceful-degradation behavior preserved.** After M0: - No command, agent, hook, or script reads or writes per-user data inside the plugin tree. - Reinstalling/updating the plugin loses no user data and clobbers no user edits. - The plugin still works on a fresh clone with zero user data (graceful degradation intact). - The grounded-sim (and any consumer) reads the user's **real** voice/profile artefacts — unblocking the hardening workstream's finding 1. --- ## 4. Success criteria (testable) > Each is a concrete check; the planning phase turns these into commands/tests (global plan-quality rule). - **SC1 — Single external root.** A new resolver returns `~/.claude/linkedin-studio/` by default (mirroring `~/.claude/linkedin-studio.local.md`), overridable by one documented env var. Verify: unit test asserts default + override for both the `.mjs` and the TS twin. - **SC2 — Zero in-plugin user-data writes.** After running a representative content + analytics flow on a fresh checkout, `git status` shows **no** new/modified files under the plugin tree's `assets/`, `config/*.local.*`, or `REMEMBER.md`; all writes land under the external dir. Verify: scripted dry-run + `git status --porcelain` is empty for user-data paths. - **SC3 — Migration moves the real files.** A migration step relocates the 5 on-disk user files (§6) to the external dir and is **idempotent** + **safe when there is nothing to move**. Verify: run on a fixture with files present → moved; run again → no-op; run on an empty fixture → no error. - **SC4 — Degradation preserved.** All invariants in §8 still hold (voice guardian silent-skip <5 samples, score hidden <3 posts, voice sentinel = 0, analytics empty-data clean-exit, profile-absent = 0 not crash). Verify: the existing degradation tests pass against the external root; new fixtures for the moved paths. - **SC5 — Analytics CLI green externally.** `scripts/analytics` test suite passes with the external default, including the resolver regression lock (`storage-root.test.ts` adapted). Verify: `npm test` green. - **SC6 — Structure lint green.** `scripts/test-runner.sh` stays at Passed/Failed = N/0; any count/path assertions are updated to the new layout. Verify: lint run. - **SC7 — Real-artefact read.** A consumer (voice-guardian / user-prompt-context / a sim) reads the user's actual voice profile from the external dir, not the placeholder. Verify: with a real external voice file present, the injected context contains it; with only the placeholder, degradation applies. --- ## 5. Non-goals (scope fence) - **NOT hardening.** S1–S26 stay parked. M0 does not re-word command prose for quality, only for path correctness (and even that is a `DECISION` — see D3). - **NOT the GUI.** Separate workstream. - **NOT command-testing** beyond what SC2/SC4/SC7 require to prove the move. - **NOT new features, new commands, new data types.** Pure relocation + seam. - **NOT a schema change.** The analytics JSON schema, queue schema, and state frontmatter are unchanged; only the *root* moves. - **NOT touching already-external data:** the state file and the `$LTL_SERIES_ROOT`/`$HOME/linkedin-series` newsletter production data are already correct and are the destination *pattern*, not move targets. - **NOT migrating plugin-shipped scaffolding** (templates, checklists, fonts, `*-template.*`). Those stay in the tree as read-only seed material. --- ## 6. Migration surface (grounded on-disk inventory, 2026-06-01) **Files that EXIST and must relocate** (all gitignored today): | Current in-plugin path | Size | → external subdir | |---|---|---| | `assets/voice-samples/authentic-voice-samples.local.md` | 7.7 KB | `voice-samples/` | | `assets/drafts/queue.json` | 33 B (empty queue) | `drafts/` | | `assets/analytics/exports/content-2026-W22-seres.csv` | 180 B | `analytics/exports/` | | `assets/analytics/posts/2026-05-26-batch-17.json` | 1,081 B | `analytics/posts/` | | `assets/analytics/weekly-reports/2026-W22.json` | 2,243 B | `analytics/weekly-reports/` | **Expected-but-ABSENT** (migration must handle "nothing to move" without error): `config/user-profile.local.md`, `assets/analytics/content-history.md`, `assets/analytics/monthly-reports/`, `assets/drafts/week-*/`. **Already-external (do NOT touch — the destination pattern):** `~/.claude/linkedin-studio.local.md` (state) · `$LTL_SERIES_ROOT`/`$HOME/linkedin-series//` (newsletter production) · everything routed through `ANALYTICS_ROOT` once the default flips. **Stays in-plugin (read-only scaffolding):** all `config/*.template.*`, `assets/templates/*`, `assets/checklists/*`, `assets/**/*-template.md`, `render/fonts/*`, READMEs, `.gitkeep`. --- ## 7. Target architecture ### 7.1 The external root ``` ~/.claude/linkedin-studio/ ← new data root (mirrors ~/.claude/linkedin-studio.local.md) voice-samples/ authentic-voice-samples.md (the user's REAL profile) analytics/ exports/ posts/ weekly-reports/ monthly-reports/ ab-tests/ content-history.md drafts/ queue.json week-*/ carousel/ multiplatform/ repurposed/ frameworks/ .md audience-insights/ demographics.md engagement-patterns.md (if D2 = migrate) plans/ .md profile/ user-profile.md (if D1 = profile-here) ``` Default overridable by **one** env var (proposed `LINKEDIN_STUDIO_DATA`), with back-compat aliases (`ANALYTICS_ROOT`, `STATE_FILE`, `PLUGIN_ROOT`) honored during a deprecation window. ### 7.2 The resolver (dual-runtime twins — the key constraint) Two runtimes cannot share one module file: - **Hooks** are `.mjs`, **zero npm deps** (marketplace rule). They use `process.env.HOME || USERPROFILE`. - **Analytics CLI** is **TypeScript** via `tsx`, compiled to `build/`, uses `findPluginRoot` + `import.meta`. So M0 introduces **one logical resolver in two byte-equivalent-logic twins**, with a consistency test (the pattern CLAUDE.md already uses for llm-security's "mirrored bit-identical" renderer): - `hooks/scripts/data-root.mjs` — `getDataRoot(subdir)`, `getStateFile()`; consumed by all hook scripts. Consolidates the **4× duplicated** `HOME`-string and **~7× duplicated** `PLUGIN_ROOT` derivation. - `scripts/analytics/src/utils/storage.ts` — generalize `getAnalyticsRoot()` → `getDataRoot("analytics")`, keep `ANALYTICS_ROOT` as an alias. The function already separates "find root" from "join subdir," and all storage functions already take `root` as a parameter, so the blast radius of the default-flip is one function body. `findPluginRoot` becomes vestigial *for data* (still used to locate read-only bundled assets like templates). ### 7.3 Template→instance flow (generalized from the state-file pattern) The state file already models the correct flow: **read-only template lives in-plugin (`config/state-file.template.md`), user instance lives external (`~/.claude/`), auto-init copies template → instance on first run** (`session-start.mjs:388-404`, including `mkdirSync(dirname, {recursive:true})`). M0 generalizes this to every data type: ship a template/seed in-plugin, materialize the instance in the external dir on first use, read external-with-fallback-to-template. --- ## 8. Graceful-degradation invariants to PRESERVE (file:line) These behaviors must hold identically after the move (SC4): 1. **Voice guardian silent-skip <5 samples** — `hooks/prompts/voice-guardian.md:53` (read at `:31`). 2. **Personalization score hidden <3 posts** — `session-start.mjs:192` (status line), `:285` (nudge), keyed on `publishedPostCount` (`:165-170`). 3. **Voice sentinel = 0** — `personalization-score.mjs:23-31` (`` → 0 pts); profile-absent = 0 not crash (`:34-42`, guarded by `existsSync`). 4. **Analytics empty-data clean-exit** — `storage.ts` returns `[]` for missing dirs (`:76-78,147-149,262-264`); `cli.ts:245-247,397-399` exit with a clear message; `ensureDirectories()` (`:55-68`) creates the tree on first write. (The fresh-clone crash fix anchors `getAnalyticsRoot()` on the `.claude-plugin/plugin.json` marker — `:18-50`, commit `798484b`; M0 changes the *default* but keeps the marker for bundled assets.) 5. **Voice-profile leak fix intact** — placeholder + `` sentinel (`authentic-voice-samples.md:1`) + gitignore (`.gitignore:7,11`) + replace-not-append in both writers (`setup.md:102-114`, `onboarding.md:143-149`). After the move the *external* real file is canonical; the in-plugin placeholder remains only as seed + leak-guard. --- ## 9. Decisions (ratify by annotation) > Each block: **recommendation** + rationale + alternatives. **All RATIFIED 2026-06-01** — the > recommendation is the locked decision; alternatives are retained as the record of what was considered. > These were the M0 "enige"-points, resolved on the artifact rather than mid-edit. ### D1 — Where does `user-profile` live? · `RATIFIED 2026-06-01` **Recommendation:** External `profile/user-profile.md`, seeded from `config/user-profile.template.md` on first `setup`. Repoint the scorer (`personalization-score.mjs:34`) and the ~10 prose references. *Rationale:* the `.local.md` instance never exists today and the category always scores 0; externalizing fixes the score AND finding 1 in one move. *Alternatives:* (a) keep the `.local.md` name but external; (b) fold profile into the state file (rejected — different lifecycle, would bloat state). ### D2 — In-place editable scaffolds (the 6 clobber-risk files) · `RATIFIED 2026-06-01` **Recommendation:** Split each into `*.template.*` (ships, read-only) + external instance; scorer reads external-with-fallback-to-template. *Rationale:* eliminates the update-clobber risk and unifies the model. *Cost:* this is the single biggest scope lever — it adds ~6 template splits + repoints `personalization-score.mjs:45-114` + the corresponding prose. *Alternatives:* (a) **leave in place** (smaller M0, but the clobber-risk and "data in plugin" problem persist for these 6 — deferred, not solved); (b) gitignore-only stopgap (rejected — doesn't fix update-clobber, only commit-leak). **This decision sets whether M0 is "move 5 gitignored files + flip seams" or "+ restructure 6 scaffold/data pairs."** ### D3 — How are the ~186 prose references repointed? · `RATIFIED 2026-06-01` **Recommendation:** Introduce a documented path convention referenced by a short preamble/variable in each command family, so prose says e.g. `the data dir's voice-samples/` resolved once, minimizing literal per-line edits. Where literal edits are unavoidable, batch them by category. *Rationale:* hand-editing 138 command lines is error-prone and noisy; a convention is maintainable. *Alternatives:* (a) literal edit all ~186 (highest blast radius, but explicit); (b) a compat **symlink/shim** from old in-plugin paths to the external dir so prose needs no change (rejected as primary — hides the real location, fragile across OSes, violates the "data not in plugin" goal even if symlinked). *Planning phase must prototype the convention on one command family before committing to the count.* ### D4 — Env var name + back-compat window · `RATIFIED 2026-06-01` **Recommendation:** Primary `LINKEDIN_STUDIO_DATA`; honor `ANALYTICS_ROOT`/`STATE_FILE`/`PLUGIN_ROOT` as deprecated aliases for one minor version, then drop. *Alternative:* reuse `ANALYTICS_ROOT` broadened (rejected — misleading name for non-analytics data). ### D5 — Version + breaking-ness · `RATIFIED 2026-06-01` **Recommendation:** Land as a **minor** bump with an **automatic, idempotent migration** on session-start (detect in-plugin user files → move → log once), so it is **non-breaking for the user**. *Rationale:* auto- migration + back-compat aliases means no manual user action. *Alternative:* tag breaking + require a manual `/linkedin:migrate` (rejected unless auto-migration proves unsafe — adds friction for a solo user-base). ### D6 — Canonical voice file name post-move · `RATIFIED 2026-06-01` **Recommendation:** External `voice-samples/authentic-voice-samples.md` is canonical (drop the `.local` suffix — external location already provides the gitignore-equivalent safety). In-plugin placeholder keeps its name + sentinel as seed/leak-guard. *Alternative:* keep `.local.md` externally (harmless, but `.local` is a gitignore convention that's meaningless outside the repo). --- ## 10. Risks - **R1 — Prose/code drift.** If code seams move but prose references don't (or vice-versa), commands write to the new dir while reading the old (or Claude follows stale prose). *Mitigation:* SC2 + SC7 as gates; D3 convention; lint assertion that no command prose references a bare in-plugin user-data path. - **R2 — Twin divergence.** The `.mjs` and TS resolvers drift apart. *Mitigation:* a consistency test asserting identical default + override semantics (model: `storage-root.test.ts`). - **R3 — Migration data loss.** A move that isn't idempotent/atomic could lose the 7.7 KB voice file. *Mitigation:* copy-then-verify-then-remove; idempotency test (SC3); never delete source until destination confirmed. - **R4 — Degradation regression.** Repointing read paths breaks a silent-skip into a crash. *Mitigation:* SC4 runs the existing degradation tests against the new root + new fixtures. - **R5 — Scope creep into hardening.** Touching 138 command files invites "while I'm here" edits. *Mitigation:* Non-goals §5; path-correctness edits ONLY; hardening stays parked. - **R6 — Cross-platform HOME.** `$HOME` vs `%USERPROFILE%`. *Mitigation:* reuse the existing `process.env.HOME || process.env.USERPROFILE` idiom already proven in 4 hook scripts. --- ## 11. Open questions for the planning phase - Does any consumer read analytics outside `getAnalyticsRoot()` (the agents found `ab-tests/` referenced only in `ab-test.md` prose, not routed through the resolver)? Enumerate before flipping the default. - Can the D3 convention actually reduce edits, or do commands need literal paths for Claude to act on? Prototype on one family (e.g. the voice readers) and measure. - Exact deprecation window for D4 aliases — tie to a named version. - Should `content-history.md` (runtime user data, template-seeded) follow analytics or drafts? --- ## 12. Verification plan > Concrete checks proving M0 is correct (global plan-quality rule — testable, not "see if it works"). 1. **Resolver unit tests** (`.mjs` + TS): default returns `~/.claude/linkedin-studio/`; env override wins; twins agree. → SC1, R2. 2. **Fresh-clone flow:** on a clean checkout with no user data, run a content command + an analytics import + a report; then `git status --porcelain` filtered to user-data paths must be **empty**, and the files must exist under the external dir. → SC2. 3. **Migration fixtures:** (a) files-present → moved + source gone + destination verified; (b) re-run → no-op; (c) empty → no error. → SC3, R3. 4. **Degradation suite:** voice-guardian with 0/4/5 samples; scorer with absent profile, placeholder voice, absent analytics; CLI on empty data exits cleanly. → SC4, R4. 5. **Analytics suite green** with external default incl. adapted `storage-root.test.ts`. → SC5. 6. **`scripts/test-runner.sh`** Passed/Failed = N/0 with updated count/path assertions + a new assertion that no command prose references a bare in-plugin user-data path. → SC6, R1. 7. **Real-artefact read:** external real voice file present → appears in injected context; only placeholder → degradation. → SC7, finding 1 closed. --- ## 13. References (tool-verified anchors) - `scripts/analytics/src/utils/storage.ts:40-50` — `getAnalyticsRoot()` (the seam to generalize) - `scripts/analytics/tests/storage-root.test.ts` — resolver regression lock (the test template) - `scripts/analytics/src/cli.ts:444` — sole `getAnalyticsRoot()` caller; `:245-247,397-399` empty-data exits - `hooks/scripts/state-updater.mjs:11-12` — `HOME` + `STATE_FILE` seam (correct external pattern) - `hooks/scripts/session-start.mjs:14-15` (HOME), `:388-404` (auto-init-from-template), `:408-409` (REMEMBER anti-pattern), `:165-170,190-192,285` (score gating) - `hooks/scripts/queue-manager.mjs:11-12` — in-plugin `QUEUE_FILE` (M0 target) - `hooks/scripts/quick-import.mjs:11-13` — in-plugin `EXPORTS_DIR`, no env override (M0 target) - `hooks/scripts/personalization-score.mjs:23-114` — 8 category read paths (+sentinel/profile degradation) - `hooks/scripts/user-prompt-context.mjs:102-105` — voice-file read, existence-guarded - `hooks/prompts/voice-guardian.md:31,53` — voice read + <5-sample silent-skip - `config/state-file.template.md:4` — self-documents the template→external split - `config/edition-state.template.json:4` / `commands/newsletter.md:46,148` — `$LTL_SERIES_ROOT` external production data (already correct) - `.gitignore:7,11,37,38,41-44` — near-complete manifest of the user-data classes M0 relocates --- _Counts and paths in this brief were verified by direct codebase mapping on 2026-06-01, not recalled. The "108" figure in STATE.md is superseded by the grounded ~138 (commands) / 195 (total)._