@drakulavich/zapara 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.0] - 2026-09-22
9
+
10
+ ### Changed
11
+ - The week grid's `peak` column is painted in a TTY with the color of the level its index falls in, by the same thresholds `zapara status` reads a level from; `--no-color` and a pipe are unchanged.
12
+ - The status file's `index` and `level` are the load of the sixty minutes ending at `asOf`, not of the calendar hour that contains it: the number no longer drops to nothing at every hour boundary (90 at 11:59:30, 6 at 12:00:30 on one machine) and no longer climbs through the hour as the bucket fills. `Day.live` in `--json` carries that bucket on the open day. Same formula, same norms; `schema` stays 1.
13
+
14
+ ## [0.4.0] - 2026-09-21
15
+
16
+ ### Changed
17
+ - Streak norm recalibrated for the presence rule: 40 minutes of your own uninterrupted actions now score the full streak points (was 120, the p90 of the old rule that let agent activity keep a streak alive; the p90 under the new rule is 33 on one machine and 48 on another, and 40 sits between them).
18
+ - Presence counts every human action, not only prompts: an interrupt, a tool rejection and an answer to a question or a plan hold a streak open and fill active minutes the same way a prompt does. They remain decisions where they already were; nothing is counted twice.
19
+ - An hour's streak is the longest streak seen in it, not the one it happened to end on. A single prompt after a break no longer erases the run the hour held, nor the streak points of that hour's index.
20
+ - The status file's streak is live: it counts from the first action of the streak you are in up to `asOf`, and resets to `0` once you have been away for more than ten minutes. Before, it was the current hour's bucket, so it fell to zero at every hour boundary.
21
+ - `Day.presence` in `--json`: the day's last human action and the start of the streak it belongs to, as instants, or `null` on a day with no action of yours.
22
+
23
+ ### Fixed
24
+ - The card's longest streak is no longer under-reported: a run that ended in an hour where another began used to be measured only up to the end of the hour before.
25
+ - The status line's streak no longer drops to zero at each hour boundary.
26
+ - `card --out` into a directory that does not exist or refuses the write says `cannot write the card: check the --out directory` instead of printing the full path back in a file-system error.
27
+ - A permission-mode switch made before the first message of a session counts: it is attributed to the first timestamped record that follows, instead of vanishing.
28
+ - A transcript whose modification time is outside the window is read when its last record is a big one: the rescue that looks for the last timestamp reads 64 KB from the end instead of 4 KB, so a day that ended on a large tool result is no longer dropped in full.
29
+ - An output-token count that is not a finite non-negative integer is ignored instead of poisoning the day: a corrupt transcript could put `NaN` in the token column and `null` in the JSON, beside a made-up level.
30
+ - A prompt with a pasted screenshot counts again: the image block comes before the typed text, and such prompts were read as no text at all, so a day spent pasting screenshots showed no prompts and no active minutes.
31
+
8
32
  ## [0.3.1] - 2026-09-19
9
33
 
10
34
  ### Changed
package/README.md CHANGED
@@ -12,75 +12,88 @@
12
12
  <img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/demo.webp" alt="zapara demo: week heatmap, day table, JSON" width="800">
13
13
  </p>
14
14
 
15
- Claude Code writes a JSONL transcript for every session under `~/.claude/projects`. zapara reads those files, puts each record in the local hour it happened in, and turns the hour into one number. A week is a heatmap of seven rows by 24 cells; a day is a table with one row per hour and, with `--explain`, the weighted contribution of each component. Nothing is installed into Claude Code, no hook is registered, and no network call is made.
15
+ Claude Code writes a JSONL transcript for every session under `~/.claude/projects`. zapara reads those files, puts each record in the local hour it happened in, and turns the hour into one number. A week is a heatmap of seven rows by 24 cells; a day is a table with one row per hour and, with `--explain`, the weighted contribution of each component.
16
16
 
17
- ## Quick Start
17
+ ## Quick start
18
18
 
19
19
  ```bash
20
- # Install Bun if you do not have it (zapara needs 1.4 or newer)
20
+ # Skip this if you already have Bun 1.4 or newer
21
21
  curl -fsSL https://bun.sh/install | bash
22
22
 
23
- # The last 7 days, straight from the registry
23
+ # Run it once, without installing
24
24
  bunx @drakulavich/zapara@latest
25
+ ```
25
26
 
26
- # Yesterday, hour by hour, with the components behind each index
27
- bunx @drakulavich/zapara@latest yesterday --explain
27
+ To keep it, install it with Bun. The same command upgrades it later.
28
28
 
29
- # The card
30
- bunx @drakulavich/zapara@latest card
29
+ ```bash
30
+ bun add -g @drakulavich/zapara
31
31
  ```
32
32
 
33
- To keep it around, `bun add -g @drakulavich/zapara` puts `zapara` on your PATH. There is no build step and no runtime dependency: Bun runs `src/index.ts` from the package as it is.
33
+ `zapara` lands in Bun's global bin directory, `~/.bun/bin` unless `BUN_INSTALL_BIN` says otherwise; `bun pm bin -g` prints the one in force. Bun's own installer puts that directory on your PATH; a Homebrew Bun does not, so add it yourself. Then:
34
+
35
+ ```bash
36
+ zapara # the last 7 days
37
+ zapara yesterday --explain # one day, with the components behind each index
38
+ zapara card # the picture
39
+ ```
40
+
41
+ No build step and no runtime dependency: Bun runs `src/index.ts` from the package as it is.
34
42
 
35
43
  ## What it looks like
36
44
 
37
- Both pictures below come from the synthetic fixture in `tests/fixtures/busy-week`: a calm morning of one session, a five-session storm in the middle of the day, and a late tail that runs past midnight.
45
+ The week below, and the day behind the fold under it, come from the synthetic fixture in `tests/fixtures/busy-week`: a calm morning of one session, a five-session storm in the middle of the day, and a late tail that runs past midnight.
38
46
 
39
47
  ```
40
48
  00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 peak active
41
49
  Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 8h50
42
50
  Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 7h55
43
51
  Wed 16/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
44
- Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 14 1h55
45
- Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 81 1h00
46
- Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 24 1h55
52
+ Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 15 1h55
53
+ Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 81 0h35
54
+ Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 25 1h55
47
55
  Sun 20/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
48
56
 
49
57
  ░ calm ▒ warming ▓ heating █ fried
50
- 21h35 active 348 prompts 0 reports 100 decisions 5 sessions at once
58
+ 21h10 active 323 prompts 0 reports 86 decisions 5 sessions at once
59
+ ```
60
+
61
+ <details>
62
+ <summary><b>Monday hour by hour, and the commands that print both</b></summary>
63
+
64
+ The fixture's timestamps are UTC and zapara buckets by local time, so pin the zone to get these exact hours:
65
+
66
+ ```bash
67
+ TZ=UTC bun src/index.ts --projects tests/fixtures/busy-week --to 2026-09-20 --no-color
68
+ ```
69
+
70
+ That prints the grid above. One day of it, with the weighted parts behind each index:
71
+
72
+ ```bash
73
+ TZ=UTC bun src/index.ts 2026-09-14 --projects tests/fixtures/busy-week --explain --no-color
51
74
  ```
52
75
 
53
76
  ```
54
77
  hour index level sess prompts intr rej quest plan mode ctx-sw streak out-tok par pace sup read strk late
55
- 09:00 9 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 4.2 0
56
- 10:00 14 Calm 1 6 0 0 0 0 0 0 110m 600 0 4.5 0 0.1 9.2 0
78
+ 09:00 15 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 10 0
79
+ 10:00 15 Calm 1 6 0 0 0 0 0 0 110m 600 0 4.5 0 0.1 10 0
57
80
  11:00 15 Calm 1 7 0 0 0 0 0 0 179m 600 0 5.3 0 0.1 10 0
58
81
  12:00 87 Fried 5 55 20 1 1 1 2 54 235m 55.0k 25 15 30 6.9 10 0
59
82
  13:00 87 Fried 5 55 20 1 1 1 2 54 295m 55.0k 25 15 30 6.9 10 0
60
83
  14:00 87 Fried 5 55 20 1 1 1 2 54 355m 55.0k 25 15 30 6.9 10 0
61
- 20:00 9 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 4.2 0
62
- 21:00 14 Calm 1 6 0 0 0 0 0 0 110m 600 0 4.5 0 0.1 9.2 0
63
- 23:00 19 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 4.2 10
84
+ 20:00 15 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 10 0
85
+ 21:00 15 Calm 1 6 0 0 0 0 0 0 110m 600 0 4.5 0 0.1 10 0
86
+ 23:00 25 Calm 1 6 0 0 0 0 0 0 50m 600 0 4.5 0 0.1 10 10
64
87
  no reports today
65
88
  ```
66
89
 
67
- <details>
68
- <summary><b>Reproduce these pictures from the repository</b></summary>
69
-
70
- Two commands in a terminal reproduce them. The fixture's timestamps are UTC and zapara buckets by local time, so pin the zone to get these exact hours:
71
-
72
- ```bash
73
- TZ=UTC bun src/index.ts --projects tests/fixtures/busy-week --to 2026-09-20 --no-color
74
- TZ=UTC bun src/index.ts 2026-09-14 --projects tests/fixtures/busy-week --explain --no-color
75
- ```
76
-
77
90
  The grid is a fixed 98 columns wide, 100 with its hour header, and does not reflow, so it needs a terminal at least that wide.
78
91
 
79
92
  </details>
80
93
 
81
94
  ## Share a card
82
95
 
83
- `zapara card` turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and three highlights. No dates, no hour totals, nothing that reads as a timesheet.
96
+ `zapara card` turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and three highlights. It carries no dates and no hour totals, so it does not read as a timesheet.
84
97
 
85
98
  ```bash
86
99
  zapara card # writes zapara-card.png in the current directory
@@ -100,7 +113,7 @@ wrote zapara-card.png
100
113
 
101
114
  <p align="center"><img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
102
115
 
103
- This one comes from the same `busy-week` fixture as the pictures above. The picture is taken by a headless browser that Bun drives: WebKit on macOS, an installed Google Chrome elsewhere, so on Linux or Windows install Chrome, or write `--out card.html` and open the page in any browser.
116
+ This one comes from the same `busy-week` fixture as the pictures above. A headless browser that Bun drives takes the picture: WebKit on macOS, an installed Google Chrome elsewhere. On Linux or Windows, install Chrome or write `--out card.html` and open the page in any browser.
104
117
 
105
118
  ## Usage
106
119
 
@@ -122,19 +135,19 @@ This one comes from the same `busy-week` fixture as the pictures above. The pict
122
135
  | `--json` | Print the whole window as one JSON document instead of a table. |
123
136
  | `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
124
137
  | `--out <path>` | Where `card` writes; the extension picks the format. |
125
- | `--no-color` | Plain glyphs with no ANSI codes. `NO_COLOR` in the environment does the same. |
138
+ | `--no-color` | Plain glyphs and peaks with no ANSI codes. `NO_COLOR` in the environment does the same. |
126
139
  | `-h`, `--help` | Usage, exit 0. |
127
140
  | `-V`, `--version` | The version from `package.json`, exit 0. |
128
141
 
129
142
  Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
130
143
 
131
- The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; there is no flag to force text in a pipe yet. `card` always writes its file and prints its two lines, piped or not, and only `card --json` prints JSON. `status` always writes its file and prints the same JSON line, piped or not, and `--json` changes nothing there.
144
+ The grid and the day print a text table when stdout is a terminal and JSON otherwise, so `zapara | cat` prints JSON; no flag forces text in a pipe yet. `card` and `status` write their file and print their lines whether piped or not. `card --json` is the exception: it prints the card's data and writes no file. `--json` changes nothing for `status`, whose line is already JSON and whose file is written either way.
132
145
 
133
- Exit codes are 0 on success, 1 when the projects directory is missing or cannot be read, or when `status` cannot write its file (three different messages, none with a path), and 2 for a usage error such as a bad date or an unknown flag, which prints one line and a hint to `--help`. A value flag given twice is a usage error. A window with no data prints an empty grid and exits 0.
146
+ Exit codes are 0 on success, 1 for a failure zapara can name, which prints one line to stderr and never a path: the projects directory missing or unreadable, `status` unable to write its file, `card` unable to write its picture or to find a browser engine, and whatever else goes wrong below the command line; and 2 for a usage error such as a bad date, an unknown flag or a value flag given twice, which prints one line and a hint to `--help`. A window with no data prints an empty grid and exits 0.
134
147
 
135
148
  ## Status line
136
149
 
137
- A status line wants one number every thirty seconds and cannot wait half a second for a transcript scan, so zapara writes the number down and the status line reads it back. `zapara status` computes today exactly as `zapara today` does, writes it as one line of JSON to `~/.claude/zapara/status.json`, and prints the same line. Only `--projects` applies to it; the window flags, `--explain` and `--out` are usage errors, and `--json` changes nothing because the output is already JSON. There is one file per user, whatever `--projects` said, created with mode `0600` in a directory with mode `0700`; the write goes to a temporary file and is renamed into place, so a reader sees the old line or the new one and never half of one. The file holds the nine values below and nothing else: no path, no project, no session count, no text.
150
+ A status line wants one number every thirty seconds and cannot wait half a second for a transcript scan, so zapara writes the number down and the status line reads it back. `zapara status` computes today exactly as `zapara today` does, writes it as one line of JSON to `~/.claude/zapara/status.json`, and prints the same line. Only `--projects` applies; the window flags, `--explain` and `--out` are usage errors. There is one file per user, whatever `--projects` said, created with mode `0600` in a directory with mode `0700`; the write goes to a temporary file and is renamed into place, so a reader sees the old line or the new one and never half of one. The file holds the nine values below and nothing else: no path, no project, no session count, no text.
138
151
 
139
152
  ```json
140
153
  {"schema":1,"asOf":"2026-09-19T12:30:38.300Z","date":"2026-09-19","hour":15,"index":36,"level":"Warming","peak":41,"activeMin":555,"streakMin":166}
@@ -146,27 +159,27 @@ A status line wants one number every thirty seconds and cannot wait half a secon
146
159
  | `asOf` | When the snapshot was taken, ISO 8601 UTC: the `Day.asOf` of the base spec, the `now` of this run. A reader decides staleness from this field, never from the file's mtime. |
147
160
  | `date` | The local calendar day the numbers describe, `YYYY-MM-DD`. |
148
161
  | `hour` | The local hour that contains `asOf`, `0`..`23`. |
149
- | `index` | That hour's load index, `0`..`100`, or `null` when the hour has no activity yet. |
150
- | `level` | That hour's level, `Calm`, `Warming`, `Heating` or `Fried`, or `null` with `index`. A reader colors by this field so it never needs the thresholds. |
162
+ | `index` | The load index of the sixty minutes ending at `asOf`, `0`..`100`, or `null` when they hold no session. It does not reset at an hour boundary: the hour buckets of `zapara today` are calendar hours, this one is the clock's last hour. |
163
+ | `level` | That index's level, `Calm`, `Warming`, `Heating` or `Fried`, or `null` with `index`. A reader colors by this field so it never needs the thresholds. |
151
164
  | `peak` | The day's peak index so far, or `null` on a day with no activity. |
152
- | `activeMin` | Minutes of your presence in the day so far: the 5-minute slots covered by your prompts and the gaps of at most 10 minutes between them; `0` on a day with no prompt. |
153
- | `streakMin` | Minutes of the unbroken presence streak as of the current hour: your prompts no more than 10 minutes apart, across sessions; `0` when the hour has none. |
165
+ | `activeMin` | Minutes of your presence in the day so far: the 5-minute slots covered by your actions and the gaps of at most 10 minutes between them; `0` on a day with no action of yours. |
166
+ | `streakMin` | Minutes of your live presence streak as of `asOf`: from the streak's first action to `asOf`, when your last action is no more than 10 minutes before `asOf`; `0` once you have been away longer. It keeps growing while you sit there, and it does not reset at an hour boundary. |
154
167
 
155
- Refreshing is the reader's job, and zapara adds no hook, no timer and no daemon. A reader decodes the file strictly and treats anything that fails validation, and a missing or unreadable file, as no data: it draws nothing and counts the file as stale. When the snapshot is stale or missing it starts `zapara status` as a detached process, does not wait for it, and draws what it has, which is also how the file first comes to exist on a machine that never ran zapara. It starts at most one run per threshold and never one per render, so a file that never validates costs one run per threshold and no more; the whole contract is in [the status file spec](docs/superpowers/specs/2026-09-19-zapara-status-file-design.md), and [pult](https://github.com/drakulavich/pult) is the reader that exists, with a five-minute threshold.
168
+ Refreshing is the reader's job, and zapara adds no hook, no timer and no daemon. A reader that finds the file stale or missing starts `zapara status` detached and draws what it has, which is also how the file first comes to exist on a machine that never ran zapara. The rest of the contract, from strict decoding to concurrent runs, is in [the status file spec](docs/superpowers/specs/2026-09-19-zapara-status-file-design.md); [pult](https://github.com/drakulavich/pult) is the reader that exists, with a five-minute threshold.
156
169
 
157
170
  ## Privacy
158
171
 
159
- zapara reads `~/.claude/projects/**/*.jsonl`, skipping subagent transcripts under `subagents/`. Selection is by modification time first, and a file whose modification time is older than the window is opened only to read the last timestamp in its final 4 KB; nothing from that tail is kept or printed. Message text is compared against a few fixed markers, for interrupts, tool rejections and inbound agent messages, and then discarded. What survives into an event is a timestamp, a session id, an event kind and a token count.
172
+ zapara reads `~/.claude/projects/**/*.jsonl`, skipping subagent transcripts under `subagents/`. It picks files by modification time first, and opens one whose modification time is older than the window only to read the last timestamp in its final 64 KB; nothing from that tail is kept or printed. It compares message text against a few fixed markers, for interrupts, tool rejections and inbound agent messages, then discards it. What survives into an event is a timestamp, a session id, an event kind and a token count.
160
173
 
161
- No message text, prompt length, file path or session title is kept, written or printed. The CLI never prints a path it derived or read, not even the projects root when it cannot open it. Nothing is sent anywhere, no file is written except the card or the status file you ask for, and nothing is installed into Claude Code.
174
+ zapara keeps, writes and prints no message text, prompt length, file path or session title. The CLI never prints a path it derived or read, not even the projects root when it cannot open it. It sends nothing anywhere, writes no file except the card or the status file you ask for, and installs nothing into Claude Code.
162
175
 
163
176
  ## Limits
164
177
 
165
- - Time is local and buckets are whole hours, so an hour that straddles midnight or a daylight-saving change is bucketed by the local clock. On a fall-back day two wall-clock hours share one label and merge.
166
- - Only Claude Code transcripts are read. Work in other tools, and time away from the keyboard, is invisible.
167
- - Files are chosen by modification time, and a file whose modification time is older than the window is still read when the last timestamp in it falls inside the window, so a restored or synced transcript is not lost. A very old session touched today is read in full, but only its in-window events count.
178
+ - Time is local and buckets are whole hours, so an hour that straddles midnight or a daylight-saving change is bucketed by the local clock. On a fall-back day two wall-clock hours share one label and merge, so that bucket can hold up to 120 active minutes and the day up to 1500.
179
+ - zapara reads only Claude Code transcripts. Work in other tools, and time away from the keyboard, is invisible.
180
+ - A file whose modification time is older than the window is still read when the last timestamp in it falls inside the window, so a restored or synced transcript is not lost. A very old session touched today is read in full, but only its in-window events count.
168
181
  - The transcript format is Claude Code's private format, built against version 2.1.274, and it may drift. `bun run stats` shows when it has.
169
- - The norms come from two machines of one user working in auto mode. They are a starting point for a conversation about the metric, not a study.
182
+ - The norms come from two machines of one user working in auto mode, which makes them a starting point for a conversation about the metric rather than a study.
170
183
  - The 98-column grid does not adapt to a narrow terminal.
171
184
  - For the grid and the day a pipe always gets JSON, and there is no flag to ask for text instead.
172
185
  - The card needs a browser engine: WebKit comes with macOS, elsewhere Google Chrome must be installed. `--out card.html` works everywhere.
@@ -174,7 +187,7 @@ No message text, prompt length, file path or session title is kept, written or p
174
187
 
175
188
  ## Under the hood
176
189
 
177
- The formula behind the number and the transcript record behind every column, for when the number surprises you.
190
+ The formula behind the number and the transcript record behind every column, for when the number surprises you. For the whole path from a transcript line to the status line, with a diagram, read [How the numbers are made](docs/how-the-numbers-are-made.md).
178
191
 
179
192
  <details>
180
193
  <summary><b>How the index works</b></summary>
@@ -186,7 +199,7 @@ parallel = clamp((sessions - 1) / 4) # 1 se
186
199
  pace = clamp(prompts / 20) # 10 prompts/hour → 0.5, 20+ → 1
187
200
  supervision = clamp((3 * decisions + reports + contextSwitches) / 45) # 15 decisions alone → 1; 45 reports alone → 1
188
201
  reading = clamp(outputTokens / 80000) # 40k → 0.5, 80k+ → 1
189
- streak = clamp(streakMin / 120) # 60 min → 0.5, 2h+ → 1
202
+ streak = clamp(streakMin / 40) # 20 min → 0.5, 40+ → 1
190
203
  late = lateNight ? 1 : 0
191
204
 
192
205
  index = round(25*parallel + 15*pace + 30*supervision + 10*reading + 10*streak + 10*late)
@@ -201,7 +214,7 @@ index = round(25*parallel + 15*pace + 30*supervision + 10*reading + 10*streak +
201
214
  | Heating | 60–84 |
202
215
  | Fried | 85–100 |
203
216
 
204
- The norms come from two machines, 14 days each, of real transcripts covering 116 and 114 active hours. The surprise in that data was how rare explicit decisions are: in auto mode the p90 is 3 decisions per hour, so a component built on decisions alone reads near zero on hours that felt heavy. What those hours actually cost is reading the reports agents send back, switching between sessions, and getting through the volume of model output, which is why supervision carries 30 points and reading 10. Human prompts reached a p90 of 13 per hour on one machine and 20 on the other, hence the pace norm of 20. The parallel-session threshold follows the research this project started from rather than the transcripts: BCG and HBR report that productivity drops past three simultaneous AI tools, and Osmani makes the same point as three focused teammates beating five scattered ones.
217
+ The norms are the p90 of two weeks of real transcripts on two machines, 116 and 114 active hours. Why each one is what it is, and what surprised us in that data, is in [How the numbers are made](docs/how-the-numbers-are-made.md#5-the-index).
205
218
 
206
219
  Weights and norms live in one exported constant in `src/score.ts`, so a recalibration is one diff there plus a line in `CHANGELOG.md`.
207
220
 
@@ -221,7 +234,7 @@ Weights and norms live in one exported constant in `src/score.ts`, so a recalibr
221
234
  | `plan` | `ExitPlanMode` tool calls in an assistant message, one per block. |
222
235
  | `mode` | Permission-mode switches. A `permission-mode` record whose mode differs from the previous one; the first record of a session sets the baseline and repeats of the same mode count nothing. |
223
236
  | `ctx-sw` | Context switches. Over the hour's prompts in time order, the number of consecutive pairs that came from different sessions. |
224
- | `streak` | Minutes since the current presence streak began: your prompts no more than 10 minutes apart, across sessions, which may reach back before the hour. Agent activity between two prompts does not bridge a gap. |
237
+ | `streak` | Minutes of the longest presence streak the hour saw. Presence is every action you take: a prompt, an interrupt, a tool rejection, an answer to a question or a plan. A streak is a run of them no more than 10 minutes apart, across sessions, and it may reach back before the hour. Agent activity between two of your actions does not bridge a gap. |
225
238
  | `out-tok` | Assistant output tokens, summed once per request and only for requests that produced a text block. Claude Code repeats the same usage on each content block of a response, and a request holding only tool calls is not text anyone reads. |
226
239
 
227
240
  `bun run stats --days 14` is the tool the norms were set with. It prints the per-hour distribution of each signal over the active hours of a window (n, p50, p75, p90, max, and how many hours were zero), the top hours by reports and by human prompts, and a format-drift line comparing records seen against events the parser recognised. Run it on another machine, or after a Claude Code update, to see whether the norms and the parser still fit. It prints numbers and nothing else.
@@ -237,7 +250,7 @@ bun install
237
250
  bun link
238
251
  ```
239
252
 
240
- `bun link` registers the clone's `bin` entry, so `zapara` runs this checkout; without it, `bun src/index.ts week` does the same thing.
253
+ `bun link` registers the clone's `bin` entry, so `zapara` runs this checkout; without it, `bun src/index.ts` does the same thing.
241
254
 
242
255
  ```bash
243
256
  bun run check # tsc --noEmit, then the test suite under TZ=UTC
@@ -245,6 +258,6 @@ bun run check # tsc --noEmit, then the test suite under TZ=UTC
245
258
 
246
259
  Tests are fixture-driven: they build or load transcripts in the real Claude Code format and assert the statistics that come out of the public seams, `analyze()`, `report()` and the CLI itself. No test imports the parser, the deriver or the scanner, so refactoring internals never touches a test. The rules every change follows are in [CLAUDE.md](CLAUDE.md), the design is in [docs/superpowers/specs/2026-09-17-zapara-design.md](docs/superpowers/specs/2026-09-17-zapara-design.md), and every change is recorded in [CHANGELOG.md](CHANGELOG.md).
247
260
 
248
- ## License
261
+ ---
249
262
 
250
- MIT. See [LICENSE](LICENSE).
263
+ <p align="center">Made with ❤️ and 🥤 energy under <a href="LICENSE">MIT License</a></p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Cognitive load index for people driving Claude Code, computed locally from transcripts",
5
5
  "license": "MIT",
6
6
  "author": "Anton Yakutovich",
package/src/analyze.ts CHANGED
@@ -1,12 +1,11 @@
1
- import { derive } from "./derive.ts";
1
+ import { compareStrings, derive } from "./derive.ts";
2
2
  import { parseTranscript } from "./parse.ts";
3
3
  import type { Day, Transcript, Window } from "./types.ts";
4
4
 
5
- // The core's entry point: transcripts already in memory, in the real JSONL format, in.
6
- // Day[] out. Transcripts are sorted by path so file discovery order cannot change a result.
5
+ // Sorted by path so file discovery order cannot change a result.
7
6
  export function analyze(transcripts: Transcript[], w: Window): Day[] {
8
7
  const events = [...transcripts]
9
- .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
8
+ .sort((a, b) => compareStrings(a.path, b.path))
10
9
  .flatMap((t) => parseTranscript(t.text));
11
10
  return derive(events, w);
12
11
  }
package/src/card.ts CHANGED
@@ -1,7 +1,5 @@
1
- // Card data: which of four characters a window is, the sentence behind it, the
2
- // peak hour, the load spectrum and three highlights, every value already
3
- // formatted for the page. Pure: Day[] in, CardData out. Nothing here knows a
4
- // date, a path or a file, so nothing here can leak one.
1
+ // Pure: Day[] in, CardData out, every value already formatted for the page.
2
+ // Nothing here knows a date, a path or a file, so nothing here can leak one.
5
3
  import { formatCount, formatTokens, plural } from "./format.ts";
6
4
  import { NORMS, WEIGHTS } from "./score.ts";
7
5
  import type { Day, HourBucket, Level, Score } from "./types.ts";
@@ -34,9 +32,8 @@ const MOTTOS: Record<Character, string> = {
34
32
  marathoner: "You do not stop while it compiles.",
35
33
  nightOwl: "The best commits happen after midnight.",
36
34
  };
37
- // Ranking norms for the third highlight: a value over its norm says how remarkable
38
- // it is next to the others. Sums are per active hour. These rank a picture and
39
- // never touch the index; the index's own norms stay in score.ts.
35
+ // Rank the third highlight, per active hour. They never touch the index, whose
36
+ // norms stay in score.ts.
40
37
  const CARD_NORMS = { reportsPerHour: 12, tokensPerHour: 65_000, interruptsPerHour: 3, latePercent: 25 } as const;
41
38
  const OWNED: Record<Character, [HighlightKey, HighlightKey]> = {
42
39
  conductor: ["peakSessions", "contextSwitches"],
@@ -94,15 +91,15 @@ export function cardData(days: Day[], w: { days: number }): CardData | null {
94
91
  const max = (f: (b: Active) => number): number => active.reduce((a, b) => Math.max(a, f(b)), 0);
95
92
  const count = (level: Level): number => active.filter((b) => b.score.level === level).length;
96
93
 
97
- // Each share is the fraction of that character's maximum possible points the
98
- // window collected, so a 10-point component competes fairly with a 40-point one.
94
+ // A share is the fraction of the character's maximum points, so a 10-point
95
+ // component competes fairly with a 40-point one.
99
96
  const shares: Record<Character, number> = {
100
97
  conductor: sum((b) => b.score.parts.parallel + b.score.parts.pace) / ((WEIGHTS.parallel + WEIGHTS.pace) * n),
101
98
  supervisor: sum((b) => b.score.parts.supervision + b.score.parts.reading) / ((WEIGHTS.supervision + WEIGHTS.reading) * n),
102
99
  marathoner: sum((b) => b.score.parts.streak) / (WEIGHTS.streak * n),
103
100
  nightOwl: sum((b) => b.score.parts.late) / (WEIGHTS.late * n),
104
101
  };
105
- // Strict > keeps the earlier of equal shares: CHARACTERS is the tie order.
102
+ // Strict >: CHARACTERS is the tie order.
106
103
  const character = CHARACTERS.reduce((best, c) => (shares[c] > shares[best] ? c : best));
107
104
 
108
105
  const maxSessions = max((b) => b.sessions);
@@ -143,7 +140,7 @@ export function cardData(days: Day[], w: { days: number }): CardData | null {
143
140
  lateShare: late / CARD_NORMS.latePercent,
144
141
  };
145
142
  const owned = OWNED[character];
146
- // A late-night number on anyone but the Night Owl is the kind of thing they would hide.
143
+ // A late-night number belongs to the Night Owl alone.
147
144
  const rest = POOL.filter((k) => !owned.includes(k) && (k !== "lateShare" || character === "nightOwl"));
148
145
  const third = rest.reduce((best, k) => (norms[k] > norms[best] ? k : best));
149
146
  const highlights = [...owned, third].map((key) => ({ key, value: values[key], caption: CAPTIONS[key] }));
package/src/cardhtml.ts CHANGED
@@ -1,7 +1,6 @@
1
- // The card page: one self-contained HTML document from CardData and the embedded
2
- // assets. Pure: strings in, string out. Nothing is escaped because nothing from a
3
- // transcript reaches this file: only CardData's fixed strings and formatted numbers.
4
- // The look is the table in docs/superpowers/specs/2026-09-18-zapara-card-design.md.
1
+ // Nothing is escaped because nothing from a transcript reaches this file: only
2
+ // CardData's fixed strings and formatted numbers. The look is the table in
3
+ // docs/superpowers/specs/2026-09-18-zapara-card-design.md.
5
4
  import type { CardData, Character, Segment } from "./card.ts";
6
5
  import type { Level } from "./types.ts";
7
6
 
@@ -10,10 +9,8 @@ export type CardAssets = {
10
9
  characters: string;
11
10
  };
12
11
 
13
- // Where each character sits on the sheet, as fractions [x, y, w, h] of its side,
14
- // measured on the current assets/characters.webp. The box is scaled so the
15
- // rectangle's longer side is BOX px and centred at CENTRE, with CSS background-size
16
- // and background-position, so no cropping happens anywhere.
12
+ // Fractions [x, y, w, h] of the sheet's side, measured on assets/characters.webp.
13
+ // Scaled so the longer side is BOX px, centred at CENTRE; nothing is cropped.
17
14
  export const CHARACTER_RECTS: Record<Character, [number, number, number, number]> = {
18
15
  conductor: [0.02, 0.01, 0.53, 0.543],
19
16
  supervisor: [0.55, 0.07, 0.38, 0.505],
@@ -23,7 +20,7 @@ export const CHARACTER_RECTS: Record<Character, [number, number, number, number]
23
20
  const BOX = 360;
24
21
  const CENTRE = { x: 195, y: 300 };
25
22
 
26
- // Streak accent, its rgb for alpha gradients, and the light shade for the repo link.
23
+ // main, its rgb for alpha gradients, and the light shade for the repo link.
27
24
  const ACCENT: Record<Character, { main: string; rgb: string; light: string }> = {
28
25
  conductor: { main: "#8b5cf6", rgb: "139,92,246", light: "#c4b5fd" },
29
26
  supervisor: { main: "#22d3ee", rgb: "34,211,238", light: "#a5f3fc" },