docs(linkedin-studio): M0 implementation plan (18 steps, adversarially reviewed + revised)
/trekplan on docs/m0/brief.md -> docs/m0/plan.md. 8 exploration agents. plan-critic REPLAN on v1 (3 blockers + 5 majors + 5 minors); all 13 closed. scope-guardian ALIGNED (SC1-SC7 + D1-D6 covered). Validator 18 steps / 0 errors. Local only - not pushed (planning push-policy). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c0e8ff256a
commit
56606317d9
1 changed files with 967 additions and 0 deletions
967
docs/m0/plan.md
Normal file
967
docs/m0/plan.md
Normal file
|
|
@ -0,0 +1,967 @@
|
||||||
|
# M0 — Per-User Data-Dir Migration · Implementation Plan
|
||||||
|
|
||||||
|
> **Plan quality: A** (90/100) — APPROVE_WITH_NOTES (post-revision; 3 blockers + 5 majors + 5 minors from adversarial review resolved)
|
||||||
|
>
|
||||||
|
> Generated by trekplan v5.1.1 on 2026-06-01 — `plan_version: 1.7`
|
||||||
|
>
|
||||||
|
> Input: `docs/m0/brief.md` (RATIFIED 2026-06-01, decisions 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<br/>getDataRoot(subdir) · getDataRoot() root · getStateFile()<br/>NEW"]
|
||||||
|
TS["scripts/analytics/src/utils/storage.ts<br/>getAnalyticsRoot → getDataRoot('analytics')<br/>default FLIPPED to external"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph hooks["Hook seams (repointed to data-root.mjs)"]
|
||||||
|
QM["queue-manager.mjs"]
|
||||||
|
QI["quick-import.mjs (gains override)"]
|
||||||
|
PS["personalization-score.mjs<br/>(dataRoot vs pluginRoot split)"]
|
||||||
|
UPC["user-prompt-context.mjs (voice read)"]
|
||||||
|
SS["session-start.mjs<br/>(REMEMBER fix + migration wire-in)"]
|
||||||
|
FILT["linkedin-content-filter.mjs<br/>(recognize external content dirs)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph prose["Prose pins repointed (B2)"]
|
||||||
|
IMP["commands/import.md + report.md<br/>(drop explicit ANALYTICS_ROOT=<plugin> pins)"]
|
||||||
|
SUR["hooks/prompts/state-update-reminder.md<br/>(content-history → external)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph migration["Migration (NEW, atomic + locked + idempotent)"]
|
||||||
|
MIG["migrate-data.mjs<br/>copy→fsync→verify→unlink, lockdir, external-canonical"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph bundled["In-plugin (read-only seed, STAYS)"]
|
||||||
|
TMPL["config/*.template.* · assets/**/*-template.md · render/fonts"]
|
||||||
|
MARKER[".claude-plugin/plugin.json (findPluginRoot marker, PRESERVED)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MJS --> external
|
||||||
|
TS --> external
|
||||||
|
QM --> MJS
|
||||||
|
QI --> MJS
|
||||||
|
PS --> MJS
|
||||||
|
UPC --> MJS
|
||||||
|
SS --> MJS
|
||||||
|
SS --> MIG
|
||||||
|
IMP -.external default.-> external
|
||||||
|
SUR -.external default.-> external
|
||||||
|
MIG --> external
|
||||||
|
PS -.fallback.-> TMPL
|
||||||
|
TS -.bundled assets.-> MARKER
|
||||||
|
FILT -.classifies.-> DRAFTS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Codebase Analysis
|
||||||
|
|
||||||
|
- **Tech stack:** Dual-runtime. (a) **Hooks** — Node.js ESM `.mjs`, **zero npm deps**
|
||||||
|
(marketplace hard rule), `node:`-builtins only, run directly by Node 25. (b) **Analytics
|
||||||
|
CLI** — TypeScript via `tsx`, one dep (`csv-parse`), `node --import tsx --test`.
|
||||||
|
- **Key patterns:**
|
||||||
|
- **Template→instance** (the one correct pattern, to generalize): read-only template in
|
||||||
|
plugin (`config/state-file.template.md`), instance external (`~/.claude/`), auto-init
|
||||||
|
copies template→instance with `mkdirSync(...,{recursive:true})` on first run —
|
||||||
|
`hooks/scripts/session-start.mjs:388-404`.
|
||||||
|
- **`HOME || USERPROFILE` idiom** (5 sites — brief said 4): `session-start.mjs:14`,
|
||||||
|
`state-updater.mjs:11`, `quick-import.mjs:12`, `user-prompt-context.mjs:21`,
|
||||||
|
`posting-reminder.mjs:12`.
|
||||||
|
- **`PLUGIN_ROOT` derivation** `join(__dirname,'..','..')` (9 literal sites; ~7
|
||||||
|
data-relevant) — the duplication `data-root.mjs` consolidates.
|
||||||
|
- **Named exports only** (no `export default` in `hooks/scripts/`); standalone-guard
|
||||||
|
`if (process.argv[1] === fileURLToPath(import.meta.url))` (`personalization-score.mjs:120`).
|
||||||
|
- **`findPluginRoot` / `.claude-plugin/plugin.json` marker** (`storage.ts:18-33`) —
|
||||||
|
locates the plugin root by walking up to the marker; **must be preserved** for bundled
|
||||||
|
read-only assets (commit `798484b`, the fresh-clone crash fix).
|
||||||
|
- **Relevant files (all verified to exist, file:line):**
|
||||||
|
- `scripts/analytics/src/utils/storage.ts:40-50` — `getAnalyticsRoot()` (the TS seam;
|
||||||
|
sole *executable* caller `cli.ts:444`; all storage fns already take `root` param → blast
|
||||||
|
radius = one function body, VERIFIED). **But prose pins it:** `commands/import.md:126,243,244`
|
||||||
|
and `commands/report.md:101,111,121,126,154,168,172,359,362,365` set
|
||||||
|
`ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"` explicitly (~14 pins).
|
||||||
|
- `hooks/scripts/queue-manager.mjs:11-12`, `quick-import.mjs:11-13` (no env override) `:66`
|
||||||
|
(stale printed path), `personalization-score.mjs:13,23-114,120-122`,
|
||||||
|
`user-prompt-context.mjs:20,102-105`, `session-start.mjs:13-15,190,388-404,408-417`,
|
||||||
|
`linkedin-content-filter.mjs:20,23-24,27,30,34`.
|
||||||
|
- `hooks/prompts/state-update-reminder.md:71` — Claude-prose instruction to init/write
|
||||||
|
`assets/analytics/content-history.md` from `config/content-history.template.md` (679 B,
|
||||||
|
exists). A real in-plugin user-data write with NO executable seam (analogous to ab-tests).
|
||||||
|
- `scripts/analytics/tests/storage-root.test.ts` (77 lines, the regression-lock template),
|
||||||
|
`hooks/scripts/__tests__/personalization-score.test.mjs` (`makePluginRoot():18-22`),
|
||||||
|
`scripts/analytics/tests/storage.test.ts` (empty-data clean-exit evidence).
|
||||||
|
- `scripts/test-runner.sh:44-47` (`EXPECT_AGENTS=19 EXPECT_COMMANDS=29 EXPECT_REFS=25
|
||||||
|
EXPECT_SKILLS=6`), refs glob `:73`, refs assert `:87`, version reads `:315`, version
|
||||||
|
consistency greps `:320` (README badge `version-${VERSION}-blue`), `:325` (plugin CLAUDE.md
|
||||||
|
header `LinkedIn Studio Plugin (v${VERSION})`), `:330` (CHANGELOG `## [${VERSION}]`),
|
||||||
|
self-test non-vacuity `:255-295,391-419`, pass/fail `:37-38,461-476`.
|
||||||
|
- `.gitignore:7,11,14,37,38,41-44,54,55` — the user-data class manifest (incl. `content-history.md` at `:44`).
|
||||||
|
- **Reusable code:** the `HOME||USERPROFILE` one-liner (`state-updater.mjs:11`),
|
||||||
|
`mkdirSync` recursive auto-init (`session-start.mjs:392`), `findPluginRoot`
|
||||||
|
(`storage.ts:18`), `storage-root.test.ts` (env save/restore `afterEach` `:14-26`,
|
||||||
|
default+override assertions `:50-75`), the atomic tmp+rename write in `state-updater.mjs:353-355`.
|
||||||
|
- **External tech (researched):** none — Node `fs`/`path`/`url` builtins + TypeScript +
|
||||||
|
`tsx`, all already in the codebase. No `research-scout` needed.
|
||||||
|
- **Recent git activity:** single author, single branch `main`, clean working tree for all
|
||||||
|
M0 code surface. The 4 cold seams (`queue-manager`, `quick-import`, `session-start`,
|
||||||
|
`user-prompt-context`) are untouched since the v3.0.0 rename → low-conflict edit surface.
|
||||||
|
`test-runner.sh` is the hottest M0 file (12 changes). `state-updater.mjs` is volatile (6
|
||||||
|
changes) but is the **read-only reference pattern**, not a move target — touching it = scope
|
||||||
|
creep (brief §13).
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
> **Ordering invariant (the spine of this revised plan, post-review B2/B3):** the migration
|
||||||
|
> RUNS exactly once at **Step 12** — *after* every reader (code seams Steps 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 `<tempdir>/drafts/queue.json`, not the plugin tree
|
||||||
|
- Pattern: `personalization-score.test.mjs:18-22`
|
||||||
|
- **Verify:** `node --test hooks/scripts/__tests__/queue-manager.test.mjs` → expected: `pass`, `fail 0`
|
||||||
|
- **On failure:** revert — `git checkout -- hooks/scripts/queue-manager.mjs hooks/scripts/__tests__/queue-manager.test.mjs`
|
||||||
|
- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-5 — queue-manager.mjs via getDataRoot('drafts')"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- hooks/scripts/queue-manager.mjs
|
||||||
|
min_file_count: 1
|
||||||
|
commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-5"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain:
|
||||||
|
- path: hooks/scripts/queue-manager.mjs
|
||||||
|
pattern: "getDataRoot"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Repoint quick-import.mjs and fix the printed example command
|
||||||
|
|
||||||
|
- **Files:** `hooks/scripts/quick-import.mjs`, `hooks/scripts/__tests__/quick-import.test.mjs` (new)
|
||||||
|
- **Changes:** Replace the no-override `EXPORTS_DIR` (`:11-13`) with `getDataRoot('analytics')` +
|
||||||
|
`'exports'` (the hard-bypass that desyncs the instant Step 2 lands). Fix the printed example
|
||||||
|
command (`:66`) that teaches the OLD `ANALYTICS_ROOT="<plugin>/assets/analytics"` path → emit
|
||||||
|
the external/`LINKEDIN_STUDIO_DATA` form. Keep eager `mkdirSync(EXPORTS_DIR)` (`:18`).
|
||||||
|
- **Reuses:** `getDataRoot` (Step 1).
|
||||||
|
- **Test first:**
|
||||||
|
- File: `hooks/scripts/__tests__/quick-import.test.mjs` (new)
|
||||||
|
- Verifies: exports dir resolves under external root; the printed command no longer contains a bare in-plugin `assets/analytics` path
|
||||||
|
- Pattern: `personalization-score.test.mjs:18-22`
|
||||||
|
- **Verify:** `node --test hooks/scripts/__tests__/quick-import.test.mjs` → expected: `pass`, `fail 0`
|
||||||
|
- **On failure:** revert — `git checkout -- hooks/scripts/quick-import.mjs hooks/scripts/__tests__/quick-import.test.mjs`
|
||||||
|
- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-6 — quick-import.mjs via getDataRoot + fix printed path"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- hooks/scripts/quick-import.mjs
|
||||||
|
min_file_count: 1
|
||||||
|
commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-6"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain:
|
||||||
|
- path: hooks/scripts/quick-import.mjs
|
||||||
|
pattern: "getDataRoot"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Split personalization-score.mjs lookups into dataRoot vs pluginRoot
|
||||||
|
|
||||||
|
- **Files:** `hooks/scripts/personalization-score.mjs`, `hooks/scripts/session-start.mjs`, `hooks/scripts/__tests__/personalization-score.test.mjs`
|
||||||
|
- **Changes:** The scorer reads 8 category paths (`:23,34,45,60,75,84,95,106`) relative to a single
|
||||||
|
`pluginRoot` param (`:13`). Split the concerns: **instance data** (voice, profile, scaffolds)
|
||||||
|
reads from `getDataRoot(...)`; **template fallback** still reads from the plugin tree. Resolve
|
||||||
|
`dataRoot` internally via `getDataRoot`, keep a `pluginRoot` param for template lookups. Update
|
||||||
|
both callers: `session-start.mjs:190` and the standalone block (`:121-122`). Update the fixture
|
||||||
|
`makePluginRoot()` (`personalization-score.test.mjs:18-22`) to seed instance data under the
|
||||||
|
external location and drive the scorer through the resolver. risk-assessor HIGH #3 — both call
|
||||||
|
sites + fixture change in this one step.
|
||||||
|
- **Reuses:** `getDataRoot` (Step 1); the `existsSync`-guarded read pattern (preserves degradation
|
||||||
|
invariant 3); the fixture shape.
|
||||||
|
- **Test first:**
|
||||||
|
- File: `hooks/scripts/__tests__/personalization-score.test.mjs` (existing — adapt + extend)
|
||||||
|
- Verifies: placeholder-with-sentinel (external `voice-samples/`) → 0; real voice → 25; profile
|
||||||
|
absent at external `profile/user-profile.md` → 0 not crash (NEW case, brief §8 inv.3)
|
||||||
|
- Pattern: `:38-56`
|
||||||
|
- **Verify:** `node --test hooks/scripts/__tests__/personalization-score.test.mjs` → expected: `pass`, `fail 0`
|
||||||
|
- **On failure:** revert — `git checkout -- hooks/scripts/personalization-score.mjs hooks/scripts/session-start.mjs hooks/scripts/__tests__/personalization-score.test.mjs`
|
||||||
|
- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-7 — scorer dataRoot/pluginRoot split + both callers (HIGH #3)"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- hooks/scripts/personalization-score.mjs
|
||||||
|
- hooks/scripts/session-start.mjs
|
||||||
|
- hooks/scripts/__tests__/personalization-score.test.mjs
|
||||||
|
min_file_count: 3
|
||||||
|
commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-7"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths:
|
||||||
|
- hooks/scripts/state-updater.mjs
|
||||||
|
must_contain:
|
||||||
|
- path: hooks/scripts/personalization-score.mjs
|
||||||
|
pattern: "getDataRoot"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Repoint user-prompt-context.mjs voice read to the resolver
|
||||||
|
|
||||||
|
- **Files:** `hooks/scripts/user-prompt-context.mjs`, `hooks/scripts/__tests__/user-prompt-context.test.mjs` (new)
|
||||||
|
- **Changes:** Replace the in-plugin voice-file existence check (`:102`) with `getDataRoot('voice-samples')`
|
||||||
|
+ `'authentic-voice-samples.md'`, existence-guarded as today (`:103`). Move the state-file read
|
||||||
|
(`:108`) onto `getStateFile()` (read-only, guarded). Delivers SC7 (real-artefact read).
|
||||||
|
- **Reuses:** `getDataRoot`/`getStateFile` (Step 1); existing `existsSync` guard (`:103`).
|
||||||
|
- **Test first:**
|
||||||
|
- File: `hooks/scripts/__tests__/user-prompt-context.test.mjs` (new)
|
||||||
|
- Verifies: real external voice present → context contains the voice reference; only in-plugin placeholder (external absent) → degradation. SC7.
|
||||||
|
- Pattern: `personalization-score.test.mjs:18-22`
|
||||||
|
- **Verify:** `node --test hooks/scripts/__tests__/user-prompt-context.test.mjs` → expected: `pass`, `fail 0`
|
||||||
|
- **On failure:** revert — `git checkout -- hooks/scripts/user-prompt-context.mjs hooks/scripts/__tests__/user-prompt-context.test.mjs`
|
||||||
|
- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-8 — voice read via getDataRoot (SC7)"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- hooks/scripts/user-prompt-context.mjs
|
||||||
|
- hooks/scripts/__tests__/user-prompt-context.test.mjs
|
||||||
|
min_file_count: 2
|
||||||
|
commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-8"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain:
|
||||||
|
- path: hooks/scripts/user-prompt-context.mjs
|
||||||
|
pattern: "getDataRoot"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Move the REMEMBER.md write out of the plugin tree
|
||||||
|
|
||||||
|
- **Files:** `hooks/scripts/session-start.mjs`, `hooks/scripts/__tests__/session-start-remember.test.mjs` (new)
|
||||||
|
- **Changes:** Fix the anti-pattern at `:408-417`: `rememberFile = join(PLUGIN_ROOT,'REMEMBER.md')`
|
||||||
|
writes inside the plugin. Repoint to `join(getDataRoot(), 'REMEMBER.md')` (the no-arg root form
|
||||||
|
from Step 1) while still seeding from in-plugin `config/REMEMBER.template.md`. Use the same
|
||||||
|
`mkdirSync(...,{recursive:true})` + copy pattern the state-file init uses 16 lines above
|
||||||
|
(`:392-393`). Leave the state auto-init (`:388-404`) unchanged.
|
||||||
|
- **Reuses:** `getDataRoot()` root form (Step 1); the state-file template→instance flow (`:388-404`).
|
||||||
|
- **Test first:**
|
||||||
|
- File: `hooks/scripts/__tests__/session-start-remember.test.mjs` (new) — exercise the extracted REMEMBER-init helper if feasible; else a tempdir-`LINKEDIN_STUDIO_DATA` integration check
|
||||||
|
- Verifies: REMEMBER.md materializes under the external root, not `PLUGIN_ROOT`
|
||||||
|
- Pattern: `personalization-score.test.mjs:18-22`
|
||||||
|
- **Verify:** `node --test hooks/scripts/__tests__/session-start-remember.test.mjs` → expected: `pass`, `fail 0`
|
||||||
|
- **On failure:** revert — `git checkout -- hooks/scripts/session-start.mjs hooks/scripts/__tests__/session-start-remember.test.mjs`
|
||||||
|
- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-9 — REMEMBER.md materializes external (anti-pattern fix)"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- hooks/scripts/session-start.mjs
|
||||||
|
min_file_count: 1
|
||||||
|
commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-9"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain:
|
||||||
|
- path: hooks/scripts/session-start.mjs
|
||||||
|
pattern: "getDataRoot"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10: Teach linkedin-content-filter.mjs to recognize external content dirs
|
||||||
|
|
||||||
|
- **Files:** `hooks/scripts/linkedin-content-filter.mjs`, `hooks/scripts/__tests__/linkedin-content-filter.test.mjs`
|
||||||
|
- **Changes:** Today `.claude` is in `infraDirs` (`:27`) and `:30` returns `false` for any path
|
||||||
|
containing `/.claude/` — firing BEFORE the positive `assets/drafts/` check (`:34`), so relocated
|
||||||
|
drafts at `~/.claude/linkedin-studio/drafts/` mis-classify as non-content and the PreToolUse
|
||||||
|
quality gate + voice guardian stop firing (break confirmed by direct read). **Fix (M1 — exact
|
||||||
|
placement):** insert a positive branch recognizing the external **content** subdir
|
||||||
|
`/.claude/linkedin-studio/drafts/` (the only data class the Write/Edit quality gate guards;
|
||||||
|
voice-samples/analytics/profile are NOT editor-gated content) **after** the `.local.md` +
|
||||||
|
`nonContent`-basename negatives (`:23-24`, which already exclude the state file + `REMEMBER.md`)
|
||||||
|
and **before** the `infraDirs` loop (`:27-31`). Keep the existing in-plugin `assets/drafts/`
|
||||||
|
positive (`:34`) for back-compat during the migration window.
|
||||||
|
- **Reuses:** existing positive/negative structure (`:14-39`); the `.local.md` exclusion (`:23`).
|
||||||
|
- **Test first:**
|
||||||
|
- File: `hooks/scripts/__tests__/linkedin-content-filter.test.mjs` (existing — extend)
|
||||||
|
- Verifies: `~/.claude/linkedin-studio/drafts/x.md` → true; `~/.claude/linkedin-studio.local.md` → false (state); `~/.claude/linkedin-studio/REMEMBER.md` → false; `~/.claude/settings.json` → false; in-plugin `assets/drafts/x.md` → still true
|
||||||
|
- Pattern: existing cases (`:13-22`)
|
||||||
|
- **Verify:** `node --test hooks/scripts/__tests__/linkedin-content-filter.test.mjs` → expected: `pass`, `fail 0`
|
||||||
|
- **On failure:** revert — `git checkout -- hooks/scripts/linkedin-content-filter.mjs hooks/scripts/__tests__/linkedin-content-filter.test.mjs`
|
||||||
|
- **Checkpoint:** `git commit -m "refactor(linkedin-studio): M0-10 — content-filter recognizes external drafts (after negatives, before infraDirs)"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- hooks/scripts/linkedin-content-filter.mjs
|
||||||
|
- hooks/scripts/__tests__/linkedin-content-filter.test.mjs
|
||||||
|
min_file_count: 2
|
||||||
|
commit_message_pattern: "^refactor\\(linkedin-studio\\): M0-10"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain:
|
||||||
|
- path: hooks/scripts/linkedin-content-filter.mjs
|
||||||
|
pattern: "linkedin-studio/drafts"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 11: Drop the in-plugin ANALYTICS_ROOT prose pins and repoint content-history
|
||||||
|
|
||||||
|
- **Files:** `commands/import.md`, `commands/report.md`, `hooks/prompts/state-update-reminder.md`
|
||||||
|
- **Changes:** (B2) Remove the explicit `ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"`
|
||||||
|
prefix from the ~14 CLI invocations in `commands/import.md` (`:126,243,244`) and `commands/report.md`
|
||||||
|
(`:101,111,121,126,154,168,172,359,362,365`) so the Step-2 external default applies (the CLI
|
||||||
|
resolves to external automatically; keep the `${CLAUDE_PLUGIN_ROOT}/scripts/analytics/...` tooling
|
||||||
|
paths — those are in-plugin executables, not data). (B1) Repoint `state-update-reminder.md:71` so
|
||||||
|
`content-history.md` initializes/writes under the external analytics subdir (referencing the
|
||||||
|
Step-13 path convention or `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/content-history.md`).
|
||||||
|
These land in the SAME session as Step 12 so no committed state has prose pointing in-plugin after
|
||||||
|
the data moves out.
|
||||||
|
- **Reuses:** the external default from Step 2; the `${LTL_SERIES_ROOT:-...}` prose pattern (`newsletter.md:46,148-149`).
|
||||||
|
- **Test first:** *(prose — verified by the Step 16 lint assertions, not a unit test)*
|
||||||
|
- **Verify:** `! grep -rn 'ANALYTICS_ROOT="\${CLAUDE_PLUGIN_ROOT}/assets/analytics"' commands/ && ! grep -n 'assets/analytics/content-history.md' hooks/prompts/state-update-reminder.md` → expected: no matches (all pins dropped, content-history repointed)
|
||||||
|
- **On failure:** revert — `git checkout -- commands/import.md commands/report.md hooks/prompts/state-update-reminder.md`
|
||||||
|
- **Checkpoint:** `git commit -m "docs(linkedin-studio): M0-11 — drop in-plugin ANALYTICS_ROOT pins + repoint content-history (B1/B2)"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- commands/import.md
|
||||||
|
- commands/report.md
|
||||||
|
- hooks/prompts/state-update-reminder.md
|
||||||
|
min_file_count: 3
|
||||||
|
commit_message_pattern: "^docs\\(linkedin-studio\\): M0-11"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain: []
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 12: Wire migrateData into session-start and run the migration once
|
||||||
|
|
||||||
|
- **Files:** `hooks/scripts/session-start.mjs`
|
||||||
|
- **Changes:** Import `migrateData` (Step 4) and call it early in `session-start.mjs` — before the
|
||||||
|
score/status computation that reads moved paths — logging once on a real move, silent no-op
|
||||||
|
otherwise (D5). Then **run the migration once now** as part of execution
|
||||||
|
(`node hooks/scripts/migrate-data.mjs`) so the operator's real data (the 5 gitignored files +
|
||||||
|
content-history + the 6 scaffold copies; the 227-line voice file → external `.md`) is externalized
|
||||||
|
immediately. **This is the ordering hinge:** every reader already points external (Steps 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 -- <family files>`; re-apply per category
|
||||||
|
- **Checkpoint:** `git commit -m "docs(linkedin-studio): M0-15 — repoint remaining prose families + route ab-tests/plans"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- commands/ab-test.md
|
||||||
|
- commands/report.md
|
||||||
|
min_file_count: 2
|
||||||
|
commit_message_pattern: "^docs\\(linkedin-studio\\): M0-15"
|
||||||
|
bash_syntax_check: []
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain: []
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 16: Extend test-runner.sh with the layout, no-bare-path, and no-pin assertions
|
||||||
|
|
||||||
|
- **Files:** `scripts/test-runner.sh`
|
||||||
|
- **Changes:** (a) Bump `EXPECT_REFS=25` → `26` (`:46`) for `references/data-path-convention.md`
|
||||||
|
(the `:73` glob count must match or the lint fails `:87`); assert the `references/` delta is
|
||||||
|
**exactly +1** so an incidental extra ref doc cannot pass silently (m3). (b) Add Section 13
|
||||||
|
asserting **no command prose references a bare in-plugin user-data path** (R1, brief §12.6) — model
|
||||||
|
the non-vacuity self-test (`:255-295,391-419`). (c) Add a sibling assertion that **no prose pins
|
||||||
|
`ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"`** (M3 — the no-bare-path grep does NOT
|
||||||
|
catch alias pins). (d) Add the SC2 dry-run: after a representative content + analytics flow,
|
||||||
|
`git status --porcelain` filtered to user-data path classes (from `.gitignore`, incl.
|
||||||
|
`content-history.md`) must be empty. (e) **Record pre-M0 assertion count `N` as baseline; require
|
||||||
|
post-M0 `N` ≥ baseline** (brief-reviewer assumption 3). Keep `Passed/Failed = N/0`.
|
||||||
|
- **Reuses:** the self-test pattern (`:255-295`), the `STAT_HITS` grep-and-assert-empty idiom (`:297-303`), the `EXPECT_*` contract (`:44-47`).
|
||||||
|
- **Test first:** *(the lint IS the test — its Section-13 self-test is the red→green)*
|
||||||
|
- **Verify:** `bash scripts/test-runner.sh` → expected: `Passed: N` (≥ baseline), `Failed: 0`, Section 13 self-test passes
|
||||||
|
- **On failure:** revert — `git checkout -- scripts/test-runner.sh`
|
||||||
|
- **Checkpoint:** `git commit -m "test(linkedin-studio): M0-16 — lint: EXPECT_REFS=26 + no-bare-path + no-pin + SC2 dry-run (R1)"`
|
||||||
|
- **Manifest:**
|
||||||
|
```yaml
|
||||||
|
manifest:
|
||||||
|
expected_paths:
|
||||||
|
- scripts/test-runner.sh
|
||||||
|
min_file_count: 1
|
||||||
|
commit_message_pattern: "^test\\(linkedin-studio\\): M0-16"
|
||||||
|
bash_syntax_check:
|
||||||
|
- scripts/test-runner.sh
|
||||||
|
forbidden_paths: []
|
||||||
|
must_contain:
|
||||||
|
- path: scripts/test-runner.sh
|
||||||
|
pattern: "EXPECT_REFS=26"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 17: Run the full green gate across all three test surfaces
|
||||||
|
|
||||||
|
- **Files:** *(no production changes — verification + any fixture gaps surfaced)*
|
||||||
|
- **Changes:** Run the complete SC4 degradation + SC5 analytics + SC6 lint + all hook tests
|
||||||
|
together; close any fixture gap surfaced. Confirm 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 <dir>` fails MODULE_NOT_FOUND on Node 25**). Analytics: TS via `tsx`,
|
||||||
|
`cd scripts/analytics && npm test`. Structure lint: `bash scripts/test-runner.sh`.
|
||||||
|
- **Existing patterns:** `storage-root.test.ts` (env save/restore `afterEach` + default/override,
|
||||||
|
`:14-75`); `personalization-score.test.mjs` (`makePluginRoot()` tempdir fixture `:18-22`);
|
||||||
|
`storage.test.ts` (empty-data `[]` clean-exit `:185-191,296-302,417-423`).
|
||||||
|
- **New tests in this plan:** `data-root.test.mjs` (SC1 + R2 + root case), `queue-manager.test.mjs`,
|
||||||
|
`quick-import.test.mjs`, `user-prompt-context.test.mjs` (SC7), `session-start-remember.test.mjs`,
|
||||||
|
`migrate-data.test.mjs` (SC3 — move/copy/idempotent/collision + content-history), extended
|
||||||
|
`personalization-score.test.mjs` (profile-absent + scaffold fallback), extended
|
||||||
|
`linkedin-content-filter.test.mjs` (external content dirs), adapted `storage-root.test.ts`.
|
||||||
|
|
||||||
|
### Tests to write
|
||||||
|
|
||||||
|
| Type | File | Verifies | Model test |
|
||||||
|
|------|------|----------|------------|
|
||||||
|
| Unit | `hooks/scripts/__tests__/data-root.test.mjs` | resolver default+override+root, twin consistency, empty-HOME fallback (SC1/R2) | `storage-root.test.ts:49-75` |
|
||||||
|
| Unit | `scripts/analytics/tests/storage-root.test.ts` (adapt) | external default (HOME-stubbed), `LINKEDIN_STUDIO_DATA` + `ANALYTICS_ROOT` alias (SC1/SC5) | itself |
|
||||||
|
| Unit | `hooks/scripts/__tests__/migrate-data.test.mjs` | move/copy/idempotent/collision; `.local.md` 227-line → external `.md`; content-history moved (SC3/R3/B1) | `storage.test.ts:87-96` |
|
||||||
|
| Unit | `hooks/scripts/__tests__/personalization-score.test.mjs` (extend) | profile-absent→0, scaffold fallback, voice via external (SC4) | `:38-56` |
|
||||||
|
| Unit | `hooks/scripts/__tests__/user-prompt-context.test.mjs` | real external voice → context; placeholder-only → degrade (SC7) | `personalization-score.test.mjs:18-22` |
|
||||||
|
| Unit | `hooks/scripts/__tests__/linkedin-content-filter.test.mjs` (extend) | external drafts→content; state file + REMEMBER→not (Step 10) | `:13-22` |
|
||||||
|
| Lint | `scripts/test-runner.sh` Section 13 | no bare in-plugin path; no `ANALYTICS_ROOT=<plugin>` pin; SC2 git-status empty; refs delta +1 (R1/SC2/SC6) | self-test `:255-295` |
|
||||||
|
|
||||||
|
*The voice-guardian `<5`-sample silent-skip is prompt logic — verified by prompt-path resolution +
|
||||||
|
code review, not a behavioral assertion (test-strategist).*
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Priority | Risk | Location | Impact | Mitigation |
|
||||||
|
|----------|------|----------|--------|------------|
|
||||||
|
| Critical | Auto-migration not atomic — partial move loses the 7.7 KB voice file | `migrate-data.mjs` (Step 4) | Irreversible loss of the user's real profile | copy→fsync→verify→rename→unlink; `.migrated` marker LAST (Step 4 + SC3 test) |
|
||||||
|
| Critical | Concurrent SessionStart (multi-window) race on the same move | `migrate-data.mjs` + wire-in (Steps 4, 12) | Half-moved tree, corruption | Lockdir via atomic `mkdirSync`; loser no-ops (Step 4) |
|
||||||
|
| Critical | Destination-collision on reinstall overwrites newer external data | `migrate-data.mjs` (Step 4) | Stale in-plugin scaffold clobbers external data | External is canonical — never overwrite external from in-plugin; `.migrated` short-circuit (Step 4) |
|
||||||
|
| Critical | Ordering gap — reader points external while data in-plugin (or vice-versa) | Steps 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/<subdir>`, root form, override, twin-consistency pass (SC1, R2)
|
||||||
|
- [ ] `cd scripts/analytics && npm test` → `pass 116+`, `fail 0`, tsc clean; external default (HOME-stubbed) + `ANALYTICS_ROOT` alias (SC1, SC5)
|
||||||
|
- [ ] `node --test hooks/scripts/__tests__/migrate-data.test.mjs` → `fail 0`; move/copy/idempotent/collision + content-history + 227-line voice (SC3, R3, B1)
|
||||||
|
- [ ] On a clean checkout: run a content command + analytics import + report (AFTER Step 12), then `git status --porcelain` filtered to user-data paths (incl. `content-history.md`) is **empty**; files exist under the external dir (SC2)
|
||||||
|
- [ ] `node --test hooks/scripts/__tests__/personalization-score.test.mjs` → `fail 0`; voice sentinel→0, profile-absent→0, scaffold fallback (SC4)
|
||||||
|
- [ ] `node --test hooks/scripts/__tests__/user-prompt-context.test.mjs` → `fail 0`; real external voice → context, placeholder-only → degrade (SC7)
|
||||||
|
- [ ] `bash scripts/test-runner.sh` → `Failed: 0`; `EXPECT_REFS=26`, refs delta +1, Section-13 no-bare-path + no-`ANALYTICS_ROOT=<plugin>`-pin + SC2 dry-run, baseline `N` not net-decreased (SC6, R1, B2)
|
||||||
|
- [ ] `grep -rn "0\.4\.0"` across `.md`/`.json` (excl. CHANGELOG) → no stale version refs after Step 18
|
||||||
|
|
||||||
|
## Estimated Scope
|
||||||
|
|
||||||
|
- **Files to modify:** ~15 code/config (`storage.ts`, 6 hook scripts, `linkedin-content-filter.mjs`,
|
||||||
|
`test-runner.sh`, 4 tracked scaffolds, `import.md`, `report.md`, `state-update-reminder.md`,
|
||||||
|
plugin.json) + ~180 prose lines across ~50 markdown files (D3)
|
||||||
|
- **Files to create:** ~13 (`data-root.mjs`, `migrate-data.mjs`, 6 new test files, 4 D2 templates, `references/data-path-convention.md`)
|
||||||
|
- **Complexity:** high (dual-runtime resolver + atomic/concurrent/idempotent migration with strict
|
||||||
|
ordering + a ~180-ref prose surface). The code core is small and well-bounded; migration safety,
|
||||||
|
the reader-flip ordering, and prose volume are where the risk concentrates.
|
||||||
|
|
||||||
|
## Execution Strategy
|
||||||
|
|
||||||
|
> **Driftsmodell note:** the operator works **interactively** (Claude drives, stops at each
|
||||||
|
> "enige"-point; operator is truth source) and pushes only on explicit OK. Sessions run
|
||||||
|
> **sequentially with operator gates via `/trekcontinue`**, NOT as parallel worktrees. "Wave" =
|
||||||
|
> dependency ordering. Each session ends green + local-committed.
|
||||||
|
|
||||||
|
### Session 1: Resolver foundation + migration tool
|
||||||
|
- **Steps:** 1, 2, 3, 4
|
||||||
|
- **Wave:** 1
|
||||||
|
- **Depends on:** none
|
||||||
|
- **Scope fence:**
|
||||||
|
- Touch: `data-root.mjs`, `storage.ts`, `migrate-data.mjs`, their tests, `storage-root.test.ts`
|
||||||
|
- Never touch: `state-updater.mjs` (read-only reference), prose, the seams (later)
|
||||||
|
|
||||||
|
### Session 2: Repoint the hook seams
|
||||||
|
- **Steps:** 5, 6, 7, 8, 9, 10
|
||||||
|
- **Wave:** 2
|
||||||
|
- **Depends on:** Session 1 (`data-root.mjs`)
|
||||||
|
- **Scope fence:**
|
||||||
|
- Touch: `queue-manager.mjs`, `quick-import.mjs`, `personalization-score.mjs`, `user-prompt-context.mjs`, `session-start.mjs` (REMEMBER + scorer caller), `linkedin-content-filter.mjs`, their tests
|
||||||
|
- Never touch: `state-updater.mjs`, `storage.ts` (done), migration wire-in, prose
|
||||||
|
- **Note:** ends with all hook seams pointing external (empty until Session 3 migration). Do NOT run `/linkedin` content/analytics commands until Session 3 completes.
|
||||||
|
|
||||||
|
### Session 3: Analytics pins → migrate-run → scrub (MUST stay together)
|
||||||
|
- **Steps:** 11, 12, 13
|
||||||
|
- **Wave:** 3
|
||||||
|
- **Depends on:** Sessions 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=<plugin>`-pin lint assertion |
|
||||||
|
| 7 | Adapted `storage-root.test.ts` default assertion `$HOME`-dependent without stubbing | major | Step 2 now stubs `HOME`+`LINKEDIN_STUDIO_DATA` as the `.mjs` test does; Risk Low row |
|
||||||
|
| 8 | `getDataRoot('')` root semantics used (Step 8) but never specified/tested (Step 1) | major | Step 1 defines `getDataRoot(subdir='')` root form + tests it; Step 9 uses the explicit no-arg form |
|
||||||
|
| 9 | Step 17 version source-of-truth ref wrong (`test-runner.sh:315` reads, not declares) | minor | Step 18 corrected: source of truth is `.claude-plugin/plugin.json`; `:315` reads it |
|
||||||
|
| 10 | Step 17 didn't enumerate the 3 version-consistency grep targets | minor | Step 18 names them: README badge `version-0.5.0-blue` (`:320`), CLAUDE.md header `(v0.5.0)` (`:325`), CHANGELOG `## [0.5.0]` (`:330`) |
|
||||||
|
| 11 | EXPECT_REFS=26 assumed refs delta +1, unasserted | minor | Step 16 adds an explicit "references/ delta = +1" assertion |
|
||||||
|
| 12 | Green-gate checkpoint commit could be empty | minor | Step 17 uses `git commit --allow-empty` |
|
||||||
|
| 13 | Step 13 verify `grep | xargs grep -L` empty-input fragile | minor | Step 14 verify uses `xargs -r` |
|
||||||
Loading…
Add table
Add a link
Reference in a new issue