@bompus/house-rules 0.11.0 → 0.13.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.
Files changed (111) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/README.md +91 -60
  3. package/compose.mjs +8 -2
  4. package/composition.mjs +11 -3
  5. package/config-view.mjs +18 -3
  6. package/config.mjs +74 -13
  7. package/docs/assets/overview.png +0 -0
  8. package/docs/configuration.md +85 -9
  9. package/package.json +4 -2
  10. package/rules/core.md +118 -209
  11. package/rules/modifiers/backlog-outline.md +25 -0
  12. package/rules/modifiers/backlog-visual.md +16 -0
  13. package/rules/modifiers/coded-offers.md +47 -17
  14. package/rules/modifiers/cross-session-relay.md +23 -0
  15. package/rules/modifiers/low-quota-handoff.md +4 -3
  16. package/rules/modifiers/model-default-questions.md +33 -0
  17. package/rules/modifiers/multi-agent.md +6 -2
  18. package/rules/modifiers/no-attribution.md +7 -1
  19. package/rules/modifiers/periodic-updates.md +25 -0
  20. package/rules/modifiers/plan-files.md +35 -3
  21. package/rules/modifiers/release-batching.md +4 -2
  22. package/rules/modifiers/shared-host-load.md +13 -42
  23. package/rules/modifiers/squash-landing.md +1 -1
  24. package/rules/modifiers/upstream-contributions.md +45 -0
  25. package/rules/modifiers/web-image-formats.md +35 -0
  26. package/rules/references/backlog-view.md +154 -0
  27. package/rules/references/landing.md +14 -14
  28. package/rules/references/multi-agent.md +6 -2
  29. package/rules/references/release-batching.md +27 -4
  30. package/rules/references/squash-landing.md +10 -4
  31. package/rules/references/thread-visuals.md +45 -0
  32. package/setup.mjs +87 -17
  33. package/skills/hr-agent-guidance-audit/SKILL.md +10 -5
  34. package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
  35. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +1 -1
  36. package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
  37. package/skills/hr-agent-guidance-refresh/SKILL.md +25 -57
  38. package/skills/hr-agent-guidance-refresh/references/verification.md +1 -1
  39. package/skills/hr-audit-choices/SKILL.md +1 -1
  40. package/skills/hr-benchmarking/SKILL.md +122 -159
  41. package/skills/hr-benchmarking/references/javascript.md +113 -0
  42. package/skills/hr-benchmarking/references/tools.md +62 -57
  43. package/skills/hr-better-accessibility/SKILL.md +1 -1
  44. package/skills/hr-better-accessibility/screen-readers.md +1 -0
  45. package/skills/hr-code-review/SKILL.md +19 -3
  46. package/skills/hr-code-review/agents/openai.yaml +1 -1
  47. package/skills/hr-code-review/references/acp-client.md +43 -0
  48. package/skills/{hr-change-impact/SKILL.md → hr-code-review/references/impact.md} +2 -12
  49. package/skills/hr-code-review/references/panel-adjudication.md +58 -0
  50. package/skills/hr-code-review/references/panel-plan.md +61 -0
  51. package/skills/hr-code-review/references/panel-recommendations.json +105 -0
  52. package/skills/hr-code-review/references/panel-recommendations.md +46 -0
  53. package/skills/hr-code-review/references/panel-setup.md +54 -0
  54. package/skills/hr-code-review/references/reviewer-role.md +31 -0
  55. package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
  56. package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
  57. package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
  58. package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
  59. package/skills/hr-design-exploration/SKILL.md +24 -1
  60. package/skills/hr-design-exploration/agents/openai.yaml +2 -2
  61. package/skills/hr-diagnosing-bugs/SKILL.md +2 -2
  62. package/skills/hr-diagnosing-bugs/references/reporting.md +29 -4
  63. package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
  64. package/skills/hr-explain-code/SKILL.md +1 -1
  65. package/skills/hr-extract-shared-steps/SKILL.md +1 -1
  66. package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
  67. package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
  68. package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
  69. package/skills/hr-handoff/LICENSE +21 -0
  70. package/skills/hr-handoff/NOTICE.md +29 -0
  71. package/skills/hr-handoff/SKILL.md +24 -1
  72. package/skills/hr-handoff/references/reconciliation.md +25 -6
  73. package/skills/hr-handoff/references/successor-thread.md +76 -0
  74. package/skills/hr-handoff/scripts/check-reconciliation.mjs +48 -6
  75. package/skills/hr-house-rules-setup/SKILL.md +22 -8
  76. package/skills/hr-lean-plan/SKILL.md +4 -0
  77. package/skills/hr-maintainability-review/SKILL.md +14 -4
  78. package/skills/hr-maintainability-review/agents/openai.yaml +1 -1
  79. package/skills/hr-manual-qa/SKILL.md +1 -1
  80. package/skills/hr-ordering-tests/SKILL.md +1 -1
  81. package/skills/hr-plain-prose/SKILL.md +7 -4
  82. package/skills/hr-pr-followup/SKILL.md +6 -3
  83. package/skills/hr-pr-followup/references/review-helpers.md +21 -0
  84. package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
  85. package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
  86. package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
  87. package/skills/hr-read-reddit/SKILL.md +1 -1
  88. package/skills/hr-research-shared-links/SKILL.md +103 -0
  89. package/skills/hr-sqlite/SKILL.md +174 -0
  90. package/skills/hr-sqlite/agents/openai.yaml +2 -0
  91. package/skills/hr-sqlite/references/evidence.md +94 -0
  92. package/skills/hr-sqlite/references/tool-decisions.md +39 -0
  93. package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
  94. package/skills/hr-stock-ui-audit/SKILL.md +1 -1
  95. package/skills/hr-test-audit/LICENSE +21 -0
  96. package/skills/hr-test-audit/NOTICE.md +67 -0
  97. package/skills/hr-usage-report/SKILL.md +90 -0
  98. package/skills/hr-usage-report/agents/openai.yaml +2 -0
  99. package/skills/hr-usage-report/assets/example-report.json +125 -0
  100. package/skills/hr-usage-report/assets/report-template.html +377 -0
  101. package/skills/hr-usage-report/scripts/render-report.ts +86 -0
  102. package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
  103. package/skills/hr-what-next/SKILL.md +100 -10
  104. package/skills/hr-what-next/references/reporting.md +13 -22
  105. package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
  106. package/skills/hr-writing-pr/SKILL.md +29 -14
  107. package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
  108. package/docs/npm-release.md +0 -45
  109. package/rules/modifiers/swarmail.md +0 -12
  110. package/skills/hr-benchmarking/references/priority-findings-20261006.md +0 -115
  111. package/skills/hr-change-impact/agents/openai.yaml +0 -5
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ // Render a model comparison report: data JSON in, self-contained HTML out.
3
+ //
4
+ // bun render-report.ts <data.json> <out.html>
5
+ //
6
+ // Data shape and section types: ../SKILL.md. Example data:
7
+ // ../assets/example-report.json.
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ type Series = { name: string; slot?: number; reference?: boolean };
12
+ type Section = {
13
+ type: string;
14
+ items?: Section[];
15
+ series?: { key: string }[];
16
+ rows?: { key?: string }[];
17
+ };
18
+ export type ReportData = { title?: string; series: Record<string, Series>; sections: Section[] };
19
+
20
+ const TYPES = new Set(["curve", "ladder", "bars", "table", "pair"]);
21
+
22
+ /** Problems that would make the page wrong or unreadable; empty when the data is usable. */
23
+ export function check(data: ReportData): string[] {
24
+ const errors: string[] = [];
25
+ const entries = Object.entries(data.series);
26
+ const colored = entries.filter(([, s]) => !s.reference);
27
+ // The template's three dark-surface slots pass the all-pairs color-vision check;
28
+ // a fourth hue does not, so further series must be a dashed reference or a separate chart.
29
+ if (colored.length > 3) {
30
+ errors.push(
31
+ `at most 3 colored series (got ${colored.length}); mark one more "reference": true or put the rest in a separate report`,
32
+ );
33
+ }
34
+ if (entries.filter(([, s]) => s.reference).length > 1) {
35
+ errors.push("at most one reference series");
36
+ }
37
+ const slots = colored.map(([, s]) => s.slot);
38
+ if (
39
+ slots.some((s) => s === undefined || s < 0 || s > 2) ||
40
+ new Set(slots).size !== slots.length
41
+ ) {
42
+ errors.push("each colored series needs a distinct slot 0, 1 or 2");
43
+ }
44
+ const walk = (sections: Section[], path: string) =>
45
+ sections.forEach((s, i) => {
46
+ const where = `${path}[${i}]`;
47
+ if (!TYPES.has(s.type)) {
48
+ errors.push(`${where}: unknown type ${s.type}`);
49
+ }
50
+ for (const k of [
51
+ ...(s.series ?? []).map((r) => r.key),
52
+ ...(s.rows ?? []).map((r) => r.key),
53
+ ]) {
54
+ if (k !== undefined && !data.series[k]) {
55
+ errors.push(`${where}: series key ${k} is not declared`);
56
+ }
57
+ }
58
+ if (s.items) {
59
+ walk(s.items, `${where}.items`);
60
+ }
61
+ });
62
+ walk(data.sections, "sections");
63
+ return errors;
64
+ }
65
+
66
+ export function render(data: ReportData, template: string): string {
67
+ const errors = check(data);
68
+ if (errors.length) {
69
+ throw new Error(errors.join("\n"));
70
+ }
71
+ // Inside <script>, a literal "</" would close the element early.
72
+ return template.replace("__REPORT_DATA__", () => JSON.stringify(data).replaceAll("</", "<\\/"));
73
+ }
74
+
75
+ if (import.meta.main) {
76
+ const [input, output] = process.argv.slice(2);
77
+ if (!input || !output) {
78
+ throw new Error("usage: bun render-report.ts <data.json> <out.html>");
79
+ }
80
+ const template = readFileSync(
81
+ join(import.meta.dir, "..", "assets", "report-template.html"),
82
+ "utf8",
83
+ );
84
+ writeFileSync(output, render(JSON.parse(readFileSync(input, "utf8")), template));
85
+ console.log(output);
86
+ }
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env bun
2
+ // Where did model usage go? Totals local transcript usage per provider, model and
3
+ // role (main session, subagent, advisor) over a window, priced at models.dev list
4
+ // rates, and reports context size per request and at session start.
5
+ //
6
+ // bun usage-by-model.ts [--days N | --since ISO] [--providers claude,codex,opencode]
7
+ // [--prices models-dev.json] [--json]
8
+ //
9
+ // List price stands in for subscription quota; providers do not publish the
10
+ // conversion. Read the shares beside the provider's live quota readings, not as an
11
+ // invoice. Method and report shape: ../SKILL.md.
12
+ import { Database } from "bun:sqlite";
13
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ export type Tokens = { fresh: number; output: number; cacheRead: number; cacheWrite: number };
18
+ export type Request = Tokens & {
19
+ provider: string;
20
+ priceProvider?: string;
21
+ model: string;
22
+ role: string;
23
+ session: string;
24
+ t: number;
25
+ };
26
+ type Rate = { input: number; output: number; cache_read?: number; cache_write?: number };
27
+ type PriceEntry = Rate & { tiers?: (Rate & { tier: { type: string; size: number } })[] };
28
+ export type Prices = Record<string, { models?: Record<string, { cost?: PriceEntry }> }>;
29
+
30
+ const PRICE_ORDER = ["anthropic", "openai", "google", "xai", "opencode", "opencode-go"];
31
+
32
+ /** models.dev entry for a model id, trying the recorded provider first, then known vendors. */
33
+ export function priceFor(prices: Prices, model: string, provider?: string): PriceEntry | undefined {
34
+ const id = model.replace(/\[.*\]$/, "").replace(/-\d{8}$/, "");
35
+ for (const p of [provider, ...PRICE_ORDER]) {
36
+ const cost = p ? prices[p]?.models?.[id]?.cost : undefined;
37
+ if (cost) {
38
+ return cost;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /** List cost of one request; long-context tiers apply when the request's context exceeds their size. */
45
+ export function requestCost(price: PriceEntry, r: Tokens): number {
46
+ const ctx = r.fresh + r.cacheRead + r.cacheWrite;
47
+ let rate: Rate = price;
48
+ // The largest exceeded tier applies, whatever order the price list gives.
49
+ let size = -1;
50
+ for (const tier of price.tiers ?? []) {
51
+ if (tier.tier.type === "context" && ctx > tier.tier.size && tier.tier.size > size) {
52
+ rate = tier;
53
+ size = tier.tier.size;
54
+ }
55
+ }
56
+ const cr = rate.cache_read ?? rate.input * 0.1;
57
+ const cw = rate.cache_write ?? rate.input * 1.25;
58
+ return (
59
+ (r.fresh * rate.input + r.output * rate.output + r.cacheRead * cr + r.cacheWrite * cw) / 1e6
60
+ );
61
+ }
62
+
63
+ function* jsonlFiles(dir: string, since: number): Generator<string> {
64
+ if (!existsSync(dir)) {
65
+ return;
66
+ }
67
+ for (const name of readdirSync(dir)) {
68
+ const path = join(dir, name);
69
+ const st = statSync(path);
70
+ if (st.isDirectory()) {
71
+ yield* jsonlFiles(path, since);
72
+ } else if (name.endsWith(".jsonl") && st.mtimeMs >= since) {
73
+ yield path;
74
+ }
75
+ }
76
+ }
77
+
78
+ function lines(path: string): unknown[] {
79
+ const out: unknown[] = [];
80
+ for (const line of readFileSync(path, "utf8").split("\n")) {
81
+ if (!line.includes("usage")) {
82
+ continue;
83
+ }
84
+ try {
85
+ out.push(JSON.parse(line));
86
+ } catch {
87
+ // A partially written last line is skipped.
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ type ClaudeUsage = {
94
+ input_tokens?: number;
95
+ output_tokens?: number;
96
+ cache_read_input_tokens?: number;
97
+ cache_creation_input_tokens?: number;
98
+ iterations?: (ClaudeUsage & { type?: string; model?: string })[];
99
+ };
100
+ type ClaudeEntry = {
101
+ timestamp?: string;
102
+ requestId?: string;
103
+ isSidechain?: boolean;
104
+ sessionId?: string;
105
+ message?: { id?: string; model?: string; usage?: ClaudeUsage };
106
+ };
107
+
108
+ const claudeTokens = (u: ClaudeUsage): Tokens => ({
109
+ fresh: u.input_tokens ?? 0,
110
+ output: u.output_tokens ?? 0,
111
+ cacheRead: u.cache_read_input_tokens ?? 0,
112
+ cacheWrite: u.cache_creation_input_tokens ?? 0,
113
+ });
114
+
115
+ /**
116
+ * Claude Code transcript roots under a home directory: the default `~/.claude/projects` and the
117
+ * `projects` directory of every other `~/.claude*` configuration directory, such as a separate
118
+ * `CLAUDE_CONFIG_DIR` used for a gateway. A directory reached through a link counts once.
119
+ */
120
+ export function claudeProjectRoots(home: string): string[] {
121
+ const roots = new Map<string, string>();
122
+ for (const name of readdirSync(home).sort()) {
123
+ if (name !== ".claude" && !name.startsWith(".claude_") && !name.startsWith(".claude-")) {
124
+ continue;
125
+ }
126
+ const root = join(home, name, "projects");
127
+ if (existsSync(root)) {
128
+ roots.set(realpathSync(root), root);
129
+ }
130
+ }
131
+ return [...roots.values()];
132
+ }
133
+
134
+ type ClaudeMessage = NonNullable<ClaudeEntry["message"]>;
135
+
136
+ /**
137
+ * Claude Code transcripts: one row per API response, advisor calls split out. A response is
138
+ * written once per streamed block: the first line carries the usage at the start of the stream
139
+ * (a few output tokens, no advisor iterations) and later lines carry the final figures, so the
140
+ * line with the most output tokens is kept, and advisor iterations from whichever line has them.
141
+ */
142
+ export function claudeRequests(roots: string | string[], since: number): Request[] {
143
+ type Seen = {
144
+ model: string;
145
+ role: string;
146
+ session: string;
147
+ t: number;
148
+ usage: ClaudeUsage;
149
+ iterations: NonNullable<ClaudeUsage["iterations"]>;
150
+ };
151
+ const responses = new Map<string, Seen>();
152
+ const files = (Array.isArray(roots) ? roots : [roots]).flatMap((root) =>
153
+ Array.from(jsonlFiles(root, since)),
154
+ );
155
+ for (const file of files) {
156
+ const fileRole = file.includes("/subagents/") ? "subagent" : "main";
157
+ for (const raw of lines(file)) {
158
+ const e = raw as ClaudeEntry;
159
+ const m: ClaudeMessage | undefined = e.message;
160
+ const t = Date.parse(e.timestamp ?? "");
161
+ // `<synthetic>` marks Claude Code's locally generated replies; they carry no usage.
162
+ if (!m?.id || !m.usage || !m.model || m.model === "<synthetic>" || !(t >= since)) {
163
+ continue;
164
+ }
165
+ const key = `${m.id}|${e.requestId ?? ""}`;
166
+ const seen = responses.get(key);
167
+ const iterations = m.usage.iterations ?? [];
168
+ if (!seen) {
169
+ responses.set(key, {
170
+ model: m.model,
171
+ role: e.isSidechain ? "subagent" : fileRole,
172
+ session: e.sessionId ?? file,
173
+ t,
174
+ usage: m.usage,
175
+ iterations,
176
+ });
177
+ continue;
178
+ }
179
+ if ((m.usage.output_tokens ?? 0) > (seen.usage.output_tokens ?? 0)) {
180
+ seen.usage = m.usage;
181
+ }
182
+ if (iterations.length > 0) {
183
+ seen.iterations = iterations;
184
+ }
185
+ }
186
+ }
187
+ const out: Request[] = [];
188
+ for (const r of responses.values()) {
189
+ out.push({
190
+ provider: "claude",
191
+ priceProvider: "anthropic",
192
+ model: r.model,
193
+ role: r.role,
194
+ session: r.session,
195
+ t: r.t,
196
+ ...claudeTokens(r.usage),
197
+ });
198
+ for (const it of r.iterations) {
199
+ if (it.type !== "advisor_message" || !it.model) {
200
+ continue;
201
+ }
202
+ out.push({
203
+ provider: "claude",
204
+ priceProvider: "anthropic",
205
+ model: it.model,
206
+ role: "advisor",
207
+ session: r.session,
208
+ t: r.t,
209
+ ...claudeTokens(it),
210
+ });
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ type CodexUsage = {
217
+ input_tokens?: number;
218
+ cached_input_tokens?: number;
219
+ cache_write_input_tokens?: number;
220
+ output_tokens?: number;
221
+ total_tokens?: number;
222
+ };
223
+ type CodexLine = {
224
+ type?: string;
225
+ timestamp?: string;
226
+ payload?: {
227
+ type?: string;
228
+ model?: string;
229
+ usage?: CodexUsage;
230
+ info?: { last_token_usage?: CodexUsage; total_token_usage?: CodexUsage } | null;
231
+ source?: unknown;
232
+ id?: string;
233
+ };
234
+ };
235
+
236
+ /** Codex rollouts: one row per response record; input tokens include the cached ones. */
237
+ // Codex writes one token_usage_record per response. Builds without it still write
238
+ // token_count events, which repeat when only rate limits change; those count only
239
+ // when the session's running total advances, and only for files with no records.
240
+ export function codexRequests(root: string, since: number): Request[] {
241
+ const out: Request[] = [];
242
+ for (const file of jsonlFiles(root, since)) {
243
+ let model = "unknown";
244
+ let role = "main";
245
+ let session = file;
246
+ let total = 0;
247
+ const records: Request[] = [];
248
+ const events: Request[] = [];
249
+ const request = (u: CodexUsage, t: number): Request => {
250
+ const cached = u.cached_input_tokens ?? 0;
251
+ return {
252
+ provider: "codex",
253
+ priceProvider: "openai",
254
+ model,
255
+ role,
256
+ session,
257
+ t,
258
+ fresh: Math.max(0, (u.input_tokens ?? 0) - cached),
259
+ cacheRead: cached,
260
+ cacheWrite: u.cache_write_input_tokens ?? 0,
261
+ output: u.output_tokens ?? 0,
262
+ };
263
+ };
264
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
265
+ if (!raw) {
266
+ continue;
267
+ }
268
+ let e: CodexLine;
269
+ try {
270
+ e = JSON.parse(raw);
271
+ } catch {
272
+ continue;
273
+ }
274
+ const p = e.payload ?? {};
275
+ const t = Date.parse(e.timestamp ?? "");
276
+ if (e.type === "session_meta") {
277
+ session = p.id ?? file;
278
+ if (p.source && typeof p.source === "object" && "subagent" in p.source) {
279
+ role = "subagent";
280
+ }
281
+ } else if (e.type === "turn_context" && p.model) {
282
+ model = p.model;
283
+ } else if (e.type === "token_usage_record" && p.usage) {
284
+ if (t >= since) {
285
+ records.push(request(p.usage, t));
286
+ }
287
+ } else if (e.type === "event_msg" && p.type === "token_count" && p.info?.last_token_usage) {
288
+ const running = p.info.total_token_usage?.total_tokens ?? 0;
289
+ if (running > total) {
290
+ total = running;
291
+ if (t >= since) {
292
+ events.push(request(p.info.last_token_usage, t));
293
+ }
294
+ }
295
+ }
296
+ }
297
+ out.push(...(records.length ? records : events));
298
+ }
299
+ return out;
300
+ }
301
+
302
+ type OpenCodeData = {
303
+ role?: string;
304
+ modelID?: string;
305
+ providerID?: string;
306
+ tokens?: {
307
+ input?: number;
308
+ output?: number;
309
+ reasoning?: number;
310
+ cache?: { read?: number; write?: number };
311
+ };
312
+ };
313
+
314
+ /** OpenCode's database: assistant messages; a session with a parent is a subagent. */
315
+ export function opencodeRequests(dbPath: string, since: number): Request[] {
316
+ if (!existsSync(dbPath)) {
317
+ return [];
318
+ }
319
+ const db = new Database(dbPath, { readonly: true });
320
+ try {
321
+ const rows = db
322
+ .query(
323
+ "select m.session_id as s, m.time_created as t, m.data as d, s.parent_id as parent from message m join session s on s.id = m.session_id where m.time_created >= ?",
324
+ )
325
+ .all(since) as { s: string; t: number; d: string; parent: string | null }[];
326
+ const out: Request[] = [];
327
+ for (const row of rows) {
328
+ const d = JSON.parse(row.d) as OpenCodeData;
329
+ if (d.role !== "assistant" || !d.modelID || !d.tokens) {
330
+ continue;
331
+ }
332
+ out.push({
333
+ provider: "opencode",
334
+ priceProvider: d.providerID,
335
+ model: d.modelID,
336
+ role: row.parent ? "subagent" : "main",
337
+ session: row.s,
338
+ t: row.t,
339
+ fresh: d.tokens.input ?? 0,
340
+ output: (d.tokens.output ?? 0) + (d.tokens.reasoning ?? 0),
341
+ cacheRead: d.tokens.cache?.read ?? 0,
342
+ cacheWrite: d.tokens.cache?.write ?? 0,
343
+ });
344
+ }
345
+ return out;
346
+ } finally {
347
+ db.close();
348
+ }
349
+ }
350
+
351
+ const quantile = (xs: number[], q: number) =>
352
+ xs.length ? (xs[Math.min(xs.length - 1, Math.floor(q * xs.length))] ?? 0) : 0;
353
+
354
+ export function summarize(reqs: Request[], prices: Prices) {
355
+ const groups = new Map<
356
+ string,
357
+ Tokens & {
358
+ provider: string;
359
+ model: string;
360
+ role: string;
361
+ requests: number;
362
+ cost: number | null;
363
+ }
364
+ >();
365
+ const unpriced = new Set<string>();
366
+ for (const r of reqs) {
367
+ const key = `${r.provider}|${r.model}|${r.role}`;
368
+ const g = groups.get(key) ?? {
369
+ provider: r.provider,
370
+ model: r.model,
371
+ role: r.role,
372
+ requests: 0,
373
+ fresh: 0,
374
+ output: 0,
375
+ cacheRead: 0,
376
+ cacheWrite: 0,
377
+ cost: 0,
378
+ };
379
+ g.requests += 1;
380
+ g.fresh += r.fresh;
381
+ g.output += r.output;
382
+ g.cacheRead += r.cacheRead;
383
+ g.cacheWrite += r.cacheWrite;
384
+ const price = priceFor(prices, r.model, r.priceProvider);
385
+ if (price && g.cost !== null) {
386
+ g.cost += requestCost(price, r);
387
+ } else if (!price) {
388
+ g.cost = null;
389
+ unpriced.add(r.model);
390
+ }
391
+ groups.set(key, g);
392
+ }
393
+ const total = [...groups.values()].reduce((s, g) => s + (g.cost ?? 0), 0);
394
+ const rows = [...groups.values()]
395
+ .map((g) => ({
396
+ ...g,
397
+ cost: g.cost === null ? null : Math.round(g.cost * 100) / 100,
398
+ sharePct: g.cost === null || !total ? null : Math.round((1000 * g.cost) / total) / 10,
399
+ }))
400
+ .sort((a, b) => (b.cost ?? -1) - (a.cost ?? -1));
401
+ const context: Record<
402
+ string,
403
+ { requestP50: number; requestP90: number; sessionStartP50: number; sessions: number }
404
+ > = {};
405
+ for (const provider of new Set(reqs.map((r) => r.provider))) {
406
+ const main = reqs
407
+ .filter((r) => r.provider === provider && r.role === "main")
408
+ .sort((a, b) => a.t - b.t);
409
+ const ctx = main.map((r) => r.fresh + r.cacheRead + r.cacheWrite).sort((a, b) => a - b);
410
+ const first = new Map<string, number>();
411
+ for (const r of main) {
412
+ if (!first.has(r.session)) {
413
+ first.set(r.session, r.fresh + r.cacheRead + r.cacheWrite);
414
+ }
415
+ }
416
+ const starts = [...first.values()].sort((a, b) => a - b);
417
+ context[provider] = {
418
+ requestP50: quantile(ctx, 0.5),
419
+ requestP90: quantile(ctx, 0.9),
420
+ sessionStartP50: quantile(starts, 0.5),
421
+ sessions: starts.length,
422
+ };
423
+ }
424
+ return { listCostTotal: Math.round(total * 100) / 100, rows, context, unpriced: [...unpriced] };
425
+ }
426
+
427
+ function arg(args: string[], name: string): string | undefined {
428
+ const i = args.indexOf(name);
429
+ return i >= 0 ? args[i + 1] : undefined;
430
+ }
431
+
432
+ async function fetchPrices(): Promise<unknown> {
433
+ const res = await fetch("https://models.dev/api.json");
434
+ if (!res.ok) {
435
+ throw new Error(`models.dev price list returned ${res.status}; pass --prices <file>`);
436
+ }
437
+ return res.json();
438
+ }
439
+
440
+ async function main(args: string[]) {
441
+ const since = arg(args, "--since")
442
+ ? Date.parse(arg(args, "--since")!)
443
+ : Date.now() - Number(arg(args, "--days") ?? 7) * 86_400_000;
444
+ if (!Number.isFinite(since)) {
445
+ throw new Error("--since needs an ISO time; --days a number");
446
+ }
447
+ const providers = (arg(args, "--providers") ?? "claude,codex,opencode").split(",");
448
+ const pricePath = arg(args, "--prices");
449
+ const prices = (
450
+ pricePath ? JSON.parse(readFileSync(pricePath, "utf8")) : await fetchPrices()
451
+ ) as Prices;
452
+ const home = homedir();
453
+ const reqs = [
454
+ ...(providers.includes("claude")
455
+ ? claudeRequests(
456
+ arg(args, "--claude-projects")?.split(",") ?? claudeProjectRoots(home),
457
+ since,
458
+ )
459
+ : []),
460
+ ...(providers.includes("codex") ? codexRequests(join(home, ".codex", "sessions"), since) : []),
461
+ ...(providers.includes("opencode")
462
+ ? opencodeRequests(join(home, ".local", "share", "opencode", "opencode.db"), since)
463
+ : []),
464
+ ];
465
+ const report = {
466
+ since: new Date(since).toISOString(),
467
+ providers,
468
+ requests: reqs.length,
469
+ ...summarize(reqs, prices),
470
+ };
471
+ if (args.includes("--json")) {
472
+ console.log(JSON.stringify(report, null, 1));
473
+ return;
474
+ }
475
+ const M = (n: number) => (n / 1e6).toFixed(2);
476
+ console.log(
477
+ `since ${report.since}; ${report.requests} requests; list total $${report.listCostTotal}`,
478
+ );
479
+ console.log(
480
+ "provider model role reqs fresh(M) out(M) cacheR(M) cacheW(M) list$ share",
481
+ );
482
+ for (const r of report.rows) {
483
+ console.log(
484
+ `${r.provider.padEnd(9)} ${r.model.slice(0, 30).padEnd(30)} ${r.role.padEnd(9)} ${String(r.requests).padStart(6)} ${M(r.fresh).padStart(9)} ${M(r.output).padStart(6)} ${M(r.cacheRead).padStart(9)} ${M(r.cacheWrite).padStart(9)} ${String(r.cost ?? "n/a").padStart(8)} ${r.sharePct === null ? " n/a" : `${r.sharePct}%`.padStart(6)}`,
485
+ );
486
+ }
487
+ for (const [p, c] of Object.entries(report.context)) {
488
+ console.log(
489
+ `${p}: main-request context p50 ${c.requestP50}, p90 ${c.requestP90}; session start p50 ${c.sessionStartP50} over ${c.sessions} sessions`,
490
+ );
491
+ }
492
+ if (report.unpriced.length) {
493
+ console.log(`no models.dev price: ${report.unpriced.join(", ")}`);
494
+ }
495
+ }
496
+
497
+ if (import.meta.main) {
498
+ await main(process.argv.slice(2));
499
+ }