# 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** `<pluginRoot>/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 | `<PLUGIN_ROOT>/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 `<!-- VOICE_PLACEHOLDER -->`, 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/<subdir>` 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/<slug>/` (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/           <slug>.md
  audience-insights/    demographics.md  engagement-patterns.md      (if D2 = migrate)
  plans/                <plan>.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` (`<!-- VOICE_PLACEHOLDER -->` → 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 + `<!-- VOICE_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/<subdir>`; 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)._
