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.
- package/CLAUDE.md +22 -0
- package/README.md +82 -0
- package/package.json +23 -0
- package/src/achievements.mjs +240 -0
- package/src/cli/commands/export.mjs +71 -0
- package/src/cli/commands/hook-test.mjs +60 -0
- package/src/cli/commands/init.mjs +155 -0
- package/src/cli/commands/report.mjs +141 -0
- package/src/cli/commands/review.mjs +90 -0
- package/src/cli/commands/serve.mjs +102 -0
- package/src/cli/commands/status.mjs +53 -0
- package/src/cli/commands/uninstall.mjs +43 -0
- package/src/cli/index.mjs +63 -0
- package/src/constants.mjs +52 -0
- package/src/dashboard/index.html +710 -0
- package/src/hooks/capture.sh +111 -0
- package/src/review/engine.mjs +185 -0
- package/src/utils.mjs +64 -0
- package/test/achievements.test.mjs +116 -0
- package/test/capture.test.mjs +108 -0
- package/test/engine.test.mjs +56 -0
- package/vibeglish-prd.md +591 -0
|
@@ -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
|
+
});
|