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>
This commit is contained in:
Sreedhara GS
2026-03-27 16:30:39 +09:00
parent cc60bf6b65
commit 1e226ba556
10 changed files with 1449 additions and 0 deletions

147
skills/ck/SKILL.md Normal file
View File

@@ -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/<name>/
├── 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: <name>
Description: <description>
Stack: <stack>
Goal: <goal>
Do-nots: <constraints or "None">
Repo: <repo or "none">
```
Wait for user approval. Apply any edits. Then pipe confirmed JSON to save.mjs --init:
```bash
echo '<confirmed-json>' | 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: '<summary>' — save this? (yes / edit)"`
Wait for confirmation. Then pipe to save.mjs:
```bash
echo '<json>' | 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 '<name>'. 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 `{}`.

View File

@@ -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.`);

View File

@@ -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));

143
skills/ck/commands/init.mjs Normal file
View File

@@ -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));

View File

@@ -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)');

View File

@@ -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);

View File

@@ -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));

210
skills/ck/commands/save.mjs Normal file
View File

@@ -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 '<json>' | node save.mjs
* JSON schema: { summary, leftOff, nextSteps[], decisions[{what,why}], blockers[], goal? }
*
* Usage (init — first registration):
* echo '<json>' | 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.`);

View File

@@ -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 = [
`<!-- Generated by ck v2 — edit context.json instead -->`,
`# 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');
}

View File

@@ -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 <name> · /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') }));
}