vibeusage 0.4.0 → 0.6.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/package.json +1 -1
- package/src/commands/doctor.js +196 -2
- package/src/commands/init.js +1 -1
- package/src/commands/sync.js +29 -1
- package/src/lib/integrations/claude.js +1 -1
- package/src/lib/integrations/opencode.js +2 -2
- package/src/lib/ops/audit-claude.js +35 -0
- package/src/lib/ops/audit-source.js +364 -0
- package/src/lib/ops/sources/_rollout-base.js +203 -0
- package/src/lib/ops/sources/claude.js +52 -0
- package/src/lib/ops/sources/codex.js +10 -0
- package/src/lib/ops/sources/every-code.js +10 -0
- package/src/lib/ops/sources/gemini.js +154 -0
- package/src/lib/ops/sources/hermes.js +69 -0
- package/src/lib/ops/sources/kimi.js +105 -0
- package/src/lib/ops/sources/openclaw.js +64 -0
- package/src/lib/ops/sources/opencode.js +100 -0
- package/src/lib/rollout.js +65 -7
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -6,6 +6,13 @@ const { resolveTrackerPaths } = require("../lib/tracker-paths");
|
|
|
6
6
|
const { collectTrackerDiagnostics } = require("../lib/diagnostics");
|
|
7
7
|
const { resolveRuntimeConfig } = require("../lib/runtime-config");
|
|
8
8
|
const { buildDoctorReport } = require("../lib/doctor");
|
|
9
|
+
const {
|
|
10
|
+
DEFAULT_DAYS: AUDIT_DEFAULT_DAYS,
|
|
11
|
+
DEFAULT_THRESHOLD_PCT: AUDIT_DEFAULT_THRESHOLD,
|
|
12
|
+
runSourceAudit,
|
|
13
|
+
getStrategy: getAuditStrategy,
|
|
14
|
+
listRegisteredSources,
|
|
15
|
+
} = require("../lib/ops/audit-source");
|
|
9
16
|
|
|
10
17
|
async function cmdDoctor(argv = []) {
|
|
11
18
|
const opts = parseArgs(argv);
|
|
@@ -16,6 +23,11 @@ async function cmdDoctor(argv = []) {
|
|
|
16
23
|
const configStatus = await readJsonStrict(configPath);
|
|
17
24
|
const config =
|
|
18
25
|
configStatus.status === "ok" && isPlainObject(configStatus.value) ? configStatus.value : {};
|
|
26
|
+
|
|
27
|
+
if (opts.auditTokens) {
|
|
28
|
+
return runAuditTokens({ opts, config });
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
const runtime = resolveRuntimeConfig({
|
|
20
32
|
cli: { baseUrl: opts.baseUrl },
|
|
21
33
|
config,
|
|
@@ -56,14 +68,192 @@ async function cmdDoctor(argv = []) {
|
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
71
|
+
function runAuditTokens({ opts, config }) {
|
|
72
|
+
const sourceId = opts.auditSource || "claude";
|
|
73
|
+
if (sourceId === "all") {
|
|
74
|
+
return runAuditTokensAll({ opts, config });
|
|
75
|
+
}
|
|
76
|
+
const strategy = getAuditStrategy(sourceId);
|
|
77
|
+
if (!strategy) {
|
|
78
|
+
const registered = ["all", ...listRegisteredSources()].join(", ");
|
|
79
|
+
const message = `unknown --source '${sourceId}'. Registered: ${registered}.`;
|
|
80
|
+
if (opts.json) {
|
|
81
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: "unknown-source", message })}\n`);
|
|
82
|
+
} else {
|
|
83
|
+
process.stderr.write(`doctor --audit-tokens: ${message}\n`);
|
|
84
|
+
}
|
|
85
|
+
process.exitCode = 2;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = runSourceAudit({
|
|
92
|
+
strategy,
|
|
93
|
+
days: opts.auditDays,
|
|
94
|
+
threshold: opts.auditThreshold,
|
|
95
|
+
deviceId: config.deviceId || null,
|
|
96
|
+
dbJsonPath: opts.auditDbJson || null,
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const message = err?.message || String(err);
|
|
100
|
+
if (opts.json) {
|
|
101
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: "audit-error", message })}\n`);
|
|
102
|
+
} else {
|
|
103
|
+
process.stderr.write(`doctor --audit-tokens: ${message}\n`);
|
|
104
|
+
}
|
|
105
|
+
process.exitCode = 2;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (opts.json) {
|
|
110
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
111
|
+
if (!result.ok) process.exitCode = 2;
|
|
112
|
+
else if (result.exceedsThreshold) process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
process.stderr.write(`doctor --audit-tokens: ${result.message || result.error}\n`);
|
|
118
|
+
process.exitCode = 2;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.stdout.write(
|
|
123
|
+
`${result.displayName || result.source} token audit (last ${result.days} days, window >= ${result.windowStartIso})\n`,
|
|
124
|
+
);
|
|
125
|
+
process.stdout.write(
|
|
126
|
+
`Scanned ${result.filesScanned} .jsonl files, ${formatNumber(result.usageLines)} usage lines, ` +
|
|
127
|
+
`${formatNumber(result.uniqueMessages)} unique message.ids, ` +
|
|
128
|
+
`${formatNumber(result.duplicatesSkipped)} duplicates skipped.\n\n`,
|
|
129
|
+
);
|
|
130
|
+
process.stdout.write(
|
|
131
|
+
`${"day".padEnd(12)} ${"truth".padStart(15)} ${"db".padStart(15)} ${"ratio".padStart(8)} ${"drift".padStart(8)}\n`,
|
|
132
|
+
);
|
|
133
|
+
process.stdout.write(`${"-".repeat(66)}\n`);
|
|
134
|
+
for (const r of result.rows) {
|
|
135
|
+
const ratio = r.ratio == null ? "—" : `${r.ratio.toFixed(3)}x`;
|
|
136
|
+
const drift = r.driftPct == null ? "—" : `${r.driftPct.toFixed(1)}%`;
|
|
137
|
+
process.stdout.write(
|
|
138
|
+
`${r.day.padEnd(12)} ${formatNumber(r.truth).padStart(15)} ` +
|
|
139
|
+
`${formatNumber(r.db).padStart(15)} ${ratio.padStart(8)} ${drift.padStart(8)}\n`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
process.stdout.write(
|
|
143
|
+
`\nMax drift: ${result.maxDriftPct.toFixed(2)}% (threshold ${result.thresholdPct}%).\n`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (result.exceedsThreshold) {
|
|
147
|
+
process.stderr.write(
|
|
148
|
+
`\nFAIL drift ${result.maxDriftPct.toFixed(2)}% exceeds threshold ${result.thresholdPct}%.\n` +
|
|
149
|
+
`If vibeusage >= 0.5.0, scrub the Claude/OpenCode cursor block in\n` +
|
|
150
|
+
`~/.vibeusage/tracker/cursors.json and rerun \`vibeusage sync --drain\`.\n`,
|
|
151
|
+
);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runAuditTokensAll({ opts, config }) {
|
|
157
|
+
const ids = listRegisteredSources();
|
|
158
|
+
const perSource = [];
|
|
159
|
+
let anyExceeds = false;
|
|
160
|
+
let anyHardError = false;
|
|
161
|
+
|
|
162
|
+
for (const id of ids) {
|
|
163
|
+
const strategy = getAuditStrategy(id);
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = runSourceAudit({
|
|
167
|
+
strategy,
|
|
168
|
+
days: opts.auditDays,
|
|
169
|
+
threshold: opts.auditThreshold,
|
|
170
|
+
deviceId: config.deviceId || null,
|
|
171
|
+
// --db-json is source-specific so we skip it under --source=all; the
|
|
172
|
+
// only sane paths are insforge auto-mode or no-local-sessions.
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
result = { ok: false, source: id, error: "audit-error", message: err?.message || String(err) };
|
|
176
|
+
anyHardError = true;
|
|
177
|
+
}
|
|
178
|
+
if (result.ok && result.exceedsThreshold) anyExceeds = true;
|
|
179
|
+
// no-local-sessions is informational, not a hard error; other non-ok states
|
|
180
|
+
// (cannot-resolve-user-id, insforge-db-query-failed, etc.) count as errors.
|
|
181
|
+
if (!result.ok && result.error !== "no-local-sessions") anyHardError = true;
|
|
182
|
+
perSource.push(result);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (opts.json) {
|
|
186
|
+
process.stdout.write(
|
|
187
|
+
`${JSON.stringify({ ok: !anyHardError, thresholdPct: opts.auditThreshold, days: opts.auditDays, sources: perSource }, null, 2)}\n`,
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
process.stdout.write(
|
|
191
|
+
`Token audit across all registered sources (last ${opts.auditDays} days)\n\n`,
|
|
192
|
+
);
|
|
193
|
+
process.stdout.write(
|
|
194
|
+
`${"source".padEnd(12)} ${"status".padEnd(22)} ${"max drift".padStart(10)} ${"files".padStart(6)} ${"events".padStart(6)}\n`,
|
|
195
|
+
);
|
|
196
|
+
process.stdout.write(`${"-".repeat(70)}\n`);
|
|
197
|
+
for (const r of perSource) {
|
|
198
|
+
if (r.ok) {
|
|
199
|
+
const statusText = r.exceedsThreshold
|
|
200
|
+
? `FAIL > ${opts.auditThreshold}%`
|
|
201
|
+
: `ok`;
|
|
202
|
+
const drift = `${r.maxDriftPct.toFixed(1)}%`;
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
`${r.source.padEnd(12)} ${statusText.padEnd(22)} ${drift.padStart(10)} ${String(r.filesScanned).padStart(6)} ${String(r.usageLines).padStart(6)}\n`,
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
const statusText =
|
|
208
|
+
r.error === "no-local-sessions" ? "no local sessions" : `ERR ${r.error}`;
|
|
209
|
+
process.stdout.write(
|
|
210
|
+
`${r.source.padEnd(12)} ${statusText.padEnd(22)} ${"—".padStart(10)} ${"—".padStart(6)} ${"—".padStart(6)}\n`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
process.stdout.write(
|
|
215
|
+
`\nThreshold ${opts.auditThreshold}%. ` +
|
|
216
|
+
(anyExceeds
|
|
217
|
+
? `At least one source exceeds threshold — rerun \`vibeusage doctor --audit-tokens --source <id>\` for details.\n`
|
|
218
|
+
: `All sources within threshold.\n`),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (anyHardError) process.exitCode = 2;
|
|
223
|
+
else if (anyExceeds) process.exitCode = 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
59
226
|
function parseArgs(argv) {
|
|
60
|
-
const out = {
|
|
227
|
+
const out = {
|
|
228
|
+
json: false,
|
|
229
|
+
out: null,
|
|
230
|
+
baseUrl: null,
|
|
231
|
+
auditTokens: false,
|
|
232
|
+
auditDays: AUDIT_DEFAULT_DAYS,
|
|
233
|
+
auditThreshold: AUDIT_DEFAULT_THRESHOLD,
|
|
234
|
+
auditDbJson: null,
|
|
235
|
+
auditSource: "claude",
|
|
236
|
+
};
|
|
61
237
|
for (let i = 0; i < argv.length; i += 1) {
|
|
62
238
|
const arg = argv[i];
|
|
63
239
|
if (arg === "--json") out.json = true;
|
|
64
240
|
else if (arg === "--out") out.out = argv[++i] || null;
|
|
65
241
|
else if (arg === "--base-url") out.baseUrl = argv[++i] || null;
|
|
66
|
-
else
|
|
242
|
+
else if (arg === "--audit-tokens") out.auditTokens = true;
|
|
243
|
+
else if (arg === "--days") {
|
|
244
|
+
const n = Number(argv[++i]);
|
|
245
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`--days expects a positive number`);
|
|
246
|
+
out.auditDays = Math.floor(n);
|
|
247
|
+
} else if (arg === "--threshold") {
|
|
248
|
+
const n = Number(argv[++i]);
|
|
249
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`--threshold expects a non-negative number`);
|
|
250
|
+
out.auditThreshold = n;
|
|
251
|
+
} else if (arg === "--db-json") out.auditDbJson = argv[++i] || null;
|
|
252
|
+
else if (arg === "--source") {
|
|
253
|
+
const raw = argv[++i];
|
|
254
|
+
if (!raw) throw new Error(`--source expects a value`);
|
|
255
|
+
out.auditSource = String(raw).trim().toLowerCase();
|
|
256
|
+
} else throw new Error(`Unknown option: ${arg}`);
|
|
67
257
|
}
|
|
68
258
|
return out;
|
|
69
259
|
}
|
|
@@ -89,6 +279,10 @@ function formatCheckLine(check = {}) {
|
|
|
89
279
|
return `- [${status}] ${check.id || "unknown"}${detail}`;
|
|
90
280
|
}
|
|
91
281
|
|
|
282
|
+
function formatNumber(n) {
|
|
283
|
+
return Number(n).toLocaleString("en-US");
|
|
284
|
+
}
|
|
285
|
+
|
|
92
286
|
function isPlainObject(value) {
|
|
93
287
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
94
288
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -190,7 +190,7 @@ function renderWelcome() {
|
|
|
190
190
|
DIVIDER,
|
|
191
191
|
"",
|
|
192
192
|
"This tool will:",
|
|
193
|
-
" - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Kimi,
|
|
193
|
+
" - Analyze your local AI CLI configurations (Codex, Every Code, Claude Code, Gemini, Kimi, Hermes, OpenCode, OpenClaw)",
|
|
194
194
|
" - Set up lightweight hooks to track your flow state",
|
|
195
195
|
" - Link your device to your VibeScore account",
|
|
196
196
|
"",
|
package/src/commands/sync.js
CHANGED
|
@@ -132,7 +132,7 @@ async function cmdSync(argv) {
|
|
|
132
132
|
: { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
133
133
|
|
|
134
134
|
const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
|
|
135
|
-
let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
135
|
+
let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0, dedupSkipped: 0 };
|
|
136
136
|
if (claudeFiles.length > 0) {
|
|
137
137
|
if (progress?.enabled) {
|
|
138
138
|
progress.start(
|
|
@@ -155,6 +155,7 @@ async function cmdSync(argv) {
|
|
|
155
155
|
},
|
|
156
156
|
source: "claude",
|
|
157
157
|
});
|
|
158
|
+
await maybeWarnClaudeDedupSpike({ trackerDir, claudeResult });
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
|
|
@@ -592,6 +593,33 @@ async function parseOpenclawSanitizedLedger({ trackerDir, cursors, queuePath })
|
|
|
592
593
|
};
|
|
593
594
|
}
|
|
594
595
|
|
|
596
|
+
// Claude Code occasionally writes the same assistant message multiple times to
|
|
597
|
+
// its session .jsonl (same message.id, different outer uuid). Parser dedup
|
|
598
|
+
// silently skips the dup rows, but a sudden spike in the dedup-skipped ratio
|
|
599
|
+
// usually means an upstream change that deserves human attention. Emit a
|
|
600
|
+
// debug.jsonl line when the ratio crosses 50% so vibeusage doctor / retro
|
|
601
|
+
// reviewers can find it; intentionally non-blocking.
|
|
602
|
+
async function maybeWarnClaudeDedupSpike({ trackerDir, claudeResult }) {
|
|
603
|
+
const events = Number(claudeResult?.eventsAggregated || 0);
|
|
604
|
+
const skipped = Number(claudeResult?.dedupSkipped || 0);
|
|
605
|
+
const total = events + skipped;
|
|
606
|
+
if (total === 0) return;
|
|
607
|
+
const ratio = skipped / total;
|
|
608
|
+
if (ratio <= 0.5) return;
|
|
609
|
+
const line =
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
ts: new Date().toISOString(),
|
|
612
|
+
event: "claude_dedup_spike",
|
|
613
|
+
source: "claude",
|
|
614
|
+
events_aggregated: events,
|
|
615
|
+
dedup_skipped: skipped,
|
|
616
|
+
ratio: Number(ratio.toFixed(3)),
|
|
617
|
+
hint: "Unusual duplicate rate from ~/.claude/projects jsonl; run `vibeusage doctor --audit-tokens` to compare local vs DB totals.",
|
|
618
|
+
}) + "\n";
|
|
619
|
+
const logPath = path.join(trackerDir, "notify.debug.jsonl");
|
|
620
|
+
await fs.appendFile(logPath, line, "utf8").catch(() => {});
|
|
621
|
+
}
|
|
622
|
+
|
|
595
623
|
async function safeStatSize(p) {
|
|
596
624
|
try {
|
|
597
625
|
const st = await fs.stat(p);
|
|
@@ -3,8 +3,8 @@ const { isDir } = require("./utils");
|
|
|
3
3
|
|
|
4
4
|
module.exports = {
|
|
5
5
|
name: "opencode",
|
|
6
|
-
summaryLabel: "
|
|
7
|
-
statusLabel: "
|
|
6
|
+
summaryLabel: "OpenCode Plugin",
|
|
7
|
+
statusLabel: "OpenCode plugin",
|
|
8
8
|
async probe(ctx) {
|
|
9
9
|
const hasConfigDir = await isDir(ctx.opencode.configDir);
|
|
10
10
|
if (!hasConfigDir) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* audit-claude.js
|
|
5
|
+
*
|
|
6
|
+
* Thin backward-compatible shim over audit-source.js. Retained so existing
|
|
7
|
+
* consumers (`scripts/ops/compare-claude-ground-truth.cjs`, older doctor
|
|
8
|
+
* imports) keep working. New code should import audit-source directly and
|
|
9
|
+
* pass the claude strategy.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
DEFAULT_DAYS,
|
|
14
|
+
DEFAULT_THRESHOLD_PCT,
|
|
15
|
+
runSourceAudit,
|
|
16
|
+
} = require("./audit-source");
|
|
17
|
+
const claudeStrategy = require("./sources/claude");
|
|
18
|
+
|
|
19
|
+
function runAudit(opts = {}) {
|
|
20
|
+
const { projectsDir, ...rest } = opts;
|
|
21
|
+
return runSourceAudit({
|
|
22
|
+
...rest,
|
|
23
|
+
strategy: claudeStrategy,
|
|
24
|
+
// Preserve the legacy `projectsDir` option so existing callers and tests
|
|
25
|
+
// (test/doctor-audit-tokens.test.js) keep working without knowing about
|
|
26
|
+
// the new sessionRootOverride plumbing.
|
|
27
|
+
sessionRootOverride: projectsDir || rest.sessionRootOverride || null,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = {
|
|
32
|
+
DEFAULT_DAYS,
|
|
33
|
+
DEFAULT_THRESHOLD_PCT,
|
|
34
|
+
runAudit,
|
|
35
|
+
};
|