The LAST second-brain slice; the S0–S3e arc is now complete.
(b) Retire the dead, zero-reader content-history.md across its 8 plumbing
surfaces: the flaky Stop-hook writer prose, the template (git-rm'd), the
migrate-data.mjs B1 MOVE entry + its test assertions, .gitignore, the gate
SC2_CLASSES guard, the data-path ref-doc, and a session-start comment. SC1
grep = 0; migrate suite 5/5 (R8 idempotency demonstrated).
(c) `brain reconcile` — read-side triple-post reconciliation joining silo 1
(## Recent Posts, auto-tracked creation) to the silo 2↔3 graph, surfacing the
coverage gap: posts created via the plugin but never `brain ingest`-ed. New
pure core scripts/brain/src/reconcile.ts (parseRecentPosts tracks the WRITER
format state-updater.mjs:116, NOT the date-only pruner; reconcileRecentPosts
matches hook→record.body→graph for the in-graph/in-brain-only/orphaned tiers;
loadRecentPosts reads STATE_FILE via the canonical getStateFile() chain — a new
cross-seam read, the state file lives outside the brain dataRoot). Wired as the
`brain reconcile` CLI subcommand (inline parsePublishedRecord loader, not the
body-less listPublished). Read-only: never writes the state silo.
Tests (verified live): gate 99/0/0 (ASSERT floor 82→84; new Section 16f
self-test + CLI grep) · brain 127/127 (floor 114→127, +13 reconcile) · hook
suite 136/136. SC4/SC6 end-to-end run is a real behavioural pass (STATE_FILE
seam read + fallback). Honest limit: read-side cannot reconstruct un-captured
specifics/trends — auto-capture is the flagged follow-up.
Brief/plan: docs/second-brain/{brief,plan}-sb-s3e.md (fbad29d). Go-before-code
gate cleared (operator: retire · read-side · build-now).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
152 lines
6 KiB
JavaScript
152 lines
6 KiB
JavaScript
#!/usr/bin/env node
|
|
// M0 migration: relocate per-user data from the plugin tree to the external data
|
|
// root (getDataRoot()). Created at Step 4, WIRED into session-start at Step 12 —
|
|
// this module only acts when called. Atomic, locked, idempotent, and
|
|
// external-canonical (never overwrites an existing external file).
|
|
//
|
|
// Two move-classes:
|
|
// (a) gitignored runtime data → atomic MOVE (source removed only after a
|
|
// verified copy).
|
|
// (b) tracked D2 scaffolds → COPY (tracked source preserved; Step 13 later
|
|
// templatizes the in-plugin file).
|
|
//
|
|
// MOVE_FILES/COPY_FILES below are the SINGLE SOURCE OF TRUTH for external paths.
|
|
// Step 7's personalization-score split MUST resolve the same scaffold dests
|
|
// (import COPY_FILES from here, or mirror it) — keep them in lockstep.
|
|
// External dest = in-plugin path minus 'assets/' (the brief 7.1 convention).
|
|
|
|
import {
|
|
existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync,
|
|
renameSync, unlinkSync, rmSync, readdirSync,
|
|
openSync, fsyncSync, closeSync,
|
|
} from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { getDataRoot } from './data-root.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// [in-plugin (rel pluginRoot), external (rel dataRoot)] — atomic MOVE.
|
|
const MOVE_FILES = [
|
|
['assets/voice-samples/authentic-voice-samples.local.md', 'voice-samples/authentic-voice-samples.md'], // D6: drop .local
|
|
['assets/drafts/queue.json', 'drafts/queue.json'],
|
|
['config/user-profile.local.md', 'profile/user-profile.md'], // D1 (expected-absent today)
|
|
];
|
|
|
|
// Whole-directory MOVE: every file under src → matching path under dest.
|
|
const MOVE_DIRS = [
|
|
['assets/analytics/exports', 'analytics/exports'],
|
|
['assets/analytics/posts', 'analytics/posts'],
|
|
['assets/analytics/weekly-reports', 'analytics/weekly-reports'],
|
|
['assets/analytics/monthly-reports', 'analytics/monthly-reports'], // expected-absent
|
|
];
|
|
|
|
// drafts/week-* runtime subdirs → external drafts/week-*.
|
|
const DRAFTS_WEEK = ['assets/drafts', 'drafts', /^week-/];
|
|
|
|
// move-class (b): tracked scaffolds → external instance (COPY, source kept).
|
|
const COPY_FILES = [
|
|
['assets/examples/high-engagement-posts.md', 'examples/high-engagement-posts.md'],
|
|
['assets/audience-insights/demographics.md', 'audience-insights/demographics.md'],
|
|
['assets/audience-insights/engagement-patterns.md', 'audience-insights/engagement-patterns.md'],
|
|
['assets/templates/my-post-templates.md', 'templates/my-post-templates.md'],
|
|
];
|
|
|
|
export { MOVE_FILES, COPY_FILES };
|
|
|
|
// Atomic copy: tmp in dest dir → fsync → byte-verify → rename. Returns false
|
|
// (without touching anything) if dest already exists — external is canonical.
|
|
function atomicCopy(src, dest) {
|
|
if (existsSync(dest)) return false;
|
|
mkdirSync(dirname(dest), { recursive: true });
|
|
const tmp = `${dest}.migrating.${process.pid}`;
|
|
copyFileSync(src, tmp);
|
|
const fd = openSync(tmp, 'r+');
|
|
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
if (!readFileSync(src).equals(readFileSync(tmp))) {
|
|
unlinkSync(tmp);
|
|
throw new Error(`migrate verify failed: ${src}`);
|
|
}
|
|
renameSync(tmp, dest);
|
|
return true;
|
|
}
|
|
|
|
function moveFile(srcAbs, destAbs, log) {
|
|
if (!existsSync(srcAbs)) return; // expected-absent → no-op
|
|
if (atomicCopy(srcAbs, destAbs)) {
|
|
unlinkSync(srcAbs); // remove source only after verified copy
|
|
log.moved.push(destAbs);
|
|
} else {
|
|
log.skipped.push(destAbs); // collision: external kept, source untouched
|
|
}
|
|
}
|
|
|
|
function copyFile(srcAbs, destAbs, log) {
|
|
if (!existsSync(srcAbs)) return;
|
|
if (atomicCopy(srcAbs, destAbs)) log.copied.push(destAbs);
|
|
else log.skipped.push(destAbs); // source always preserved for COPY class
|
|
}
|
|
|
|
function walkFiles(dir) {
|
|
const out = [];
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...walkFiles(p));
|
|
else if (e.isFile()) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function moveDir(srcDir, destDir, log) {
|
|
if (!existsSync(srcDir)) return;
|
|
for (const f of walkFiles(srcDir)) {
|
|
moveFile(f, join(destDir, f.slice(srcDir.length + 1)), log);
|
|
}
|
|
}
|
|
|
|
export function migrateData({ pluginRoot, dataRoot } = {}) {
|
|
pluginRoot = pluginRoot || join(__dirname, '..', '..');
|
|
dataRoot = dataRoot || getDataRoot();
|
|
|
|
const marker = join(dataRoot, '.migrated');
|
|
if (existsSync(marker)) return { status: 'already-migrated', moved: [], copied: [], skipped: [] };
|
|
|
|
mkdirSync(dataRoot, { recursive: true });
|
|
const lockDir = join(dataRoot, '.migrate.lock');
|
|
try {
|
|
mkdirSync(lockDir); // atomic; throws EEXIST if held
|
|
} catch (e) {
|
|
if (e.code === 'EEXIST') return { status: 'locked', moved: [], copied: [], skipped: [] };
|
|
throw e;
|
|
}
|
|
|
|
const log = { status: 'migrated', moved: [], copied: [], skipped: [] };
|
|
try {
|
|
for (const [src, dest] of MOVE_FILES) moveFile(join(pluginRoot, src), join(dataRoot, dest), log);
|
|
for (const [src, dest] of MOVE_DIRS) moveDir(join(pluginRoot, src), join(dataRoot, dest), log);
|
|
|
|
const [wSrc, wDest, wRe] = DRAFTS_WEEK;
|
|
const wSrcAbs = join(pluginRoot, wSrc);
|
|
if (existsSync(wSrcAbs)) {
|
|
for (const e of readdirSync(wSrcAbs, { withFileTypes: true })) {
|
|
if (e.isDirectory() && wRe.test(e.name)) {
|
|
moveDir(join(wSrcAbs, e.name), join(dataRoot, wDest, e.name), log);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [src, dest] of COPY_FILES) copyFile(join(pluginRoot, src), join(dataRoot, dest), log);
|
|
|
|
// Marker LAST → the whole run is idempotently skippable on re-entry.
|
|
writeFileSync(marker, `linkedin-studio M0 data migration\nmoved=${log.moved.length} copied=${log.copied.length} skipped=${log.skipped.length}\n`);
|
|
} finally {
|
|
rmSync(lockDir, { recursive: true, force: true });
|
|
}
|
|
return log;
|
|
}
|
|
|
|
// Standalone: run against the real roots.
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const r = migrateData();
|
|
console.log(`Migration: ${r.status} — moved ${r.moved.length}, copied ${r.copied.length}, skipped ${r.skipped.length}`);
|
|
}
|