mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
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>
199 lines
7.1 KiB
JavaScript
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);
|