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 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-02 09:46:28 +02:00
commit 4a60abec7f

967
docs/m0/plan.md Normal file
View file

@ -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 D1D6 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 (S1S26) 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<br/>getDataRoot(subdir) · getDataRoot() root · getStateFile()<br/>NEW"]
TS["scripts/analytics/src/utils/storage.ts<br/>getAnalyticsRoot → getDataRoot('analytics')<br/>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<br/>(dataRoot vs pluginRoot split)"]
UPC["user-prompt-context.mjs (voice read)"]
SS["session-start.mjs<br/>(REMEMBER fix + migration wire-in)"]
FILT["linkedin-content-filter.mjs<br/>(recognize external content dirs)"]
end
subgraph prose["Prose pins repointed (B2)"]
IMP["commands/import.md + report.md<br/>(drop explicit ANALYTICS_ROOT=<plugin> pins)"]
SUR["hooks/prompts/state-update-reminder.md<br/>(content-history → external)"]
end
subgraph migration["Migration (NEW, atomic + locked + idempotent)"]
MIG["migrate-data.mjs<br/>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 510 + 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 512). Execute
> Sessions 23 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 12.
- **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 `<tempdir>/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="<plugin>/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 511), 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 -- <family files>`; 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 SC1SC7 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 116.
- **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 (SC1SC7 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 <dir>` 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=<plugin>` 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 512 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 SC1SC7 / §12).*
- [ ] `node --test hooks/scripts/__tests__/data-root.test.mjs``fail 0`; default = `~/.claude/linkedin-studio/<subdir>`, 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=<plugin>`-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 12 (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 14
- **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 14)
### 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 513) now closes the B2/B3 gaps; resolver-first/release-last |
| Step quality | 0.20 | 90 | Each step 13 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 + D1D6 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 SC1SC7 + D1D6 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=<plugin>`-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` |