@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 +24 -0
- package/README.md +66 -53
- package/package.json +1 -1
- package/src/analyze.ts +3 -4
- package/src/card.ts +8 -11
- package/src/cardhtml.ts +6 -9
- package/src/derive.ts +117 -67
- package/src/format.ts +2 -6
- package/src/image.ts +18 -14
- package/src/index.ts +15 -31
- package/src/parse.ts +30 -21
- package/src/render.ts +15 -20
- package/src/report.ts +0 -1
- package/src/scan.ts +8 -13
- package/src/score.ts +6 -9
- package/src/status.ts +10 -13
- package/src/statusfile.ts +13 -26
- package/src/types.ts +8 -5
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.
|
|
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
|
|
17
|
+
## Quick start
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
#
|
|
20
|
+
# Skip this if you already have Bun 1.4 or newer
|
|
21
21
|
curl -fsSL https://bun.sh/install | bash
|
|
22
22
|
|
|
23
|
-
#
|
|
23
|
+
# Run it once, without installing
|
|
24
24
|
bunx @drakulavich/zapara@latest
|
|
25
|
+
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
bunx @drakulavich/zapara@latest yesterday --explain
|
|
27
|
+
To keep it, install it with Bun. The same command upgrades it later.
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
```bash
|
|
30
|
+
bun add -g @drakulavich/zapara
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
|
|
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
|
-
|
|
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 · · · · · · · · · · · ░ ░ · · · · · · · · · · ·
|
|
45
|
-
Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 81
|
|
46
|
-
Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · ·
|
|
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
|
-
|
|
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
|
|
56
|
-
10:00
|
|
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
|
|
62
|
-
21:00
|
|
63
|
-
23:00
|
|
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.
|
|
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.
|
|
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;
|
|
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
|
|
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
|
|
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` |
|
|
150
|
-
| `level` | That
|
|
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
|
|
153
|
-
| `streakMin` | Minutes of
|
|
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
|
|
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/`.
|
|
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
|
-
|
|
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
|
-
-
|
|
167
|
-
-
|
|
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
|
|
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 /
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
261
|
+
---
|
|
249
262
|
|
|
250
|
-
|
|
263
|
+
<p align="center">Made with ❤️ and 🥤 energy under <a href="LICENSE">MIT License</a></p>
|
package/package.json
CHANGED
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
38
|
-
//
|
|
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
|
-
//
|
|
98
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
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" },
|