transcripto 0.1.1__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transcripto
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Search everything your coding agents ever did, grade your own prompts, and price your decisions. Local, stdlib-only, your data never leaves the machine.
5
5
  Author: Oscar Morke
6
6
  License: MIT
@@ -29,6 +29,56 @@ your disk and never opens a socket.
29
29
  uvx transcripto coach
30
30
  ```
31
31
 
32
+ > **check which build you got.** `trace`, `--harness cursor`, and the "run `index`
33
+ > first" errors below all arrive in **0.1.2**. On anything older, `trace` is not a
34
+ > command, cursor is rejected, and the index-gated commands fail with a raw sqlite
35
+ > traceback instead of an instruction. One line settles which one you are holding:
36
+ >
37
+ > ```
38
+ > uvx transcripto --version # 0.1.2 or newer = this README is accurate
39
+ > ```
40
+ >
41
+ > *Read 2026-08-31: PyPI was serving 0.1.1 while this README described 0.1.2, so
42
+ > `uvx transcripto` gave the older build. If `--version` is not even a recognised
43
+ > flag, you have 0.1.1 — it was added in 0.1.2 precisely because there was no way
44
+ > to tell.* The repo always matches this README and needs nothing installed:
45
+ >
46
+ > ```
47
+ > git clone https://github.com/Morkeeth/transcripto && cd transcripto
48
+ > python3 transcripto.py coach
49
+ > ```
50
+
51
+ ## three harnesses, one instrument
52
+
53
+ ```
54
+ transcripto coach # Claude Code, ~/.claude/projects
55
+ transcripto coach --harness codex # Codex, ~/.codex
56
+ transcripto coach --harness cursor # Cursor, ~/.cursor/projects/*/agent-transcripts
57
+ ```
58
+
59
+ **Authorship is not the same gate in all three, and the tool says so rather than pooling them.**
60
+ Claude Code stamps `promptSource: typed`, which is the measured-reliable signal: about 95% of raw
61
+ `type: user` records are not the operator at all. Cursor has no such field. Its one honest
62
+ equivalent is the `<user_query>` wrapper it puts around a submitted prompt, which injected and
63
+ tool-result records do not carry. That is a weaker signal and it is labelled weaker.
64
+
65
+ ## `trace` — what actually happened after you asked
66
+
67
+ `ask` shows what you typed. `find` shows what a file went through. Neither answers the
68
+ question that matters after the fact: **you asked for X, did anything durable happen?**
69
+
70
+ ```
71
+ transcripto trace "the gate"
72
+ ```
73
+
74
+ It walks each of your matching prompts forward inside its own session and lists the writes
75
+ and edits that followed, stopping at your next prompt so one turn cannot claim the next
76
+ turn's work. Green dot = something durable landed. Red = nothing was touched.
77
+
78
+ **Honest limit:** a write following a prompt in the same session is CO-OCCURRENCE, not proof
79
+ the write was caused by that prompt or that it was correct. Same proxy `coach` uses, labelled
80
+ the same way.
81
+
32
82
  ## what you get back
33
83
 
34
84
  this is a real run on one machine, pasted unedited, 2026-08-28:
@@ -73,9 +123,13 @@ them as a snapshot rather than a constant. yours will be different, which is the
73
123
  whole point. the last two lines are the ones that sting: it hands you back your
74
124
  own best and worst prompt, verbatim, with the receipt for why it scored each one.
75
125
 
76
- on that machine, prompts that wrote down what done looks like survived **63% of
77
- the time (66 of 104)**. prompts with no stated intent survived **39% (411 of
78
- 1043)**. i had spent a year blaming the model.
126
+ on that machine, on that date, prompts that wrote down what done looks like
127
+ survived **63% of the time (66 of 104)**. prompts with no stated intent survived
128
+ **39% (411 of 1043)**. i had spent a year blaming the model.
129
+
130
+ one day later, 2026-08-29, the same command on the same machine read 63% (67 of
131
+ 107) and 40% (424 of 1072) over 2,874 transcripts. the percentages held and the
132
+ denominators moved, which is what a snapshot is supposed to do.
79
133
 
80
134
  ## the proxy caveat, which travels with every number
81
135
 
@@ -127,8 +181,10 @@ read these before you quote a number at anyone.
127
181
  - **one operator's corpus.** every figure in this README comes from one machine.
128
182
  it is an existence proof that the measurement runs, not a finding about how
129
183
  people prompt. run it on yours and you get yours.
130
- - **two harnesses today: Claude Code and Codex.** nothing else is supported.
131
- cursor, aider, and the rest are not read.
184
+ - **three harnesses today: Claude Code, Codex, Cursor.** nothing else is supported.
185
+ aider and the rest are not read. and the three are not equal: Claude Code has a
186
+ measured-reliable authorship field, Cursor has only the `<user_query>` wrapper,
187
+ which is weaker and is labelled weaker wherever it is used.
132
188
  - **the habit labels are heuristics.** "states-a-check-or-done-condition" is a
133
189
  pattern match over your text, not comprehension. it will misfile some prompts.
134
190
  - **correlation, not instruction.** detailed prompts surviving more often does not
@@ -144,25 +200,50 @@ claim, so here is the grep that settles it against the single file it ships as:
144
200
  $ grep -nE '^[[:space:]]*(import|from) ' transcripto.py
145
201
  8:import sys, os, json, glob, re, sqlite3, argparse
146
202
  9:from datetime import datetime, timezone
147
- 225: import time
203
+ 260: import time
204
+ 1051: import datetime
205
+ 1061: from the separator), so the result is checked on disk and dropped if it is
148
206
  ```
149
207
 
150
- that is the whole import list, three lines. `time` sits inside the `watch` loop,
151
- which is why the pattern allows for indentation. anchor it at `^import` and you
152
- would miss one, so do not take my word for the anchor either.
208
+ five lines, four of which are imports and all four are stdlib. `time` and
209
+ `datetime` sit inside functions, which is why the pattern allows for indentation —
210
+ anchor it at `^import` and you would miss two, so do not take my word for the
211
+ anchor either. line 1061 is the pattern catching a docstring that happens to begin
212
+ with the word `from`; it is prose, not an import, and it is left in rather than
213
+ tuned out, because a grep you tuned until it agreed with you proves nothing.
153
214
 
154
- your transcripts stay in `~/.claude` and `~/.codex`. the index it builds stays in
155
- `~/.trace`.
215
+ what the list does NOT contain is the actual claim: no `socket`, no `urllib`, no
216
+ `requests`, no `http.client`, no `subprocess`. that one is checkable too, and the
217
+ right answer is no output at all:
218
+
219
+ ```
220
+ $ grep -nE '\b(socket|urllib|requests|http\.client|subprocess)\b' transcripto.py
221
+ $
222
+ ```
223
+
224
+ your transcripts stay in `~/.claude`, `~/.codex` and `~/.cursor`. the index it
225
+ builds stays in `~/.trace`.
156
226
 
157
227
  ## the rest of it
158
228
 
229
+ `coach` and `cost` read your transcript files directly and need nothing set up.
230
+ **the other six read a local index, so run this once first:**
231
+
232
+ ```
233
+ transcripto index # a few minutes on a large corpus, incremental after that
234
+ ```
235
+
236
+ on a 2,874-file corpus that was 164 seconds, measured 2026-08-29. if you skip it,
237
+ the six say so and exit 2.
238
+
159
239
  ```
160
240
  transcripto index build / refresh (incremental)
161
241
  transcripto watch live, new sessions get picked up as your agents work
162
242
  transcripto ask YOUR OWN messages about a topic, newest first + a rollup
163
243
  transcripto search full-text across everything (you + agents + tool logs)
164
244
  transcripto find every session that wrote / edited / read a file
165
- transcripto sessions recent sessions + their opening ask
245
+ transcripto trace what durably happened after each prompt you typed (0.1.2+)
246
+ transcripto sessions recent sessions + the first prompt YOU typed in each
166
247
  transcripto stats what you actually work on
167
248
  transcripto cost what ONE of your decisions costs
168
249
  transcripto coach which of YOUR prompt habits survive (a proxy)
@@ -172,11 +253,17 @@ transcripto coach which of YOUR prompt habits survive (a proxy)
172
253
  thinking about X across ALL my sessions", in your own words only.
173
254
 
174
255
  ```
175
- $ transcripto find USER-JOURNEY.md
176
- 2026-08-20 WROTE ~/CODE/mountain-of-helicon-main/USER-JOURNEY.md
256
+ $ transcripto find USER-JOURNEY.md # run 2026-08-29
257
+ USER-JOURNEY.md 4 touches across sessions (3 were writes/edits)
258
+
259
+ 2026-08-20 WROTE ~/CODE/mountain-of-helicon-main/USER-JOURNEY.md abd9e871
260
+ 2026-08-21 WROTE ~/…/Obsidian LIFE/00 Dashboard/suite-user-journey.md 0f845ede
261
+ 2026-08-27 read ~/CODE/hack-fleet-ata/docs/USER-JOURNEY.md cddfde29
262
+ 2026-08-27 WROTE ~/CODE/hack-fleet-ata/docs/USER-JOURNEY.md cddfde29
177
263
  ```
178
264
 
179
- the file you lost, found across every session you ever ran, one line.
265
+ the file you lost, found across every session you ever ran, with the session id
266
+ that touched it. `find` needs `transcripto index` first.
180
267
 
181
268
  ## Codex
182
269
 
@@ -215,11 +302,15 @@ just gives the file a name on your PATH.
215
302
  ## tests
216
303
 
217
304
  ```
218
- ./test_coach.sh 14 assertions
219
- ./test_codex.sh 14 assertions
220
- ./test_cost.sh 12 assertions
305
+ ./test_coach.sh 15 assertions
306
+ ./test_codex.sh 14 assertions
307
+ ./test_cost.sh 12 assertions
308
+ ./test_small_n.sh 7 assertions
309
+ ./test_label_bands.sh 13 assertions
221
310
  ```
222
311
 
312
+ 61 assertions, all green, re-run 2026-08-31.
313
+
223
314
  offline, no keys, on fixtures that inherit the real transcript shape including
224
315
  all four ways a non-human record disguises itself as `type: user`.
225
316
 
@@ -227,6 +318,15 @@ the load-bearing one in `test_coach.sh` is `REVERTED IS NOT SURVIVED`: a commit
227
318
  that got `reset --hard` in the same session left no durable record. flip that one
228
319
  line and the suite goes red, which is the point. a generous proxy is a broken one.
229
320
 
321
+ `test_label_bands.sh` is the other one, and it exists because 0.1.1 shipped the
322
+ defect it pins. `SURVIVES MOST` took the top five habits and `SURVIVES LEAST` took
323
+ the bottom five, which overlap whenever you have fewer than ten rankable habits —
324
+ so a new user, who necessarily has few, read the same habit at the same percentage
325
+ under both "do more of these" and "these tend to loop". on a 3-habit corpus 0.1.1
326
+ reprinted all three, all at 65% (22/34). the suite is red on the published 0.1.1
327
+ file and green on this one, and its `wide` band asserts the fix leaves a large
328
+ corpus byte-identical.
329
+
230
330
  ## why though
231
331
 
232
332
  your agent history is proof. every "yeah it's done" has a real trace sitting
@@ -11,6 +11,56 @@ your disk and never opens a socket.
11
11
  uvx transcripto coach
12
12
  ```
13
13
 
14
+ > **check which build you got.** `trace`, `--harness cursor`, and the "run `index`
15
+ > first" errors below all arrive in **0.1.2**. On anything older, `trace` is not a
16
+ > command, cursor is rejected, and the index-gated commands fail with a raw sqlite
17
+ > traceback instead of an instruction. One line settles which one you are holding:
18
+ >
19
+ > ```
20
+ > uvx transcripto --version # 0.1.2 or newer = this README is accurate
21
+ > ```
22
+ >
23
+ > *Read 2026-08-31: PyPI was serving 0.1.1 while this README described 0.1.2, so
24
+ > `uvx transcripto` gave the older build. If `--version` is not even a recognised
25
+ > flag, you have 0.1.1 — it was added in 0.1.2 precisely because there was no way
26
+ > to tell.* The repo always matches this README and needs nothing installed:
27
+ >
28
+ > ```
29
+ > git clone https://github.com/Morkeeth/transcripto && cd transcripto
30
+ > python3 transcripto.py coach
31
+ > ```
32
+
33
+ ## three harnesses, one instrument
34
+
35
+ ```
36
+ transcripto coach # Claude Code, ~/.claude/projects
37
+ transcripto coach --harness codex # Codex, ~/.codex
38
+ transcripto coach --harness cursor # Cursor, ~/.cursor/projects/*/agent-transcripts
39
+ ```
40
+
41
+ **Authorship is not the same gate in all three, and the tool says so rather than pooling them.**
42
+ Claude Code stamps `promptSource: typed`, which is the measured-reliable signal: about 95% of raw
43
+ `type: user` records are not the operator at all. Cursor has no such field. Its one honest
44
+ equivalent is the `<user_query>` wrapper it puts around a submitted prompt, which injected and
45
+ tool-result records do not carry. That is a weaker signal and it is labelled weaker.
46
+
47
+ ## `trace` — what actually happened after you asked
48
+
49
+ `ask` shows what you typed. `find` shows what a file went through. Neither answers the
50
+ question that matters after the fact: **you asked for X, did anything durable happen?**
51
+
52
+ ```
53
+ transcripto trace "the gate"
54
+ ```
55
+
56
+ It walks each of your matching prompts forward inside its own session and lists the writes
57
+ and edits that followed, stopping at your next prompt so one turn cannot claim the next
58
+ turn's work. Green dot = something durable landed. Red = nothing was touched.
59
+
60
+ **Honest limit:** a write following a prompt in the same session is CO-OCCURRENCE, not proof
61
+ the write was caused by that prompt or that it was correct. Same proxy `coach` uses, labelled
62
+ the same way.
63
+
14
64
  ## what you get back
15
65
 
16
66
  this is a real run on one machine, pasted unedited, 2026-08-28:
@@ -55,9 +105,13 @@ them as a snapshot rather than a constant. yours will be different, which is the
55
105
  whole point. the last two lines are the ones that sting: it hands you back your
56
106
  own best and worst prompt, verbatim, with the receipt for why it scored each one.
57
107
 
58
- on that machine, prompts that wrote down what done looks like survived **63% of
59
- the time (66 of 104)**. prompts with no stated intent survived **39% (411 of
60
- 1043)**. i had spent a year blaming the model.
108
+ on that machine, on that date, prompts that wrote down what done looks like
109
+ survived **63% of the time (66 of 104)**. prompts with no stated intent survived
110
+ **39% (411 of 1043)**. i had spent a year blaming the model.
111
+
112
+ one day later, 2026-08-29, the same command on the same machine read 63% (67 of
113
+ 107) and 40% (424 of 1072) over 2,874 transcripts. the percentages held and the
114
+ denominators moved, which is what a snapshot is supposed to do.
61
115
 
62
116
  ## the proxy caveat, which travels with every number
63
117
 
@@ -109,8 +163,10 @@ read these before you quote a number at anyone.
109
163
  - **one operator's corpus.** every figure in this README comes from one machine.
110
164
  it is an existence proof that the measurement runs, not a finding about how
111
165
  people prompt. run it on yours and you get yours.
112
- - **two harnesses today: Claude Code and Codex.** nothing else is supported.
113
- cursor, aider, and the rest are not read.
166
+ - **three harnesses today: Claude Code, Codex, Cursor.** nothing else is supported.
167
+ aider and the rest are not read. and the three are not equal: Claude Code has a
168
+ measured-reliable authorship field, Cursor has only the `<user_query>` wrapper,
169
+ which is weaker and is labelled weaker wherever it is used.
114
170
  - **the habit labels are heuristics.** "states-a-check-or-done-condition" is a
115
171
  pattern match over your text, not comprehension. it will misfile some prompts.
116
172
  - **correlation, not instruction.** detailed prompts surviving more often does not
@@ -126,25 +182,50 @@ claim, so here is the grep that settles it against the single file it ships as:
126
182
  $ grep -nE '^[[:space:]]*(import|from) ' transcripto.py
127
183
  8:import sys, os, json, glob, re, sqlite3, argparse
128
184
  9:from datetime import datetime, timezone
129
- 225: import time
185
+ 260: import time
186
+ 1051: import datetime
187
+ 1061: from the separator), so the result is checked on disk and dropped if it is
130
188
  ```
131
189
 
132
- that is the whole import list, three lines. `time` sits inside the `watch` loop,
133
- which is why the pattern allows for indentation. anchor it at `^import` and you
134
- would miss one, so do not take my word for the anchor either.
190
+ five lines, four of which are imports and all four are stdlib. `time` and
191
+ `datetime` sit inside functions, which is why the pattern allows for indentation —
192
+ anchor it at `^import` and you would miss two, so do not take my word for the
193
+ anchor either. line 1061 is the pattern catching a docstring that happens to begin
194
+ with the word `from`; it is prose, not an import, and it is left in rather than
195
+ tuned out, because a grep you tuned until it agreed with you proves nothing.
135
196
 
136
- your transcripts stay in `~/.claude` and `~/.codex`. the index it builds stays in
137
- `~/.trace`.
197
+ what the list does NOT contain is the actual claim: no `socket`, no `urllib`, no
198
+ `requests`, no `http.client`, no `subprocess`. that one is checkable too, and the
199
+ right answer is no output at all:
200
+
201
+ ```
202
+ $ grep -nE '\b(socket|urllib|requests|http\.client|subprocess)\b' transcripto.py
203
+ $
204
+ ```
205
+
206
+ your transcripts stay in `~/.claude`, `~/.codex` and `~/.cursor`. the index it
207
+ builds stays in `~/.trace`.
138
208
 
139
209
  ## the rest of it
140
210
 
211
+ `coach` and `cost` read your transcript files directly and need nothing set up.
212
+ **the other six read a local index, so run this once first:**
213
+
214
+ ```
215
+ transcripto index # a few minutes on a large corpus, incremental after that
216
+ ```
217
+
218
+ on a 2,874-file corpus that was 164 seconds, measured 2026-08-29. if you skip it,
219
+ the six say so and exit 2.
220
+
141
221
  ```
142
222
  transcripto index build / refresh (incremental)
143
223
  transcripto watch live, new sessions get picked up as your agents work
144
224
  transcripto ask YOUR OWN messages about a topic, newest first + a rollup
145
225
  transcripto search full-text across everything (you + agents + tool logs)
146
226
  transcripto find every session that wrote / edited / read a file
147
- transcripto sessions recent sessions + their opening ask
227
+ transcripto trace what durably happened after each prompt you typed (0.1.2+)
228
+ transcripto sessions recent sessions + the first prompt YOU typed in each
148
229
  transcripto stats what you actually work on
149
230
  transcripto cost what ONE of your decisions costs
150
231
  transcripto coach which of YOUR prompt habits survive (a proxy)
@@ -154,11 +235,17 @@ transcripto coach which of YOUR prompt habits survive (a proxy)
154
235
  thinking about X across ALL my sessions", in your own words only.
155
236
 
156
237
  ```
157
- $ transcripto find USER-JOURNEY.md
158
- 2026-08-20 WROTE ~/CODE/mountain-of-helicon-main/USER-JOURNEY.md
238
+ $ transcripto find USER-JOURNEY.md # run 2026-08-29
239
+ USER-JOURNEY.md 4 touches across sessions (3 were writes/edits)
240
+
241
+ 2026-08-20 WROTE ~/CODE/mountain-of-helicon-main/USER-JOURNEY.md abd9e871
242
+ 2026-08-21 WROTE ~/…/Obsidian LIFE/00 Dashboard/suite-user-journey.md 0f845ede
243
+ 2026-08-27 read ~/CODE/hack-fleet-ata/docs/USER-JOURNEY.md cddfde29
244
+ 2026-08-27 WROTE ~/CODE/hack-fleet-ata/docs/USER-JOURNEY.md cddfde29
159
245
  ```
160
246
 
161
- the file you lost, found across every session you ever ran, one line.
247
+ the file you lost, found across every session you ever ran, with the session id
248
+ that touched it. `find` needs `transcripto index` first.
162
249
 
163
250
  ## Codex
164
251
 
@@ -197,11 +284,15 @@ just gives the file a name on your PATH.
197
284
  ## tests
198
285
 
199
286
  ```
200
- ./test_coach.sh 14 assertions
201
- ./test_codex.sh 14 assertions
202
- ./test_cost.sh 12 assertions
287
+ ./test_coach.sh 15 assertions
288
+ ./test_codex.sh 14 assertions
289
+ ./test_cost.sh 12 assertions
290
+ ./test_small_n.sh 7 assertions
291
+ ./test_label_bands.sh 13 assertions
203
292
  ```
204
293
 
294
+ 61 assertions, all green, re-run 2026-08-31.
295
+
205
296
  offline, no keys, on fixtures that inherit the real transcript shape including
206
297
  all four ways a non-human record disguises itself as `type: user`.
207
298
 
@@ -209,6 +300,15 @@ the load-bearing one in `test_coach.sh` is `REVERTED IS NOT SURVIVED`: a commit
209
300
  that got `reset --hard` in the same session left no durable record. flip that one
210
301
  line and the suite goes red, which is the point. a generous proxy is a broken one.
211
302
 
303
+ `test_label_bands.sh` is the other one, and it exists because 0.1.1 shipped the
304
+ defect it pins. `SURVIVES MOST` took the top five habits and `SURVIVES LEAST` took
305
+ the bottom five, which overlap whenever you have fewer than ten rankable habits —
306
+ so a new user, who necessarily has few, read the same habit at the same percentage
307
+ under both "do more of these" and "these tend to loop". on a 3-habit corpus 0.1.1
308
+ reprinted all three, all at 65% (22/34). the suite is red on the published 0.1.1
309
+ file and green on this one, and its `wide` band asserts the fix leaves a large
310
+ corpus byte-identical.
311
+
212
312
  ## why though
213
313
 
214
314
  your agent history is proof. every "yeah it's done" has a real trace sitting
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "transcripto"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Search everything your coding agents ever did, grade your own prompts, and price your decisions. Local, stdlib-only, your data never leaves the machine."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transcripto
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Search everything your coding agents ever did, grade your own prompts, and price your decisions. Local, stdlib-only, your data never leaves the machine.
5
5
  Author: Oscar Morke
6
6
  License: MIT
@@ -29,6 +29,56 @@ your disk and never opens a socket.
29
29
  uvx transcripto coach
30
30
  ```
31
31
 
32
+ > **check which build you got.** `trace`, `--harness cursor`, and the "run `index`
33
+ > first" errors below all arrive in **0.1.2**. On anything older, `trace` is not a
34
+ > command, cursor is rejected, and the index-gated commands fail with a raw sqlite
35
+ > traceback instead of an instruction. One line settles which one you are holding:
36
+ >
37
+ > ```
38
+ > uvx transcripto --version # 0.1.2 or newer = this README is accurate
39
+ > ```
40
+ >
41
+ > *Read 2026-08-31: PyPI was serving 0.1.1 while this README described 0.1.2, so
42
+ > `uvx transcripto` gave the older build. If `--version` is not even a recognised
43
+ > flag, you have 0.1.1 — it was added in 0.1.2 precisely because there was no way
44
+ > to tell.* The repo always matches this README and needs nothing installed:
45
+ >
46
+ > ```
47
+ > git clone https://github.com/Morkeeth/transcripto && cd transcripto
48
+ > python3 transcripto.py coach
49
+ > ```
50
+
51
+ ## three harnesses, one instrument
52
+
53
+ ```
54
+ transcripto coach # Claude Code, ~/.claude/projects
55
+ transcripto coach --harness codex # Codex, ~/.codex
56
+ transcripto coach --harness cursor # Cursor, ~/.cursor/projects/*/agent-transcripts
57
+ ```
58
+
59
+ **Authorship is not the same gate in all three, and the tool says so rather than pooling them.**
60
+ Claude Code stamps `promptSource: typed`, which is the measured-reliable signal: about 95% of raw
61
+ `type: user` records are not the operator at all. Cursor has no such field. Its one honest
62
+ equivalent is the `<user_query>` wrapper it puts around a submitted prompt, which injected and
63
+ tool-result records do not carry. That is a weaker signal and it is labelled weaker.
64
+
65
+ ## `trace` — what actually happened after you asked
66
+
67
+ `ask` shows what you typed. `find` shows what a file went through. Neither answers the
68
+ question that matters after the fact: **you asked for X, did anything durable happen?**
69
+
70
+ ```
71
+ transcripto trace "the gate"
72
+ ```
73
+
74
+ It walks each of your matching prompts forward inside its own session and lists the writes
75
+ and edits that followed, stopping at your next prompt so one turn cannot claim the next
76
+ turn's work. Green dot = something durable landed. Red = nothing was touched.
77
+
78
+ **Honest limit:** a write following a prompt in the same session is CO-OCCURRENCE, not proof
79
+ the write was caused by that prompt or that it was correct. Same proxy `coach` uses, labelled
80
+ the same way.
81
+
32
82
  ## what you get back
33
83
 
34
84
  this is a real run on one machine, pasted unedited, 2026-08-28:
@@ -73,9 +123,13 @@ them as a snapshot rather than a constant. yours will be different, which is the
73
123
  whole point. the last two lines are the ones that sting: it hands you back your
74
124
  own best and worst prompt, verbatim, with the receipt for why it scored each one.
75
125
 
76
- on that machine, prompts that wrote down what done looks like survived **63% of
77
- the time (66 of 104)**. prompts with no stated intent survived **39% (411 of
78
- 1043)**. i had spent a year blaming the model.
126
+ on that machine, on that date, prompts that wrote down what done looks like
127
+ survived **63% of the time (66 of 104)**. prompts with no stated intent survived
128
+ **39% (411 of 1043)**. i had spent a year blaming the model.
129
+
130
+ one day later, 2026-08-29, the same command on the same machine read 63% (67 of
131
+ 107) and 40% (424 of 1072) over 2,874 transcripts. the percentages held and the
132
+ denominators moved, which is what a snapshot is supposed to do.
79
133
 
80
134
  ## the proxy caveat, which travels with every number
81
135
 
@@ -127,8 +181,10 @@ read these before you quote a number at anyone.
127
181
  - **one operator's corpus.** every figure in this README comes from one machine.
128
182
  it is an existence proof that the measurement runs, not a finding about how
129
183
  people prompt. run it on yours and you get yours.
130
- - **two harnesses today: Claude Code and Codex.** nothing else is supported.
131
- cursor, aider, and the rest are not read.
184
+ - **three harnesses today: Claude Code, Codex, Cursor.** nothing else is supported.
185
+ aider and the rest are not read. and the three are not equal: Claude Code has a
186
+ measured-reliable authorship field, Cursor has only the `<user_query>` wrapper,
187
+ which is weaker and is labelled weaker wherever it is used.
132
188
  - **the habit labels are heuristics.** "states-a-check-or-done-condition" is a
133
189
  pattern match over your text, not comprehension. it will misfile some prompts.
134
190
  - **correlation, not instruction.** detailed prompts surviving more often does not
@@ -144,25 +200,50 @@ claim, so here is the grep that settles it against the single file it ships as:
144
200
  $ grep -nE '^[[:space:]]*(import|from) ' transcripto.py
145
201
  8:import sys, os, json, glob, re, sqlite3, argparse
146
202
  9:from datetime import datetime, timezone
147
- 225: import time
203
+ 260: import time
204
+ 1051: import datetime
205
+ 1061: from the separator), so the result is checked on disk and dropped if it is
148
206
  ```
149
207
 
150
- that is the whole import list, three lines. `time` sits inside the `watch` loop,
151
- which is why the pattern allows for indentation. anchor it at `^import` and you
152
- would miss one, so do not take my word for the anchor either.
208
+ five lines, four of which are imports and all four are stdlib. `time` and
209
+ `datetime` sit inside functions, which is why the pattern allows for indentation —
210
+ anchor it at `^import` and you would miss two, so do not take my word for the
211
+ anchor either. line 1061 is the pattern catching a docstring that happens to begin
212
+ with the word `from`; it is prose, not an import, and it is left in rather than
213
+ tuned out, because a grep you tuned until it agreed with you proves nothing.
153
214
 
154
- your transcripts stay in `~/.claude` and `~/.codex`. the index it builds stays in
155
- `~/.trace`.
215
+ what the list does NOT contain is the actual claim: no `socket`, no `urllib`, no
216
+ `requests`, no `http.client`, no `subprocess`. that one is checkable too, and the
217
+ right answer is no output at all:
218
+
219
+ ```
220
+ $ grep -nE '\b(socket|urllib|requests|http\.client|subprocess)\b' transcripto.py
221
+ $
222
+ ```
223
+
224
+ your transcripts stay in `~/.claude`, `~/.codex` and `~/.cursor`. the index it
225
+ builds stays in `~/.trace`.
156
226
 
157
227
  ## the rest of it
158
228
 
229
+ `coach` and `cost` read your transcript files directly and need nothing set up.
230
+ **the other six read a local index, so run this once first:**
231
+
232
+ ```
233
+ transcripto index # a few minutes on a large corpus, incremental after that
234
+ ```
235
+
236
+ on a 2,874-file corpus that was 164 seconds, measured 2026-08-29. if you skip it,
237
+ the six say so and exit 2.
238
+
159
239
  ```
160
240
  transcripto index build / refresh (incremental)
161
241
  transcripto watch live, new sessions get picked up as your agents work
162
242
  transcripto ask YOUR OWN messages about a topic, newest first + a rollup
163
243
  transcripto search full-text across everything (you + agents + tool logs)
164
244
  transcripto find every session that wrote / edited / read a file
165
- transcripto sessions recent sessions + their opening ask
245
+ transcripto trace what durably happened after each prompt you typed (0.1.2+)
246
+ transcripto sessions recent sessions + the first prompt YOU typed in each
166
247
  transcripto stats what you actually work on
167
248
  transcripto cost what ONE of your decisions costs
168
249
  transcripto coach which of YOUR prompt habits survive (a proxy)
@@ -172,11 +253,17 @@ transcripto coach which of YOUR prompt habits survive (a proxy)
172
253
  thinking about X across ALL my sessions", in your own words only.
173
254
 
174
255
  ```
175
- $ transcripto find USER-JOURNEY.md
176
- 2026-08-20 WROTE ~/CODE/mountain-of-helicon-main/USER-JOURNEY.md
256
+ $ transcripto find USER-JOURNEY.md # run 2026-08-29
257
+ USER-JOURNEY.md 4 touches across sessions (3 were writes/edits)
258
+
259
+ 2026-08-20 WROTE ~/CODE/mountain-of-helicon-main/USER-JOURNEY.md abd9e871
260
+ 2026-08-21 WROTE ~/…/Obsidian LIFE/00 Dashboard/suite-user-journey.md 0f845ede
261
+ 2026-08-27 read ~/CODE/hack-fleet-ata/docs/USER-JOURNEY.md cddfde29
262
+ 2026-08-27 WROTE ~/CODE/hack-fleet-ata/docs/USER-JOURNEY.md cddfde29
177
263
  ```
178
264
 
179
- the file you lost, found across every session you ever ran, one line.
265
+ the file you lost, found across every session you ever ran, with the session id
266
+ that touched it. `find` needs `transcripto index` first.
180
267
 
181
268
  ## Codex
182
269
 
@@ -215,11 +302,15 @@ just gives the file a name on your PATH.
215
302
  ## tests
216
303
 
217
304
  ```
218
- ./test_coach.sh 14 assertions
219
- ./test_codex.sh 14 assertions
220
- ./test_cost.sh 12 assertions
305
+ ./test_coach.sh 15 assertions
306
+ ./test_codex.sh 14 assertions
307
+ ./test_cost.sh 12 assertions
308
+ ./test_small_n.sh 7 assertions
309
+ ./test_label_bands.sh 13 assertions
221
310
  ```
222
311
 
312
+ 61 assertions, all green, re-run 2026-08-31.
313
+
223
314
  offline, no keys, on fixtures that inherit the real transcript shape including
224
315
  all four ways a non-human record disguises itself as `type: user`.
225
316
 
@@ -227,6 +318,15 @@ the load-bearing one in `test_coach.sh` is `REVERTED IS NOT SURVIVED`: a commit
227
318
  that got `reset --hard` in the same session left no durable record. flip that one
228
319
  line and the suite goes red, which is the point. a generous proxy is a broken one.
229
320
 
321
+ `test_label_bands.sh` is the other one, and it exists because 0.1.1 shipped the
322
+ defect it pins. `SURVIVES MOST` took the top five habits and `SURVIVES LEAST` took
323
+ the bottom five, which overlap whenever you have fewer than ten rankable habits —
324
+ so a new user, who necessarily has few, read the same habit at the same percentage
325
+ under both "do more of these" and "these tend to loop". on a 3-habit corpus 0.1.1
326
+ reprinted all three, all at 65% (22/34). the suite is red on the published 0.1.1
327
+ file and green on this one, and its `wide` band asserts the fix leaves a large
328
+ corpus byte-identical.
329
+
230
330
  ## why though
231
331
 
232
332
  your agent history is proof. every "yeah it's done" has a real trace sitting
@@ -21,6 +21,12 @@ def _prog():
21
21
 
22
22
  PROG = _prog()
23
23
 
24
+ # The single source of truth for the version, so `--version` cannot drift from the
25
+ # packaging. A stranger who reads the README on GitHub and installs from PyPI can be
26
+ # holding a different build than the one the README describes, and until this flag
27
+ # existed there was no way for them to tell which.
28
+ VERSION = "0.1.2"
29
+
24
30
  USAGE = """
25
31
  %(p)s index build / refresh the index (incremental)
26
32
  %(p)s ask "<topic>" YOUR OWN messages about a topic, newest first + a rollup
@@ -30,7 +36,7 @@ USAGE = """
30
36
  %(p)s stats what you work on most: projects, files, volume
31
37
  %(p)s cost what ONE decision of yours costs: spend / turns you typed
32
38
  %(p)s coach which of YOUR prompt habits actually survive (a proxy)
33
- %(p)s coach --harness codex grade your Codex (~/.codex) transcripts instead
39
+ %(p)s coach --harness codex grade your Codex (~/.codex) transcripts instead\n %(p)s coach --harness cursor grade your Cursor (~/.cursor) transcripts instead
34
40
  %(p)s coach --verified-human subtract likely-PASTED turns (echoes of agent output)
35
41
  """ % {"p": PROG}
36
42
 
@@ -42,10 +48,24 @@ FILE_TOOLS = {"Write": "write", "Edit": "edit", "Read": "read",
42
48
  "NotebookEdit": "edit", "MultiEdit": "edit"}
43
49
 
44
50
 
45
- def connect():
51
+ def connect(require_index=True):
52
+ """Open the local index. Six commands (search/ask/find/trace/sessions/stats)
53
+ READ it and are meaningless without it; `index` and `watch` BUILD it and pass
54
+ require_index=False. Before this guard existed a cold start printed a raw
55
+ `sqlite3.OperationalError: no such table: messages_fts` — and three of the six
56
+ printed it and still exited 0."""
46
57
  os.makedirs(os.path.dirname(DB), exist_ok=True)
47
58
  con = sqlite3.connect(DB)
48
59
  con.execute("PRAGMA journal_mode=WAL")
60
+ if require_index and not con.execute(
61
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
62
+ ).fetchone():
63
+ print("\n no index yet. (looked in %s)\n" % DB)
64
+ print(" this command reads a local index of your transcripts. build it once:\n")
65
+ print(" transcripto index\n")
66
+ print(" it takes a few minutes on a large corpus and is incremental after that.")
67
+ print(" `coach` and `cost` read your transcripts directly and need no index.\n")
68
+ sys.exit(2)
49
69
  return con
50
70
 
51
71
 
@@ -166,13 +186,26 @@ def is_human_turn(d):
166
186
  return True
167
187
 
168
188
 
169
- def _index_once(con):
170
- """Incrementally index every changed/new transcript. Returns (new_sessions, new_msgs)."""
189
+ def _index_once(con, progress=False):
190
+ """Incrementally index every changed/new transcript. Returns (new_sessions, new_msgs).
191
+
192
+ progress=True prints a heartbeat to stderr. A first index over a few thousand
193
+ transcripts takes minutes, and a silent terminal for that long reads as a hang,
194
+ which is the point at which a first-time user kills it.
195
+ """
171
196
  seen = []
172
197
  for root in ROOTS:
173
198
  seen += glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)
199
+ if progress:
200
+ sys.stderr.write("scanning %d transcript file(s) in %s\n"
201
+ % (len(seen), ", ".join(r.replace(HOME, "~") for r in ROOTS)))
202
+ sys.stderr.flush()
174
203
  new = msgs = 0
175
- for f in sorted(seen):
204
+ for done, f in enumerate(sorted(seen), 1):
205
+ if progress and done % 250 == 0:
206
+ sys.stderr.write(" %d/%d files · %d changed · %d messages\r"
207
+ % (done, len(seen), new, msgs))
208
+ sys.stderr.flush()
176
209
  mt = os.path.getmtime(f)
177
210
  row = con.execute("SELECT mtime FROM indexed WHERE session_file=?", (f,)).fetchone()
178
211
  if row and abs(row[0] - mt) < 1e-6:
@@ -214,8 +247,10 @@ def _index_once(con):
214
247
 
215
248
 
216
249
  def cmd_index(args):
217
- con = connect(); init_schema(con)
218
- new, msgs = _index_once(con)
250
+ con = connect(require_index=False); init_schema(con)
251
+ new, msgs = _index_once(con, progress=sys.stderr.isatty())
252
+ if sys.stderr.isatty():
253
+ sys.stderr.write(" " * 60 + "\r"); sys.stderr.flush()
219
254
  tot = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
220
255
  print("indexed %d changed sessions · +%d messages · %d total searchable" % (new, msgs, tot))
221
256
 
@@ -223,7 +258,7 @@ def cmd_index(args):
223
258
  def cmd_watch(args):
224
259
  """Live indexing: pick up new transcripts + trace lines automatically as they land."""
225
260
  import time
226
- con = connect(); init_schema(con)
261
+ con = connect(require_index=False); init_schema(con)
227
262
  _index_once(con)
228
263
  tot = con.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
229
264
  print(PROG + " watch, live. %d messages indexed; polling %s every %ds. Ctrl-C to stop."
@@ -299,11 +334,11 @@ def cmd_ask(args):
299
334
  any_hit = 0
300
335
  if any_hit:
301
336
  print("no messages YOU typed about '%s', but %d agent/tool turns mention it."
302
- "\ntry `" + PROG + " search \"%s\"` to see those, or `" + PROG + " index` if it's new."
303
- % (args.query, any_hit, args.query))
337
+ "\ntry `%s search \"%s\"` to see those, or `%s index` if it's new."
338
+ % (args.query, any_hit, PROG, args.query, PROG))
304
339
  else:
305
- print("nothing about '%s' yet. try `" + PROG + " index` first, or broader terms."
306
- % args.query)
340
+ print("nothing about '%s' yet. try `%s index` first, or broader terms."
341
+ % (args.query, PROG))
307
342
  return
308
343
 
309
344
  # ---- rollup: the arc across ALL your matches, not just the shown page ----
@@ -357,15 +392,99 @@ def cmd_find(args):
357
392
  print("%s %s %s \033[2m%s\033[0m" % (_day(ts), tag, path, sid[:8]))
358
393
 
359
394
 
395
+ def cmd_trace(args):
396
+ """WHAT ACTUALLY HAPPENED after you asked. The join nothing else makes.
397
+
398
+ `ask` shows what you typed. `find` shows what a file went through. Neither
399
+ answers the only question that matters after the fact: you asked for X, did
400
+ anything durable happen? This walks each of your matching prompts forward
401
+ inside its own session and lists the writes and edits that followed it,
402
+ stopping at your next prompt so one turn cannot claim the next turn's work.
403
+
404
+ HONEST LIMIT, stated because the product is about claims: a write following
405
+ a prompt in the same session is CO-OCCURRENCE, not proof the write was caused
406
+ by that prompt or that it was correct. It is the same proxy `coach` uses and
407
+ it is labelled the same way.
408
+ """
409
+ con = connect()
410
+ try:
411
+ rows = con.execute(
412
+ "SELECT m.id,m.ts,m.project,m.cwd,m.session_id,m.text,m.git_branch"
413
+ " FROM messages_fts JOIN messages m ON m.id=messages_fts.rowid"
414
+ " WHERE messages_fts MATCH ? AND m.is_human=1"
415
+ " ORDER BY m.ts DESC LIMIT ?",
416
+ (_match(args.query), args.limit)).fetchall()
417
+ except sqlite3.OperationalError as e:
418
+ print("trace error:", e); return
419
+ if not rows:
420
+ print("no prompts YOU typed matching '%s'. try `%s ask \"%s\"` or `%s index`."
421
+ % (args.query, PROG, args.query, PROG)); return
422
+
423
+ landed = stalled = 0
424
+ blocks = []
425
+ for _id, ts, proj, cwd, sid, text, branch in rows:
426
+ nxt = con.execute(
427
+ "SELECT MIN(ts) FROM messages WHERE session_id=? AND is_human=1 AND ts>?",
428
+ (sid, ts)).fetchone()[0]
429
+ if nxt:
430
+ files = con.execute(
431
+ "SELECT ts,action,path FROM files WHERE session_id=? AND ts>? AND ts<?"
432
+ " ORDER BY ts", (sid, ts, nxt)).fetchall()
433
+ else:
434
+ files = con.execute(
435
+ "SELECT ts,action,path FROM files WHERE session_id=? AND ts>?"
436
+ " ORDER BY ts", (sid, ts)).fetchall()
437
+ durable = [f for f in files if f[1] in ("write", "edit")]
438
+ if durable: landed += 1
439
+ else: stalled += 1
440
+ blocks.append((ts, proj, cwd, sid, text, branch, files, durable))
441
+
442
+ total = len(blocks)
443
+ pct = (100.0 * landed / total) if total else 0.0
444
+ print("\033[1m%s\033[0m %d prompt%s you typed · \033[32m%d landed\033[0m · "
445
+ "\033[31m%d produced nothing durable\033[0m · %.0f%%"
446
+ % (args.query, total, "" if total == 1 else "s", landed, stalled, pct))
447
+ print("\033[2mdurable = a Write or Edit in the same session before your next prompt. "
448
+ "CO-OCCURRENCE, not proof of cause or correctness.\033[0m\n")
449
+
450
+ for ts, proj, cwd, sid, text, branch, files, durable in blocks:
451
+ mark = "\033[32m●\033[0m" if durable else "\033[31m○\033[0m"
452
+ head = " ".join((text or "").split())[:110]
453
+ br = (" \033[2m%s\033[0m" % branch) if branch else ""
454
+ print("%s %s \033[36m%s\033[0m%s \033[2m%s\033[0m"
455
+ % (mark, _day(ts), _repo(cwd, proj)[:22], br, (sid or "")[:8]))
456
+ print(" \033[1m\"%s\"\033[0m" % head)
457
+ if not files:
458
+ print(" \033[31mnothing touched\033[0m")
459
+ else:
460
+ shown = files if args.all else files[:8]
461
+ for fts, action, path in shown:
462
+ tag = {"write": "\033[32mWROTE\033[0m", "edit": "\033[33mEDIT \033[0m",
463
+ "read": "\033[2mread \033[0m"}.get(action, action.upper())
464
+ print(" %s %s" % (tag, path))
465
+ if len(files) > len(shown):
466
+ print(" \033[2m… %d more, -a to show all\033[0m" % (len(files) - len(shown)))
467
+ print()
468
+
469
+
360
470
  def cmd_sessions(args):
361
471
  con = connect()
362
472
  rows = con.execute(
363
473
  "SELECT session_id,project,MAX(ts) mx,COUNT(*) FROM messages"
364
474
  " GROUP BY session_id ORDER BY mx DESC LIMIT ?", (args.limit,)).fetchall()
365
475
  for sid, proj, mx, cnt in rows:
476
+ # Same gate the rest of the tool sells: the opening ask is the first turn
477
+ # the OPERATOR typed (is_human=1), not the first `type: user` record. Without
478
+ # it the column filled up with `<command-name>/clear</command-name>` — a slash
479
+ # command the harness wrote, printed under a heading that says "opening ask".
480
+ # Slash commands clear is_human on most harnesses but not all, so the
481
+ # wrapper is excluded by name too, and a run of them is skipped rather than
482
+ # taken as the title.
366
483
  t = con.execute("SELECT text FROM messages WHERE session_id=? AND role='user'"
367
- " AND text!='' ORDER BY ts LIMIT 1", (sid,)).fetchone()
368
- title = (t[0][:90].replace("\n", " ") if t else "(no user text)")
484
+ " AND is_human=1 AND text!='' AND text NOT LIKE '<command-name>%'"
485
+ " AND text NOT LIKE '<local-command-%' ORDER BY ts LIMIT 1",
486
+ (sid,)).fetchone()
487
+ title = (t[0][:90].replace("\n", " ") if t else "\033[2m(no prompt you typed)\033[0m")
369
488
  print("\033[2m%s\033[0m \033[36m%-22s\033[0m %4d msg %s"
370
489
  % (_day(mx), proj[:22], cnt, title))
371
490
 
@@ -908,6 +1027,102 @@ def _codex_rows(path):
908
1027
  return rows
909
1028
 
910
1029
 
1030
+ # --- Cursor CLI ------------------------------------------------------------
1031
+ # Cursor writes ~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl
1032
+ # with the SAME {role, message:{content:[...]}} shape Claude Code uses, so
1033
+ # extract() already reads it. Three things are missing and this connector adds
1034
+ # them: there is no timestamp field (it is embedded in the user text), no cwd
1035
+ # field (it is the directory slug), and the human turn is wrapped in
1036
+ # <user_query> tags that would otherwise be graded as part of the prompt.
1037
+
1038
+ _CUR_TS = re.compile(r"<timestamp>(.*?)</timestamp>", re.S)
1039
+ _CUR_Q = re.compile(r"<user_query>\s*(.*?)\s*</user_query>", re.S)
1040
+
1041
+
1042
+ def _cursor_ts(text):
1043
+ """'Thursday, Aug 13, 2026, 5:53 PM (UTC+2)' -> ISO-ish, or ''. Never guesses
1044
+ a date it cannot parse: an unparsed stamp yields '' rather than today."""
1045
+ m = _CUR_TS.search(text or "")
1046
+ if not m:
1047
+ return ""
1048
+ raw = m.group(1).strip()
1049
+ for fmt in ("%A, %b %d, %Y, %I:%M %p", "%A, %B %d, %Y, %I:%M %p"):
1050
+ try:
1051
+ import datetime
1052
+ return datetime.datetime.strptime(raw.split(" (")[0], fmt).isoformat()
1053
+ except Exception:
1054
+ pass
1055
+ return ""
1056
+
1057
+
1058
+ def _cursor_cwd(path):
1059
+ """~/.cursor/projects/Users-morkeeth-CODE-zup/... -> /Users/morkeeth/CODE/zup.
1060
+ The slug is lossy (a real hyphen in a directory name is indistinguishable
1061
+ from the separator), so the result is checked on disk and dropped if it is
1062
+ not a real directory. A wrong cwd is worse than no cwd."""
1063
+ parts = os.path.normpath(path).split(os.sep)
1064
+ try:
1065
+ slug = parts[parts.index("projects") + 1]
1066
+ except (ValueError, IndexError):
1067
+ return ""
1068
+ cand = "/" + slug.replace("-", "/")
1069
+ if os.path.isdir(cand):
1070
+ return cand
1071
+ # try collapsing trailing segments back into hyphenated names
1072
+ segs = slug.split("-")
1073
+ for join_from in range(len(segs) - 1, 0, -1):
1074
+ cand = "/" + "/".join(segs[:join_from] + ["-".join(segs[join_from:])])
1075
+ if os.path.isdir(cand):
1076
+ return cand
1077
+ return ""
1078
+
1079
+
1080
+ def _cursor_rows(path):
1081
+ """Normalise Cursor records onto the Claude shape the indexer already reads.
1082
+
1083
+ AUTHORSHIP, stated because it is a DIFFERENT gate with different provenance.
1084
+ Claude Code stamps `promptSource: typed`, which is the measured-reliable signal
1085
+ (~95% of raw `type: user` records are not the operator). Cursor has no such
1086
+ field. Its one honest equivalent is the `<user_query>` wrapper: Cursor puts it
1087
+ around a prompt the operator submitted, and injected/tool-result user records
1088
+ do not carry it. So a Cursor record counts as typed IFF it carried that
1089
+ wrapper. This is a weaker signal than Claude's and it is labelled as such
1090
+ rather than silently pooled with it.
1091
+ """
1092
+ cwd = _cursor_cwd(path)
1093
+ sid = os.path.splitext(os.path.basename(path))[0]
1094
+ last_ts = ""
1095
+ out = []
1096
+ for d in _iter_json(path):
1097
+ if not isinstance(d, dict) or "message" not in d:
1098
+ continue
1099
+ msg = d.get("message") or {}
1100
+ c = msg.get("content")
1101
+ wrapped = False
1102
+ if isinstance(c, list):
1103
+ for b in c:
1104
+ if isinstance(b, dict) and b.get("type") == "text":
1105
+ t = b.get("text") or ""
1106
+ ts = _cursor_ts(t)
1107
+ if ts:
1108
+ last_ts = ts
1109
+ q = _CUR_Q.search(t)
1110
+ if q:
1111
+ b["text"] = q.group(1)
1112
+ wrapped = True
1113
+ elif _CUR_TS.search(t):
1114
+ b["text"] = _CUR_TS.sub("", t).strip()
1115
+ role = d.get("role") or msg.get("role")
1116
+ d["type"] = "user" if role == "user" else "assistant"
1117
+ if role == "user" and wrapped:
1118
+ d["promptSource"] = "typed"
1119
+ d["timestamp"] = last_ts
1120
+ d["cwd"] = cwd
1121
+ d["sessionId"] = sid
1122
+ out.append(d)
1123
+ return out
1124
+
1125
+
911
1126
  def _sniff(path):
912
1127
  """'codex' (rollout), 'codex-history' (history.jsonl), or 'claude', from the
913
1128
  first parseable record. Lets `--root ~/.codex` and mixed dirs just work."""
@@ -916,6 +1131,9 @@ def _sniff(path):
916
1131
  return "codex"
917
1132
  if set(d.keys()) == {"session_id", "ts", "text"}:
918
1133
  return "codex-history"
1134
+ # Cursor: role + message only, and none of Claude's envelope fields.
1135
+ if set(d.keys()) <= {"role", "message"} and "message" in d:
1136
+ return "cursor"
919
1137
  return "claude"
920
1138
  return "claude"
921
1139
 
@@ -928,6 +1146,8 @@ def _rows_for_file(path):
928
1146
  return _codex_rows(path), "codex"
929
1147
  if h == "codex-history":
930
1148
  return [], "codex-history"
1149
+ if h == "cursor":
1150
+ return _cursor_rows(path), "cursor"
931
1151
  return list(_iter_json(path)), "claude"
932
1152
 
933
1153
 
@@ -942,6 +1162,10 @@ def _coach_files(roots, harness):
942
1162
  paths.append(r)
943
1163
  continue
944
1164
  base = os.path.basename(r.rstrip("/"))
1165
+ if harness == "cursor" or base == ".cursor":
1166
+ paths += sorted(glob.glob(
1167
+ os.path.join(r, "projects", "*", "agent-transcripts", "*", "*.jsonl")))
1168
+ continue
945
1169
  if harness == "codex" or base == ".codex":
946
1170
  got = sorted(glob.glob(os.path.join(r, "archived_sessions", "*.jsonl")))
947
1171
  got += sorted(glob.glob(os.path.join(r, "sessions", "**", "*.jsonl"),
@@ -1194,7 +1418,11 @@ def _coach_roots(root=None, harness=None):
1194
1418
  Single source of truth so the empty-result message names the real path."""
1195
1419
  if root:
1196
1420
  return [root]
1197
- return [os.path.expanduser("~/.codex")] if harness == "codex" else list(ROOTS)
1421
+ if harness == "codex":
1422
+ return [os.path.expanduser("~/.codex")]
1423
+ if harness == "cursor":
1424
+ return [os.path.expanduser("~/.cursor")]
1425
+ return list(ROOTS)
1198
1426
 
1199
1427
 
1200
1428
  def coach(roots=None, harness=None, verified_human=False):
@@ -1247,8 +1475,15 @@ def coach(roots=None, harness=None, verified_human=False):
1247
1475
  "durable_rate": round(durable / len(episodes), 3) if episodes else 0.0,
1248
1476
  "tiers": tiers,
1249
1477
  "top_patterns": rankable[:5],
1250
- "bottom_patterns": (rankable[-5:][::-1] if len(rankable) > 5
1251
- else rankable[::-1][:5]),
1478
+ # SURVIVES LEAST must draw only from habits SURVIVES MOST did not take.
1479
+ # The old guard was `len(rankable) > 5`, which only protected the <=5 case:
1480
+ # with 6-9 rankable habits, rankable[:5] and rankable[-5:] overlap, and the
1481
+ # tool printed the SAME row under "do more of these" and "these tend to
1482
+ # loop" with the same denominator. A first-time user has 6-9 habits, so the
1483
+ # first outside run was the one that saw it. Slicing from index 5 makes the
1484
+ # two lists disjoint by construction at every corpus size, and is
1485
+ # byte-identical to the old output once there are >=10 rankable habits.
1486
+ "bottom_patterns": rankable[5:][-5:][::-1],
1252
1487
  "best_prompt": _pick(episodes, True,
1253
1488
  lambda e: (e["score"], -e["corrective_turns"],
1254
1489
  e["assistant_turns"])),
@@ -1280,8 +1515,12 @@ def cmd_coach(args):
1280
1515
  print("\n this reads transcripts that already exist on your machine; it does not")
1281
1516
  print(" create them. if that directory is empty, use the agent for a session first.\n")
1282
1517
  print(" otherwise:")
1283
- print(" %s coach --harness codex grade Codex (~/.codex) instead" % PROG)
1284
- print(" %s coach --root <dir> point at a folder of .jsonl transcripts\n" % PROG)
1518
+ # All three supported harnesses are offered here. Listing only two while the
1519
+ # README sells three is how a stranger concludes cursor is unsupported.
1520
+ print(" %s coach --harness codex grade Codex (~/.codex) instead" % PROG)
1521
+ print(" %s coach --harness cursor grade Cursor "
1522
+ "(~/.cursor/projects/*/agent-transcripts)" % PROG)
1523
+ print(" %s coach --root <dir> point at a folder of .jsonl transcripts\n" % PROG)
1285
1524
  return
1286
1525
  B, D = "\033[1m", "\033[0m"
1287
1526
  print("\n %sYOUR PROMPT HABITS, GRADED%s (offline, your machine only)\n" % (B, D))
@@ -1322,10 +1561,17 @@ def cmd_coach(args):
1322
1561
  for p in r["top_patterns"]:
1323
1562
  print(" %3d%% (%s/%s) %s" % (round(p["survival_rate"] * 100),
1324
1563
  p["survived"], p["n"], p["pattern"]))
1325
- print("\n %sSURVIVES LEAST%s these tend to loop:" % (B, D))
1326
- for p in r["bottom_patterns"]:
1327
- print(" %3d%% (%s/%s) %s" % (round(p["survival_rate"] * 100),
1328
- p["survived"], p["n"], p["pattern"]))
1564
+ # With <=5 rankable habits SURVIVES MOST already showed all of them, so
1565
+ # there is no honest "least" left to print. An empty header under a
1566
+ # promise ("these tend to loop") is a claim about rows that do not exist.
1567
+ if r["bottom_patterns"]:
1568
+ print("\n %sSURVIVES LEAST%s these tend to loop:" % (B, D))
1569
+ for p in r["bottom_patterns"]:
1570
+ print(" %3d%% (%s/%s) %s" % (round(p["survival_rate"] * 100),
1571
+ p["survived"], p["n"], p["pattern"]))
1572
+ else:
1573
+ print("\n \033[2m(every rankable habit is listed above; too few to split "
1574
+ "into a most/least pair)\033[0m")
1329
1575
  b = r["best_prompt"]
1330
1576
  if b:
1331
1577
  print("\n \033[32m+\033[0m your best landed prompt, with its witness:")
@@ -1344,12 +1590,16 @@ def cmd_coach(args):
1344
1590
  def main():
1345
1591
  p = argparse.ArgumentParser(prog=PROG, description=__doc__ + USAGE,
1346
1592
  formatter_class=argparse.RawDescriptionHelpFormatter)
1593
+ p.add_argument("--version", action="version", version="%s %s" % (PROG, VERSION))
1347
1594
  sub = p.add_subparsers(dest="cmd")
1348
1595
  sub.add_parser("index").set_defaults(fn=cmd_index)
1349
1596
  s = sub.add_parser("watch"); s.add_argument("--interval", type=int, default=5); s.set_defaults(fn=cmd_watch)
1350
1597
  s = sub.add_parser("ask"); s.add_argument("query"); s.add_argument("-n", "--limit", type=int, default=25); s.set_defaults(fn=cmd_ask)
1351
1598
  s = sub.add_parser("search"); s.add_argument("query"); s.add_argument("-n", "--limit", type=int, default=25); s.set_defaults(fn=cmd_search)
1352
1599
  s = sub.add_parser("find"); s.add_argument("name"); s.set_defaults(fn=cmd_find)
1600
+ s = sub.add_parser("trace"); s.add_argument("query"); s.add_argument("-n", "--limit", type=int, default=10)
1601
+ s.add_argument("-a", "--all", action="store_true", help="show every file, not the first 8")
1602
+ s.set_defaults(fn=cmd_trace)
1353
1603
  s = sub.add_parser("sessions"); s.add_argument("-n", "--limit", type=int, default=30); s.set_defaults(fn=cmd_sessions)
1354
1604
  sub.add_parser("stats").set_defaults(fn=cmd_stats)
1355
1605
  s = sub.add_parser("cost")
@@ -1359,9 +1609,10 @@ def main():
1359
1609
  s.set_defaults(fn=cmd_cost)
1360
1610
  s = sub.add_parser("coach")
1361
1611
  s.add_argument("--root", help="grade this transcript dir instead of ~/.claude/projects")
1362
- s.add_argument("--harness", choices=["claude", "codex"],
1612
+ s.add_argument("--harness", choices=["claude", "codex", "cursor"],
1363
1613
  help="which agent's transcripts to grade. codex reads "
1364
- "~/.codex (archived_sessions + sessions). default: auto-detect")
1614
+ "~/.codex (archived_sessions + sessions); cursor reads "
1615
+ "~/.cursor/projects/*/agent-transcripts. default: auto-detect")
1365
1616
  s.add_argument("--verified-human", dest="verified_human", action="store_true",
1366
1617
  help="subtract likely-PASTED turns: a typed turn whose text is a "
1367
1618
  "verbatim/high n-gram echo of an earlier agent or tool message "
File without changes
File without changes