From c0e8ff256a5f024318d261284d1c9251839dd64f Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 1 Jun 2026 14:34:55 +0200 Subject: [PATCH] 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 --- docs/m0/brief.md | 354 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 docs/m0/brief.md diff --git a/docs/m0/brief.md b/docs/m0/brief.md new file mode 100644 index 0000000..d96b713 --- /dev/null +++ b/docs/m0/brief.md @@ -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 (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)._