Files
everything-claude-code/skills/ck/commands/migrate.mjs
Sreedhara GS 1e226ba556 feat(skill): ck — context-keeper v2, persistent per-project memory
Adds the ck (Context Keeper) skill — deterministic Node.js scripts
that give Claude Code persistent, per-project memory across sessions.

Architecture:
- commands/ — 8 Node.js scripts handle all command logic (init, save,
  resume, info, list, forget, migrate, shared). Claude calls scripts
  and displays output — no LLM interpretation of command logic.
- hooks/session-start.mjs — injects ~100 token compact summary on
  session start (not kilobytes). Detects unsaved sessions, git
  activity since last save, goal mismatch vs CLAUDE.md.
- context.json as source of truth — CONTEXT.md is generated from it.
  Full session history, session IDs, git activity per save.

Commands: /ck:init /ck:save /ck:resume /ck:info /ck:list /ck:forget /ck:migrate
Source: https://github.com/sreedhargs89/context-keeper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:30:39 +09:00

199 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
/**
* ck — Context Keeper v2
* migrate.mjs — convert v1 (CONTEXT.md + meta.json) to v2 (context.json)
*
* Usage:
* node migrate.mjs — migrate all v1 projects
* node migrate.mjs --dry-run — preview without writing
*
* Safe: backs up meta.json to meta.json.v1-backup, never deletes data.
* exit 0: success exit 1: error
*/
import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
import { resolve } from 'path';
import { readProjects, writeProjects, saveContext, today, shortId, CONTEXTS_DIR } from './shared.mjs';
const isDryRun = process.argv.includes('--dry-run');
if (isDryRun) {
console.log('ck migrate — DRY RUN (no files will be written)\n');
}
// ── v1 markdown parsers ───────────────────────────────────────────────────────
function extractSection(md, heading) {
const re = new RegExp(`## ${heading}\\n([\\s\\S]*?)(?=\\n## |$)`);
const m = md.match(re);
return m ? m[1].trim() : null;
}
function parseBullets(text) {
if (!text) return [];
return text.split('\n')
.filter(l => /^[-*\d]\s/.test(l.trim()))
.map(l => l.replace(/^[-*\d]+\.?\s+/, '').trim())
.filter(Boolean);
}
function parseDecisionsTable(text) {
if (!text) return [];
const rows = [];
for (const line of text.split('\n')) {
if (!line.startsWith('|') || line.match(/^[|\s-]+$/)) continue;
const cols = line.split('|').map(c => c.trim()).filter((c, i) => i > 0 && i < 4);
if (cols.length >= 1 && !cols[0].startsWith('Decision') && !cols[0].startsWith('_')) {
rows.push({ what: cols[0] || '', why: cols[1] || '', date: cols[2] || '' });
}
}
return rows;
}
/**
* Parse "Where I Left Off" which in v1 can be:
* - Simple bullet list
* - Multi-session blocks: "Session N (date):\n- bullet\n"
* Returns array of session-like objects {date?, leftOff}
*/
function parseLeftOff(text) {
if (!text) return [{ leftOff: null }];
// Detect multi-session format: "Session N ..."
const sessionBlocks = text.split(/(?=Session \d+)/);
if (sessionBlocks.length > 1) {
return sessionBlocks
.filter(b => b.trim())
.map(block => {
const dateMatch = block.match(/\((\d{4}-\d{2}-\d{2})\)/);
const bullets = parseBullets(block);
return {
date: dateMatch?.[1] || null,
leftOff: bullets.length ? bullets.join('\n') : block.replace(/^Session \d+.*\n/, '').trim(),
};
});
}
// Simple format
const bullets = parseBullets(text);
return [{ leftOff: bullets.length ? bullets.join('\n') : text.trim() }];
}
// ── Main migration ─────────────────────────────────────────────────────────────
const projects = readProjects();
let migrated = 0;
let skipped = 0;
let errors = 0;
for (const [projectPath, info] of Object.entries(projects)) {
const contextDir = info.contextDir;
const contextDirPath = resolve(CONTEXTS_DIR, contextDir);
const contextJsonPath = resolve(contextDirPath, 'context.json');
const contextMdPath = resolve(contextDirPath, 'CONTEXT.md');
const metaPath = resolve(contextDirPath, 'meta.json');
// Already v2
if (existsSync(contextJsonPath)) {
try {
const existing = JSON.parse(readFileSync(contextJsonPath, 'utf8'));
if (existing.version === 2) {
console.log(`${contextDir} — already v2, skipping`);
skipped++;
continue;
}
} catch { /* fall through to migrate */ }
}
console.log(`\n → Migrating: ${contextDir}`);
try {
// Read v1 files
const contextMd = existsSync(contextMdPath) ? readFileSync(contextMdPath, 'utf8') : '';
let meta = {};
if (existsSync(metaPath)) {
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')); } catch {}
}
// Extract fields from CONTEXT.md
const description = extractSection(contextMd, 'What This Is') || extractSection(contextMd, 'About') || null;
const stackRaw = extractSection(contextMd, 'Tech Stack') || '';
const stack = stackRaw.split(/[,\n]/).map(s => s.replace(/^[-*]\s+/, '').trim()).filter(Boolean);
const goal = (extractSection(contextMd, 'Current Goal') || '').split('\n')[0].trim() || null;
const constraintRaw = extractSection(contextMd, 'Do Not Do') || '';
const constraints = parseBullets(constraintRaw);
const decisionsRaw = extractSection(contextMd, 'Decisions Made') || '';
const decisions = parseDecisionsTable(decisionsRaw);
const nextStepsRaw = extractSection(contextMd, 'Next Steps') || '';
const nextSteps = parseBullets(nextStepsRaw);
const blockersRaw = extractSection(contextMd, 'Blockers') || '';
const blockers = parseBullets(blockersRaw).filter(b => b.toLowerCase() !== 'none');
const leftOffRaw = extractSection(contextMd, 'Where I Left Off') || '';
const leftOffParsed = parseLeftOff(leftOffRaw);
// Build sessions from parsed left-off blocks (may be multiple)
const sessions = leftOffParsed.map((lo, idx) => ({
id: idx === leftOffParsed.length - 1 && meta.lastSessionId
? meta.lastSessionId.slice(0, 8)
: shortId(),
date: lo.date || meta.lastUpdated || today(),
summary: idx === leftOffParsed.length - 1
? (meta.lastSessionSummary || 'Migrated from v1')
: `Session ${idx + 1} (migrated)`,
leftOff: lo.leftOff,
nextSteps: idx === leftOffParsed.length - 1 ? nextSteps : [],
decisions: idx === leftOffParsed.length - 1 ? decisions : [],
blockers: idx === leftOffParsed.length - 1 ? blockers : [],
}));
const context = {
version: 2,
name: contextDir,
path: meta.path || projectPath,
description,
stack,
goal,
constraints,
repo: meta.repo || null,
createdAt: meta.lastUpdated || today(),
sessions,
};
if (isDryRun) {
console.log(` description: ${description?.slice(0, 60) || '(none)'}`);
console.log(` stack: ${stack.join(', ') || '(none)'}`);
console.log(` goal: ${goal?.slice(0, 60) || '(none)'}`);
console.log(` sessions: ${sessions.length}`);
console.log(` decisions: ${decisions.length}`);
console.log(` nextSteps: ${nextSteps.length}`);
migrated++;
continue;
}
// Backup meta.json
if (existsSync(metaPath)) {
renameSync(metaPath, resolve(contextDirPath, 'meta.json.v1-backup'));
}
// Write context.json + regenerated CONTEXT.md
saveContext(contextDir, context);
// Update projects.json entry
projects[projectPath].lastUpdated = today();
console.log(` ✓ Migrated — ${sessions.length} session(s), ${decisions.length} decision(s)`);
migrated++;
} catch (e) {
console.log(` ✗ Error: ${e.message}`);
errors++;
}
}
if (!isDryRun && migrated > 0) {
writeProjects(projects);
}
console.log(`\nck migrate: ${migrated} migrated, ${skipped} already v2, ${errors} errors`);
if (isDryRun) console.log('Run without --dry-run to apply.');
if (errors > 0) process.exit(1);