From 1e226ba55690ecccddca9bd5e17ed2abe5ca9941 Mon Sep 17 00:00:00 2001 From: Sreedhara GS Date: Fri, 27 Mar 2026 16:30:39 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(skill):=20ck=20=E2=80=94=20context-kee?= =?UTF-8?q?per=20v2,=20persistent=20per-project=20memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- skills/ck/SKILL.md | 147 ++++++++++++ skills/ck/commands/forget.mjs | 44 ++++ skills/ck/commands/info.mjs | 24 ++ skills/ck/commands/init.mjs | 143 +++++++++++ skills/ck/commands/list.mjs | 41 ++++ skills/ck/commands/migrate.mjs | 198 +++++++++++++++ skills/ck/commands/resume.mjs | 41 ++++ skills/ck/commands/save.mjs | 210 ++++++++++++++++ skills/ck/commands/shared.mjs | 384 ++++++++++++++++++++++++++++++ skills/ck/hooks/session-start.mjs | 217 +++++++++++++++++ 10 files changed, 1449 insertions(+) create mode 100644 skills/ck/SKILL.md create mode 100644 skills/ck/commands/forget.mjs create mode 100644 skills/ck/commands/info.mjs create mode 100644 skills/ck/commands/init.mjs create mode 100644 skills/ck/commands/list.mjs create mode 100644 skills/ck/commands/migrate.mjs create mode 100644 skills/ck/commands/resume.mjs create mode 100644 skills/ck/commands/save.mjs create mode 100644 skills/ck/commands/shared.mjs create mode 100644 skills/ck/hooks/session-start.mjs diff --git a/skills/ck/SKILL.md b/skills/ck/SKILL.md new file mode 100644 index 00000000..a5f0adf2 --- /dev/null +++ b/skills/ck/SKILL.md @@ -0,0 +1,147 @@ +--- +name: ck +description: Persistent per-project memory for Claude Code. Auto-loads project context on session start, tracks sessions with git activity, and writes to native memory. Commands run deterministic Node.js scripts — behavior is consistent across model versions. +origin: community +version: 2.0.0 +author: sreedhargs89 +repo: https://github.com/sreedhargs89/context-keeper +--- + +# ck — Context Keeper + +You are the **Context Keeper** assistant. When the user invokes any `/ck:*` command, +run the corresponding Node.js script and present its stdout to the user verbatim. +Scripts live at: `~/.claude/skills/ck/commands/` (expand `~` with `$HOME`). + +--- + +## Data Layout + +``` +~/.claude/ck/ +├── projects.json ← path → {name, contextDir, lastUpdated} +└── contexts// + ├── context.json ← SOURCE OF TRUTH (structured JSON, v2) + └── CONTEXT.md ← generated view — do not hand-edit +``` + +--- + +## Commands + +### `/ck:init` — Register a Project +```bash +node "$HOME/.claude/skills/ck/commands/init.mjs" +``` +The script outputs JSON with auto-detected info. Present it as a confirmation draft: +``` +Here's what I found — confirm or edit anything: +Project: +Description: +Stack: +Goal: +Do-nots: +Repo: +``` +Wait for user approval. Apply any edits. Then pipe confirmed JSON to save.mjs --init: +```bash +echo '' | node "$HOME/.claude/skills/ck/commands/save.mjs" --init +``` +Confirmed JSON schema: `{"name":"...","path":"...","description":"...","stack":["..."],"goal":"...","constraints":["..."],"repo":"..." }` + +--- + +### `/ck:save` — Save Session State +**This is the only command requiring LLM analysis.** Analyze the current conversation: +- `summary`: one sentence, max 10 words, what was accomplished +- `leftOff`: what was actively being worked on (specific file/feature/bug) +- `nextSteps`: ordered array of concrete next steps +- `decisions`: array of `{what, why}` for decisions made this session +- `blockers`: array of current blockers (empty array if none) +- `goal`: updated goal string **only if it changed this session**, else omit + +Show a draft summary to the user: `"Session: '' — save this? (yes / edit)"` +Wait for confirmation. Then pipe to save.mjs: +```bash +echo '' | node "$HOME/.claude/skills/ck/commands/save.mjs" +``` +JSON schema (exact): `{"summary":"...","leftOff":"...","nextSteps":["..."],"decisions":[{"what":"...","why":"..."}],"blockers":["..."]}` +Display the script's stdout confirmation verbatim. + +--- + +### `/ck:resume [name|number]` — Full Briefing +```bash +node "$HOME/.claude/skills/ck/commands/resume.mjs" [arg] +``` +Display output verbatim. Then ask: "Continue from here? Or has anything changed?" +If user reports changes → run `/ck:save` immediately. + +--- + +### `/ck:info [name|number]` — Quick Snapshot +```bash +node "$HOME/.claude/skills/ck/commands/info.mjs" [arg] +``` +Display output verbatim. No follow-up question. + +--- + +### `/ck:list` — Portfolio View +```bash +node "$HOME/.claude/skills/ck/commands/list.mjs" +``` +Display output verbatim. If user replies with a number or name → run `/ck:resume`. + +--- + +### `/ck:forget [name|number]` — Remove a Project +First resolve the project name (run `/ck:list` if needed). +Ask: `"This will permanently delete context for ''. Are you sure? (yes/no)"` +If yes: +```bash +node "$HOME/.claude/skills/ck/commands/forget.mjs" [name] +``` +Display confirmation verbatim. + +--- + +### `/ck:migrate` — Convert v1 Data to v2 +```bash +node "$HOME/.claude/skills/ck/commands/migrate.mjs" +``` +For a dry run first: +```bash +node "$HOME/.claude/skills/ck/commands/migrate.mjs" --dry-run +``` +Display output verbatim. Migrates all v1 CONTEXT.md + meta.json files to v2 context.json. +Originals are backed up as `meta.json.v1-backup` — nothing is deleted. + +--- + +## SessionStart Hook + +The hook at `~/.claude/skills/ck/hooks/session-start.mjs` must be registered in +`~/.claude/settings.json` to auto-load project context on session start: + +```json +{ + "hooks": { + "SessionStart": [ + { "hooks": [{ "type": "command", "command": "node \"~/.claude/skills/ck/hooks/session-start.mjs\"" }] } + ] + } +} +``` + +The hook injects ~100 tokens per session (compact 5-line summary). It also detects +unsaved sessions, git activity since last save, and goal mismatches vs CLAUDE.md. + +--- + +## Rules +- Always expand `~` as `$HOME` in Bash calls. +- Commands are case-insensitive: `/CK:SAVE`, `/ck:save`, `/Ck:Save` all work. +- If a script exits with code 1, display its stdout as an error message. +- Never edit `context.json` or `CONTEXT.md` directly — always use the scripts. +- If `projects.json` is malformed, tell the user and offer to reset it to `{}`. diff --git a/skills/ck/commands/forget.mjs b/skills/ck/commands/forget.mjs new file mode 100644 index 00000000..8b88c776 --- /dev/null +++ b/skills/ck/commands/forget.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * forget.mjs — remove a project's context and registry entry + * + * Usage: node forget.mjs [name|number] + * stdout: confirmation or error + * exit 0: success exit 1: not found + * + * Note: SKILL.md instructs Claude to ask "Are you sure?" before calling this script. + * This script is the "do it" step — no confirmation prompt here. + */ + +import { rmSync } from 'fs'; +import { resolve } from 'path'; +import { resolveContext, readProjects, writeProjects, CONTEXTS_DIR } from './shared.mjs'; + +const arg = process.argv[2]; +const cwd = process.env.PWD || process.cwd(); + +const resolved = resolveContext(arg, cwd); +if (!resolved) { + const hint = arg ? `No project matching "${arg}".` : 'This directory is not registered.'; + console.log(`${hint}`); + process.exit(1); +} + +const { name, contextDir, projectPath } = resolved; + +// Remove context directory +const contextDirPath = resolve(CONTEXTS_DIR, contextDir); +try { + rmSync(contextDirPath, { recursive: true, force: true }); +} catch (e) { + console.log(`ck: could not remove context directory — ${e.message}`); + process.exit(1); +} + +// Remove from projects.json +const projects = readProjects(); +delete projects[projectPath]; +writeProjects(projects); + +console.log(`✓ Context for '${name}' removed.`); diff --git a/skills/ck/commands/info.mjs b/skills/ck/commands/info.mjs new file mode 100644 index 00000000..5ca86ac7 --- /dev/null +++ b/skills/ck/commands/info.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * info.mjs — quick read-only context snapshot + * + * Usage: node info.mjs [name|number] + * stdout: compact info block + * exit 0: success exit 1: not found + */ + +import { resolveContext, renderInfoBlock } from './shared.mjs'; + +const arg = process.argv[2]; +const cwd = process.env.PWD || process.cwd(); + +const resolved = resolveContext(arg, cwd); +if (!resolved) { + const hint = arg ? `No project matching "${arg}".` : 'This directory is not registered.'; + console.log(`${hint} Run /ck:init to register it.`); + process.exit(1); +} + +console.log(''); +console.log(renderInfoBlock(resolved.context)); diff --git a/skills/ck/commands/init.mjs b/skills/ck/commands/init.mjs new file mode 100644 index 00000000..ef8e647b --- /dev/null +++ b/skills/ck/commands/init.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * init.mjs — auto-detect project info and output JSON for Claude to confirm + * + * Usage: node init.mjs + * stdout: JSON with auto-detected project info + * exit 0: success exit 1: error + */ + +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { readProjects } from './shared.mjs'; + +const cwd = process.env.PWD || process.cwd(); +const projects = readProjects(); + +const output = { + path: cwd, + name: null, + description: null, + stack: [], + goal: null, + constraints: [], + repo: null, + alreadyRegistered: !!projects[cwd], +}; + +function readFile(filename) { + const p = resolve(cwd, filename); + if (!existsSync(p)) return null; + try { return readFileSync(p, 'utf8'); } catch { return null; } +} + +function extractSection(md, heading) { + const re = new RegExp(`## ${heading}\\n([\\s\\S]*?)(?=\\n## |$)`); + const m = md.match(re); + return m ? m[1].trim() : null; +} + +// ── package.json ────────────────────────────────────────────────────────────── +const pkg = readFile('package.json'); +if (pkg) { + try { + const parsed = JSON.parse(pkg); + if (parsed.name && !output.name) output.name = parsed.name; + if (parsed.description && !output.description) output.description = parsed.description; + + // Detect stack from dependencies + const deps = Object.keys({ ...(parsed.dependencies || {}), ...(parsed.devDependencies || {}) }); + const stackMap = { + next: 'Next.js', react: 'React', vue: 'Vue', svelte: 'Svelte', astro: 'Astro', + express: 'Express', fastify: 'Fastify', hono: 'Hono', nestjs: 'NestJS', + typescript: 'TypeScript', prisma: 'Prisma', drizzle: 'Drizzle', + '@neondatabase/serverless': 'Neon', '@upstash/redis': 'Upstash Redis', + '@clerk/nextjs': 'Clerk', stripe: 'Stripe', tailwindcss: 'Tailwind CSS', + }; + for (const [dep, label] of Object.entries(stackMap)) { + if (deps.includes(dep) && !output.stack.includes(label)) { + output.stack.push(label); + } + } + if (deps.includes('typescript') || existsSync(resolve(cwd, 'tsconfig.json'))) { + if (!output.stack.includes('TypeScript')) output.stack.push('TypeScript'); + } + } catch { /* malformed package.json */ } +} + +// ── go.mod ──────────────────────────────────────────────────────────────────── +const goMod = readFile('go.mod'); +if (goMod) { + if (!output.stack.includes('Go')) output.stack.push('Go'); + const modName = goMod.match(/^module\s+(\S+)/m)?.[1]; + if (modName && !output.name) output.name = modName.split('/').pop(); +} + +// ── Cargo.toml ──────────────────────────────────────────────────────────────── +const cargo = readFile('Cargo.toml'); +if (cargo) { + if (!output.stack.includes('Rust')) output.stack.push('Rust'); + const crateName = cargo.match(/^name\s*=\s*"(.+?)"/m)?.[1]; + if (crateName && !output.name) output.name = crateName; +} + +// ── pyproject.toml ──────────────────────────────────────────────────────────── +const pyproject = readFile('pyproject.toml'); +if (pyproject) { + if (!output.stack.includes('Python')) output.stack.push('Python'); + const pyName = pyproject.match(/^name\s*=\s*"(.+?)"/m)?.[1]; + if (pyName && !output.name) output.name = pyName; +} + +// ── .git/config (repo URL) ──────────────────────────────────────────────────── +const gitConfig = readFile('.git/config'); +if (gitConfig) { + const repoMatch = gitConfig.match(/url\s*=\s*(.+)/); + if (repoMatch) output.repo = repoMatch[1].trim(); +} + +// ── CLAUDE.md ───────────────────────────────────────────────────────────────── +const claudeMd = readFile('CLAUDE.md'); +if (claudeMd) { + const goal = extractSection(claudeMd, 'Current Goal'); + if (goal && !output.goal) output.goal = goal.split('\n')[0].trim(); + + const doNot = extractSection(claudeMd, 'Do Not Do'); + if (doNot) { + const bullets = doNot.split('\n') + .filter(l => /^[-*]\s+/.test(l)) + .map(l => l.replace(/^[-*]\s+/, '').trim()); + output.constraints = bullets; + } + + const stack = extractSection(claudeMd, 'Tech Stack'); + if (stack && output.stack.length === 0) { + output.stack = stack.split(/[,\n]/).map(s => s.replace(/^[-*]\s+/, '').trim()).filter(Boolean); + } + + // Description from first section or "What This Is" + const whatItIs = extractSection(claudeMd, 'What This Is') || extractSection(claudeMd, 'About'); + if (whatItIs && !output.description) output.description = whatItIs.split('\n')[0].trim(); +} + +// ── README.md (description fallback) ───────────────────────────────────────── +const readme = readFile('README.md'); +if (readme && !output.description) { + // First non-header, non-badge, non-empty paragraph + const lines = readme.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!') && !trimmed.startsWith('>') && !trimmed.startsWith('[') && trimmed !== '---' && trimmed !== '___') { + output.description = trimmed.slice(0, 120); + break; + } + } +} + +// ── Name fallback: directory name ───────────────────────────────────────────── +if (!output.name) { + output.name = cwd.split('/').pop().toLowerCase().replace(/\s+/g, '-'); +} + +console.log(JSON.stringify(output, null, 2)); diff --git a/skills/ck/commands/list.mjs b/skills/ck/commands/list.mjs new file mode 100644 index 00000000..c4cd04e2 --- /dev/null +++ b/skills/ck/commands/list.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * list.mjs — portfolio view of all registered projects + * + * Usage: node list.mjs + * stdout: ASCII table of all projects + prompt to resume + * exit 0: success exit 1: no projects + */ + +import { readProjects, loadContext, today, CONTEXTS_DIR } from './shared.mjs'; +import { renderListTable } from './shared.mjs'; + +const cwd = process.env.PWD || process.cwd(); +const projects = readProjects(); +const entries = Object.entries(projects); + +if (entries.length === 0) { + console.log('No projects registered. Run /ck:init to get started.'); + process.exit(1); +} + +// Build enriched list sorted alphabetically by contextDir +const enriched = entries + .map(([path, info]) => { + const context = loadContext(info.contextDir); + return { + name: info.name, + contextDir: info.contextDir, + path, + context, + lastUpdated: info.lastUpdated, + }; + }) + .sort((a, b) => a.contextDir.localeCompare(b.contextDir)); + +const table = renderListTable(enriched, cwd, today()); +console.log(''); +console.log(table); +console.log(''); +console.log('Resume which? (number or name)'); diff --git a/skills/ck/commands/migrate.mjs b/skills/ck/commands/migrate.mjs new file mode 100644 index 00000000..d4966fa8 --- /dev/null +++ b/skills/ck/commands/migrate.mjs @@ -0,0 +1,198 @@ +#!/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); diff --git a/skills/ck/commands/resume.mjs b/skills/ck/commands/resume.mjs new file mode 100644 index 00000000..b384fcd8 --- /dev/null +++ b/skills/ck/commands/resume.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * resume.mjs — full project briefing + * + * Usage: node resume.mjs [name|number] + * stdout: bordered briefing box + * exit 0: success exit 1: not found + */ + +import { resolveContext, renderBriefingBox } from './shared.mjs'; +import { execSync } from 'child_process'; + +const arg = process.argv[2]; +const cwd = process.env.PWD || process.cwd(); + +const resolved = resolveContext(arg, cwd); +if (!resolved) { + const hint = arg ? `No project matching "${arg}".` : 'This directory is not registered.'; + console.log(`${hint} Run /ck:init to register it.`); + process.exit(1); +} + +const { context, projectPath } = resolved; + +// Attempt to cd to the project path +if (projectPath && projectPath !== cwd) { + try { + const exists = execSync(`test -d "${projectPath}" && echo yes || echo no`, { + stdio: 'pipe', encoding: 'utf8', timeout: 2000, + }).trim(); + if (exists === 'yes') { + console.log(`→ cd ${projectPath}`); + } else { + console.log(`⚠ Path not found: ${projectPath}`); + } + } catch { /* non-fatal */ } +} + +console.log(''); +console.log(renderBriefingBox(context)); diff --git a/skills/ck/commands/save.mjs b/skills/ck/commands/save.mjs new file mode 100644 index 00000000..522e7848 --- /dev/null +++ b/skills/ck/commands/save.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * save.mjs — write session data to context.json, regenerate CONTEXT.md, + * and write a native memory entry. + * + * Usage (regular save): + * echo '' | node save.mjs + * JSON schema: { summary, leftOff, nextSteps[], decisions[{what,why}], blockers[], goal? } + * + * Usage (init — first registration): + * echo '' | node save.mjs --init + * JSON schema: { name, path, description, stack[], goal, constraints[], repo? } + * + * stdout: confirmation message + * exit 0: success exit 1: error + */ + +import { readFileSync, mkdirSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { + readProjects, writeProjects, loadContext, saveContext, + today, shortId, gitSummary, nativeMemoryDir, encodeProjectPath, + CONTEXTS_DIR, CURRENT_SESSION, +} from './shared.mjs'; + +const isInit = process.argv.includes('--init'); +const cwd = process.env.PWD || process.cwd(); + +// ── Read JSON from stdin ────────────────────────────────────────────────────── +let input; +try { + const raw = readFileSync(0, 'utf8').trim(); + if (!raw) throw new Error('empty stdin'); + input = JSON.parse(raw); +} catch (e) { + console.error(`ck save: invalid JSON on stdin — ${e.message}`); + console.log('Expected schema (save): {"summary":"...","leftOff":"...","nextSteps":["..."],"decisions":[{"what":"...","why":"..."}],"blockers":["..."]}'); + console.log('Expected schema (--init): {"name":"...","path":"...","description":"...","stack":["..."],"goal":"...","constraints":["..."]}'); + process.exit(1); +} + +// ───────────────────────────────────────────────────────────────────────────── +// INIT MODE: first-time project registration +// ───────────────────────────────────────────────────────────────────────────── +if (isInit) { + const { name, path: projectPath, description, stack, goal, constraints, repo } = input; + + if (!name || !projectPath) { + console.log('ck init: name and path are required.'); + process.exit(1); + } + + const projects = readProjects(); + + // Derive contextDir (lowercase, spaces→dashes, deduplicate) + let contextDir = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + let suffix = 2; + const existingDirs = Object.values(projects).map(p => p.contextDir); + while (existingDirs.includes(contextDir) && projects[projectPath]?.contextDir !== contextDir) { + contextDir = `${contextDir.replace(/-\d+$/, '')}-${suffix++}`; + } + + const context = { + version: 2, + name: contextDir, + displayName: name, + path: projectPath, + description: description || null, + stack: Array.isArray(stack) ? stack : (stack ? [stack] : []), + goal: goal || null, + constraints: Array.isArray(constraints) ? constraints : [], + repo: repo || null, + createdAt: today(), + sessions: [], + }; + + saveContext(contextDir, context); + + // Update projects.json + projects[projectPath] = { + name: contextDir, + contextDir, + lastUpdated: today(), + }; + writeProjects(projects); + + console.log(`✓ Project '${contextDir}' registered.`); + console.log(` Use /ck:save to save session state and /ck:resume to reload it next time.`); + process.exit(0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SAVE MODE: record a session +// ───────────────────────────────────────────────────────────────────────────── +const projects = readProjects(); +const projectEntry = projects[cwd]; + +if (!projectEntry) { + console.log("This project isn't registered yet. Run /ck:init first."); + process.exit(1); +} + +const { contextDir } = projectEntry; +let context = loadContext(contextDir); + +if (!context) { + console.log(`ck: context.json not found for '${contextDir}'. The install may be corrupted.`); + process.exit(1); +} + +// Get session ID from current-session.json +let sessionId; +try { + const sess = JSON.parse(readFileSync(CURRENT_SESSION, 'utf8')); + sessionId = sess.sessionId || shortId(); +} catch { + sessionId = shortId(); +} + +// Check for duplicate (re-save of same session) +const existingIdx = context.sessions.findIndex(s => s.id === sessionId); + +const { summary, leftOff, nextSteps, decisions, blockers, goal } = input; + +// Capture git activity since the last session +const lastSessionDate = context.sessions?.[context.sessions.length - 1]?.date; +const gitActivity = gitSummary(cwd, lastSessionDate); + +const session = { + id: sessionId, + date: today(), + summary: summary || 'Session saved', + leftOff: leftOff || null, + nextSteps: Array.isArray(nextSteps) ? nextSteps : (nextSteps ? [nextSteps] : []), + decisions: Array.isArray(decisions) ? decisions : [], + blockers: Array.isArray(blockers) ? blockers.filter(Boolean) : [], + ...(gitActivity ? { gitActivity } : {}), +}; + +if (existingIdx >= 0) { + // Update existing session (re-save) + context.sessions[existingIdx] = session; +} else { + context.sessions.push(session); +} + +// Update goal if provided +if (goal && goal !== context.goal) { + context.goal = goal; +} + +// Save context.json + regenerate CONTEXT.md +saveContext(contextDir, context); + +// Update projects.json timestamp +projects[cwd].lastUpdated = today(); +writeProjects(projects); + +// ── Write to native memory ──────────────────────────────────────────────────── +try { + const memDir = nativeMemoryDir(cwd); + mkdirSync(memDir, { recursive: true }); + + const memFile = resolve(memDir, `ck_${today()}_${sessionId.slice(0, 8)}.md`); + const decisionsBlock = session.decisions.length + ? session.decisions.map(d => `- **${d.what}**: ${d.why || ''}`).join('\n') + : '- None this session'; + const nextBlock = session.nextSteps.length + ? session.nextSteps.map((s, i) => `${i + 1}. ${s}`).join('\n') + : '- None recorded'; + const blockersBlock = session.blockers.length + ? session.blockers.map(b => `- ${b}`).join('\n') + : '- None'; + + const memContent = [ + `---`, + `name: Session ${today()} — ${session.summary}`, + `description: Key decisions and outcomes from ck session ${sessionId.slice(0, 8)}`, + `type: project`, + `source: ck`, + `sessionId: ${sessionId}`, + `---`, + ``, + `# Session: ${session.summary}`, + ``, + `## Decisions`, + decisionsBlock, + ``, + `## Left Off`, + session.leftOff || '—', + ``, + `## Next Steps`, + nextBlock, + ``, + `## Blockers`, + blockersBlock, + ``, + ...(gitActivity ? [`## Git Activity`, gitActivity, ``] : []), + ].join('\n'); + + writeFileSync(memFile, memContent, 'utf8'); +} catch (e) { + // Non-fatal — native memory write failure should not block the save + process.stderr.write(`ck: warning — could not write native memory entry: ${e.message}\n`); +} + +console.log(`✓ Saved. Session: ${sessionId.slice(0, 8)}`); +if (gitActivity) console.log(` Git: ${gitActivity}`); +console.log(` See you next time.`); diff --git a/skills/ck/commands/shared.mjs b/skills/ck/commands/shared.mjs new file mode 100644 index 00000000..248b3cad --- /dev/null +++ b/skills/ck/commands/shared.mjs @@ -0,0 +1,384 @@ +/** + * ck — Context Keeper v2 + * shared.mjs — common utilities for all command scripts + * + * No external dependencies. Node.js stdlib only. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'; +import { resolve, basename } from 'path'; +import { homedir } from 'os'; +import { execSync } from 'child_process'; +import { randomBytes } from 'crypto'; + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +export const CK_HOME = resolve(homedir(), '.claude', 'ck'); +export const CONTEXTS_DIR = resolve(CK_HOME, 'contexts'); +export const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); +export const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json'); +export const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md'); + +// ─── JSON I/O ───────────────────────────────────────────────────────────────── + +export function readJson(filePath) { + try { + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +export function writeJson(filePath, data) { + const dir = resolve(filePath, '..'); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8'); +} + +export function readProjects() { + return readJson(PROJECTS_FILE) || {}; +} + +export function writeProjects(projects) { + writeJson(PROJECTS_FILE, projects); +} + +// ─── Context I/O ────────────────────────────────────────────────────────────── + +export function contextPath(contextDir) { + return resolve(CONTEXTS_DIR, contextDir, 'context.json'); +} + +export function contextMdPath(contextDir) { + return resolve(CONTEXTS_DIR, contextDir, 'CONTEXT.md'); +} + +export function loadContext(contextDir) { + return readJson(contextPath(contextDir)); +} + +export function saveContext(contextDir, data) { + const dir = resolve(CONTEXTS_DIR, contextDir); + mkdirSync(dir, { recursive: true }); + writeJson(contextPath(contextDir), data); + writeFileSync(contextMdPath(contextDir), renderContextMd(data), 'utf8'); +} + +/** + * Resolve which project to operate on. + * @param {string|undefined} arg — undefined = cwd match, number string = alphabetical index, else name search + * @param {string} cwd + * @returns {{ name, contextDir, projectPath, context } | null} + */ +export function resolveContext(arg, cwd) { + const projects = readProjects(); + const entries = Object.entries(projects); // [path, {name, contextDir, lastUpdated}] + + if (!arg) { + // Match by cwd + const entry = projects[cwd]; + if (!entry) return null; + const context = loadContext(entry.contextDir); + if (!context) return null; + return { name: entry.name, contextDir: entry.contextDir, projectPath: cwd, context }; + } + + // Collect all contexts sorted alphabetically by contextDir + const sorted = entries + .map(([path, info]) => ({ path, ...info })) + .sort((a, b) => a.contextDir.localeCompare(b.contextDir)); + + const asNumber = parseInt(arg, 10); + if (!isNaN(asNumber) && String(asNumber) === arg) { + // Number-based lookup (1-indexed) + const item = sorted[asNumber - 1]; + if (!item) return null; + const context = loadContext(item.contextDir); + if (!context) return null; + return { name: item.name, contextDir: item.contextDir, projectPath: item.path, context }; + } + + // Name-based lookup: exact > prefix > substring (case-insensitive) + const lower = arg.toLowerCase(); + let match = + sorted.find(e => e.name.toLowerCase() === lower) || + sorted.find(e => e.name.toLowerCase().startsWith(lower)) || + sorted.find(e => e.name.toLowerCase().includes(lower)); + + if (!match) return null; + const context = loadContext(match.contextDir); + if (!context) return null; + return { name: match.name, contextDir: match.contextDir, projectPath: match.path, context }; +} + +// ─── Date helpers ───────────────────────────────────────────────────────────── + +export function today() { + return new Date().toISOString().slice(0, 10); +} + +export function daysAgoLabel(dateStr) { + if (!dateStr) return 'unknown'; + const diff = Math.floor((Date.now() - new Date(dateStr)) / 86_400_000); + if (diff === 0) return 'Today'; + if (diff === 1) return '1 day ago'; + return `${diff} days ago`; +} + +export function stalenessIcon(dateStr) { + if (!dateStr) return '○'; + const diff = Math.floor((Date.now() - new Date(dateStr)) / 86_400_000); + if (diff < 1) return '●'; + if (diff <= 5) return '◐'; + return '○'; +} + +// ─── ID generation ──────────────────────────────────────────────────────────── + +export function shortId() { + return randomBytes(4).toString('hex'); +} + +// ─── Git helpers ────────────────────────────────────────────────────────────── + +function runGit(args, cwd) { + try { + return execSync(`git -C "${cwd}" ${args}`, { + timeout: 3000, + stdio: 'pipe', + encoding: 'utf8', + }).trim(); + } catch { + return null; + } +} + +export function gitLogSince(projectPath, sinceDate) { + if (!sinceDate) return null; + return runGit(`log --oneline --since="${sinceDate}"`, projectPath); +} + +export function gitSummary(projectPath, sinceDate) { + const log = gitLogSince(projectPath, sinceDate); + if (!log) return null; + const commits = log.split('\n').filter(Boolean).length; + if (commits === 0) return null; + + // Count unique files changed across those commits + const diff = runGit(`diff --shortstat HEAD@{$(git -C "${projectPath}" rev-list --count HEAD --since="${sinceDate}")}..HEAD 2>/dev/null`, projectPath) + || runGit(`diff --shortstat HEAD~${Math.min(commits, 50)}..HEAD`, projectPath); + + if (diff) { + const filesMatch = diff.match(/(\d+) file/); + const files = filesMatch ? parseInt(filesMatch[1]) : '?'; + return `${commits} commit${commits !== 1 ? 's' : ''}, ${files} file${files !== 1 ? 's' : ''} changed`; + } + return `${commits} commit${commits !== 1 ? 's' : ''}`; +} + +// ─── Native memory path encoding ────────────────────────────────────────────── + +export function encodeProjectPath(absolutePath) { + // "/Users/sree/dev/app" -> "-Users-sree-dev-app" + return absolutePath.replace(/\//g, '-'); +} + +export function nativeMemoryDir(absolutePath) { + const encoded = encodeProjectPath(absolutePath); + return resolve(homedir(), '.claude', 'projects', encoded, 'memory'); +} + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +/** Render the human-readable CONTEXT.md from context.json */ +export function renderContextMd(ctx) { + const latest = ctx.sessions?.[ctx.sessions.length - 1] || null; + const lines = [ + ``, + `# Project: ${ctx.name}`, + `> Path: ${ctx.path}`, + ]; + if (ctx.repo) lines.push(`> Repo: ${ctx.repo}`); + const sessionCount = ctx.sessions?.length || 0; + lines.push(`> Last Session: ${ctx.sessions?.[sessionCount - 1]?.date || 'never'} | Sessions: ${sessionCount}`); + lines.push(``); + lines.push(`## What This Is`); + lines.push(ctx.description || '_Not set._'); + lines.push(``); + lines.push(`## Tech Stack`); + lines.push(Array.isArray(ctx.stack) ? ctx.stack.join(', ') : (ctx.stack || '_Not set._')); + lines.push(``); + lines.push(`## Current Goal`); + lines.push(ctx.goal || '_Not set._'); + lines.push(``); + lines.push(`## Where I Left Off`); + lines.push(latest?.leftOff || '_Not yet recorded. Run /ck:save after your first session._'); + lines.push(``); + lines.push(`## Next Steps`); + if (latest?.nextSteps?.length) { + latest.nextSteps.forEach((s, i) => lines.push(`${i + 1}. ${s}`)); + } else { + lines.push(`_Not yet recorded._`); + } + lines.push(``); + lines.push(`## Blockers`); + if (latest?.blockers?.length) { + latest.blockers.forEach(b => lines.push(`- ${b}`)); + } else { + lines.push(`- None`); + } + lines.push(``); + lines.push(`## Do Not Do`); + if (ctx.constraints?.length) { + ctx.constraints.forEach(c => lines.push(`- ${c}`)); + } else { + lines.push(`- None specified`); + } + lines.push(``); + + // All decisions across sessions + const allDecisions = (ctx.sessions || []).flatMap(s => + (s.decisions || []).map(d => ({ ...d, date: s.date })) + ); + lines.push(`## Decisions Made`); + lines.push(`| Decision | Why | Date |`); + lines.push(`|----------|-----|------|`); + if (allDecisions.length) { + allDecisions.forEach(d => lines.push(`| ${d.what} | ${d.why || ''} | ${d.date || ''} |`)); + } else { + lines.push(`| _(none yet)_ | | |`); + } + lines.push(``); + + // Session history (most recent first) + if (ctx.sessions?.length > 1) { + lines.push(`## Session History`); + const reversed = [...ctx.sessions].reverse(); + reversed.forEach(s => { + lines.push(`### ${s.date} — ${s.summary || 'Session'}`); + if (s.gitActivity) lines.push(`_${s.gitActivity}_`); + if (s.leftOff) lines.push(`**Left off:** ${s.leftOff}`); + }); + lines.push(``); + } + + return lines.join('\n'); +} + +/** Render the bordered briefing box used by /ck:resume */ +export function renderBriefingBox(ctx, meta = {}) { + const latest = ctx.sessions?.[ctx.sessions.length - 1] || {}; + const W = 57; + const pad = (str, w) => { + const s = String(str || ''); + return s.length > w ? s.slice(0, w - 1) + '…' : s.padEnd(w); + }; + const row = (label, value) => `│ ${label} → ${pad(value, W - label.length - 7)}│`; + + const when = daysAgoLabel(ctx.sessions?.[ctx.sessions.length - 1]?.date); + const sessions = ctx.sessions?.length || 0; + const shortSessId = latest.id?.slice(0, 8) || null; + + const lines = [ + `┌${'─'.repeat(W)}┐`, + `│ RESUMING: ${pad(ctx.name, W - 12)}│`, + `│ Last session: ${pad(`${when} | Sessions: ${sessions}`, W - 16)}│`, + ]; + if (shortSessId) lines.push(`│ Session ID: ${pad(shortSessId, W - 14)}│`); + lines.push(`├${'─'.repeat(W)}┤`); + lines.push(row('WHAT IT IS', ctx.description || '—')); + lines.push(row('STACK ', Array.isArray(ctx.stack) ? ctx.stack.join(', ') : (ctx.stack || '—'))); + lines.push(row('PATH ', ctx.path)); + if (ctx.repo) lines.push(row('REPO ', ctx.repo)); + lines.push(row('GOAL ', ctx.goal || '—')); + lines.push(`├${'─'.repeat(W)}┤`); + lines.push(`│ WHERE I LEFT OFF${' '.repeat(W - 18)}│`); + const leftOffLines = (latest.leftOff || '—').split('\n').filter(Boolean); + leftOffLines.forEach(l => lines.push(`│ • ${pad(l, W - 7)}│`)); + lines.push(`├${'─'.repeat(W)}┤`); + lines.push(`│ NEXT STEPS${' '.repeat(W - 12)}│`); + const steps = latest.nextSteps || []; + if (steps.length) { + steps.forEach((s, i) => lines.push(`│ ${i + 1}. ${pad(s, W - 8)}│`)); + } else { + lines.push(`│ —${' '.repeat(W - 5)}│`); + } + const blockers = latest.blockers?.length ? latest.blockers.join(', ') : 'None'; + lines.push(`│ BLOCKERS → ${pad(blockers, W - 13)}│`); + if (latest.gitActivity) { + lines.push(`│ GIT → ${pad(latest.gitActivity, W - 13)}│`); + } + lines.push(`└${'─'.repeat(W)}┘`); + return lines.join('\n'); +} + +/** Render compact info block used by /ck:info */ +export function renderInfoBlock(ctx) { + const latest = ctx.sessions?.[ctx.sessions.length - 1] || {}; + const sep = '─'.repeat(44); + const lines = [ + `ck: ${ctx.name}`, + sep, + ]; + lines.push(`PATH ${ctx.path}`); + if (ctx.repo) lines.push(`REPO ${ctx.repo}`); + if (latest.id) lines.push(`SESSION ${latest.id.slice(0, 8)}`); + lines.push(`GOAL ${ctx.goal || '—'}`); + lines.push(sep); + lines.push(`WHERE I LEFT OFF`); + (latest.leftOff || '—').split('\n').filter(Boolean).forEach(l => lines.push(` • ${l}`)); + lines.push(`NEXT STEPS`); + (latest.nextSteps || []).forEach((s, i) => lines.push(` ${i + 1}. ${s}`)); + if (!latest.nextSteps?.length) lines.push(` —`); + lines.push(`BLOCKERS`); + if (latest.blockers?.length) { + latest.blockers.forEach(b => lines.push(` • ${b}`)); + } else { + lines.push(` • None`); + } + return lines.join('\n'); +} + +/** Render ASCII list table used by /ck:list */ +export function renderListTable(entries, cwd, todayStr) { + // entries: [{name, contextDir, path, context, lastUpdated}] + // Sorted alphabetically by contextDir before calling + const rows = entries.map((e, i) => { + const isHere = e.path === cwd; + const latest = e.context?.sessions?.[e.context.sessions.length - 1] || {}; + const when = daysAgoLabel(latest.date); + const icon = stalenessIcon(latest.date); + const statusLabel = icon === '●' ? '● Active' : icon === '◐' ? '◐ Warm' : '○ Stale'; + const sessId = latest.id ? latest.id.slice(0, 8) : '—'; + const summary = (latest.summary || '—').slice(0, 34); + const displayName = (e.name + (isHere ? ' <-' : '')).slice(0, 18); + return { + num: String(i + 1), + name: displayName, + status: statusLabel, + when: when.slice(0, 10), + sessId, + summary, + }; + }); + + const cols = { + num: Math.max(1, ...rows.map(r => r.num.length)), + name: Math.max(7, ...rows.map(r => r.name.length)), + status: Math.max(6, ...rows.map(r => r.status.length)), + when: Math.max(9, ...rows.map(r => r.when.length)), + sessId: Math.max(7, ...rows.map(r => r.sessId.length)), + summary: Math.max(12, ...rows.map(r => r.summary.length)), + }; + + const hr = `+${'-'.repeat(cols.num + 2)}+${'-'.repeat(cols.name + 2)}+${'-'.repeat(cols.status + 2)}+${'-'.repeat(cols.when + 2)}+${'-'.repeat(cols.sessId + 2)}+${'-'.repeat(cols.summary + 2)}+`; + const cell = (val, width) => ` ${val.padEnd(width)} `; + const headerRow = `|${cell('#', cols.num)}|${cell('Project', cols.name)}|${cell('Status', cols.status)}|${cell('Last Seen', cols.when)}|${cell('Session', cols.sessId)}|${cell('Last Summary', cols.summary)}|`; + + const dataRows = rows.map(r => + `|${cell(r.num, cols.num)}|${cell(r.name, cols.name)}|${cell(r.status, cols.status)}|${cell(r.when, cols.when)}|${cell(r.sessId, cols.sessId)}|${cell(r.summary, cols.summary)}|` + ); + + return [hr, headerRow, hr, ...dataRows, hr].join('\n'); +} diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs new file mode 100644 index 00000000..e1743d65 --- /dev/null +++ b/skills/ck/hooks/session-start.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +/** + * ck — Context Keeper v2 + * session-start.mjs — inject compact project context on session start. + * + * Injects ~100 tokens (not ~2,500 like v1). + * SKILL.md is injected separately (still small at ~50 lines). + * + * Features: + * - Compact 5-line summary for registered projects + * - Unsaved session detection → "Last session wasn't saved. Run /ck:save." + * - Git activity since last session + * - Goal mismatch detection vs CLAUDE.md + * - Mini portfolio for unregistered directories + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import { execSync } from 'child_process'; + +const CK_HOME = resolve(homedir(), '.claude', 'ck'); +const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); +const CURRENT_SESSION = resolve(CK_HOME, 'current-session.json'); +const SKILL_FILE = resolve(homedir(), '.claude', 'skills', 'ck', 'SKILL.md'); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function readJson(p) { + try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } +} + +function daysAgo(dateStr) { + if (!dateStr) return 'unknown'; + const diff = Math.floor((Date.now() - new Date(dateStr)) / 86_400_000); + if (diff === 0) return 'today'; + if (diff === 1) return '1 day ago'; + return `${diff} days ago`; +} + +function stalenessIcon(dateStr) { + if (!dateStr) return '○'; + const diff = Math.floor((Date.now() - new Date(dateStr)) / 86_400_000); + return diff < 1 ? '●' : diff <= 5 ? '◐' : '○'; +} + +function gitLogSince(projectPath, sinceDate) { + if (!sinceDate || !existsSync(resolve(projectPath, '.git'))) return null; + try { + const result = execSync(`git -C "${projectPath}" log --oneline --since="${sinceDate}"`, { + timeout: 3000, stdio: 'pipe', encoding: 'utf8', + }).trim(); + const commits = result.split('\n').filter(Boolean).length; + return commits > 0 ? `${commits} commit${commits !== 1 ? 's' : ''} since last session` : null; + } catch { return null; } +} + +function extractClaudeMdGoal(projectPath) { + const p = resolve(projectPath, 'CLAUDE.md'); + if (!existsSync(p)) return null; + try { + const md = readFileSync(p, 'utf8'); + const m = md.match(/## Current Goal\n([\s\S]*?)(?=\n## |$)/); + return m ? m[1].trim().split('\n')[0].trim() : null; + } catch { return null; } +} + +// ─── Session ID from stdin ──────────────────────────────────────────────────── + +function readSessionId() { + try { + const raw = readFileSync(0, 'utf8'); + return JSON.parse(raw).session_id || null; + } catch { return null; } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +function main() { + const cwd = process.env.PWD || process.cwd(); + const sessionId = readSessionId(); + + // Load skill (always inject — now only ~50 lines) + const skill = existsSync(SKILL_FILE) ? readFileSync(SKILL_FILE, 'utf8') : ''; + + const projects = readJson(PROJECTS_FILE) || {}; + const entry = projects[cwd]; + + // Write current-session.json + try { + writeFileSync(CURRENT_SESSION, JSON.stringify({ + sessionId, + projectPath: cwd, + projectName: entry?.name || null, + startedAt: new Date().toISOString(), + }, null, 2), 'utf8'); + } catch { /* non-fatal */ } + + const parts = []; + if (skill) parts.push(skill); + + // ── REGISTERED PROJECT ──────────────────────────────────────────────────── + if (entry?.contextDir) { + const contextFile = resolve(CK_HOME, 'contexts', entry.contextDir, 'context.json'); + const context = readJson(contextFile); + + if (context) { + const latest = context.sessions?.[context.sessions.length - 1] || {}; + const sessionDate = latest.date || context.createdAt; + const sessionCount = context.sessions?.length || 0; + + // ── Compact summary block (~100 tokens) ────────────────────────────── + const summaryLines = [ + `ck: ${context.name} | ${daysAgo(sessionDate)} | ${sessionCount} session${sessionCount !== 1 ? 's' : ''}`, + `Goal: ${context.goal || '—'}`, + latest.leftOff ? `Left off: ${latest.leftOff.split('\n')[0]}` : null, + latest.nextSteps?.length ? `Next: ${latest.nextSteps.slice(0, 2).join(' · ')}` : null, + ].filter(Boolean); + + // ── Unsaved session detection ───────────────────────────────────────── + const prevSession = readJson(CURRENT_SESSION); + if (prevSession?.sessionId && prevSession.sessionId !== sessionId) { + // Check if previous session ID exists in sessions array + const alreadySaved = context.sessions?.some(s => s.id === prevSession.sessionId); + if (!alreadySaved) { + summaryLines.push(`⚠ Last session wasn't saved — run /ck:save to capture it`); + } + } + + // ── Git activity ────────────────────────────────────────────────────── + const gitLine = gitLogSince(cwd, sessionDate); + if (gitLine) summaryLines.push(`Git: ${gitLine}`); + + // ── Goal mismatch detection ─────────────────────────────────────────── + const claudeMdGoal = extractClaudeMdGoal(cwd); + if (claudeMdGoal && context.goal && + claudeMdGoal.toLowerCase().trim() !== context.goal.toLowerCase().trim()) { + summaryLines.push(`⚠ Goal mismatch — ck: "${context.goal.slice(0, 40)}" · CLAUDE.md: "${claudeMdGoal.slice(0, 40)}"`); + summaryLines.push(` Run /ck:save with updated goal to sync`); + } + + parts.push([ + `---`, + `## ck: ${context.name}`, + ``, + summaryLines.join('\n'), + ].join('\n')); + + // Instruct Claude to display compact briefing at session start + parts.push([ + `---`, + `## ck: SESSION START`, + ``, + `IMPORTANT: Display the following as your FIRST message, verbatim:`, + ``, + '```', + summaryLines.join('\n'), + '```', + ``, + `After the block, add one line: "Ready — what are we working on?"`, + `If you see ⚠ warnings above, mention them briefly after the block.`, + ].join('\n')); + + return parts; + } + } + + // ── NOT IN A REGISTERED PROJECT ──────────────────────────────────────────── + const entries = Object.entries(projects); + if (entries.length === 0) return parts; + + // Load and sort by most recent + const recent = entries + .map(([path, info]) => { + const ctx = readJson(resolve(CK_HOME, 'contexts', info.contextDir, 'context.json')); + const latest = ctx?.sessions?.[ctx.sessions.length - 1] || {}; + return { name: info.name, path, lastDate: latest.date || '', summary: latest.summary || '—', ctx }; + }) + .sort((a, b) => (b.lastDate > a.lastDate ? 1 : -1)) + .slice(0, 3); + + const miniRows = recent.map(p => { + const icon = stalenessIcon(p.lastDate); + const when = daysAgo(p.lastDate); + const name = p.name.padEnd(16).slice(0, 16); + const whenStr = when.padEnd(12).slice(0, 12); + const summary = p.summary.slice(0, 32); + return ` ${name} ${icon} ${whenStr} ${summary}`; + }); + + const miniStatus = [ + `ck — recent projects:`, + ` ${'PROJECT'.padEnd(16)} S ${'LAST SEEN'.padEnd(12)} LAST SESSION`, + ` ${'─'.repeat(68)}`, + ...miniRows, + ``, + `Run /ck:list · /ck:resume · /ck:init to register this folder`, + ].join('\n'); + + parts.push([ + `---`, + `## ck: SESSION START`, + ``, + `IMPORTANT: Display the following as your FIRST message, verbatim:`, + ``, + '```', + miniStatus, + '```', + ].join('\n')); + + return parts; +} + +const parts = main(); +if (parts.length > 0) { + console.log(JSON.stringify({ additionalContext: parts.join('\n\n---\n\n') })); +} From 17f6f9509031fa1c9ce6724c3c0ca6b1d8c90ad0 Mon Sep 17 00:00:00 2001 From: Sreedhara GS Date: Fri, 27 Mar 2026 16:44:11 +0900 Subject: [PATCH 2/3] fix(ck): address Greptile + CodeRabbit review bugs - Fix read-after-write in session-start.mjs: read prevSession BEFORE overwriting current-session.json so unsaved-session detection fires - Fix shell injection in resume.mjs: replace execSync shell string with fs.existsSync for directory existence check - Fix shell injection in shared.mjs gitSummary: replace nested \$(git ...) subshell with a separate runGit() call to get rev count - Fix displayName never shown: render functions now use ctx.displayName ?? ctx.name so user-supplied names show instead of the slug - Fix renderListTable: uses context.displayName ?? entry.name - Fix init.mjs: use path.basename() instead of cwd.split('/').pop() - Fix save.mjs confirmation: show original name, not contextDir slug Co-Authored-By: Claude Sonnet 4.6 --- skills/ck/commands/init.mjs | 4 ++-- skills/ck/commands/resume.mjs | 17 ++++++----------- skills/ck/commands/save.mjs | 2 +- skills/ck/commands/shared.mjs | 15 ++++++++------- skills/ck/hooks/session-start.mjs | 9 ++++++--- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/skills/ck/commands/init.mjs b/skills/ck/commands/init.mjs index ef8e647b..fd25bf2d 100644 --- a/skills/ck/commands/init.mjs +++ b/skills/ck/commands/init.mjs @@ -9,7 +9,7 @@ */ import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; +import { resolve, basename } from 'path'; import { readProjects } from './shared.mjs'; const cwd = process.env.PWD || process.cwd(); @@ -137,7 +137,7 @@ if (readme && !output.description) { // ── Name fallback: directory name ───────────────────────────────────────────── if (!output.name) { - output.name = cwd.split('/').pop().toLowerCase().replace(/\s+/g, '-'); + output.name = basename(cwd).toLowerCase().replace(/\s+/g, '-'); } console.log(JSON.stringify(output, null, 2)); diff --git a/skills/ck/commands/resume.mjs b/skills/ck/commands/resume.mjs index b384fcd8..ccb5e313 100644 --- a/skills/ck/commands/resume.mjs +++ b/skills/ck/commands/resume.mjs @@ -8,8 +8,8 @@ * exit 0: success exit 1: not found */ +import { existsSync } from 'fs'; import { resolveContext, renderBriefingBox } from './shared.mjs'; -import { execSync } from 'child_process'; const arg = process.argv[2]; const cwd = process.env.PWD || process.cwd(); @@ -25,16 +25,11 @@ const { context, projectPath } = resolved; // Attempt to cd to the project path if (projectPath && projectPath !== cwd) { - try { - const exists = execSync(`test -d "${projectPath}" && echo yes || echo no`, { - stdio: 'pipe', encoding: 'utf8', timeout: 2000, - }).trim(); - if (exists === 'yes') { - console.log(`→ cd ${projectPath}`); - } else { - console.log(`⚠ Path not found: ${projectPath}`); - } - } catch { /* non-fatal */ } + if (existsSync(projectPath)) { + console.log(`→ cd ${projectPath}`); + } else { + console.log(`⚠ Path not found: ${projectPath}`); + } } console.log(''); diff --git a/skills/ck/commands/save.mjs b/skills/ck/commands/save.mjs index 522e7848..dc60efc4 100644 --- a/skills/ck/commands/save.mjs +++ b/skills/ck/commands/save.mjs @@ -85,7 +85,7 @@ if (isInit) { }; writeProjects(projects); - console.log(`✓ Project '${contextDir}' registered.`); + console.log(`✓ Project '${name}' registered.`); console.log(` Use /ck:save to save session state and /ck:resume to reload it next time.`); process.exit(0); } diff --git a/skills/ck/commands/shared.mjs b/skills/ck/commands/shared.mjs index 248b3cad..73ae6521 100644 --- a/skills/ck/commands/shared.mjs +++ b/skills/ck/commands/shared.mjs @@ -165,9 +165,10 @@ export function gitSummary(projectPath, sinceDate) { const commits = log.split('\n').filter(Boolean).length; if (commits === 0) return null; - // Count unique files changed across those commits - const diff = runGit(`diff --shortstat HEAD@{$(git -C "${projectPath}" rev-list --count HEAD --since="${sinceDate}")}..HEAD 2>/dev/null`, projectPath) - || runGit(`diff --shortstat HEAD~${Math.min(commits, 50)}..HEAD`, projectPath); + // Count unique files changed: use a separate runGit call to avoid nested shell substitution + const countStr = runGit(`rev-list --count HEAD --since="${sinceDate}"`, projectPath); + const revCount = countStr ? parseInt(countStr, 10) : commits; + const diff = runGit(`diff --shortstat HEAD~${Math.min(revCount, 50)}..HEAD`, projectPath); if (diff) { const filesMatch = diff.match(/(\d+) file/); @@ -196,7 +197,7 @@ export function renderContextMd(ctx) { const latest = ctx.sessions?.[ctx.sessions.length - 1] || null; const lines = [ ``, - `# Project: ${ctx.name}`, + `# Project: ${ctx.displayName ?? ctx.name}`, `> Path: ${ctx.path}`, ]; if (ctx.repo) lines.push(`> Repo: ${ctx.repo}`); @@ -282,7 +283,7 @@ export function renderBriefingBox(ctx, meta = {}) { const lines = [ `┌${'─'.repeat(W)}┐`, - `│ RESUMING: ${pad(ctx.name, W - 12)}│`, + `│ RESUMING: ${pad(ctx.displayName ?? ctx.name, W - 12)}│`, `│ Last session: ${pad(`${when} | Sessions: ${sessions}`, W - 16)}│`, ]; if (shortSessId) lines.push(`│ Session ID: ${pad(shortSessId, W - 14)}│`); @@ -318,7 +319,7 @@ export function renderInfoBlock(ctx) { const latest = ctx.sessions?.[ctx.sessions.length - 1] || {}; const sep = '─'.repeat(44); const lines = [ - `ck: ${ctx.name}`, + `ck: ${ctx.displayName ?? ctx.name}`, sep, ]; lines.push(`PATH ${ctx.path}`); @@ -352,7 +353,7 @@ export function renderListTable(entries, cwd, todayStr) { const statusLabel = icon === '●' ? '● Active' : icon === '◐' ? '◐ Warm' : '○ Stale'; const sessId = latest.id ? latest.id.slice(0, 8) : '—'; const summary = (latest.summary || '—').slice(0, 34); - const displayName = (e.name + (isHere ? ' <-' : '')).slice(0, 18); + const displayName = ((e.context?.displayName ?? e.name) + (isHere ? ' <-' : '')).slice(0, 18); return { num: String(i + 1), name: displayName, diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs index e1743d65..10f87f23 100644 --- a/skills/ck/hooks/session-start.mjs +++ b/skills/ck/hooks/session-start.mjs @@ -86,6 +86,9 @@ function main() { const projects = readJson(PROJECTS_FILE) || {}; const entry = projects[cwd]; + // Read previous session BEFORE overwriting current-session.json + const prevSession = readJson(CURRENT_SESSION); + // Write current-session.json try { writeFileSync(CURRENT_SESSION, JSON.stringify({ @@ -108,17 +111,17 @@ function main() { const latest = context.sessions?.[context.sessions.length - 1] || {}; const sessionDate = latest.date || context.createdAt; const sessionCount = context.sessions?.length || 0; + const displayName = context.displayName ?? context.name; // ── Compact summary block (~100 tokens) ────────────────────────────── const summaryLines = [ - `ck: ${context.name} | ${daysAgo(sessionDate)} | ${sessionCount} session${sessionCount !== 1 ? 's' : ''}`, + `ck: ${displayName} | ${daysAgo(sessionDate)} | ${sessionCount} session${sessionCount !== 1 ? 's' : ''}`, `Goal: ${context.goal || '—'}`, latest.leftOff ? `Left off: ${latest.leftOff.split('\n')[0]}` : null, latest.nextSteps?.length ? `Next: ${latest.nextSteps.slice(0, 2).join(' · ')}` : null, ].filter(Boolean); // ── Unsaved session detection ───────────────────────────────────────── - const prevSession = readJson(CURRENT_SESSION); if (prevSession?.sessionId && prevSession.sessionId !== sessionId) { // Check if previous session ID exists in sessions array const alreadySaved = context.sessions?.some(s => s.id === prevSession.sessionId); @@ -141,7 +144,7 @@ function main() { parts.push([ `---`, - `## ck: ${context.name}`, + `## ck: ${displayName}`, ``, summaryLines.join('\n'), ].join('\n')); From 00787d68e466c838448179d42b73157bcef3c4e1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 28 Mar 2026 23:23:54 -0400 Subject: [PATCH 3/3] fix(ck): preserve display names and harden git helpers --- skills/ck/commands/save.mjs | 2 +- skills/ck/commands/shared.mjs | 14 ++++++++------ skills/ck/hooks/session-start.mjs | 14 +++++++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/skills/ck/commands/save.mjs b/skills/ck/commands/save.mjs index dc60efc4..0d25029c 100644 --- a/skills/ck/commands/save.mjs +++ b/skills/ck/commands/save.mjs @@ -79,7 +79,7 @@ if (isInit) { // Update projects.json projects[projectPath] = { - name: contextDir, + name, contextDir, lastUpdated: today(), }; diff --git a/skills/ck/commands/shared.mjs b/skills/ck/commands/shared.mjs index 73ae6521..49a36363 100644 --- a/skills/ck/commands/shared.mjs +++ b/skills/ck/commands/shared.mjs @@ -8,7 +8,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'; import { resolve, basename } from 'path'; import { homedir } from 'os'; -import { execSync } from 'child_process'; +import { spawnSync } from 'child_process'; import { randomBytes } from 'crypto'; // ─── Paths ──────────────────────────────────────────────────────────────────── @@ -144,11 +144,13 @@ export function shortId() { function runGit(args, cwd) { try { - return execSync(`git -C "${cwd}" ${args}`, { + const result = spawnSync('git', ['-C', cwd, ...args], { timeout: 3000, stdio: 'pipe', encoding: 'utf8', - }).trim(); + }); + if (result.status !== 0) return null; + return result.stdout.trim(); } catch { return null; } @@ -156,7 +158,7 @@ function runGit(args, cwd) { export function gitLogSince(projectPath, sinceDate) { if (!sinceDate) return null; - return runGit(`log --oneline --since="${sinceDate}"`, projectPath); + return runGit(['log', '--oneline', `--since=${sinceDate}`], projectPath); } export function gitSummary(projectPath, sinceDate) { @@ -166,9 +168,9 @@ export function gitSummary(projectPath, sinceDate) { if (commits === 0) return null; // Count unique files changed: use a separate runGit call to avoid nested shell substitution - const countStr = runGit(`rev-list --count HEAD --since="${sinceDate}"`, projectPath); + const countStr = runGit(['rev-list', '--count', 'HEAD', `--since=${sinceDate}`], projectPath); const revCount = countStr ? parseInt(countStr, 10) : commits; - const diff = runGit(`diff --shortstat HEAD~${Math.min(revCount, 50)}..HEAD`, projectPath); + const diff = runGit(['diff', '--shortstat', `HEAD~${Math.min(revCount, 50)}..HEAD`], projectPath); if (diff) { const filesMatch = diff.match(/(\d+) file/); diff --git a/skills/ck/hooks/session-start.mjs b/skills/ck/hooks/session-start.mjs index 10f87f23..c3ecee66 100644 --- a/skills/ck/hooks/session-start.mjs +++ b/skills/ck/hooks/session-start.mjs @@ -17,7 +17,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; -import { execSync } from 'child_process'; +import { spawnSync } from 'child_process'; const CK_HOME = resolve(homedir(), '.claude', 'ck'); const PROJECTS_FILE = resolve(CK_HOME, 'projects.json'); @@ -47,10 +47,14 @@ function stalenessIcon(dateStr) { function gitLogSince(projectPath, sinceDate) { if (!sinceDate || !existsSync(resolve(projectPath, '.git'))) return null; try { - const result = execSync(`git -C "${projectPath}" log --oneline --since="${sinceDate}"`, { - timeout: 3000, stdio: 'pipe', encoding: 'utf8', - }).trim(); - const commits = result.split('\n').filter(Boolean).length; + const result = spawnSync( + 'git', + ['-C', projectPath, 'log', '--oneline', `--since=${sinceDate}`], + { timeout: 3000, stdio: 'pipe', encoding: 'utf8' }, + ); + if (result.status !== 0) return null; + const output = result.stdout.trim(); + const commits = output.split('\n').filter(Boolean).length; return commits > 0 ? `${commits} commit${commits !== 1 ? 's' : ''} since last session` : null; } catch { return null; } }