🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@claudemini/shit-cli

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@claudemini/shit-cli - npm Package Compare versions

Package version was removed
This package version has been unpublished, mostly likely due to security reasons
Comparing version
1.3.0
to
1.8.2
+732
lib/review.js
/**
* Structured code review over session artifacts.
* Inspired by mco's findings model:
* - fixed findings schema
* - evidence-grounded findings
* - deterministic severity/confidence
* - dedup + aggregation for CI/PR workflows
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { createHash } from 'crypto';
import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
import { redactSecrets } from './redact.js';
import { dispatchWebhook } from './webhook.js';
const SEVERITY_SCORE = {
info: 1,
low: 2,
medium: 3,
high: 4,
critical: 5,
};
const CONFIDENCE_SCORE = {
low: 1,
medium: 2,
high: 3,
};
function parseArgs(args) {
const options = {
format: 'text', // text | json | markdown
strict: false,
minSeverity: 'info',
failOn: 'high',
sessionId: null,
recent: 1,
all: false,
help: false,
};
for (const arg of args) {
if (arg === '--json') {
options.format = 'json';
continue;
}
if (arg === '--markdown' || arg === '--md') {
options.format = 'markdown';
continue;
}
if (arg === '--strict') {
options.strict = true;
continue;
}
if (arg === '--all') {
options.all = true;
continue;
}
if (arg === '--help' || arg === '-h') {
options.help = true;
continue;
}
if (arg.startsWith('--recent=')) {
const value = Number.parseInt(arg.split('=')[1], 10);
if (Number.isFinite(value) && value > 0) {
options.recent = value;
}
continue;
}
if (arg.startsWith('--min-severity=')) {
const value = (arg.split('=')[1] || '').toLowerCase();
if (SEVERITY_SCORE[value]) {
options.minSeverity = value;
}
continue;
}
if (arg.startsWith('--fail-on=')) {
const value = (arg.split('=')[1] || '').toLowerCase();
if (SEVERITY_SCORE[value]) {
options.failOn = value;
}
continue;
}
if (!arg.startsWith('-') && !options.sessionId) {
options.sessionId = arg;
}
}
return options;
}
function printUsage() {
console.log('Usage: shit review [session-id] [options]');
console.log('');
console.log('Options:');
console.log(' --json Output JSON report');
console.log(' --markdown, --md Output Markdown report');
console.log(' --recent=<n> Review latest n sessions (default: 1)');
console.log(' --all Review all sessions');
console.log(' --min-severity=<level> Filter findings below severity');
console.log(' --fail-on=<level> Strict mode failure threshold (default: high)');
console.log(' --strict Exit non-zero when findings hit fail-on threshold');
}
function getSessionDirs(logDir) {
if (!existsSync(logDir)) {
return [];
}
const sessions = [];
for (const name of readdirSync(logDir)) {
if (!SESSION_ID_REGEX.test(name)) {
continue;
}
const fullPath = join(logDir, name);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (!stat.isDirectory()) {
continue;
}
sessions.push({
id: name,
dir: fullPath,
mtime: stat.mtime.getTime(),
});
}
return sessions.sort((a, b) => b.mtime - a.mtime);
}
function loadJsonIfExists(filePath, fallback = null) {
if (!existsSync(filePath)) {
return fallback;
}
try {
return JSON.parse(readFileSync(filePath, 'utf-8'));
} catch {
return fallback;
}
}
function normalizeEvidence(evidence) {
return evidence
.filter(Boolean)
.map(item => String(item).trim())
.filter(Boolean);
}
function normalizeFinding(input) {
return {
id: input.id || '',
rule_id: input.rule_id || 'review.generic',
category: input.category || 'maintainability',
severity: input.severity || 'low',
confidence: input.confidence || 'medium',
summary: input.summary || '',
details: input.details || '',
suggestion: input.suggestion || '',
location: input.location || null,
evidence: normalizeEvidence(input.evidence || []),
sessions: Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [],
};
}
function normalizeLocation(location) {
if (!location || typeof location !== 'object') {
return '';
}
const file = location.file || '';
const line = location.line || '';
const column = location.column || '';
return `${file}:${line}:${column}`;
}
function canonicalFindingKey(finding) {
return [
String(finding.rule_id || '').trim().toLowerCase(),
String(finding.category || '').trim().toLowerCase(),
normalizeLocation(finding.location),
String(finding.summary || '').trim().toLowerCase(),
].join('|');
}
function buildFindingId(finding) {
const canonical = canonicalFindingKey(finding);
const digest = createHash('sha256').update(canonical).digest('hex');
return `f_${digest.slice(0, 16)}`;
}
function dedupeFindings(findings) {
const map = new Map();
for (const candidate of findings) {
const finding = normalizeFinding(candidate);
const key = canonicalFindingKey(finding);
finding.id = buildFindingId(finding);
if (!map.has(key)) {
map.set(key, finding);
continue;
}
const prev = map.get(key);
const merged = {
...prev,
severity: SEVERITY_SCORE[finding.severity] > SEVERITY_SCORE[prev.severity] ? finding.severity : prev.severity,
confidence: (CONFIDENCE_SCORE[finding.confidence] || 0) > (CONFIDENCE_SCORE[prev.confidence] || 0)
? finding.confidence
: prev.confidence,
details: prev.details.length >= finding.details.length ? prev.details : finding.details,
suggestion: prev.suggestion || finding.suggestion,
evidence: [...new Set([...prev.evidence, ...finding.evidence])],
sessions: [...new Set([...(prev.sessions || []), ...(finding.sessions || [])])],
};
merged.id = prev.id;
map.set(key, merged);
}
return [...map.values()].sort((a, b) => SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity]);
}
function getAllCommands(summary) {
const commandGroups = summary?.activity?.commands || {};
return Object.values(commandGroups).flatMap(list => Array.isArray(list) ? list : []);
}
const WINDOWS_ABS_PATH_REGEX = /(^|[\s([{:="'`])([A-Za-z]:\\(?:[^\\\s"'`|<>]+\\){1,}[^\\\s"'`|<>]+)/g;
const UNIX_SENSITIVE_ROOT_PATH_REGEX = /(^|[\s([{:="'`])(\/(?:Users|home|var|tmp|opt|etc|private|Volumes|workspace|workspaces|usr)(?:\/[^\s"'`|<>]+){2,})/g;
const UNIX_FILE_PATH_WITH_EXT_REGEX = /(^|[\s([{:="'`])(\/(?:[^\/\s"'`|<>]+\/){2,}[^\/\s"'`|<>]*\.[A-Za-z0-9]{1,10})/g;
function sanitizeErrorDetails(message) {
const text = String(message || 'Tool returned an error');
const redacted = redactSecrets(text)
.replace(WINDOWS_ABS_PATH_REGEX, '$1[PATH]')
.replace(UNIX_SENSITIVE_ROOT_PATH_REGEX, '$1[PATH]')
.replace(UNIX_FILE_PATH_WITH_EXT_REGEX, '$1[PATH]')
.replace(/\b[A-Z_]{2,}=[^\s"'`|<>]+/g, '[ENV_REDACTED]');
return redacted.slice(0, 200);
}
function generateFindings(summary, state, sessionId) {
const findings = [];
const reviewHints = summary?.review_hints || {};
const changes = summary?.changes?.files || [];
const activity = summary?.activity || {};
const errors = Array.isArray(activity.errors) ? activity.errors : [];
const commands = getAllCommands(summary);
const modifiedSourceFiles = changes.filter(file =>
file.category === 'source' &&
Array.isArray(file.operations) &&
file.operations.some(op => op !== 'read')
);
if (modifiedSourceFiles.length > 0 && !reviewHints.tests_run) {
findings.push({
rule_id: 'testing.no_tests_after_source_changes',
category: 'testing',
severity: modifiedSourceFiles.length >= 5 ? 'high' : 'medium',
confidence: 'high',
summary: 'Source code changed without test execution evidence',
details: `Detected ${modifiedSourceFiles.length} modified source file(s), but no test command was recorded.`,
suggestion: 'Run targeted tests for changed modules and attach the command/output in the session.',
evidence: [
`session_id=${sessionId}`,
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
`modified_source_files=${modifiedSourceFiles.length}`,
],
sessions: [sessionId],
});
}
if (reviewHints.migration_added && !reviewHints.tests_run) {
findings.push({
rule_id: 'correctness.migration_without_tests',
category: 'correctness',
severity: 'high',
confidence: 'high',
summary: 'Database migration changed without test validation',
details: 'Migration-related changes are present and no test run was captured.',
suggestion: 'Run migration verification and regression tests before merge.',
evidence: [
`session_id=${sessionId}`,
`review_hints.migration_added=${Boolean(reviewHints.migration_added)}`,
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
],
sessions: [sessionId],
});
}
if (reviewHints.config_changed && !reviewHints.build_verified) {
findings.push({
rule_id: 'reliability.config_without_build',
category: 'reliability',
severity: 'medium',
confidence: 'high',
summary: 'Configuration changed without build verification',
details: 'Config-level edits were detected, but no build/compile command was recorded.',
suggestion: 'Run a full build/compile and include output to reduce deployment risk.',
evidence: [
`session_id=${sessionId}`,
`review_hints.config_changed=${Boolean(reviewHints.config_changed)}`,
`review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
],
sessions: [sessionId],
});
}
if (reviewHints.large_change && !reviewHints.tests_run && !reviewHints.build_verified) {
findings.push({
rule_id: 'maintainability.large_change_without_validation',
category: 'maintainability',
severity: 'high',
confidence: 'medium',
summary: 'Large change set lacks both test and build signals',
details: 'A large multi-file change was made without recorded test/build validation.',
suggestion: 'Split change into smaller PRs or provide CI/local validation evidence.',
evidence: [
`session_id=${sessionId}`,
`review_hints.large_change=${Boolean(reviewHints.large_change)}`,
`review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
`review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
],
sessions: [sessionId],
});
}
if (Array.isArray(reviewHints.files_without_tests) && reviewHints.files_without_tests.length > 0) {
const sample = reviewHints.files_without_tests.slice(0, 3);
findings.push({
rule_id: 'testing.files_without_tests',
category: 'testing',
severity: reviewHints.files_without_tests.length > 5 ? 'high' : 'medium',
confidence: 'medium',
summary: 'Modified source files have no corresponding test changes',
details: `Detected ${reviewHints.files_without_tests.length} file(s) without related test updates.`,
suggestion: 'Add/adjust tests for changed source files or document why tests are not needed.',
location: { file: sample[0] },
evidence: [
`session_id=${sessionId}`,
`review_hints.files_without_tests_count=${reviewHints.files_without_tests.length}`,
...sample.map(file => `file_without_test=${file}`),
],
sessions: [sessionId],
});
}
for (const error of errors.slice(-3)) {
findings.push({
rule_id: 'reliability.tool_error',
category: 'reliability',
severity: 'medium',
confidence: 'high',
summary: `Tool error detected: ${error.tool || 'unknown tool'}`,
details: sanitizeErrorDetails(error.message),
suggestion: 'Confirm the error is resolved and include successful rerun evidence.',
evidence: [
`session_id=${sessionId}`,
'activity.errors_present=true',
`error_tool=${error.tool || 'unknown'}`,
],
sessions: [sessionId],
});
}
const rollbackCommands = commands.filter(cmd =>
/\bgit\s+(reset|restore|revert)\b/i.test(cmd) ||
/\bgit\s+checkout\s+--\b/i.test(cmd) ||
/\bundo\b/i.test(cmd)
);
if (rollbackCommands.length >= 2) {
findings.push({
rule_id: 'maintainability.frequent_rollback_commands',
category: 'maintainability',
severity: rollbackCommands.length >= 4 ? 'high' : 'medium',
confidence: 'medium',
summary: 'Frequent rollback/undo commands detected in one session',
details: `Detected ${rollbackCommands.length} rollback-style command(s), which may indicate unstable iteration.`,
suggestion: 'Split the change and validate each step to reduce back-and-forth edits.',
evidence: [
`session_id=${sessionId}`,
`rollback_command_count=${rollbackCommands.length}`,
...rollbackCommands.slice(0, 3).map(cmd => `rollback_cmd=${cmd.slice(0, 80)}`),
],
sessions: [sessionId],
});
}
if ((summary?.session?.risk || '') === 'high') {
findings.push({
rule_id: 'maintainability.high_risk_session',
category: 'maintainability',
severity: 'medium',
confidence: 'medium',
summary: 'Session-level risk was classified as high',
details: 'The extraction engine labeled this session as high risk based on change shape.',
suggestion: 'Require manual reviewer sign-off and verify rollback/checkpoint strategy.',
evidence: [
`session_id=${sessionId}`,
`summary.session.risk=${summary.session.risk}`,
],
sessions: [sessionId],
});
}
if (state?.checkpoints && state.checkpoints.length > 0) {
findings.push({
rule_id: 'maintainability.checkpoints_present',
category: 'maintainability',
severity: 'info',
confidence: 'high',
summary: 'Checkpoint chain exists for this session',
details: `Detected ${state.checkpoints.length} checkpoint commit(s), enabling safer rollback.`,
suggestion: 'Use "shit rewind <checkpoint>" if post-merge regression appears.',
evidence: [
`session_id=${sessionId}`,
`state.checkpoints_count=${state.checkpoints.length}`,
],
sessions: [sessionId],
});
}
return findings;
}
function generateSummaryMissingFinding(sessionId) {
return {
rule_id: 'integrity.missing_summary',
category: 'correctness',
severity: 'high',
confidence: 'high',
summary: 'Session summary artifact is missing or invalid',
details: `summary.json was missing/corrupted for session ${sessionId}, review confidence is degraded.`,
suggestion: 'Re-run session processing or inspect raw events to reconstruct summary artifacts.',
evidence: [`session_id=${sessionId}`],
sessions: [sessionId],
};
}
function generateCrossSessionFindings(snapshots) {
if (snapshots.length < 2) {
return [];
}
const fileToSessions = new Map();
for (const snap of snapshots) {
const files = Array.isArray(snap.summary?.changes?.files) ? snap.summary.changes.files : [];
for (const file of files) {
if (file.category !== 'source') {
continue;
}
if (!fileToSessions.has(file.path)) {
fileToSessions.set(file.path, new Set());
}
fileToSessions.get(file.path).add(snap.id);
}
}
const hotFiles = [...fileToSessions.entries()]
.filter(([, sessions]) => sessions.size >= 2)
.sort((a, b) => b[1].size - a[1].size);
if (hotFiles.length === 0) {
return [];
}
const HOT_FILE_LIMIT = 3;
return hotFiles.slice(0, HOT_FILE_LIMIT).map(([filePath, sessionSet], idx) => ({
rule_id: 'maintainability.hot_file_across_sessions',
category: 'maintainability',
severity: sessionSet.size >= 4 ? 'high' : 'medium',
confidence: 'medium',
summary: 'Same source file modified across multiple reviewed sessions',
details: `File "${filePath}" was modified in ${sessionSet.size} reviewed sessions, which may signal unresolved churn.`,
suggestion: 'Investigate root cause and consolidate related changes into a single reviewed thread.',
location: { file: filePath },
evidence: [
`hot_file=${filePath}`,
`session_count=${sessionSet.size}`,
`hot_file_rank=${idx + 1}`,
...[...sessionSet].slice(0, 5).map(id => `session_id=${id}`),
],
sessions: [...sessionSet],
}));
}
function filterBySeverity(findings, minSeverity) {
const threshold = SEVERITY_SCORE[minSeverity] || SEVERITY_SCORE.info;
return findings.filter(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
}
function shouldFailByThreshold(findings, failOn) {
const threshold = SEVERITY_SCORE[failOn] || SEVERITY_SCORE.high;
return findings.some(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
}
function computeVerdict(findings, failOn) {
if (shouldFailByThreshold(findings, failOn)) {
return 'fail';
}
if (findings.length > 0) {
return 'warn';
}
return 'pass';
}
function buildSessionSnapshot(sessionEntry) {
const summaryPath = join(sessionEntry.dir, 'summary.json');
const statePath = join(sessionEntry.dir, 'state.json');
const metadataPath = join(sessionEntry.dir, 'metadata.json');
const summary = loadJsonIfExists(summaryPath);
const state = loadJsonIfExists(statePath, {});
const metadata = loadJsonIfExists(metadataPath, {});
return {
id: sessionEntry.id,
summary,
state,
metadata,
source: {
summary_file: summaryPath,
state_file: statePath,
},
};
}
function buildReport(selectedSessions, options) {
const snapshots = selectedSessions.map(buildSessionSnapshot);
const rawFindings = [];
for (const snap of snapshots) {
if (!snap.summary) {
rawFindings.push(generateSummaryMissingFinding(snap.id));
continue;
}
rawFindings.push(...generateFindings(snap.summary, snap.state, snap.id));
}
rawFindings.push(...generateCrossSessionFindings(snapshots.filter(snap => snap.summary)));
const deduped = dedupeFindings(rawFindings);
const filteredFindings = filterBySeverity(deduped, options.minSeverity);
const verdict = computeVerdict(filteredFindings, options.failOn);
return {
schema_version: '1.1',
generated_at: new Date().toISOString(),
policy: {
min_severity: options.minSeverity,
fail_on: options.failOn,
strict: options.strict,
recent: options.all ? 'all' : options.recent,
session_filter: options.sessionId || null,
},
verdict,
sessions: snapshots.map(snap => ({
id: snap.id,
type: snap.summary?.session?.type || snap.metadata?.type || 'unknown',
risk: snap.summary?.session?.risk || snap.metadata?.risk || 'unknown',
duration_minutes: snap.summary?.session?.duration_minutes ?? snap.metadata?.duration_minutes ?? 0,
source: snap.source,
})),
findings: filteredFindings,
stats: {
reviewed_sessions: snapshots.length,
total_findings: filteredFindings.length,
by_severity: Object.keys(SEVERITY_SCORE).reduce((acc, key) => {
acc[key] = filteredFindings.filter(f => f.severity === key).length;
return acc;
}, {}),
},
};
}
function printHumanReport(report) {
console.log(`🧪 Code Review`);
console.log(` Sessions: ${report.stats.reviewed_sessions}`);
console.log(` Verdict: ${report.verdict.toUpperCase()} | Findings: ${report.findings.length}`);
console.log(` Policy: min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
console.log();
if (report.sessions.length === 1) {
const session = report.sessions[0];
console.log(`📦 Session: ${session.id}`);
console.log(` Type: ${session.type} | Risk: ${session.risk} | Duration: ${session.duration_minutes}m`);
console.log();
}
if (report.findings.length === 0) {
console.log('✅ No findings above current severity threshold.');
return;
}
for (const [idx, finding] of report.findings.entries()) {
console.log(`${idx + 1}. [${finding.severity.toUpperCase()}][${finding.category}] ${finding.summary}`);
console.log(` Rule: ${finding.rule_id} | Confidence: ${finding.confidence}`);
if (finding.details) {
console.log(` Detail: ${finding.details}`);
}
if (finding.location?.file) {
console.log(` Location: ${finding.location.file}`);
}
if (finding.sessions?.length > 0) {
console.log(` Sessions: ${finding.sessions.slice(0, 3).join(', ')}${finding.sessions.length > 3 ? ` (+${finding.sessions.length - 3})` : ''}`);
}
if (finding.evidence.length > 0) {
console.log(` Evidence: ${finding.evidence.slice(0, 3).join(' | ')}`);
}
if (finding.suggestion) {
console.log(` Suggestion: ${finding.suggestion}`);
}
console.log();
}
}
function printMarkdownReport(report) {
const lines = [];
lines.push('# Code Review Report');
lines.push('');
lines.push(`- Verdict: **${report.verdict.toUpperCase()}**`);
lines.push(`- Sessions: **${report.stats.reviewed_sessions}**`);
lines.push(`- Findings: **${report.findings.length}**`);
lines.push(`- Policy: \`min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
lines.push('');
lines.push('## Findings');
lines.push('');
if (report.findings.length === 0) {
lines.push('No findings above threshold.');
console.log(lines.join('\n'));
return;
}
lines.push('| Severity | Category | Rule | Summary | Sessions |');
lines.push('|---|---|---|---|---|');
for (const finding of report.findings) {
const sessions = finding.sessions?.length || 0;
const summary = finding.summary.replace(/\|/g, '\\|');
lines.push(`| ${finding.severity.toUpperCase()} | ${finding.category} | \`${finding.rule_id}\` | ${summary} | ${sessions} |`);
}
lines.push('');
lines.push('## Details');
lines.push('');
for (const finding of report.findings) {
lines.push(`### [${finding.severity.toUpperCase()}] ${finding.summary}`);
lines.push(`- Rule: \`${finding.rule_id}\``);
lines.push(`- Confidence: \`${finding.confidence}\``);
if (finding.sessions?.length > 0) {
lines.push(`- Sessions: ${finding.sessions.join(', ')}`);
}
if (finding.location?.file) {
lines.push(`- Location: \`${finding.location.file}\``);
}
if (finding.details) {
lines.push(`- Detail: ${finding.details}`);
}
if (finding.suggestion) {
lines.push(`- Suggestion: ${finding.suggestion}`);
}
if (finding.evidence.length > 0) {
lines.push(`- Evidence: ${finding.evidence.slice(0, 5).join(' | ')}`);
}
lines.push('');
}
console.log(lines.join('\n'));
}
function selectSessions(allSessions, options) {
if (options.sessionId) {
const matched = allSessions.find(s => s.id === options.sessionId || s.id.startsWith(options.sessionId));
return matched ? [matched] : [];
}
if (options.all) {
return allSessions;
}
return allSessions.slice(0, options.recent);
}
export default async function review(args) {
try {
const options = parseArgs(args);
if (options.help) {
printUsage();
return 0;
}
const projectRoot = getProjectRoot();
const logDir = getLogDir(projectRoot);
const sessions = getSessionDirs(logDir);
if (sessions.length === 0) {
console.error('❌ No sessions found for review.');
return 1;
}
const selectedSessions = selectSessions(sessions, options);
if (selectedSessions.length === 0) {
console.error(`❌ Session not found: ${options.sessionId}`);
return 1;
}
const report = buildReport(selectedSessions, options);
// Dispatch webhook for review completion
await dispatchWebhook(projectRoot, 'review.completed', report);
if (options.format === 'json') {
console.log(JSON.stringify(report, null, 2));
} else if (options.format === 'markdown') {
printMarkdownReport(report);
} else {
printHumanReport(report);
console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...>');
}
if (options.strict && shouldFailByThreshold(report.findings, options.failOn)) {
return 1;
}
return 0;
} catch (error) {
console.error('❌ Failed to run review:', error.message);
return 1;
}
}
#!/usr/bin/env node
/**
* AI Summarization module
* Automatically generates AI-powered summaries of sessions using LLM APIs
* Supports OpenAI and Anthropic APIs
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { getProjectRoot } from './config.js';
// Default configuration
const DEFAULT_CONFIG = {
provider: 'openai', // or 'anthropic'
model: 'gpt-4o-mini',
max_tokens: 1000,
temperature: 0.7,
openai_base_url: 'https://api.openai.com/v1',
};
/**
* Get API configuration from environment or config file
*/
function getApiConfig(projectRoot) {
const config = { ...DEFAULT_CONFIG };
// Check for project config
const configFile = join(projectRoot, '.shit-logs', 'config.json');
if (existsSync(configFile)) {
try {
const fileConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
Object.assign(config, fileConfig);
} catch {
// Use defaults
}
}
// Environment variables override file config
if (process.env.OPENAI_API_KEY) {
config.provider = 'openai';
config.api_key = process.env.OPENAI_API_KEY;
} else if (process.env.ANTHROPIC_API_KEY) {
config.provider = 'anthropic';
config.api_key = process.env.ANTHROPIC_API_KEY;
}
if (process.env.OPENAI_BASE_URL) {
config.openai_base_url = process.env.OPENAI_BASE_URL;
}
if (process.env.OPENAI_ENDPOINT) {
config.openai_endpoint = process.env.OPENAI_ENDPOINT;
}
return config;
}
/**
* Extract relevant context from session for summarization
*/
function extractContext(sessionDir) {
const context = {
prompts: [],
changes: [],
tools: {},
errors: [],
summary: null,
};
// Read summary.json
const summaryFile = join(sessionDir, 'summary.json');
if (existsSync(summaryFile)) {
try {
const summary = JSON.parse(readFileSync(summaryFile, 'utf-8'));
context.summary = summary;
context.prompts = summary.prompts || [];
context.tools = summary.activity?.tools || {};
context.errors = summary.activity?.errors || [];
context.changes = summary.changes?.files || [];
} catch {
// Best effort
}
}
// Read prompts.txt
const promptsFile = join(sessionDir, 'prompts.txt');
if (existsSync(promptsFile)) {
try {
context.prompts_text = readFileSync(promptsFile, 'utf-8').slice(0, 3000);
} catch {
// Best effort
}
}
// Read context.md
const contextFile = join(sessionDir, 'context.md');
if (existsSync(contextFile)) {
try {
context.context_md = readFileSync(contextFile, 'utf-8').slice(0, 2000);
} catch {
// Best effort
}
}
return context;
}
/**
* Build prompt for LLM summarization
*/
function buildSummarizePrompt(context) {
const parts = [];
// System prompt
parts.push(`You are a helpful assistant that summarizes AI coding sessions. Generate a concise summary that explains:`);
parts.push(`1. What the user wanted to accomplish`);
parts.push(`2. What changes were made`);
parts.push(`3. Any issues or errors encountered`);
parts.push(`4. Overall outcome`);
parts.push(`\n---\n`);
// User prompts
if (context.prompts_text) {
parts.push(`## User Prompts\n${context.prompts_text}\n`);
} else if (context.prompts && context.prompts.length > 0) {
parts.push(`## User Prompts\n`);
context.prompts.slice(0, 5).forEach(p => {
const text = typeof p === 'string' ? p : p.text || '';
parts.push(`- ${text.slice(0, 200)}`);
});
parts.push('');
}
// Changes summary
if (context.changes && context.changes.length > 0) {
parts.push(`## Files Changed\n`);
context.changes.slice(0, 10).forEach(f => {
const ops = f.operations?.join(', ') || 'modified';
parts.push(`- ${f.path}: ${ops}`);
});
parts.push('');
}
// Tool usage
if (context.tools && Object.keys(context.tools).length > 0) {
parts.push(`## Tools Used\n`);
Object.entries(context.tools).forEach(([tool, count]) => {
parts.push(`- ${tool}: ${count} times`);
});
parts.push('');
}
// Errors
if (context.errors && context.errors.length > 0) {
parts.push(`## Errors\n`);
context.errors.slice(0, 5).forEach(e => {
parts.push(`- ${e.tool}: ${(e.message || '').slice(0, 100)}`);
});
parts.push('');
}
return parts.join('\n');
}
/**
* Call OpenAI API
*/
function resolveOpenAIEndpoint(config) {
const explicitEndpoint = (config.openai_endpoint || '').trim();
if (explicitEndpoint) {
return explicitEndpoint;
}
const baseUrl = String(config.openai_base_url || DEFAULT_CONFIG.openai_base_url).trim().replace(/\/+$/, '');
if (baseUrl.endsWith('/chat/completions')) {
return baseUrl;
}
return `${baseUrl}/chat/completions`;
}
async function callOpenAI(apiKey, endpoint, model, prompt, maxTokens, temperature) {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: 'You are a helpful assistant that summarizes AI coding sessions.' },
{ role: 'user', content: prompt }
],
max_tokens: maxTokens,
temperature,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
}
const data = await response.json();
return data.choices[0].message.content;
}
/**
* Call Anthropic API
*/
async function callAnthropic(apiKey, model, prompt, maxTokens, temperature) {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
temperature,
messages: [
{ role: 'user', content: prompt }
],
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic API error: ${response.status} - ${error}`);
}
const data = await response.json();
return data.content[0].text;
}
/**
* Generate AI summary for a session
*/
export async function summarizeSession(projectRoot, sessionId, sessionDir) {
const config = getApiConfig(projectRoot);
if (!config.api_key) {
return {
success: false,
reason: 'No API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.'
};
}
// Extract context from session
const context = extractContext(sessionDir);
// Build prompt
const prompt = buildSummarizePrompt(context);
try {
let summary;
if (config.provider === 'anthropic') {
summary = await callAnthropic(
config.api_key,
config.model || 'claude-3-haiku-20240307',
prompt,
config.max_tokens,
config.temperature
);
} else {
const openaiEndpoint = resolveOpenAIEndpoint(config);
summary = await callOpenAI(
config.api_key,
openaiEndpoint,
config.model || 'gpt-4o-mini',
prompt,
config.max_tokens,
config.temperature
);
}
// Save summary
const aiSummaryFile = join(sessionDir, 'ai-summary.md');
writeFileSync(aiSummaryFile, summary);
// Update state
const stateFile = join(sessionDir, 'state.json');
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
state.ai_summary = {
provider: config.provider,
model: config.model,
generated_at: new Date().toISOString(),
};
writeFileSync(stateFile, JSON.stringify(state, null, 2));
} catch {
// Best effort
}
}
return {
success: true,
summary,
provider: config.provider,
model: config.model,
};
} catch (error) {
return {
success: false,
reason: error.message
};
}
}
/**
* CLI command for manual summarization
*/
export default async function summarize(args) {
const projectRoot = getProjectRoot();
const sessionId = args[0];
if (!sessionId) {
console.log('Usage: shit summarize <session-id>');
console.log('\nEnvironment variables:');
console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
console.log(' OPENAI_BASE_URL # OpenAI-compatible base URL (e.g. https://api.openai.com/v1)');
console.log(' OPENAI_ENDPOINT # Full OpenAI-compatible endpoint (overrides OPENAI_BASE_URL)');
console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
console.log('\nConfiguration (.shit-logs/config.json):');
console.log(` {"provider": "openai", "model": "gpt-4o-mini", "openai_base_url": "https://api.openai.com/v1"}`);
process.exit(1);
}
const sessionDir = join(projectRoot, '.shit-logs', sessionId);
if (!existsSync(sessionDir)) {
console.error(`Session not found: ${sessionId}`);
process.exit(1);
}
console.log(`🤖 Generating AI summary for session: ${sessionId}\n`);
const result = await summarizeSession(projectRoot, sessionId, sessionDir);
if (result.success) {
console.log('✅ AI Summary generated!\n');
console.log(result.summary);
console.log(`\n---`);
console.log(`Provider: ${result.provider}`);
console.log(`Model: ${result.model}`);
} else {
console.error('❌ Failed to generate summary:', result.reason);
process.exit(1);
}
}
/**
* Webhook support for shit-cli.
* Fire-and-forget notifications for session.ended and review.completed events.
*/
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { createHmac } from 'crypto';
import { request as httpsRequest } from 'https';
import { request as httpRequest } from 'http';
/**
* Load webhook configuration from .shit-logs/config.json + environment variables.
* Priority (highest wins): process.env > config.json env > config.json webhooks
*/
export function loadWebhookConfig(projectRoot) {
let fileConfig = {};
let configEnv = {};
const configPath = join(projectRoot, '.shit-logs', 'config.json');
if (existsSync(configPath)) {
try {
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
fileConfig = raw.webhooks || {};
if (raw.env && typeof raw.env === 'object') {
configEnv = raw.env;
}
} catch { /* ignore malformed config */ }
}
// Resolve: process.env > config.json env > config.json webhooks field
const env = (key) => {
if (key in process.env) return process.env[key];
if (key in configEnv) return configEnv[key];
return '';
};
const url = env('SHIT_WEBHOOK_URL') || fileConfig.url;
if (!url) return null;
const envEvents = env('SHIT_WEBHOOK_EVENTS');
const events = envEvents
? envEvents.split(',').map(e => e.trim()).filter(Boolean)
: (Array.isArray(fileConfig.events) ? fileConfig.events : []);
return {
url,
events,
secret: env('SHIT_WEBHOOK_SECRET') || fileConfig.secret || '',
auth_token: env('SHIT_WEBHOOK_AUTH_TOKEN') || fileConfig.auth_token || '',
headers: fileConfig.headers || {},
timeout_ms: fileConfig.timeout_ms || 5000,
retry: typeof fileConfig.retry === 'number' ? fileConfig.retry : 1,
};
}
/**
* Compute HMAC-SHA256 signature in GitHub-compatible format.
*/
function computeSignature(secret, body) {
const hmac = createHmac('sha256', secret);
hmac.update(body);
return `sha256=${hmac.digest('hex')}`;
}
/**
* Send a single HTTP POST request. Returns a promise that resolves/rejects.
*/
function httpPost(url, headers, body, timeoutMs) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const reqFn = isHttps ? httpsRequest : httpRequest;
const options = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...headers,
},
timeout: timeoutMs,
};
const req = reqFn(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body: data });
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
/**
* Send webhook for a single config entry. Supports retry.
*/
export async function sendWebhook(config, event, payload) {
const body = JSON.stringify({
event,
timestamp: new Date().toISOString(),
payload,
});
const headers = { ...config.headers };
// Auth: HMAC signature or Bearer token (mutually exclusive, HMAC preferred)
if (config.secret) {
headers['X-Signature-256'] = computeSignature(config.secret, body);
} else if (config.auth_token) {
headers['Authorization'] = `Bearer ${config.auth_token}`;
}
const maxAttempts = (config.retry || 0) + 1;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await httpPost(config.url, headers, body, config.timeout_ms || 5000);
return; // success
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
// Brief delay before retry
await new Promise(r => setTimeout(r, 500 * attempt));
}
}
}
throw lastError;
}
/**
* Main entry point: load config, filter by event, dispatch webhook.
* Non-blocking — logs warnings to stderr, never throws.
*/
export async function dispatchWebhook(projectRoot, event, payload) {
try {
const config = loadWebhookConfig(projectRoot);
if (!config) return;
// If events list is configured, only fire for matching events
if (config.events.length > 0 && !config.events.includes(event)) return;
await sendWebhook(config, event, payload);
} catch (err) {
process.stderr.write(`[shit-cli] webhook warning: ${err.message}\n`);
}
}
/**
* CLI command: shit webhook
* Shows current webhook configuration status.
*/
export default async function webhook(args) {
const { getProjectRoot } = await import('./config.js');
const projectRoot = getProjectRoot();
const config = loadWebhookConfig(projectRoot);
if (args.includes('--help') || args.includes('-h')) {
console.log('Usage: shit webhook [options]');
console.log('');
console.log('Show current webhook configuration.');
console.log('');
console.log('Options:');
console.log(' --test Send a test webhook ping');
console.log(' --help Show this help');
console.log('');
console.log('Configuration (highest priority first):');
console.log(' 1. Environment variables:');
console.log(' SHIT_WEBHOOK_URL Webhook endpoint URL');
console.log(' SHIT_WEBHOOK_SECRET HMAC-SHA256 signing secret');
console.log(' SHIT_WEBHOOK_AUTH_TOKEN Bearer token');
console.log(' SHIT_WEBHOOK_EVENTS Comma-separated event list');
console.log(' 2. .shit-logs/config.json "env" field');
console.log(' 3. .shit-logs/config.json "webhooks" field');
return 0;
}
if (!config) {
console.log('No webhook configured.');
console.log('');
console.log('Set SHIT_WEBHOOK_URL or add "webhooks" to .shit-logs/config.json');
return 0;
}
console.log('Webhook configuration:');
console.log(` URL: ${config.url}`);
console.log(` Events: ${config.events.length > 0 ? config.events.join(', ') : '(all)'}`);
console.log(` Auth: ${config.secret ? 'HMAC-SHA256' : config.auth_token ? 'Bearer token' : 'none'}`);
console.log(` Timeout: ${config.timeout_ms}ms`);
console.log(` Retry: ${config.retry}`);
if (Object.keys(config.headers).length > 0) {
console.log(` Headers: ${Object.keys(config.headers).join(', ')}`);
}
if (args.includes('--test')) {
console.log('');
console.log('Sending test ping...');
try {
await sendWebhook(config, 'ping', { message: 'shit-cli webhook test' });
console.log('OK — webhook delivered successfully.');
} catch (err) {
console.error(`Failed: ${err.message}`);
return 1;
}
}
return 0;
}
+19
-6

@@ -16,4 +16,6 @@ #!/usr/bin/env node

view: 'View session details',
review: 'Run structured code review for a session',
query: 'Query session memory (cross-session)',
explain: 'Explain a session or commit',
summarize: 'Generate AI summary for a session',
rewind: 'Rollback to previous checkpoint',

@@ -25,2 +27,3 @@ resume: 'Resume session from checkpoint',

clean: 'Clean old sessions',
webhook: 'Manage webhook configuration',
help: 'Show help',

@@ -41,2 +44,4 @@ };

console.log(' shit view <session-id> # View session');
console.log(' shit review [session-id] # Structured code review');
console.log(' shit review --recent=3 --md # Markdown review for latest 3 sessions');
console.log(' shit rewind <checkpoint> # Rollback to checkpoint');

@@ -66,11 +71,19 @@ console.log(' shit resume <checkpoint> # Resume from checkpoint');

if (!Object.prototype.hasOwnProperty.call(commands, command)) {
console.error(`Unknown command: ${command}`);
process.exit(1);
}
try {
const mod = await import(`../lib/${command}.js`);
await mod.default(args.slice(1));
if (typeof mod.default !== 'function') {
throw new Error(`Command module "${command}" has no default function export`);
}
const exitCode = await mod.default(args.slice(1));
if (Number.isInteger(exitCode) && exitCode !== 0) {
process.exitCode = exitCode;
}
} catch (error) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
console.error(`Unknown command: ${command}`);
process.exit(1);
}
throw error;
console.error(`Failed to run command "${command}": ${error.message}`);
process.exit(1);
}

@@ -11,3 +11,3 @@ #!/usr/bin/env node

import { readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';

@@ -143,3 +143,5 @@ import { join, dirname } from 'path';

*/
export async function commitCheckpoint(projectRoot, sessionDir, sessionId, commitSha = null) {
export async function commitCheckpoint(projectRoot, sessionDir, sessionId, commitSha = null, options = {}) {
const autoSummarize = options.autoSummarize !== false; // default true
// Verify we're in a git repo

@@ -152,5 +154,6 @@ try {

// Branch naming: shit/checkpoints/v1/YYYY-MM-DD-<short-uuid>
const datePart = sessionId.split('-').slice(0, 3).join('-');
const uuidPart = sessionId.split('-').slice(3).join('-').slice(0, 8);
// Branch naming: shit/checkpoints/v1/YYYY-MM-DD-<session-short>
const datePart = new Date().toISOString().slice(0, 10);
const sessionCompact = sessionId.toLowerCase().replace(/[^a-f0-9]/g, '');
const uuidPart = (sessionCompact.slice(-8) || sessionCompact.slice(0, 8) || 'unknown00').padEnd(8, '0');
const branchName = `shit/checkpoints/v1/${datePart}-${uuidPart}`;

@@ -212,2 +215,15 @@ const refPath = `refs/heads/${branchName}`;

// Auto-summarize if enabled
if (autoSummarize) {
try {
const { summarizeSession } = await import('./summarize.js');
const summaryResult = await summarizeSession(projectRoot, sessionId, sessionDir);
if (summaryResult.success) {
console.log(`✅ AI summary generated: ${summaryResult.model}`);
}
} catch {
// Best effort - summarize is optional
}
}
return {

@@ -233,25 +249,31 @@ success: true,

try {
// Extract session info from branch name
// Extract session info from branch name (supports current and legacy formats)
const match = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
if (match) {
const [, date, uuidShort] = match;
const legacyMatch = branch.match(/^shit\/checkpoints\/v1\/([a-f0-9-]+)$/);
if (!match && !legacyMatch) {
continue;
}
// Get commit info
const log = git(`log ${branch} --oneline -1`, projectRoot);
const commitMatch = log.match(/^([a-f0-9]+)\s+checkpoint/);
const commit = commitMatch ? commitMatch[1] : log.split(' ')[0];
const date = match ? match[1] : git(`log ${branch} --format=%cs -1`, projectRoot);
const uuidShort = match
? match[2]
: ((legacyMatch[1].replace(/[^a-f0-9]/g, '').slice(-8) || legacyMatch[1].slice(0, 8)).toLowerCase());
// Get linked commit from message
const fullLog = git(`log ${branch} --format=%B -1`, projectRoot);
const linkedMatch = fullLog.match(/@ ([a-f0-9]+)/);
const linkedCommit = linkedMatch ? linkedMatch[1] : null;
// Get commit info
const log = git(`log ${branch} --oneline -1`, projectRoot);
const commitMatch = log.match(/^([a-f0-9]+)\s+checkpoint/);
const commit = commitMatch ? commitMatch[1] : log.split(' ')[0];
checkpoints.push({
branch,
commit: commit.slice(0, 12),
linked_commit: linkedCommit,
date,
uuid: uuidShort,
});
}
// Get linked commit from message
const fullLog = git(`log ${branch} --format=%B -1`, projectRoot);
const linkedMatch = fullLog.match(/@ ([a-f0-9]+)/);
const linkedCommit = linkedMatch ? linkedMatch[1] : null;
checkpoints.push({
branch,
commit: commit.slice(0, 12),
linked_commit: linkedCommit,
date,
uuid: uuidShort,
});
} catch {

@@ -258,0 +280,0 @@ // Skip invalid branches

@@ -8,20 +8,8 @@ #!/usr/bin/env node

import { existsSync } from 'fs';
import { join } from 'path';
import { listCheckpoints, getCheckpoint } from './checkpoint.js';
import { listCheckpoints } from './checkpoint.js';
import { getProjectRoot } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
export default async function checkpoints(args) {
try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const verbose = args.includes('--verbose') || args.includes('-v');

@@ -28,0 +16,0 @@ const json = args.includes('--json');

@@ -8,21 +8,11 @@ #!/usr/bin/env node

import { existsSync, readFileSync, writeFileSync } from 'fs';
import { existsSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { commitCheckpoint } from './checkpoint.js';
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
export default async function commitHook(args) {
try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();

@@ -43,5 +33,7 @@ // Get the commit that was just created

// Find the most recent session
const { readdirSync, statSync } = await import('fs');
const sessions = readdirSync(shitLogsDir)
.filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/))
.filter(name => {
const fullPath = join(shitLogsDir, name);
return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
})
.map(name => ({

@@ -48,0 +40,0 @@ name,

@@ -6,3 +6,5 @@ #!/usr/bin/env node

export const SESSION_ID_REGEX = /^[a-f0-9-]{36}$/;
export const UUID_SESSION_ID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
export const LEGACY_SESSION_ID_REGEX = /^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/;
export const SESSION_ID_REGEX = /^(?:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|\d{4}-\d{2}-\d{2}-[a-f0-9-]+)$/;

@@ -9,0 +11,0 @@ export function getProjectRoot() {

@@ -10,13 +10,13 @@ #!/usr/bin/env node

import { join } from 'path';
import { getProjectRoot } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
const CLAUDE_HOOK_TYPES = [
'SessionStart',
'SessionEnd',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'Stop',
'Notification',
];

@@ -32,10 +32,46 @@ function removeClaudeHooks(projectRoot) {

const settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
let removed = false;
if (settings.hooks) {
// Remove shit-cli hooks
delete settings.hooks.session_start;
delete settings.hooks.session_end;
delete settings.hooks.tool_use;
delete settings.hooks.edit_applied;
for (const hookType of CLAUDE_HOOK_TYPES) {
const rawEntries = settings.hooks[hookType];
if (Array.isArray(rawEntries)) {
const nextEntries = rawEntries
.map(entry => {
if (!Array.isArray(entry?.hooks)) {
return entry;
}
const nextHooks = entry.hooks.filter(hook =>
!(typeof hook?.command === 'string' && hook.command.includes('shit log'))
);
if (nextHooks.length !== entry.hooks.length) {
removed = true;
}
if (nextHooks.length === 0) {
return null;
}
return { ...entry, hooks: nextHooks };
})
.filter(Boolean);
if (nextEntries.length > 0) {
settings.hooks[hookType] = nextEntries;
} else {
delete settings.hooks[hookType];
}
} else if (typeof rawEntries === 'string' && rawEntries.includes('shit log')) {
delete settings.hooks[hookType];
removed = true;
}
}
// Cleanup legacy wrong names written by old versions.
const legacyKeys = ['session_start', 'session_end', 'tool_use', 'edit_applied'];
for (const key of legacyKeys) {
if (Object.prototype.hasOwnProperty.call(settings.hooks, key)) {
delete settings.hooks[key];
removed = true;
}
}
// If hooks object is empty, remove it

@@ -48,3 +84,3 @@ if (Object.keys(settings.hooks).length === 0) {

writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
return true;
return removed;
} catch {

@@ -80,3 +116,3 @@ return false;

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const cleanData = args.includes('--clean') || args.includes('--purge');

@@ -83,0 +119,0 @@

@@ -8,17 +8,7 @@ #!/usr/bin/env node

import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
function git(cmd, cwd) {

@@ -37,3 +27,2 @@ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();

fix: () => {
const { mkdirSync } = require('fs');
mkdirSync(shitLogsDir, { recursive: true });

@@ -72,3 +61,2 @@ return 'Created .shit-logs directory';

const backup = `${indexFile}.backup.${Date.now()}`;
const { copyFileSync } = require('fs');
copyFileSync(indexFile, backup);

@@ -104,3 +92,3 @@

const fullPath = join(shitLogsDir, name);
return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
});

@@ -139,3 +127,2 @@

const backup = `${stateFile}.backup.${Date.now()}`;
const { copyFileSync } = require('fs');
copyFileSync(stateFile, backup);

@@ -174,3 +161,3 @@

const fullPath = join(shitLogsDir, name);
return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
}) : [];

@@ -217,3 +204,3 @@

const fullPath = join(shitLogsDir, name);
return statSync(fullPath).isDirectory() && name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/);
return statSync(fullPath).isDirectory() && SESSION_ID_REGEX.test(name);
});

@@ -223,2 +210,3 @@

const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const endedHookTypes = new Set(['session-end', 'SessionEnd', 'stop', 'session_end', 'end']);

@@ -232,9 +220,14 @@ for (const sessionDir of sessionDirs) {

const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
const startTime = new Date(state.start_time);
const lastActivity = new Date(state.last_time || state.start_time);
const hasValidLastActivity = !Number.isNaN(lastActivity.getTime());
const endedByHook = endedHookTypes.has(state.last_hook_type);
const hasShadowBranch = typeof state.shadow_branch === 'string' && state.shadow_branch.length > 0;
const hasCheckpoint = Array.isArray(state.checkpoints) && state.checkpoints.length > 0;
const consideredEnded = Boolean(state.end_time) || endedByHook || hasShadowBranch || hasCheckpoint;
// Check for sessions older than 24 hours without end_time
if (!state.end_time && startTime < oneDayAgo) {
// Stuck means inactive for >24h and not explicitly/implicitly ended.
if (!consideredEnded && hasValidLastActivity && lastActivity < oneDayAgo) {
issues.push({
type: 'stuck_session',
message: `Stuck session: ${sessionDir} (started ${startTime.toLocaleString()})`,
message: `Stuck session: ${sessionDir} (last activity ${lastActivity.toLocaleString()})`,
sessionDir,

@@ -260,3 +253,3 @@ fix: () => {

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const autoFix = args.includes('--fix') || args.includes('--auto-fix');

@@ -263,0 +256,0 @@ const verbose = args.includes('--verbose') || args.includes('-v');

@@ -12,14 +12,4 @@ #!/usr/bin/env node

import { execSync } from 'child_process';
import { getProjectRoot } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
// Agent-specific hook configurations

@@ -99,5 +89,26 @@ const AGENT_HOOKS = {

// Add shit-cli hooks
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks) {
settings.hooks = {};
}
for (const [hookName, command] of Object.entries(config.hooks)) {
if (agentType === 'claude-code') {
const current = settings.hooks[hookName];
const existing = Array.isArray(current)
? current
: (typeof current === 'string'
? [{ matcher: '', hooks: [{ type: 'command', command: current }] }]
: []);
const alreadyExists = existing.some(entry =>
Array.isArray(entry?.hooks) &&
entry.hooks.some(hook => typeof hook?.command === 'string' && hook.command.includes('shit log'))
);
if (!alreadyExists) {
existing.push({ matcher: '', hooks: [{ type: 'command', command }] });
}
settings.hooks[hookName] = existing;
continue;
}
settings.hooks[hookName] = command;

@@ -169,3 +180,3 @@ }

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();

@@ -179,2 +190,3 @@ // Parse arguments

let telemetry = true;
let summarize = true;

@@ -195,2 +207,4 @@ for (const arg of args) {

pushSessions = false;
} else if (arg === '--no-summarize') {
summarize = false;
} else if (arg.startsWith('--telemetry=')) {

@@ -221,2 +235,3 @@ telemetry = arg.split('=')[1] !== 'false';

push_sessions: pushSessions,
summarize: summarize,
telemetry: telemetry,

@@ -273,4 +288,9 @@ log_level: 'info'

console.log('\nOptions:');
console.log(' --all # Enable for all supported agents');
console.log(' --all # Enable for all supported agents');
console.log(' --checkpoint, -c # Enable automatic checkpoint on git commit');
console.log(' --no-summarize # Disable AI summary generation');
console.log(' --skip-push-sessions # Disable auto-push to remote');
console.log(' --telemetry=false # Disable anonymous telemetry');
console.log('\nAI Summary:');
console.log(' Set OPENAI_API_KEY or ANTHROPIC_API_KEY to enable AI summaries');

@@ -277,0 +297,0 @@ } catch (error) {

@@ -8,15 +8,21 @@ #!/usr/bin/env node

import { existsSync, readFileSync } from 'fs';
import { existsSync, readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
function git(args, cwd, ignoreError = false) {
try {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (error) {
if (ignoreError) {
return null;
}
dir = join(dir, '..');
throw error;
}
throw new Error('Not in a git repository');
}

@@ -45,3 +51,2 @@

function explainFromSummary(summaryText) {
const lines = summaryText.split('\n');
const explanation = [];

@@ -118,19 +123,16 @@

// Try to find associated checkpoint
const checkpoints = execSync('git branch --list "shit/checkpoints/v1/*"', {
cwd: projectRoot,
encoding: 'utf-8'
}).split('\n').filter(Boolean);
const checkpoints = (git(['branch', '--list', 'shit/checkpoints/v1/*'], projectRoot, true) || '')
.split('\n')
.map(line => line.trim().replace(/^\*?\s*/, ''))
.filter(Boolean);
for (const branch of checkpoints) {
try {
const log = execSync(`git log ${branch} --oneline -1`, {
cwd: projectRoot,
encoding: 'utf-8'
}).trim();
const log = git(['log', branch, '--oneline', '-1'], projectRoot, true);
if (!log) {
continue;
}
if (log.includes(commitSha.slice(0, 12))) {
const message = execSync(`git log ${branch} --format=%B -1`, {
cwd: projectRoot,
encoding: 'utf-8'
}).trim();
const message = git(['log', branch, '--format=%B', '-1'], projectRoot, true) || '';

@@ -152,3 +154,3 @@ return `📸 This commit has an associated checkpoint:\n\nBranch: ${branch}\n\n${message}`;

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const target = args[0];

@@ -159,3 +161,3 @@

console.log('\nExamples:');
console.log(' shit explain 2026-02-28-abc12345 # Explain a session');
console.log(' shit explain b5613b31-c732-4546-9be5-f8ae36f2327f # Explain a session');
console.log(' shit explain abc1234 # Explain a commit');

@@ -178,19 +180,22 @@ process.exit(1);

// Try as commit
try {
execSync('git rev-parse --verify ' + target + '^{commit}', {
cwd: projectRoot,
stdio: 'ignore'
});
console.log('📸 Commit Explanation\n');
console.log(explainFromCommit(target, projectRoot));
return;
} catch {
// Not a commit
const isCommitLike = /^[a-f0-9]{4,40}$/i.test(target);
if (isCommitLike) {
const resolvedCommit = git(['rev-parse', '--verify', `${target}^{commit}`], projectRoot, true);
if (resolvedCommit) {
console.log('📸 Commit Explanation\n');
console.log(explainFromCommit(resolvedCommit, projectRoot));
return;
}
}
// Not a commit
// Try partial match
const sessions = execSync('ls .shit-logs', { cwd: projectRoot, encoding: 'utf-8' })
.split('\n')
.filter(s => s.includes(target));
const logDir = join(projectRoot, '.shit-logs');
const sessions = existsSync(logDir)
? readdirSync(logDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && SESSION_ID_REGEX.test(entry.name))
.map(entry => entry.name)
.filter(name => name.includes(target))
: [];

@@ -197,0 +202,0 @@ if (sessions.length > 0) {

@@ -8,3 +8,3 @@ #!/usr/bin/env node

import { appendFileSync, mkdirSync } from 'fs';
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';

@@ -15,2 +15,3 @@ import { getProjectRoot, getLogDir } from './config.js';

import { generateReports } from './report.js';
import { dispatchWebhook } from './webhook.js';

@@ -55,4 +56,7 @@ export default async function log(args) {

// 4. On session end: checkpoint + update index
if (hookType === 'session-end' || hookType === 'stop') {
// 4. On session end: checkpoint + update index + webhook (idempotent — run once per session)
const isSessionEnd = ['session-end', 'SessionEnd', 'stop', 'session_end', 'end', 'onSessionEnd'].includes(hookType);
const endedSentinel = join(sessionDir, '.ended');
if (isSessionEnd && !existsSync(endedSentinel)) {
writeFileSync(endedSentinel, new Date().toISOString());
try {

@@ -66,2 +70,9 @@ const { commitCheckpoint } = await import('./checkpoint.js');

} catch { /* index is best-effort */ }
// Dispatch webhook for session end — must await before exit
try {
const summaryPath = join(sessionDir, 'summary.json');
const summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
await dispatchWebhook(projectRoot, 'session.ended', summary);
} catch { /* webhook is best-effort */ }
}

@@ -68,0 +79,0 @@

@@ -8,17 +8,7 @@ #!/usr/bin/env node

import { existsSync, readFileSync, rmSync } from 'fs';
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
function git(cmd, cwd) {

@@ -57,5 +47,7 @@ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();

const { readdirSync, statSync } = require('fs');
const sessions = readdirSync(shitLogsDir)
.filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/))
.filter(name => {
const fullPath = join(shitLogsDir, name);
return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
})
.map(name => ({

@@ -73,3 +65,3 @@ name,

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const force = args.includes('--force') || args.includes('-f');

@@ -118,3 +110,3 @@

state.checkpoints = state.checkpoints.filter(cp => cp.linked_commit !== commitSha);
require('fs').writeFileSync(stateFile, JSON.stringify(state, null, 2));
writeFileSync(stateFile, JSON.stringify(state, null, 2));
console.log('✅ Updated session state');

@@ -121,0 +113,0 @@ }

@@ -11,14 +11,5 @@ #!/usr/bin/env node

import { execSync } from 'child_process';
import { randomUUID } from 'crypto';
import { getProjectRoot } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
function git(cmd, cwd) {

@@ -30,3 +21,3 @@ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();

try {
const branches = git('branch --list "shit/*"', projectRoot)
const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
.split('\n')

@@ -37,12 +28,30 @@ .map(b => b.trim().replace(/^\*?\s*/, ''))

for (const branch of branches) {
const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
if (match) {
const [, baseCommit, sessionShort] = match;
if (sessionShort.startsWith(checkpointId) || baseCommit.startsWith(checkpointId)) {
const log = git(`log ${branch} --oneline -1`, projectRoot);
const timestamp = git(`log ${branch} --format=%ci -1`, projectRoot);
const timestamp = git(`log ${branch} --format=%ci -1`, projectRoot);
const log = git(`log ${branch} --oneline -1`, projectRoot);
const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
if (checkpointMatch) {
const branchKey = `${checkpointMatch[1]}-${checkpointMatch[2]}`;
const message = git(`log ${branch} --format=%B -1`, projectRoot);
const linkedMatch = message.match(/@ ([a-f0-9]+)/);
const baseCommit = linkedMatch ? linkedMatch[1] : null;
if (branchKey.startsWith(checkpointId) || checkpointMatch[2].startsWith(checkpointId) || (baseCommit && baseCommit.startsWith(checkpointId))) {
return {
branch,
baseCommit,
sessionShort: checkpointMatch[2],
timestamp,
message: log.split(' ').slice(1).join(' ')
};
}
}
const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
if (shadowMatch) {
const [, baseCommit, sessionShort] = shadowMatch;
if (sessionShort.startsWith(checkpointId) || baseCommit.startsWith(checkpointId)) {
return {
branch,
baseCommit,
sessionShort,

@@ -122,7 +131,3 @@ timestamp,

function generateSessionId() {
const now = new Date();
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
const uuid = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
return `${date}-${uuid}`;
return randomUUID();
}

@@ -156,3 +161,3 @@

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const checkpointId = args.find(arg => !arg.startsWith('-'));

@@ -172,3 +177,3 @@

console.error(`❌ Checkpoint not found: ${checkpointId}`);
console.error(' Use "shit shadow" to list available checkpoints');
console.error(' Use "shit checkpoints" to list available checkpoints');
process.exit(1);

@@ -179,3 +184,3 @@ }

console.log(` Branch: ${checkpoint.branch}`);
console.log(` Base commit: ${checkpoint.baseCommit}`);
console.log(` Base commit: ${checkpoint.baseCommit || 'unknown'}`);
console.log(` Created: ${new Date(checkpoint.timestamp).toLocaleString()}`);

@@ -186,3 +191,3 @@ console.log();

const currentCommit = git('rev-parse HEAD', projectRoot);
if (!currentCommit.startsWith(checkpoint.baseCommit)) {
if (checkpoint.baseCommit && !currentCommit.startsWith(checkpoint.baseCommit)) {
console.log(`🔄 Checking out base commit: ${checkpoint.baseCommit}`);

@@ -189,0 +194,0 @@ git(`checkout ${checkpoint.baseCommit}`, projectRoot);

@@ -8,17 +8,5 @@ #!/usr/bin/env node

import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { getProjectRoot } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
function git(cmd, cwd) {

@@ -28,31 +16,60 @@ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();

function parseCheckpointRef(projectRoot, branch) {
const shadowMatch = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
if (shadowMatch) {
return {
type: 'shadow',
baseCommit: shadowMatch[1],
sessionShort: shadowMatch[2],
lookupKey: shadowMatch[2],
};
}
const checkpointMatch = branch.match(/^shit\/checkpoints\/v1\/(\d{4}-\d{2}-\d{2})-([a-f0-9]+)$/);
if (checkpointMatch) {
const message = git(`log ${branch} --format=%B -1`, projectRoot);
const linkedMatch = message.match(/@ ([a-f0-9]+)/);
const date = checkpointMatch[1];
const sessionShort = checkpointMatch[2];
return {
type: 'checkpoint',
baseCommit: linkedMatch ? linkedMatch[1] : null,
sessionShort,
lookupKey: `${date}-${sessionShort}`,
};
}
return null;
}
function listCheckpoints(projectRoot) {
try {
// Get shadow branches
const branches = git('branch --list "shit/*"', projectRoot)
const branches = git('branch --list "shit/checkpoints/v1/*" "shit/*"', projectRoot)
.split('\n')
.map(b => b.trim().replace(/^\*?\s*/, ''))
.filter(Boolean);
const uniqueBranches = [...new Set(branches)];
const checkpoints = [];
for (const branch of branches) {
for (const branch of uniqueBranches) {
try {
const parsed = parseCheckpointRef(projectRoot, branch);
if (!parsed) {
continue;
}
const log = git(`log ${branch} --oneline -1`, projectRoot);
const [commit, ...messageParts] = log.split(' ');
const message = messageParts.join(' ');
// Extract session info from branch name: shit/<commit>-<session>
const match = branch.match(/^shit\/([a-f0-9]+)-([a-f0-9]+)$/);
if (match) {
const [, baseCommit, sessionShort] = match;
checkpoints.push({
branch,
commit,
baseCommit,
sessionShort,
message,
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
});
}
checkpoints.push({
branch,
commit,
baseCommit: parsed.baseCommit,
sessionShort: parsed.sessionShort,
lookupKey: parsed.lookupKey,
type: parsed.type,
message,
timestamp: git(`log ${branch} --format=%ci -1`, projectRoot)
});
} catch {

@@ -89,2 +106,6 @@ // Skip invalid branches

if (!checkpoint.baseCommit) {
throw new Error(`Checkpoint ${checkpoint.lookupKey || checkpoint.sessionShort} is missing a linked base commit`);
}
if (!force && hasUncommittedChanges(projectRoot)) {

@@ -106,3 +127,3 @@ throw new Error('Working directory has uncommitted changes. Use --force to override or commit changes first.');

try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const force = args.includes('--force') || args.includes('-f');

@@ -119,3 +140,3 @@ const interactive = args.includes('--interactive') || args.includes('-i');

console.log('❌ No checkpoints found');
console.log(' Checkpoints are created automatically when sessions end.');
console.log(' Checkpoints are created when you run "shit commit".');
process.exit(1);

@@ -128,3 +149,4 @@ }

cp.sessionShort.startsWith(checkpointArg) ||
cp.baseCommit.startsWith(checkpointArg)
cp.lookupKey.startsWith(checkpointArg) ||
(cp.baseCommit && cp.baseCommit.startsWith(checkpointArg))
);

@@ -141,3 +163,5 @@

const date = new Date(cp.timestamp).toLocaleString();
console.log(`${i + 1}. ${cp.sessionShort} (${cp.baseCommit.slice(0, 7)}) - ${date}`);
const base = cp.baseCommit ? cp.baseCommit.slice(0, 7) : 'unknown';
const key = cp.lookupKey || cp.sessionShort;
console.log(`${i + 1}. ${key} (${base}) - ${date}`);
console.log(` ${cp.message}`);

@@ -149,3 +173,3 @@ console.log();

targetCheckpoint = checkpoints[0];
console.log(`Using most recent checkpoint: ${targetCheckpoint.sessionShort}`);
console.log(`Using most recent checkpoint: ${targetCheckpoint.lookupKey || targetCheckpoint.sessionShort}`);
} else {

@@ -156,5 +180,6 @@ // Use most recent checkpoint

console.log(`🔄 Rewinding to checkpoint: ${targetCheckpoint.sessionShort}`);
const selectedKey = targetCheckpoint.lookupKey || targetCheckpoint.sessionShort;
console.log(`🔄 Rewinding to checkpoint: ${selectedKey}`);
console.log(` Branch: ${targetCheckpoint.branch}`);
console.log(` Base commit: ${targetCheckpoint.baseCommit}`);
console.log(` Base commit: ${targetCheckpoint.baseCommit || 'unknown'}`);
console.log(` Created: ${new Date(targetCheckpoint.timestamp).toLocaleString()}`);

@@ -176,3 +201,3 @@ console.log();

console.log('💡 To resume from this checkpoint:');
console.log(` shit resume ${targetCheckpoint.sessionShort}`);
console.log(` shit resume ${selectedKey}`);

@@ -179,0 +204,0 @@ } catch (error) {

@@ -108,2 +108,8 @@ #!/usr/bin/env node

// Mark session as ended for end/stop hooks.
if (hookType === 'session-end' || hookType === 'SessionEnd' || hookType === 'stop' || hookType === 'session_end' || hookType === 'end') {
state.end_time = now;
return;
}
// Tool events

@@ -110,0 +116,0 @@ if (hookType === 'post-tool-use' && toolName) {

@@ -8,17 +8,7 @@ #!/usr/bin/env node

import { existsSync, readFileSync } from 'fs';
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
function findProjectRoot() {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, '.git'))) {
return dir;
}
dir = join(dir, '..');
}
throw new Error('Not in a git repository');
}
function getCurrentSession(projectRoot) {

@@ -31,5 +21,7 @@ const shitLogsDir = join(projectRoot, '.shit-logs');

// Find the most recent session directory
const { readdirSync, statSync } = await import('fs');
const sessions = readdirSync(shitLogsDir)
.filter(name => name.match(/^\d{4}-\d{2}-\d{2}-[a-f0-9-]+$/)) // session format
.filter(name => {
const fullPath = join(shitLogsDir, name);
return SESSION_ID_REGEX.test(name) && statSync(fullPath).isDirectory();
})
.map(name => ({

@@ -95,7 +87,41 @@ name,

function getTouchedFileCount(state) {
const ops = state?.file_ops;
if (!ops || typeof ops !== 'object') {
return 0;
}
const touched = new Set([
...(Array.isArray(ops.write) ? ops.write : []),
...(Array.isArray(ops.edit) ? ops.edit : []),
...(Array.isArray(ops.read) ? ops.read : []),
].filter(Boolean));
return touched.size;
}
function hasShitHooks(settings) {
if (!settings?.hooks || typeof settings.hooks !== 'object') {
return false;
}
return Object.values(settings.hooks).some(value => {
if (typeof value === 'string') {
return value.includes('shit log');
}
if (!Array.isArray(value)) {
return false;
}
return value.some(entry =>
Array.isArray(entry?.hooks) &&
entry.hooks.some(hook => typeof hook?.command === 'string' && hook.command.includes('shit log'))
);
});
}
export default async function status(args) {
try {
const projectRoot = findProjectRoot();
const projectRoot = getProjectRoot();
const gitInfo = getGitInfo(projectRoot);
const currentSession = await getCurrentSession(projectRoot);
const currentSession = getCurrentSession(projectRoot);

@@ -127,3 +153,3 @@ console.log('📊 shit-cli Status\n');

console.log(` Events: ${state.event_count || 0}`);
console.log(` Files: ${Object.keys(state.files || {}).length}`);
console.log(` Files: ${getTouchedFileCount(state)}`);

@@ -156,4 +182,3 @@ if (state.shadow_branch) {

const settings = JSON.parse(readFileSync(claudeSettings, 'utf-8'));
const hasHooks = settings.hooks &&
(settings.hooks.session_start || settings.hooks.session_end);
const hasHooks = hasShitHooks(settings);

@@ -160,0 +185,0 @@ console.log();

{
"name": "@claudemini/shit-cli",
"version": "1.3.0",
"description": "Session-based Hook Intelligence Tracker - CLI tool for logging Claude Code hooks",
"version": "1.8.2",
"description": "Session-based Hook Intelligence Tracker - Zero-dependency memory system for human-AI coding sessions",
"type": "module",

@@ -9,2 +9,10 @@ "bin": {

},
"files": [
"bin/",
"lib/",
"README.md"
],
"engines": {
"node": ">=18.0.0"
},
"scripts": {

@@ -15,10 +23,18 @@ "test": "echo \"Error: no test specified\" && exit 1"

"claude-code",
"gemini-cli",
"cursor",
"ai-coding",
"hooks",
"logging",
"session-tracking"
"session-tracking",
"code-review",
"checkpoint"
],
"repository": {
"type": "git",
"url": "git+https://github.com/anthropics/shit-cli.git"
},
"author": "",
"license": "MIT",
"license": "UNLICENSED",
"dependencies": {},
"devDependencies": {}
}
+220
-115

@@ -5,65 +5,69 @@ # shit-cli

A memory system for human-AI interactions, designed to provide reliable data support for code review automation.
A zero-dependency memory system for human-AI coding sessions. Tracks what happened, classifies intent and risk, and provides structured data for code review automation.
## Design Vision
Supports **Claude Code**, **Gemini CLI**, **Cursor**, and **OpenCode**.
1. **Human-AI Interaction Memory System** - Long-term memory, not temporary logs
2. **Code Review Bot Data Support** - Structured semantic data for intelligent code review
## Quick Start
See [DESIGN_PHILOSOPHY.md](./DESIGN_PHILOSOPHY.md) for detailed design rationale.
## Installation
```bash
cd shit-cli
npm link
```
npm install -g @claudemini/shit-cli
Or use directly:
```bash
node bin/shit.js <command>
cd /path/to/your/project
shit enable # Setup hooks + .shit-logs
# ... use Claude Code normally ...
shit list # See sessions
shit status # Check current session
```
## Usage
## Installation
### Initialize hooks
```bash
cd /path/to/your/project
shit init
npm install -g @claudemini/shit-cli
```
Registers all hooks in `.claude/settings.json` automatically.
Or use directly without installing:
### List sessions
```bash
shit list
npx @claudemini/shit-cli <command>
```
Output:
```
3 session(s):
## Commands
1. f608c31e [bugfix] risk:medium
Fix auth timeout by adjusting retry logic
45min | 42 events | 28 tools | 3 files | 0 errors
2/27/2026, 3:15:00 PM
### Setup
2. a1b2c3d4 [feature] risk:low
Add user profile endpoint
30min | 28 events | 15 tools | 2 files | 0 errors
2/26/2026, 10:30:00 AM
```bash
shit enable # Enable for Claude Code (default)
shit enable gemini-cli # Enable for Gemini CLI
shit enable --all # Enable for all supported agents
shit enable --checkpoint # Also create checkpoints on git commit
shit disable # Remove hooks (keep data)
shit disable --clean # Remove hooks and all data
shit init # Low-level: register hooks in .claude/settings.json
```
### View session details
### Session Tracking
```bash
shit view f608c31e-453c-435a-b0e2-3116dc56ad71
shit view f608c31e-453c-435a-b0e2-3116dc56ad71 --json # Include raw JSON
shit status # Show current session + git info
shit list # List all sessions with type, risk, intent
shit view <session-id> # View semantic session report
shit view <session-id> --json # Include raw JSON data
shit review [session-id] # Run structured code review from session data
shit review --json # Machine-readable findings (structured schema)
shit review --recent=3 --md # Aggregated Markdown report for PR comments
shit review --strict --fail-on=medium # CI gate by severity threshold
shit explain <session-id> # Human-friendly explanation of a session
shit explain <commit-sha> # Explain a commit via its checkpoint
```
Output includes: intent, changes by category, tools, commands, review hints (tests run, build verified, config changed, etc.).
`shit review` options:
- `--recent=<n>` review latest `n` sessions (default `1`)
- `--all` review all sessions in `.shit-logs`
- `--min-severity=<info|low|medium|high|critical>` filter findings
- `--fail-on=<info|low|medium|high|critical>` strict-mode failure threshold (default `high`)
- `--strict` exit code `1` when findings reach `--fail-on`
- `--json` output structured JSON
- `--markdown` / `--md` output Markdown
### Query session memory
### Cross-Session Queries

@@ -78,49 +82,71 @@ ```bash

### Shadow branches
### Checkpoints & Recovery
```bash
shit shadow # List shadow branches
shit shadow info <branch> # Show branch details
shit checkpoints # List all checkpoints
shit commit # Manually create checkpoint for current HEAD
shit rewind <checkpoint> # Rollback to a checkpoint (git reset --hard)
shit rewind --interactive # Choose from available checkpoints
shit resume <checkpoint> # Restore session data from a checkpoint
shit reset --force # Delete checkpoint for current HEAD
```
### Clean old sessions
### Maintenance
```bash
shit clean --days=7 --dry-run # Preview
shit clean --days=7 # Delete sessions older than 7 days
shit doctor # Diagnose issues (corrupted state, stuck sessions)
shit doctor --fix # Auto-fix detected issues
shit shadow # List shadow branches
shit shadow info <branch> # Show branch details
shit clean --days=7 --dry-run # Preview cleanup
shit clean --days=7 # Delete sessions older than 7 days
shit summarize <session-id> # Generate AI summary (requires API key)
```
## Commands
## Command Reference
| Command | Description |
|---------|-------------|
| `init` | Initialize hooks in .claude/settings.json |
| `log <hook-type>` | Log a hook event from stdin (called by hooks) |
| `enable` | Enable shit-cli in repository (multi-agent support) |
| `disable` | Remove hooks, optionally clean data |
| `status` | Show current session and git info |
| `init` | Register hooks in .claude/settings.json |
| `log <type>` | Log a hook event from stdin (called by hooks) |
| `list` | List all sessions with type, intent, risk |
| `view <session-id> [--json]` | View semantic session report |
| `query [options]` | Query session memory across sessions |
| `shadow [info <branch>]` | List or inspect shadow branches |
| `clean [--days=N] [--dry-run]` | Clean old sessions |
| `help` | Show help |
| `view <id>` | View semantic session report |
| `review [id]` | Run structured code review (single or multi-session) |
| `query` | Query session memory across sessions |
| `explain <id>` | Human-friendly explanation of a session or commit |
| `commit` | Create checkpoint on git commit |
| `checkpoints` | List all checkpoints |
| `rewind <cp>` | Rollback to a checkpoint |
| `resume <cp>` | Resume session from a checkpoint |
| `reset` | Delete checkpoint for current HEAD |
| `summarize <id>` | Generate AI summary for a session |
| `doctor` | Diagnose and fix issues |
| `shadow` | List/inspect shadow branches |
| `clean` | Clean old sessions |
| `webhook` | Show/test webhook configuration |
## Architecture
## How It Works
```
shit-cli/
├── bin/shit.js # CLI entry point
├── lib/
│ ├── config.js # Shared config: getProjectRoot(), getLogDir(), toRelative()
│ ├── extract.js # Semantic extraction: intent, changes, classification
│ ├── report.js # Report generation: summary.json v2, summary.txt, metadata
│ ├── session.js # Session state management + cross-session index
│ ├── log.js # Event ingestion dispatcher (stdin → parse → extract → save)
│ ├── init.js # shit init (hook registration)
│ ├── list.js # shit list (semantic session listing)
│ ├── view.js # shit view (semantic report display)
│ ├── query.js # shit query (cross-session memory queries)
│ ├── clean.js # shit clean (session cleanup)
│ ├── shadow.js # shit shadow (branch listing)
│ └── git-shadow.js # Git plumbing for shadow branches
Human <-> AI Agent (Claude Code, Gemini CLI, ...)
| (hooks)
Event Ingestion (log.js)
|
Semantic Extraction (extract.js)
|
Session State (session.js) + Reports (report.js)
|
Memory System (.shit-logs/ + index.json)
|
Code Review Bot / Human Queries
```
1. **Ingestion** - Hooks fire on every agent event (tool use, prompts, session start/end). Events are appended to `events.jsonl`.
2. **Extraction** - Each event updates incremental state. Intent, change categories, and risk are computed using rule-based pattern matching (zero latency, zero cost, fully offline).
3. **Reports** - `summary.json` (bot-readable), `summary.txt` (human-readable), `context.md`, `metadata.json`, and `prompts.txt` are regenerated on every event.
4. **Checkpoints** - On session end or git commit, session data is committed to an orphan git branch using plumbing commands (no working tree impact).
## Data Model

@@ -132,13 +158,15 @@

.shit-logs/
├── index.json # Cross-session index (file history, types)
├── index.json # Cross-session index
└── <session-id>/
├── events.jsonl # Raw hook events
├── state.json # Incremental processing state
├── summary.json # Bot data interface (v2 schema)
├── summary.txt # Human-readable semantic report
├── summary.json # Bot data interface (v2)
├── summary.txt # Human-readable report
├── context.md # Session context (Entire-style)
├── prompts.txt # User prompts with timestamps
└── metadata.json # Lightweight session metadata
├── metadata.json # Lightweight session metadata
└── ai-summary.md # AI-generated summary (optional)
```
### summary.json v2 Schema
### summary.json v2

@@ -159,9 +187,3 @@ ```json

"changes": {
"files": [{
"path": "src/auth/auth.service.ts",
"category": "source",
"operations": ["edit"],
"editCount": 2,
"editSummary": "Modified timeout logic"
}],
"files": [{ "path": "src/auth.ts", "category": "source", "operations": ["edit"] }],
"summary": { "source": 3, "test": 1 }

@@ -171,6 +193,3 @@ },

"tools": { "Read": 15, "Edit": 3, "Bash": 5 },
"commands": {
"test": ["npm run test"],
"git": ["git status"]
},
"commands": { "test": ["npm run test"], "git": ["git status"] },
"errors": []

@@ -181,3 +200,3 @@ },

"build_verified": false,
"files_without_tests": ["src/auth/auth.service.ts"],
"files_without_tests": ["src/auth.ts"],
"large_change": false,

@@ -187,3 +206,3 @@ "config_changed": false,

},
"prompts": ["Fix the auth timeout bug", "Run the tests"],
"prompts": [{ "time": "...", "text": "Fix the auth timeout bug" }],
"scope": ["auth"]

@@ -193,22 +212,2 @@ }

### Cross-Session Index
```json
{
"project": "my-project",
"sessions": [{
"id": "f608c31e...",
"date": "2026-02-27",
"type": "bugfix",
"intent": "Fix auth timeout",
"files": ["src/auth/auth.service.ts"],
"duration": 45,
"risk": "medium"
}],
"file_history": {
"src/auth/auth.service.ts": ["f608c31e...", "a1b2c3d4..."]
}
}
```
## Session Types

@@ -230,9 +229,8 @@

| `perf` | Performance optimization |
| `unknown` | Unclassified |
## Risk Levels
- **low**: Few files, no config/migration changes, tests run
- **medium**: Multiple files, some config changes
- **high**: Many files (>10), migration changes, infra changes without tests
- **low** - Few files, no config/migration changes, tests run
- **medium** - Multiple files, some config changes
- **high** - Many files (>10), migration changes, infra changes without tests

@@ -242,4 +240,6 @@ ## Bot Integration

```javascript
import { readFileSync } from 'fs';
// Read session data
const summary = JSON.parse(fs.readFileSync('.shit-logs/<id>/summary.json'));
const summary = JSON.parse(readFileSync('.shit-logs/<id>/summary.json', 'utf-8'));

@@ -255,5 +255,5 @@ // Check review hints

// Query file history via index
const index = JSON.parse(fs.readFileSync('.shit-logs/index.json'));
const index = JSON.parse(readFileSync('.shit-logs/index.json', 'utf-8'));
const history = index.file_history['src/auth/auth.service.ts'];
if (history.length > 3) {
if (history && history.length > 3) {
review.note('This file has been modified frequently');

@@ -263,8 +263,113 @@ }

## Webhook
shit-cli can send webhook notifications to external systems (Slack, Lark, CI, custom platforms) when key events occur.
### Events
| Event | Trigger | Payload |
|-------|---------|---------|
| `session.ended` | Session ends (hook `session-end` / `stop`) | `summary.json` content |
| `review.completed` | `shit review` finishes | Review report |
### Configuration
Webhook supports three configuration sources (highest priority first):
**1. Environment variables** (highest priority):
```bash
export SHIT_WEBHOOK_URL=https://example.com/hook
export SHIT_WEBHOOK_SECRET=my-secret # HMAC-SHA256 signing
export SHIT_WEBHOOK_AUTH_TOKEN=bearer-token # Bearer auth (alternative to secret)
export SHIT_WEBHOOK_EVENTS=session.ended,review.completed
```
**2. `.shit-logs/config.json` `env` field**:
```json
{
"env": {
"SHIT_WEBHOOK_URL": "https://example.com/hook",
"SHIT_WEBHOOK_SECRET": "my-secret",
"SHIT_WEBHOOK_EVENTS": "session.ended,review.completed"
}
}
```
**3. `.shit-logs/config.json` `webhooks` field** (lowest priority):
```json
{
"webhooks": {
"url": "https://example.com/hook",
"events": ["session.ended", "review.completed"],
"secret": "hmac-secret-key",
"headers": { "X-Custom": "value" },
"timeout_ms": 5000,
"retry": 1
}
}
```
### Authentication
- **HMAC-SHA256** — Set `secret` or `SHIT_WEBHOOK_SECRET`. Adds `X-Signature-256: sha256=<hex>` header (GitHub-compatible format).
- **Bearer token** — Set `auth_token` or `SHIT_WEBHOOK_AUTH_TOKEN`. Adds `Authorization: Bearer <token>` header.
- If neither is set, requests are sent without authentication.
### Payload Format
```json
{
"event": "session.ended",
"timestamp": "2026-03-03T12:00:00.000Z",
"payload": { ... }
}
```
### Commands
```bash
shit webhook # Show current webhook configuration
shit webhook --test # Send a test ping to the configured URL
```
## AI Summary
Set one of these environment variables to enable AI-powered session summaries:
```bash
export OPENAI_API_KEY=sk-... # Uses gpt-4o-mini by default
export OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: OpenAI-compatible base URL
export ANTHROPIC_API_KEY=sk-... # Uses claude-3-haiku by default
```
Then run:
```bash
shit summarize <session-id>
```
## Environment Variables
- `SHIT_LOG_DIR`: Custom log directory (default: `./.shit-logs` in project root)
| Variable | Description |
|----------|-------------|
| `SHIT_LOG_DIR` | Custom log directory (default: `.shit-logs` in project root) |
| `OPENAI_API_KEY` | Enable AI summaries via OpenAI |
| `OPENAI_BASE_URL` | OpenAI-compatible base URL for summaries (default: `https://api.openai.com/v1`) |
| `OPENAI_ENDPOINT` | Full OpenAI-compatible endpoint (overrides `OPENAI_BASE_URL`) |
| `ANTHROPIC_API_KEY` | Enable AI summaries via Anthropic |
| `SHIT_WEBHOOK_URL` | Webhook endpoint URL |
| `SHIT_WEBHOOK_SECRET` | HMAC-SHA256 signing secret for webhooks |
| `SHIT_WEBHOOK_AUTH_TOKEN` | Bearer token for webhook authentication |
| `SHIT_WEBHOOK_EVENTS` | Comma-separated list of webhook events to subscribe to |
## Security
- Session logs are stored locally in `.shit-logs/` (added to `.gitignore` automatically)
- Secrets (API keys, tokens, passwords) are automatically redacted when writing to shadow branches
- Checkpoint data uses git plumbing commands — no impact on your working tree
## License
MIT
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log session-start"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log session-end"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log user-prompt-submit"
}
]
}
],
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log pre-tool-use"
}
]
}
],
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log post-tool-use"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log notification"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "shit log stop"
}
]
}
]
}
}
{
"permissions": {
"allow": [
"Bash(git remote add:*)",
"Bash(git push:*)",
"Bash(npm publish:*)",
"Bash(npm whoami:*)",
"WebFetch(domain:www.npmjs.com)",
"Bash(npm config delete:*)",
"Bash(sudo chown:*)",
"Bash(npm cache clean:*)",
"Bash(npm install:*)",
"Bash(git add:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\n更新包名和版本号\n\n- 包名改为 @cluademini/shit-cli\n- 版本号升至 1.0.3\nEOF\n\\)\")",
"Bash(git commit:*)",
"Bash(npm config set:*)",
"WebFetch(domain:github.com)"
]
}
}
# Project Structure Comparison
## Directory Layout
```
your-project/
├── .claude/ # Claude Code configuration
│ └── settings.json # Hook configurations
├── .entire/ # Entire tool (session management)
│ ├── metadata/
│ │ └── <session-id>/
│ │ ├── full.jsonl # Complete transcript
│ │ ├── prompt.txt # User prompts
│ │ ├── context.md # Session context
│ │ └── summary.txt # Session summary
│ ├── logs/
│ └── settings.json
└── .shit-logs/ # shit-cli (hook event logging)
├── <session-id>/
│ ├── events.jsonl # Hook events only
│ ├── prompts.txt # User prompts
│ ├── context.md # Session context
│ ├── summary.txt # Session summary
│ └── metadata.json # Session metadata
└── index.txt # Global index
```
## Feature Comparison
| Feature | `.entire` | `.shit-logs` |
|---------|-----------|--------------|
| **Scope** | Complete session transcript | Hook events only |
| **Location** | Project root | Project root ✓ |
| **Session-based** | ✓ | ✓ |
| **Transcript** | `full.jsonl` (all messages) | `events.jsonl` (hooks only) |
| **Prompts** | `prompt.txt` | `prompts.txt` |
| **Context** | `context.md` | `context.md` |
| **Summary** | `summary.txt` | `summary.txt` |
| **Checkpoints** | Git shadow branches | Not implemented |
| **CLI** | `entire` commands | `shit` commands |
| **Auto-init** | `entire enable` | `shit init` |
## Key Differences
### `.entire` (Entire Tool)
- **Purpose**: Complete session management and checkpointing
- **Data**: Full conversation transcript (user + assistant messages)
- **Features**: Git integration, checkpoints, session replay
- **Trigger**: Automatic (entire daemon)
### `.shit-logs` (shit-cli)
- **Purpose**: Hook event logging and analysis
- **Data**: Hook events only (tool calls, session events)
- **Features**: Session aggregation, event filtering, cleanup
- **Trigger**: Hook-based (Claude Code hooks)
## Use Cases
### Use `.entire` when you need:
- Complete session history
- Git-based checkpointing
- Session replay and analysis
- Cross-session context
### Use `.shit-logs` when you need:
- Hook event debugging
- Tool usage analysis
- Session statistics
- Lightweight logging
## Both Together
Running both systems provides:
- **Complete coverage**: Full transcript + hook events
- **Different perspectives**: Conversation flow + tool execution
- **Complementary data**: `.entire` for context, `.shit-logs` for debugging
## .gitignore
Both directories are typically excluded from git:
```gitignore
# Entire tool
.entire/store/
.entire/monitor.pid
.entire/monitor.sessions.json
# shit-cli logs
.shit-logs/
```
Note: `.entire/settings.json` and `.entire/.gitignore` are usually committed.
# shit-cli Design Philosophy
## Core Vision
### 1. Human-AI Interaction Memory System
Long-term memory storage for human-AI interactions, not just temporary logs. Each session is analyzed for semantic meaning — what was the intent, what changed, what's the risk.
### 2. Code Review Bot Data Support
Provide reliable, structured data to support code review automation. The bot doesn't need to parse raw events — it gets pre-classified changes, risk assessments, and review hints.
## Architecture
```
Human ↔ AI (Claude Code)
↓ (hooks)
Event Ingestion (log.js)
Semantic Extraction (extract.js)
Session State (session.js) + Reports (report.js)
Memory System (.shit-logs + index.json)
Code Review Bot / Human Queries
```
### Three-Layer Architecture
1. **Ingestion Layer** — `log.js` reads stdin, parses events, dispatches
2. **Intelligence Layer** — `extract.js` classifies intent, changes, risk
3. **Storage Layer** — `session.js` manages state, `report.js` generates outputs
## Semantic Model
### Intent Extraction
From user prompts, extract:
- **Goal**: What the user is trying to accomplish
- **Type**: bugfix, feature, refactor, debug, test, docs, etc.
- **Scope**: Which domains are involved (auth, api, database, etc.)
### Change Extraction
From tool events, extract:
- **Files**: Categorized (source, test, config, doc, script, infra, deps, migration)
- **Operations**: What was done to each file (read, write, edit)
- **Commands**: Categorized (test, build, git, deploy, install, lint, database)
### Session Classification
Combining intent + changes:
- **Type**: Dominant activity category
- **Risk**: Assessment based on file count, config changes, test coverage
- **Review Hints**: Actionable flags for code review bots
## File Structure
```
.shit-logs/
├── index.json # Cross-session index (file history, session types)
└── <session-id>/
├── events.jsonl # Raw hook events (complete history)
├── state.json # Incremental processing state
├── summary.json # Bot data interface (v2 schema)
├── summary.txt # Human-readable semantic report
├── prompts.txt # User prompts with timestamps
└── metadata.json # Lightweight session metadata
```
## Key Design Decisions
### 1. Rule-Based Classification (No AI Dependency)
Intent and change classification use simple pattern matching rules. This ensures:
- Zero latency — runs during hook processing
- Zero cost — no API calls
- Predictable — deterministic output
- Offline — works without network
### 2. Cross-Session Index
The `index.json` file enables:
- File history queries ("how often was this file changed?")
- Session type filtering ("show all bugfix sessions")
- Risk tracking over time
- Bot can query without scanning all sessions
### 3. summary.json v2 Schema
Upgraded from v1 (statistics-only) to v2 (semantic):
- **v1**: event counts, file lists, tool usage
- **v2**: intent, type, risk, review_hints, categorized changes/commands
### 4. Incremental Processing
State is maintained incrementally in `state.json`. Each event updates the state, and reports are regenerated from the latest state. This means:
- Fast processing per event (~5ms)
- Reports always reflect current session state
- No need to re-read events.jsonl
### 5. Best-Effort Shadow Branches
Git shadow branches and index updates are best-effort — failures don't affect core logging. This ensures hooks never block Claude Code.
## Bot Integration Patterns
### Direct File Read
```javascript
const summary = JSON.parse(fs.readFileSync('.shit-logs/<id>/summary.json'));
// Use summary.review_hints for automated review decisions
```
### Cross-Session Query
```javascript
const index = JSON.parse(fs.readFileSync('.shit-logs/index.json'));
// Find all sessions that touched a specific file
const sessions = index.file_history['src/auth/service.ts'];
// Filter by type or risk
const risky = index.sessions.filter(s => s.risk === 'high');
```
### CLI Query (for humans)
```bash
shit query --file=src/auth/service.ts # File history
shit query --type=bugfix --recent=5 # Recent bugfixes
shit query --risk=high --json # High-risk as JSON
```
## Comparison with `.entire`
| Aspect | `.entire` | `.shit-logs` |
|--------|-----------|--------------|
| **Data Source** | Full transcript | Hook events |
| **Intelligence** | Raw messages | Semantic extraction |
| **Bot Interface** | Parse messages | Structured JSON (v2) |
| **Cross-Session** | No | Index with file history |
| **Review Hints** | No | Tests, risk, coverage |
| **Purpose** | Conversation memory | Operation intelligence |
## Future Enhancements
- AI-powered intent extraction (optional, when available)
- Diff-level change tracking (what exactly changed in each edit)
- Session linking (detect related sessions across time)
- Anomaly detection (unusual patterns in session activity)
- Bot API endpoint (serve session data over HTTP)
# Quick Start Guide
## Installation
```bash
# Clone or download shit-cli
cd ~/Desktop/shit-cli
# Install globally
npm link
# Or add to PATH
echo 'export PATH="$HOME/Desktop/shit-cli/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
```
## Setup (One-time)
```bash
# Navigate to your project
cd /path/to/your/project
# Initialize hooks
shit init
```
This creates `.claude/settings.json` with all necessary hooks.
## Verify Installation
```bash
# Check if shit is available
shit help
# Should output:
# shit-cli - Session-based Hook Intelligence Tracker
# Usage: shit <command> [options]
# ...
```
## Usage
Once initialized, hooks will automatically log events to `~/.shit-logs/`.
### View your sessions
```bash
# List all sessions
shit list
# View specific session
shit view <session-id>
```
### Clean up old logs
```bash
# Preview what will be deleted
shit clean --days=7 --dry-run
# Actually delete
shit clean --days=7
```
## Troubleshooting
### Command not found: shit
If `npm link` doesn't work, use the full path:
```bash
node ~/Desktop/shit-cli/bin/shit.js init
```
Or create an alias:
```bash
echo 'alias shit="node ~/Desktop/shit-cli/bin/shit.js"' >> ~/.zshrc
source ~/.zshrc
```
### Hooks not working
1. Check if `.claude/settings.json` exists in your project
2. Verify hooks are registered: `cat .claude/settings.json`
3. Restart Claude Code
### Logs not appearing
1. Check log directory: `ls -la ~/.shit-logs/`
2. Verify hooks are being triggered (check Claude Code output)
3. Test manually:
```bash
echo '{"session_id":"test-123","hook_event_name":"Test"}' | shit log test
shit list
```
## Uninstall
```bash
# Remove global link
npm unlink -g shit-cli
# Remove logs
rm -rf ~/.shit-logs
# Remove hooks from project
# Edit .claude/settings.json and remove shit-related hooks
```