@claudemini/shit-cli 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +81 -0
- package/.claude/settings.local.json +19 -0
- package/COMPARISON.md +92 -0
- package/DESIGN_PHILOSOPHY.md +138 -0
- package/QUICKSTART.md +109 -0
- package/README.md +258 -0
- package/bin/shit.js +61 -0
- package/lib/clean.js +45 -0
- package/lib/config.js +26 -0
- package/lib/extract.js +265 -0
- package/lib/git-shadow.js +136 -0
- package/lib/init.js +83 -0
- package/lib/list.js +62 -0
- package/lib/log.js +66 -0
- package/lib/query.js +110 -0
- package/lib/report.js +185 -0
- package/lib/session.js +184 -0
- package/lib/shadow.js +51 -0
- package/lib/view.js +50 -0
- package/package.json +22 -0
package/bin/shit.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const command = args[0];
|
|
5
|
+
|
|
6
|
+
const commands = {
|
|
7
|
+
init: 'Initialize hooks in .claude/settings.json',
|
|
8
|
+
log: 'Log a hook event (called by hooks)',
|
|
9
|
+
list: 'List all sessions',
|
|
10
|
+
view: 'View session details',
|
|
11
|
+
query: 'Query session memory (cross-session)',
|
|
12
|
+
shadow: 'List shadow branches',
|
|
13
|
+
clean: 'Clean old sessions',
|
|
14
|
+
help: 'Show help',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function showHelp() {
|
|
18
|
+
console.log('shit-cli - Session Hook Intelligence Tracker\n');
|
|
19
|
+
console.log('Usage: shit <command> [options]\n');
|
|
20
|
+
console.log('Commands:');
|
|
21
|
+
Object.entries(commands).forEach(([cmd, desc]) => {
|
|
22
|
+
console.log(` ${cmd.padEnd(10)} ${desc}`);
|
|
23
|
+
});
|
|
24
|
+
console.log('\nExamples:');
|
|
25
|
+
console.log(' shit init # Register hooks');
|
|
26
|
+
console.log(' shit list # List sessions');
|
|
27
|
+
console.log(' shit view <session-id> # View session');
|
|
28
|
+
console.log(' shit view <session-id> --json # View with JSON data');
|
|
29
|
+
console.log(' shit query --recent=5 # Recent 5 sessions');
|
|
30
|
+
console.log(' shit query --file=src/app.ts # Sessions that touched file');
|
|
31
|
+
console.log(' shit query --type=bugfix # Filter by session type');
|
|
32
|
+
console.log(' shit query --risk=high --json # High-risk sessions as JSON');
|
|
33
|
+
console.log(' shit shadow # List shadow branches');
|
|
34
|
+
console.log(' shit clean --days=7 # Clean old sessions');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!command || command === 'help' || command === '--help') {
|
|
38
|
+
showHelp();
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (command === '--version' || command === '-v') {
|
|
43
|
+
const { readFileSync } = await import('fs');
|
|
44
|
+
const { fileURLToPath } = await import('url');
|
|
45
|
+
const { dirname, join } = await import('path');
|
|
46
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
48
|
+
console.log(`shit-cli v${pkg.version}`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const mod = await import(`../lib/${command}.js`);
|
|
54
|
+
await mod.default(args.slice(1));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
57
|
+
console.error(`Unknown command: ${command}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
package/lib/clean.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdirSync, statSync, rmSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getProjectRoot, getLogDir, SESSION_ID_REGEX } from './config.js';
|
|
6
|
+
|
|
7
|
+
export default async function clean(args) {
|
|
8
|
+
const logDir = getLogDir(getProjectRoot());
|
|
9
|
+
const daysArg = args.find(a => a.startsWith('--days='));
|
|
10
|
+
const days = daysArg ? parseInt(daysArg.split('=')[1]) : 7;
|
|
11
|
+
const dryRun = args.includes('--dry-run');
|
|
12
|
+
const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const entries = readdirSync(logDir);
|
|
16
|
+
const old = entries
|
|
17
|
+
.filter(name => SESSION_ID_REGEX.test(name))
|
|
18
|
+
.map(id => {
|
|
19
|
+
const dir = join(logDir, id);
|
|
20
|
+
return { id, dir, mtime: statSync(dir).mtime.getTime() };
|
|
21
|
+
})
|
|
22
|
+
.filter(s => s.mtime < cutoffTime);
|
|
23
|
+
|
|
24
|
+
if (old.length === 0) {
|
|
25
|
+
console.log(`No sessions older than ${days} days.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`${old.length} session(s) older than ${days} days:`);
|
|
30
|
+
old.forEach(s => {
|
|
31
|
+
const age = Math.floor((Date.now() - s.mtime) / 86400000);
|
|
32
|
+
console.log(` ${s.id.slice(0, 8)}... (${age}d)`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (dryRun) {
|
|
36
|
+
console.log('\n[DRY RUN] No files deleted.');
|
|
37
|
+
} else {
|
|
38
|
+
old.forEach(s => rmSync(s.dir, { recursive: true, force: true }));
|
|
39
|
+
console.log(`\nDeleted ${old.length} session(s).`);
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT') console.log('No log directory found.');
|
|
43
|
+
else throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
export const SESSION_ID_REGEX = /^[a-f0-9-]{36}$/;
|
|
7
|
+
|
|
8
|
+
export function getProjectRoot() {
|
|
9
|
+
try {
|
|
10
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
11
|
+
encoding: 'utf-8',
|
|
12
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
13
|
+
}).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
return process.cwd();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getLogDir(projectRoot) {
|
|
20
|
+
return process.env.SHIT_LOG_DIR || join(projectRoot || getProjectRoot(), '.shit-logs');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toRelative(projectRoot, filePath) {
|
|
24
|
+
if (!filePath || !filePath.startsWith('/')) return filePath;
|
|
25
|
+
return relative(projectRoot, filePath) || filePath;
|
|
26
|
+
}
|
package/lib/extract.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Semantic extraction engine.
|
|
5
|
+
* Transforms raw hook events into structured, meaningful data
|
|
6
|
+
* for both humans and code review bots.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// --- Intent extraction from user prompts ---
|
|
10
|
+
|
|
11
|
+
const INTENT_PATTERNS = [
|
|
12
|
+
{ pattern: /\b(fix|bug|error|crash|broken|issue|wrong|fail)\b/i, type: 'bugfix' },
|
|
13
|
+
{ pattern: /\b(add|create|implement|new|feature|introduce)\b/i, type: 'feature' },
|
|
14
|
+
{ pattern: /\b(refactor|clean|reorganize|restructure|extract|move|rename|simplify)\b/i, type: 'refactor' },
|
|
15
|
+
{ pattern: /\b(debug|investigate|trace|log|print|inspect|check)\b/i, type: 'debug' },
|
|
16
|
+
{ pattern: /\b(test|spec|coverage|assert)\b/i, type: 'test' },
|
|
17
|
+
{ pattern: /\b(doc|readme|comment|explain|document)\b/i, type: 'docs' },
|
|
18
|
+
{ pattern: /\b(deploy|release|publish|build|ci|cd|pipeline)\b/i, type: 'devops' },
|
|
19
|
+
{ pattern: /\b(upgrade|update|bump|migrate|version)\b/i, type: 'upgrade' },
|
|
20
|
+
{ pattern: /\b(config|setting|env|environment|setup)\b/i, type: 'config' },
|
|
21
|
+
{ pattern: /\b(style|format|lint|prettier|css|ui|layout)\b/i, type: 'style' },
|
|
22
|
+
{ pattern: /\b(security|auth|permission|token|secret|vulnerability)\b/i, type: 'security' },
|
|
23
|
+
{ pattern: /\b(perf|performance|optimize|speed|slow|fast|cache)\b/i, type: 'perf' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const SCOPE_PATTERNS = [
|
|
27
|
+
{ pattern: /\b(auth|login|token|jwt|session|password)\b/i, scope: 'auth' },
|
|
28
|
+
{ pattern: /\b(api|endpoint|route|controller|handler)\b/i, scope: 'api' },
|
|
29
|
+
{ pattern: /\b(database|db|prisma|migration|schema|sql|query)\b/i, scope: 'database' },
|
|
30
|
+
{ pattern: /\b(test|spec|jest|vitest)\b/i, scope: 'testing' },
|
|
31
|
+
{ pattern: /\b(ui|component|page|view|template|frontend)\b/i, scope: 'ui' },
|
|
32
|
+
{ pattern: /\b(deploy|docker|ci|pipeline|k8s|server)\b/i, scope: 'infra' },
|
|
33
|
+
{ pattern: /\b(config|env|setting)\b/i, scope: 'config' },
|
|
34
|
+
{ pattern: /\b(webhook|hook|event)\b/i, scope: 'webhook' },
|
|
35
|
+
{ pattern: /\b(agent|bot|worker)\b/i, scope: 'agent' },
|
|
36
|
+
{ pattern: /\b(job|task|queue)\b/i, scope: 'job' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function extractIntent(prompts) {
|
|
40
|
+
if (!prompts || prompts.length === 0) {
|
|
41
|
+
return { goal: '', type: 'unknown', scope: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Use all prompts for classification, but first prompt is typically the main goal
|
|
45
|
+
const allText = prompts.map(p => typeof p === 'string' ? p : p.text || '').join(' ');
|
|
46
|
+
const firstPrompt = typeof prompts[0] === 'string' ? prompts[0] : prompts[0].text || '';
|
|
47
|
+
|
|
48
|
+
// Determine type by counting pattern matches across all prompts
|
|
49
|
+
const typeCounts = {};
|
|
50
|
+
for (const { pattern, type } of INTENT_PATTERNS) {
|
|
51
|
+
const matches = allText.match(new RegExp(pattern, 'gi'));
|
|
52
|
+
if (matches) typeCounts[type] = (typeCounts[type] || 0) + matches.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const type = Object.entries(typeCounts)
|
|
56
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
|
|
57
|
+
|
|
58
|
+
// Extract scopes
|
|
59
|
+
const scopes = new Set();
|
|
60
|
+
for (const { pattern, scope } of SCOPE_PATTERNS) {
|
|
61
|
+
if (pattern.test(allText)) scopes.add(scope);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Goal: use first prompt, truncated
|
|
65
|
+
const goal = firstPrompt.slice(0, 200).trim();
|
|
66
|
+
|
|
67
|
+
return { goal, type, scope: [...scopes] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Change extraction from tool events ---
|
|
71
|
+
|
|
72
|
+
const FILE_CATEGORIES = [
|
|
73
|
+
{ pattern: /\.(spec|test)\.(ts|js|tsx|jsx)$/, category: 'test' },
|
|
74
|
+
{ pattern: /__(tests|mocks|fixtures)__\//, category: 'test' },
|
|
75
|
+
{ pattern: /\.(config|rc)\.(ts|js|json|yaml|yml)$/, category: 'config' },
|
|
76
|
+
{ pattern: /\.(env|env\.\w+)$/, category: 'config' },
|
|
77
|
+
{ pattern: /\.gitignore|\.eslintrc|\.prettierrc|tsconfig/, category: 'config' },
|
|
78
|
+
{ pattern: /prisma\/.*\.prisma$/, category: 'config' },
|
|
79
|
+
{ pattern: /prisma\/migrations\//, category: 'migration' },
|
|
80
|
+
{ pattern: /\.(md|txt|rst|doc)$/, category: 'doc' },
|
|
81
|
+
{ pattern: /README|CHANGELOG|LICENSE|CONTRIBUTING/, category: 'doc' },
|
|
82
|
+
{ pattern: /scripts\/|\.sh$/, category: 'script' },
|
|
83
|
+
{ pattern: /Dockerfile|docker-compose|\.dockerignore/, category: 'infra' },
|
|
84
|
+
{ pattern: /\.github\/|\.gitlab-ci|\.circleci/, category: 'infra' },
|
|
85
|
+
{ pattern: /package(-lock)?\.json|yarn\.lock|pnpm-lock/, category: 'deps' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function categorizeFile(filePath) {
|
|
89
|
+
for (const { pattern, category } of FILE_CATEGORIES) {
|
|
90
|
+
if (pattern.test(filePath)) return category;
|
|
91
|
+
}
|
|
92
|
+
return 'source';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const COMMAND_CATEGORIES = [
|
|
96
|
+
{ pattern: /\b(test|jest|vitest|mocha|cypress|playwright)\b/i, category: 'test' },
|
|
97
|
+
{ pattern: /\b(build|compile|tsc|webpack|vite|rollup|esbuild)\b/i, category: 'build' },
|
|
98
|
+
{ pattern: /\bgit\b/i, category: 'git' },
|
|
99
|
+
{ pattern: /\b(deploy|ssh|scp|rsync|docker)\b/i, category: 'deploy' },
|
|
100
|
+
{ pattern: /\b(npm|yarn|pnpm|pip|cargo)\s+(install|add|remove)/i, category: 'install' },
|
|
101
|
+
{ pattern: /\b(lint|eslint|prettier|format)\b/i, category: 'lint' },
|
|
102
|
+
{ pattern: /\b(prisma|migrate|migration)\b/i, category: 'database' },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
function categorizeCommand(cmd) {
|
|
106
|
+
for (const { pattern, category } of COMMAND_CATEGORIES) {
|
|
107
|
+
if (pattern.test(cmd)) return category;
|
|
108
|
+
}
|
|
109
|
+
return 'other';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function extractChanges(state) {
|
|
113
|
+
const fileMap = new Map(); // path -> entry
|
|
114
|
+
|
|
115
|
+
// Process file operations
|
|
116
|
+
for (const path of (state.file_ops?.write || [])) {
|
|
117
|
+
const entry = getOrCreateFile(fileMap, path);
|
|
118
|
+
if (!entry.operations.includes('write')) entry.operations.push('write');
|
|
119
|
+
}
|
|
120
|
+
for (const path of (state.file_ops?.edit || [])) {
|
|
121
|
+
const entry = getOrCreateFile(fileMap, path);
|
|
122
|
+
if (!entry.operations.includes('edit')) entry.operations.push('edit');
|
|
123
|
+
}
|
|
124
|
+
for (const path of (state.file_ops?.read || [])) {
|
|
125
|
+
const entry = getOrCreateFile(fileMap, path);
|
|
126
|
+
if (!entry.operations.includes('read')) entry.operations.push('read');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Enrich with edit counts and summaries
|
|
130
|
+
for (const edit of (state.edits || [])) {
|
|
131
|
+
const entry = fileMap.get(edit.file);
|
|
132
|
+
if (entry) {
|
|
133
|
+
entry.editCount = (entry.editCount || 0) + 1;
|
|
134
|
+
if (edit.new && !entry.editSummary) {
|
|
135
|
+
entry.editSummary = edit.new.slice(0, 100);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Categorize commands
|
|
141
|
+
const commands = {};
|
|
142
|
+
for (const cmd of (state.commands || [])) {
|
|
143
|
+
const cat = categorizeCommand(cmd);
|
|
144
|
+
if (!commands[cat]) commands[cat] = [];
|
|
145
|
+
commands[cat].push(cmd);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Build summary counts (modified files only)
|
|
149
|
+
const summary = {};
|
|
150
|
+
for (const [, entry] of fileMap) {
|
|
151
|
+
if (entry.operations.some(op => op !== 'read')) {
|
|
152
|
+
summary[entry.category] = (summary[entry.category] || 0) + 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
files: [...fileMap.values()],
|
|
158
|
+
commands,
|
|
159
|
+
summary,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getOrCreateFile(fileMap, path) {
|
|
164
|
+
if (!fileMap.has(path)) {
|
|
165
|
+
fileMap.set(path, {
|
|
166
|
+
path,
|
|
167
|
+
category: categorizeFile(path),
|
|
168
|
+
operations: [],
|
|
169
|
+
editCount: 0,
|
|
170
|
+
editSummary: '',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return fileMap.get(path);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Session classification ---
|
|
177
|
+
|
|
178
|
+
export function classifySession(intent, changes) {
|
|
179
|
+
const type = intent.type !== 'unknown' ? intent.type : inferTypeFromChanges(changes);
|
|
180
|
+
|
|
181
|
+
const risk = assessRisk(changes);
|
|
182
|
+
|
|
183
|
+
const testsRun = !!(changes.commands?.test?.length > 0);
|
|
184
|
+
const buildVerified = !!(changes.commands?.build?.length > 0);
|
|
185
|
+
|
|
186
|
+
// Find source files that lack corresponding test files
|
|
187
|
+
const sourceFiles = changes.files
|
|
188
|
+
.filter(f => f.category === 'source' && f.operations.some(op => op !== 'read'));
|
|
189
|
+
const testFiles = new Set(
|
|
190
|
+
changes.files.filter(f => f.category === 'test').map(f => f.path)
|
|
191
|
+
);
|
|
192
|
+
const filesWithoutTests = sourceFiles
|
|
193
|
+
.filter(f => {
|
|
194
|
+
const testVariants = [
|
|
195
|
+
f.path.replace(/\.(ts|js)$/, '.spec.$1'),
|
|
196
|
+
f.path.replace(/\.(ts|js)$/, '.test.$1'),
|
|
197
|
+
f.path.replace(/src\//, 'test/').replace(/\.(ts|js)$/, '.e2e-spec.$1'),
|
|
198
|
+
];
|
|
199
|
+
return !testVariants.some(tv => testFiles.has(tv));
|
|
200
|
+
})
|
|
201
|
+
.map(f => f.path);
|
|
202
|
+
|
|
203
|
+
const modifiedFileCount = changes.files
|
|
204
|
+
.filter(f => f.operations.some(op => op !== 'read')).length;
|
|
205
|
+
|
|
206
|
+
const summary = buildSummary(intent, type, modifiedFileCount);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
type,
|
|
210
|
+
risk,
|
|
211
|
+
summary,
|
|
212
|
+
reviewHints: {
|
|
213
|
+
testsRun,
|
|
214
|
+
buildVerified,
|
|
215
|
+
filesWithoutTests,
|
|
216
|
+
largeChange: modifiedFileCount > 10,
|
|
217
|
+
configChanged: changes.files.some(f => f.category === 'config' && f.operations.some(op => op !== 'read')),
|
|
218
|
+
migrationAdded: changes.files.some(f => f.category === 'migration' && f.operations.some(op => op !== 'read')),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function inferTypeFromChanges(changes) {
|
|
224
|
+
if (changes.files.some(f => f.category === 'test' && f.operations.includes('write'))) return 'test';
|
|
225
|
+
if (changes.files.some(f => f.category === 'doc' && f.operations.some(op => op !== 'read'))) return 'docs';
|
|
226
|
+
if (changes.files.some(f => f.category === 'config' && f.operations.some(op => op !== 'read'))) return 'config';
|
|
227
|
+
if (changes.files.some(f => f.category === 'infra' && f.operations.some(op => op !== 'read'))) return 'devops';
|
|
228
|
+
return 'unknown';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function assessRisk(changes) {
|
|
232
|
+
let score = 0;
|
|
233
|
+
const modified = changes.files.filter(f => f.operations.some(op => op !== 'read'));
|
|
234
|
+
|
|
235
|
+
// More files = more risk
|
|
236
|
+
if (modified.length > 10) score += 2;
|
|
237
|
+
else if (modified.length > 5) score += 1;
|
|
238
|
+
|
|
239
|
+
// Config/migration changes are risky
|
|
240
|
+
if (modified.some(f => f.category === 'config')) score += 1;
|
|
241
|
+
if (modified.some(f => f.category === 'migration')) score += 2;
|
|
242
|
+
if (modified.some(f => f.category === 'infra')) score += 1;
|
|
243
|
+
|
|
244
|
+
// Tests reduce risk
|
|
245
|
+
if (changes.commands?.test?.length > 0) score -= 1;
|
|
246
|
+
|
|
247
|
+
if (score >= 3) return 'high';
|
|
248
|
+
if (score >= 1) return 'medium';
|
|
249
|
+
return 'low';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildSummary(intent, type, fileCount) {
|
|
253
|
+
const typeLabel = {
|
|
254
|
+
bugfix: 'Fixed', feature: 'Added', refactor: 'Refactored',
|
|
255
|
+
debug: 'Debugged', test: 'Tested', docs: 'Documented',
|
|
256
|
+
devops: 'DevOps', upgrade: 'Upgraded', config: 'Configured',
|
|
257
|
+
style: 'Styled', security: 'Security fix', perf: 'Optimized',
|
|
258
|
+
unknown: 'Modified',
|
|
259
|
+
}[type] || 'Modified';
|
|
260
|
+
|
|
261
|
+
if (intent.goal) {
|
|
262
|
+
return `${typeLabel}: ${intent.goal.slice(0, 150)}`;
|
|
263
|
+
}
|
|
264
|
+
return `${typeLabel} ${fileCount} file(s)`;
|
|
265
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shadow branch module - saves session logs to orphan git branches.
|
|
5
|
+
*
|
|
6
|
+
* Approach (same as .entire):
|
|
7
|
+
* - Creates orphan branches: shit/<base-commit>-<session-short>
|
|
8
|
+
* - Uses git plumbing commands (no checkout, no working tree impact)
|
|
9
|
+
* - Each session-end/stop creates a commit on the shadow branch
|
|
10
|
+
*
|
|
11
|
+
* Branch naming: shit/<HEAD-short>-<session-id-first-8>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { join, basename } from 'path';
|
|
17
|
+
|
|
18
|
+
function git(cmd, cwd) {
|
|
19
|
+
return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 10000 }).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gitRaw(cmd, cwd, input) {
|
|
23
|
+
return execSync(`git ${cmd}`, {
|
|
24
|
+
cwd, encoding: 'utf-8', timeout: 10000,
|
|
25
|
+
input,
|
|
26
|
+
}).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a git tree from a directory using plumbing commands.
|
|
31
|
+
* Returns the tree SHA.
|
|
32
|
+
*/
|
|
33
|
+
function buildTree(cwd, dirPath) {
|
|
34
|
+
const entries = readdirSync(dirPath);
|
|
35
|
+
const treeLines = [];
|
|
36
|
+
|
|
37
|
+
for (const name of entries) {
|
|
38
|
+
const fullPath = join(dirPath, name);
|
|
39
|
+
const stat = statSync(fullPath);
|
|
40
|
+
|
|
41
|
+
if (stat.isDirectory()) {
|
|
42
|
+
const subTreeSha = buildTree(cwd, fullPath);
|
|
43
|
+
treeLines.push(`040000 tree ${subTreeSha}\t${name}`);
|
|
44
|
+
} else {
|
|
45
|
+
const content = readFileSync(fullPath);
|
|
46
|
+
const blobSha = gitRaw('hash-object -w --stdin', cwd, content);
|
|
47
|
+
treeLines.push(`100644 blob ${blobSha}\t${name}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (treeLines.length === 0) return null;
|
|
52
|
+
return gitRaw('mktree', cwd, treeLines.join('\n'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Commit session logs to a shadow branch.
|
|
57
|
+
* Called on session-end or stop.
|
|
58
|
+
*/
|
|
59
|
+
export async function commitShadow(projectRoot, sessionDir, sessionId) {
|
|
60
|
+
// Verify we're in a git repo
|
|
61
|
+
try {
|
|
62
|
+
git('rev-parse --git-dir', projectRoot);
|
|
63
|
+
} catch {
|
|
64
|
+
return; // not a git repo, skip
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const headShort = git('rev-parse --short HEAD', projectRoot);
|
|
68
|
+
const sessionShort = sessionId.slice(0, 8);
|
|
69
|
+
const branchName = `shit/${headShort}-${sessionShort}`;
|
|
70
|
+
const refPath = `refs/heads/${branchName}`;
|
|
71
|
+
|
|
72
|
+
// Build tree from session directory
|
|
73
|
+
const sessionTree = buildTree(projectRoot, sessionDir);
|
|
74
|
+
if (!sessionTree) return;
|
|
75
|
+
|
|
76
|
+
// Wrap session files under .shit-logs/<session-id>/ in the tree
|
|
77
|
+
const wrapperLine = `040000 tree ${sessionTree}\t${sessionId}`;
|
|
78
|
+
const logsTree = gitRaw('mktree', projectRoot, wrapperLine);
|
|
79
|
+
const rootLine = `040000 tree ${logsTree}\t.shit-logs`;
|
|
80
|
+
const rootTree = gitRaw('mktree', projectRoot, rootLine);
|
|
81
|
+
|
|
82
|
+
// Check if branch already exists (for parent chaining)
|
|
83
|
+
let parentArg = '';
|
|
84
|
+
try {
|
|
85
|
+
const existing = git(`rev-parse ${refPath}`, projectRoot);
|
|
86
|
+
parentArg = `-p ${existing}`;
|
|
87
|
+
} catch {
|
|
88
|
+
// orphan - no parent
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create commit
|
|
92
|
+
const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
93
|
+
const message = `session ${sessionShort} @ ${timestamp}`;
|
|
94
|
+
const commitSha = git(
|
|
95
|
+
`commit-tree ${rootTree} ${parentArg} -m "${message}"`,
|
|
96
|
+
projectRoot
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Update branch ref
|
|
100
|
+
git(`update-ref ${refPath} ${commitSha}`, projectRoot);
|
|
101
|
+
|
|
102
|
+
// Save branch info to state
|
|
103
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
104
|
+
try {
|
|
105
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
106
|
+
state.shadow_branch = branchName;
|
|
107
|
+
const { writeFileSync } = await import('fs');
|
|
108
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
109
|
+
} catch { /* best effort */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List all shadow branches in the repo.
|
|
114
|
+
*/
|
|
115
|
+
export function listShadowBranches(projectRoot) {
|
|
116
|
+
try {
|
|
117
|
+
const output = git('branch --list "shit/*"', projectRoot);
|
|
118
|
+
return output.split('\n').map(b => b.trim()).filter(Boolean);
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Show shadow branch info.
|
|
126
|
+
*/
|
|
127
|
+
export function shadowInfo(projectRoot, branchName) {
|
|
128
|
+
try {
|
|
129
|
+
const log = git(`log ${branchName} --oneline -5`, projectRoot);
|
|
130
|
+
const files = git(`ls-tree -r --name-only ${branchName}`, projectRoot);
|
|
131
|
+
return { log, files: files.split('\n') };
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
package/lib/init.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getProjectRoot } from './config.js';
|
|
6
|
+
|
|
7
|
+
const HOOK_TYPES = [
|
|
8
|
+
'SessionStart', 'SessionEnd', 'UserPromptSubmit',
|
|
9
|
+
'PreToolUse', 'PostToolUse', 'Notification', 'Stop',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const SHIT_COMMAND = (hookType) => `shit log ${hookType.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`;
|
|
13
|
+
|
|
14
|
+
export default async function init(args) {
|
|
15
|
+
const cwd = getProjectRoot();
|
|
16
|
+
const claudeDir = join(cwd, '.claude');
|
|
17
|
+
const settingsFile = join(claudeDir, 'settings.json');
|
|
18
|
+
|
|
19
|
+
console.log('Initializing shit-cli hooks...\n');
|
|
20
|
+
|
|
21
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
22
|
+
|
|
23
|
+
// Load existing settings
|
|
24
|
+
let settings = {};
|
|
25
|
+
if (existsSync(settingsFile)) {
|
|
26
|
+
try {
|
|
27
|
+
settings = JSON.parse(readFileSync(settingsFile, 'utf-8'));
|
|
28
|
+
} catch {
|
|
29
|
+
console.log('Warning: could not parse settings.json, creating new');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!settings.hooks) settings.hooks = {};
|
|
33
|
+
|
|
34
|
+
// Register hooks
|
|
35
|
+
let added = 0, skipped = 0;
|
|
36
|
+
|
|
37
|
+
for (const hookType of HOOK_TYPES) {
|
|
38
|
+
const command = SHIT_COMMAND(hookType);
|
|
39
|
+
const existing = settings.hooks[hookType] || [];
|
|
40
|
+
|
|
41
|
+
const alreadyExists = existing.some(entry =>
|
|
42
|
+
entry.hooks?.some(h => h.command?.includes('shit log'))
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (alreadyExists) {
|
|
46
|
+
skipped++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const catchAll = existing.find(entry => entry.matcher === '');
|
|
51
|
+
if (catchAll) {
|
|
52
|
+
catchAll.hooks = catchAll.hooks || [];
|
|
53
|
+
catchAll.hooks.push({ type: 'command', command });
|
|
54
|
+
} else {
|
|
55
|
+
existing.push({ matcher: '', hooks: [{ type: 'command', command }] });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
settings.hooks[hookType] = existing;
|
|
59
|
+
added++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
63
|
+
|
|
64
|
+
console.log(`Added: ${added} hooks | Skipped: ${skipped} (already registered)`);
|
|
65
|
+
console.log('Hook types:', HOOK_TYPES.join(', '));
|
|
66
|
+
|
|
67
|
+
// Create .shit-logs and update .gitignore
|
|
68
|
+
mkdirSync(join(cwd, '.shit-logs'), { recursive: true });
|
|
69
|
+
|
|
70
|
+
const gitignoreFile = join(cwd, '.gitignore');
|
|
71
|
+
if (existsSync(gitignoreFile)) {
|
|
72
|
+
const content = readFileSync(gitignoreFile, 'utf-8');
|
|
73
|
+
if (!content.includes('.shit-logs')) {
|
|
74
|
+
appendFileSync(gitignoreFile, '\n# shit-cli logs\n.shit-logs/\n');
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
writeFileSync(gitignoreFile, '# shit-cli logs\n.shit-logs/\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('\nLogs: .shit-logs/ (project-local)');
|
|
81
|
+
console.log('Shadow branches: shit/<commit>-<session> (git)');
|
|
82
|
+
console.log('\nDone. Start Claude Code and hooks will log automatically.');
|
|
83
|
+
}
|
package/lib/list.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getProjectRoot, getLogDir, SESSION_ID_REGEX } from './config.js';
|
|
6
|
+
|
|
7
|
+
export default async function list(args) {
|
|
8
|
+
const logDir = getLogDir(getProjectRoot());
|
|
9
|
+
|
|
10
|
+
if (!existsSync(logDir)) {
|
|
11
|
+
console.log('No log directory found. Run "shit init" first.');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const entries = readdirSync(logDir);
|
|
16
|
+
const sessions = entries
|
|
17
|
+
.filter(name => SESSION_ID_REGEX.test(name))
|
|
18
|
+
.map(id => {
|
|
19
|
+
const dir = join(logDir, id);
|
|
20
|
+
const metaFile = join(dir, 'metadata.json');
|
|
21
|
+
const stateFile = join(dir, 'state.json');
|
|
22
|
+
try {
|
|
23
|
+
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
|
|
24
|
+
let state = null;
|
|
25
|
+
if (existsSync(stateFile)) {
|
|
26
|
+
state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
27
|
+
}
|
|
28
|
+
return { id, meta, state, mtime: statSync(dir).mtime };
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
})
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
33
|
+
|
|
34
|
+
if (sessions.length === 0) {
|
|
35
|
+
console.log('No sessions found.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`${sessions.length} session(s):\n`);
|
|
40
|
+
|
|
41
|
+
sessions.forEach((s, i) => {
|
|
42
|
+
const m = s.meta;
|
|
43
|
+
const st = s.state;
|
|
44
|
+
const dur = m.duration_minutes ?? (st?.start_time && st?.last_time
|
|
45
|
+
? Math.round((new Date(st.last_time) - new Date(st.start_time)) / 60000) : 0);
|
|
46
|
+
const type = m.type || 'unknown';
|
|
47
|
+
const risk = m.risk || '-';
|
|
48
|
+
const intent = m.intent || '';
|
|
49
|
+
const files = (m.files_touched || 0);
|
|
50
|
+
const errs = (m.errors || 0);
|
|
51
|
+
const shadow = st?.shadow_branch ? ` [${st.shadow_branch}]` : '';
|
|
52
|
+
const date = new Date(m.last_updated).toLocaleString();
|
|
53
|
+
|
|
54
|
+
console.log(`${i + 1}. ${s.id.slice(0, 8)} [${type}] risk:${risk}`);
|
|
55
|
+
if (intent) {
|
|
56
|
+
console.log(` ${intent.slice(0, 100)}`);
|
|
57
|
+
}
|
|
58
|
+
console.log(` ${dur}min | ${m.event_count} events | ${m.tool_calls || 0} tools | ${files} files | ${errs} errors${shadow}`);
|
|
59
|
+
console.log(` ${date}`);
|
|
60
|
+
console.log('');
|
|
61
|
+
});
|
|
62
|
+
}
|