Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ This is the most common kind of contribution. The pattern:
2. **Add a hook installer in `src/commands/init.js`** — most tools support a config file or hook script you can patch. Make it idempotent (safe to re-run).
3. **Add a status check in `src/commands/status.js`** — show whether the hook is installed and whether data has been collected.
4. **Add a parser test in its own `test/<tool>-parser.test.js`** — use a real (anonymized) sample log fixture. Recent tools each got their own file (`droid-parser.test.js`, `zed-parser.test.js`, `goose-parser.test.js`); only the older ones share `rollout-parser.test.js`.

**Also add a conformance fixture: `test/fixtures/parser-conformance/<tool>.cjs`.** `test/parser-conformance.test.js` enumerates every `parse*Incremental` from `src/lib/rollout.js` **source**, so a new parser without a fixture fails the build — it cannot be silently skipped. The alternative is an entry in `allowlist.json` explaining why a fixture is not possible (a SQLite schema, a live API shape), and that list is a ratchet: it may only shrink.

The fixture checks the parser's *output* — the column invariant `total = input + output + cache_creation + cache_read + reasoning`, non-negative counts, a model, a 30-minute UTC bucket, and that re-parsing the same input does not double-count. It deliberately does **not** prove you read the provider's format correctly; a parser that double-counts cache reads and inflates the total to match still passes. That is what step 4's real sample log is for. Copy `craft.cjs` for the shape.
5. **Add the tool to the Supported tools list in `README.md`** — the blockquote under "🔌 Supported tools". Leave the "20+" count alone; a hard number in prose has no validator and goes stale silently.

Look at how Claude Code, Codex, or Gemini are wired in for reference — they're the simplest examples.
Expand Down
28 changes: 28 additions & 0 deletions test/fixtures/parser-conformance/allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"_why": "Parsers with no conformance fixture yet. This list is a RATCHET: parser-conformance.test.js fails if it is longer than max_size, and also fails if it is SHORTER, telling you to lower max_size in the same commit. An allowlist that can grow is a TODO list, and this repo has evidence about what happens to those.",
"_how_to_remove_an_entry": "Add test/fixtures/parser-conformance/<parserName>.cjs (see craft.cjs for the shape), delete the entry here, and lower max_size by one.",
"max_size": 21,
"parsers": {
"parseRolloutIncremental": "Codex rollout JSONL. Covered behaviourally by test/rollout-parser.test.js; no conformance fixture yet.",
"parseClaudeIncremental": "Covered behaviourally by test/rollout-parser.test.js; no conformance fixture yet.",
"parseGeminiIncremental": "Covered behaviourally by test/rollout-parser.test.js; no conformance fixture yet.",
"parseOpencodeIncremental": "Covered behaviourally elsewhere; no conformance fixture yet.",
"parseOpenclawIncremental": "No conformance fixture yet.",
"parseOpencodeDbIncremental": "Reads a SQLite database. A fixture needs a real schema, not a JSONL line.",
"parseCursorApiIncremental": "Talks to Cursor's API shape rather than a local log; covered by test/cursor-parser.test.js.",
"parseKiroIncremental": "No conformance fixture yet; test/fixtures/kiro-cli exists for the CLI variant.",
"parseHermesIncremental": "Reads a SQLite database across profile directories. A fixture needs a real schema, not a JSONL line.",
"parseKiroCliIncremental": "No conformance fixture yet.",
"parseKimiCodeIncremental": "No conformance fixture yet.",
"parseRoocodeIncremental": "Covered by test/roocode-parser.test.js; no conformance fixture yet.",
"parseZedIncremental": "Covered by test/zed-parser.test.js; no conformance fixture yet.",
"parseGooseIncremental": "Covered by test/goose-parser.test.js; no conformance fixture yet.",
"parseDroidIncremental": "Covered by test/droid-parser.test.js; no conformance fixture yet.",
"parseKilocodeIncremental": "Covered by test/kilocode-parser.test.js; no conformance fixture yet.",
"parseOmpIncremental": "No conformance fixture yet.",
"parsePiIncremental": "No conformance fixture yet.",
"parseCopilotIncremental": "Reads the Copilot quota API shape; counts are unverified against a live account (#105).",
"parseGrokBuildIncremental": "No conformance fixture yet.",
"parseAntigravityIncremental": "Finds the app by lsof-ing listening ports; not reducible to a file fixture today (#105)."
}
}
58 changes: 58 additions & 0 deletions test/fixtures/parser-conformance/codebuddy.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use strict";

// Conformance fixture for parseCodebuddyIncremental.
//
// CodeBuddy writes JSONL where assistant messages carry `providerData.rawUsage`
// in OpenAI's shape. The load-bearing detail, and the reason this parser is
// worth a fixture at all: **`prompt_tokens` INCLUDES the cached tokens**, so
// `input_tokens` is `prompt_tokens - cache_read`. Getting that wrong
// double-counts cache reads — the cached-input-semantics failure class
// CLAUDE.md records at 1.6-7x magnitude.
//
// The numbers below are chosen so a parser that forgot the subtraction would
// break the column-sum invariant rather than merely be a bit off.

const fs = require("node:fs");
const path = require("node:path");

const assistantMessage = (uuid, tsMs, rawUsage) =>
JSON.stringify({
type: "message",
role: "assistant",
sessionId: "codebuddy-session-1",
uuid,
timestamp: tsMs,
providerData: { rawUsage },
}) + "\n";

// Fixed instant, so the 30-minute bucket is deterministic rather than
// whatever the clock says when CI happens to run.
const T = Date.parse("2026-05-14T09:12:30.000Z");

module.exports = {
source: "codebuddy",
parser: "parseCodebuddyIncremental",
build(dir) {
const log = path.join(dir, "codebuddy-session-1.jsonl");
fs.writeFileSync(
log,
[
assistantMessage("cb-msg-1", T, {
// 3000 prompt tokens of which 2400 were cache reads -> 600 real input.
prompt_tokens: 3000,
completion_tokens: 250,
prompt_tokens_details: { cached_tokens: 2400 },
cache_creation_input_tokens: 100,
}),
// A user turn, which carries no usage and must be skipped.
JSON.stringify({ type: "message", role: "user", uuid: "cb-user-1" }) + "\n",
assistantMessage("cb-msg-2", T + 60_000, {
prompt_tokens: 40,
completion_tokens: 8,
prompt_tokens_details: {},
}),
].join(""),
);
return { projectFiles: [log], defaultModel: "codebuddy-default" };
},
};
51 changes: 51 additions & 0 deletions test/fixtures/parser-conformance/craft.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use strict";

// Conformance fixture for parseCraftIncremental.
//
// Craft writes one JSONL file per session whose FIRST line is a header carrying
// running totals: `{ id, tokenUsage: { inputTokens, outputTokens,
// cacheReadTokens, cacheCreationTokens, totalTokens } }`. The header is
// rewritten in place as the session grows, so the parser contributes deltas.
// That snapshot-vs-cumulative shape is one of the failure classes CLAUDE.md
// records at 1.6-7x magnitude, which is why it is worth a fixture.

const fs = require("node:fs");
const path = require("node:path");

const session = (id, usage) =>
JSON.stringify({
id,
model: "claude-sonnet-5",
tokenUsage: usage,
}) + "\n";

module.exports = {
source: "craft",
parser: "parseCraftIncremental",
build(dir) {
const one = path.join(dir, "session-one.jsonl");
const two = path.join(dir, "session-two.jsonl");
fs.writeFileSync(
one,
session("craft-session-one", {
inputTokens: 1200,
outputTokens: 340,
cacheReadTokens: 800,
cacheCreationTokens: 60,
totalTokens: 2400,
}),
);
// Zero cache columns: a row that omits them must still satisfy the column
// sum rather than being excused from it.
fs.writeFileSync(
two,
session("craft-session-two", {
inputTokens: 90,
outputTokens: 10,
cacheReadTokens: 0,
cacheCreationTokens: 0,
}),
);
return { sessionFiles: [one, two] };
},
};
52 changes: 52 additions & 0 deletions test/fixtures/parser-conformance/kimi.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use strict";

// Conformance fixture for parseKimiIncremental.
//
// Kimi writes a wire log of JSONL envelopes; only `message.type === "StatusUpdate"`
// carries usage, under `payload.token_usage`, and `payload.message_id` is the
// dedup key. The four token fields arrive already separated — `input_other` is
// non-cached input — so this fixture's job is to hold that mapping still and to
// prove the message_id dedup survives a second parse.

const fs = require("node:fs");
const path = require("node:path");

// Fixed instant so the 30-minute bucket is deterministic rather than whatever
// the clock says when CI runs. Kimi's timestamp is epoch SECONDS, not ms.
const T_SECONDS = Date.parse("2026-05-14T09:12:30.000Z") / 1000;

const statusUpdate = (messageId, usage, atSeconds) =>
JSON.stringify({
timestamp: atSeconds,
message: {
type: "StatusUpdate",
payload: { message_id: messageId, token_usage: usage },
},
}) + "\n";

module.exports = {
source: "kimi",
parser: "parseKimiIncremental",
build(dir) {
const wire = path.join(dir, "wire.jsonl");
fs.writeFileSync(
wire,
[
statusUpdate(
"kimi-msg-1",
{ input_other: 500, output: 120, input_cache_read: 2000, input_cache_creation: 40 },
T_SECONDS,
),
// A second message in the same file, and one line the parser must skip
// rather than choke on.
JSON.stringify({ message: { type: "Heartbeat" } }) + "\n",
statusUpdate(
"kimi-msg-2",
{ input_other: 30, output: 7, input_cache_read: 0, input_cache_creation: 0 },
T_SECONDS + 60,
),
].join(""),
);
return { wireFiles: [wire], model: "kimi-k2" };
},
};
Loading