From 4a60abec7fad72cd50b76311b21ac2d040c46886 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 2 Jun 2026 09:46:28 +0200 Subject: [PATCH] docs(linkedin-studio): M0 implementation plan (18 steps, adversarially reviewed + revised) /trekplan on docs/m0/brief.md -> docs/m0/plan.md. 8 exploration agents. plan-critic REPLAN on v1 (3 blockers + 5 majors + 5 minors); all 13 closed. scope-guardian ALIGNED (SC1-SC7 + D1-D6 covered). Validator 18 steps / 0 errors. Local only - not pushed (planning push-policy). Co-Authored-By: Claude Opus 4.8 --- docs/m0/plan.md | 967 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 docs/m0/plan.md diff --git a/docs/m0/plan.md b/docs/m0/plan.md new file mode 100644 index 0000000..e607ae8 --- /dev/null +++ b/docs/m0/plan.md @@ -0,0 +1,967 @@ +# M0 — Per-User Data-Dir Migration · Implementation Plan + +> **Plan quality: A** (90/100) — APPROVE_WITH_NOTES (post-revision; 3 blockers + 5 majors + 5 minors from adversarial review resolved) +> +> Generated by trekplan v5.1.1 on 2026-06-01 — `plan_version: 1.7` +> +> Input: `docs/m0/brief.md` (RATIFIED 2026-06-01, decisions D1–D6 locked). This plan +> turns the brief's Goal (§3) + Success Criteria (§4) + Decisions (§9) into ordered, +> testable steps. It does **not** re-litigate any ratified decision. + +## Context + +The hardening workstream (S1–S26) was paused at S2 because two architecture findings +surfaced that no hardening can paper over (brief §1): + +1. **Grounded simulation must read REAL voice/profile artefacts, not reconstruct them.** + The real voice profile lives in `assets/voice-samples/authentic-voice-samples.local.md` + (227 lines, 7.7 KB), but every consumer reads the *placeholder* + `authentic-voice-samples.md` (sentinel → score 0), and `config/user-profile.local.md` + never exists at all. +2. **User data lives INSIDE the plugin tree** (`assets/`, `config/`), defended only by + `.gitignore` — the wrong location for data that must survive a reinstall and must never + risk being committed. + +**M0 relocates all per-user data to a single external data dir +(`~/.claude/linkedin-studio/`, mirroring the state file) behind one path-resolution seam +per runtime, with automatic idempotent migration and every graceful-degradation behavior +preserved** (brief §3). Hardening, command-testing, and the GUI then build on the correct +foundation. This is the **architecture** workstream on the path `v0.4.0 → v1.0.0`. + +## Architecture Diagram + +```mermaid +graph TD + subgraph external["~/.claude/linkedin-studio/ (NEW external data root)"] + VOICE["voice-samples/authentic-voice-samples.md (REAL profile, D6)"] + ANA["analytics/ exports posts weekly-reports monthly-reports ab-tests content-history.md"] + DRAFTS["drafts/ queue.json week-*"] + PROF["profile/user-profile.md (D1)"] + SCAF["frameworks/ case-studies/ examples/ audience-insights/ templates/ (D2 instances)"] + PLANS["plans/"] + REMEM["REMEMBER.md (moved out of plugin)"] + MARK[".migrated marker + lockdir"] + end + + subgraph resolverlayer["Resolver layer (two byte-equivalent twins, R2)"] + MJS["hooks/scripts/data-root.mjs
getDataRoot(subdir) · getDataRoot() root · getStateFile()
NEW"] + TS["scripts/analytics/src/utils/storage.ts
getAnalyticsRoot → getDataRoot('analytics')
default FLIPPED to external"] + end + + subgraph hooks["Hook seams (repointed to data-root.mjs)"] + QM["queue-manager.mjs"] + QI["quick-import.mjs (gains override)"] + PS["personalization-score.mjs
(dataRoot vs pluginRoot split)"] + UPC["user-prompt-context.mjs (voice read)"] + SS["session-start.mjs
(REMEMBER fix + migration wire-in)"] + FILT["linkedin-content-filter.mjs
(recognize external content dirs)"] + end + + subgraph prose["Prose pins repointed (B2)"] + IMP["commands/import.md + report.md
(drop explicit ANALYTICS_ROOT= pins)"] + SUR["hooks/prompts/state-update-reminder.md
(content-history → external)"] + end + + subgraph migration["Migration (NEW, atomic + locked + idempotent)"] + MIG["migrate-data.mjs
copy→fsync→verify→unlink, lockdir, external-canonical"] + end + + subgraph bundled["In-plugin (read-only seed, STAYS)"] + TMPL["config/*.template.* · assets/**/*-template.md · render/fonts"] + MARKER[".claude-plugin/plugin.json (findPluginRoot marker, PRESERVED)"] + end + + MJS --> external + TS --> external + QM --> MJS + QI --> MJS + PS --> MJS + UPC --> MJS + SS --> MJS + SS --> MIG + IMP -.external default.-> external + SUR -.external default.-> external + MIG --> external + PS -.fallback.-> TMPL + TS -.bundled assets.-> MARKER + FILT -.classifies.-> DRAFTS +``` + +## Codebase Analysis + +- **Tech stack:** Dual-runtime. (a) **Hooks** — Node.js ESM `.mjs`, **zero npm deps** + (marketplace hard rule), `node:`-builtins only, run directly by Node 25. (b) **Analytics + CLI** — TypeScript via `tsx`, one dep (`csv-parse`), `node --import tsx --test`. +- **Key patterns:** + - **Template→instance** (the one correct pattern, to generalize): read-only template in + plugin (`config/state-file.template.md`), instance external (`~/.claude/`), auto-init + copies template→instance with `mkdirSync(...,{recursive:true})` on first run — + `hooks/scripts/session-start.mjs:388-404`. + - **`HOME || USERPROFILE` idiom** (5 sites — brief said 4): `session-start.mjs:14`, + `state-updater.mjs:11`, `quick-import.mjs:12`, `user-prompt-context.mjs:21`, + `posting-reminder.mjs:12`. + - **`PLUGIN_ROOT` derivation** `join(__dirname,'..','..')` (9 literal sites; ~7 + data-relevant) — the duplication `data-root.mjs` consolidates. + - **Named exports only** (no `export default` in `hooks/scripts/`); standalone-guard + `if (process.argv[1] === fileURLToPath(import.meta.url))` (`personalization-score.mjs:120`). + - **`findPluginRoot` / `.claude-plugin/plugin.json` marker** (`storage.ts:18-33`) — + locates the plugin root by walking up to the marker; **must be preserved** for bundled + read-only assets (commit `798484b`, the fresh-clone crash fix). +- **Relevant files (all verified to exist, file:line):** + - `scripts/analytics/src/utils/storage.ts:40-50` — `getAnalyticsRoot()` (the TS seam; + sole *executable* caller `cli.ts:444`; all storage fns already take `root` param → blast + radius = one function body, VERIFIED). **But prose pins it:** `commands/import.md:126,243,244` + and `commands/report.md:101,111,121,126,154,168,172,359,362,365` set + `ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"` explicitly (~14 pins). + - `hooks/scripts/queue-manager.mjs:11-12`, `quick-import.mjs:11-13` (no env override) `:66` + (stale printed path), `personalization-score.mjs:13,23-114,120-122`, + `user-prompt-context.mjs:20,102-105`, `session-start.mjs:13-15,190,388-404,408-417`, + `linkedin-content-filter.mjs:20,23-24,27,30,34`. + - `hooks/prompts/state-update-reminder.md:71` — Claude-prose instruction to init/write + `assets/analytics/content-history.md` from `config/content-history.template.md` (679 B, + exists). A real in-plugin user-data write with NO executable seam (analogous to ab-tests). + - `scripts/analytics/tests/storage-root.test.ts` (77 lines, the regression-lock template), + `hooks/scripts/__tests__/personalization-score.test.mjs` (`makePluginRoot():18-22`), + `scripts/analytics/tests/storage.test.ts` (empty-data clean-exit evidence). + - `scripts/test-runner.sh:44-47` (`EXPECT_AGENTS=19 EXPECT_COMMANDS=29 EXPECT_REFS=25 + EXPECT_SKILLS=6`), refs glob `:73`, refs assert `:87`, version reads `:315`, version + consistency greps `:320` (README badge `version-${VERSION}-blue`), `:325` (plugin CLAUDE.md + header `LinkedIn Studio Plugin (v${VERSION})`), `:330` (CHANGELOG `## [${VERSION}]`), + self-test non-vacuity `:255-295,391-419`, pass/fail `:37-38,461-476`. + - `.gitignore:7,11,14,37,38,41-44,54,55` — the user-data class manifest (incl. `content-history.md` at `:44`). +- **Reusable code:** the `HOME||USERPROFILE` one-liner (`state-updater.mjs:11`), + `mkdirSync` recursive auto-init (`session-start.mjs:392`), `findPluginRoot` + (`storage.ts:18`), `storage-root.test.ts` (env save/restore `afterEach` `:14-26`, + default+override assertions `:50-75`), the atomic tmp+rename write in `state-updater.mjs:353-355`. +- **External tech (researched):** none — Node `fs`/`path`/`url` builtins + TypeScript + + `tsx`, all already in the codebase. No `research-scout` needed. +- **Recent git activity:** single author, single branch `main`, clean working tree for all + M0 code surface. The 4 cold seams (`queue-manager`, `quick-import`, `session-start`, + `user-prompt-context`) are untouched since the v3.0.0 rename → low-conflict edit surface. + `test-runner.sh` is the hottest M0 file (12 changes). `state-updater.mjs` is volatile (6 + changes) but is the **read-only reference pattern**, not a move target — touching it = scope + creep (brief §13). + +## Implementation Plan + +> **Ordering invariant (the spine of this revised plan, post-review B2/B3):** the migration +> RUNS exactly once at **Step 12** — *after* every reader (code seams Steps 5–10 + the +> analytics CLI prose pins + content-history prose, Step 11) already points at the external +> root, and *before* any in-plugin file is overwritten (the D2 scrub, Step 13). This closes +> the data-availability gap (a reader pointing external while data is still in-plugin, or a +> reader pointing in-plugin after data moved out) and guarantees the leaked real post is +> externalized before it is scrubbed. +> +> **Transient-execution note:** between Step 5 (first seam flipped to external) and Step 12 +> (migration runs), the plugin's content/analytics data paths resolve to the *empty* external +> dir while data still sits in-plugin. This is **safe — no data loss** (sources untouched +> until Step 12; degradation is clean-empty, never crash) but means **do not run +> `/linkedin` content/analytics commands while M0 is mid-execution** (Steps 5–12). Execute +> Sessions 2–3 without exercising the plugin's data features in between. +> +> **Commit-type discipline (verified against the docs-gate hook + `feedback_feat_commit_docs_gate`):** +> intermediate steps use `refactor` / `test` / `docs` / `chore(linkedin-studio):` so the +> `feat:`-only 3-doc gate does NOT fire per-step. The **single `feat(linkedin-studio):` M0 +> commit is the release step (Step 18)**. All checkpoints are **local commits — push only on +> explicit operator OK** (STATE.md push-policy). + +### Step 1: Create the data-root.mjs resolver twin + +- **Files:** `hooks/scripts/data-root.mjs` (new file), `hooks/scripts/__tests__/data-root.test.mjs` (new file) +- **Changes:** Create the `.mjs` resolver. Export named `getDataRoot(subdir = '')` returning + `subdir ? join(dataBase, subdir) : dataBase` (the no-arg/empty form returns the root itself — + needed by Step 9's REMEMBER write) and `getStateFile()`. `dataBase` resolves as: + `process.env.LINKEDIN_STUDIO_DATA` else `join(HOME, '.claude', 'linkedin-studio')`, where + `HOME = process.env.HOME || process.env.USERPROFILE`; if BOTH are empty, fall back to + `os.homedir()` (NOT silent `|| ''` relative-path trap — risk-assessor Low #2). `getStateFile()`: + `process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md')` (mirrors + `state-updater.mjs:12`). `node:`-builtins only, named exports, no default export. Add the + "CANONICAL — twin of storage.ts:getDataRoot, must stay in sync" header (model: + `plugins/llm-security/scripts/lib/report-renderers.mjs:1-18`). (new file) +- **Reuses:** `HOME||USERPROFILE` idiom (`state-updater.mjs:11`), state-file derivation + (`session-start.mjs:15`), `fileURLToPath(import.meta.url)`, named-export convention (`queue-manager.mjs:43`). +- **Test first:** + - File: `hooks/scripts/__tests__/data-root.test.mjs` (new) + - Verifies: default `getDataRoot('analytics') === join(HOME,'.claude','linkedin-studio','analytics')`; + **`getDataRoot()` (no arg) returns the root `~/.claude/linkedin-studio`** (M5); `LINKEDIN_STUDIO_DATA` + override wins; `getStateFile()` default + `STATE_FILE` override; empty-HOME → `os.homedir()` not `''`. + Stubs `HOME`/`LINKEDIN_STUDIO_DATA`/`STATE_FILE` via env save/restore in `afterEach`. + - Pattern: `scripts/analytics/tests/storage-root.test.ts:14-75`, expressed with `node:test` + `node:assert/strict` +- **Verify:** `node --test hooks/scripts/__tests__/data-root.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/data-root.mjs hooks/scripts/__tests__/data-root.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-1 — data-root.mjs resolver twin + test"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/data-root.mjs + - hooks/scripts/__tests__/data-root.test.mjs + min_file_count: 2 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-1" + bash_syntax_check: [] + forbidden_paths: + - hooks/scripts/state-updater.mjs + must_contain: + - path: hooks/scripts/data-root.mjs + pattern: "getDataRoot" + - path: hooks/scripts/data-root.mjs + pattern: "LINKEDIN_STUDIO_DATA" + ``` + +### Step 2: Generalize getAnalyticsRoot to getDataRoot in storage.ts and flip the default + +- **Files:** `scripts/analytics/src/utils/storage.ts`, `scripts/analytics/tests/storage-root.test.ts` +- **Changes:** Add `export function getDataRoot(subdir: string): string` whose default is + `join(HOME, '.claude', 'linkedin-studio', subdir)` (external), honoring + `process.env.LINKEDIN_STUDIO_DATA`. Redefine `getAnalyticsRoot()` as `getDataRoot('analytics')` + but keep honoring the deprecated `ANALYTICS_ROOT` alias (D4) so `cli.ts:444` is unchanged. + **Preserve `findPluginRoot` + the `.claude-plugin/plugin.json` marker** (`:18-33`) — it now + locates bundled read-only assets only (brief §7.2; commit `798484b`). Keep the empty-on-absence + contract (`:76-78`). Add the CANONICAL twin header. Adapt `storage-root.test.ts`: change the + default assertion (`:57-75`) to the external `~/.claude/linkedin-studio/analytics`, **stubbing + `HOME` + `LINKEDIN_STUDIO_DATA` with save/restore exactly as the `.mjs` test does** (M4 — the + default must not couple to the dev's real `$HOME`); add an `LINKEDIN_STUDIO_DATA` override test + + an `ANALYTICS_ROOT` back-compat-alias test; keep the `findPluginRoot` marker tests unchanged. +- **Reuses:** existing `getAnalyticsRoot` body (`:40-50`), `findPluginRoot` (`:18-33`), + `savedEnv` save/restore idiom (`storage-root.test.ts:14-26`). +- **Test first:** + - File: `scripts/analytics/tests/storage-root.test.ts` (existing — adapt) + - Verifies: external default (HOME-stubbed); `LINKEDIN_STUDIO_DATA` override; `ANALYTICS_ROOT` alias; `findPluginRoot` marker-walk unchanged + - Pattern: itself (`:49-75`) +- **Verify:** `cd scripts/analytics && npm test` → expected: `pass 116+`, `fail 0`, tsc clean +- **On failure:** revert — `git checkout -- scripts/analytics/src/utils/storage.ts scripts/analytics/tests/storage-root.test.ts` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-2 — storage.ts getDataRoot + external default (ANALYTICS_ROOT alias kept)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - scripts/analytics/src/utils/storage.ts + - scripts/analytics/tests/storage-root.test.ts + min_file_count: 2 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-2" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: scripts/analytics/src/utils/storage.ts + pattern: "getDataRoot" + - path: scripts/analytics/src/utils/storage.ts + pattern: "findPluginRoot" + ``` + +### Step 3: Add the twin-consistency test + +- **Files:** `hooks/scripts/__tests__/data-root.test.mjs` (extend) +- **Changes:** Add a `describe('twin consistency')` block asserting the `.mjs` resolver produces + the SAME default + override semantics documented for the TS twin (the TS twin cannot be + imported into a no-`tsx` `.mjs` run, so assert each twin against the same literal strings with + a shared comment naming the other — the behavioral-consistency model, since llm-security's + "bit-identical" claim is header-convention-only, not a test). R2's mitigation. +- **Reuses:** the assertions from Step 1; the CANONICAL header convention added in Steps 1–2. +- **Test first:** + - File: `hooks/scripts/__tests__/data-root.test.mjs` (existing from Step 1) + - Verifies: `.mjs` default + `LINKEDIN_STUDIO_DATA` override match the TS twin's documented contract literals + - Pattern: the env save/restore block from Step 1 +- **Verify:** `node --test hooks/scripts/__tests__/data-root.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/__tests__/data-root.test.mjs` +- **Checkpoint:** `git commit -m "test(linkedin-studio): M0-3 — resolver twin-consistency test (R2)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/__tests__/data-root.test.mjs + min_file_count: 1 + commit_message_pattern: "^test\\(linkedin-studio\\): M0-3" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/__tests__/data-root.test.mjs + pattern: "twin" + ``` + +### Step 4: Create migrate-data.mjs (atomic, locked, idempotent, external-canonical) + +- **Files:** `hooks/scripts/migrate-data.mjs` (new), `hooks/scripts/__tests__/migrate-data.test.mjs` (new) +- **Changes:** Create an importable + standalone-runnable `migrateData()` that relocates per-user + data from the plugin tree to the external root. **Two move-classes (risk-assessor HIGH #2):** + (a) the gitignored runtime files — `authentic-voice-samples.local.md` (move its 227-line REAL + content → external `voice-samples/authentic-voice-samples.md`, D6, NOT the placeholder), + `assets/drafts/queue.json`, the 3 analytics files, **and `assets/analytics/content-history.md` + if present (B1)** — atomic MOVE (copy→fsync→verify size+content→`renameSync` temp→final→`unlinkSync` + source); (b) the 6 tracked D2 scaffold instances — **COPY** their current content to the external + instance (preserves user edits) WITHOUT deleting the tracked file (Step 13 templatizes it). + **Safety (3 CRITICALs):** lock via `mkdirSync(lockDir)` (atomic, throws if exists) before any + op, loser no-ops; **external is canonical — never overwrite an existing external file from the + in-plugin tree**; write the `.migrated` marker LAST so the whole run is idempotently skippable. + Handle the expected-but-absent set (`config/user-profile.local.md`, empty `monthly-reports/`, + `drafts/week-*`) as clean no-ops. `node:`-builtins only. +- **Reuses:** atomic tmp+rename (`state-updater.mjs:353-355`), `mkdirSync` recursive + (`session-start.mjs:392`), `getDataRoot` (Step 1). +- **Test first:** + - File: `hooks/scripts/__tests__/migrate-data.test.mjs` (new) + - Verifies: (a) present→moved (gitignored sources gone, dest byte-equal, `.local.md` 227-line + content at `authentic-voice-samples.md`, content-history moved); (b) scaffolds→COPIED (external + instance written, tracked source still present); (c) re-run→no-op (content + mtime unchanged, + no throw, `.migrated` short-circuits); (d) empty fixture→no error; (e) collision→external kept, + in-plugin source not clobbered. All against `mkdtempSync` fixtures with `LINKEDIN_STUDIO_DATA` + stubbed — never the real `$HOME`. + - Pattern: `scripts/analytics/tests/storage.test.ts:87-96` (tempdir + `afterEach`) +- **Verify:** `node --test hooks/scripts/__tests__/migrate-data.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** escalate — migration safety is load-bearing; do not proceed until green +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-4 — migrate-data.mjs atomic+locked+idempotent (created, not wired)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/migrate-data.mjs + - hooks/scripts/__tests__/migrate-data.test.mjs + min_file_count: 2 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-4" + bash_syntax_check: [] + forbidden_paths: + - hooks/scripts/state-updater.mjs + must_contain: + - path: hooks/scripts/migrate-data.mjs + pattern: "migrated" + - path: hooks/scripts/migrate-data.mjs + pattern: "content-history" + ``` + +### Step 5: Repoint queue-manager.mjs to the resolver + +- **Files:** `hooks/scripts/queue-manager.mjs`, `hooks/scripts/__tests__/queue-manager.test.mjs` (new) +- **Changes:** Replace `process.env.PLUGIN_ROOT || join(__dirname,'..','..')` → + `assets/drafts/queue.json` (`:11-12`) with `import { getDataRoot } from './data-root.mjs'` and + `QUEUE_FILE = join(getDataRoot('drafts'), 'queue.json')`. Keep the `mkdirSync(...,{recursive:true})` + first-write (`:16`). +- **Reuses:** `getDataRoot` (Step 1), existing `mkdirSync` auto-init (`:16`). +- **Test first:** + - File: `hooks/scripts/__tests__/queue-manager.test.mjs` (new) + - Verifies: with `LINKEDIN_STUDIO_DATA` set to a tempdir, queue read/write lands under `/drafts/queue.json`, not the plugin tree + - Pattern: `personalization-score.test.mjs:18-22` +- **Verify:** `node --test hooks/scripts/__tests__/queue-manager.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/queue-manager.mjs hooks/scripts/__tests__/queue-manager.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-5 — queue-manager.mjs via getDataRoot('drafts')"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/queue-manager.mjs + min_file_count: 1 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-5" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/queue-manager.mjs + pattern: "getDataRoot" + ``` + +### Step 6: Repoint quick-import.mjs and fix the printed example command + +- **Files:** `hooks/scripts/quick-import.mjs`, `hooks/scripts/__tests__/quick-import.test.mjs` (new) +- **Changes:** Replace the no-override `EXPORTS_DIR` (`:11-13`) with `getDataRoot('analytics')` + + `'exports'` (the hard-bypass that desyncs the instant Step 2 lands). Fix the printed example + command (`:66`) that teaches the OLD `ANALYTICS_ROOT="/assets/analytics"` path → emit + the external/`LINKEDIN_STUDIO_DATA` form. Keep eager `mkdirSync(EXPORTS_DIR)` (`:18`). +- **Reuses:** `getDataRoot` (Step 1). +- **Test first:** + - File: `hooks/scripts/__tests__/quick-import.test.mjs` (new) + - Verifies: exports dir resolves under external root; the printed command no longer contains a bare in-plugin `assets/analytics` path + - Pattern: `personalization-score.test.mjs:18-22` +- **Verify:** `node --test hooks/scripts/__tests__/quick-import.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/quick-import.mjs hooks/scripts/__tests__/quick-import.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-6 — quick-import.mjs via getDataRoot + fix printed path"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/quick-import.mjs + min_file_count: 1 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-6" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/quick-import.mjs + pattern: "getDataRoot" + ``` + +### Step 7: Split personalization-score.mjs lookups into dataRoot vs pluginRoot + +- **Files:** `hooks/scripts/personalization-score.mjs`, `hooks/scripts/session-start.mjs`, `hooks/scripts/__tests__/personalization-score.test.mjs` +- **Changes:** The scorer reads 8 category paths (`:23,34,45,60,75,84,95,106`) relative to a single + `pluginRoot` param (`:13`). Split the concerns: **instance data** (voice, profile, scaffolds) + reads from `getDataRoot(...)`; **template fallback** still reads from the plugin tree. Resolve + `dataRoot` internally via `getDataRoot`, keep a `pluginRoot` param for template lookups. Update + both callers: `session-start.mjs:190` and the standalone block (`:121-122`). Update the fixture + `makePluginRoot()` (`personalization-score.test.mjs:18-22`) to seed instance data under the + external location and drive the scorer through the resolver. risk-assessor HIGH #3 — both call + sites + fixture change in this one step. +- **Reuses:** `getDataRoot` (Step 1); the `existsSync`-guarded read pattern (preserves degradation + invariant 3); the fixture shape. +- **Test first:** + - File: `hooks/scripts/__tests__/personalization-score.test.mjs` (existing — adapt + extend) + - Verifies: placeholder-with-sentinel (external `voice-samples/`) → 0; real voice → 25; profile + absent at external `profile/user-profile.md` → 0 not crash (NEW case, brief §8 inv.3) + - Pattern: `:38-56` +- **Verify:** `node --test hooks/scripts/__tests__/personalization-score.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/personalization-score.mjs hooks/scripts/session-start.mjs hooks/scripts/__tests__/personalization-score.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-7 — scorer dataRoot/pluginRoot split + both callers (HIGH #3)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/personalization-score.mjs + - hooks/scripts/session-start.mjs + - hooks/scripts/__tests__/personalization-score.test.mjs + min_file_count: 3 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-7" + bash_syntax_check: [] + forbidden_paths: + - hooks/scripts/state-updater.mjs + must_contain: + - path: hooks/scripts/personalization-score.mjs + pattern: "getDataRoot" + ``` + +### Step 8: Repoint user-prompt-context.mjs voice read to the resolver + +- **Files:** `hooks/scripts/user-prompt-context.mjs`, `hooks/scripts/__tests__/user-prompt-context.test.mjs` (new) +- **Changes:** Replace the in-plugin voice-file existence check (`:102`) with `getDataRoot('voice-samples')` + + `'authentic-voice-samples.md'`, existence-guarded as today (`:103`). Move the state-file read + (`:108`) onto `getStateFile()` (read-only, guarded). Delivers SC7 (real-artefact read). +- **Reuses:** `getDataRoot`/`getStateFile` (Step 1); existing `existsSync` guard (`:103`). +- **Test first:** + - File: `hooks/scripts/__tests__/user-prompt-context.test.mjs` (new) + - Verifies: real external voice present → context contains the voice reference; only in-plugin placeholder (external absent) → degradation. SC7. + - Pattern: `personalization-score.test.mjs:18-22` +- **Verify:** `node --test hooks/scripts/__tests__/user-prompt-context.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/user-prompt-context.mjs hooks/scripts/__tests__/user-prompt-context.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-8 — voice read via getDataRoot (SC7)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/user-prompt-context.mjs + - hooks/scripts/__tests__/user-prompt-context.test.mjs + min_file_count: 2 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-8" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/user-prompt-context.mjs + pattern: "getDataRoot" + ``` + +### Step 9: Move the REMEMBER.md write out of the plugin tree + +- **Files:** `hooks/scripts/session-start.mjs`, `hooks/scripts/__tests__/session-start-remember.test.mjs` (new) +- **Changes:** Fix the anti-pattern at `:408-417`: `rememberFile = join(PLUGIN_ROOT,'REMEMBER.md')` + writes inside the plugin. Repoint to `join(getDataRoot(), 'REMEMBER.md')` (the no-arg root form + from Step 1) while still seeding from in-plugin `config/REMEMBER.template.md`. Use the same + `mkdirSync(...,{recursive:true})` + copy pattern the state-file init uses 16 lines above + (`:392-393`). Leave the state auto-init (`:388-404`) unchanged. +- **Reuses:** `getDataRoot()` root form (Step 1); the state-file template→instance flow (`:388-404`). +- **Test first:** + - File: `hooks/scripts/__tests__/session-start-remember.test.mjs` (new) — exercise the extracted REMEMBER-init helper if feasible; else a tempdir-`LINKEDIN_STUDIO_DATA` integration check + - Verifies: REMEMBER.md materializes under the external root, not `PLUGIN_ROOT` + - Pattern: `personalization-score.test.mjs:18-22` +- **Verify:** `node --test hooks/scripts/__tests__/session-start-remember.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/session-start.mjs hooks/scripts/__tests__/session-start-remember.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-9 — REMEMBER.md materializes external (anti-pattern fix)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/session-start.mjs + min_file_count: 1 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-9" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/session-start.mjs + pattern: "getDataRoot" + ``` + +### Step 10: Teach linkedin-content-filter.mjs to recognize external content dirs + +- **Files:** `hooks/scripts/linkedin-content-filter.mjs`, `hooks/scripts/__tests__/linkedin-content-filter.test.mjs` +- **Changes:** Today `.claude` is in `infraDirs` (`:27`) and `:30` returns `false` for any path + containing `/.claude/` — firing BEFORE the positive `assets/drafts/` check (`:34`), so relocated + drafts at `~/.claude/linkedin-studio/drafts/` mis-classify as non-content and the PreToolUse + quality gate + voice guardian stop firing (break confirmed by direct read). **Fix (M1 — exact + placement):** insert a positive branch recognizing the external **content** subdir + `/.claude/linkedin-studio/drafts/` (the only data class the Write/Edit quality gate guards; + voice-samples/analytics/profile are NOT editor-gated content) **after** the `.local.md` + + `nonContent`-basename negatives (`:23-24`, which already exclude the state file + `REMEMBER.md`) + and **before** the `infraDirs` loop (`:27-31`). Keep the existing in-plugin `assets/drafts/` + positive (`:34`) for back-compat during the migration window. +- **Reuses:** existing positive/negative structure (`:14-39`); the `.local.md` exclusion (`:23`). +- **Test first:** + - File: `hooks/scripts/__tests__/linkedin-content-filter.test.mjs` (existing — extend) + - Verifies: `~/.claude/linkedin-studio/drafts/x.md` → true; `~/.claude/linkedin-studio.local.md` → false (state); `~/.claude/linkedin-studio/REMEMBER.md` → false; `~/.claude/settings.json` → false; in-plugin `assets/drafts/x.md` → still true + - Pattern: existing cases (`:13-22`) +- **Verify:** `node --test hooks/scripts/__tests__/linkedin-content-filter.test.mjs` → expected: `pass`, `fail 0` +- **On failure:** revert — `git checkout -- hooks/scripts/linkedin-content-filter.mjs hooks/scripts/__tests__/linkedin-content-filter.test.mjs` +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-10 — content-filter recognizes external drafts (after negatives, before infraDirs)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/linkedin-content-filter.mjs + - hooks/scripts/__tests__/linkedin-content-filter.test.mjs + min_file_count: 2 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-10" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/linkedin-content-filter.mjs + pattern: "linkedin-studio/drafts" + ``` + +### Step 11: Drop the in-plugin ANALYTICS_ROOT prose pins and repoint content-history + +- **Files:** `commands/import.md`, `commands/report.md`, `hooks/prompts/state-update-reminder.md` +- **Changes:** (B2) Remove the explicit `ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"` + prefix from the ~14 CLI invocations in `commands/import.md` (`:126,243,244`) and `commands/report.md` + (`:101,111,121,126,154,168,172,359,362,365`) so the Step-2 external default applies (the CLI + resolves to external automatically; keep the `${CLAUDE_PLUGIN_ROOT}/scripts/analytics/...` tooling + paths — those are in-plugin executables, not data). (B1) Repoint `state-update-reminder.md:71` so + `content-history.md` initializes/writes under the external analytics subdir (referencing the + Step-13 path convention or `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/content-history.md`). + These land in the SAME session as Step 12 so no committed state has prose pointing in-plugin after + the data moves out. +- **Reuses:** the external default from Step 2; the `${LTL_SERIES_ROOT:-...}` prose pattern (`newsletter.md:46,148-149`). +- **Test first:** *(prose — verified by the Step 16 lint assertions, not a unit test)* +- **Verify:** `! grep -rn 'ANALYTICS_ROOT="\${CLAUDE_PLUGIN_ROOT}/assets/analytics"' commands/ && ! grep -n 'assets/analytics/content-history.md' hooks/prompts/state-update-reminder.md` → expected: no matches (all pins dropped, content-history repointed) +- **On failure:** revert — `git checkout -- commands/import.md commands/report.md hooks/prompts/state-update-reminder.md` +- **Checkpoint:** `git commit -m "docs(linkedin-studio): M0-11 — drop in-plugin ANALYTICS_ROOT pins + repoint content-history (B1/B2)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - commands/import.md + - commands/report.md + - hooks/prompts/state-update-reminder.md + min_file_count: 3 + commit_message_pattern: "^docs\\(linkedin-studio\\): M0-11" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 12: Wire migrateData into session-start and run the migration once + +- **Files:** `hooks/scripts/session-start.mjs` +- **Changes:** Import `migrateData` (Step 4) and call it early in `session-start.mjs` — before the + score/status computation that reads moved paths — logging once on a real move, silent no-op + otherwise (D5). Then **run the migration once now** as part of execution + (`node hooks/scripts/migrate-data.mjs`) so the operator's real data (the 5 gitignored files + + content-history + the 6 scaffold copies; the 227-line voice file → external `.md`) is externalized + immediately. **This is the ordering hinge:** every reader already points external (Steps 5–11), so + the move makes the whole system consistent; and it runs BEFORE the Step-13 scrub, so the real post + is externalized before its in-plugin copy is replaced (B3). +- **Reuses:** `migrateData` (Step 4), `getDataRoot` (Step 1). +- **Test first:** *(integration — the migrate-data unit tests cover the move; this step's check is the live run)* +- **Verify:** `node hooks/scripts/migrate-data.mjs && test -f "$HOME/.claude/linkedin-studio/voice-samples/authentic-voice-samples.md" && grep -q migrateData hooks/scripts/session-start.mjs` → expected: marker present, voice file externalized (227-line real content), wire-in present +- **On failure:** escalate — if the live migration errors, do NOT proceed to Step 13 (scrub); the real content must be safely external first +- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-12 — wire + run migrateData (data externalized before scrub)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - hooks/scripts/session-start.mjs + min_file_count: 1 + commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-12" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/session-start.mjs + pattern: "migrateData" + ``` + +### Step 13: Create the 4 missing D2 templates, scrub the leak, finalize scaffold fallback + +- **Files:** `assets/examples/high-engagement-posts-template.md` (new), `assets/audience-insights/demographics-template.md` (new), `assets/audience-insights/engagement-patterns-template.md` (new), `assets/templates/my-post-templates-template.md` (new), `assets/examples/high-engagement-posts.md`, `assets/audience-insights/demographics.md`, `assets/audience-insights/engagement-patterns.md`, `assets/templates/my-post-templates.md`, `hooks/scripts/personalization-score.mjs` +- **Changes:** D2: each of the 6 scaffolds = `*-template.*` (read-only seed) + external instance. + Two templates already ship (`case-study-template.md`, `framework-template.md`); CREATE the 4 + missing templates (generic seed, no real user data) and replace the 4 tracked editable files' + content with the template/placeholder. **Safe now (B3):** Step 12 already copied each scaffold's + real content to the external instance, so replacing the tracked working-tree file loses nothing. + **Leak (risk-assessor HIGH #2):** `high-engagement-posts.md` held the operator's real post at HEAD + — its working-tree content becomes the generic placeholder; **git-history scrub (filter-repo/BFG) + is OUT of M0 scope**, logged separately in `docs/m0/log.md`. Finalize the scorer's + external-instance-with-template-fallback for the 6 scaffolds (`personalization-score.mjs:45-114`), + now that both instances (Step 12) and templates (this step) exist. +- **Reuses:** the shape of `case-study-template.md` / `framework-template.md`; the scorer split (Step 7). +- **Test first:** + - File: `hooks/scripts/__tests__/personalization-score.test.mjs` (extend) + - Verifies: scaffold category scores from the external instance when present; falls back to template (no crash) when absent + - Pattern: existing scorer cases +- **Verify:** `ls assets/examples/high-engagement-posts-template.md assets/audience-insights/demographics-template.md assets/audience-insights/engagement-patterns-template.md assets/templates/my-post-templates-template.md && ! grep -q "Ralph Wiggum" assets/examples/high-engagement-posts.md && node --test hooks/scripts/__tests__/personalization-score.test.mjs` → expected: 4 templates listed, no leaked content, tests pass +- **On failure:** revert — `git checkout -- assets/examples/ assets/audience-insights/ assets/templates/ hooks/scripts/personalization-score.mjs` +- **Checkpoint:** `git commit -m "chore(linkedin-studio): M0-13 — 4 D2 templates + scrub leak + scaffold fallback"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - assets/examples/high-engagement-posts-template.md + - assets/audience-insights/demographics-template.md + - assets/audience-insights/engagement-patterns-template.md + - assets/templates/my-post-templates-template.md + min_file_count: 4 + commit_message_pattern: "^chore\\(linkedin-studio\\): M0-13" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: hooks/scripts/personalization-score.mjs + pattern: "getDataRoot" + ``` + +### Step 14: Prototype the D3 path convention on the voice-readers family + +- **Files:** `references/data-path-convention.md` (new), plus the voice-readers prose family (10 commands + 4 agents + 2 skills + 2 hook prompts + 1 reference = 19 files, 38 refs — e.g. `commands/setup.md:84`, `commands/onboarding.md`, `agents/voice-trainer.md`, `hooks/prompts/voice-guardian.md:31`) +- **Changes:** Author a short reference doc defining the path convention — generalize the proven + `${LTL_SERIES_ROOT:-$HOME/linkedin-series}` prose pattern (`commands/newsletter.md:46,148-149`) to + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}`. Apply it to the voice-readers family + ONLY (smallest well-bounded, single-canonical-file family). **Measure** whether the convention + reduces edits or whether commands need literal paths (brief D3 + open-question 2; brief-reviewer + assumption 2). GATE — its outcome decides Step 15's edit volume. If the convention works, proceed; + if not, Step 15 falls back to batched literal edits (D3 alternative a — NOT a re-decision). +- **Reuses:** the `newsletter.md` external-data prose pattern; the voice canonical path from Step 8. +- **Test first:** *(prose — verified by the Step 16 lint assertion)* +- **Verify:** `grep -rl "authentic-voice-samples" commands/ agents/ skills/ hooks/prompts/ references/ | xargs -r grep -L "LINKEDIN_STUDIO_DATA\|data-path-convention"` → expected: empty (every voice reader routes through the convention) OR a documented literal-edit list if the convention was rejected (`xargs -r` guards the empty-input case — m5) +- **On failure:** skip — record the prototype outcome in `docs/m0/log.md`; Step 15 adapts +- **Checkpoint:** `git commit -m "docs(linkedin-studio): M0-14 — D3 path-convention + voice-readers prototype"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - references/data-path-convention.md + min_file_count: 1 + commit_message_pattern: "^docs\\(linkedin-studio\\): M0-14" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: references/data-path-convention.md + pattern: "LINKEDIN_STUDIO_DATA" + ``` + +### Step 15: Apply the convention to the remaining prose families and route ab-tests + plans + +- **Files:** the remaining prose families across `commands/`, `agents/`, `skills/`, `references/` — analytics (61 refs, incl. `commands/report.md`, `import.md`, `ab-test.md`), drafts/queue (24), profile/D1 (15), audience-insights (14), examples (11), frameworks (6), case-studies (5), my-post-templates (4) +- **Changes:** Apply the Step-14 convention (or batched literal edits if D3 was rejected) to every + remaining family. **Route the code-invisible data classes (dependency-tracer + risk-assessor):** + `ab-tests/` (only in `ab-test.md`/`measure.md`/`linkedin.md` prose) and `plans/` (in `audit.md`, + `batch.md`, `analytics-interpreter.md`, `content-planner.md` prose) — assign each an external + subdir and repoint their prose so they don't silently orphan when the default flips. Keep Style-A + `${CLAUDE_PLUGIN_ROOT}` for in-plugin read-only assets; repoint only Style-B bare data paths. +- **Reuses:** the convention from Step 14. +- **Test first:** *(prose — verified by Step 16 lint)* +- **Verify:** `bash scripts/test-runner.sh` (with the Step-16 R1 assertions in place) → expected: `Failed: 0`, no command prose references a bare in-plugin user-data path +- **On failure:** revert the offending family — `git checkout -- `; re-apply per category +- **Checkpoint:** `git commit -m "docs(linkedin-studio): M0-15 — repoint remaining prose families + route ab-tests/plans"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - commands/ab-test.md + - commands/report.md + min_file_count: 2 + commit_message_pattern: "^docs\\(linkedin-studio\\): M0-15" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 16: Extend test-runner.sh with the layout, no-bare-path, and no-pin assertions + +- **Files:** `scripts/test-runner.sh` +- **Changes:** (a) Bump `EXPECT_REFS=25` → `26` (`:46`) for `references/data-path-convention.md` + (the `:73` glob count must match or the lint fails `:87`); assert the `references/` delta is + **exactly +1** so an incidental extra ref doc cannot pass silently (m3). (b) Add Section 13 + asserting **no command prose references a bare in-plugin user-data path** (R1, brief §12.6) — model + the non-vacuity self-test (`:255-295,391-419`). (c) Add a sibling assertion that **no prose pins + `ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"`** (M3 — the no-bare-path grep does NOT + catch alias pins). (d) Add the SC2 dry-run: after a representative content + analytics flow, + `git status --porcelain` filtered to user-data path classes (from `.gitignore`, incl. + `content-history.md`) must be empty. (e) **Record pre-M0 assertion count `N` as baseline; require + post-M0 `N` ≥ baseline** (brief-reviewer assumption 3). Keep `Passed/Failed = N/0`. +- **Reuses:** the self-test pattern (`:255-295`), the `STAT_HITS` grep-and-assert-empty idiom (`:297-303`), the `EXPECT_*` contract (`:44-47`). +- **Test first:** *(the lint IS the test — its Section-13 self-test is the red→green)* +- **Verify:** `bash scripts/test-runner.sh` → expected: `Passed: N` (≥ baseline), `Failed: 0`, Section 13 self-test passes +- **On failure:** revert — `git checkout -- scripts/test-runner.sh` +- **Checkpoint:** `git commit -m "test(linkedin-studio): M0-16 — lint: EXPECT_REFS=26 + no-bare-path + no-pin + SC2 dry-run (R1)"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - scripts/test-runner.sh + min_file_count: 1 + commit_message_pattern: "^test\\(linkedin-studio\\): M0-16" + bash_syntax_check: + - scripts/test-runner.sh + forbidden_paths: [] + must_contain: + - path: scripts/test-runner.sh + pattern: "EXPECT_REFS=26" + ``` + +### Step 17: Run the full green gate across all three test surfaces + +- **Files:** *(no production changes — verification + any fixture gaps surfaced)* +- **Changes:** Run the complete SC4 degradation + SC5 analytics + SC6 lint + all hook tests + together; close any fixture gap surfaced. Confirm SC1–SC7 each have a passing check. The + voice-guardian `<5`-sample silent-skip is **prompt logic** (`voice-guardian.md:53`), not + unit-testable — verify by (1) confirming the prompt points at the resolved external path after + Step 14, and (2) the read-path resolution check in `data-root.test.mjs`; do NOT author a + behavioral assertion of Claude's skip (test-strategist). +- **Reuses:** all tests from Steps 1–16. +- **Test first:** *(this step is the integration gate)* +- **Verify:** run all three — + `node --test hooks/scripts/__tests__/*.test.mjs` (expect `fail 0`) · + `cd scripts/analytics && npm test` (expect `pass 116+`, `fail 0`, tsc clean) · + `bash scripts/test-runner.sh` (expect `Failed: 0`) +- **On failure:** escalate — do not release until all three surfaces are green +- **Checkpoint:** `git commit --allow-empty -m "test(linkedin-studio): M0-17 — full green gate (SC1–SC7 verified)"` *(--allow-empty: this step may produce no file change if no fixture gap surfaces — m4)* +- **Manifest:** + ```yaml + manifest: + expected_paths: [] + min_file_count: 0 + commit_message_pattern: "^test\\(linkedin-studio\\): M0-17" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 18: Release — version bump, three-doc update, CHANGELOG + +- **Files:** `.claude-plugin/plugin.json`, `README.md` (plugin), `CLAUDE.md` (plugin), `CHANGELOG.md` (plugin), `/Users/ktg/.claude/plugins/marketplaces/ktg-plugin-marketplace/README.md` (root), `/Users/ktg/.claude/plugins/marketplaces/ktg-plugin-marketplace/CLAUDE.md` (root) +- **Changes:** Bump version `0.4.0 → 0.5.0` (D5 minor, non-breaking — auto-migration + back-compat + aliases; **no `!`**). Update the three doc levels (plugin README + plugin CLAUDE.md + root README, + per the docs-gate) describing the external data dir, resolver twins, migration, D2 split, D3 + convention. Add the CHANGELOG entry. **Version-sync — update every `0.4.0` hit, and specifically + the three literal strings the lint greps (m1/m2):** the plugin README badge `version-0.5.0-blue` + (`test-runner.sh:320`), the plugin CLAUDE.md header `# LinkedIn Studio Plugin (v0.5.0)` + (currently `(v0.4.0)`, `:325`), and the CHANGELOG entry `## [0.5.0]` (`:330`). The version + source-of-truth is `.claude-plugin/plugin.json` (`test-runner.sh:315` *reads* it, does not declare + it). **`git add` MUST be a separate Bash call before `git commit`** so the docs-gate hook sees + staged state (`feedback_feat_commit_docs_gate`). +- **Reuses:** the commit shape of `baca30f`/`1fa2cc9` (5-doc + plugin.json in one commit). +- **Test first:** *(release metadata — verified by the lint version-consistency guard)* +- **Verify:** `bash scripts/test-runner.sh` → expected: `Failed: 0` (version/count consistency guards pass) AND `grep -rn "0\\.4\\.0" --include=*.md --include=*.json . | grep -v CHANGELOG` → expected: no stale version refs +- **On failure:** revert — `git checkout -- .claude-plugin/plugin.json README.md CLAUDE.md CHANGELOG.md` + the two root docs +- **Checkpoint:** `git commit -m "feat(linkedin-studio): M0 — per-user external data dir (v0.5.0)"` + > **Push is NOT part of this step.** Per STATE.md push-policy, product/version commits push only + > on **explicit operator OK**. Stop after the local commit and report. +- **Manifest:** + ```yaml + manifest: + expected_paths: + - .claude-plugin/plugin.json + - README.md + - CLAUDE.md + - CHANGELOG.md + min_file_count: 4 + commit_message_pattern: "^feat\\(linkedin-studio\\): M0" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: + - path: .claude-plugin/plugin.json + pattern: "0.5.0" + ``` + +## Alternatives Considered + +| Approach | Pros | Cons | Why rejected | +|----------|------|------|--------------| +| One shared resolver module for both runtimes | DRY, single source | Hooks are zero-npm-dep `.mjs`; analytics is `tsx`-TS with dual `src/`/`build/` location → a hook importing `.ts` needs `tsx` at hook runtime (breaks the zero-dep + fast-hook contract) | Forced by the runtime split; brief §7.2 mandates two twins + a consistency test (Step 3). Not a preference. | +| Migration COPY-then-flip-then-cleanup (two-phase) instead of move-at-Step-12 | Zero transient-empty window during developer execution | Adds a separate source-cleanup step; dual-mode migrate-data (copy for dev, move for end-user) complicates the function the brief wants idempotent-move | Rejected — the single move-at-Step-12 (after all readers flipped) + the documented "don't use content commands mid-execution" note achieves the same safety with a simpler function (see Ordering invariant). | +| Compat symlink/shim from old in-plugin paths → external | Prose needs no change | Hides the real location, fragile cross-OS, violates "data not in plugin" even if symlinked | Brief D3 alternative (b), rejected as primary. | +| Tag M0 breaking + require manual `/linkedin:migrate` | Explicit, no auto-migration risk | Friction for a solo user-base; adds a command (changes `EXPECT_COMMANDS`) | Brief D5 alternative, rejected unless auto-migration proves unsafe. | +| Leave the 6 D2 scaffolds in place (move only 5 files) | Smaller M0 | Clobber-risk + "data in plugin" persists for 6 files; the leak in `high-engagement-posts.md` stays in the working tree | Brief D2 alternative (a), rejected — D2 ratified to full restructure. | +| Defer all prose repointing (D3) to a later release | Smaller code-only M0 | SC2/SC7 require prose+code consistency or commands write the new dir while Claude follows stale prose (R1) | Cannot defer entirely; the prototype-first GATE (Step 14) is the de-risk instead. | + +## Test Strategy + +- **Framework:** `node:test` + `node:assert/strict` everywhere. Hooks: zero-npm-dep `.mjs` under + `hooks/scripts/__tests__/`, run `node --test hooks/scripts/__tests__/*.test.mjs` (**glob form — + `node --test ` fails MODULE_NOT_FOUND on Node 25**). Analytics: TS via `tsx`, + `cd scripts/analytics && npm test`. Structure lint: `bash scripts/test-runner.sh`. +- **Existing patterns:** `storage-root.test.ts` (env save/restore `afterEach` + default/override, + `:14-75`); `personalization-score.test.mjs` (`makePluginRoot()` tempdir fixture `:18-22`); + `storage.test.ts` (empty-data `[]` clean-exit `:185-191,296-302,417-423`). +- **New tests in this plan:** `data-root.test.mjs` (SC1 + R2 + root case), `queue-manager.test.mjs`, + `quick-import.test.mjs`, `user-prompt-context.test.mjs` (SC7), `session-start-remember.test.mjs`, + `migrate-data.test.mjs` (SC3 — move/copy/idempotent/collision + content-history), extended + `personalization-score.test.mjs` (profile-absent + scaffold fallback), extended + `linkedin-content-filter.test.mjs` (external content dirs), adapted `storage-root.test.ts`. + +### Tests to write + +| Type | File | Verifies | Model test | +|------|------|----------|------------| +| Unit | `hooks/scripts/__tests__/data-root.test.mjs` | resolver default+override+root, twin consistency, empty-HOME fallback (SC1/R2) | `storage-root.test.ts:49-75` | +| Unit | `scripts/analytics/tests/storage-root.test.ts` (adapt) | external default (HOME-stubbed), `LINKEDIN_STUDIO_DATA` + `ANALYTICS_ROOT` alias (SC1/SC5) | itself | +| Unit | `hooks/scripts/__tests__/migrate-data.test.mjs` | move/copy/idempotent/collision; `.local.md` 227-line → external `.md`; content-history moved (SC3/R3/B1) | `storage.test.ts:87-96` | +| Unit | `hooks/scripts/__tests__/personalization-score.test.mjs` (extend) | profile-absent→0, scaffold fallback, voice via external (SC4) | `:38-56` | +| Unit | `hooks/scripts/__tests__/user-prompt-context.test.mjs` | real external voice → context; placeholder-only → degrade (SC7) | `personalization-score.test.mjs:18-22` | +| Unit | `hooks/scripts/__tests__/linkedin-content-filter.test.mjs` (extend) | external drafts→content; state file + REMEMBER→not (Step 10) | `:13-22` | +| Lint | `scripts/test-runner.sh` Section 13 | no bare in-plugin path; no `ANALYTICS_ROOT=` pin; SC2 git-status empty; refs delta +1 (R1/SC2/SC6) | self-test `:255-295` | + +*The voice-guardian `<5`-sample silent-skip is prompt logic — verified by prompt-path resolution + +code review, not a behavioral assertion (test-strategist).* + +## Risks and Mitigations + +| Priority | Risk | Location | Impact | Mitigation | +|----------|------|----------|--------|------------| +| Critical | Auto-migration not atomic — partial move loses the 7.7 KB voice file | `migrate-data.mjs` (Step 4) | Irreversible loss of the user's real profile | copy→fsync→verify→rename→unlink; `.migrated` marker LAST (Step 4 + SC3 test) | +| Critical | Concurrent SessionStart (multi-window) race on the same move | `migrate-data.mjs` + wire-in (Steps 4, 12) | Half-moved tree, corruption | Lockdir via atomic `mkdirSync`; loser no-ops (Step 4) | +| Critical | Destination-collision on reinstall overwrites newer external data | `migrate-data.mjs` (Step 4) | Stale in-plugin scaffold clobbers external data | External is canonical — never overwrite external from in-plugin; `.migrated` short-circuit (Step 4) | +| Critical | Ordering gap — reader points external while data in-plugin (or vice-versa) | Steps 5–12 sequencing | CLI/hook reads empty silently (storage.ts:76-78) | Migration RUNS at Step 12 after ALL readers (code + analytics pins + content-history) point external; documented "no content commands mid-execution" window (Ordering invariant) | +| High | Real post overwritten before migration externalizes it | Steps 12→13 order | Real content lost to git history only | Step 12 runs migration (copies scaffold content external) BEFORE Step 13 scrub (B3) | +| High | Scorer signature silently flips plugin-root→data-root | `personalization-score.mjs:13`, callers `:190,121` | Half-wired → every category scores 0 forever | Split lookups + update both call sites + fixture in ONE step (Step 7) | +| High | Migration moves the placeholder, not the real voice file | `migrate-data.mjs` (Step 4) | Real 227-line profile lost; score stays 0 | Move `.local.md` CONTENT → external `.md`; SC7 + migrate-data test assert the 227-line file (Steps 4, 8) | +| High | `content-history.md` is a prose-driven in-plugin write missed by code audits | `state-update-reminder.md:71`, `.gitignore:44` | Plugin keeps writing user data in-plugin → SC2 fails | Migrate it (Step 4) + repoint the prose (Step 11) + SC2 dry-run catches it (Step 16) — B1 | +| High | Content gate stops firing on relocated drafts | `linkedin-content-filter.mjs:27-34` | Quality gate + voice guardian silently skip external drafts | Positive branch for external drafts inserted after negatives, before infraDirs (Step 10 + test) — M1 | +| Medium | Analytics prose pins override the flipped default | `import.md`, `report.md` (~14 pins) | CLI reads in-plugin (empty after move) | Drop the pins in lockstep with migration (Step 11) + lint no-pin assertion (Step 16) — B2/M3 | +| Medium | `quick-import.mjs` no env override → desyncs from CLI on default-flip | `quick-import.mjs:11-13,66` | quick-import writes in-plugin, CLI reads external | Repoint to `getDataRoot` + fix printed path (Step 6) | +| Medium | `ab-tests/` + `plans/` are prose-only data classes, no code seam | `ab-test.md`, `measure.md`, `audit.md`, `batch.md` prose | Silently orphan when default flips | Assign external subdir + repoint prose (Step 15) | +| Medium | Twin divergence (R2) | `data-root.mjs` ↔ `storage.ts` | Resolvers drift apart | Behavioral consistency test (Step 3) + CANONICAL headers | +| Low | Empty HOME → relative data root | resolver (Step 1) | Writes land relative to cwd | `os.homedir()` fallback, never silent `|| ''` (Step 1) | +| Low | Adapted TS default test couples to dev `$HOME` | `storage-root.test.ts` (Step 2) | Flaky/false-green on unusual `$HOME` | Stub `HOME`/`LINKEDIN_STUDIO_DATA` as the `.mjs` test does (Step 2) — M4 | + +## Assumptions + +| # | Assumption | Why unverifiable | Impact if wrong | +|---|-----------|-----------------|-----------------| +| 1 | D2 = full 6-file restructure; the 4 missing templates can be authored as generic seed | Ratified decision; "right" template content is editorial | Step 13 grows if seed content needs care; no logic impact | +| 2 | The D3 convention reduces ~180 prose edits (vs literal edits) | Empirical — must prototype on voice-readers (Step 14) before scaling | Step 15 falls back to batched literal edits (D3 alt. a) — more churn, same outcome, NOT a re-decision | +| 3 | The lint's assertion count must not net-decrease across M0 | The lint self-modifies its own assertions (SC6) | A green lint could mask a dropped check — Step 16 records baseline `N`, requires post-M0 ≥ baseline | +| 4 | `content-history.md` has two surfaces: the state-file `## Recent Posts` section (already external) AND a separate prose-driven `assets/analytics/content-history.md` file (`state-update-reminder.md:71`) — the FILE is migrated (B1) | Verified: `.gitignore:44` + `config/content-history.template.md` exist | If a third writer exists, the SC2 dry-run (Step 16) catches the in-plugin write | +| 5 | Auto-migration on session-start is acceptable latency for a solo user | Runs once then `.migrated` short-circuits | If slow on large data, gate behind a one-time prompt (D5 alt.) | +| 6 | The operator does not run `/linkedin` content/analytics commands between Steps 5 and 12 | Operationally controlled (interactive, focused M0 execution) | Reads return empty (degradation, no loss); resolved when Step 12 migration runs | + +## Verification + +*Per-step manifests are checked automatically during execution. These are the end-to-end, +cross-step integration checks (mapped to the brief's SC1–SC7 / §12).* + +- [ ] `node --test hooks/scripts/__tests__/data-root.test.mjs` → `fail 0`; default = `~/.claude/linkedin-studio/`, root form, override, twin-consistency pass (SC1, R2) +- [ ] `cd scripts/analytics && npm test` → `pass 116+`, `fail 0`, tsc clean; external default (HOME-stubbed) + `ANALYTICS_ROOT` alias (SC1, SC5) +- [ ] `node --test hooks/scripts/__tests__/migrate-data.test.mjs` → `fail 0`; move/copy/idempotent/collision + content-history + 227-line voice (SC3, R3, B1) +- [ ] On a clean checkout: run a content command + analytics import + report (AFTER Step 12), then `git status --porcelain` filtered to user-data paths (incl. `content-history.md`) is **empty**; files exist under the external dir (SC2) +- [ ] `node --test hooks/scripts/__tests__/personalization-score.test.mjs` → `fail 0`; voice sentinel→0, profile-absent→0, scaffold fallback (SC4) +- [ ] `node --test hooks/scripts/__tests__/user-prompt-context.test.mjs` → `fail 0`; real external voice → context, placeholder-only → degrade (SC7) +- [ ] `bash scripts/test-runner.sh` → `Failed: 0`; `EXPECT_REFS=26`, refs delta +1, Section-13 no-bare-path + no-`ANALYTICS_ROOT=`-pin + SC2 dry-run, baseline `N` not net-decreased (SC6, R1, B2) +- [ ] `grep -rn "0\.4\.0"` across `.md`/`.json` (excl. CHANGELOG) → no stale version refs after Step 18 + +## Estimated Scope + +- **Files to modify:** ~15 code/config (`storage.ts`, 6 hook scripts, `linkedin-content-filter.mjs`, + `test-runner.sh`, 4 tracked scaffolds, `import.md`, `report.md`, `state-update-reminder.md`, + plugin.json) + ~180 prose lines across ~50 markdown files (D3) +- **Files to create:** ~13 (`data-root.mjs`, `migrate-data.mjs`, 6 new test files, 4 D2 templates, `references/data-path-convention.md`) +- **Complexity:** high (dual-runtime resolver + atomic/concurrent/idempotent migration with strict + ordering + a ~180-ref prose surface). The code core is small and well-bounded; migration safety, + the reader-flip ordering, and prose volume are where the risk concentrates. + +## Execution Strategy + +> **Driftsmodell note:** the operator works **interactively** (Claude drives, stops at each +> "enige"-point; operator is truth source) and pushes only on explicit OK. Sessions run +> **sequentially with operator gates via `/trekcontinue`**, NOT as parallel worktrees. "Wave" = +> dependency ordering. Each session ends green + local-committed. + +### Session 1: Resolver foundation + migration tool +- **Steps:** 1, 2, 3, 4 +- **Wave:** 1 +- **Depends on:** none +- **Scope fence:** + - Touch: `data-root.mjs`, `storage.ts`, `migrate-data.mjs`, their tests, `storage-root.test.ts` + - Never touch: `state-updater.mjs` (read-only reference), prose, the seams (later) + +### Session 2: Repoint the hook seams +- **Steps:** 5, 6, 7, 8, 9, 10 +- **Wave:** 2 +- **Depends on:** Session 1 (`data-root.mjs`) +- **Scope fence:** + - Touch: `queue-manager.mjs`, `quick-import.mjs`, `personalization-score.mjs`, `user-prompt-context.mjs`, `session-start.mjs` (REMEMBER + scorer caller), `linkedin-content-filter.mjs`, their tests + - Never touch: `state-updater.mjs`, `storage.ts` (done), migration wire-in, prose + - **Note:** ends with all hook seams pointing external (empty until Session 3 migration). Do NOT run `/linkedin` content/analytics commands until Session 3 completes. + +### Session 3: Analytics pins → migrate-run → scrub (MUST stay together) +- **Steps:** 11, 12, 13 +- **Wave:** 3 +- **Depends on:** Sessions 1–2 (resolver + all seams flipped) +- **Scope fence:** + - Touch: `import.md`, `report.md`, `state-update-reminder.md` (pins/content-history), `session-start.mjs` (migration wire-in), the 4 new templates + 4 tracked scaffolds, `personalization-score.mjs` (scaffold fallback) + - Never touch: resolver internals (frozen), other prose families + - **Critical adjacency:** Step 11 (pins external) → Step 12 (run migration) → Step 13 (scrub) execute in order without interruption — this is the B2/B3 fix. Do NOT reorder or split across the 12→13 boundary. + +### Session 4: Prose repointing (D3) +- **Steps:** 14, 15 +- **Wave:** 4 +- **Depends on:** Session 2 (seams stable) — Step 14 GATES Step 15 +- **Scope fence:** + - Touch: `references/data-path-convention.md`, prose in `commands/`, `agents/`, `skills/`, `references/`, `hooks/prompts/` + - Never touch: any `.mjs`/`.ts` code, any test + +### Session 5: Verify + release +- **Steps:** 16, 17, 18 +- **Wave:** 5 +- **Depends on:** Sessions 1–4 +- **Scope fence:** + - Touch: `scripts/test-runner.sh`, `.claude-plugin/plugin.json`, plugin + root docs, CHANGELOG + - Never touch: production code logic (verification only); **do NOT push** (operator-gated) + +### Execution Order + +- **Wave 1:** Session 1 +- **Wave 2:** Session 2 (after 1) +- **Wave 3:** Session 3 (after 1+2) — internal step order 11→12→13 is load-bearing +- **Wave 4:** Session 4 (after 2) +- **Wave 5:** Session 5 (after 1–4) + +### Grouping rules applied + +- Resolver + migration tool built first (Session 1) so every later session builds on stable seams +- All readers flipped (Session 2) before the migration RUNS (Session 3) — the B2/B3 ordering fix +- The pins→migrate→scrub triple kept in one uninterrupted session (Session 3) +- The D3 prototype (14) gates the bulk prose edit (15) — same session, sequential +- Release isolated last so the single `feat` + 3-doc + version bump is atomic + +## Plan Quality Score + +| Dimension | Weight | Score | Notes | +|-----------|--------|-------|-------| +| Structural integrity | 0.15 | 90 | 18 steps dependency-ordered; the reader-flip→migrate→scrub spine (Steps 5–13) now closes the B2/B3 gaps; resolver-first/release-last | +| Step quality | 0.20 | 90 | Each step 1–3 files (Step 11/13 deliberately multi-file for atomicity), TDD where code, real reuse refs; insertion point pinned (Step 10) | +| Coverage completeness | 0.20 | 92 | All 7 SCs + D1–D6 mapped; content-history (B1) + analytics pins (B2) + ab-tests/plans now covered | +| Specification quality | 0.15 | 90 | Concrete paths/commands; commit-types pinned (no deferred (a)/(b)); a few prose steps necessarily judgment-bearing (D3 outcome) | +| Risk & pre-mortem | 0.15 | 93 | 15 risks incl. 4 migration criticals + the ordering gap; each mapped to a step | +| Headless readiness | 0.10 | 86 | On-failure + Checkpoint per step; --allow-empty on the gate; driftsmodell is interactive so headless decomposition is advisory | +| Manifest quality | 0.05 | 88 | Every step has a checkable manifest; commit_message_pattern pinned per step (no `(feat\|refactor)` ambiguity) | +| **Weighted total** | **1.00** | **90** | **Grade: A** | + +**Adversarial review:** +- **Plan critic:** REPLAN on v1 — 3 blockers (content-history unmigrated; analytics-pin/migration data gap; scrub-before-migration), 5 majors, 5 minors. All resolved in this revision (see Revisions). +- **Scope guardian:** ALIGNED — all SC1–SC7 + D1–D6 covered, 0 hard scope-creep, 0 gaps, ~30 file:line anchors verified; `plans/` routing low-confidence-creep accepted (brief §7.1 target tree). + +## Revisions + +| # | Finding | Severity | Resolution | +|---|---------|----------|------------| +| 1 | `content-history.md` is an in-plugin user-data file (`state-update-reminder.md:71` + `.gitignore:44` + template), unmigrated; Assumption 4 false; breaks SC2 | blocker | migrate-data now moves it (Step 4); prose repointed (Step 11); SC2 dry-run catches it (Step 16); Assumption 4 rewritten | +| 2 | Data-availability gap: `import.md`/`report.md` explicit `ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"` (~14 pins) keep the CLI on in-plugin after the move | blocker | New Step 11 drops the pins in lockstep with the Step-12 migration-run; lint no-pin assertion added (Step 16); Risk row + M3 | +| 3 | Scrub (old Step 10) ran before migration (old Step 11), overwriting the real post before `migrate-data` existed | blocker | Reordered: migrate-data created Step 4, RUN at Step 12, scrub at Step 13 (after externalization); Ordering invariant added | +| 4 | Step 9 content-filter fix underspecified (insertion point + which subdirs) | major | Step 10 pins insertion after `:23-24` negatives, before `:27` infraDirs; scopes positive match to `drafts/` only; test asserts state-file/REMEMBER stay excluded | +| 5 | Migration step `feat` checkpoint + `(feat\|refactor)` regex risked tripping the 3-doc gate mid-plan | major | Migration step pinned to `refactor` (Step 4/12); the (a)/(b) deferral removed; single `feat` only at Step 18 | +| 6 | `import.md` pins not flagged top-priority; no-bare-path lint wouldn't catch alias pins | major | Step 11 prioritizes them; Step 16 adds a dedicated no-`ANALYTICS_ROOT=`-pin lint assertion | +| 7 | Adapted `storage-root.test.ts` default assertion `$HOME`-dependent without stubbing | major | Step 2 now stubs `HOME`+`LINKEDIN_STUDIO_DATA` as the `.mjs` test does; Risk Low row | +| 8 | `getDataRoot('')` root semantics used (Step 8) but never specified/tested (Step 1) | major | Step 1 defines `getDataRoot(subdir='')` root form + tests it; Step 9 uses the explicit no-arg form | +| 9 | Step 17 version source-of-truth ref wrong (`test-runner.sh:315` reads, not declares) | minor | Step 18 corrected: source of truth is `.claude-plugin/plugin.json`; `:315` reads it | +| 10 | Step 17 didn't enumerate the 3 version-consistency grep targets | minor | Step 18 names them: README badge `version-0.5.0-blue` (`:320`), CLAUDE.md header `(v0.5.0)` (`:325`), CHANGELOG `## [0.5.0]` (`:330`) | +| 11 | EXPECT_REFS=26 assumed refs delta +1, unasserted | minor | Step 16 adds an explicit "references/ delta = +1" assertion | +| 12 | Green-gate checkpoint commit could be empty | minor | Step 17 uses `git commit --allow-empty` | +| 13 | Step 13 verify `grep | xargs grep -L` empty-input fragile | minor | Step 14 verify uses `xargs -r` |