@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 +17 -0
- package/bin/shit.js +17 -6
- package/lib/review.js +728 -0
- package/lib/summarize.js +37 -13
- package/package.json +3 -4
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
const
|
|
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.
|
|
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": "
|
|
35
|
+
"license": "UNLICENSED",
|
|
37
36
|
"dependencies": {},
|
|
38
37
|
"devDependencies": {}
|
|
39
38
|
}
|