@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/lib/log.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* shit log — event ingestion dispatcher.
|
|
5
|
+
* Reads stdin, parses event, delegates to session/extract/report modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { getProjectRoot, getLogDir } from './config.js';
|
|
11
|
+
import { loadState, saveState, processEvent, updateIndex } from './session.js';
|
|
12
|
+
import { extractIntent, extractChanges, classifySession } from './extract.js';
|
|
13
|
+
import { generateReports } from './report.js';
|
|
14
|
+
|
|
15
|
+
export default async function log(args) {
|
|
16
|
+
const hookType = args[0] || 'unknown';
|
|
17
|
+
|
|
18
|
+
// Read payload from stdin
|
|
19
|
+
let payload = '';
|
|
20
|
+
for await (const chunk of process.stdin) {
|
|
21
|
+
payload += chunk;
|
|
22
|
+
}
|
|
23
|
+
if (!payload.trim()) process.exit(0);
|
|
24
|
+
|
|
25
|
+
let event;
|
|
26
|
+
try {
|
|
27
|
+
event = JSON.parse(payload);
|
|
28
|
+
} catch {
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const projectRoot = getProjectRoot();
|
|
33
|
+
const logDir = getLogDir(projectRoot);
|
|
34
|
+
const sessionId = event.session_id || 'unknown';
|
|
35
|
+
const sessionDir = join(logDir, sessionId);
|
|
36
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
// 1. Append raw event
|
|
39
|
+
appendFileSync(join(sessionDir, 'events.jsonl'), payload.trim() + '\n');
|
|
40
|
+
|
|
41
|
+
// 2. Load and update state
|
|
42
|
+
const state = loadState(sessionDir);
|
|
43
|
+
if (!state) process.exit(1);
|
|
44
|
+
processEvent(state, event, hookType, projectRoot);
|
|
45
|
+
saveState(sessionDir, state);
|
|
46
|
+
|
|
47
|
+
// 3. Extract semantics and generate reports
|
|
48
|
+
const intent = extractIntent(state.prompts);
|
|
49
|
+
const changes = extractChanges(state);
|
|
50
|
+
const classification = classifySession(intent, changes);
|
|
51
|
+
generateReports(sessionDir, sessionId, state, intent, changes, classification);
|
|
52
|
+
|
|
53
|
+
// 4. On session end: shadow branch + update index
|
|
54
|
+
if (hookType === 'session-end' || hookType === 'stop') {
|
|
55
|
+
try {
|
|
56
|
+
const { commitShadow } = await import('./git-shadow.js');
|
|
57
|
+
await commitShadow(projectRoot, sessionDir, sessionId);
|
|
58
|
+
} catch { /* shadow is best-effort */ }
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
updateIndex(logDir, sessionId, intent, classification, changes, state);
|
|
62
|
+
} catch { /* index is best-effort */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
package/lib/query.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* shit query — cross-session memory queries.
|
|
5
|
+
* Reads index.json to answer questions about past sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { getProjectRoot, getLogDir } from './config.js';
|
|
11
|
+
|
|
12
|
+
function loadIndex(logDir) {
|
|
13
|
+
const indexFile = join(logDir, 'index.json');
|
|
14
|
+
if (!existsSync(indexFile)) return null;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(indexFile, 'utf-8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function query(args) {
|
|
23
|
+
const logDir = getLogDir(getProjectRoot());
|
|
24
|
+
const index = loadIndex(logDir);
|
|
25
|
+
|
|
26
|
+
if (!index || index.sessions.length === 0) {
|
|
27
|
+
console.log('No session index found. Sessions are indexed on session-end.');
|
|
28
|
+
console.log('Run "shit list" to see raw sessions.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const jsonOutput = args.includes('--json');
|
|
33
|
+
const fileArg = args.find(a => a.startsWith('--file='));
|
|
34
|
+
const recentArg = args.find(a => a.startsWith('--recent='));
|
|
35
|
+
const typeArg = args.find(a => a.startsWith('--type='));
|
|
36
|
+
const riskArg = args.find(a => a.startsWith('--risk='));
|
|
37
|
+
|
|
38
|
+
let results = [...index.sessions];
|
|
39
|
+
|
|
40
|
+
// Filter by file
|
|
41
|
+
if (fileArg) {
|
|
42
|
+
const filePath = fileArg.split('=')[1];
|
|
43
|
+
const sessionIds = index.file_history?.[filePath] || [];
|
|
44
|
+
if (sessionIds.length === 0) {
|
|
45
|
+
// Try partial match
|
|
46
|
+
const matchingFiles = Object.keys(index.file_history || {})
|
|
47
|
+
.filter(f => f.includes(filePath));
|
|
48
|
+
const matchedIds = new Set();
|
|
49
|
+
for (const f of matchingFiles) {
|
|
50
|
+
for (const id of index.file_history[f]) matchedIds.add(id);
|
|
51
|
+
}
|
|
52
|
+
results = results.filter(s => matchedIds.has(s.id));
|
|
53
|
+
if (matchingFiles.length > 0 && !jsonOutput) {
|
|
54
|
+
console.log(`Matched files: ${matchingFiles.join(', ')}\n`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
const idSet = new Set(sessionIds);
|
|
58
|
+
results = results.filter(s => idSet.has(s.id));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Filter by type
|
|
63
|
+
if (typeArg) {
|
|
64
|
+
const type = typeArg.split('=')[1];
|
|
65
|
+
results = results.filter(s => s.type === type);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Filter by risk
|
|
69
|
+
if (riskArg) {
|
|
70
|
+
const risk = riskArg.split('=')[1];
|
|
71
|
+
results = results.filter(s => s.risk === risk);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Limit by recent
|
|
75
|
+
if (recentArg) {
|
|
76
|
+
const n = parseInt(recentArg.split('=')[1]) || 5;
|
|
77
|
+
results = results.slice(-n);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Output
|
|
81
|
+
if (jsonOutput) {
|
|
82
|
+
console.log(JSON.stringify(results, null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (results.length === 0) {
|
|
87
|
+
console.log('No matching sessions found.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`${results.length} session(s):\n`);
|
|
92
|
+
for (const s of results.reverse()) {
|
|
93
|
+
const filesCount = s.files?.length || 0;
|
|
94
|
+
console.log(` ${s.id.slice(0, 8)} ${s.date} [${s.type}] risk:${s.risk} ${s.duration}min ${filesCount} files`);
|
|
95
|
+
if (s.intent) {
|
|
96
|
+
console.log(` ${s.intent.slice(0, 100)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Show file history summary if --file was used
|
|
101
|
+
if (fileArg) {
|
|
102
|
+
const filePath = fileArg.split('=')[1];
|
|
103
|
+
const history = index.file_history?.[filePath];
|
|
104
|
+
if (history) {
|
|
105
|
+
console.log(`\nFile "${filePath}" modified in ${history.length} session(s)`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log('\nOptions: --file=<path> --type=<type> --risk=<level> --recent=<n> --json');
|
|
110
|
+
}
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Report generation module.
|
|
5
|
+
* Produces summary.json (v2, bot-readable), summary.txt (human-readable),
|
|
6
|
+
* prompts.txt, and metadata.json from session state + semantic data.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate all report files for a session.
|
|
14
|
+
*/
|
|
15
|
+
export function generateReports(sessionDir, sessionId, state, intent, changes, classification) {
|
|
16
|
+
const durationMs = state.start_time && state.last_time
|
|
17
|
+
? new Date(state.last_time) - new Date(state.start_time) : 0;
|
|
18
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
19
|
+
|
|
20
|
+
writeSummaryJson(sessionDir, sessionId, state, intent, changes, classification, durationMin);
|
|
21
|
+
writeSummaryTxt(sessionDir, sessionId, state, intent, changes, classification, durationMin);
|
|
22
|
+
writePromptsTxt(sessionDir, state);
|
|
23
|
+
writeMetadataJson(sessionDir, sessionId, state, intent, classification, durationMin);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeSummaryJson(sessionDir, sessionId, state, intent, changes, classification, durationMin) {
|
|
27
|
+
const summary = {
|
|
28
|
+
version: '2.0',
|
|
29
|
+
session: {
|
|
30
|
+
id: sessionId,
|
|
31
|
+
start: state.start_time,
|
|
32
|
+
end: state.last_time,
|
|
33
|
+
duration_minutes: durationMin,
|
|
34
|
+
type: classification.type,
|
|
35
|
+
intent: intent.goal,
|
|
36
|
+
risk: classification.risk,
|
|
37
|
+
summary: classification.summary,
|
|
38
|
+
},
|
|
39
|
+
changes: {
|
|
40
|
+
files: changes.files
|
|
41
|
+
.filter(f => f.operations.some(op => op !== 'read'))
|
|
42
|
+
.map(f => ({
|
|
43
|
+
path: f.path,
|
|
44
|
+
category: f.category,
|
|
45
|
+
operations: f.operations.filter(op => op !== 'read'),
|
|
46
|
+
editCount: f.editCount,
|
|
47
|
+
editSummary: f.editSummary,
|
|
48
|
+
})),
|
|
49
|
+
summary: changes.summary,
|
|
50
|
+
},
|
|
51
|
+
activity: {
|
|
52
|
+
tools: state.tool_counts,
|
|
53
|
+
commands: changes.commands,
|
|
54
|
+
errors: state.errors,
|
|
55
|
+
},
|
|
56
|
+
review_hints: {
|
|
57
|
+
tests_run: classification.reviewHints.testsRun,
|
|
58
|
+
build_verified: classification.reviewHints.buildVerified,
|
|
59
|
+
files_without_tests: classification.reviewHints.filesWithoutTests,
|
|
60
|
+
large_change: classification.reviewHints.largeChange,
|
|
61
|
+
config_changed: classification.reviewHints.configChanged,
|
|
62
|
+
migration_added: classification.reviewHints.migrationAdded,
|
|
63
|
+
},
|
|
64
|
+
prompts: state.prompts.map(p => typeof p === 'string' ? p : p.text),
|
|
65
|
+
scope: intent.scope,
|
|
66
|
+
};
|
|
67
|
+
writeFileSync(join(sessionDir, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeSummaryTxt(sessionDir, sessionId, state, intent, changes, classification, durationMin) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
|
|
73
|
+
// Header
|
|
74
|
+
lines.push(`# Session: ${sessionId.slice(0, 8)}...`);
|
|
75
|
+
lines.push(`Type: ${classification.type} | Risk: ${classification.risk} | Duration: ${durationMin}min`);
|
|
76
|
+
if (classification.summary) {
|
|
77
|
+
lines.push(`Summary: ${classification.summary}`);
|
|
78
|
+
}
|
|
79
|
+
lines.push('');
|
|
80
|
+
|
|
81
|
+
// Intent
|
|
82
|
+
if (intent.goal) {
|
|
83
|
+
lines.push('## Intent');
|
|
84
|
+
lines.push(` Goal: ${intent.goal.slice(0, 200)}`);
|
|
85
|
+
if (intent.scope.length > 0) {
|
|
86
|
+
lines.push(` Scope: ${intent.scope.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Changes
|
|
92
|
+
const modified = changes.files.filter(f => f.operations.some(op => op !== 'read'));
|
|
93
|
+
if (modified.length > 0) {
|
|
94
|
+
lines.push('## Changes');
|
|
95
|
+
for (const f of modified.slice(0, 20)) {
|
|
96
|
+
const ops = f.operations.filter(op => op !== 'read').join(',');
|
|
97
|
+
lines.push(` [${f.category}] ${f.path} (${ops}${f.editCount > 0 ? ` x${f.editCount}` : ''})`);
|
|
98
|
+
}
|
|
99
|
+
if (modified.length > 20) {
|
|
100
|
+
lines.push(` ... and ${modified.length - 20} more files`);
|
|
101
|
+
}
|
|
102
|
+
lines.push('');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tools
|
|
106
|
+
if (Object.keys(state.tool_counts).length > 0) {
|
|
107
|
+
lines.push('## Tools');
|
|
108
|
+
Object.entries(state.tool_counts)
|
|
109
|
+
.sort((a, b) => b[1] - a[1])
|
|
110
|
+
.forEach(([t, c]) => lines.push(` ${t}: ${c}`));
|
|
111
|
+
lines.push('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Commands by category
|
|
115
|
+
const cmdEntries = Object.entries(changes.commands).filter(([, cmds]) => cmds.length > 0);
|
|
116
|
+
if (cmdEntries.length > 0) {
|
|
117
|
+
lines.push('## Commands');
|
|
118
|
+
for (const [cat, cmds] of cmdEntries) {
|
|
119
|
+
lines.push(` [${cat}] ${cmds.slice(0, 5).join(' | ')}${cmds.length > 5 ? ` (+${cmds.length - 5})` : ''}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Review hints
|
|
125
|
+
const hints = classification.reviewHints;
|
|
126
|
+
lines.push('## Review Hints');
|
|
127
|
+
lines.push(` Tests run: ${hints.testsRun ? 'YES' : 'NO'}`);
|
|
128
|
+
lines.push(` Build verified: ${hints.buildVerified ? 'YES' : 'NO'}`);
|
|
129
|
+
if (hints.configChanged) lines.push(' WARNING: Config files changed');
|
|
130
|
+
if (hints.migrationAdded) lines.push(' WARNING: Database migration added');
|
|
131
|
+
if (hints.largeChange) lines.push(' WARNING: Large change (>10 files)');
|
|
132
|
+
if (hints.filesWithoutTests.length > 0) {
|
|
133
|
+
lines.push(` Files without tests: ${hints.filesWithoutTests.slice(0, 5).join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push('');
|
|
136
|
+
|
|
137
|
+
// Errors
|
|
138
|
+
if (state.errors.length > 0) {
|
|
139
|
+
lines.push('## Errors');
|
|
140
|
+
state.errors.slice(-5).forEach(e => lines.push(` [${e.tool}] ${(e.message || '').slice(0, 100)}`));
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Prompts
|
|
145
|
+
if (state.prompts.length > 0) {
|
|
146
|
+
lines.push('## Prompts');
|
|
147
|
+
state.prompts.forEach(p => {
|
|
148
|
+
const text = typeof p === 'string' ? p : p.text;
|
|
149
|
+
lines.push(` > ${text.slice(0, 120)}`);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
writeFileSync(join(sessionDir, 'summary.txt'), lines.join('\n') + '\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writePromptsTxt(sessionDir, state) {
|
|
157
|
+
if (state.prompts.length > 0) {
|
|
158
|
+
const promptLines = state.prompts
|
|
159
|
+
.map(p => {
|
|
160
|
+
const time = typeof p === 'string' ? '' : p.time;
|
|
161
|
+
const text = typeof p === 'string' ? p : p.text;
|
|
162
|
+
return time ? `=== ${time} ===\n${text}\n` : `${text}\n`;
|
|
163
|
+
})
|
|
164
|
+
.join('\n');
|
|
165
|
+
writeFileSync(join(sessionDir, 'prompts.txt'), promptLines);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function writeMetadataJson(sessionDir, sessionId, state, intent, classification, durationMin) {
|
|
170
|
+
writeFileSync(join(sessionDir, 'metadata.json'), JSON.stringify({
|
|
171
|
+
session_id: sessionId,
|
|
172
|
+
start_time: state.start_time,
|
|
173
|
+
last_updated: state.last_time,
|
|
174
|
+
duration_minutes: durationMin,
|
|
175
|
+
type: classification.type,
|
|
176
|
+
intent: intent.goal.slice(0, 200),
|
|
177
|
+
risk: classification.risk,
|
|
178
|
+
event_count: state.event_count,
|
|
179
|
+
tool_calls: Object.values(state.tool_counts).reduce((a, b) => a + b, 0),
|
|
180
|
+
files_touched: state.file_ops.write.length + state.file_ops.edit.length,
|
|
181
|
+
errors: state.errors.length,
|
|
182
|
+
scope: intent.scope,
|
|
183
|
+
last_hook_type: state.last_hook_type,
|
|
184
|
+
}, null, 2));
|
|
185
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session state management and cross-session index.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { toRelative } from './config.js';
|
|
10
|
+
|
|
11
|
+
// --- State management ---
|
|
12
|
+
|
|
13
|
+
const EMPTY_STATE = {
|
|
14
|
+
start_time: null,
|
|
15
|
+
last_time: null,
|
|
16
|
+
event_count: 0,
|
|
17
|
+
tool_counts: {},
|
|
18
|
+
file_ops: { read: [], write: [], edit: [], glob: [], grep: [] },
|
|
19
|
+
commands: [],
|
|
20
|
+
edits: [],
|
|
21
|
+
errors: [],
|
|
22
|
+
prompts: [],
|
|
23
|
+
last_hook_type: null,
|
|
24
|
+
shadow_branch: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function loadState(sessionDir) {
|
|
28
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
29
|
+
if (!existsSync(stateFile)) {
|
|
30
|
+
return {
|
|
31
|
+
...EMPTY_STATE,
|
|
32
|
+
file_ops: { read: [], write: [], edit: [], glob: [], grep: [] },
|
|
33
|
+
commands: [],
|
|
34
|
+
edits: [],
|
|
35
|
+
errors: [],
|
|
36
|
+
prompts: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveState(sessionDir, state) {
|
|
47
|
+
writeFileSync(join(sessionDir, 'state.json'), JSON.stringify(state, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Event processing ---
|
|
51
|
+
|
|
52
|
+
function addUnique(arr, val) {
|
|
53
|
+
if (val && !arr.includes(val)) arr.push(val);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function processEvent(state, event, hookType, projectRoot) {
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
state.event_count++;
|
|
59
|
+
state.last_hook_type = hookType;
|
|
60
|
+
state.last_time = now;
|
|
61
|
+
if (!state.start_time) state.start_time = now;
|
|
62
|
+
|
|
63
|
+
const toolName = event.tool_name;
|
|
64
|
+
const input = event.tool_input || {};
|
|
65
|
+
|
|
66
|
+
// User prompts
|
|
67
|
+
if (hookType === 'user-prompt-submit') {
|
|
68
|
+
const prompt = event.prompt || event.message || '';
|
|
69
|
+
if (prompt) state.prompts.push({ time: now, text: prompt });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tool events
|
|
74
|
+
if (hookType === 'post-tool-use' && toolName) {
|
|
75
|
+
state.tool_counts[toolName] = (state.tool_counts[toolName] || 0) + 1;
|
|
76
|
+
|
|
77
|
+
const rel = (p) => toRelative(projectRoot, p);
|
|
78
|
+
|
|
79
|
+
switch (toolName) {
|
|
80
|
+
case 'Read':
|
|
81
|
+
addUnique(state.file_ops.read, rel(input.file_path));
|
|
82
|
+
break;
|
|
83
|
+
case 'Write':
|
|
84
|
+
addUnique(state.file_ops.write, rel(input.file_path));
|
|
85
|
+
break;
|
|
86
|
+
case 'Edit':
|
|
87
|
+
addUnique(state.file_ops.edit, rel(input.file_path));
|
|
88
|
+
if (input.old_string || input.new_string) {
|
|
89
|
+
state.edits.push({
|
|
90
|
+
file: rel(input.file_path),
|
|
91
|
+
old: (input.old_string || '').slice(0, 200),
|
|
92
|
+
new: (input.new_string || '').slice(0, 200),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case 'Glob':
|
|
97
|
+
addUnique(state.file_ops.glob, input.pattern);
|
|
98
|
+
break;
|
|
99
|
+
case 'Grep':
|
|
100
|
+
addUnique(state.file_ops.grep, input.pattern);
|
|
101
|
+
break;
|
|
102
|
+
case 'Bash':
|
|
103
|
+
if (input.command) state.commands.push(input.command.slice(0, 500));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Track errors
|
|
108
|
+
const result = event.tool_result;
|
|
109
|
+
if (result && typeof result === 'object' && result.isError) {
|
|
110
|
+
state.errors.push({
|
|
111
|
+
tool: toolName,
|
|
112
|
+
time: now,
|
|
113
|
+
message: (typeof result.content === 'string'
|
|
114
|
+
? result.content : JSON.stringify(result.content)).slice(0, 300),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Cross-session index ---
|
|
121
|
+
|
|
122
|
+
export function updateIndex(logDir, sessionId, intent, classification, changes, state) {
|
|
123
|
+
const indexFile = join(logDir, 'index.json');
|
|
124
|
+
let index = { project: '', sessions: [], file_history: {} };
|
|
125
|
+
|
|
126
|
+
if (existsSync(indexFile)) {
|
|
127
|
+
try {
|
|
128
|
+
index = JSON.parse(readFileSync(indexFile, 'utf-8'));
|
|
129
|
+
} catch { /* start fresh */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Detect project name from git or directory
|
|
133
|
+
if (!index.project) {
|
|
134
|
+
const parts = logDir.split('/');
|
|
135
|
+
// logDir is typically <project>/.shit-logs
|
|
136
|
+
index.project = parts[parts.length - 2] || 'unknown';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const durationMs = state.start_time && state.last_time
|
|
140
|
+
? new Date(state.last_time) - new Date(state.start_time) : 0;
|
|
141
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
142
|
+
|
|
143
|
+
// Upsert session entry
|
|
144
|
+
const modifiedFiles = changes.files
|
|
145
|
+
.filter(f => f.operations.some(op => op !== 'read'))
|
|
146
|
+
.map(f => f.path);
|
|
147
|
+
|
|
148
|
+
const existingIdx = index.sessions.findIndex(s => s.id === sessionId);
|
|
149
|
+
const sessionEntry = {
|
|
150
|
+
id: sessionId,
|
|
151
|
+
date: (state.start_time || new Date().toISOString()).slice(0, 10),
|
|
152
|
+
type: classification.type,
|
|
153
|
+
intent: intent.goal.slice(0, 200),
|
|
154
|
+
files: modifiedFiles,
|
|
155
|
+
duration: durationMin,
|
|
156
|
+
risk: classification.risk,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (existingIdx >= 0) {
|
|
160
|
+
index.sessions[existingIdx] = sessionEntry;
|
|
161
|
+
} else {
|
|
162
|
+
index.sessions.push(sessionEntry);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Keep last 100 sessions
|
|
166
|
+
if (index.sessions.length > 100) {
|
|
167
|
+
index.sessions = index.sessions.slice(-100);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Update file history
|
|
171
|
+
if (!index.file_history) index.file_history = {};
|
|
172
|
+
for (const file of modifiedFiles) {
|
|
173
|
+
if (!index.file_history[file]) index.file_history[file] = [];
|
|
174
|
+
if (!index.file_history[file].includes(sessionId)) {
|
|
175
|
+
index.file_history[file].push(sessionId);
|
|
176
|
+
}
|
|
177
|
+
// Keep last 20 sessions per file
|
|
178
|
+
if (index.file_history[file].length > 20) {
|
|
179
|
+
index.file_history[file] = index.file_history[file].slice(-20);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeFileSync(indexFile, JSON.stringify(index, null, 2));
|
|
184
|
+
}
|
package/lib/shadow.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { getProjectRoot } from './config.js';
|
|
5
|
+
|
|
6
|
+
export { commitShadow, listShadowBranches, shadowInfo } from './git-shadow.js';
|
|
7
|
+
|
|
8
|
+
export default async function shadowCmd(args) {
|
|
9
|
+
const projectRoot = getProjectRoot();
|
|
10
|
+
const sub = args[0];
|
|
11
|
+
|
|
12
|
+
if (sub === 'info' && args[1]) {
|
|
13
|
+
const { shadowInfo } = await import('./git-shadow.js');
|
|
14
|
+
const info = shadowInfo(projectRoot, args[1]);
|
|
15
|
+
if (!info) {
|
|
16
|
+
console.error(`Branch not found: ${args[1]}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
console.log('Recent commits:');
|
|
20
|
+
console.log(info.log);
|
|
21
|
+
console.log('\nFiles:');
|
|
22
|
+
info.files.forEach(f => console.log(` ${f}`));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Default: list shadow branches
|
|
27
|
+
const { listShadowBranches } = await import('./git-shadow.js');
|
|
28
|
+
const branches = listShadowBranches(projectRoot);
|
|
29
|
+
|
|
30
|
+
if (branches.length === 0) {
|
|
31
|
+
console.log('No shadow branches found.');
|
|
32
|
+
console.log('Shadow branches are created on session-end.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`${branches.length} shadow branch(es):\n`);
|
|
37
|
+
branches.forEach(b => {
|
|
38
|
+
try {
|
|
39
|
+
const log = execSync(`git log ${b} --oneline -1`, {
|
|
40
|
+
cwd: projectRoot, encoding: 'utf-8',
|
|
41
|
+
}).trim();
|
|
42
|
+
console.log(` ${b} ${log}`);
|
|
43
|
+
} catch {
|
|
44
|
+
console.log(` ${b}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
console.log('\nUsage:');
|
|
49
|
+
console.log(' shit shadow info <branch> # Show branch details');
|
|
50
|
+
console.log(' git log <branch> --oneline # View commits');
|
|
51
|
+
}
|
package/lib/view.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getProjectRoot, getLogDir } from './config.js';
|
|
6
|
+
|
|
7
|
+
export default async function view(args) {
|
|
8
|
+
const sessionId = args[0];
|
|
9
|
+
if (!sessionId) {
|
|
10
|
+
console.error('Usage: shit view <session-id>');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const logDir = getLogDir(getProjectRoot());
|
|
15
|
+
const sessionDir = join(logDir, sessionId);
|
|
16
|
+
if (!existsSync(sessionDir)) {
|
|
17
|
+
console.error(`Session not found: ${sessionId}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Show summary.txt (contains semantic report)
|
|
22
|
+
const summaryFile = join(sessionDir, 'summary.txt');
|
|
23
|
+
if (existsSync(summaryFile)) {
|
|
24
|
+
console.log(readFileSync(summaryFile, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Show summary.json v2 data if --json flag
|
|
28
|
+
if (args.includes('--json')) {
|
|
29
|
+
const jsonFile = join(sessionDir, 'summary.json');
|
|
30
|
+
if (existsSync(jsonFile)) {
|
|
31
|
+
console.log('\n--- summary.json ---');
|
|
32
|
+
console.log(readFileSync(jsonFile, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Show shadow branch info
|
|
37
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
38
|
+
if (existsSync(stateFile)) {
|
|
39
|
+
try {
|
|
40
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
41
|
+
if (state.shadow_branch) {
|
|
42
|
+
console.log(`\nShadow branch: ${state.shadow_branch}`);
|
|
43
|
+
console.log(` git log ${state.shadow_branch} --oneline`);
|
|
44
|
+
console.log(` git show ${state.shadow_branch}:.shit-logs/${sessionId}/summary.json`);
|
|
45
|
+
}
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(`\nFull logs: ${sessionDir}`);
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@claudemini/shit-cli",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Session-based Hook Intelligence Tracker - CLI tool for logging Claude Code hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shit": "./bin/shit.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude-code",
|
|
14
|
+
"hooks",
|
|
15
|
+
"logging",
|
|
16
|
+
"session-tracking"
|
|
17
|
+
],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {},
|
|
21
|
+
"devDependencies": {}
|
|
22
|
+
}
|