claude-bingo 0.1.0__tar.gz

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,16 @@
1
+ {
2
+ "name": "claude-bingo",
3
+ "owner": { "name": "Marc Lavallee" },
4
+ "description": "Bingo for LLM-isms, scored against your own Claude transcripts.",
5
+ "plugins": [
6
+ {
7
+ "name": "claude-bingo",
8
+ "displayName": "Claude Bingo",
9
+ "description": "Generate a bingo board of LLM verbal tics and score it against your local Claude Code transcripts.",
10
+ "source": { "source": "github", "repo": "lavallee/claude-bingo" },
11
+ "version": "0.1.0",
12
+ "license": "MIT",
13
+ "category": "fun"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "claude-bingo",
3
+ "displayName": "Claude Bingo",
4
+ "version": "0.1.0",
5
+ "description": "Bingo for LLM-isms, scored against your own Claude transcripts.",
6
+ "author": { "name": "Marc Lavallee" },
7
+ "repository": "https://github.com/lavallee/claude-bingo",
8
+ "license": "MIT",
9
+ "keywords": ["fun", "analysis", "transcripts"]
10
+ }
@@ -0,0 +1,50 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python: ["3.11", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python }}
20
+
21
+ - name: Phrase bank loads and every regex compiles
22
+ run: |
23
+ python -c "
24
+ from claude_bingo import phrases
25
+ b = phrases.load()
26
+ assert b['labels'], 'empty phrase bank'
27
+ assert len(b['labels']) == len(set(b['labels'])), 'duplicate labels'
28
+ print(f\"{len(b['labels'])} phrases ok\")
29
+ "
30
+
31
+ - name: Board generates and renders
32
+ run: |
33
+ python -m claude_bingo board --seed 1
34
+ python -m claude_bingo board --size 4 --seed 2
35
+ python -m claude_bingo phrases > /dev/null
36
+
37
+ - name: Score fails gracefully with no transcripts
38
+ run: |
39
+ if python -m claude_bingo score > out.txt 2>&1; then
40
+ echo "expected a nonzero exit with no transcripts present" >&2
41
+ exit 1
42
+ fi
43
+ grep -q "no transcripts" out.txt
44
+
45
+ - name: Package builds and installs
46
+ run: |
47
+ pip install --quiet build
48
+ python -m build --wheel
49
+ pip install --quiet dist/*.whl
50
+ claude-bingo phrases > /dev/null
@@ -0,0 +1,47 @@
1
+ name: publish
2
+
3
+ # Publishes to PyPI when a GitHub release is published.
4
+ # Auth is via PyPI Trusted Publishing (OIDC) — no API token stored anywhere.
5
+
6
+ on:
7
+ release:
8
+ types: [published]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: astral-sh/setup-uv@v5
17
+
18
+ - name: Check tag matches pyproject version
19
+ run: |
20
+ tag="${GITHUB_REF_NAME#v}"
21
+ version=$(python3 -c \
22
+ "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
23
+ if [ "$tag" != "$version" ]; then
24
+ echo "tag $tag != pyproject version $version" >&2
25
+ exit 1
26
+ fi
27
+
28
+ - run: uv build
29
+
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ publish:
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment: pypi
39
+ permissions:
40
+ id-token: write # required for trusted publishing
41
+ steps:
42
+ - uses: actions/download-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ venv/
8
+
9
+ # Board state lives in XDG_DATA_HOME now, but don't let a stray one in —
10
+ # it's a fingerprint of your own transcripts.
11
+ board.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Lavallee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-bingo
3
+ Version: 0.1.0
4
+ Summary: Bingo for LLM-isms, scored against your own Claude transcripts.
5
+ Project-URL: Homepage, https://github.com/lavallee/claude-bingo
6
+ Project-URL: Issues, https://github.com/lavallee/claude-bingo/issues
7
+ Author: Marc Lavallee
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: bingo,claude,cli,llm
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Games/Entertainment
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+
18
+ # claude-bingo
19
+
20
+ A 5×5 bingo board of LLM verbal tics, scored against your own Claude Code
21
+ transcripts.
22
+
23
+ ```
24
+ B I N G O
25
+ ┌───────────────┬───────────────┬───────────────┬───────────────┬───────────────┐
26
+ │ I hear you │ Good catch │ battle-tested │ load-bearing │ worth naming │
27
+ │ ×2 │ ×9 │ — │ ×71 │ ×3 │
28
+ ├───────────────┼───────────────┼───────────────┼───────────────┼───────────────┤
29
+ │ Happy to │ Let me be │ Hope this │ Excellent! │ Let me │
30
+ │ ×4 │ direct │ helps │ — │ ×74 │
31
+ │ │ ×2 │ — │ │ │
32
+ └───────────────┴───────────────┴───────────────┴───────────────┴───────────────┘
33
+
34
+ BINGO! row 1, diagonal ↘
35
+ 18/25 squares · 201 utterances · 1,534 transcripts
36
+
37
+ ── worst offenders ────────────────────────────────────────
38
+ 74 Let me
39
+ …before changing anything. Let me check what the config actual…
40
+ 71 load-bearing
41
+ …that assumption is doing a lot of load-bearing work here.
42
+ ```
43
+
44
+ Everything runs locally. It reads `~/.claude/projects/**/*.jsonl` and prints to
45
+ your terminal. There are no network calls and no dependencies beyond the Python
46
+ standard library.
47
+
48
+ ## Install
49
+
50
+ **As a Claude Code plugin** — then just ask Claude to play bingo:
51
+
52
+ ```
53
+ /plugin marketplace add lavallee/claude-bingo
54
+ /plugin install claude-bingo@claude-bingo
55
+ ```
56
+
57
+ **As a CLI:**
58
+
59
+ ```bash
60
+ uvx claude-bingo board # no install
61
+ pipx install claude-bingo # or keep it around
62
+ ```
63
+
64
+ **From source:**
65
+
66
+ ```bash
67
+ git clone https://github.com/lavallee/claude-bingo
68
+ cd claude-bingo
69
+ python3 -m claude_bingo board
70
+ ```
71
+
72
+ Requires Python 3.11+ (for `tomllib`).
73
+
74
+ ## Use
75
+
76
+ ```bash
77
+ claude-bingo board # generate a fresh board
78
+ claude-bingo board --size 3 # smaller, for a quick round
79
+ claude-bingo board --seed 12345 # reproduce a specific board
80
+ claude-bingo score # score it against the last 7 days
81
+ claude-bingo score --days 30 # ...or however far back you dare look
82
+ claude-bingo score --no-quotes # skip the receipts
83
+ claude-bingo phrases # list the phrase bank
84
+ ```
85
+
86
+ The board is saved to `$XDG_DATA_HOME/claude-bingo/board.json` (default
87
+ `~/.local/share/claude-bingo/board.json`), so `score` picks up whatever you last
88
+ generated.
89
+
90
+ A given `--seed` reproduces a given board only for a given phrase bank — add
91
+ phrases and the same seed deals a different hand.
92
+
93
+ ## The phrase bank
94
+
95
+ 65 phrases across 9 categories, in
96
+ [`claude_bingo/phrases.toml`](claude_bingo/phrases.toml). Each entry is a board
97
+ label and the regex that hunts for it:
98
+
99
+ ```toml
100
+ [linkedin-brain]
101
+ "delve" = "\\bdelv(?:e|es|ing)\\b"
102
+ "tapestry" = "\\btapestry\\b"
103
+ ```
104
+
105
+ Patterns are matched case-insensitively and in multiline mode, so `^` anchors to
106
+ the start of any line.
107
+
108
+ Add your own without touching the package in
109
+ `~/.config/claude-bingo/phrases.toml` — same format. A label that collides with
110
+ a bundled one overrides its pattern.
111
+
112
+ ## Contributing
113
+
114
+ PRs to the phrase bank are the point of this repo. Add to an existing category
115
+ or start a new one. Two things make a good entry:
116
+
117
+ - **The label reads at a glance.** It lands in a 13-column cell.
118
+ - **The regex is specific enough to mean something.** `\bjust\b` will match
119
+ everything and tell you nothing; `you'?re absolutely right` earns its square.
120
+
121
+ Run `claude-bingo phrases` to check yours loaded, and `claude-bingo score` to
122
+ see whether it actually catches anything.
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,109 @@
1
+ # claude-bingo
2
+
3
+ A 5×5 bingo board of LLM verbal tics, scored against your own Claude Code
4
+ transcripts.
5
+
6
+ ```
7
+ B I N G O
8
+ ┌───────────────┬───────────────┬───────────────┬───────────────┬───────────────┐
9
+ │ I hear you │ Good catch │ battle-tested │ load-bearing │ worth naming │
10
+ │ ×2 │ ×9 │ — │ ×71 │ ×3 │
11
+ ├───────────────┼───────────────┼───────────────┼───────────────┼───────────────┤
12
+ │ Happy to │ Let me be │ Hope this │ Excellent! │ Let me │
13
+ │ ×4 │ direct │ helps │ — │ ×74 │
14
+ │ │ ×2 │ — │ │ │
15
+ └───────────────┴───────────────┴───────────────┴───────────────┴───────────────┘
16
+
17
+ BINGO! row 1, diagonal ↘
18
+ 18/25 squares · 201 utterances · 1,534 transcripts
19
+
20
+ ── worst offenders ────────────────────────────────────────
21
+ 74 Let me
22
+ …before changing anything. Let me check what the config actual…
23
+ 71 load-bearing
24
+ …that assumption is doing a lot of load-bearing work here.
25
+ ```
26
+
27
+ Everything runs locally. It reads `~/.claude/projects/**/*.jsonl` and prints to
28
+ your terminal. There are no network calls and no dependencies beyond the Python
29
+ standard library.
30
+
31
+ ## Install
32
+
33
+ **As a Claude Code plugin** — then just ask Claude to play bingo:
34
+
35
+ ```
36
+ /plugin marketplace add lavallee/claude-bingo
37
+ /plugin install claude-bingo@claude-bingo
38
+ ```
39
+
40
+ **As a CLI:**
41
+
42
+ ```bash
43
+ uvx claude-bingo board # no install
44
+ pipx install claude-bingo # or keep it around
45
+ ```
46
+
47
+ **From source:**
48
+
49
+ ```bash
50
+ git clone https://github.com/lavallee/claude-bingo
51
+ cd claude-bingo
52
+ python3 -m claude_bingo board
53
+ ```
54
+
55
+ Requires Python 3.11+ (for `tomllib`).
56
+
57
+ ## Use
58
+
59
+ ```bash
60
+ claude-bingo board # generate a fresh board
61
+ claude-bingo board --size 3 # smaller, for a quick round
62
+ claude-bingo board --seed 12345 # reproduce a specific board
63
+ claude-bingo score # score it against the last 7 days
64
+ claude-bingo score --days 30 # ...or however far back you dare look
65
+ claude-bingo score --no-quotes # skip the receipts
66
+ claude-bingo phrases # list the phrase bank
67
+ ```
68
+
69
+ The board is saved to `$XDG_DATA_HOME/claude-bingo/board.json` (default
70
+ `~/.local/share/claude-bingo/board.json`), so `score` picks up whatever you last
71
+ generated.
72
+
73
+ A given `--seed` reproduces a given board only for a given phrase bank — add
74
+ phrases and the same seed deals a different hand.
75
+
76
+ ## The phrase bank
77
+
78
+ 65 phrases across 9 categories, in
79
+ [`claude_bingo/phrases.toml`](claude_bingo/phrases.toml). Each entry is a board
80
+ label and the regex that hunts for it:
81
+
82
+ ```toml
83
+ [linkedin-brain]
84
+ "delve" = "\\bdelv(?:e|es|ing)\\b"
85
+ "tapestry" = "\\btapestry\\b"
86
+ ```
87
+
88
+ Patterns are matched case-insensitively and in multiline mode, so `^` anchors to
89
+ the start of any line.
90
+
91
+ Add your own without touching the package in
92
+ `~/.config/claude-bingo/phrases.toml` — same format. A label that collides with
93
+ a bundled one overrides its pattern.
94
+
95
+ ## Contributing
96
+
97
+ PRs to the phrase bank are the point of this repo. Add to an existing category
98
+ or start a new one. Two things make a good entry:
99
+
100
+ - **The label reads at a glance.** It lands in a 13-column cell.
101
+ - **The regex is specific enough to mean something.** `\bjust\b` will match
102
+ everything and tell you nothing; `you'?re absolutely right` earns its square.
103
+
104
+ Run `claude-bingo phrases` to check yours loaded, and `claude-bingo score` to
105
+ see whether it actually catches anything.
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,3 @@
1
+ """claude-bingo — a board of LLM-isms, scored against your own transcripts."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env python3
2
+ """claude-bingo — a board of LLM-isms, scored against your own transcripts.
3
+
4
+ claude-bingo board generate a fresh 5x5 board
5
+ claude-bingo score score it against the last 7 days of logs
6
+ claude-bingo score --days 30 ...or however far back you dare look
7
+ claude-bingo phrases list the phrase bank
8
+
9
+ Everything runs locally. Transcripts are read, never sent anywhere.
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import random
16
+ import re
17
+ import sys
18
+ import textwrap
19
+ import time
20
+ from concurrent.futures import ProcessPoolExecutor
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+
24
+ from . import phrases
25
+
26
+ LOG_ROOT = Path.home() / ".claude" / "projects"
27
+
28
+ CELL_W = 15
29
+ FREE = "FREE SPACE"
30
+
31
+ # ANSI, kept minimal so it degrades to noise-free plain text when piped.
32
+ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
33
+ GREEN, YELLOW, RED, CYAN, MAGENTA = (
34
+ "\033[32m", "\033[33m", "\033[31m", "\033[36m", "\033[35m",
35
+ )
36
+
37
+ _BANK = None
38
+
39
+
40
+ def bank():
41
+ """Phrase bank, loaded once per process (workers re-import this module)."""
42
+ global _BANK
43
+ if _BANK is None:
44
+ _BANK = phrases.load()
45
+ return _BANK
46
+
47
+
48
+ def board_path():
49
+ base = os.environ.get("XDG_DATA_HOME")
50
+ root = Path(base) if base else Path.home() / ".local" / "share"
51
+ return root / "claude-bingo" / "board.json"
52
+
53
+
54
+ def color(s, c):
55
+ return s if not sys.stdout.isatty() else f"{c}{s}{RESET}"
56
+
57
+
58
+ # ---------------------------------------------------------------- board gen
59
+
60
+ def make_board(size, seed):
61
+ labels = bank()["labels"]
62
+ needed = size * size - (1 if size % 2 else 0)
63
+ if needed > len(labels):
64
+ sys.exit(f"only {len(labels)} phrases in the bank; {size}x{size} needs "
65
+ f"{needed}")
66
+ rng = random.Random(seed)
67
+ picks = rng.sample(labels, needed)
68
+ if size % 2:
69
+ picks.insert(size * size // 2, FREE)
70
+ return {
71
+ "seed": seed,
72
+ "size": size,
73
+ "created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
74
+ "cells": picks,
75
+ }
76
+
77
+
78
+ # ---------------------------------------------------------------- scanning
79
+
80
+ def recent_logs(days):
81
+ cutoff = time.time() - days * 86400
82
+ for path in LOG_ROOT.rglob("*.jsonl"):
83
+ try:
84
+ if path.stat().st_mtime >= cutoff:
85
+ yield path
86
+ except OSError:
87
+ continue
88
+
89
+
90
+ def _scan_file(args):
91
+ """Worker: return {label: (count, first_example)} for one transcript."""
92
+ path, cutoff_iso, wanted = args
93
+ by_label = bank()["by_label"]
94
+ pats = [(label, by_label[label]) for label in wanted if label in by_label]
95
+ hits = {}
96
+ try:
97
+ with open(path, "r", errors="replace") as fh:
98
+ for line in fh:
99
+ if '"assistant"' not in line:
100
+ continue
101
+ try:
102
+ rec = json.loads(line)
103
+ except ValueError:
104
+ continue
105
+ if rec.get("type") != "assistant":
106
+ continue
107
+ if (rec.get("timestamp") or "") < cutoff_iso:
108
+ continue
109
+ content = rec.get("message", {}).get("content")
110
+ if not isinstance(content, list):
111
+ continue
112
+ for block in content:
113
+ if not isinstance(block, dict) or block.get("type") != "text":
114
+ continue
115
+ text = block.get("text") or ""
116
+ for label, pat in pats:
117
+ found = pat.findall(text)
118
+ if not found:
119
+ continue
120
+ count, example = hits.get(label, (0, None))
121
+ hits[label] = (
122
+ count + len(found),
123
+ example or _snippet(text, pat),
124
+ )
125
+ except OSError:
126
+ pass
127
+ return hits
128
+
129
+
130
+ def _snippet(text, pat, width=64):
131
+ m = pat.search(text)
132
+ if not m:
133
+ return None
134
+ start = max(0, m.start() - width // 3)
135
+ end = min(len(text), m.end() + width // 2)
136
+ frag = " ".join(text[start:end].split())
137
+ return ("…" if start else "") + frag + ("…" if end < len(text) else "")
138
+
139
+
140
+ def scan(days, wanted, jobs):
141
+ if not LOG_ROOT.exists():
142
+ sys.exit(f"no transcripts found — {LOG_ROOT} doesn't exist.\n"
143
+ "claude-bingo scores your local Claude Code logs; you need "
144
+ "some history first.")
145
+ cutoff_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
146
+ files = list(recent_logs(days))
147
+ if not files:
148
+ sys.exit(f"no transcripts touched in the last {days} days under {LOG_ROOT}")
149
+
150
+ totals = {}
151
+ work = [(f, cutoff_iso, wanted) for f in files]
152
+ done = 0
153
+ with ProcessPoolExecutor(max_workers=jobs) as pool:
154
+ for hits in pool.map(_scan_file, work, chunksize=8):
155
+ done += 1
156
+ if sys.stderr.isatty() and done % 50 == 0:
157
+ pct = 100 * done // len(files)
158
+ print(f"\r scanning… {done}/{len(files)} ({pct}%)",
159
+ end="", file=sys.stderr)
160
+ for label, (count, example) in hits.items():
161
+ prev_count, prev_example = totals.get(label, (0, None))
162
+ totals[label] = (prev_count + count, prev_example or example)
163
+ if sys.stderr.isatty():
164
+ print("\r" + " " * 40 + "\r", end="", file=sys.stderr)
165
+ return totals, len(files)
166
+
167
+
168
+ # ---------------------------------------------------------------- rendering
169
+
170
+ def wrap_cell(label, count=None):
171
+ lines = textwrap.wrap(label, CELL_W - 2) or [""]
172
+ if count is not None:
173
+ lines.append(f"×{count}" if count else "—")
174
+ lines = lines[:4]
175
+ return lines + [""] * (4 - len(lines))
176
+
177
+
178
+ def render(board, totals=None):
179
+ size = board["size"]
180
+ cells = board["cells"]
181
+ marked = set()
182
+ if totals is not None:
183
+ for i, label in enumerate(cells):
184
+ if label == FREE or totals.get(label, (0, None))[0]:
185
+ marked.add(i)
186
+
187
+ header = "BINGO"[:size] if size <= 5 else "".join(
188
+ chr(65 + i) for i in range(size))
189
+ top = "┌" + "┬".join("─" * CELL_W for _ in range(size)) + "┐"
190
+ mid = "├" + "┼".join("─" * CELL_W for _ in range(size)) + "┤"
191
+ bot = "└" + "┴".join("─" * CELL_W for _ in range(size)) + "┘"
192
+
193
+ out = []
194
+ out.append(" " + "".join(
195
+ color(ch.center(CELL_W + 1), BOLD + MAGENTA) for ch in header))
196
+ out.append(" " + top)
197
+ for r in range(size):
198
+ if r:
199
+ out.append(" " + mid)
200
+ rows = []
201
+ for c in range(size):
202
+ i = r * size + c
203
+ label = cells[i]
204
+ count = None if totals is None else totals.get(label, (0, None))[0]
205
+ if label == FREE:
206
+ rows.append((["", "FREE", "SPACE", ""], True))
207
+ else:
208
+ rows.append((wrap_cell(label, count), i in marked))
209
+ for line_no in range(4):
210
+ parts = []
211
+ for lines, is_marked in rows:
212
+ text = lines[line_no].center(CELL_W)
213
+ if is_marked:
214
+ text = color(text, GREEN + BOLD)
215
+ elif totals is not None:
216
+ text = color(text, DIM)
217
+ parts.append(text)
218
+ out.append(" │" + "│".join(parts) + "│")
219
+ out.append(" " + bot)
220
+ return "\n".join(out)
221
+
222
+
223
+ def find_bingos(board, marked):
224
+ size = board["size"]
225
+ lines = []
226
+ for r in range(size):
227
+ lines.append(("row %d" % (r + 1), [r * size + c for c in range(size)]))
228
+ for c in range(size):
229
+ name = "BINGO"[c] if size <= 5 else chr(65 + c)
230
+ lines.append(("col %s" % name, [r * size + c for r in range(size)]))
231
+ lines.append(("diagonal ↘", [i * size + i for i in range(size)]))
232
+ lines.append(("diagonal ↙", [i * size + (size - 1 - i) for i in range(size)]))
233
+ return [name for name, idx in lines if all(i in marked for i in idx)]
234
+
235
+
236
+ # ---------------------------------------------------------------- commands
237
+
238
+ def cmd_board(args):
239
+ seed = args.seed if args.seed is not None else random.randrange(1 << 30)
240
+ board = make_board(args.size, seed)
241
+ path = board_path()
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ path.write_text(json.dumps(board, indent=2) + "\n")
244
+ print()
245
+ print(render(board))
246
+ print()
247
+ print(color(f" board #{seed}", DIM) + color(f" → {path}", DIM))
248
+ print(color(" score it: claude-bingo score", DIM))
249
+ print()
250
+
251
+
252
+ def cmd_score(args):
253
+ path = board_path()
254
+ if not path.exists():
255
+ sys.exit("no board yet — run: claude-bingo board")
256
+ board = json.loads(path.read_text())
257
+ wanted = [c for c in board["cells"] if c != FREE]
258
+
259
+ print(color(f"\n scanning {LOG_ROOT} — last {args.days} days…", DIM),
260
+ file=sys.stderr)
261
+ totals, n_files = scan(args.days, wanted, args.jobs)
262
+
263
+ print()
264
+ print(render(board, totals))
265
+ print()
266
+
267
+ marked = {i for i, label in enumerate(board["cells"])
268
+ if label == FREE or totals.get(label, (0, None))[0]}
269
+ bingos = find_bingos(board, marked)
270
+ squares = len(marked)
271
+ total_hits = sum(c for c, _ in totals.values())
272
+
273
+ verdict = (color("BLACKOUT — every square. Seek help.", RED + BOLD)
274
+ if squares == len(board["cells"])
275
+ else color("BINGO! " + ", ".join(bingos), GREEN + BOLD)
276
+ if bingos else color("no bingo — suspiciously well-behaved", YELLOW))
277
+ print(f" {verdict}")
278
+ print(color(f" {squares}/{len(board['cells'])} squares · {total_hits:,} "
279
+ f"utterances · {n_files:,} transcripts", DIM))
280
+ print()
281
+
282
+ ranked = sorted(totals.items(), key=lambda kv: -kv[1][0])[:args.top]
283
+ if ranked:
284
+ print(color(" ── worst offenders " + "─" * 40, DIM))
285
+ for label, (count, example) in ranked:
286
+ if not count:
287
+ continue
288
+ print(f" {color(str(count).rjust(5), CYAN + BOLD)} "
289
+ f"{color(label, BOLD)}")
290
+ if example and args.quotes:
291
+ for line in textwrap.wrap(example, 68):
292
+ print(color(f" {line}", DIM))
293
+ print()
294
+
295
+
296
+ def cmd_phrases(args):
297
+ b = bank()
298
+ by_category = {}
299
+ for label in b["labels"]:
300
+ by_category.setdefault(b["categories"][label], []).append(label)
301
+
302
+ if args.category:
303
+ wanted = {c.lower() for c in args.category}
304
+ by_category = {k: v for k, v in by_category.items() if k.lower() in wanted}
305
+ if not by_category:
306
+ sys.exit("no such category. try: claude-bingo phrases")
307
+
308
+ print()
309
+ for category, labels in by_category.items():
310
+ print(f" {color(category, BOLD + MAGENTA)}")
311
+ for label in labels:
312
+ print(f" {label}")
313
+ print()
314
+ print(color(f" {len(b['labels'])} phrases · bundled: {phrases.BUNDLED}", DIM))
315
+ print(color(f" add your own: {phrases.USER_PHRASES}", DIM))
316
+ print()
317
+
318
+
319
+ def main():
320
+ p = argparse.ArgumentParser(
321
+ prog="claude-bingo",
322
+ description="Bingo for LLM-isms, scored against your Claude transcripts. "
323
+ "Reads local logs only; nothing is uploaded.")
324
+ sub = p.add_subparsers(dest="cmd", required=True)
325
+
326
+ b = sub.add_parser("board", help="generate a fresh board")
327
+ b.add_argument("--size", type=int, default=5, help="board dimension (default 5)")
328
+ b.add_argument("--seed", type=int,
329
+ help="reproduce a specific board (only stable for a given "
330
+ "phrase bank)")
331
+ b.set_defaults(func=cmd_board)
332
+
333
+ s = sub.add_parser("score", help="score the board against your logs")
334
+ s.add_argument("--days", type=int, default=7, help="how far back (default 7)")
335
+ s.add_argument("--top", type=int, default=12, help="offenders to list")
336
+ s.add_argument("--jobs", type=int, default=min(8, (os.cpu_count() or 4)))
337
+ s.add_argument("--no-quotes", dest="quotes", action="store_false",
338
+ help="skip the receipts")
339
+ s.set_defaults(func=cmd_score)
340
+
341
+ ph = sub.add_parser("phrases", help="list the phrase bank")
342
+ ph.add_argument("--category", action="append",
343
+ help="limit to a category (repeatable)")
344
+ ph.set_defaults(func=cmd_phrases)
345
+
346
+ args = p.parse_args()
347
+ args.func(args)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()
@@ -0,0 +1,66 @@
1
+ """Loads the phrase bank from TOML.
2
+
3
+ The bundled bank lives next to this module. Users can add their own in
4
+ $XDG_CONFIG_HOME/claude-bingo/phrases.toml (same format); entries there are
5
+ merged in, and a duplicate label overrides the bundled pattern.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import sys
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ BUNDLED = Path(__file__).parent / "phrases.toml"
15
+
16
+
17
+ def _config_dir():
18
+ base = os.environ.get("XDG_CONFIG_HOME")
19
+ return Path(base) if base else Path.home() / ".config"
20
+
21
+
22
+ USER_PHRASES = _config_dir() / "claude-bingo" / "phrases.toml"
23
+
24
+
25
+ def _load_file(path):
26
+ """Return [(category, label, compiled_pattern)] from one TOML file."""
27
+ try:
28
+ with open(path, "rb") as fh:
29
+ data = tomllib.load(fh)
30
+ except tomllib.TOMLDecodeError as e:
31
+ sys.exit(f"{path}: malformed TOML — {e}")
32
+ except OSError as e:
33
+ sys.exit(f"{path}: {e.strerror}")
34
+
35
+ out = []
36
+ for category, entries in data.items():
37
+ if not isinstance(entries, dict):
38
+ sys.exit(f"{path}: [{category}] must be a table of label = \"regex\"")
39
+ for label, pattern in entries.items():
40
+ if not isinstance(pattern, str):
41
+ sys.exit(f"{path}: [{category}] {label!r} must be a string regex")
42
+ try:
43
+ compiled = re.compile(pattern, re.IGNORECASE | re.MULTILINE)
44
+ except re.error as e:
45
+ sys.exit(f"{path}: [{category}] {label!r} has a bad regex — {e}")
46
+ out.append((category, label, compiled))
47
+ return out
48
+
49
+
50
+ def load():
51
+ entries = _load_file(BUNDLED)
52
+ if USER_PHRASES.exists():
53
+ entries += _load_file(USER_PHRASES)
54
+
55
+ # Later entries win, but keep first-seen ordering so boards stay stable.
56
+ by_label, order = {}, []
57
+ for category, label, pattern in entries:
58
+ if label not in by_label:
59
+ order.append(label)
60
+ by_label[label] = (category, pattern)
61
+
62
+ return {
63
+ "labels": order,
64
+ "by_label": {label: by_label[label][1] for label in order},
65
+ "categories": {label: by_label[label][0] for label in order},
66
+ }
@@ -0,0 +1,96 @@
1
+ # The phrase bank: things Claude says, catalogued for sport.
2
+ #
3
+ # Each entry is label = "regex".
4
+ #
5
+ # The label is what lands on the board, so it has to read at a glance in a
6
+ # 13-column cell. The regex is what hunts through the logs, so it can be as
7
+ # pedantic as it likes. Patterns are matched case-insensitively and in
8
+ # multiline mode, so ^ anchors to the start of any line.
9
+ #
10
+ # Table names are categories. They don't affect scoring — they're for
11
+ # organising the bank and for `claude-bingo phrases --category`.
12
+ #
13
+ # PRs welcome. Add to an existing category or start a new one.
14
+
15
+ [sycophancy]
16
+ "You're absolutely right" = "you'?re absolutely right"
17
+ "You're right" = "you'?re (?:so )?right\\b(?! to)"
18
+ "Great question!" = "(?:great|good|excellent) question"
19
+ "Good catch" = "good catch"
20
+ "You're right to push back" = "you'?re right to push back"
21
+ "Perfect!" = "^\\s*perfect[!.]"
22
+ "Excellent!" = "^\\s*excellent[!.]"
23
+
24
+ [honesty-performance]
25
+ "honest" = "\\b(?:to be honest|honestly|i'?ll be honest|let me be honest)\\b"
26
+ "Let me be direct" = "let me be direct"
27
+ "To be clear" = "to be clear"
28
+ "I want to be careful" = "i want to be (?:careful|precise)"
29
+ "I should be upfront" = "i (?:should|want to) be upfront"
30
+
31
+ [therapy-register]
32
+ "sit with" = "\\bsit with (?:it|this|that)\\b"
33
+ "that's a real" = "that'?s a real\\b"
34
+ "I hear you" = "i hear you\\b"
35
+ "worth naming" = "worth naming\\b"
36
+ "the tension here" = "the tension (?:here|is)\\b"
37
+
38
+ [load-bearing-vocabulary]
39
+ "load-bearing" = "load[- ]bearing"
40
+ "does the heavy lifting" = "(?:does|doing) the heavy lifting"
41
+ "single source of truth" = "single source of truth"
42
+ "footgun" = "\\bfootgun"
43
+ "escape hatch" = "escape hatch"
44
+ "happy path" = "happy path"
45
+ "belt and suspenders" = "belt[- ]and[- ]suspenders"
46
+ "blast radius" = "blast radius"
47
+ "surface area" = "surface area"
48
+ "sharp edges" = "sharp edges"
49
+
50
+ [linkedin-brain]
51
+ "robust" = "\\brobust\\b"
52
+ "seamless" = "\\bseamless(?:ly)?\\b"
53
+ "leverage" = "\\bleverag(?:e|es|ing)\\b"
54
+ "comprehensive" = "\\bcomprehensive\\b"
55
+ "elegant" = "\\belegant(?:ly)?\\b"
56
+ "delve" = "\\bdelv(?:e|es|ing)\\b"
57
+ "nuanced" = "\\bnuanced?\\b"
58
+ "meaningful" = "\\bmeaningful(?:ly)?\\b"
59
+ "crucially" = "\\b(?:crucially|importantly|notably)\\b"
60
+ "tapestry" = "\\btapestry\\b"
61
+
62
+ [structural-tics]
63
+ "not just X, but Y" = "not (?:just|only) .{3,40}?,? but\\b"
64
+ "It's worth noting" = "it'?s worth noting"
65
+ "Here's the thing" = "here'?s the (?:thing|kicker|rub)"
66
+ "That said," = "\\bthat said,"
67
+ "At the end of the day" = "at the end of the day"
68
+ "The key insight" = "the key (?:insight|question|thing)"
69
+ "Let me" = "^\\s*let me\\b"
70
+ "I'll go ahead and" = "i'?ll go ahead and"
71
+ "Let's dig in" = "let'?s (?:dig in|dive in|take a look)"
72
+
73
+ [engineering-theater]
74
+ "production-ready" = "production[- ]ready"
75
+ "battle-tested" = "battle[- ]tested"
76
+ "first-class" = "first[- ]class\\b"
77
+ "under the hood" = "under the hood"
78
+ "out of the box" = "out of the box"
79
+ "root cause" = "root cause"
80
+ "sanity check" = "sanity check"
81
+ "surgical" = "\\bsurgical(?:ly)?\\b"
82
+ "idiomatic" = "\\bidiomatic(?:ally)?\\b"
83
+
84
+ [confession-booth]
85
+ "I apologize" = "i apologi[sz]e"
86
+ "You're absolutely correct" = "you'?re absolutely correct"
87
+ "I made an error" = "i (?:made an error|was wrong|got that wrong)"
88
+ "Let me reconsider" = "let me (?:reconsider|rethink|take another look)"
89
+ "Wait," = "^\\s*wait[,.]"
90
+ "Hmm" = "^\\s*hmm+\\b"
91
+
92
+ [closers]
93
+ "Let me know if" = "let me know if"
94
+ "Hope this helps" = "hope (?:this|that) helps"
95
+ "Would you like me to" = "would you like me to"
96
+ "Happy to" = "\\bhappy to\\b"
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "claude-bingo"
7
+ version = "0.1.0"
8
+ description = "Bingo for LLM-isms, scored against your own Claude transcripts."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "Marc Lavallee" }]
13
+ keywords = ["claude", "llm", "bingo", "cli"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Games/Entertainment",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/lavallee/claude-bingo"
24
+ Issues = "https://github.com/lavallee/claude-bingo/issues"
25
+
26
+ [project.scripts]
27
+ claude-bingo = "claude_bingo.cli:main"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["claude_bingo"]
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: bingo
3
+ description: Play LLM-ism bingo — generate a board of Claude's verbal tics ("delve", "You're absolutely right", "load-bearing") and score it against the user's own local Claude Code transcripts. Use when the user asks to play bingo, wants to see which LLM-isms or verbal tics they've been subjected to, asks how often Claude says a given phrase, or wants their transcripts analysed for slop.
4
+ allowed-tools: Bash(python3 ${CLAUDE_PLUGIN_ROOT}/*)
5
+ ---
6
+
7
+ # Claude Bingo
8
+
9
+ A 5×5 board of LLM verbal tics, scored against the user's local Claude Code
10
+ transcripts under `~/.claude/projects`.
11
+
12
+ This is a game. Keep the tone light, and don't get defensive about the results —
13
+ the phrases on the board are things *you* say, and the user finding that funny is
14
+ the entire point.
15
+
16
+ ## Running it
17
+
18
+ The tool is pure stdlib, so it runs straight from the plugin directory with no
19
+ install step:
20
+
21
+ ```bash
22
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo board
23
+ ```
24
+
25
+ Generate a fresh board:
26
+
27
+ ```bash
28
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo board
29
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo board --size 3
30
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo board --seed 12345
31
+ ```
32
+
33
+ Score the saved board against recent history:
34
+
35
+ ```bash
36
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo score
37
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo score --days 30
38
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo score --no-quotes
39
+ ```
40
+
41
+ Inspect the phrase bank:
42
+
43
+ ```bash
44
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo phrases
45
+ PYTHONPATH="${CLAUDE_PLUGIN_ROOT}" python3 -m claude_bingo phrases --category linkedin-brain
46
+ ```
47
+
48
+ ## How to respond
49
+
50
+ - The output is already formatted for a terminal — box-drawing board, colour,
51
+ a ranked "worst offenders" list with real quotes. **Show it as-is** in a code
52
+ block. Don't re-render the board as a markdown table or re-summarise every
53
+ cell; that throws away the thing the user asked for.
54
+ - Add a short reaction after the output, not before it.
55
+ - If there's no board yet, `score` will say so. Run `board` first, then score.
56
+ - `score` walks every transcript in the window, so a wide `--days` on a heavy
57
+ user takes a few seconds. That's expected.
58
+
59
+ ## Adding phrases
60
+
61
+ The bank is TOML at `${CLAUDE_PLUGIN_ROOT}/claude_bingo/phrases.toml`. Users can
62
+ add their own without touching the plugin, in
63
+ `~/.config/claude-bingo/phrases.toml`:
64
+
65
+ ```toml
66
+ [my-category]
67
+ "the label on the board" = "the regex that hunts for it"
68
+ ```
69
+
70
+ Patterns are matched case-insensitively and in multiline mode. A label that
71
+ collides with a bundled one overrides its pattern.
72
+
73
+ ## Privacy
74
+
75
+ Everything is local. The tool reads transcript files and prints to the terminal;
76
+ it makes no network calls. If a user asks what it touches, the honest answer is
77
+ `~/.claude/projects/**/*.jsonl`, read-only.