/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>
66 KiB
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.7Input:
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):
- 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 placeholderauthentic-voice-samples.md(sentinel → score 0), andconfig/user-profile.local.mdnever exists at all. - 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
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 viatsx, 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 withmkdirSync(...,{recursive:true})on first run —hooks/scripts/session-start.mjs:388-404. HOME || USERPROFILEidiom (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_ROOTderivationjoin(__dirname,'..','..')(9 literal sites; ~7 data-relevant) — the duplicationdata-root.mjsconsolidates.- Named exports only (no
export defaultinhooks/scripts/); standalone-guardif (process.argv[1] === fileURLToPath(import.meta.url))(personalization-score.mjs:120). findPluginRoot/.claude-plugin/plugin.jsonmarker (storage.ts:18-33) — locates the plugin root by walking up to the marker; must be preserved for bundled read-only assets (commit798484b, the fresh-clone crash fix).
- Template→instance (the one correct pattern, to generalize): read-only template in
plugin (
- Relevant files (all verified to exist, file:line):
scripts/analytics/src/utils/storage.ts:40-50—getAnalyticsRoot()(the TS seam; sole executable callercli.ts:444; all storage fns already takerootparam → blast radius = one function body, VERIFIED). But prose pins it:commands/import.md:126,243,244andcommands/report.md:101,111,121,126,154,168,172,359,362,365setANALYTICS_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/writeassets/analytics/content-history.mdfromconfig/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 badgeversion-${VERSION}-blue),:325(plugin CLAUDE.md headerLinkedIn 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.mdat:44).
- Reusable code: the
HOME||USERPROFILEone-liner (state-updater.mjs:11),mkdirSyncrecursive auto-init (session-start.mjs:392),findPluginRoot(storage.ts:18),storage-root.test.ts(env save/restoreafterEach:14-26, default+override assertions:50-75), the atomic tmp+rename write instate-updater.mjs:353-355. - External tech (researched): none — Node
fs/path/urlbuiltins + TypeScript +tsx, all already in the codebase. Noresearch-scoutneeded. - 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.shis the hottest M0 file (12 changes).state-updater.mjsis 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
Commit-type discipline (verified against the docs-gate hook +
feedback_feat_commit_docs_gate): intermediate steps userefactor/test/docs/chore(linkedin-studio):so thefeat:-only 3-doc gate does NOT fire per-step. The singlefeat(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
.mjsresolver. Export namedgetDataRoot(subdir = '')returningsubdir ? join(dataBase, subdir) : dataBase(the no-arg/empty form returns the root itself — needed by Step 9's REMEMBER write) andgetStateFile().dataBaseresolves as:process.env.LINKEDIN_STUDIO_DATAelsejoin(HOME, '.claude', 'linkedin-studio'), whereHOME = process.env.HOME || process.env.USERPROFILE; if BOTH are empty, fall back toos.homedir()(NOT silent|| ''relative-path trap — risk-assessor Low #2).getStateFile():process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md')(mirrorsstate-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||USERPROFILEidiom (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_DATAoverride wins;getStateFile()default +STATE_FILEoverride; empty-HOME →os.homedir()not''. StubsHOME/LINKEDIN_STUDIO_DATA/STATE_FILEvia env save/restore inafterEach. - Pattern:
scripts/analytics/tests/storage-root.test.ts:14-75, expressed withnode:test+node:assert/strict
- File:
- 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:
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): stringwhose default isjoin(HOME, '.claude', 'linkedin-studio', subdir)(external), honoringprocess.env.LINKEDIN_STUDIO_DATA. RedefinegetAnalyticsRoot()asgetDataRoot('analytics')but keep honoring the deprecatedANALYTICS_ROOTalias (D4) socli.ts:444is unchanged. PreservefindPluginRoot+ the.claude-plugin/plugin.jsonmarker (:18-33) — it now locates bundled read-only assets only (brief §7.2; commit798484b). Keep the empty-on-absence contract (:76-78). Add the CANONICAL twin header. Adaptstorage-root.test.ts: change the default assertion (:57-75) to the external~/.claude/linkedin-studio/analytics, stubbingHOME+LINKEDIN_STUDIO_DATAwith save/restore exactly as the.mjstest does (M4 — the default must not couple to the dev's real$HOME); add anLINKEDIN_STUDIO_DATAoverride test- an
ANALYTICS_ROOTback-compat-alias test; keep thefindPluginRootmarker tests unchanged.
- an
- Reuses: existing
getAnalyticsRootbody (:40-50),findPluginRoot(:18-33),savedEnvsave/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_DATAoverride;ANALYTICS_ROOTalias;findPluginRootmarker-walk unchanged - Pattern: itself (
:49-75)
- File:
- 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:
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.mjsresolver produces the SAME default + override semantics documented for the TS twin (the TS twin cannot be imported into a no-tsx.mjsrun, 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:
.mjsdefault +LINKEDIN_STUDIO_DATAoverride match the TS twin's documented contract literals - Pattern: the env save/restore block from Step 1
- File:
- 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:
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 → externalvoice-samples/authentic-voice-samples.md, D6, NOT the placeholder),assets/drafts/queue.json, the 3 analytics files, andassets/analytics/content-history.mdif present (B1) — atomic MOVE (copy→fsync→verify size+content→renameSynctemp→final→unlinkSyncsource); (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 viamkdirSync(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.migratedmarker LAST so the whole run is idempotently skippable. Handle the expected-but-absent set (config/user-profile.local.md, emptymonthly-reports/,drafts/week-*) as clean no-ops.node:-builtins only. - Reuses: atomic tmp+rename (
state-updater.mjs:353-355),mkdirSyncrecursive (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.md227-line content atauthentic-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,.migratedshort-circuits); (d) empty fixture→no error; (e) collision→external kept, in-plugin source not clobbered. All againstmkdtempSyncfixtures withLINKEDIN_STUDIO_DATAstubbed — never the real$HOME. - Pattern:
scripts/analytics/tests/storage.test.ts:87-96(tempdir +afterEach)
- File:
- 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:
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) withimport { getDataRoot } from './data-root.mjs'andQUEUE_FILE = join(getDataRoot('drafts'), 'queue.json'). Keep themkdirSync(...,{recursive:true})first-write (:16). - Reuses:
getDataRoot(Step 1), existingmkdirSyncauto-init (:16). - Test first:
- File:
hooks/scripts/__tests__/queue-manager.test.mjs(new) - Verifies: with
LINKEDIN_STUDIO_DATAset to a tempdir, queue read/write lands under<tempdir>/drafts/queue.json, not the plugin tree - Pattern:
personalization-score.test.mjs:18-22
- File:
- 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:
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) withgetDataRoot('analytics')+'exports'(the hard-bypass that desyncs the instant Step 2 lands). Fix the printed example command (:66) that teaches the OLDANALYTICS_ROOT="<plugin>/assets/analytics"path → emit the external/LINKEDIN_STUDIO_DATAform. Keep eagermkdirSync(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/analyticspath - Pattern:
personalization-score.test.mjs:18-22
- File:
- 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:
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 singlepluginRootparam (:13). Split the concerns: instance data (voice, profile, scaffolds) reads fromgetDataRoot(...); template fallback still reads from the plugin tree. ResolvedataRootinternally viagetDataRoot, keep apluginRootparam for template lookups. Update both callers:session-start.mjs:190and the standalone block (:121-122). Update the fixturemakePluginRoot()(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); theexistsSync-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 externalprofile/user-profile.md→ 0 not crash (NEW case, brief §8 inv.3) - Pattern:
:38-56
- File:
- 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:
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) withgetDataRoot('voice-samples')'authentic-voice-samples.md', existence-guarded as today (:103). Move the state-file read (:108) ontogetStateFile()(read-only, guarded). Delivers SC7 (real-artefact read).
- Reuses:
getDataRoot/getStateFile(Step 1); existingexistsSyncguard (: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
- File:
- 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:
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 tojoin(getDataRoot(), 'REMEMBER.md')(the no-arg root form from Step 1) while still seeding from in-pluginconfig/REMEMBER.template.md. Use the samemkdirSync(...,{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_DATAintegration check - Verifies: REMEMBER.md materializes under the external root, not
PLUGIN_ROOT - Pattern:
personalization-score.test.mjs:18-22
- File:
- 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:
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
.claudeis ininfraDirs(:27) and:30returnsfalsefor any path containing/.claude/— firing BEFORE the positiveassets/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 theinfraDirsloop (:27-31). Keep the existing in-pluginassets/drafts/positive (:34) for back-compat during the migration window. - Reuses: existing positive/negative structure (
:14-39); the.local.mdexclusion (: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-pluginassets/drafts/x.md→ still true - Pattern: existing cases (
:13-22)
- File:
- 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:
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 incommands/import.md(:126,243,244) andcommands/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) Repointstate-update-reminder.md:71socontent-history.mdinitializes/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:
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 insession-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:
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.mdheld 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 indocs/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
- File:
- 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:
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.mdexternal-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 -rguards 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:
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 inab-test.md/measure.md/linkedin.mdprose) andplans/(inaudit.md,batch.md,analytics-interpreter.md,content-planner.mdprose) — 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:
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) forreferences/data-path-convention.md(the:73glob count must match or the lint fails:87); assert thereferences/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 pinsANALYTICS_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 --porcelainfiltered to user-data path classes (from.gitignore, incl.content-history.md) must be empty. (e) Record pre-M0 assertion countNas baseline; require post-M0N≥ baseline (brief-reviewer assumption 3). KeepPassed/Failed = N/0. - Reuses: the self-test pattern (
:255-295), theSTAT_HITSgrep-and-assert-empty idiom (:297-303), theEXPECT_*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:
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 indata-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(expectfail 0) ·cd scripts/analytics && npm test(expectpass 116+,fail 0, tsc clean) ·bash scripts/test-runner.sh(expectFailed: 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:
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 every0.4.0hit, and specifically the three literal strings the lint greps (m1/m2): the plugin README badgeversion-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:315reads it, does not declare it).git addMUST be a separate Bash call beforegit commitso 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) ANDgrep -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:
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/stricteverywhere. Hooks: zero-npm-dep.mjsunderhooks/scripts/__tests__/, runnode --test hooks/scripts/__tests__/*.test.mjs(glob form —node --test <dir>fails MODULE_NOT_FOUND on Node 25). Analytics: TS viatsx,cd scripts/analytics && npm test. Structure lint:bash scripts/test-runner.sh. - Existing patterns:
storage-root.test.ts(env save/restoreafterEach+ 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), extendedpersonalization-score.test.mjs(profile-absent + scaffold fallback), extendedlinkedin-content-filter.test.mjs(external content dirs), adaptedstorage-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 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 ` |
| 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/<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_ROOTalias (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 --porcelainfiltered 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, baselineNnot 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)
- Touch:
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
/linkedincontent/analytics commands until Session 3 completes.
- Touch:
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.
- Touch:
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 incommands/,agents/,skills/,references/,hooks/prompts/ - Never touch: any
.mjs/.tscode, any test
- Touch:
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)
- Touch:
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=<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 |