@drakulavich/zapara 0.1.0 → 0.3.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 +21 -0
- package/README.md +53 -20
- package/package.json +1 -1
- package/src/derive.ts +7 -2
- package/src/index.ts +133 -35
- package/src/render.ts +12 -3
- package/src/report.ts +2 -2
- package/src/scan.ts +45 -19
- package/src/status.ts +42 -0
- package/src/statusfile.ts +51 -0
- package/src/types.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project are documented here. The format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-09-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- A window that includes today says when the snapshot was taken: `asOf` on today's JSON entry and an `as of HH:MM, this hour is still running` line under the tables.
|
|
12
|
+
- `zapara status` writes today's load to `~/.claude/zapara/status.json` for a status line to read; the file format and the reader's refresh contract are in the spec.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- A window or output flag given twice (`--days 3 --days 5`) is a usage error, `--days given twice`, instead of the last value winning silently.
|
|
16
|
+
- A transcript whose modification time is older than the window is still read when the last timestamp in it falls inside the window; before, a restored or synced file was dropped in full without a word.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-09-18
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- The command line is the window and the view, not names for windows: `zapara` is the grid of the last 7 days, `zapara --days 30`, `zapara --to 2026-09-14` or `zapara --from 2026-09-01 --to 2026-09-14` any window up to 90 days; `zapara today`, `zapara yesterday` or `zapara 2026-09-14` one day, hour by hour; `zapara card` the picture, with the same window flags. `week` and `day` are gone. A date is `YYYY-MM-DD`, `today` or `yesterday` everywhere; `--days=30` works beside `--days 30`; `-V` beside `--version`.
|
|
22
|
+
- A usage error prints its line and `run 'zapara --help' for usage`, exit 2, instead of the whole usage screen; `--help` is one screen under 80 columns.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- `--days -1` is answered by the range message (`--days must be 1..90, got -1`) instead of `--days needs a value`: a negative number is a value, not a flag.
|
|
26
|
+
- A projects directory that exists but cannot be read says so (`projects directory cannot be read (check its permissions)`, exit 1) instead of `not found`; the message still names no path.
|
|
27
|
+
- The README's screencast and card show on npmjs.com: both images use absolute raw.githubusercontent.com URLs, and the demo files left Git LFS, whose objects the raw endpoint serves as pointer text.
|
|
28
|
+
|
|
8
29
|
## [0.1.0] - 2026-09-18
|
|
9
30
|
|
|
10
31
|
### Added
|
package/README.md
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
5
5
|
<a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
|
|
6
|
+
<a href="https://www.npmjs.com/package/@drakulavich/zapara"><img src="https://img.shields.io/npm/v/@drakulavich/zapara?logo=npm&color=cb3837" alt="npm"></a>
|
|
6
7
|
</p>
|
|
7
8
|
|
|
8
9
|
<p align="center"><b>How hard was today?</b> zapara reads the transcripts Claude Code already writes on your machine and scores every hour 0–100 from parallel sessions, prompt pace, agent supervision, model output, streak length and late-night work. Nothing leaves the machine, no message text is kept.</p>
|
|
9
10
|
|
|
10
11
|
<p align="center">
|
|
11
|
-
<img src="assets/demo.webp" alt="zapara demo: week heatmap, day table, JSON" width="800">
|
|
12
|
+
<img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/demo.webp" alt="zapara demo: week heatmap, day table, JSON" width="800">
|
|
12
13
|
</p>
|
|
13
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.
|
|
@@ -19,11 +20,11 @@ Claude Code writes a JSONL transcript for every session under `~/.claude/project
|
|
|
19
20
|
# Install Bun if you do not have it (zapara needs 1.4 or newer)
|
|
20
21
|
curl -fsSL https://bun.sh/install | bash
|
|
21
22
|
|
|
22
|
-
#
|
|
23
|
+
# The last 7 days, straight from the registry
|
|
23
24
|
bunx @drakulavich/zapara@latest
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
-
bunx @drakulavich/zapara@latest
|
|
26
|
+
# Yesterday, hour by hour, with the components behind each index
|
|
27
|
+
bunx @drakulavich/zapara@latest yesterday --explain
|
|
27
28
|
|
|
28
29
|
# The card
|
|
29
30
|
bunx @drakulavich/zapara@latest card
|
|
@@ -69,8 +70,8 @@ hour index level sess prompts intr rej quest plan mode ctx-sw stre
|
|
|
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:
|
|
70
71
|
|
|
71
72
|
```bash
|
|
72
|
-
TZ=UTC bun src/index.ts
|
|
73
|
-
TZ=UTC bun src/index.ts
|
|
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
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
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.
|
|
@@ -86,12 +87,18 @@ zapara card # writes zapara-card.png in the current directory
|
|
|
86
87
|
zapara card --out card.webp # WebP instead; --out card.html writes the page itself
|
|
87
88
|
```
|
|
88
89
|
|
|
90
|
+
On macOS, one more command puts the picture on the clipboard, ready to paste into a chat:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
zapara card && osascript -e 'set the clipboard to (read (POSIX file "zapara-card.png") as «class PNGf»)'
|
|
94
|
+
```
|
|
95
|
+
|
|
89
96
|
```
|
|
90
97
|
The Marathoner: Longest streak 7h53m without a break, 68% of your hours calm.
|
|
91
98
|
wrote zapara-card.png
|
|
92
99
|
```
|
|
93
100
|
|
|
94
|
-
<p align="center"><img src="assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
|
|
101
|
+
<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>
|
|
95
102
|
|
|
96
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.
|
|
97
104
|
|
|
@@ -99,45 +106,71 @@ This one comes from the same `busy-week` fixture as the pictures above. The pict
|
|
|
99
106
|
|
|
100
107
|
| Command | What it does |
|
|
101
108
|
|---|---|
|
|
102
|
-
| `zapara` |
|
|
103
|
-
| `zapara
|
|
104
|
-
| `zapara
|
|
105
|
-
| `zapara
|
|
106
|
-
| `zapara
|
|
109
|
+
| `zapara` | The last 7 days ending today, one cell per hour, in local time. |
|
|
110
|
+
| `zapara --days 30` | The last 30 days. `--days` takes an integer from 1 to 90. |
|
|
111
|
+
| `zapara --from 2026-09-01 --to 2026-09-14` | Any window, both days inclusive, at most 90 days. `--to` alone is 7 days ending there, `--days 30 --to 2026-09-14` is 30 days ending there. |
|
|
112
|
+
| `zapara today` | Today, one row per hour that had activity. `yesterday` likewise. |
|
|
113
|
+
| `zapara 2026-09-14 --explain` | One day, with the six weighted components behind each index. |
|
|
107
114
|
| `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in the current directory. |
|
|
108
115
|
| `zapara card --days 30 --out me.webp` | Any window from 1 to 90 days; `.png`, `.webp` or `.html` by extension. `--json` prints the card's data instead. |
|
|
116
|
+
| `zapara status` | Writes today's load to `~/.claude/zapara/status.json` for a status line to read, and prints the same line. See [Status line](#status-line). |
|
|
109
117
|
|
|
110
118
|
| Flag | What it does |
|
|
111
119
|
|---|---|
|
|
120
|
+
| `--days <N>`, `--from <date>`, `--to <date>` | The window. A date is `YYYY-MM-DD`, `today` or `yesterday`. `--days=30` works as well as `--days 30`. |
|
|
121
|
+
| `--explain` | With a day: the six weighted parts behind each index. |
|
|
112
122
|
| `--json` | Print the whole window as one JSON document instead of a table. |
|
|
113
123
|
| `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
|
|
114
124
|
| `--out <path>` | Where `card` writes; the extension picks the format. |
|
|
115
125
|
| `--no-color` | Plain glyphs with no ANSI codes. `NO_COLOR` in the environment does the same. |
|
|
116
|
-
| `--help` | Usage, exit 0. |
|
|
117
|
-
| `--version` | The version from `package.json`, exit 0. |
|
|
126
|
+
| `-h`, `--help` | Usage, exit 0. |
|
|
127
|
+
| `-V`, `--version` | The version from `package.json`, exit 0. |
|
|
118
128
|
|
|
119
129
|
Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
|
|
120
130
|
|
|
121
|
-
|
|
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.
|
|
132
|
+
|
|
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.
|
|
122
134
|
|
|
123
|
-
|
|
135
|
+
## Status line
|
|
136
|
+
|
|
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.
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{"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}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
| Field | Meaning |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `schema` | The shape of this file: `1`. A reader that sees a number it does not know shows nothing. It changes only when a field changes meaning or goes away; adding a field does not bump it. |
|
|
146
|
+
| `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
|
+
| `date` | The local calendar day the numbers describe, `YYYY-MM-DD`. |
|
|
148
|
+
| `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. |
|
|
151
|
+
| `peak` | The day's peak index so far, or `null` on a day with no activity. |
|
|
152
|
+
| `activeMin` | Active minutes in the day so far; `0` on a day with no activity. |
|
|
153
|
+
| `streakMin` | Minutes of the unbroken streak as of the current hour, `0` when there is none. |
|
|
154
|
+
|
|
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.
|
|
124
156
|
|
|
125
157
|
## Privacy
|
|
126
158
|
|
|
127
|
-
zapara reads `~/.claude/projects/**/*.jsonl`,
|
|
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.
|
|
128
160
|
|
|
129
|
-
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 you ask for, and nothing is installed into Claude Code.
|
|
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.
|
|
130
162
|
|
|
131
163
|
## Limits
|
|
132
164
|
|
|
133
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.
|
|
134
166
|
- Only Claude Code transcripts are read. Work in other tools, and time away from the keyboard, is invisible.
|
|
135
|
-
- Files are chosen by modification time. A very old session touched today is read in full, but only its in-window events count.
|
|
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.
|
|
136
168
|
- 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.
|
|
137
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.
|
|
138
170
|
- The 98-column grid does not adapt to a narrow terminal.
|
|
139
|
-
- For
|
|
171
|
+
- For the grid and the day a pipe always gets JSON, and there is no flag to ask for text instead.
|
|
140
172
|
- The card needs a browser engine: WebKit comes with macOS, elsewhere Google Chrome must be installed. `--out card.html` works everywhere.
|
|
173
|
+
- A window that includes today is a snapshot: today's entry in the JSON carries `asOf`, the tables end with `as of HH:MM`, and two runs minutes apart differ while Claude Code is still writing. Today's numbers cover everything up to `asOf` and nothing timestamped after it, even if it lands in the same run. `zapara status` writes that snapshot to a file for a status line.
|
|
141
174
|
|
|
142
175
|
## Under the hood
|
|
143
176
|
|
package/package.json
CHANGED
package/src/derive.ts
CHANGED
|
@@ -121,9 +121,14 @@ export function derive(events: Event[], w: Window): Day[] {
|
|
|
121
121
|
const { startMs, endMs, cutoffMs, dates } = windowBounds(w);
|
|
122
122
|
const sorted = events
|
|
123
123
|
.map((e, i) => ({ e, i }))
|
|
124
|
-
.filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs)
|
|
124
|
+
.filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
|
|
125
125
|
.sort((a, b) => a.e.ts - b.e.ts || (a.e.sessionId < b.e.sessionId ? -1 : a.e.sessionId > b.e.sessionId ? 1 : 0) || a.i - b.i)
|
|
126
126
|
.map(({ e }) => e);
|
|
127
127
|
const acc = foldEvents(sorted, startMs);
|
|
128
|
-
|
|
128
|
+
const days = dates.map((date) => buildDay(date, acc));
|
|
129
|
+
if (w.now) {
|
|
130
|
+
const today = localDate(w.now);
|
|
131
|
+
for (const d of days) if (d.date === today) d.asOf = w.now.toISOString();
|
|
132
|
+
}
|
|
133
|
+
return days;
|
|
129
134
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { localDate } from "./derive.ts";
|
|
|
9
9
|
import { loadAssets, renderCard } from "./image.ts";
|
|
10
10
|
import { renderDay, renderJson, renderWeek } from "./render.ts";
|
|
11
11
|
import { report } from "./report.ts";
|
|
12
|
+
import { renderStatus, statusOf } from "./status.ts";
|
|
13
|
+
import { writeStatus } from "./statusfile.ts";
|
|
12
14
|
import type { Day } from "./types.ts";
|
|
13
15
|
|
|
14
16
|
// Read lazily, only when --version is actually handled, so a broken install
|
|
@@ -22,13 +24,30 @@ function version(): string {
|
|
|
22
24
|
throw new Error("package.json has no version");
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
const USAGE = `usage: zapara [
|
|
26
|
-
zapara day
|
|
27
|
-
zapara card [
|
|
28
|
-
|
|
27
|
+
const USAGE = `usage: zapara [window] the last 7 days, one cell per hour
|
|
28
|
+
zapara today|yesterday|<date> one day, one row per active hour
|
|
29
|
+
zapara card [window] [--out] the last 14 days as one picture
|
|
30
|
+
zapara status write today's load for a status line
|
|
31
|
+
|
|
32
|
+
window:
|
|
33
|
+
--days <N> the last N days, 1..90; with --to, N days ending there
|
|
34
|
+
--from <date> first day; --to <date> last day, default today
|
|
35
|
+
a date is YYYY-MM-DD, today or yesterday
|
|
36
|
+
|
|
37
|
+
options:
|
|
38
|
+
--explain with a day: the six weighted parts behind each index
|
|
39
|
+
--out <path> with card: .png, .webp or .html (default zapara-card.png)
|
|
40
|
+
--json the same data as JSON; a pipe gets JSON without asking
|
|
41
|
+
--projects <dir> read this directory instead of ~/.claude/projects
|
|
42
|
+
--no-color no ANSI colors; NO_COLOR does the same
|
|
43
|
+
-h, --help -V, --version
|
|
44
|
+
|
|
29
45
|
levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
|
|
46
|
+
const HINT = "run 'zapara --help' for usage";
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
// A grid is the window as one cell per hour; a day is one date as one row per
|
|
49
|
+
// hour; status is today, written to the status file for a status line to read.
|
|
50
|
+
type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
|
|
32
51
|
|
|
33
52
|
class UsageError extends Error {}
|
|
34
53
|
// Thrown only at a flag position (never when a token was consumed as another
|
|
@@ -37,68 +56,136 @@ class UsageError extends Error {}
|
|
|
37
56
|
class HelpRequested extends Error {}
|
|
38
57
|
class VersionRequested extends Error {}
|
|
39
58
|
|
|
59
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
60
|
+
|
|
61
|
+
// A usage error quotes the offending value only when it is short, printable ASCII
|
|
62
|
+
// with no path separator: the CLI never prints a filesystem path or an escape,
|
|
63
|
+
// not even one the person typed.
|
|
64
|
+
const quotable = (v: string): boolean => /^[\x21-\x7e]{1,24}$/.test(v) && !/[\/\\]/.test(v);
|
|
65
|
+
const got = (v: string): string => (quotable(v) ? `, got ${v}` : "");
|
|
66
|
+
const named = (v: string): string => (quotable(v) ? ` ${v}` : "");
|
|
67
|
+
|
|
40
68
|
function validDate(s: string): boolean {
|
|
41
|
-
const m =
|
|
69
|
+
const m = DATE.exec(s);
|
|
42
70
|
if (!m) return false;
|
|
43
71
|
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
44
72
|
const dt = new Date(y, mo - 1, d);
|
|
45
73
|
return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
|
|
46
74
|
}
|
|
47
75
|
|
|
76
|
+
// A date on the command line: YYYY-MM-DD, today or yesterday, in local time.
|
|
77
|
+
function resolveDate(what: string, s: string, now: Date): string {
|
|
78
|
+
if (s === "today") return localDate(now);
|
|
79
|
+
if (s === "yesterday") return localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
|
|
80
|
+
if (!validDate(s)) throw new UsageError(`${what} must be YYYY-MM-DD, today or yesterday${got(s)}`);
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Calendar days from one date to another, inclusive; UTC arithmetic so a DST day is still one day.
|
|
85
|
+
function spanDays(from: string, to: string): number {
|
|
86
|
+
const utc = (s: string): number => { const [y = 0, m = 0, d = 0] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
|
|
87
|
+
return Math.round((utc(to) - utc(from)) / 86_400_000) + 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The window from its flags: --days ending today or at --to, or --from/--to, both
|
|
91
|
+
// inclusive. --from with --days is one length too many. Checks run in the order
|
|
92
|
+
// a reader meets the flags in --help.
|
|
93
|
+
function windowOf(days: string | null, from: string | null, to: string | null, defaultDays: number, now: Date): { to: string; days: number } {
|
|
94
|
+
if (from !== null && days !== null) throw new UsageError("--from sets the length; drop --days");
|
|
95
|
+
if (days !== null && (!/^\d+$/.test(days) || Number(days) < 1 || Number(days) > 90)) throw new UsageError(`--days must be 1..90${got(days)}`);
|
|
96
|
+
const last = to === null ? localDate(now) : resolveDate("--to", to, now);
|
|
97
|
+
if (from === null) return { to: last, days: days === null ? defaultDays : Number(days) };
|
|
98
|
+
const first = resolveDate("--from", from, now);
|
|
99
|
+
const span = spanDays(first, last);
|
|
100
|
+
if (span < 1) throw new UsageError(`--from ${first} is after --to ${last}`);
|
|
101
|
+
if (span > 90) throw new UsageError(`--from ${first} to ${last} is ${span} days; the most is 90`);
|
|
102
|
+
return { to: last, days: span };
|
|
103
|
+
}
|
|
104
|
+
|
|
48
105
|
function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boolean): Args {
|
|
49
|
-
const a: Args = { command: "
|
|
50
|
-
let days:
|
|
106
|
+
const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: "zapara-card.png", projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
|
|
107
|
+
let days: string | null = null;
|
|
108
|
+
let from: string | null = null;
|
|
109
|
+
let to: string | null = null;
|
|
51
110
|
let jsonFlag = false;
|
|
52
111
|
let outGiven = false;
|
|
53
112
|
const positional: string[] = [];
|
|
113
|
+
// A value flag given twice is a usage error, not the last value winning
|
|
114
|
+
// silently; bare flags (--json, --explain, --no-color) are idempotent and stay untracked.
|
|
115
|
+
const seen = new Set<string>();
|
|
54
116
|
for (let i = 0; i < argv.length; i++) {
|
|
55
|
-
|
|
117
|
+
let arg = argv[i]!;
|
|
118
|
+
// `--days=30` is `--days 30`. A flag that takes no value refuses an inline one.
|
|
119
|
+
let inline: string | null = null;
|
|
120
|
+
const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
121
|
+
if (eq > 0) { inline = arg.slice(eq + 1); arg = arg.slice(0, eq); }
|
|
122
|
+
const bare = (): void => { if (inline !== null) throw new UsageError(`unknown flag${named(argv[i]!)}`); };
|
|
56
123
|
// A missing value or one that looks like another flag is a usage error,
|
|
57
|
-
// never treated as this flag's value (e.g. `--projects --json`).
|
|
58
|
-
|
|
124
|
+
// never treated as this flag's value (e.g. `--projects --json`). Only --days
|
|
125
|
+
// takes a negative number as a value, so `--days -1` reaches the range check.
|
|
126
|
+
const value = (negativeNumberIsValue = false): string => {
|
|
127
|
+
let v: string;
|
|
128
|
+
if (inline !== null) v = inline;
|
|
129
|
+
else {
|
|
130
|
+
const next = argv[++i];
|
|
131
|
+
if (next === undefined || (next.startsWith("-") && !(negativeNumberIsValue && /^-\d/.test(next)))) throw new UsageError(`${arg} needs a value`);
|
|
132
|
+
v = next;
|
|
133
|
+
}
|
|
134
|
+
if (seen.has(arg)) throw new UsageError(`${arg} given twice`);
|
|
135
|
+
seen.add(arg);
|
|
136
|
+
return v;
|
|
137
|
+
};
|
|
59
138
|
switch (arg) {
|
|
60
139
|
case "--help":
|
|
61
|
-
case "-h": throw new HelpRequested();
|
|
62
|
-
case "--version":
|
|
63
|
-
case "
|
|
64
|
-
case "--
|
|
65
|
-
case "--
|
|
140
|
+
case "-h": bare(); throw new HelpRequested();
|
|
141
|
+
case "--version":
|
|
142
|
+
case "-V": bare(); throw new VersionRequested();
|
|
143
|
+
case "--json": bare(); jsonFlag = true; break;
|
|
144
|
+
case "--explain": bare(); a.explain = true; break;
|
|
145
|
+
case "--no-color": bare(); a.color = false; break;
|
|
66
146
|
case "--projects": a.projects = value(); break;
|
|
67
|
-
case "--
|
|
68
|
-
case "--
|
|
147
|
+
case "--from": from = value(); break;
|
|
148
|
+
case "--to": to = value(); break;
|
|
149
|
+
case "--days": days = value(true); break;
|
|
69
150
|
case "--out": a.out = value(); outGiven = true; break;
|
|
70
151
|
default:
|
|
71
|
-
if (arg.startsWith("-")) throw new UsageError(`unknown flag
|
|
152
|
+
if (arg.startsWith("-")) throw new UsageError(`unknown flag${named(arg)}`);
|
|
72
153
|
positional.push(arg);
|
|
73
154
|
}
|
|
74
155
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
else if (
|
|
79
|
-
else
|
|
80
|
-
|
|
81
|
-
|
|
156
|
+
if (positional.length > 1) throw new UsageError(`unexpected argument${named(positional[1]!)}`);
|
|
157
|
+
const [word] = positional;
|
|
158
|
+
if (word === undefined) a.command = "grid";
|
|
159
|
+
else if (word === "card") a.command = "card";
|
|
160
|
+
else if (word === "status") { a.command = "status"; a.to = localDate(now); a.days = 1; }
|
|
161
|
+
else if (word === "today" || word === "yesterday" || DATE.test(word)) { a.command = "day"; a.to = resolveDate("date", word, now); a.days = 1; }
|
|
162
|
+
else throw new UsageError(`unknown command${named(word)} (try today, yesterday, a date, card or status)`);
|
|
163
|
+
|
|
164
|
+
// Both commands fix their own window: a named day is the date given, status is today.
|
|
165
|
+
if (a.command === "day" || a.command === "status") {
|
|
166
|
+
if (days !== null || from !== null || to !== null) throw new UsageError(`--days, --from and --to do not apply to ${a.command === "day" ? "a named day" : "status"}`);
|
|
167
|
+
} else {
|
|
168
|
+
// Two weeks make a pattern; a week makes a picture of one week.
|
|
169
|
+
({ to: a.to, days: a.days } = windowOf(days, from, to, a.command === "card" ? 14 : 7, now));
|
|
170
|
+
}
|
|
82
171
|
// Tables turn into JSON in a pipe; the card is a file either way, so only an explicit --json switches it.
|
|
83
172
|
a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
|
|
84
|
-
if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to day only");
|
|
173
|
+
if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to a named day only");
|
|
85
174
|
if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
|
|
86
|
-
// The value is printed back verbatim in `wrote
|
|
175
|
+
// The value is printed back verbatim in `wrote \u2026`, so it must be one plain line:
|
|
87
176
|
// no control character, and the message never quotes it.
|
|
88
177
|
if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
|
|
89
178
|
if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
|
|
90
|
-
if (!validDate(a.to)) throw new UsageError(`--to must be YYYY-MM-DD, got ${a.to}`);
|
|
91
|
-
if (a.date !== null && !validDate(a.date)) throw new UsageError(`date must be YYYY-MM-DD, got ${a.date}`);
|
|
92
179
|
return a;
|
|
93
180
|
}
|
|
94
181
|
|
|
95
182
|
async function main(): Promise<number> {
|
|
96
183
|
const argv = process.argv.slice(2);
|
|
97
|
-
const
|
|
184
|
+
const now = new Date();
|
|
185
|
+
const a = parseArgs(argv, now, process.env, process.stdout.isTTY === true);
|
|
98
186
|
if (a.command === "card") return card(a);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: await report({ projects: a.projects, to: a.to, days: a.days });
|
|
187
|
+
if (a.command === "status") return status(a, now);
|
|
188
|
+
const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now });
|
|
102
189
|
const data = a.command === "day" ? days[0] : days;
|
|
103
190
|
if (a.json) console.log(renderJson(data!));
|
|
104
191
|
else if (a.command === "day") console.log(renderDay(days[0]!, { explain: a.explain, color: a.color }));
|
|
@@ -106,6 +193,17 @@ async function main(): Promise<number> {
|
|
|
106
193
|
return 0;
|
|
107
194
|
}
|
|
108
195
|
|
|
196
|
+
// Today's load, written to the status file and then printed. The write comes
|
|
197
|
+
// first so that a run whose write failed prints its one-line error and nothing
|
|
198
|
+
// else: a caller never reads a line that was not saved for the status line.
|
|
199
|
+
async function status(a: Args, now: Date): Promise<number> {
|
|
200
|
+
const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now });
|
|
201
|
+
const line = renderStatus(statusOf(days[0]!, now));
|
|
202
|
+
await writeStatus(line, process.env);
|
|
203
|
+
process.stdout.write(line);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
109
207
|
async function card(a: Args): Promise<number> {
|
|
110
208
|
const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days });
|
|
111
209
|
const data = cardData(days, { days: a.days });
|
|
@@ -134,7 +232,7 @@ if (import.meta.main) {
|
|
|
134
232
|
catch (err) { console.error(`zapara: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); }
|
|
135
233
|
}
|
|
136
234
|
const msg = e instanceof Error ? e.message : String(e);
|
|
137
|
-
if (e instanceof UsageError) { console.error(`zapara: ${msg}\n${
|
|
235
|
+
if (e instanceof UsageError) { console.error(`zapara: ${msg}\n${HINT}`); process.exit(2); }
|
|
138
236
|
console.error(`zapara: ${msg}`);
|
|
139
237
|
process.exit(1);
|
|
140
238
|
});
|
package/src/render.ts
CHANGED
|
@@ -10,6 +10,12 @@ const WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
|
10
10
|
const paint = (s: string, level: Level, color: boolean) => (color ? `\x1b[${ANSI[level]}m${s}\x1b[0m` : s);
|
|
11
11
|
const dim = (s: string, color: boolean) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
12
12
|
const hm = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
|
|
13
|
+
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
14
|
+
// A window that includes today reads a transcript Claude Code is still appending
|
|
15
|
+
// to; this line marks the snapshot time on the one day still open, so two runs
|
|
16
|
+
// minutes apart are explained rather than silently disagreeing.
|
|
17
|
+
const snapshotLine = (d: Day | undefined, color: boolean): string[] =>
|
|
18
|
+
d?.asOf ? [dim(` as of ${hhmm(new Date(d.asOf))}, this hour is still running`, color)] : [];
|
|
13
19
|
const label = (date: string) => {
|
|
14
20
|
const [y, m, d] = date.split("-").map(Number) as [number, number, number];
|
|
15
21
|
return `${WEEKDAY[new Date(y, m - 1, d).getDay()]} ${String(d).padStart(2, "0")}/${String(m).padStart(2, "0")}`;
|
|
@@ -34,7 +40,7 @@ export function renderWeek(days: Day[], color: boolean): string {
|
|
|
34
40
|
// Counts go through formatCount so a very active window (999 999 999 prompts) still
|
|
35
41
|
// fits inside the grid's 100 columns; hm(active) has no compact form, so it stays as is.
|
|
36
42
|
const totals = dim(` ${hm(active)} active ${plural(prompts, "prompt")} ${plural(reports, "report")} ${plural(decisions, "decision")} ${plural(maxSessions, "session")} at once`, color);
|
|
37
|
-
return [header, ...rows, "", legend, totals].join("\n");
|
|
43
|
+
return [header, ...rows, "", legend, totals, ...snapshotLine(days[days.length - 1], color)].join("\n");
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// Values at or above 1000 are shown as one decimal of a thousand (e.g. "41.2k"); smaller values print as-is.
|
|
@@ -83,8 +89,10 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
|
|
|
83
89
|
cells.map((c, i) => { const w = columns[i]![1]; if (i === 0) return c.padEnd(w); if (columns[i]![0] === "level") return ` ${c.padEnd(w - 2)}`; return c.padStart(w); }).join("").trimEnd();
|
|
84
90
|
|
|
85
91
|
const active = day.buckets.filter((b) => b.score !== null);
|
|
86
|
-
// No active bucket:
|
|
87
|
-
|
|
92
|
+
// No active bucket: just the full header, plus the snapshot line if this
|
|
93
|
+
// quiet day is still open — otherwise a run at 09:00 and one at 18:00 on an
|
|
94
|
+
// empty today would print the identical line.
|
|
95
|
+
if (active.length === 0) return [line(cols, cols.map(([name]) => name)), ...snapshotLine(day, opts.color)].join("\n");
|
|
88
96
|
|
|
89
97
|
const visible = cols.filter(([name, , f]) => !EVENT_COLS.has(name) || active.some((b) => f(b) !== "0"));
|
|
90
98
|
const leftOut = cols.filter((col) => EVENT_COLS.has(col[0]) && !visible.includes(col));
|
|
@@ -102,6 +110,7 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
|
|
|
102
110
|
const note = ` no ${leftOut.map(([name]) => FULL_NAME[name]).join(", ")} today`;
|
|
103
111
|
lines.push(dim(note, opts.color));
|
|
104
112
|
}
|
|
113
|
+
lines.push(...snapshotLine(day, opts.color));
|
|
105
114
|
return lines.join("\n");
|
|
106
115
|
}
|
|
107
116
|
|
package/src/report.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { windowBounds } from "./derive.ts";
|
|
|
4
4
|
import { scan } from "./scan.ts";
|
|
5
5
|
import type { Day, Transcript } from "./types.ts";
|
|
6
6
|
|
|
7
|
-
export type ReportOptions = { projects: string; to: string; days: number };
|
|
7
|
+
export type ReportOptions = { projects: string; to: string; days: number; now?: Date };
|
|
8
8
|
|
|
9
9
|
// The shell's seam: lists and reads files, then hands the text to the pure core.
|
|
10
10
|
export async function report(o: ReportOptions): Promise<Day[]> {
|
|
11
|
-
const window = { to: o.to, days: o.days };
|
|
11
|
+
const window = { to: o.to, days: o.days, now: o.now };
|
|
12
12
|
const paths = await scan(o.projects, windowBounds(window).cutoffMs);
|
|
13
13
|
const transcripts: Transcript[] = [];
|
|
14
14
|
for (const path of paths) {
|
package/src/scan.ts
CHANGED
|
@@ -1,40 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
4
5
|
// Lists transcript files worth reading. Subagent transcripts live under a `subagents`
|
|
5
|
-
// directory and are the parent agent's conversation, not the human's;
|
|
6
|
-
//
|
|
6
|
+
// directory and are the parent agent's conversation, not the human's; mtime is checked
|
|
7
|
+
// first, and for a file mtime would drop, the last timestamp in its tail decides.
|
|
7
8
|
export async function scan(projects: string, cutoffMs: number): Promise<string[]> {
|
|
9
|
+
// No path in either message: it may be a value the user typed, or the
|
|
10
|
+
// homedir-derived default, and the CLI must never print a filesystem path.
|
|
11
|
+
let root;
|
|
8
12
|
try {
|
|
9
13
|
if (!(await stat(projects)).isDirectory()) throw new Error("not a directory");
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
root = await readdir(projects, { withFileTypes: true });
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const code = (err as { code?: string }).code;
|
|
17
|
+
if (code === "EACCES" || code === "EPERM") throw new Error("projects directory cannot be read (check its permissions)");
|
|
13
18
|
throw new Error("projects directory not found (pass --projects <dir>)");
|
|
14
19
|
}
|
|
15
20
|
const out: string[] = [];
|
|
16
|
-
await
|
|
21
|
+
await collect(projects, root, cutoffMs, out);
|
|
17
22
|
return out.sort();
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
// One directory at a time, so one unreadable directory or a symlink loop
|
|
21
|
-
// that directory. A symlink is not followed into: Claude Code never
|
|
22
|
-
// following it is how a loop or a stray link to $HOME would get in.
|
|
23
|
-
async function
|
|
24
|
-
let entries;
|
|
25
|
-
try {
|
|
26
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
27
|
-
} catch {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
25
|
+
// One directory at a time, so one unreadable directory or a symlink loop below the
|
|
26
|
+
// root costs only that directory. A symlink is not followed into: Claude Code never
|
|
27
|
+
// writes one, and following it is how a loop or a stray link to $HOME would get in.
|
|
28
|
+
async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: string[]): Promise<void> {
|
|
30
29
|
for (const entry of entries) {
|
|
31
30
|
const full = join(dir, entry.name);
|
|
32
31
|
if (entry.isDirectory()) {
|
|
33
|
-
if (entry.name
|
|
32
|
+
if (entry.name === "subagents") continue;
|
|
33
|
+
try {
|
|
34
|
+
await collect(full, await readdir(full, { withFileTypes: true }), cutoffMs, out);
|
|
35
|
+
} catch {}
|
|
34
36
|
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
35
37
|
try {
|
|
36
|
-
if ((await stat(full)).mtimeMs >= cutoffMs) out.push(full);
|
|
38
|
+
if ((await stat(full)).mtimeMs >= cutoffMs || (await lastTimestampMs(full)) >= cutoffMs) out.push(full);
|
|
37
39
|
} catch {}
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
// mtime is a hint, not the truth: a transcript synced from another machine, restored by a
|
|
45
|
+
// tool that rewrites times, or written under clock skew can be older by mtime than the
|
|
46
|
+
// records inside it. For a file mtime would drop, the last "timestamp" in its final 4 KB
|
|
47
|
+
// decides. The tail is matched for that one field and discarded; nothing else is read.
|
|
48
|
+
const TAIL_BYTES = 4096;
|
|
49
|
+
async function lastTimestampMs(path: string): Promise<number> {
|
|
50
|
+
const fh = await open(path, "r");
|
|
51
|
+
try {
|
|
52
|
+
const size = (await fh.stat()).size;
|
|
53
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
54
|
+
const buf = Buffer.alloc(size - start);
|
|
55
|
+
await fh.read(buf, 0, buf.length, start);
|
|
56
|
+
const tail = buf.toString("utf8");
|
|
57
|
+
let last = -Infinity;
|
|
58
|
+
for (const m of tail.matchAll(/"timestamp":"([^"]{20,40})"/g)) {
|
|
59
|
+
const ms = Date.parse(m[1]!);
|
|
60
|
+
if (!Number.isNaN(ms)) last = ms;
|
|
61
|
+
}
|
|
62
|
+
return last;
|
|
63
|
+
} finally {
|
|
64
|
+
await fh.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Day, Level } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
// The status file's content, as data: today's load reduced to the nine values a
|
|
4
|
+
// status line needs. Core, not shell — this file never reads the clock, the
|
|
5
|
+
// environment or the file system, and never writes one; `now` arrives as an
|
|
6
|
+
// argument and `src/statusfile.ts` does the writing. The field order below is
|
|
7
|
+
// the file format (see the status-file design spec) and JSON.stringify keeps it.
|
|
8
|
+
export type Status = {
|
|
9
|
+
schema: 1;
|
|
10
|
+
asOf: string;
|
|
11
|
+
date: string;
|
|
12
|
+
hour: number;
|
|
13
|
+
index: number | null;
|
|
14
|
+
level: Level | null;
|
|
15
|
+
peak: number | null;
|
|
16
|
+
activeMin: number;
|
|
17
|
+
streakMin: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// `hour` is the local hour containing `now`; index, level and streakMin describe
|
|
21
|
+
// that hour's bucket, peak and activeMin the whole day. A day the report did not
|
|
22
|
+
// mark as open has no asOf, so `now` stands in and the function stays total.
|
|
23
|
+
export function statusOf(day: Day, now: Date): Status {
|
|
24
|
+
const hour = now.getHours();
|
|
25
|
+
const bucket = day.buckets[hour];
|
|
26
|
+
return {
|
|
27
|
+
schema: 1,
|
|
28
|
+
asOf: day.asOf ?? now.toISOString(),
|
|
29
|
+
date: day.date,
|
|
30
|
+
hour,
|
|
31
|
+
index: bucket?.score?.index ?? null,
|
|
32
|
+
level: bucket?.score?.level ?? null,
|
|
33
|
+
peak: day.peak,
|
|
34
|
+
activeMin: day.activeMin,
|
|
35
|
+
streakMin: bucket?.streakMin ?? 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One line of JSON, the fields in the order declared above, newline at the end.
|
|
40
|
+
export function renderStatus(s: Status): string {
|
|
41
|
+
return JSON.stringify(s) + "\n";
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The only module that writes the status file. Shell, not core: it owns the
|
|
2
|
+
// path, the directory, the temporary file, the rename and the modes. The line
|
|
3
|
+
// it is handed comes from the pure `src/status.ts`.
|
|
4
|
+
import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const FAILED = "cannot write the status file";
|
|
8
|
+
|
|
9
|
+
// Write `line` to ~/.claude/zapara/status.json atomically: an exclusively
|
|
10
|
+
// created temporary file in the same directory, then a rename over the target.
|
|
11
|
+
// A reader sees the old file or the new one, never a partial line, and never a
|
|
12
|
+
// file written through a symlink someone left at the target: `rename` replaces
|
|
13
|
+
// the link. HOME comes from the env passed in rather than os.homedir(), so an
|
|
14
|
+
// empty HOME is the error the spec names instead of a silent fallback.
|
|
15
|
+
export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
16
|
+
const home = env.HOME;
|
|
17
|
+
if (!home) throw new Error(FAILED);
|
|
18
|
+
// The modes below are a request the umask narrows, so the umask becomes ours
|
|
19
|
+
// before anything is created: zapara is a short-lived CLI and the status file
|
|
20
|
+
// is the only thing it writes from here on. Setting it, rather than widening
|
|
21
|
+
// each path afterwards, is what makes `mkdir` and `open` come out exactly
|
|
22
|
+
// 0700 and 0600 on the first try — and it closes the window where one run has
|
|
23
|
+
// created `~/.claude` too narrow to enter and a second run, seeing a parent
|
|
24
|
+
// that already exists, fails inside it.
|
|
25
|
+
process.umask(0o077);
|
|
26
|
+
const dir = join(home, ".claude", "zapara");
|
|
27
|
+
// The name is unique to this run: two detached runs may write at once, each
|
|
28
|
+
// renames its own complete file and the last rename wins. So this run opens
|
|
29
|
+
// its temp file with `wx` (an existing file or symlink at the name is an
|
|
30
|
+
// error, never followed) and never opens, reuses or deletes one it did not
|
|
31
|
+
// create — from the outside a crashed run's leftover and a slow run's file
|
|
32
|
+
// in flight look the same, and removing the second would break that promise.
|
|
33
|
+
const tmp = join(dir, `status.json.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`);
|
|
34
|
+
let created = false;
|
|
35
|
+
try {
|
|
36
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
// `mkdir` leaves a directory that already exists exactly as it was, so a
|
|
38
|
+
// status directory someone else created loose is tightened here.
|
|
39
|
+
await chmod(dir, 0o700);
|
|
40
|
+
const handle = await open(tmp, "wx", 0o600);
|
|
41
|
+
created = true;
|
|
42
|
+
try { await handle.writeFile(line); } finally { await handle.close(); }
|
|
43
|
+
await chmod(tmp, 0o600);
|
|
44
|
+
await rename(tmp, join(dir, "status.json"));
|
|
45
|
+
} catch {
|
|
46
|
+
// Only this run's own file, and a failure to remove it changes nothing:
|
|
47
|
+
// the error below is what the caller acts on either way.
|
|
48
|
+
if (created) await unlink(tmp).catch(() => {});
|
|
49
|
+
throw new Error(FAILED);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type EventKind = "prompt" | "report" | "output" | "interrupt" | "reject" | "question" | "plan_review" | "mode_change" | "activity";
|
|
2
2
|
export type Event = { ts: number; sessionId: string; kind: EventKind; tokens?: number };
|
|
3
3
|
export type Transcript = { path: string; text: string };
|
|
4
|
-
export type Window = { to: string; days: number };
|
|
4
|
+
export type Window = { to: string; days: number; now?: Date }; // to = "YYYY-MM-DD" local
|
|
5
5
|
export type Metrics = {
|
|
6
6
|
sessions: number; prompts: number; reports: number; outputTokens: number; interrupts: number; rejects: number; questions: number;
|
|
7
7
|
plans: number; modeSwitches: number; decisions: number; contextSwitches: number;
|
|
@@ -12,4 +12,6 @@ export type Parts = { parallel: number; pace: number; supervision: number; readi
|
|
|
12
12
|
export type Score = { index: number; level: Level; parts: Parts };
|
|
13
13
|
export type HourBucket = Metrics & { hour: number; score: Score | null };
|
|
14
14
|
export type Totals = { prompts: number; reports: number; outputTokens: number; interrupts: number; rejects: number; questions: number; plans: number; modeSwitches: number; decisions: number; contextSwitches: number; maxSessions: number };
|
|
15
|
-
|
|
15
|
+
// asOf is set only on the day that is still open when the report runs (the day
|
|
16
|
+
// containing `now`), ISO 8601 UTC.
|
|
17
|
+
export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; totals: Totals; buckets: HourBucket[]; asOf?: string };
|