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

22 KiB
Raw Permalink Blame History

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.tsgetAnalyticsRoot() (: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/exportsno 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.mjsgetDataRoot(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 sampleshooks/prompts/voice-guardian.md:53 (read at :31).
  2. Personalization score hidden <3 postssession-start.mjs:192 (status line), :285 (nudge), keyed on publishedPostCount (:165-170).
  3. Voice sentinel = 0personalization-score.mjs:23-31 (<!-- VOICE_PLACEHOLDER --> → 0 pts); profile-absent = 0 not crash (:34-42, guarded by existsSync).
  4. Analytics empty-data clean-exitstorage.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-50getAnalyticsRoot() (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-12HOME + 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).