mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
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:
147
skills/ck/SKILL.md
Normal file
147
skills/ck/SKILL.md
Normal 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 `{}`.
|
||||||
44
skills/ck/commands/forget.mjs
Normal file
44
skills/ck/commands/forget.mjs
Normal 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.`);
|
||||||
24
skills/ck/commands/info.mjs
Normal file
24
skills/ck/commands/info.mjs
Normal 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
143
skills/ck/commands/init.mjs
Normal 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));
|
||||||
41
skills/ck/commands/list.mjs
Normal file
41
skills/ck/commands/list.mjs
Normal 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)');
|
||||||
198
skills/ck/commands/migrate.mjs
Normal file
198
skills/ck/commands/migrate.mjs
Normal 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);
|
||||||
41
skills/ck/commands/resume.mjs
Normal file
41
skills/ck/commands/resume.mjs
Normal 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
210
skills/ck/commands/save.mjs
Normal 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.`);
|
||||||
384
skills/ck/commands/shared.mjs
Normal file
384
skills/ck/commands/shared.mjs
Normal 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');
|
||||||
|
}
|
||||||
217
skills/ck/hooks/session-start.mjs
Normal file
217
skills/ck/hooks/session-start.mjs
Normal 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') }));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user