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>
22 KiB
M0 — Per-User Data-Dir Migration · Brief
Voyage
/trekbrief-style task brief. Foundation document for the architecture workstream on the pathv0.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-annotationDECISIONblocks.
- Written: 2026-06-01
- Status: RATIFIED 2026-06-01 — all
DECISIONblocks locked; ready for the/trekplanphase (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:
-
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, whileconfig/user-profile.local.mddoes not exist at all (onlyconfig/user-profile.template.mdships). -
User data lives INSIDE the plugin tree. Per-user content sits under
assets/andconfig/, 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 anANALYTICS_ROOTenv 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 gitignoredauthentic-voice-samples.local.md. This is finding 1 above. M0 must declare a single canonical external voice file. config/user-profile.local.mdnever exists. Only the template ships; the personalization scorer readsconfig/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.mjsand the TS twin. - SC2 — Zero in-plugin user-data writes. After running a representative content + analytics flow on a
fresh checkout,
git statusshows no new/modified files under the plugin tree'sassets/,config/*.local.*, orREMEMBER.md; all writes land under the external dir. Verify: scripted dry-run +git status --porcelainis 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/analyticstest suite passes with the external default, including the resolver regression lock (storage-root.test.tsadapted). Verify:npm testgreen. - SC6 — Structure lint green.
scripts/test-runner.shstays 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-seriesnewsletter 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 useprocess.env.HOME || USERPROFILE. - Analytics CLI is TypeScript via
tsx, compiled tobuild/, usesfindPluginRoot+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× duplicatedHOME-string and ~7× duplicatedPLUGIN_ROOTderivation.scripts/analytics/src/utils/storage.ts— generalizegetAnalyticsRoot()→getDataRoot("analytics"), keepANALYTICS_ROOTas an alias. The function already separates "find root" from "join subdir," and all storage functions already takerootas a parameter, so the blast radius of the default-flip is one function body.findPluginRootbecomes 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):
- Voice guardian silent-skip <5 samples —
hooks/prompts/voice-guardian.md:53(read at:31). - Personalization score hidden <3 posts —
session-start.mjs:192(status line),:285(nudge), keyed onpublishedPostCount(:165-170). - Voice sentinel = 0 —
personalization-score.mjs:23-31(<!-- VOICE_PLACEHOLDER -->→ 0 pts); profile-absent = 0 not crash (:34-42, guarded byexistsSync). - Analytics empty-data clean-exit —
storage.tsreturns[]for missing dirs (:76-78,147-149,262-264);cli.ts:245-247,397-399exit with a clear message;ensureDirectories()(:55-68) creates the tree on first write. (The fresh-clone crash fix anchorsgetAnalyticsRoot()on the.claude-plugin/plugin.jsonmarker —:18-50, commit798484b; M0 changes the default but keeps the marker for bundled assets.) - 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
.mjsand 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.
$HOMEvs%USERPROFILE%. Mitigation: reuse the existingprocess.env.HOME || process.env.USERPROFILEidiom already proven in 4 hook scripts.
11. Open questions for the planning phase
- Does any consumer read analytics outside
getAnalyticsRoot()(the agents foundab-tests/referenced only inab-test.mdprose, 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").
- Resolver unit tests (
.mjs+ TS): default returns~/.claude/linkedin-studio/<subdir>; env override wins; twins agree. → SC1, R2. - Fresh-clone flow: on a clean checkout with no user data, run a content command + an analytics import +
a report; then
git status --porcelainfiltered to user-data paths must be empty, and the files must exist under the external dir. → SC2. - Migration fixtures: (a) files-present → moved + source gone + destination verified; (b) re-run → no-op; (c) empty → no error. → SC3, R3.
- 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.
- Analytics suite green with external default incl. adapted
storage-root.test.ts. → SC5. scripts/test-runner.shPassed/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.- 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— solegetAnalyticsRoot()caller;:245-247,397-399empty-data exitshooks/scripts/state-updater.mjs:11-12—HOME+STATE_FILEseam (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-pluginQUEUE_FILE(M0 target)hooks/scripts/quick-import.mjs:11-13— in-pluginEXPORTS_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-guardedhooks/prompts/voice-guardian.md:31,53— voice read + <5-sample silent-skipconfig/state-file.template.md:4— self-documents the template→external splitconfig/edition-state.template.json:4/commands/newsletter.md:46,148—$LTL_SERIES_ROOTexternal 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).