docs(linkedin-studio): M0 per-user data-dir migration brief (ratified)

Grounds the architecture-first M0 workstream (v0.4.0 -> v1.0.0). Three
read-only mapping passes corrected the surface from STATE.md's stale 108
to 195 references (~138 in commands/), of which ~186 are .md prose and
only ~3 executable seams do real I/O. Six decisions (D1-D6) ratified.

Brief only; M0 is not implemented. Next step is /trekplan in a fresh
session. Local commit, not pushed (operator-decided).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-01 14:34:55 +02:00
commit c0e8ff256a

354
docs/m0/brief.md Normal file
View file

@ -0,0 +1,354 @@
# 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 (S1S26) 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.** S1S26 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)._