linkedin-studio/docs/m0/plan.md
Kjell Tore Guttormsen 4a60abec7f 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>
2026-06-02 09:46:28 +02:00

967 lines
66 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` |