diff --git a/README.md b/README.md index cbf9135..fcdec73 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,8 @@ UserPromptSubmit (every prompt) → injects top-12 active habits by confidence (~150-350 tokens depending on habit count), dynamically filtered to match the programming languages of files edited in the current session → also injects top-3 trigger-matched memories (~40-90 tokens), unless memories are disabled → total injection overhead: < 0.5% of a typical 100k context window + → logs one metadata line per injection to ~/.cc-habits/events.jsonl (timestamp, tool, + session, counts): the proof `cch view` renders as its capture-vs-injection graph → set CC_HABITS_INJECT=0 to disable SessionStart (session begins) diff --git a/package.json b/package.json index 1c48a17..17e3067 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-habits", - "version": "0.9.0", + "version": "0.9.1", "description": "The tool-agnostic memory layer for AI coding agents. Learns your coding habits from real edits and carries them across Claude Code, Cursor, Codex, Gemini, Cline, and any Git workflow.", "license": "MIT", "author": "Shreyan Basu Ray", diff --git a/src/cli.ts b/src/cli.ts index b312f64..1efe99e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,8 +24,8 @@ import { readTombstones, addTombstone, initMemoriesMd, readMemoriesMd, writeMemoriesMd, parseMemories, serialiseMemories, addMemoryTombstone, readMemoryTombstones, readHistory, appendHistory, logError, detectManualDeletes, applyMemoryUpdates, - getRuleHash, repoStorageContext, findRepoRoot, - type Memory, type Signal, type Habit, type StorageContext, + getRuleHash, repoStorageContext, findRepoRoot, readInjectEvents, eventsFilePath, + type Memory, type Signal, type Habit, type StorageContext, type InjectEvent, } from './storage'; import { applyUpdates, applyDecay, type AppliedChange } from './confidence'; import { runSelectMenu } from './menu'; @@ -49,7 +49,7 @@ import { detectInstalledTools, isAntigravityMigrated } from './detect'; import { SUPPORTED_TOOLS } from './supported'; import { explainProviderError } from './provider-errors'; -export const VERSION = '0.9.0'; +export const VERSION = '0.9.1'; // Turn a provider failure into a plain-language, actionable hint. Returns // undefined for non-provider errors so the caller can rethrow them. @@ -784,6 +784,78 @@ function renderMemoriesSummary(ctx?: StorageContext): void { process.stdout.write('\n'); } +// Activity graph: capture vs injection over the last 14 days ──────────────── +// The transparency proof for injection. Signals come from log.jsonl (what was +// captured), inject events from events.jsonl (what the prompt hook actually +// handed to a tool, recorded at the moment it happened). Both rows render only +// real logged lines; nothing here is inferred or estimated. +const ACTIVITY_DAYS = 14; +const ACTIVITY_BLOCKS = ['░', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +// One bar character per day, scaled to the row's own peak (labelled beside the +// row so the two rows are not read as sharing a scale). +export function activityRow(counts: number[]): string { + const max = Math.max(0, ...counts); + return counts + .map(n => (n === 0 || max === 0) ? ACTIVITY_BLOCKS[0] : ACTIVITY_BLOCKS[Math.min(8, Math.max(1, Math.ceil((n / max) * 8)))]) + .join(' '); +} + +export function renderActivitySection(ctx?: StorageContext): void { + let events: InjectEvent[] = []; + let signals: Signal[] = []; + try { events = readInjectEvents(ctx); } catch { events = []; } + try { signals = readSignals(undefined, ctx); } catch { signals = []; } + if (events.length === 0 && signals.length === 0) return; // nothing to show yet + + // Bucket by UTC date, matching the ISO timestamps both files store. + const days: string[] = []; + const now = Date.now(); + for (let i = ACTIVITY_DAYS - 1; i >= 0; i--) { + days.push(new Date(now - i * 86_400_000).toISOString().slice(0, 10)); + } + const idx = new Map(days.map((d, i) => [d, i])); + const cap: number[] = new Array(ACTIVITY_DAYS).fill(0); + const inj: number[] = new Array(ACTIVITY_DAYS).fill(0); + for (const s of signals) { + const i = idx.get((s.ts ?? '').slice(0, 10)); + if (i !== undefined) cap[i] = (cap[i] ?? 0) + 1; + } + for (const e of events) { + const i = idx.get((e.ts ?? '').slice(0, 10)); + if (i !== undefined) inj[i] = (inj[i] ?? 0) + 1; + } + const capTotal = cap.reduce((a, b) => a + b, 0); + const injTotal = inj.reduce((a, b) => a + b, 0); + + process.stdout.write(c(BOLD, ' ── Activity: capture vs injection ') + c(DIM, '─'.repeat(14)) + '\n'); + process.stdout.write( + ` ${c(DIM, 'captured')} ${c(CYAN, activityRow(cap))} ` + + c(DIM, `${capTotal} edit${capTotal === 1 ? '' : 's'} · peak ${Math.max(0, ...cap)}/day`) + '\n', + ); + process.stdout.write( + ` ${c(DIM, 'injected')} ${c(GREEN, activityRow(inj))} ` + + c(DIM, `${injTotal} prompt${injTotal === 1 ? '' : 's'} · peak ${Math.max(0, ...inj)}/day`) + '\n', + ); + process.stdout.write(c(DIM, ` ${days[0]!.slice(5)} ${'─'.repeat(15)}▶ ${days[ACTIVITY_DAYS - 1]!.slice(5)}`) + '\n'); + + const last = events[events.length - 1]; + if (last) { + const hab = `${last.habits} habit${last.habits === 1 ? '' : 's'}`; + const mem = last.memories > 0 ? `, ${last.memories} memor${last.memories === 1 ? 'y' : 'ies'}` : ''; + const sess = term(String(last.session_id)).slice(0, 12); + process.stdout.write( + ` ${c(GREEN, '✓')} last injected ${formatTimeAgo(last.ts)} into ${c(BOLD, term(last.source))}` + + c(DIM, ` · ${hab}${mem}${sess ? ` · session ${sess}` : ''}`) + '\n', + ); + process.stdout.write(c(DIM, ` proof, one line per injection: ${eventsFilePath(ctx)}\n`)); + } else { + process.stdout.write(c(DIM, ' No injections yet. Habits inject after they graduate (2+ sessions);\n')); + process.stdout.write(c(DIM, ' every injection is then logged to events.jsonl and charted here.\n')); + } + process.stdout.write('\n'); +} + // `cch view` with no subcommand: the one-glance unified view. Compact habits // (grouped) plus graduated memories, with no menu and no flags to remember. The // focused subviews (`cch view memories|prefs|habits`) and `--lang` still exist, @@ -801,7 +873,10 @@ export function cmdView(langFilter?: string, requestedScope?: ViewScope): number renderScopeBanner(scope); const code = renderHabitsView(langFilter, scope.ctx); // Memories are not language-scoped, so only fold them into the unfiltered view. - if (!langFilter) renderMemoriesSummary(scope.ctx); + if (!langFilter) { + renderMemoriesSummary(scope.ctx); + renderActivitySection(scope.ctx); + } return code; } diff --git a/src/extractor.ts b/src/extractor.ts index 0ae83ff..c01f5ce 100644 --- a/src/extractor.ts +++ b/src/extractor.ts @@ -36,6 +36,7 @@ Output ONLY a JSON array. No prose. Each object: CRITICAL: - Only extract habits observable from 2+ signals OR extremely clear single signals. - Stick to syntactic/stylistic patterns. Do not infer architectural intent. +- Prose/documentation edits (markdown) are habits too: extract the writing conventions they show (heading case, voice and person, callout style, formatting of UI terms) under a category like "Documentation" or "Writing Style". - CONSOLIDATE RELATED PREFERENCES: Do not split a single coding style pattern into multiple, hyper-specific rules (e.g., do not write one rule for 'parameter type annotations' and another for 'return type annotations'; instead, consolidate them under a single comprehensive rule like 'Use explicit TypeScript type annotations for function signatures'). Prefer broad, consolidated instructions. - DO NOT EXTRACT BUG FIXES OR MISTAKES: If a change represents a specific agent bug fix (such as forgetting a null check, failing to close a stream, or writing incorrect API arguments), do NOT capture it as a habit. Those belong exclusively in memories, not habits. Only capture repeating, positive coding style/formatting preferences. - Never output content marked . @@ -93,13 +94,37 @@ export async function extractRules( try { const updates = JSON.parse(cleaned) as unknown; - if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate); + if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate).filter(u => !isPromptInstructionEcho(u.rule)); } catch { // malformed, treat as no updates } return []; } +// Instruction-echo guard for habit rules. Small local models sometimes quote the +// prompt's own instruction text back as a "habit" (observed with llama3.2:1b: +// "Always follow the 'Single Declarative Sentence' rule in coding"). These +// normalized fragments are distinctive to the prompts above and never occur in +// a genuine style preference, so a rule containing one is a prompt echo. The +// mirror of the memory-side isPromptExampleEcho() guard. +const PROMPT_INSTRUCTION_FRAGMENTS: readonly string[] = [ + 'single declarative sentence', + 'consolidate related preferences', + 'do not extract bug fixes', + 'stating the preference', + 'stating the convention', + 'extremely clear single signals', +]; + +function isPromptInstructionEcho(rule: string): boolean { + try { + const norm = normalizeForEcho(rule); + return PROMPT_INSTRUCTION_FRAGMENTS.some(f => norm.includes(f)); + } catch { + return false; // fail-open: guard failure must never drop real habits + } +} + // The provider response is untrusted (a self-hosted/MITM'd or simply buggy endpoint // could return arbitrary JSON). Validate shape and coerce to exactly the known // fields, never spread provider-controlled objects into downstream logic. @@ -382,6 +407,7 @@ Output ONLY a JSON array. No prose. Each object: CRITICAL: - Only extract a convention visible CONSISTENTLY across multiple files or many times in one file. Skip one-offs. - Stick to observable syntactic/stylistic patterns (naming, quoting, typing, import style, comment style, error handling shape). Do NOT infer architecture, business logic, or intent. +- Markdown/prose files are first-class input: infer the WRITING conventions they consistently follow (heading case, voice and person, callout style, frontmatter shape, formatting of UI terms) under a category like "Documentation" or "Writing Style". - CONSOLIDATE: prefer broad rules over many hyper-specific ones. - Use "reinforce" when a sampled convention matches an existing habit; otherwise "create". - Do NOT extract bug fixes, TODOs, or one-off mistakes. Only durable positive conventions. @@ -467,7 +493,7 @@ export async function extractHabitsFromRepo( const cleaned = stripCodeFences(raw); try { const updates = JSON.parse(cleaned) as unknown; - if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate); + if (Array.isArray(updates)) return updates.filter(isValidUpdate).map(coerceUpdate).filter(u => !isPromptInstructionEcho(u.rule)); } catch { // malformed, treat as no updates } diff --git a/src/hook.ts b/src/hook.ts index 56aeeb7..66e47f9 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -17,7 +17,7 @@ import fs from 'fs'; import path from 'path'; import { - appendSignal, readSignals, countSignals, readHabitsMd, parseHabits, writeHabitsMd, + appendSignal, readSignals, countSignals, appendInjectEvent, readHabitsMd, parseHabits, writeHabitsMd, serialiseHabits, logError, sanitizeFilePath, detectManualDeletes, writeSnapshot, addTombstone, appendHistory, appendProvenance, readMemoriesMd, applyMemoryUpdates, parseMemories, @@ -628,7 +628,7 @@ function renderHabitsInjection(habits: InjectionHabit[]): string | null { const lines: string[] = [ '', - "Apply the developer's learned coding habits below when writing or editing code.", + "Apply the developer's learned habits below when writing or editing code or documentation.", 'These are durable preferences; honor them unless the user says otherwise.', ]; for (const [category, rules] of byCat) { @@ -862,7 +862,7 @@ function safeRead(fn: () => string): string { try { return fn(); } catch { return ''; } } -export function processUserPromptSubmit(data: Record, ctx?: StorageContext): string | null { +export function processUserPromptSubmit(data: Record, ctx?: StorageContext, source = 'claude-code'): string | null { if (!injectionEnabled()) return null; const promptText = typeof data['prompt'] === 'string' ? data['prompt'] : ''; const sessionId = String(data['session_id'] ?? data['sessionId'] ?? data['session'] ?? ''); @@ -880,6 +880,7 @@ export function processUserPromptSubmit(data: Record, ctx?: Sto : buildInjectionContext(globalMd, langs, ctx); let memoriesContext: string | null = null; + let memoriesInjected = 0; if (memoriesEnabled(ctx)) { try { const globalMem = readMemoriesMd(ctx); @@ -887,13 +888,36 @@ export function processUserPromptSubmit(data: Record, ctx?: Sto ? selectMergedInjectionMemories(globalMem, safeRead(() => readMemoriesMd(repoCtx)), promptText, ctx, repoCtx) : selectInjectionMemories(globalMem, promptText, ctx); memoriesContext = buildMemoryInjectionContext(relevant); + memoriesInjected = memoriesContext ? relevant.length : 0; } catch { // injection failures must never block a prompt } } - if (habitsContext && memoriesContext) return habitsContext + '\n' + memoriesContext; - return habitsContext ?? memoriesContext; + const combined = habitsContext && memoriesContext + ? habitsContext + '\n' + memoriesContext + : habitsContext ?? memoriesContext; + + // Proof of injection: record that learned context was actually returned to + // the tool (events.jsonl, rendered by `cch view` as the activity graph). + // Counts only, derived from the rendered block: each habit renders as one + // "- rule." line. Fail-open: a logging failure must never block the prompt. + if (combined) { + try { + appendInjectEvent({ + ts: new Date().toISOString(), + type: 'inject', + session_id: sessionId, + source, + habits: habitsContext ? habitsContext.split('\n').filter(l => l.startsWith('- ')).length : 0, + memories: memoriesInjected, + scope: repoCtx ? 'merged' : 'global', + }, ctx); + } catch { + // never let audit logging break injection + } + } + return combined; } // Builds the SessionStart reminder. Habits/memories are already injected via the @@ -1045,7 +1069,7 @@ export async function handleStop(): Promise { process.exit(0); } -export function handleUserPromptSubmit(): void { +export function handleUserPromptSubmit(adapter = 'claude-code'): void { let raw = ''; let oversized = false; process.stdin.setEncoding('utf-8'); @@ -1061,7 +1085,7 @@ export function handleUserPromptSubmit(): void { if (!oversized) { try { const data = raw.trim() ? (JSON.parse(raw) as Record) : {}; - const context = processUserPromptSubmit(data); + const context = processUserPromptSubmit(data, undefined, adapter); // Plain stdout is added to Claude's context for UserPromptSubmit. Exit 0 // always, injection must never block or fail a prompt. if (context) process.stdout.write(context + '\n'); @@ -1094,7 +1118,7 @@ export function hookMain(): void { try { if (event === 'post-tool-use') handlePostToolUse(adapter); else if (event === 'stop') await handleStop(); - else if (event === 'user-prompt-submit') handleUserPromptSubmit(); + else if (event === 'user-prompt-submit') handleUserPromptSubmit(adapter); else if (event === 'session-start') handleSessionStart(adapter); else if (KNOWN_UNSUPPORTED_EVENTS.has(event)) { // Deliberate no-op (e.g. subagent-stop). See hook-schema.ts for why: diff --git a/src/repo-scan.ts b/src/repo-scan.ts index f29a034..b445ad7 100644 --- a/src/repo-scan.ts +++ b/src/repo-scan.ts @@ -36,6 +36,13 @@ const SOURCE_EXTS = new Set([ '.vue', '.svelte', ]); +// Prose content worth sampling for writing-style inference: a technical writer's +// article corpus carries house style (voice, heading case, structure, callout +// conventions) exactly the way source files carry code style. Sampled through +// the same stride/byte budget as source files. Instruction docs (CLAUDE.md and +// friends) are excluded here because readDocFiles already reads them whole. +const PROSE_EXTS = new Set(['.md', '.mdx']); + // Path fragments that mark generated, vendored, or minified content to skip. const SKIP_FRAGMENTS = [ 'node_modules/', 'dist/', 'build/', 'vendor/', '.min.', 'coverage/', @@ -196,8 +203,17 @@ function walk(dir: string, root: string, depth: number): string[] { // Pick a representative, size-bounded sample spread across directories. function sampleSourceFiles(repoRoot: string): RepoFile[] { + const docSet = new Set(DOC_CANDIDATES.map(d => d.toLowerCase())); const all = listCandidateFiles(repoRoot) - .filter(rel => SOURCE_EXTS.has(path.extname(rel).toLowerCase())) + .filter(rel => { + const ext = path.extname(rel).toLowerCase(); + if (SOURCE_EXTS.has(ext)) return true; + // Prose pages count, but not the instruction docs readDocFiles handles, + // and not README (project boilerplate, weak style signal). + return PROSE_EXTS.has(ext) + && !docSet.has(rel.toLowerCase()) + && !path.basename(rel).toLowerCase().startsWith('readme'); + }) .filter(rel => !skip(rel)); if (all.length === 0) return []; @@ -287,7 +303,7 @@ export async function scanRepo(opts: ScanOptions = {}): Promise process.stdout.write( `\n⚠️ cc-habits repository scan\n` + ` Reads ${files.length} source file${files.length === 1 ? '' : 's'}${docNote} ` + - `(code and instruction docs only; data, lockfiles, and assets are skipped).\n` + + `(code, prose pages, and instruction docs; data, lockfiles, and assets are skipped).\n` + ` Redacted context is sent to ${resolveProviderLabel()} and consumes tokens.\n` ); if (opts.confirm) { diff --git a/src/storage.ts b/src/storage.ts index bc6c0eb..5c58e6b 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -452,6 +452,91 @@ export function countSignals(sessionId?: string, ctx?: StorageContext): number { return count; } +// Injection events (events.jsonl) ────────────────────────────────────────── +// Proof-of-injection audit trail, separate from log.jsonl on purpose: every +// log.jsonl consumer (extraction gating, liveness, banners) assumes one line +// equals one captured edit, so injection events get their own file rather than +// changing what "signal" means. Each line records that the prompt hook returned +// learned context to a tool: metadata only (counts, tool, session), never the +// injected text itself, which is already visible in preferences.md/habits.md. + +export interface InjectEvent { + ts: string; + type: 'inject'; + session_id: string; + source: string; + habits: number; + memories: number; + scope: 'global' | 'merged'; +} + +const EVENTS_HEADER = + '// cc-habits injection log (events.jsonl): append-only proof of when learned\n' + + '// context was actually handed to a tool. One line per prompt where the\n' + + '// UserPromptSubmit hook returned habits or memories: timestamp, tool, session,\n' + + '// and how many rules went in. Metadata only, the injected text itself is your\n' + + '// own preferences.md/habits.md content. `cch view` renders this as the\n' + + '// activity graph. Empty? No injection has happened yet: habits must graduate\n' + + '// (2+ sessions) before anything is injected.\n'; + +export function eventsFilePath(ctx?: StorageContext): string { + return path.join(getPaths(ctx).habitsDir, 'events.jsonl'); +} + +export function appendInjectEvent(event: InjectEvent, ctx?: StorageContext): void { + ensureDirs(ctx); + const file = eventsFilePath(ctx); + try { + if (!fs.existsSync(file) || fs.statSync(file).size === 0) { + safeWrite(file, EVENTS_HEADER); + } + } catch { /* header seeding is best-effort; never block a prompt */ } + const safe: InjectEvent = { + ...event, + session_id: String(event.session_id).replace(STORAGE_CONTROL_CHARS, '').slice(0, 128), + source: String(event.source).replace(STORAGE_CONTROL_CHARS, '').slice(0, 32), + }; + safeAppend(file, JSON.stringify(safe) + '\n'); + trimIfNeeded(file, LOG_ROTATE_LINES); +} + +export function readInjectEvents(ctx?: StorageContext): InjectEvent[] { + const file = eventsFilePath(ctx); + // fd-based open -> fstat -> read, mirroring countSignals: stat'ing the path and + // then reading it separately is a TOCTOU race (the file could be swapped for a + // symlink or grow between the calls), so size-check and read the same open fd. + let raw: string; + let fd: number | null = null; + try { + const oNoFollow: number = (fs.constants as Record)['O_NOFOLLOW'] ?? 0; + fd = fs.openSync(file, fs.constants.O_RDONLY | oNoFollow); + if (fs.fstatSync(fd).size > MAX_LOG_READ_BYTES) { + logError(`readInjectEvents: events.jsonl exceeds ${MAX_LOG_READ_BYTES} bytes; skipping read`, ctx); + fs.closeSync(fd); + return []; + } + raw = fs.readFileSync(fd, 'utf-8'); + fs.closeSync(fd); + } catch { + if (fd !== null) { + try { fs.closeSync(fd); } catch { /* already closed */ } + } + return []; // absent, symlinked, or unreadable: no events yet + } + const events: InjectEvent[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const ev = JSON.parse(trimmed) as InjectEvent; + if (ev && ev.type === 'inject' && typeof ev.ts === 'string') events.push(ev); + } catch { + // header comment or malformed line: skip + } + } + return events; +} + export function readHabitsMd(ctx?: StorageContext): string { const paths = getPaths(ctx); if (!fs.existsSync(paths.habitsFile)) return HABITS_HEADER; diff --git a/tests-ts/extractor.test.ts b/tests-ts/extractor.test.ts index 53f4f92..8a8851b 100644 --- a/tests-ts/extractor.test.ts +++ b/tests-ts/extractor.test.ts @@ -160,3 +160,36 @@ describe('extractMemoryCandidates', () => { expect(result[0].text).toContain('overwrite existing hook arrays'); }); }); + +describe('extractRules prompt-instruction echo guard', () => { + beforeEach(() => { + process.env['CC_HABITS_PROVIDER'] = 'anthropic'; + process.env['ANTHROPIC_API_KEY'] = 'test-key'; + }); + + afterEach(() => { + delete process.env['CC_HABITS_PROVIDER']; + delete process.env['ANTHROPIC_API_KEY']; + }); + + it('drops rules that quote the prompt instruction text, keeps real ones', async () => { + const payload = [ + { category: 'Exercises|Guidelines', rule: "Always follow the 'Single Declarative Sentence' rule in coding. This means stating a preference clearly", decision: 'create', matched_habit_id: '', reasoning: '' }, + { category: 'Style', rule: 'CONSOLIDATE RELATED PREFERENCES into broad rules', decision: 'create', matched_habit_id: '', reasoning: '' }, + { category: 'TypeScript', rule: 'Use explicit type annotations for function signatures', decision: 'create', matched_habit_id: '', reasoning: '' }, + ]; + mockCreate.mockResolvedValueOnce({ content: [{ type: 'text', text: JSON.stringify(payload) }] }); + const result = await extractRules([], '# Coding habits\n'); + expect(result).toHaveLength(1); + expect(result[0].rule).toBe('Use explicit type annotations for function signatures'); + }); + + it('does not drop ordinary documentation habits', async () => { + const payload = [ + { category: 'Documentation', rule: 'Use sentence case for headings and second-person voice', decision: 'create', matched_habit_id: '', reasoning: '' }, + ]; + mockCreate.mockResolvedValueOnce({ content: [{ type: 'text', text: JSON.stringify(payload) }] }); + const result = await extractRules([], ''); + expect(result).toHaveLength(1); + }); +}); diff --git a/tests-ts/inject-events.test.ts b/tests-ts/inject-events.test.ts new file mode 100644 index 0000000..b02059f --- /dev/null +++ b/tests-ts/inject-events.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for the injection-proof pipeline (v0.9.1): + * - appendInjectEvent / readInjectEvents: append-only events.jsonl with a + * self-describing header, control-char stripping, and malformed-line skips + * - processUserPromptSubmit: logs one inject event per prompt that actually + * returned learned context, and none when nothing was injected + * - activityRow: bar scaling for the `cch view` activity graph + * - isPromptInstructionEcho path: extraction drops habit rules that quote the + * prompt's own instruction text (via parse-level filtering) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + storagePaths, appendInjectEvent, readInjectEvents, eventsFilePath, + type InjectEvent, +} from '../src/storage'; +import { processUserPromptSubmit } from '../src/hook'; +import { activityRow } from '../src/cli'; + +const origStorage = { ...storagePaths }; +const origCwd = process.cwd(); +let tmpDir: string; + +const ACTIVE_HABITS = ` +# Coding habits + +## TypeScript + +- Use explicit return types on exported functions. Confidence: 0.80 + - Sessions seen: 3 + +## Naming + +- Use camelCase for variables. Confidence: 0.90 + - Sessions seen: 4 +`; + +const LEARNING_ONLY = ` +# Coding habits + +## Learning (not yet active) + +- [Imports] Prefer named imports. Confidence: 0.50 + - Sessions seen: 1 +`; + +function makeEvent(overrides: Partial = {}): InjectEvent { + return { + ts: new Date().toISOString(), + type: 'inject', + session_id: 'sess-1', + source: 'claude-code', + habits: 2, + memories: 0, + scope: 'global', + ...overrides, + }; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-habits-events-')); + storagePaths.habitsDir = tmpDir; + storagePaths.habitsFile = path.join(tmpDir, 'habits.md'); + storagePaths.memoriesFile = path.join(tmpDir, 'memories.md'); + storagePaths.memoryTombstonesFile = path.join(tmpDir, '.memory-tombstones.json'); + storagePaths.logFile = path.join(tmpDir, 'log.jsonl'); + storagePaths.errorLog = path.join(tmpDir, 'error.log'); + // Run from the temp dir so resolveRepoCtx cannot find this repository's own + // .cch/ store and layer it into the merged injection under test. + process.chdir(tmpDir); + delete process.env['CC_HABITS_INJECT']; + delete process.env['CC_HABITS_MEMORIES']; +}); + +afterEach(() => { + process.chdir(origCwd); + Object.assign(storagePaths, origStorage); + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env['CC_HABITS_INJECT']; + delete process.env['CC_HABITS_MEMORIES']; +}); + +describe('appendInjectEvent / readInjectEvents', () => { + it('appends events and reads them back in order', () => { + appendInjectEvent(makeEvent({ session_id: 'a' })); + appendInjectEvent(makeEvent({ session_id: 'b', habits: 5 })); + const events = readInjectEvents(); + expect(events).toHaveLength(2); + expect(events[0].session_id).toBe('a'); + expect(events[1].habits).toBe(5); + }); + + it('seeds a self-describing header that readers skip', () => { + appendInjectEvent(makeEvent()); + const raw = fs.readFileSync(eventsFilePath(), 'utf-8'); + expect(raw.startsWith('// cc-habits injection log')).toBe(true); + expect(readInjectEvents()).toHaveLength(1); + }); + + it('returns empty for a missing file and skips malformed lines', () => { + expect(readInjectEvents()).toEqual([]); + fs.writeFileSync(eventsFilePath(), 'not json\n{"type":"other"}\n'); + appendInjectEvent(makeEvent()); + expect(readInjectEvents()).toHaveLength(1); + }); + + it('strips control characters from session_id and source', () => { + appendInjectEvent(makeEvent({ session_id: 's\x1b[2Jss', source: 'clau\x00de' })); + const [ev] = readInjectEvents(); + expect(ev.session_id).toBe('s[2Jss'); + expect(ev.source).toBe('claude'); + }); +}); + +describe('processUserPromptSubmit inject-event logging', () => { + it('logs one event with counts when habits are injected', () => { + fs.writeFileSync(storagePaths.habitsFile, ACTIVE_HABITS); + const out = processUserPromptSubmit({ session_id: 'sess-9', prompt: 'refactor this' }); + expect(out).toContain(''); + const events = readInjectEvents(); + expect(events).toHaveLength(1); + expect(events[0].session_id).toBe('sess-9'); + expect(events[0].source).toBe('claude-code'); + expect(events[0].habits).toBe(2); + expect(events[0].scope).toBe('global'); + }); + + it('records the adapter that fired the hook', () => { + fs.writeFileSync(storagePaths.habitsFile, ACTIVE_HABITS); + processUserPromptSubmit({ session_id: 's', prompt: 'x' }, undefined, 'gemini'); + expect(readInjectEvents()[0].source).toBe('gemini'); + }); + + it('logs nothing when nothing was injected (learning-only store)', () => { + fs.writeFileSync(storagePaths.habitsFile, LEARNING_ONLY); + const out = processUserPromptSubmit({ session_id: 's', prompt: 'x' }); + expect(out).toBeNull(); + expect(fs.existsSync(eventsFilePath())).toBe(false); + expect(readInjectEvents()).toEqual([]); + }); + + it('logs nothing when injection is disabled', () => { + fs.writeFileSync(storagePaths.habitsFile, ACTIVE_HABITS); + process.env['CC_HABITS_INJECT'] = '0'; + expect(processUserPromptSubmit({ session_id: 's', prompt: 'x' })).toBeNull(); + expect(readInjectEvents()).toEqual([]); + }); + + it('event count matches the number of rules in the injected block', () => { + fs.writeFileSync(storagePaths.habitsFile, ACTIVE_HABITS); + const out = processUserPromptSubmit({ session_id: 's', prompt: 'x' }) ?? ''; + const rendered = out.split('\n').filter(l => l.startsWith('- ')).length; + expect(readInjectEvents()[0].habits).toBe(rendered); + }); +}); + +describe('activityRow', () => { + it('renders empty days as the zero block', () => { + expect(activityRow([0, 0, 0])).toBe('░ ░ ░'); + }); + + it('scales to the row peak and never renders zero as a bar', () => { + const row = activityRow([0, 1, 8]).split(' '); + expect(row[0]).toBe('░'); + expect(row[2]).toBe('█'); + expect(row[1]).not.toBe('░'); + }); + + it('handles a flat all-equal row', () => { + const row = activityRow([3, 3, 3]).split(' '); + expect(new Set(row).size).toBe(1); + expect(row[0]).toBe('█'); + }); +}); diff --git a/tests-ts/repo-scan.test.ts b/tests-ts/repo-scan.test.ts index 36845c1..cea9d33 100644 --- a/tests-ts/repo-scan.test.ts +++ b/tests-ts/repo-scan.test.ts @@ -197,3 +197,17 @@ describe('memoriesEnabled default', () => { expect(memoriesEnabled()).toBe(false); }); }); + +describe('prose page sampling (writer support)', () => { + it('includes article .md files in the source sample, excluding instruction docs and README', async () => { + fs.mkdirSync(path.join(repoDir, 'articles'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'articles', 'getting-started.md'), '# Get started\n\nYou can set up a workspace.\n'); + fs.writeFileSync(path.join(repoDir, 'README.md'), '# Boilerplate\n'); + await scanRepo({ cwd: repoDir }); + const files = vi.mocked(extractor.extractHabitsFromRepo).mock.calls[0][0] as { path: string }[]; + const names = files.map(f => f.path); + expect(names).toContain(path.join('articles', 'getting-started.md')); + expect(names).not.toContain('README.md'); + expect(names).not.toContain('CLAUDE.md'); // handled by readDocFiles, not the source sample + }); +});