linkedin-studio/docs/m0/brief.md
Kjell Tore Guttormsen a9d86005f5 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>
2026-06-01 14:34:55 +02:00

354 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)._