@claudemini/shit-cli 1.5.0 → 1.7.0

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 CHANGED
@@ -51,10 +51,23 @@ shit status # Show current session + git info
51
51
  shit list # List all sessions with type, risk, intent
52
52
  shit view <session-id> # View semantic session report
53
53
  shit view <session-id> --json # Include raw JSON data
54
+ shit review [session-id] # Run structured code review from session data
55
+ shit review --json # Machine-readable findings (structured schema)
56
+ shit review --recent=3 --md # Aggregated Markdown report for PR comments
57
+ shit review --strict --fail-on=medium # CI gate by severity threshold
54
58
  shit explain <session-id> # Human-friendly explanation of a session
55
59
  shit explain <commit-sha> # Explain a commit via its checkpoint
56
60
  ```
57
61
 
62
+ `shit review` options:
63
+ - `--recent=<n>` review latest `n` sessions (default `1`)
64
+ - `--all` review all sessions in `.shit-logs`
65
+ - `--min-severity=<info|low|medium|high|critical>` filter findings
66
+ - `--fail-on=<info|low|medium|high|critical>` strict-mode failure threshold (default `high`)
67
+ - `--strict` exit code `1` when findings reach `--fail-on`
68
+ - `--json` output structured JSON
69
+ - `--markdown` / `--md` output Markdown
70
+
58
71
  ### Cross-Session Queries
59
72
 
60
73
  ```bash
@@ -99,6 +112,7 @@ shit summarize <session-id> # Generate AI summary (requires API key)
99
112
  | `log <type>` | Log a hook event from stdin (called by hooks) |
100
113
  | `list` | List all sessions with type, intent, risk |
101
114
  | `view <id>` | View semantic session report |
115
+ | `review [id]` | Run structured code review (single or multi-session) |
102
116
  | `query` | Query session memory across sessions |
103
117
  | `explain <id>` | Human-friendly explanation of a session or commit |
104
118
  | `commit` | Create checkpoint on git commit |
@@ -240,6 +254,7 @@ Set one of these environment variables to enable AI-powered session summaries:
240
254
 
241
255
  ```bash
242
256
  export OPENAI_API_KEY=sk-... # Uses gpt-4o-mini by default
257
+ export OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: OpenAI-compatible base URL
243
258
  export ANTHROPIC_API_KEY=sk-... # Uses claude-3-haiku by default
244
259
  ```
245
260
 
@@ -254,6 +269,8 @@ shit summarize <session-id>
254
269
  |----------|-------------|
255
270
  | `SHIT_LOG_DIR` | Custom log directory (default: `.shit-logs` in project root) |
256
271
  | `OPENAI_API_KEY` | Enable AI summaries via OpenAI |
272
+ | `OPENAI_BASE_URL` | OpenAI-compatible base URL for summaries (default: `https://api.openai.com/v1`) |
273
+ | `OPENAI_ENDPOINT` | Full OpenAI-compatible endpoint (overrides `OPENAI_BASE_URL`) |
257
274
  | `ANTHROPIC_API_KEY` | Enable AI summaries via Anthropic |
258
275
 
259
276
  ## Security
package/bin/shit.js CHANGED
@@ -13,6 +13,7 @@ const commands = {
13
13
  list: 'List all sessions',
14
14
  checkpoints: 'List all checkpoints',
15
15
  view: 'View session details',
16
+ review: 'Run structured code review for a session',
16
17
  query: 'Query session memory (cross-session)',
17
18
  explain: 'Explain a session or commit',
18
19
  summarize: 'Generate AI summary for a session',
@@ -37,6 +38,8 @@ function showHelp() {
37
38
  console.log(' shit status # Show current session');
38
39
  console.log(' shit list # List sessions');
39
40
  console.log(' shit view <session-id> # View session');
41
+ console.log(' shit review [session-id] # Structured code review');
42
+ console.log(' shit review --recent=3 --md # Markdown review for latest 3 sessions');
40
43
  console.log(' shit rewind <checkpoint> # Rollback to checkpoint');
41
44
  console.log(' shit resume <checkpoint> # Resume from checkpoint');
42
45
  console.log(' shit doctor --fix # Fix stuck sessions');
@@ -61,13 +64,21 @@ if (command === '--version' || command === '-v') {
61
64
  process.exit(0);
62
65
  }
63
66
 
67
+ if (!Object.prototype.hasOwnProperty.call(commands, command)) {
68
+ console.error(`Unknown command: ${command}`);
69
+ process.exit(1);
70
+ }
71
+
64
72
  try {
65
73
  const mod = await import(`../lib/${command}.js`);
66
- await mod.default(args.slice(1));
67
- } catch (error) {
68
- if (error.code === 'ERR_MODULE_NOT_FOUND') {
69
- console.error(`Unknown command: ${command}`);
70
- process.exit(1);
74
+ if (typeof mod.default !== 'function') {
75
+ throw new Error(`Command module "${command}" has no default function export`);
71
76
  }
72
- throw error;
77
+ const exitCode = await mod.default(args.slice(1));
78
+ if (Number.isInteger(exitCode) && exitCode !== 0) {
79
+ process.exitCode = exitCode;
80
+ }
81
+ } catch (error) {
82
+ console.error(`Failed to run command "${command}": ${error.message}`);
83
+ process.exit(1);
73
84
  }
package/lib/review.js ADDED
@@ -0,0 +1,728 @@
1
+ /**
2
+ * Structured code review over session artifacts.
3
+ * Inspired by mco's findings model:
4
+ * - fixed findings schema
5
+ * - evidence-grounded findings
6
+ * - deterministic severity/confidence
7
+ * - dedup + aggregation for CI/PR workflows
8
+ */
9
+
10
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { createHash } from 'crypto';
13
+ import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
14
+ import { redactSecrets } from './redact.js';
15
+
16
+ const SEVERITY_SCORE = {
17
+ info: 1,
18
+ low: 2,
19
+ medium: 3,
20
+ high: 4,
21
+ critical: 5,
22
+ };
23
+
24
+ const CONFIDENCE_SCORE = {
25
+ low: 1,
26
+ medium: 2,
27
+ high: 3,
28
+ };
29
+
30
+ function parseArgs(args) {
31
+ const options = {
32
+ format: 'text', // text | json | markdown
33
+ strict: false,
34
+ minSeverity: 'info',
35
+ failOn: 'high',
36
+ sessionId: null,
37
+ recent: 1,
38
+ all: false,
39
+ help: false,
40
+ };
41
+
42
+ for (const arg of args) {
43
+ if (arg === '--json') {
44
+ options.format = 'json';
45
+ continue;
46
+ }
47
+ if (arg === '--markdown' || arg === '--md') {
48
+ options.format = 'markdown';
49
+ continue;
50
+ }
51
+ if (arg === '--strict') {
52
+ options.strict = true;
53
+ continue;
54
+ }
55
+ if (arg === '--all') {
56
+ options.all = true;
57
+ continue;
58
+ }
59
+ if (arg === '--help' || arg === '-h') {
60
+ options.help = true;
61
+ continue;
62
+ }
63
+ if (arg.startsWith('--recent=')) {
64
+ const value = Number.parseInt(arg.split('=')[1], 10);
65
+ if (Number.isFinite(value) && value > 0) {
66
+ options.recent = value;
67
+ }
68
+ continue;
69
+ }
70
+ if (arg.startsWith('--min-severity=')) {
71
+ const value = (arg.split('=')[1] || '').toLowerCase();
72
+ if (SEVERITY_SCORE[value]) {
73
+ options.minSeverity = value;
74
+ }
75
+ continue;
76
+ }
77
+ if (arg.startsWith('--fail-on=')) {
78
+ const value = (arg.split('=')[1] || '').toLowerCase();
79
+ if (SEVERITY_SCORE[value]) {
80
+ options.failOn = value;
81
+ }
82
+ continue;
83
+ }
84
+ if (!arg.startsWith('-') && !options.sessionId) {
85
+ options.sessionId = arg;
86
+ }
87
+ }
88
+
89
+ return options;
90
+ }
91
+
92
+ function printUsage() {
93
+ console.log('Usage: shit review [session-id] [options]');
94
+ console.log('');
95
+ console.log('Options:');
96
+ console.log(' --json Output JSON report');
97
+ console.log(' --markdown, --md Output Markdown report');
98
+ console.log(' --recent=<n> Review latest n sessions (default: 1)');
99
+ console.log(' --all Review all sessions');
100
+ console.log(' --min-severity=<level> Filter findings below severity');
101
+ console.log(' --fail-on=<level> Strict mode failure threshold (default: high)');
102
+ console.log(' --strict Exit non-zero when findings hit fail-on threshold');
103
+ }
104
+
105
+ function getSessionDirs(logDir) {
106
+ if (!existsSync(logDir)) {
107
+ return [];
108
+ }
109
+
110
+ const sessions = [];
111
+ for (const name of readdirSync(logDir)) {
112
+ if (!SESSION_ID_REGEX.test(name)) {
113
+ continue;
114
+ }
115
+
116
+ const fullPath = join(logDir, name);
117
+ let stat;
118
+ try {
119
+ stat = statSync(fullPath);
120
+ } catch {
121
+ continue;
122
+ }
123
+ if (!stat.isDirectory()) {
124
+ continue;
125
+ }
126
+
127
+ sessions.push({
128
+ id: name,
129
+ dir: fullPath,
130
+ mtime: stat.mtime.getTime(),
131
+ });
132
+ }
133
+
134
+ return sessions.sort((a, b) => b.mtime - a.mtime);
135
+ }
136
+
137
+ function loadJsonIfExists(filePath, fallback = null) {
138
+ if (!existsSync(filePath)) {
139
+ return fallback;
140
+ }
141
+ try {
142
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
143
+ } catch {
144
+ return fallback;
145
+ }
146
+ }
147
+
148
+ function normalizeEvidence(evidence) {
149
+ return evidence
150
+ .filter(Boolean)
151
+ .map(item => String(item).trim())
152
+ .filter(Boolean);
153
+ }
154
+
155
+ function normalizeFinding(input) {
156
+ return {
157
+ id: input.id || '',
158
+ rule_id: input.rule_id || 'review.generic',
159
+ category: input.category || 'maintainability',
160
+ severity: input.severity || 'low',
161
+ confidence: input.confidence || 'medium',
162
+ summary: input.summary || '',
163
+ details: input.details || '',
164
+ suggestion: input.suggestion || '',
165
+ location: input.location || null,
166
+ evidence: normalizeEvidence(input.evidence || []),
167
+ sessions: Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [],
168
+ };
169
+ }
170
+
171
+ function normalizeLocation(location) {
172
+ if (!location || typeof location !== 'object') {
173
+ return '';
174
+ }
175
+ const file = location.file || '';
176
+ const line = location.line || '';
177
+ const column = location.column || '';
178
+ return `${file}:${line}:${column}`;
179
+ }
180
+
181
+ function canonicalFindingKey(finding) {
182
+ return [
183
+ String(finding.rule_id || '').trim().toLowerCase(),
184
+ String(finding.category || '').trim().toLowerCase(),
185
+ normalizeLocation(finding.location),
186
+ String(finding.summary || '').trim().toLowerCase(),
187
+ ].join('|');
188
+ }
189
+
190
+ function buildFindingId(finding) {
191
+ const canonical = canonicalFindingKey(finding);
192
+ const digest = createHash('sha256').update(canonical).digest('hex');
193
+ return `f_${digest.slice(0, 16)}`;
194
+ }
195
+
196
+ function dedupeFindings(findings) {
197
+ const map = new Map();
198
+
199
+ for (const candidate of findings) {
200
+ const finding = normalizeFinding(candidate);
201
+ const key = canonicalFindingKey(finding);
202
+ finding.id = buildFindingId(finding);
203
+
204
+ if (!map.has(key)) {
205
+ map.set(key, finding);
206
+ continue;
207
+ }
208
+
209
+ const prev = map.get(key);
210
+ const merged = {
211
+ ...prev,
212
+ severity: SEVERITY_SCORE[finding.severity] > SEVERITY_SCORE[prev.severity] ? finding.severity : prev.severity,
213
+ confidence: (CONFIDENCE_SCORE[finding.confidence] || 0) > (CONFIDENCE_SCORE[prev.confidence] || 0)
214
+ ? finding.confidence
215
+ : prev.confidence,
216
+ details: prev.details.length >= finding.details.length ? prev.details : finding.details,
217
+ suggestion: prev.suggestion || finding.suggestion,
218
+ evidence: [...new Set([...prev.evidence, ...finding.evidence])],
219
+ sessions: [...new Set([...(prev.sessions || []), ...(finding.sessions || [])])],
220
+ };
221
+ merged.id = prev.id;
222
+ map.set(key, merged);
223
+ }
224
+
225
+ return [...map.values()].sort((a, b) => SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity]);
226
+ }
227
+
228
+ function getAllCommands(summary) {
229
+ const commandGroups = summary?.activity?.commands || {};
230
+ return Object.values(commandGroups).flatMap(list => Array.isArray(list) ? list : []);
231
+ }
232
+
233
+ const WINDOWS_ABS_PATH_REGEX = /(^|[\s([{:="'`])([A-Za-z]:\\(?:[^\\\s"'`|<>]+\\){1,}[^\\\s"'`|<>]+)/g;
234
+ const UNIX_SENSITIVE_ROOT_PATH_REGEX = /(^|[\s([{:="'`])(\/(?:Users|home|var|tmp|opt|etc|private|Volumes|workspace|workspaces|usr)(?:\/[^\s"'`|<>]+){2,})/g;
235
+ const UNIX_FILE_PATH_WITH_EXT_REGEX = /(^|[\s([{:="'`])(\/(?:[^\/\s"'`|<>]+\/){2,}[^\/\s"'`|<>]*\.[A-Za-z0-9]{1,10})/g;
236
+
237
+ function sanitizeErrorDetails(message) {
238
+ const text = String(message || 'Tool returned an error');
239
+ const redacted = redactSecrets(text)
240
+ .replace(WINDOWS_ABS_PATH_REGEX, '$1[PATH]')
241
+ .replace(UNIX_SENSITIVE_ROOT_PATH_REGEX, '$1[PATH]')
242
+ .replace(UNIX_FILE_PATH_WITH_EXT_REGEX, '$1[PATH]')
243
+ .replace(/\b[A-Z_]{2,}=[^\s"'`|<>]+/g, '[ENV_REDACTED]');
244
+ return redacted.slice(0, 200);
245
+ }
246
+
247
+ function generateFindings(summary, state, sessionId) {
248
+ const findings = [];
249
+ const reviewHints = summary?.review_hints || {};
250
+ const changes = summary?.changes?.files || [];
251
+ const activity = summary?.activity || {};
252
+ const errors = Array.isArray(activity.errors) ? activity.errors : [];
253
+ const commands = getAllCommands(summary);
254
+ const modifiedSourceFiles = changes.filter(file =>
255
+ file.category === 'source' &&
256
+ Array.isArray(file.operations) &&
257
+ file.operations.some(op => op !== 'read')
258
+ );
259
+
260
+ if (modifiedSourceFiles.length > 0 && !reviewHints.tests_run) {
261
+ findings.push({
262
+ rule_id: 'testing.no_tests_after_source_changes',
263
+ category: 'testing',
264
+ severity: modifiedSourceFiles.length >= 5 ? 'high' : 'medium',
265
+ confidence: 'high',
266
+ summary: 'Source code changed without test execution evidence',
267
+ details: `Detected ${modifiedSourceFiles.length} modified source file(s), but no test command was recorded.`,
268
+ suggestion: 'Run targeted tests for changed modules and attach the command/output in the session.',
269
+ evidence: [
270
+ `session_id=${sessionId}`,
271
+ `review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
272
+ `modified_source_files=${modifiedSourceFiles.length}`,
273
+ ],
274
+ sessions: [sessionId],
275
+ });
276
+ }
277
+
278
+ if (reviewHints.migration_added && !reviewHints.tests_run) {
279
+ findings.push({
280
+ rule_id: 'correctness.migration_without_tests',
281
+ category: 'correctness',
282
+ severity: 'high',
283
+ confidence: 'high',
284
+ summary: 'Database migration changed without test validation',
285
+ details: 'Migration-related changes are present and no test run was captured.',
286
+ suggestion: 'Run migration verification and regression tests before merge.',
287
+ evidence: [
288
+ `session_id=${sessionId}`,
289
+ `review_hints.migration_added=${Boolean(reviewHints.migration_added)}`,
290
+ `review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
291
+ ],
292
+ sessions: [sessionId],
293
+ });
294
+ }
295
+
296
+ if (reviewHints.config_changed && !reviewHints.build_verified) {
297
+ findings.push({
298
+ rule_id: 'reliability.config_without_build',
299
+ category: 'reliability',
300
+ severity: 'medium',
301
+ confidence: 'high',
302
+ summary: 'Configuration changed without build verification',
303
+ details: 'Config-level edits were detected, but no build/compile command was recorded.',
304
+ suggestion: 'Run a full build/compile and include output to reduce deployment risk.',
305
+ evidence: [
306
+ `session_id=${sessionId}`,
307
+ `review_hints.config_changed=${Boolean(reviewHints.config_changed)}`,
308
+ `review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
309
+ ],
310
+ sessions: [sessionId],
311
+ });
312
+ }
313
+
314
+ if (reviewHints.large_change && !reviewHints.tests_run && !reviewHints.build_verified) {
315
+ findings.push({
316
+ rule_id: 'maintainability.large_change_without_validation',
317
+ category: 'maintainability',
318
+ severity: 'high',
319
+ confidence: 'medium',
320
+ summary: 'Large change set lacks both test and build signals',
321
+ details: 'A large multi-file change was made without recorded test/build validation.',
322
+ suggestion: 'Split change into smaller PRs or provide CI/local validation evidence.',
323
+ evidence: [
324
+ `session_id=${sessionId}`,
325
+ `review_hints.large_change=${Boolean(reviewHints.large_change)}`,
326
+ `review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
327
+ `review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
328
+ ],
329
+ sessions: [sessionId],
330
+ });
331
+ }
332
+
333
+ if (Array.isArray(reviewHints.files_without_tests) && reviewHints.files_without_tests.length > 0) {
334
+ const sample = reviewHints.files_without_tests.slice(0, 3);
335
+ findings.push({
336
+ rule_id: 'testing.files_without_tests',
337
+ category: 'testing',
338
+ severity: reviewHints.files_without_tests.length > 5 ? 'high' : 'medium',
339
+ confidence: 'medium',
340
+ summary: 'Modified source files have no corresponding test changes',
341
+ details: `Detected ${reviewHints.files_without_tests.length} file(s) without related test updates.`,
342
+ suggestion: 'Add/adjust tests for changed source files or document why tests are not needed.',
343
+ location: { file: sample[0] },
344
+ evidence: [
345
+ `session_id=${sessionId}`,
346
+ `review_hints.files_without_tests_count=${reviewHints.files_without_tests.length}`,
347
+ ...sample.map(file => `file_without_test=${file}`),
348
+ ],
349
+ sessions: [sessionId],
350
+ });
351
+ }
352
+
353
+ for (const error of errors.slice(-3)) {
354
+ findings.push({
355
+ rule_id: 'reliability.tool_error',
356
+ category: 'reliability',
357
+ severity: 'medium',
358
+ confidence: 'high',
359
+ summary: `Tool error detected: ${error.tool || 'unknown tool'}`,
360
+ details: sanitizeErrorDetails(error.message),
361
+ suggestion: 'Confirm the error is resolved and include successful rerun evidence.',
362
+ evidence: [
363
+ `session_id=${sessionId}`,
364
+ 'activity.errors_present=true',
365
+ `error_tool=${error.tool || 'unknown'}`,
366
+ ],
367
+ sessions: [sessionId],
368
+ });
369
+ }
370
+
371
+ const rollbackCommands = commands.filter(cmd =>
372
+ /\bgit\s+(reset|restore|revert)\b/i.test(cmd) ||
373
+ /\bgit\s+checkout\s+--\b/i.test(cmd) ||
374
+ /\bundo\b/i.test(cmd)
375
+ );
376
+ if (rollbackCommands.length >= 2) {
377
+ findings.push({
378
+ rule_id: 'maintainability.frequent_rollback_commands',
379
+ category: 'maintainability',
380
+ severity: rollbackCommands.length >= 4 ? 'high' : 'medium',
381
+ confidence: 'medium',
382
+ summary: 'Frequent rollback/undo commands detected in one session',
383
+ details: `Detected ${rollbackCommands.length} rollback-style command(s), which may indicate unstable iteration.`,
384
+ suggestion: 'Split the change and validate each step to reduce back-and-forth edits.',
385
+ evidence: [
386
+ `session_id=${sessionId}`,
387
+ `rollback_command_count=${rollbackCommands.length}`,
388
+ ...rollbackCommands.slice(0, 3).map(cmd => `rollback_cmd=${cmd.slice(0, 80)}`),
389
+ ],
390
+ sessions: [sessionId],
391
+ });
392
+ }
393
+
394
+ if ((summary?.session?.risk || '') === 'high') {
395
+ findings.push({
396
+ rule_id: 'maintainability.high_risk_session',
397
+ category: 'maintainability',
398
+ severity: 'medium',
399
+ confidence: 'medium',
400
+ summary: 'Session-level risk was classified as high',
401
+ details: 'The extraction engine labeled this session as high risk based on change shape.',
402
+ suggestion: 'Require manual reviewer sign-off and verify rollback/checkpoint strategy.',
403
+ evidence: [
404
+ `session_id=${sessionId}`,
405
+ `summary.session.risk=${summary.session.risk}`,
406
+ ],
407
+ sessions: [sessionId],
408
+ });
409
+ }
410
+
411
+ if (state?.checkpoints && state.checkpoints.length > 0) {
412
+ findings.push({
413
+ rule_id: 'maintainability.checkpoints_present',
414
+ category: 'maintainability',
415
+ severity: 'info',
416
+ confidence: 'high',
417
+ summary: 'Checkpoint chain exists for this session',
418
+ details: `Detected ${state.checkpoints.length} checkpoint commit(s), enabling safer rollback.`,
419
+ suggestion: 'Use "shit rewind <checkpoint>" if post-merge regression appears.',
420
+ evidence: [
421
+ `session_id=${sessionId}`,
422
+ `state.checkpoints_count=${state.checkpoints.length}`,
423
+ ],
424
+ sessions: [sessionId],
425
+ });
426
+ }
427
+
428
+ return findings;
429
+ }
430
+
431
+ function generateSummaryMissingFinding(sessionId) {
432
+ return {
433
+ rule_id: 'integrity.missing_summary',
434
+ category: 'correctness',
435
+ severity: 'high',
436
+ confidence: 'high',
437
+ summary: 'Session summary artifact is missing or invalid',
438
+ details: `summary.json was missing/corrupted for session ${sessionId}, review confidence is degraded.`,
439
+ suggestion: 'Re-run session processing or inspect raw events to reconstruct summary artifacts.',
440
+ evidence: [`session_id=${sessionId}`],
441
+ sessions: [sessionId],
442
+ };
443
+ }
444
+
445
+ function generateCrossSessionFindings(snapshots) {
446
+ if (snapshots.length < 2) {
447
+ return [];
448
+ }
449
+
450
+ const fileToSessions = new Map();
451
+
452
+ for (const snap of snapshots) {
453
+ const files = Array.isArray(snap.summary?.changes?.files) ? snap.summary.changes.files : [];
454
+ for (const file of files) {
455
+ if (file.category !== 'source') {
456
+ continue;
457
+ }
458
+ if (!fileToSessions.has(file.path)) {
459
+ fileToSessions.set(file.path, new Set());
460
+ }
461
+ fileToSessions.get(file.path).add(snap.id);
462
+ }
463
+ }
464
+
465
+ const hotFiles = [...fileToSessions.entries()]
466
+ .filter(([, sessions]) => sessions.size >= 2)
467
+ .sort((a, b) => b[1].size - a[1].size);
468
+
469
+ if (hotFiles.length === 0) {
470
+ return [];
471
+ }
472
+
473
+ const HOT_FILE_LIMIT = 3;
474
+ return hotFiles.slice(0, HOT_FILE_LIMIT).map(([filePath, sessionSet], idx) => ({
475
+ rule_id: 'maintainability.hot_file_across_sessions',
476
+ category: 'maintainability',
477
+ severity: sessionSet.size >= 4 ? 'high' : 'medium',
478
+ confidence: 'medium',
479
+ summary: 'Same source file modified across multiple reviewed sessions',
480
+ details: `File "${filePath}" was modified in ${sessionSet.size} reviewed sessions, which may signal unresolved churn.`,
481
+ suggestion: 'Investigate root cause and consolidate related changes into a single reviewed thread.',
482
+ location: { file: filePath },
483
+ evidence: [
484
+ `hot_file=${filePath}`,
485
+ `session_count=${sessionSet.size}`,
486
+ `hot_file_rank=${idx + 1}`,
487
+ ...[...sessionSet].slice(0, 5).map(id => `session_id=${id}`),
488
+ ],
489
+ sessions: [...sessionSet],
490
+ }));
491
+ }
492
+
493
+ function filterBySeverity(findings, minSeverity) {
494
+ const threshold = SEVERITY_SCORE[minSeverity] || SEVERITY_SCORE.info;
495
+ return findings.filter(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
496
+ }
497
+
498
+ function shouldFailByThreshold(findings, failOn) {
499
+ const threshold = SEVERITY_SCORE[failOn] || SEVERITY_SCORE.high;
500
+ return findings.some(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
501
+ }
502
+
503
+ function computeVerdict(findings, failOn) {
504
+ if (shouldFailByThreshold(findings, failOn)) {
505
+ return 'fail';
506
+ }
507
+ if (findings.length > 0) {
508
+ return 'warn';
509
+ }
510
+ return 'pass';
511
+ }
512
+
513
+ function buildSessionSnapshot(sessionEntry) {
514
+ const summaryPath = join(sessionEntry.dir, 'summary.json');
515
+ const statePath = join(sessionEntry.dir, 'state.json');
516
+ const metadataPath = join(sessionEntry.dir, 'metadata.json');
517
+
518
+ const summary = loadJsonIfExists(summaryPath);
519
+ const state = loadJsonIfExists(statePath, {});
520
+ const metadata = loadJsonIfExists(metadataPath, {});
521
+
522
+ return {
523
+ id: sessionEntry.id,
524
+ summary,
525
+ state,
526
+ metadata,
527
+ source: {
528
+ summary_file: summaryPath,
529
+ state_file: statePath,
530
+ },
531
+ };
532
+ }
533
+
534
+ function buildReport(selectedSessions, options) {
535
+ const snapshots = selectedSessions.map(buildSessionSnapshot);
536
+ const rawFindings = [];
537
+
538
+ for (const snap of snapshots) {
539
+ if (!snap.summary) {
540
+ rawFindings.push(generateSummaryMissingFinding(snap.id));
541
+ continue;
542
+ }
543
+ rawFindings.push(...generateFindings(snap.summary, snap.state, snap.id));
544
+ }
545
+ rawFindings.push(...generateCrossSessionFindings(snapshots.filter(snap => snap.summary)));
546
+
547
+ const deduped = dedupeFindings(rawFindings);
548
+ const filteredFindings = filterBySeverity(deduped, options.minSeverity);
549
+ const verdict = computeVerdict(filteredFindings, options.failOn);
550
+
551
+ return {
552
+ schema_version: '1.1',
553
+ generated_at: new Date().toISOString(),
554
+ policy: {
555
+ min_severity: options.minSeverity,
556
+ fail_on: options.failOn,
557
+ strict: options.strict,
558
+ recent: options.all ? 'all' : options.recent,
559
+ session_filter: options.sessionId || null,
560
+ },
561
+ verdict,
562
+ sessions: snapshots.map(snap => ({
563
+ id: snap.id,
564
+ type: snap.summary?.session?.type || snap.metadata?.type || 'unknown',
565
+ risk: snap.summary?.session?.risk || snap.metadata?.risk || 'unknown',
566
+ duration_minutes: snap.summary?.session?.duration_minutes ?? snap.metadata?.duration_minutes ?? 0,
567
+ source: snap.source,
568
+ })),
569
+ findings: filteredFindings,
570
+ stats: {
571
+ reviewed_sessions: snapshots.length,
572
+ total_findings: filteredFindings.length,
573
+ by_severity: Object.keys(SEVERITY_SCORE).reduce((acc, key) => {
574
+ acc[key] = filteredFindings.filter(f => f.severity === key).length;
575
+ return acc;
576
+ }, {}),
577
+ },
578
+ };
579
+ }
580
+
581
+ function printHumanReport(report) {
582
+ console.log(`🧪 Code Review`);
583
+ console.log(` Sessions: ${report.stats.reviewed_sessions}`);
584
+ console.log(` Verdict: ${report.verdict.toUpperCase()} | Findings: ${report.findings.length}`);
585
+ console.log(` Policy: min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
586
+ console.log();
587
+
588
+ if (report.sessions.length === 1) {
589
+ const session = report.sessions[0];
590
+ console.log(`📦 Session: ${session.id}`);
591
+ console.log(` Type: ${session.type} | Risk: ${session.risk} | Duration: ${session.duration_minutes}m`);
592
+ console.log();
593
+ }
594
+
595
+ if (report.findings.length === 0) {
596
+ console.log('✅ No findings above current severity threshold.');
597
+ return;
598
+ }
599
+
600
+ for (const [idx, finding] of report.findings.entries()) {
601
+ console.log(`${idx + 1}. [${finding.severity.toUpperCase()}][${finding.category}] ${finding.summary}`);
602
+ console.log(` Rule: ${finding.rule_id} | Confidence: ${finding.confidence}`);
603
+ if (finding.details) {
604
+ console.log(` Detail: ${finding.details}`);
605
+ }
606
+ if (finding.location?.file) {
607
+ console.log(` Location: ${finding.location.file}`);
608
+ }
609
+ if (finding.sessions?.length > 0) {
610
+ console.log(` Sessions: ${finding.sessions.slice(0, 3).join(', ')}${finding.sessions.length > 3 ? ` (+${finding.sessions.length - 3})` : ''}`);
611
+ }
612
+ if (finding.evidence.length > 0) {
613
+ console.log(` Evidence: ${finding.evidence.slice(0, 3).join(' | ')}`);
614
+ }
615
+ if (finding.suggestion) {
616
+ console.log(` Suggestion: ${finding.suggestion}`);
617
+ }
618
+ console.log();
619
+ }
620
+ }
621
+
622
+ function printMarkdownReport(report) {
623
+ const lines = [];
624
+ lines.push('# Code Review Report');
625
+ lines.push('');
626
+ lines.push(`- Verdict: **${report.verdict.toUpperCase()}**`);
627
+ lines.push(`- Sessions: **${report.stats.reviewed_sessions}**`);
628
+ lines.push(`- Findings: **${report.findings.length}**`);
629
+ lines.push(`- Policy: \`min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
630
+ lines.push('');
631
+
632
+ lines.push('## Findings');
633
+ lines.push('');
634
+ if (report.findings.length === 0) {
635
+ lines.push('No findings above threshold.');
636
+ console.log(lines.join('\n'));
637
+ return;
638
+ }
639
+
640
+ lines.push('| Severity | Category | Rule | Summary | Sessions |');
641
+ lines.push('|---|---|---|---|---|');
642
+ for (const finding of report.findings) {
643
+ const sessions = finding.sessions?.length || 0;
644
+ const summary = finding.summary.replace(/\|/g, '\\|');
645
+ lines.push(`| ${finding.severity.toUpperCase()} | ${finding.category} | \`${finding.rule_id}\` | ${summary} | ${sessions} |`);
646
+ }
647
+ lines.push('');
648
+ lines.push('## Details');
649
+ lines.push('');
650
+ for (const finding of report.findings) {
651
+ lines.push(`### [${finding.severity.toUpperCase()}] ${finding.summary}`);
652
+ lines.push(`- Rule: \`${finding.rule_id}\``);
653
+ lines.push(`- Confidence: \`${finding.confidence}\``);
654
+ if (finding.sessions?.length > 0) {
655
+ lines.push(`- Sessions: ${finding.sessions.join(', ')}`);
656
+ }
657
+ if (finding.location?.file) {
658
+ lines.push(`- Location: \`${finding.location.file}\``);
659
+ }
660
+ if (finding.details) {
661
+ lines.push(`- Detail: ${finding.details}`);
662
+ }
663
+ if (finding.suggestion) {
664
+ lines.push(`- Suggestion: ${finding.suggestion}`);
665
+ }
666
+ if (finding.evidence.length > 0) {
667
+ lines.push(`- Evidence: ${finding.evidence.slice(0, 5).join(' | ')}`);
668
+ }
669
+ lines.push('');
670
+ }
671
+
672
+ console.log(lines.join('\n'));
673
+ }
674
+
675
+ function selectSessions(allSessions, options) {
676
+ if (options.sessionId) {
677
+ const matched = allSessions.find(s => s.id === options.sessionId || s.id.startsWith(options.sessionId));
678
+ return matched ? [matched] : [];
679
+ }
680
+ if (options.all) {
681
+ return allSessions;
682
+ }
683
+ return allSessions.slice(0, options.recent);
684
+ }
685
+
686
+ export default async function review(args) {
687
+ try {
688
+ const options = parseArgs(args);
689
+ if (options.help) {
690
+ printUsage();
691
+ return 0;
692
+ }
693
+
694
+ const projectRoot = getProjectRoot();
695
+ const logDir = getLogDir(projectRoot);
696
+ const sessions = getSessionDirs(logDir);
697
+
698
+ if (sessions.length === 0) {
699
+ console.error('❌ No sessions found for review.');
700
+ return 1;
701
+ }
702
+
703
+ const selectedSessions = selectSessions(sessions, options);
704
+ if (selectedSessions.length === 0) {
705
+ console.error(`❌ Session not found: ${options.sessionId}`);
706
+ return 1;
707
+ }
708
+
709
+ const report = buildReport(selectedSessions, options);
710
+
711
+ if (options.format === 'json') {
712
+ console.log(JSON.stringify(report, null, 2));
713
+ } else if (options.format === 'markdown') {
714
+ printMarkdownReport(report);
715
+ } else {
716
+ printHumanReport(report);
717
+ console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...>');
718
+ }
719
+
720
+ if (options.strict && shouldFailByThreshold(report.findings, options.failOn)) {
721
+ return 1;
722
+ }
723
+ return 0;
724
+ } catch (error) {
725
+ console.error('❌ Failed to run review:', error.message);
726
+ return 1;
727
+ }
728
+ }
package/lib/summarize.js CHANGED
@@ -16,24 +16,15 @@ const DEFAULT_CONFIG = {
16
16
  model: 'gpt-4o-mini',
17
17
  max_tokens: 1000,
18
18
  temperature: 0.7,
19
+ openai_base_url: 'https://api.openai.com/v1',
19
20
  };
20
21
 
21
22
  /**
22
23
  * Get API configuration from environment or config file
23
24
  */
24
25
  function getApiConfig(projectRoot) {
25
- // Check for environment variables first
26
26
  const config = { ...DEFAULT_CONFIG };
27
27
 
28
- // OpenAI
29
- if (process.env.OPENAI_API_KEY) {
30
- config.provider = 'openai';
31
- config.api_key = process.env.OPENAI_API_KEY;
32
- } else if (process.env.ANTHROPIC_API_KEY) {
33
- config.provider = 'anthropic';
34
- config.api_key = process.env.ANTHROPIC_API_KEY;
35
- }
36
-
37
28
  // Check for project config
38
29
  const configFile = join(projectRoot, '.shit-logs', 'config.json');
39
30
  if (existsSync(configFile)) {
@@ -45,6 +36,22 @@ function getApiConfig(projectRoot) {
45
36
  }
46
37
  }
47
38
 
39
+ // Environment variables override file config
40
+ if (process.env.OPENAI_API_KEY) {
41
+ config.provider = 'openai';
42
+ config.api_key = process.env.OPENAI_API_KEY;
43
+ } else if (process.env.ANTHROPIC_API_KEY) {
44
+ config.provider = 'anthropic';
45
+ config.api_key = process.env.ANTHROPIC_API_KEY;
46
+ }
47
+
48
+ if (process.env.OPENAI_BASE_URL) {
49
+ config.openai_base_url = process.env.OPENAI_BASE_URL;
50
+ }
51
+ if (process.env.OPENAI_ENDPOINT) {
52
+ config.openai_endpoint = process.env.OPENAI_ENDPOINT;
53
+ }
54
+
48
55
  return config;
49
56
  }
50
57
 
@@ -159,8 +166,21 @@ function buildSummarizePrompt(context) {
159
166
  /**
160
167
  * Call OpenAI API
161
168
  */
162
- async function callOpenAI(apiKey, model, prompt, maxTokens, temperature) {
163
- const response = await fetch('https://api.openai.com/v1/chat/completions', {
169
+ function resolveOpenAIEndpoint(config) {
170
+ const explicitEndpoint = (config.openai_endpoint || '').trim();
171
+ if (explicitEndpoint) {
172
+ return explicitEndpoint;
173
+ }
174
+
175
+ const baseUrl = String(config.openai_base_url || DEFAULT_CONFIG.openai_base_url).trim().replace(/\/+$/, '');
176
+ if (baseUrl.endsWith('/chat/completions')) {
177
+ return baseUrl;
178
+ }
179
+ return `${baseUrl}/chat/completions`;
180
+ }
181
+
182
+ async function callOpenAI(apiKey, endpoint, model, prompt, maxTokens, temperature) {
183
+ const response = await fetch(endpoint, {
164
184
  method: 'POST',
165
185
  headers: {
166
186
  'Content-Type': 'application/json',
@@ -247,8 +267,10 @@ export async function summarizeSession(projectRoot, sessionId, sessionDir) {
247
267
  config.temperature
248
268
  );
249
269
  } else {
270
+ const openaiEndpoint = resolveOpenAIEndpoint(config);
250
271
  summary = await callOpenAI(
251
272
  config.api_key,
273
+ openaiEndpoint,
252
274
  config.model || 'gpt-4o-mini',
253
275
  prompt,
254
276
  config.max_tokens,
@@ -301,9 +323,11 @@ export default async function summarize(args) {
301
323
  console.log('Usage: shit summarize <session-id>');
302
324
  console.log('\nEnvironment variables:');
303
325
  console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
326
+ console.log(' OPENAI_BASE_URL # OpenAI-compatible base URL (e.g. https://api.openai.com/v1)');
327
+ console.log(' OPENAI_ENDPOINT # Full OpenAI-compatible endpoint (overrides OPENAI_BASE_URL)');
304
328
  console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
305
329
  console.log('\nConfiguration (.shit-logs/config.json):');
306
- console.log(` {"provider": "openai", "model": "gpt-4o-mini"}`);
330
+ console.log(` {"provider": "openai", "model": "gpt-4o-mini", "openai_base_url": "https://api.openai.com/v1"}`);
307
331
  process.exit(1);
308
332
  }
309
333
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudemini/shit-cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Session-based Hook Intelligence Tracker - Zero-dependency memory system for human-AI coding sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,8 +9,7 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "lib/",
12
- "README.md",
13
- "LICENSE"
12
+ "README.md"
14
13
  ],
15
14
  "engines": {
16
15
  "node": ">=18.0.0"
@@ -33,7 +32,7 @@
33
32
  "url": "git+https://github.com/anthropics/shit-cli.git"
34
33
  },
35
34
  "author": "",
36
- "license": "MIT",
35
+ "license": "UNLICENSED",
37
36
  "dependencies": {},
38
37
  "devDependencies": {}
39
38
  }