@claudemini/ses-cli 1.4.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/README.md +465 -0
- package/bin/ses.js +85 -0
- package/lib/agent-review.js +722 -0
- package/lib/checkpoint.js +320 -0
- package/lib/checkpoints.js +54 -0
- package/lib/clean.js +45 -0
- package/lib/commit.js +60 -0
- package/lib/config.js +28 -0
- package/lib/disable.js +152 -0
- package/lib/doctor.js +307 -0
- package/lib/enable.js +294 -0
- package/lib/explain.js +212 -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 +77 -0
- package/lib/prompts.js +125 -0
- package/lib/query.js +110 -0
- package/lib/redact.js +170 -0
- package/lib/report.js +296 -0
- package/lib/reset.js +122 -0
- package/lib/resume.js +224 -0
- package/lib/review-common.js +100 -0
- package/lib/review.js +652 -0
- package/lib/rewind.js +198 -0
- package/lib/session.js +225 -0
- package/lib/shadow.js +51 -0
- package/lib/status.js +198 -0
- package/lib/summarize.js +315 -0
- package/lib/view.js +50 -0
- package/lib/webhook.js +224 -0
- package/package.json +41 -0
package/lib/explain.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Explain a session or checkpoint
|
|
5
|
+
* Provides human-readable explanation of what happened in a session
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import { getProjectRoot, SESSION_ID_REGEX } from './config.js';
|
|
12
|
+
|
|
13
|
+
function git(args, cwd, ignoreError = false) {
|
|
14
|
+
try {
|
|
15
|
+
return execFileSync('git', args, {
|
|
16
|
+
cwd,
|
|
17
|
+
encoding: 'utf-8',
|
|
18
|
+
timeout: 10000,
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (ignoreError) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getSessionData(sessionId, projectRoot) {
|
|
30
|
+
const sessionDir = join(projectRoot, '.ses-logs', sessionId);
|
|
31
|
+
|
|
32
|
+
if (!existsSync(sessionDir)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const files = {};
|
|
37
|
+
const fileNames = ['summary.json', 'summary.txt', 'context.md', 'metadata.json'];
|
|
38
|
+
|
|
39
|
+
for (const file of fileNames) {
|
|
40
|
+
const filePath = join(sessionDir, file);
|
|
41
|
+
if (existsSync(filePath)) {
|
|
42
|
+
files[file] = readFileSync(filePath, 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { dir: sessionDir, files };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function explainFromSummary(summaryText) {
|
|
50
|
+
const explanation = [];
|
|
51
|
+
|
|
52
|
+
// Extract key information
|
|
53
|
+
const typeMatch = summaryText.match(/Type:\s*(\w+)/i);
|
|
54
|
+
const riskMatch = summaryText.match(/Risk:\s*(\w+)/i);
|
|
55
|
+
const intentMatch = summaryText.match(/Goal:\s*(.+)/i);
|
|
56
|
+
const durationMatch = summaryText.match(/Duration:\s*(\d+)min/i);
|
|
57
|
+
|
|
58
|
+
if (typeMatch) {
|
|
59
|
+
explanation.push(`š Session type: **${typeMatch[1]}**`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (riskMatch) {
|
|
63
|
+
const risk = riskMatch[1].toLowerCase();
|
|
64
|
+
const emoji = risk === 'high' ? 'š“' : risk === 'medium' ? 'š”' : 'š¢';
|
|
65
|
+
explanation.push(`${emoji} Risk level: **${risk}**`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (intentMatch) {
|
|
69
|
+
explanation.push(`šÆ Intent: ${intentMatch[1].slice(0, 100)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (durationMatch) {
|
|
73
|
+
explanation.push(`ā±ļø Duration: ${durationMatch[1]} minutes`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Extract files changed
|
|
77
|
+
const changesMatch = summaryText.match(/## Changes\n([\s\S]+?)##/);
|
|
78
|
+
if (changesMatch) {
|
|
79
|
+
const fileLines = changesMatch[1].split('\n').filter(l => l.trim().startsWith('['));
|
|
80
|
+
if (fileLines.length > 0) {
|
|
81
|
+
explanation.push(`\nš Files changed (${fileLines.length}):`);
|
|
82
|
+
fileLines.slice(0, 5).forEach(line => {
|
|
83
|
+
explanation.push(` ${line.trim()}`);
|
|
84
|
+
});
|
|
85
|
+
if (fileLines.length > 5) {
|
|
86
|
+
explanation.push(` ... and ${fileLines.length - 5} more`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Extract tools used
|
|
92
|
+
const toolsMatch = summaryText.match(/## Tools\n([\s\S]+?)##/);
|
|
93
|
+
if (toolsMatch) {
|
|
94
|
+
const toolLines = toolsMatch[1].split('\n').filter(l => l.includes(':'));
|
|
95
|
+
if (toolLines.length > 0) {
|
|
96
|
+
explanation.push(`\nš§ Tools used:`);
|
|
97
|
+
toolLines.slice(0, 5).forEach(line => {
|
|
98
|
+
explanation.push(` ${line.trim()}`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Extract errors
|
|
104
|
+
if (summaryText.includes('## Errors')) {
|
|
105
|
+
const errorsMatch = summaryText.match(/## Errors\n([\s\S]+)/);
|
|
106
|
+
if (errorsMatch && errorsMatch[1].trim()) {
|
|
107
|
+
explanation.push(`\nā ļø Errors encountered:`);
|
|
108
|
+
const errorLines = errorsMatch[1].split('\n').filter(l => l.trim());
|
|
109
|
+
errorLines.slice(0, 3).forEach(line => {
|
|
110
|
+
explanation.push(` ${line.trim()}`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return explanation.join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function explainFromCommit(commitSha, projectRoot) {
|
|
119
|
+
try {
|
|
120
|
+
// Try to find associated checkpoint
|
|
121
|
+
const checkpoints = (git(['branch', '--list', 'ses/checkpoints/v1/*'], projectRoot, true) || '')
|
|
122
|
+
.split('\n')
|
|
123
|
+
.map(line => line.trim().replace(/^\*?\s*/, ''))
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
|
|
126
|
+
for (const branch of checkpoints) {
|
|
127
|
+
try {
|
|
128
|
+
const log = git(['log', branch, '--oneline', '-1'], projectRoot, true);
|
|
129
|
+
if (!log) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (log.includes(commitSha.slice(0, 12))) {
|
|
134
|
+
const message = git(['log', branch, '--format=%B', '-1'], projectRoot, true) || '';
|
|
135
|
+
|
|
136
|
+
return `šø This commit has an associated checkpoint:\n\nBranch: ${branch}\n\n${message}`;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Skip this branch
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return `No checkpoint found for commit ${commitSha}`;
|
|
144
|
+
} catch {
|
|
145
|
+
return 'No checkpoint information available';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default async function explain(args) {
|
|
150
|
+
try {
|
|
151
|
+
const projectRoot = getProjectRoot();
|
|
152
|
+
const target = args[0];
|
|
153
|
+
|
|
154
|
+
if (!target) {
|
|
155
|
+
console.log('Usage: ses explain <session-id | commit-sha>');
|
|
156
|
+
console.log('\nExamples:');
|
|
157
|
+
console.log(' ses explain b5613b31-c732-4546-9be5-f8ae36f2327f # Explain a session');
|
|
158
|
+
console.log(' ses explain abc1234 # Explain a commit');
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`š Explaining: ${target}\n`);
|
|
163
|
+
|
|
164
|
+
// Try to find session first
|
|
165
|
+
const sessionData = getSessionData(target, projectRoot);
|
|
166
|
+
|
|
167
|
+
if (sessionData && sessionData.files['summary.txt']) {
|
|
168
|
+
console.log('š Session Explanation\n');
|
|
169
|
+
console.log(explainFromSummary(sessionData.files['summary.txt']));
|
|
170
|
+
console.log('\n---\nš” Use "ses view ' + target + '" for full details');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Try as commit
|
|
175
|
+
const isCommitLike = /^[a-f0-9]{4,40}$/i.test(target);
|
|
176
|
+
if (isCommitLike) {
|
|
177
|
+
const resolvedCommit = git(['rev-parse', '--verify', `${target}^{commit}`], projectRoot, true);
|
|
178
|
+
if (resolvedCommit) {
|
|
179
|
+
console.log('šø Commit Explanation\n');
|
|
180
|
+
console.log(explainFromCommit(resolvedCommit, projectRoot));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Not a commit
|
|
186
|
+
|
|
187
|
+
// Try partial match
|
|
188
|
+
const logDir = join(projectRoot, '.ses-logs');
|
|
189
|
+
const sessions = existsSync(logDir)
|
|
190
|
+
? readdirSync(logDir, { withFileTypes: true })
|
|
191
|
+
.filter(entry => entry.isDirectory() && SESSION_ID_REGEX.test(entry.name))
|
|
192
|
+
.map(entry => entry.name)
|
|
193
|
+
.filter(name => name.includes(target))
|
|
194
|
+
: [];
|
|
195
|
+
|
|
196
|
+
if (sessions.length > 0) {
|
|
197
|
+
const matchedSession = sessions[0];
|
|
198
|
+
const data = getSessionData(matchedSession, projectRoot);
|
|
199
|
+
if (data && data.files['summary.txt']) {
|
|
200
|
+
console.log('š Found session: ' + matchedSession + '\n');
|
|
201
|
+
console.log(explainFromSummary(data.files['summary.txt']));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log(`ā Could not find session or commit: ${target}`);
|
|
207
|
+
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('ā Failed to explain:', error.message);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
}
|
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: ses/<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: ses/<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 = `ses/${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 .ses-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.ses-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 "ses/*"', 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 SES_COMMAND = (hookType) => `ses 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 ses-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 = SES_COMMAND(hookType);
|
|
39
|
+
const existing = settings.hooks[hookType] || [];
|
|
40
|
+
|
|
41
|
+
const alreadyExists = existing.some(entry =>
|
|
42
|
+
entry.hooks?.some(h => h.command?.includes('ses 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 .ses-logs and update .gitignore
|
|
68
|
+
mkdirSync(join(cwd, '.ses-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('.ses-logs')) {
|
|
74
|
+
appendFileSync(gitignoreFile, '\n# ses-cli logs\n.ses-logs/\n');
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
writeFileSync(gitignoreFile, '# ses-cli logs\n.ses-logs/\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log('\nLogs: .ses-logs/ (project-local)');
|
|
81
|
+
console.log('Shadow branches: ses/<commit>-<session> (git)');
|
|
82
|
+
console.log('\nDone. Start Claude Code and hooks will log automatically.');
|
|
83
|
+
}
|