@claudemini/shit-cli
Advanced tools
+732
| /** | ||
| * 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; | ||
| } | ||
| } |
+355
| #!/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); | ||
| } | ||
| } |
+224
| /** | ||
| * 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); | ||
| } |
+46
-24
@@ -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 |
+3
-15
@@ -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'); |
+7
-15
@@ -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, |
+3
-1
@@ -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() { |
+53
-17
@@ -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 @@ |
+16
-23
@@ -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'); |
+34
-14
@@ -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) { |
+43
-38
@@ -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) { |
+14
-3
@@ -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
-16
@@ -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 @@ } |
+32
-27
@@ -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); |
+63
-38
@@ -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) { |
+6
-0
@@ -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) { |
+44
-19
@@ -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(); |
+21
-5
| { | ||
| "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) |
-109
| # 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 | ||
| ``` |
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
Misc. License Issues
LicenseA package's licensing information has fine-grained problems.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 3 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Unpublished package
Supply chain riskPackage version was not found on the registry. It may exist on a different registry and need to be configured to pull from that registry.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Deprecated
MaintenanceThe maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
175173
31.45%4437
36.65%0
-100%364
40.54%30
-6.25%1
Infinity%1
Infinity%0
-100%39
14.71%17
41.67%