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.
- claude_bingo-0.1.0/.claude-plugin/marketplace.json +16 -0
- claude_bingo-0.1.0/.claude-plugin/plugin.json +10 -0
- claude_bingo-0.1.0/.github/workflows/ci.yml +50 -0
- claude_bingo-0.1.0/.github/workflows/publish.yml +47 -0
- claude_bingo-0.1.0/.gitignore +11 -0
- claude_bingo-0.1.0/LICENSE +21 -0
- claude_bingo-0.1.0/PKG-INFO +126 -0
- claude_bingo-0.1.0/README.md +109 -0
- claude_bingo-0.1.0/claude_bingo/__init__.py +3 -0
- claude_bingo-0.1.0/claude_bingo/__main__.py +4 -0
- claude_bingo-0.1.0/claude_bingo/cli.py +351 -0
- claude_bingo-0.1.0/claude_bingo/phrases.py +66 -0
- claude_bingo-0.1.0/claude_bingo/phrases.toml +96 -0
- claude_bingo-0.1.0/pyproject.toml +30 -0
- claude_bingo-0.1.0/skills/bingo/SKILL.md +77 -0
|
@@ -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,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,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.
|