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