@claudemini/shit-cli 1.8.1 → 1.9.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/lib/review.js CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
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
3
+ * Uses goatchain agent output and keeps the existing findings/report schema.
8
4
  */
9
5
 
10
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
6
+ import { existsSync, readdirSync, statSync } from 'fs';
11
7
  import { join } from 'path';
12
8
  import { createHash } from 'crypto';
13
9
  import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
14
- import { redactSecrets } from './redact.js';
15
10
  import { dispatchWebhook } from './webhook.js';
11
+ import {
12
+ loadJsonIfExists,
13
+ normalizeEvidence,
14
+ normalizeLocation,
15
+ normalizeLocationKey,
16
+ } from './review-common.js';
16
17
 
17
18
  const SEVERITY_SCORE = {
18
19
  info: 1,
@@ -30,7 +31,7 @@ const CONFIDENCE_SCORE = {
30
31
 
31
32
  function parseArgs(args) {
32
33
  const options = {
33
- format: 'text', // text | json | markdown
34
+ format: 'text',
34
35
  strict: false,
35
36
  minSeverity: 'info',
36
37
  failOn: 'high',
@@ -38,6 +39,12 @@ function parseArgs(args) {
38
39
  recent: 1,
39
40
  all: false,
40
41
  help: false,
42
+ engine: 'agent',
43
+ agentTimeoutMs: 120000,
44
+ allowWorktreeDiff: false,
45
+ agentAutoApprove: true,
46
+ deprecatedFlags: [],
47
+ deprecatedEngineValue: null,
41
48
  };
42
49
 
43
50
  for (const arg of args) {
@@ -57,6 +64,10 @@ function parseArgs(args) {
57
64
  options.all = true;
58
65
  continue;
59
66
  }
67
+ if (arg === '--agent') {
68
+ options.deprecatedFlags.push('--agent');
69
+ continue;
70
+ }
60
71
  if (arg === '--help' || arg === '-h') {
61
72
  options.help = true;
62
73
  continue;
@@ -82,6 +93,41 @@ function parseArgs(args) {
82
93
  }
83
94
  continue;
84
95
  }
96
+ if (arg.startsWith('--engine=')) {
97
+ const value = (arg.split('=')[1] || '').toLowerCase();
98
+ options.deprecatedFlags.push('--engine');
99
+ options.deprecatedEngineValue = value || null;
100
+ continue;
101
+ }
102
+ if (arg.startsWith('--agent-timeout-ms=')) {
103
+ const value = Number.parseInt(arg.split('=')[1], 10);
104
+ if (Number.isFinite(value) && value > 0) {
105
+ options.agentTimeoutMs = value;
106
+ }
107
+ continue;
108
+ }
109
+ if (arg === '--allow-worktree-diff') {
110
+ options.allowWorktreeDiff = true;
111
+ continue;
112
+ }
113
+ if (arg === '--agent-auto-approve') {
114
+ options.agentAutoApprove = true;
115
+ continue;
116
+ }
117
+ if (arg === '--no-agent-auto-approve') {
118
+ options.agentAutoApprove = false;
119
+ continue;
120
+ }
121
+ if (arg.startsWith('--agent-auto-approve=')) {
122
+ const value = (arg.split('=')[1] || '').toLowerCase();
123
+ if (value === 'true' || value === '1' || value === 'yes') {
124
+ options.agentAutoApprove = true;
125
+ }
126
+ if (value === 'false' || value === '0' || value === 'no') {
127
+ options.agentAutoApprove = false;
128
+ }
129
+ continue;
130
+ }
85
131
  if (!arg.startsWith('-') && !options.sessionId) {
86
132
  options.sessionId = arg;
87
133
  }
@@ -100,7 +146,15 @@ function printUsage() {
100
146
  console.log(' --all Review all sessions');
101
147
  console.log(' --min-severity=<level> Filter findings below severity');
102
148
  console.log(' --fail-on=<level> Strict mode failure threshold (default: high)');
149
+ console.log(' --agent-timeout-ms=<ms> Agent timeout in milliseconds (default: 120000)');
150
+ console.log(' --allow-worktree-diff Allow worktree diff fallback without checkpoint commits');
151
+ console.log(' --agent-auto-approve=<bool> Auto approve agent tool calls (default: true)');
152
+ console.log(' --no-agent-auto-approve Disable auto approval for agent tool calls');
103
153
  console.log(' --strict Exit non-zero when findings hit fail-on threshold');
154
+ console.log('');
155
+ console.log('Deprecated options (accepted but ignored):');
156
+ console.log(' --agent');
157
+ console.log(' --engine=<name>');
104
158
  }
105
159
 
106
160
  function getSessionDirs(logDir) {
@@ -121,6 +175,7 @@ function getSessionDirs(logDir) {
121
175
  } catch {
122
176
  continue;
123
177
  }
178
+
124
179
  if (!stat.isDirectory()) {
125
180
  continue;
126
181
  }
@@ -135,25 +190,8 @@ function getSessionDirs(logDir) {
135
190
  return sessions.sort((a, b) => b.mtime - a.mtime);
136
191
  }
137
192
 
138
- function loadJsonIfExists(filePath, fallback = null) {
139
- if (!existsSync(filePath)) {
140
- return fallback;
141
- }
142
- try {
143
- return JSON.parse(readFileSync(filePath, 'utf-8'));
144
- } catch {
145
- return fallback;
146
- }
147
- }
148
-
149
- function normalizeEvidence(evidence) {
150
- return evidence
151
- .filter(Boolean)
152
- .map(item => String(item).trim())
153
- .filter(Boolean);
154
- }
155
-
156
193
  function normalizeFinding(input) {
194
+ const sessions = Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [];
157
195
  return {
158
196
  id: input.id || '',
159
197
  rule_id: input.rule_id || 'review.generic',
@@ -163,27 +201,19 @@ function normalizeFinding(input) {
163
201
  summary: input.summary || '',
164
202
  details: input.details || '',
165
203
  suggestion: input.suggestion || '',
166
- location: input.location || null,
167
- evidence: normalizeEvidence(input.evidence || []),
168
- sessions: Array.isArray(input.sessions) ? [...new Set(input.sessions)] : [],
204
+ location: normalizeLocation(input.location),
205
+ evidence: normalizeEvidence(input.evidence, {
206
+ fallbackItems: sessions.length > 0 ? sessions.map(id => `session_id=${id}`) : ['evidence_missing=true'],
207
+ }),
208
+ sessions,
169
209
  };
170
210
  }
171
211
 
172
- function normalizeLocation(location) {
173
- if (!location || typeof location !== 'object') {
174
- return '';
175
- }
176
- const file = location.file || '';
177
- const line = location.line || '';
178
- const column = location.column || '';
179
- return `${file}:${line}:${column}`;
180
- }
181
-
182
212
  function canonicalFindingKey(finding) {
183
213
  return [
184
214
  String(finding.rule_id || '').trim().toLowerCase(),
185
215
  String(finding.category || '').trim().toLowerCase(),
186
- normalizeLocation(finding.location),
216
+ normalizeLocationKey(finding.location),
187
217
  String(finding.summary || '').trim().toLowerCase(),
188
218
  ].join('|');
189
219
  }
@@ -219,6 +249,7 @@ function dedupeFindings(findings) {
219
249
  evidence: [...new Set([...prev.evidence, ...finding.evidence])],
220
250
  sessions: [...new Set([...(prev.sessions || []), ...(finding.sessions || [])])],
221
251
  };
252
+
222
253
  merged.id = prev.id;
223
254
  map.set(key, merged);
224
255
  }
@@ -226,209 +257,6 @@ function dedupeFindings(findings) {
226
257
  return [...map.values()].sort((a, b) => SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity]);
227
258
  }
228
259
 
229
- function getAllCommands(summary) {
230
- const commandGroups = summary?.activity?.commands || {};
231
- return Object.values(commandGroups).flatMap(list => Array.isArray(list) ? list : []);
232
- }
233
-
234
- const WINDOWS_ABS_PATH_REGEX = /(^|[\s([{:="'`])([A-Za-z]:\\(?:[^\\\s"'`|<>]+\\){1,}[^\\\s"'`|<>]+)/g;
235
- const UNIX_SENSITIVE_ROOT_PATH_REGEX = /(^|[\s([{:="'`])(\/(?:Users|home|var|tmp|opt|etc|private|Volumes|workspace|workspaces|usr)(?:\/[^\s"'`|<>]+){2,})/g;
236
- const UNIX_FILE_PATH_WITH_EXT_REGEX = /(^|[\s([{:="'`])(\/(?:[^\/\s"'`|<>]+\/){2,}[^\/\s"'`|<>]*\.[A-Za-z0-9]{1,10})/g;
237
-
238
- function sanitizeErrorDetails(message) {
239
- const text = String(message || 'Tool returned an error');
240
- const redacted = redactSecrets(text)
241
- .replace(WINDOWS_ABS_PATH_REGEX, '$1[PATH]')
242
- .replace(UNIX_SENSITIVE_ROOT_PATH_REGEX, '$1[PATH]')
243
- .replace(UNIX_FILE_PATH_WITH_EXT_REGEX, '$1[PATH]')
244
- .replace(/\b[A-Z_]{2,}=[^\s"'`|<>]+/g, '[ENV_REDACTED]');
245
- return redacted.slice(0, 200);
246
- }
247
-
248
- function generateFindings(summary, state, sessionId) {
249
- const findings = [];
250
- const reviewHints = summary?.review_hints || {};
251
- const changes = summary?.changes?.files || [];
252
- const activity = summary?.activity || {};
253
- const errors = Array.isArray(activity.errors) ? activity.errors : [];
254
- const commands = getAllCommands(summary);
255
- const modifiedSourceFiles = changes.filter(file =>
256
- file.category === 'source' &&
257
- Array.isArray(file.operations) &&
258
- file.operations.some(op => op !== 'read')
259
- );
260
-
261
- if (modifiedSourceFiles.length > 0 && !reviewHints.tests_run) {
262
- findings.push({
263
- rule_id: 'testing.no_tests_after_source_changes',
264
- category: 'testing',
265
- severity: modifiedSourceFiles.length >= 5 ? 'high' : 'medium',
266
- confidence: 'high',
267
- summary: 'Source code changed without test execution evidence',
268
- details: `Detected ${modifiedSourceFiles.length} modified source file(s), but no test command was recorded.`,
269
- suggestion: 'Run targeted tests for changed modules and attach the command/output in the session.',
270
- evidence: [
271
- `session_id=${sessionId}`,
272
- `review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
273
- `modified_source_files=${modifiedSourceFiles.length}`,
274
- ],
275
- sessions: [sessionId],
276
- });
277
- }
278
-
279
- if (reviewHints.migration_added && !reviewHints.tests_run) {
280
- findings.push({
281
- rule_id: 'correctness.migration_without_tests',
282
- category: 'correctness',
283
- severity: 'high',
284
- confidence: 'high',
285
- summary: 'Database migration changed without test validation',
286
- details: 'Migration-related changes are present and no test run was captured.',
287
- suggestion: 'Run migration verification and regression tests before merge.',
288
- evidence: [
289
- `session_id=${sessionId}`,
290
- `review_hints.migration_added=${Boolean(reviewHints.migration_added)}`,
291
- `review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
292
- ],
293
- sessions: [sessionId],
294
- });
295
- }
296
-
297
- if (reviewHints.config_changed && !reviewHints.build_verified) {
298
- findings.push({
299
- rule_id: 'reliability.config_without_build',
300
- category: 'reliability',
301
- severity: 'medium',
302
- confidence: 'high',
303
- summary: 'Configuration changed without build verification',
304
- details: 'Config-level edits were detected, but no build/compile command was recorded.',
305
- suggestion: 'Run a full build/compile and include output to reduce deployment risk.',
306
- evidence: [
307
- `session_id=${sessionId}`,
308
- `review_hints.config_changed=${Boolean(reviewHints.config_changed)}`,
309
- `review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
310
- ],
311
- sessions: [sessionId],
312
- });
313
- }
314
-
315
- if (reviewHints.large_change && !reviewHints.tests_run && !reviewHints.build_verified) {
316
- findings.push({
317
- rule_id: 'maintainability.large_change_without_validation',
318
- category: 'maintainability',
319
- severity: 'high',
320
- confidence: 'medium',
321
- summary: 'Large change set lacks both test and build signals',
322
- details: 'A large multi-file change was made without recorded test/build validation.',
323
- suggestion: 'Split change into smaller PRs or provide CI/local validation evidence.',
324
- evidence: [
325
- `session_id=${sessionId}`,
326
- `review_hints.large_change=${Boolean(reviewHints.large_change)}`,
327
- `review_hints.tests_run=${Boolean(reviewHints.tests_run)}`,
328
- `review_hints.build_verified=${Boolean(reviewHints.build_verified)}`,
329
- ],
330
- sessions: [sessionId],
331
- });
332
- }
333
-
334
- if (Array.isArray(reviewHints.files_without_tests) && reviewHints.files_without_tests.length > 0) {
335
- const sample = reviewHints.files_without_tests.slice(0, 3);
336
- findings.push({
337
- rule_id: 'testing.files_without_tests',
338
- category: 'testing',
339
- severity: reviewHints.files_without_tests.length > 5 ? 'high' : 'medium',
340
- confidence: 'medium',
341
- summary: 'Modified source files have no corresponding test changes',
342
- details: `Detected ${reviewHints.files_without_tests.length} file(s) without related test updates.`,
343
- suggestion: 'Add/adjust tests for changed source files or document why tests are not needed.',
344
- location: { file: sample[0] },
345
- evidence: [
346
- `session_id=${sessionId}`,
347
- `review_hints.files_without_tests_count=${reviewHints.files_without_tests.length}`,
348
- ...sample.map(file => `file_without_test=${file}`),
349
- ],
350
- sessions: [sessionId],
351
- });
352
- }
353
-
354
- for (const error of errors.slice(-3)) {
355
- findings.push({
356
- rule_id: 'reliability.tool_error',
357
- category: 'reliability',
358
- severity: 'medium',
359
- confidence: 'high',
360
- summary: `Tool error detected: ${error.tool || 'unknown tool'}`,
361
- details: sanitizeErrorDetails(error.message),
362
- suggestion: 'Confirm the error is resolved and include successful rerun evidence.',
363
- evidence: [
364
- `session_id=${sessionId}`,
365
- 'activity.errors_present=true',
366
- `error_tool=${error.tool || 'unknown'}`,
367
- ],
368
- sessions: [sessionId],
369
- });
370
- }
371
-
372
- const rollbackCommands = commands.filter(cmd =>
373
- /\bgit\s+(reset|restore|revert)\b/i.test(cmd) ||
374
- /\bgit\s+checkout\s+--\b/i.test(cmd) ||
375
- /\bundo\b/i.test(cmd)
376
- );
377
- if (rollbackCommands.length >= 2) {
378
- findings.push({
379
- rule_id: 'maintainability.frequent_rollback_commands',
380
- category: 'maintainability',
381
- severity: rollbackCommands.length >= 4 ? 'high' : 'medium',
382
- confidence: 'medium',
383
- summary: 'Frequent rollback/undo commands detected in one session',
384
- details: `Detected ${rollbackCommands.length} rollback-style command(s), which may indicate unstable iteration.`,
385
- suggestion: 'Split the change and validate each step to reduce back-and-forth edits.',
386
- evidence: [
387
- `session_id=${sessionId}`,
388
- `rollback_command_count=${rollbackCommands.length}`,
389
- ...rollbackCommands.slice(0, 3).map(cmd => `rollback_cmd=${cmd.slice(0, 80)}`),
390
- ],
391
- sessions: [sessionId],
392
- });
393
- }
394
-
395
- if ((summary?.session?.risk || '') === 'high') {
396
- findings.push({
397
- rule_id: 'maintainability.high_risk_session',
398
- category: 'maintainability',
399
- severity: 'medium',
400
- confidence: 'medium',
401
- summary: 'Session-level risk was classified as high',
402
- details: 'The extraction engine labeled this session as high risk based on change shape.',
403
- suggestion: 'Require manual reviewer sign-off and verify rollback/checkpoint strategy.',
404
- evidence: [
405
- `session_id=${sessionId}`,
406
- `summary.session.risk=${summary.session.risk}`,
407
- ],
408
- sessions: [sessionId],
409
- });
410
- }
411
-
412
- if (state?.checkpoints && state.checkpoints.length > 0) {
413
- findings.push({
414
- rule_id: 'maintainability.checkpoints_present',
415
- category: 'maintainability',
416
- severity: 'info',
417
- confidence: 'high',
418
- summary: 'Checkpoint chain exists for this session',
419
- details: `Detected ${state.checkpoints.length} checkpoint commit(s), enabling safer rollback.`,
420
- suggestion: 'Use "shit rewind <checkpoint>" if post-merge regression appears.',
421
- evidence: [
422
- `session_id=${sessionId}`,
423
- `state.checkpoints_count=${state.checkpoints.length}`,
424
- ],
425
- sessions: [sessionId],
426
- });
427
- }
428
-
429
- return findings;
430
- }
431
-
432
260
  function generateSummaryMissingFinding(sessionId) {
433
261
  return {
434
262
  rule_id: 'integrity.missing_summary',
@@ -451,15 +279,12 @@ function generateCrossSessionFindings(snapshots) {
451
279
  const fileToSessions = new Map();
452
280
 
453
281
  for (const snap of snapshots) {
454
- const files = Array.isArray(snap.summary?.changes?.files) ? snap.summary.changes.files : [];
455
- for (const file of files) {
456
- if (file.category !== 'source') {
457
- continue;
458
- }
459
- if (!fileToSessions.has(file.path)) {
460
- fileToSessions.set(file.path, new Set());
282
+ const sourcePaths = collectSessionSourcePaths(snap);
283
+ for (const sourcePath of sourcePaths) {
284
+ if (!fileToSessions.has(sourcePath)) {
285
+ fileToSessions.set(sourcePath, new Set());
461
286
  }
462
- fileToSessions.get(file.path).add(snap.id);
287
+ fileToSessions.get(sourcePath).add(snap.id);
463
288
  }
464
289
  }
465
290
 
@@ -491,6 +316,58 @@ function generateCrossSessionFindings(snapshots) {
491
316
  }));
492
317
  }
493
318
 
319
+ function collectSessionSourcePaths(snapshot) {
320
+ const pathsFromSummary = Array.isArray(snapshot.summary?.changes?.files)
321
+ ? snapshot.summary.changes.files
322
+ .filter(file => file?.category === 'source')
323
+ .map(file => String(file.path || '').trim())
324
+ .filter(Boolean)
325
+ : [];
326
+
327
+ if (pathsFromSummary.length > 0) {
328
+ return [...new Set(pathsFromSummary)];
329
+ }
330
+
331
+ const statePaths = new Set();
332
+ collectPathCandidates(snapshot.state?.edits, statePaths);
333
+ collectPathCandidates(snapshot.state?.file_ops, statePaths);
334
+ return [...statePaths];
335
+ }
336
+
337
+ function collectPathCandidates(entries, outputSet) {
338
+ if (!Array.isArray(entries)) {
339
+ return;
340
+ }
341
+
342
+ for (const entry of entries) {
343
+ if (typeof entry === 'string') {
344
+ addPathCandidate(entry, outputSet);
345
+ continue;
346
+ }
347
+ if (!entry || typeof entry !== 'object') {
348
+ continue;
349
+ }
350
+
351
+ addPathCandidate(entry.path, outputSet);
352
+ addPathCandidate(entry.file, outputSet);
353
+ addPathCandidate(entry.target, outputSet);
354
+ addPathCandidate(entry.source, outputSet);
355
+ addPathCandidate(entry.from, outputSet);
356
+ addPathCandidate(entry.to, outputSet);
357
+ }
358
+ }
359
+
360
+ function addPathCandidate(pathValue, outputSet) {
361
+ const path = String(pathValue || '').trim();
362
+ if (!path) {
363
+ return;
364
+ }
365
+ if (path.startsWith('.git/') || path.startsWith('.shit-logs/') || path.includes('/.git/')) {
366
+ return;
367
+ }
368
+ outputSet.add(path);
369
+ }
370
+
494
371
  function filterBySeverity(findings, minSeverity) {
495
372
  const threshold = SEVERITY_SCORE[minSeverity] || SEVERITY_SCORE.info;
496
373
  return findings.filter(f => (SEVERITY_SCORE[f.severity] || 0) >= threshold);
@@ -532,30 +409,19 @@ function buildSessionSnapshot(sessionEntry) {
532
409
  };
533
410
  }
534
411
 
535
- function buildReport(selectedSessions, options) {
536
- const snapshots = selectedSessions.map(buildSessionSnapshot);
537
- const rawFindings = [];
538
-
539
- for (const snap of snapshots) {
540
- if (!snap.summary) {
541
- rawFindings.push(generateSummaryMissingFinding(snap.id));
542
- continue;
543
- }
544
- rawFindings.push(...generateFindings(snap.summary, snap.state, snap.id));
545
- }
546
- rawFindings.push(...generateCrossSessionFindings(snapshots.filter(snap => snap.summary)));
547
-
412
+ function buildReportFromRawFindings(snapshots, rawFindings, options) {
548
413
  const deduped = dedupeFindings(rawFindings);
549
414
  const filteredFindings = filterBySeverity(deduped, options.minSeverity);
550
415
  const verdict = computeVerdict(filteredFindings, options.failOn);
551
416
 
552
417
  return {
553
- schema_version: '1.1',
418
+ schema_version: '1.2',
554
419
  generated_at: new Date().toISOString(),
555
420
  policy: {
556
421
  min_severity: options.minSeverity,
557
422
  fail_on: options.failOn,
558
423
  strict: options.strict,
424
+ engine: options.engine,
559
425
  recent: options.all ? 'all' : options.recent,
560
426
  session_filter: options.sessionId || null,
561
427
  },
@@ -583,7 +449,7 @@ function printHumanReport(report) {
583
449
  console.log(`🧪 Code Review`);
584
450
  console.log(` Sessions: ${report.stats.reviewed_sessions}`);
585
451
  console.log(` Verdict: ${report.verdict.toUpperCase()} | Findings: ${report.findings.length}`);
586
- console.log(` Policy: min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
452
+ console.log(` Policy: engine=${report.policy.engine}, min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}`);
587
453
  console.log();
588
454
 
589
455
  if (report.sessions.length === 1) {
@@ -627,11 +493,12 @@ function printMarkdownReport(report) {
627
493
  lines.push(`- Verdict: **${report.verdict.toUpperCase()}**`);
628
494
  lines.push(`- Sessions: **${report.stats.reviewed_sessions}**`);
629
495
  lines.push(`- Findings: **${report.findings.length}**`);
630
- lines.push(`- Policy: \`min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
496
+ lines.push(`- Policy: \`engine=${report.policy.engine}, min=${report.policy.min_severity}, fail-on=${report.policy.fail_on}\``);
631
497
  lines.push('');
632
498
 
633
499
  lines.push('## Findings');
634
500
  lines.push('');
501
+
635
502
  if (report.findings.length === 0) {
636
503
  lines.push('No findings above threshold.');
637
504
  console.log(lines.join('\n'));
@@ -645,9 +512,11 @@ function printMarkdownReport(report) {
645
512
  const summary = finding.summary.replace(/\|/g, '\\|');
646
513
  lines.push(`| ${finding.severity.toUpperCase()} | ${finding.category} | \`${finding.rule_id}\` | ${summary} | ${sessions} |`);
647
514
  }
515
+
648
516
  lines.push('');
649
517
  lines.push('## Details');
650
518
  lines.push('');
519
+
651
520
  for (const finding of report.findings) {
652
521
  lines.push(`### [${finding.severity.toUpperCase()}] ${finding.summary}`);
653
522
  lines.push(`- Rule: \`${finding.rule_id}\``);
@@ -678,9 +547,11 @@ function selectSessions(allSessions, options) {
678
547
  const matched = allSessions.find(s => s.id === options.sessionId || s.id.startsWith(options.sessionId));
679
548
  return matched ? [matched] : [];
680
549
  }
550
+
681
551
  if (options.all) {
682
552
  return allSessions;
683
553
  }
554
+
684
555
  return allSessions.slice(0, options.recent);
685
556
  }
686
557
 
@@ -692,6 +563,13 @@ export default async function review(args) {
692
563
  return 0;
693
564
  }
694
565
 
566
+ if (options.deprecatedFlags.length > 0) {
567
+ console.error(`⚠️ Deprecated review flags are ignored: ${[...new Set(options.deprecatedFlags)].join(', ')}.`);
568
+ if (options.deprecatedEngineValue && options.deprecatedEngineValue !== 'agent') {
569
+ console.error(`⚠️ --engine=${options.deprecatedEngineValue} is unsupported. Using goatchain agent review.`);
570
+ }
571
+ }
572
+
695
573
  const projectRoot = getProjectRoot();
696
574
  const logDir = getLogDir(projectRoot);
697
575
  const sessions = getSessionDirs(logDir);
@@ -707,10 +585,24 @@ export default async function review(args) {
707
585
  return 1;
708
586
  }
709
587
 
710
- const report = buildReport(selectedSessions, options);
588
+ const snapshots = selectedSessions.map(buildSessionSnapshot);
589
+ const rawFindings = [];
711
590
 
712
- // Dispatch webhook for review completion
713
- dispatchWebhook(projectRoot, 'review.completed', report).catch(() => {});
591
+ for (const snap of snapshots) {
592
+ if (!snap.summary) {
593
+ rawFindings.push(generateSummaryMissingFinding(snap.id));
594
+ }
595
+ }
596
+
597
+ const { runAgentReview } = await import('./agent-review.js');
598
+ const agentFindings = await runAgentReview(projectRoot, selectedSessions, options);
599
+ rawFindings.push(...agentFindings);
600
+ // Intentional: include snapshots without summary and fallback to state paths when available.
601
+ rawFindings.push(...generateCrossSessionFindings(snapshots));
602
+
603
+ const report = buildReportFromRawFindings(snapshots, rawFindings, options);
604
+
605
+ await dispatchWebhook(projectRoot, 'review.completed', report);
714
606
 
715
607
  if (options.format === 'json') {
716
608
  console.log(JSON.stringify(report, null, 2));
@@ -718,12 +610,13 @@ export default async function review(args) {
718
610
  printMarkdownReport(report);
719
611
  } else {
720
612
  printHumanReport(report);
721
- console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...>');
613
+ console.log('Options: --json --markdown --recent=<n> --all --strict --min-severity=<...> --fail-on=<...> --agent-timeout-ms=<...>');
722
614
  }
723
615
 
724
616
  if (options.strict && shouldFailByThreshold(report.findings, options.failOn)) {
725
617
  return 1;
726
618
  }
619
+
727
620
  return 0;
728
621
  } catch (error) {
729
622
  console.error('❌ Failed to run review:', error.message);
package/lib/summarize.js CHANGED
@@ -22,7 +22,7 @@ const DEFAULT_CONFIG = {
22
22
  /**
23
23
  * Get API configuration from environment or config file
24
24
  */
25
- function getApiConfig(projectRoot) {
25
+ export function getApiConfig(projectRoot) {
26
26
  const config = { ...DEFAULT_CONFIG };
27
27
 
28
28
  // Check for project config
@@ -51,6 +51,18 @@ function getApiConfig(projectRoot) {
51
51
  if (process.env.OPENAI_ENDPOINT) {
52
52
  config.openai_endpoint = process.env.OPENAI_ENDPOINT;
53
53
  }
54
+ // Universal override for all providers.
55
+ if (process.env.AI_MODEL) {
56
+ config.model = process.env.AI_MODEL;
57
+ }
58
+
59
+ // Provider-specific overrides take precedence when applicable.
60
+ if (config.provider === 'openai' && process.env.OPENAI_MODEL) {
61
+ config.model = process.env.OPENAI_MODEL;
62
+ }
63
+ if (config.provider === 'anthropic' && process.env.ANTHROPIC_MODEL) {
64
+ config.model = process.env.ANTHROPIC_MODEL;
65
+ }
54
66
 
55
67
  return config;
56
68
  }
@@ -203,7 +215,9 @@ async function callOpenAI(apiKey, endpoint, model, prompt, maxTokens, temperatur
203
215
  }
204
216
 
205
217
  const data = await response.json();
206
- return data.choices[0].message.content;
218
+ const msg = data.choices[0].message;
219
+ // Some OpenAI-compatible providers return reasoning output in `reasoning_content`.
220
+ return msg.content || msg.reasoning_content || '';
207
221
  }
208
222
 
209
223
  /**
@@ -325,6 +339,9 @@ export default async function summarize(args) {
325
339
  console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
326
340
  console.log(' OPENAI_BASE_URL # OpenAI-compatible base URL (e.g. https://api.openai.com/v1)');
327
341
  console.log(' OPENAI_ENDPOINT # Full OpenAI-compatible endpoint (overrides OPENAI_BASE_URL)');
342
+ console.log(' AI_MODEL # Universal model override (applies to all providers)');
343
+ console.log(' OPENAI_MODEL # OpenAI-only model override');
344
+ console.log(' ANTHROPIC_MODEL # Anthropic-only model override');
328
345
  console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
329
346
  console.log('\nConfiguration (.shit-logs/config.json):');
330
347
  console.log(` {"provider": "openai", "model": "gpt-4o-mini", "openai_base_url": "https://api.openai.com/v1"}`);
package/lib/webhook.js CHANGED
@@ -28,7 +28,11 @@ export function loadWebhookConfig(projectRoot) {
28
28
  }
29
29
 
30
30
  // Resolve: process.env > config.json env > config.json webhooks field
31
- const env = (key) => process.env[key] || configEnv[key] || '';
31
+ const env = (key) => {
32
+ if (key in process.env) return process.env[key];
33
+ if (key in configEnv) return configEnv[key];
34
+ return '';
35
+ };
32
36
 
33
37
  const url = env('SHIT_WEBHOOK_URL') || fileConfig.url;
34
38
  if (!url) return null;