vibeglish 0.1.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.
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bash
2
+ # VibeGlish capture hook — silently captures Claude Code prompts
3
+ # Must complete in < 50ms. No network requests. Silent on failure.
4
+
5
+ set -euo pipefail
6
+ trap 'exit 0' ERR
7
+
8
+ VIBEGLISH_DIR="${HOME}/.vibeglish"
9
+ RAW_DIR="${VIBEGLISH_DIR}/raw"
10
+ ERROR_LOG="${VIBEGLISH_DIR}/error.log"
11
+
12
+ # Read stdin
13
+ INPUT=$(cat 2>/dev/null) || exit 0
14
+
15
+ # Validate JSON and extract fields
16
+ if ! command -v jq &>/dev/null; then
17
+ echo "$(date -Iseconds) jq not installed" >> "${ERROR_LOG}" 2>/dev/null
18
+ exit 0
19
+ fi
20
+
21
+ PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null) || exit 0
22
+ [ -z "$PROMPT" ] && exit 0
23
+
24
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null) || true
25
+ CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || true
26
+ PROJECT=$(basename "$CWD" 2>/dev/null) || PROJECT="unknown"
27
+
28
+ # --- Filtering ---
29
+
30
+ # Skip slash commands
31
+ [[ "$PROMPT" == /* ]] && exit 0
32
+
33
+ # Skip pure file paths
34
+ if [[ "$PROMPT" =~ ^[/~][a-zA-Z0-9_./-]+$ ]]; then
35
+ exit 0
36
+ fi
37
+
38
+ # Skip if no ASCII letters (pure Chinese / symbols)
39
+ if ! echo "$PROMPT" | grep -q '[a-zA-Z]'; then
40
+ exit 0
41
+ fi
42
+
43
+ # Word count check (< 4 words = skip)
44
+ WORD_COUNT=$(echo "$PROMPT" | wc -w | tr -d ' ')
45
+ [ "$WORD_COUNT" -lt 4 ] && exit 0
46
+
47
+ # Skip if code blocks dominate (> 70%)
48
+ TOTAL_LINES=$(echo "$PROMPT" | wc -l | tr -d ' ')
49
+ if [ "$TOTAL_LINES" -gt 0 ]; then
50
+ CODE_LINES=$(echo "$PROMPT" | awk '
51
+ /^```/ { in_code = !in_code; next }
52
+ in_code { count++ }
53
+ /^ / && !in_code { count++ }
54
+ END { print count+0 }
55
+ ')
56
+ if [ "$TOTAL_LINES" -gt 2 ]; then
57
+ RATIO=$((CODE_LINES * 100 / TOTAL_LINES))
58
+ [ "$RATIO" -gt 70 ] && exit 0
59
+ fi
60
+ fi
61
+
62
+ # Skip duplicate (same session, same prompt hash)
63
+ PROMPT_HASH=$(echo -n "$PROMPT" | md5sum 2>/dev/null | cut -d' ' -f1 || echo -n "$PROMPT" | md5 2>/dev/null)
64
+ LAST_FILE="/tmp/.vibeglish-last-${SESSION_ID}"
65
+ if [ -f "$LAST_FILE" ]; then
66
+ LAST_HASH=$(cat "$LAST_FILE" 2>/dev/null) || true
67
+ [ "$PROMPT_HASH" = "$LAST_HASH" ] && exit 0
68
+ fi
69
+ echo -n "$PROMPT_HASH" > "$LAST_FILE" 2>/dev/null || true
70
+
71
+ # --- Sanitization ---
72
+
73
+ # Redact obvious secrets
74
+ PROMPT=$(echo "$PROMPT" | sed -E \
75
+ -e 's/sk-[a-zA-Z0-9]{20,}/[REDACTED]/g' \
76
+ -e 's/ghp_[a-zA-Z0-9]{20,}/[REDACTED]/g' \
77
+ -e 's/gho_[a-zA-Z0-9]{20,}/[REDACTED]/g' \
78
+ -e 's/AKIA[A-Z0-9]{16}/[REDACTED]/g' \
79
+ -e 's/xoxb-[a-zA-Z0-9-]{20,}/[REDACTED]/g' \
80
+ )
81
+
82
+ # --- Write ---
83
+
84
+ CHAR_COUNT=${#PROMPT}
85
+ DATE=$(date +%Y-%m-%d)
86
+ TS=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z)
87
+ TARGET="${RAW_DIR}/${DATE}.jsonl"
88
+
89
+ mkdir -p "$RAW_DIR" 2>/dev/null || exit 0
90
+
91
+ JSON_LINE=$(jq -cn \
92
+ --arg ts "$TS" \
93
+ --arg sid "$SESSION_ID" \
94
+ --arg proj "$PROJECT" \
95
+ --arg prompt "$PROMPT" \
96
+ --argjson cc "$CHAR_COUNT" \
97
+ --argjson wc "$WORD_COUNT" \
98
+ '{ts:$ts, session_id:$sid, project:$proj, prompt:$prompt, char_count:$cc, word_count:$wc}' \
99
+ 2>/dev/null) || exit 0
100
+
101
+ # Atomic append with lock (use mkdir as cross-platform lock)
102
+ LOCKDIR="/tmp/.vibeglish-raw.lock"
103
+ if mkdir "$LOCKDIR" 2>/dev/null; then
104
+ echo "$JSON_LINE" >> "$TARGET" 2>/dev/null || true
105
+ rmdir "$LOCKDIR" 2>/dev/null || true
106
+ else
107
+ # Lock held by another process, try without lock (append is mostly atomic for small writes)
108
+ echo "$JSON_LINE" >> "$TARGET" 2>/dev/null || true
109
+ fi
110
+
111
+ exit 0
@@ -0,0 +1,185 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { REVIEW_SYSTEM_PROMPT } from '../constants.mjs';
3
+ import { readJSON } from '../utils.mjs';
4
+ import { CONFIG_PATH, DEFAULT_CONFIG } from '../constants.mjs';
5
+
6
+ export async function reviewBatch(entries, config = {}) {
7
+ const cfg = { ...DEFAULT_CONFIG, ...readJSON(CONFIG_PATH), ...config };
8
+
9
+ // Prepare entries with IDs
10
+ const prepared = entries.map((e) => {
11
+ const ts = e.ts || new Date().toISOString();
12
+ const timePart = ts.replace(/[-:T]/g, '').slice(8, 14);
13
+ const rand = Math.random().toString(16).slice(2, 6);
14
+ return {
15
+ ...e,
16
+ id: e.id || `${ts.slice(0, 10).replace(/-/g, '')}-${timePart}-${rand}`,
17
+ };
18
+ });
19
+
20
+ // Truncate long prompts
21
+ for (const entry of prepared) {
22
+ if (entry.prompt.length > 2000) {
23
+ const words = entry.prompt.split(/\s+/).slice(0, 500);
24
+ entry.prompt = words.join(' ');
25
+ entry.truncated = true;
26
+ }
27
+ }
28
+
29
+ // Split into batches
30
+ const batchSize = cfg.batch_size || 20;
31
+ const batches = [];
32
+ for (let i = 0; i < prepared.length; i += batchSize) {
33
+ batches.push(prepared.slice(i, i + batchSize));
34
+ }
35
+
36
+ // Process batches sequentially (claude CLI has subscription rate limits)
37
+ const results = [];
38
+ for (let i = 0; i < batches.length; i++) {
39
+ process.stdout.write(` Reviewing batch ${i + 1}/${batches.length}...\r`);
40
+ const batchResult = await callClaude(batches[i]);
41
+ results.push(batchResult);
42
+ }
43
+
44
+ // Flatten and merge results
45
+ const allResults = [];
46
+ for (let i = 0; i < batches.length; i++) {
47
+ const batch = batches[i];
48
+ const batchResults = results[i] || [];
49
+
50
+ for (let j = 0; j < batch.length; j++) {
51
+ const entry = batch[j];
52
+ const result = batchResults[j] || { skip: true, review_status: 'failed' };
53
+
54
+ if (result.skip) {
55
+ allResults.push({
56
+ id: entry.id,
57
+ ts: entry.ts,
58
+ project: entry.project,
59
+ original: entry.prompt,
60
+ skipped: true,
61
+ ...(entry.truncated ? { truncated: true } : {}),
62
+ ...(result.review_status ? { review_status: result.review_status } : {}),
63
+ });
64
+ } else {
65
+ allResults.push({
66
+ id: entry.id,
67
+ ts: entry.ts,
68
+ project: entry.project,
69
+ original: entry.prompt,
70
+ corrected: result.corrected || entry.prompt,
71
+ score: result.score ?? 0,
72
+ is_clean: result.is_clean ?? false,
73
+ issues: result.issues || [],
74
+ ...(entry.truncated ? { truncated: true } : {}),
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ return allResults;
81
+ }
82
+
83
+ async function callClaude(batch, retries = 3) {
84
+ const payload = batch.map(e => ({ id: e.id, prompt: e.prompt }));
85
+ const userMessage = JSON.stringify(payload);
86
+
87
+ for (let attempt = 0; attempt < retries; attempt++) {
88
+ try {
89
+ const text = await execClaude(REVIEW_SYSTEM_PROMPT, userMessage);
90
+ return parseResponse(text, batch.length);
91
+ } catch (err) {
92
+ if (attempt < retries - 1) {
93
+ const delay = Math.pow(2, attempt) * 1000;
94
+ await new Promise(r => setTimeout(r, delay));
95
+ continue;
96
+ }
97
+ console.error(`\n claude call failed after ${retries} retries: ${err.message}`);
98
+ return batch.map(() => ({ skip: true, review_status: 'failed' }));
99
+ }
100
+ }
101
+ }
102
+
103
+ function execClaude(systemPrompt, userMessage) {
104
+ return new Promise((resolve, reject) => {
105
+ const args = [
106
+ '-p', userMessage,
107
+ '--system-prompt', systemPrompt,
108
+ '--output-format', 'text',
109
+ ];
110
+
111
+ const child = execFile('claude', args, {
112
+ maxBuffer: 10 * 1024 * 1024,
113
+ timeout: 120_000,
114
+ }, (err, stdout, stderr) => {
115
+ if (err) {
116
+ reject(new Error(`claude CLI error: ${err.message}${stderr ? `\n${stderr}` : ''}`));
117
+ return;
118
+ }
119
+ resolve(stdout);
120
+ });
121
+ });
122
+ }
123
+
124
+ function parseResponse(text, expectedLength) {
125
+ // Try parsing as-is
126
+ let parsed = tryParse(text);
127
+ if (parsed) return validateLength(parsed, expectedLength);
128
+
129
+ // Strip markdown fences
130
+ const stripped = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '');
131
+ parsed = tryParse(stripped);
132
+ if (parsed) return validateLength(parsed, expectedLength);
133
+
134
+ // Try fixing truncated JSON by closing brackets
135
+ const fixed = stripped + (stripped.includes('[') && !stripped.endsWith(']') ? ']' : '');
136
+ parsed = tryParse(fixed);
137
+ if (parsed) return validateLength(parsed, expectedLength);
138
+
139
+ console.error(' Failed to parse response from claude');
140
+ return Array.from({ length: expectedLength }, () => ({ skip: true, review_status: 'parse_failed' }));
141
+ }
142
+
143
+ function tryParse(text) {
144
+ try {
145
+ const result = JSON.parse(text);
146
+ return Array.isArray(result) ? result : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function validateLength(parsed, expectedLength) {
153
+ if (parsed.length === expectedLength) return parsed;
154
+ if (parsed.length < expectedLength) {
155
+ while (parsed.length < expectedLength) {
156
+ parsed.push({ skip: true, review_status: 'mismatch' });
157
+ }
158
+ }
159
+ return parsed.slice(0, expectedLength);
160
+ }
161
+
162
+ export function computeStats(entries) {
163
+ const reviewed = entries.filter(e => !e.skipped);
164
+ const skipped = entries.filter(e => e.skipped);
165
+
166
+ const issueBreakdown = { grammar: 0, vocabulary: 0, spelling: 0, punctuation: 0, style: 0, word_order: 0 };
167
+ let totalScore = 0;
168
+
169
+ for (const entry of reviewed) {
170
+ totalScore += entry.score || 0;
171
+ for (const issue of entry.issues || []) {
172
+ if (issueBreakdown[issue.type] !== undefined) {
173
+ issueBreakdown[issue.type]++;
174
+ }
175
+ }
176
+ }
177
+
178
+ return {
179
+ total_captured: entries.length,
180
+ total_reviewed: reviewed.length,
181
+ skipped: skipped.length,
182
+ avg_score: reviewed.length > 0 ? Math.round((totalScore / reviewed.length) * 10) / 10 : 0,
183
+ issue_breakdown: issueBreakdown,
184
+ };
185
+ }
package/src/utils.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ export function readJSON(path) {
5
+ try {
6
+ return JSON.parse(readFileSync(path, 'utf-8'));
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ export function writeJSON(path, data) {
13
+ mkdirSync(dirname(path), { recursive: true });
14
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
15
+ }
16
+
17
+ export function readJSONL(path) {
18
+ try {
19
+ const lines = readFileSync(path, 'utf-8').trim().split('\n').filter(Boolean);
20
+ return lines.map(line => JSON.parse(line));
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ export function appendJSONL(path, obj) {
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ appendFileSync(path, JSON.stringify(obj) + '\n');
29
+ }
30
+
31
+ export function listFiles(dir, ext) {
32
+ try {
33
+ return readdirSync(dir).filter(f => f.endsWith(ext)).sort();
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ export function formatDate(date = new Date()) {
40
+ const y = date.getFullYear();
41
+ const m = String(date.getMonth() + 1).padStart(2, '0');
42
+ const d = String(date.getDate()).padStart(2, '0');
43
+ return `${y}-${m}-${d}`;
44
+ }
45
+
46
+ export function parseDate(str) {
47
+ const [y, m, d] = str.split('-').map(Number);
48
+ return new Date(y, m - 1, d);
49
+ }
50
+
51
+ export function dateRange(from, to) {
52
+ const dates = [];
53
+ const current = parseDate(from);
54
+ const end = parseDate(to);
55
+ while (current <= end) {
56
+ dates.push(formatDate(current));
57
+ current.setDate(current.getDate() + 1);
58
+ }
59
+ return dates;
60
+ }
61
+
62
+ export function ensureDir(dir) {
63
+ mkdirSync(dir, { recursive: true });
64
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { checkAchievements } from '../src/achievements.mjs';
4
+
5
+ function makeDay(date, entries = [], overrides = {}) {
6
+ const reviewed = entries.filter(e => !e.skipped);
7
+ const issueBreakdown = { grammar: 0, vocabulary: 0, spelling: 0, punctuation: 0, style: 0, word_order: 0 };
8
+ for (const e of reviewed) {
9
+ for (const i of e.issues || []) {
10
+ if (issueBreakdown[i.type] !== undefined) issueBreakdown[i.type]++;
11
+ }
12
+ }
13
+ return {
14
+ date,
15
+ stats: {
16
+ total_captured: entries.length,
17
+ total_reviewed: reviewed.length,
18
+ skipped: entries.length - reviewed.length,
19
+ avg_score: reviewed.length > 0 ? reviewed.reduce((s, e) => s + (e.score || 0), 0) / reviewed.length : 0,
20
+ issue_breakdown: issueBreakdown,
21
+ ...overrides,
22
+ },
23
+ entries: reviewed,
24
+ };
25
+ }
26
+
27
+ function makeEntry(score, issues = [], opts = {}) {
28
+ return {
29
+ id: Math.random().toString(36).slice(2),
30
+ ts: opts.ts || '2026-04-01T10:00:00+08:00',
31
+ project: opts.project || 'test',
32
+ original: 'test prompt',
33
+ corrected: 'test prompt.',
34
+ score,
35
+ is_clean: score >= 95,
36
+ issues,
37
+ };
38
+ }
39
+
40
+ describe('achievements', () => {
41
+ it('unlocks First Blood with one reviewed day', () => {
42
+ const data = [makeDay('2026-04-01', [makeEntry(70)])];
43
+ const results = checkAchievements(data);
44
+ const fb = results.find(a => a.id === 'first_blood');
45
+ assert.ok(fb.unlocked);
46
+ });
47
+
48
+ it('does not unlock First Blood with no data', () => {
49
+ const data = [makeDay('2026-04-01', [])];
50
+ const results = checkAchievements(data);
51
+ const fb = results.find(a => a.id === 'first_blood');
52
+ assert.ok(!fb.unlocked);
53
+ });
54
+
55
+ it('unlocks Century Club at 100 entries', () => {
56
+ const entries = Array.from({ length: 100 }, () => makeEntry(60));
57
+ const data = [makeDay('2026-04-01', entries)];
58
+ const results = checkAchievements(data);
59
+ const cc = results.find(a => a.id === 'century_club');
60
+ assert.ok(cc.unlocked);
61
+ });
62
+
63
+ it('unlocks Clean Coder when >50% clean', () => {
64
+ const entries = [
65
+ makeEntry(96), makeEntry(97), makeEntry(98), // 3 clean
66
+ makeEntry(60), makeEntry(50), // 2 not clean
67
+ ];
68
+ const data = [makeDay('2026-04-01', entries)];
69
+ const results = checkAchievements(data);
70
+ const cc = results.find(a => a.id === 'clean_coder');
71
+ assert.ok(cc.unlocked);
72
+ });
73
+
74
+ it('unlocks Perfect Day when all scores >= 90', () => {
75
+ const entries = [makeEntry(92), makeEntry(95), makeEntry(90)];
76
+ const data = [makeDay('2026-04-01', entries)];
77
+ const results = checkAchievements(data);
78
+ const pd = results.find(a => a.id === 'perfect_day');
79
+ assert.ok(pd.unlocked);
80
+ });
81
+
82
+ it('does not unlock Perfect Day with one low score', () => {
83
+ const entries = [makeEntry(92), makeEntry(50), makeEntry(90)];
84
+ const data = [makeDay('2026-04-01', entries)];
85
+ const results = checkAchievements(data);
86
+ const pd = results.find(a => a.id === 'perfect_day');
87
+ assert.ok(!pd.unlocked);
88
+ });
89
+
90
+ it('unlocks Night Owl for late night prompts', () => {
91
+ const entries = [makeEntry(70, [], { ts: '2026-04-01T02:30:00+08:00' })];
92
+ const data = [makeDay('2026-04-01', entries)];
93
+ const results = checkAchievements(data);
94
+ const no = results.find(a => a.id === 'night_owl');
95
+ assert.ok(no.unlocked);
96
+ });
97
+
98
+ it('unlocks Streak Master x7 with 7 consecutive days', () => {
99
+ const data = [];
100
+ for (let i = 0; i < 7; i++) {
101
+ const d = `2026-04-${String(i + 1).padStart(2, '0')}`;
102
+ data.push(makeDay(d, [makeEntry(60)]));
103
+ }
104
+ const results = checkAchievements(data);
105
+ const sm = results.find(a => a.id === 'streak_7');
106
+ assert.ok(sm.unlocked);
107
+ });
108
+
109
+ it('does not unlock Streak 7 with gap', () => {
110
+ const dates = ['2026-04-01', '2026-04-02', '2026-04-03', '2026-04-05', '2026-04-06', '2026-04-07', '2026-04-08'];
111
+ const data = dates.map(d => makeDay(d, [makeEntry(60)]));
112
+ const results = checkAchievements(data);
113
+ const sm = results.find(a => a.id === 'streak_7');
114
+ assert.ok(!sm.unlocked);
115
+ });
116
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execSync } from 'node:child_process';
4
+ import { mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+
8
+ const CAPTURE_SCRIPT = join(import.meta.dirname, '../src/hooks/capture.sh');
9
+ const RAW_DIR = join(homedir(), '.vibeglish', 'raw');
10
+
11
+ function capture(input) {
12
+ const json = JSON.stringify(input);
13
+ try {
14
+ execSync(`echo '${json.replace(/'/g, "'\\''")}' | bash "${CAPTURE_SCRIPT}"`, {
15
+ timeout: 5000,
16
+ stdio: 'pipe',
17
+ });
18
+ } catch (err) {
19
+ // capture.sh should never throw
20
+ return err;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ function todayFile() {
26
+ const d = new Date();
27
+ const date = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
28
+ return join(RAW_DIR, `${date}.jsonl`);
29
+ }
30
+
31
+ function lineCount() {
32
+ const f = todayFile();
33
+ if (!existsSync(f)) return 0;
34
+ return readFileSync(f, 'utf-8').trim().split('\n').filter(Boolean).length;
35
+ }
36
+
37
+ describe('capture.sh', () => {
38
+ it('captures a valid prompt', () => {
39
+ const before = lineCount();
40
+ capture({
41
+ session_id: 'test-capture-valid',
42
+ prompt: 'This is a valid test prompt for capture testing',
43
+ cwd: '/tmp/test-project',
44
+ });
45
+ assert.equal(lineCount(), before + 1);
46
+ });
47
+
48
+ it('filters slash commands', () => {
49
+ const before = lineCount();
50
+ capture({ session_id: 'test-slash', prompt: '/help', cwd: '/tmp' });
51
+ assert.equal(lineCount(), before);
52
+ });
53
+
54
+ it('filters short prompts (< 4 words)', () => {
55
+ const before = lineCount();
56
+ capture({ session_id: 'test-short', prompt: 'yes ok', cwd: '/tmp' });
57
+ assert.equal(lineCount(), before);
58
+ });
59
+
60
+ it('filters pure Chinese prompts', () => {
61
+ const before = lineCount();
62
+ capture({ session_id: 'test-chinese', prompt: '这是纯中文消息', cwd: '/tmp' });
63
+ assert.equal(lineCount(), before);
64
+ });
65
+
66
+ it('filters file paths', () => {
67
+ const before = lineCount();
68
+ capture({ session_id: 'test-path', prompt: '/Users/test/project/file.ts', cwd: '/tmp' });
69
+ assert.equal(lineCount(), before);
70
+ });
71
+
72
+ it('filters duplicate prompts in same session', () => {
73
+ const before = lineCount();
74
+ capture({ session_id: 'test-dup-session', prompt: 'this is a duplicate test prompt for checking', cwd: '/tmp' });
75
+ const after1 = lineCount();
76
+ assert.equal(after1, before + 1);
77
+ capture({ session_id: 'test-dup-session', prompt: 'this is a duplicate test prompt for checking', cwd: '/tmp' });
78
+ assert.equal(lineCount(), after1); // no change
79
+ });
80
+
81
+ it('redacts API keys', () => {
82
+ capture({
83
+ session_id: 'test-redact',
84
+ prompt: 'use this key sk-abcdefghijklmnopqrstuvwxyz to access the api',
85
+ cwd: '/tmp/redact-test',
86
+ });
87
+ const f = todayFile();
88
+ const last = readFileSync(f, 'utf-8').trim().split('\n').pop();
89
+ const data = JSON.parse(last);
90
+ assert.ok(data.prompt.includes('[REDACTED]'));
91
+ assert.ok(!data.prompt.includes('sk-abcdef'));
92
+ });
93
+
94
+ it('exits 0 on invalid JSON input', () => {
95
+ const err = execSync(`echo 'not json' | bash "${CAPTURE_SCRIPT}" 2>&1; echo $?`, { encoding: 'utf-8' });
96
+ assert.ok(err.trim().endsWith('0'));
97
+ });
98
+
99
+ it('keeps mixed Chinese-English prompts', () => {
100
+ const before = lineCount();
101
+ capture({
102
+ session_id: 'test-mixed',
103
+ prompt: '帮我 fix this bug in the authentication module',
104
+ cwd: '/tmp/mixed',
105
+ });
106
+ assert.equal(lineCount(), before + 1);
107
+ });
108
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { computeStats } from '../src/review/engine.mjs';
4
+
5
+ describe('computeStats', () => {
6
+ it('computes correct stats for mixed entries', () => {
7
+ const entries = [
8
+ {
9
+ id: '1', original: 'test', corrected: 'test!', score: 80, is_clean: false,
10
+ issues: [{ type: 'grammar', original: 'a', corrected: 'b', rule: 'test' }],
11
+ },
12
+ {
13
+ id: '2', original: 'good', corrected: 'good', score: 95, is_clean: true,
14
+ issues: [],
15
+ },
16
+ {
17
+ id: '3', original: 'code', skipped: true,
18
+ },
19
+ {
20
+ id: '4', original: 'bad', corrected: 'well', score: 50, is_clean: false,
21
+ issues: [
22
+ { type: 'grammar', original: 'x', corrected: 'y', rule: 'r1' },
23
+ { type: 'vocabulary', original: 'a', corrected: 'b', rule: 'r2' },
24
+ ],
25
+ },
26
+ ];
27
+
28
+ const stats = computeStats(entries);
29
+ assert.equal(stats.total_captured, 4);
30
+ assert.equal(stats.total_reviewed, 3);
31
+ assert.equal(stats.skipped, 1);
32
+ assert.equal(stats.avg_score, 75); // (80+95+50)/3 = 75
33
+ assert.equal(stats.issue_breakdown.grammar, 2);
34
+ assert.equal(stats.issue_breakdown.vocabulary, 1);
35
+ assert.equal(stats.issue_breakdown.spelling, 0);
36
+ });
37
+
38
+ it('handles empty entries', () => {
39
+ const stats = computeStats([]);
40
+ assert.equal(stats.total_captured, 0);
41
+ assert.equal(stats.total_reviewed, 0);
42
+ assert.equal(stats.avg_score, 0);
43
+ });
44
+
45
+ it('handles all skipped entries', () => {
46
+ const entries = [
47
+ { id: '1', original: 'code', skipped: true },
48
+ { id: '2', original: 'path', skipped: true },
49
+ ];
50
+ const stats = computeStats(entries);
51
+ assert.equal(stats.total_captured, 2);
52
+ assert.equal(stats.total_reviewed, 0);
53
+ assert.equal(stats.skipped, 2);
54
+ assert.equal(stats.avg_score, 0);
55
+ });
56
+ });