@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/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
+ }