@drakulavich/zapara 0.2.0 → 0.3.1

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,21 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.1] - 2026-09-19
9
+
10
+ ### Changed
11
+ - Streak and active minutes count your presence, not the agent's: a streak is your prompts no more than 10 minutes apart across sessions, and active minutes are the five-minute slots those streaks cover. Agent work while you are away no longer keeps a streak alive or fills the day.
12
+
13
+ ## [0.3.0] - 2026-09-19
14
+
15
+ ### Added
16
+ - 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.
17
+ - `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.
18
+
19
+ ### Fixed
20
+ - 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.
21
+ - 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.
22
+
8
23
  ## [0.2.0] - 2026-09-18
9
24
 
10
25
  ### Changed
package/README.md CHANGED
@@ -38,29 +38,29 @@ Both pictures below come from the synthetic fixture in `tests/fixtures/busy-week
38
38
 
39
39
  ```
40
40
  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
- Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 6h00
42
- Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 4h00
41
+ Mon 14/09 · · · · · · · · · ░ ░ ░ █ █ █ · · · · · ░ ░ · ░ 87 8h50
42
+ Tue 15/09 · · · · · · · · · · ░ ░ ░ ░ ▒ ▒ ▒ ░ · · · · · · 33 7h55
43
43
  Wed 16/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
44
- Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 14 1h00
45
- Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 82 1h00
46
- Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 24 1h00
44
+ Thu 17/09 · · · · · · · · · · · ░ ░ · · · · · · · · · · · 14 1h55
45
+ Fri 18/09 · · · · · · · · · · · · · · · ▓ · · · · · · · · 81 1h00
46
+ Sat 19/09 ░ ░ · · · · · · · · · · · · · · · · · · · · · · 24 1h55
47
47
  Sun 20/09 · · · · · · · · · · · · · · · · · · · · · · · · - 0h00
48
48
 
49
49
  ░ calm ▒ warming ▓ heating █ fried
50
- 13h00 active 346 prompts 0 reports 100 decisions 5 sessions at once
50
+ 21h35 active 348 prompts 0 reports 100 decisions 5 sessions at once
51
51
  ```
52
52
 
53
53
  ```
54
54
  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 53m 600 0 4.5 0 0.1 4.4 0
56
- 10:00 14 Calm 1 6 0 0 0 0 0 0 113m 600 0 4.5 0 0.1 9.4 0
57
- 11:00 15 Calm 1 6 0 0 0 0 0 0 173m 600 0 4.5 0 0.1 10 0
58
- 12:00 87 Fried 5 55 20 1 1 1 2 54 237m 55.0k 25 15 30 6.9 10 0
59
- 13:00 87 Fried 5 55 20 1 1 1 2 54 297m 55.0k 25 15 30 6.9 10 0
60
- 14:00 87 Fried 5 55 20 1 1 1 2 54 357m 55.0k 25 15 30 6.9 10 0
61
- 20:00 9 Calm 1 6 0 0 0 0 0 0 53m 600 0 4.5 0 0.1 4.4 0
62
- 21:00 14 Calm 1 6 0 0 0 0 0 0 113m 600 0 4.5 0 0.1 9.4 0
63
- 23:00 19 Calm 1 6 0 0 0 0 0 0 53m 600 0 4.5 0 0.1 4.4 10
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
57
+ 11:00 15 Calm 1 7 0 0 0 0 0 0 179m 600 0 5.3 0 0.1 10 0
58
+ 12:00 87 Fried 5 55 20 1 1 1 2 54 235m 55.0k 25 15 30 6.9 10 0
59
+ 13:00 87 Fried 5 55 20 1 1 1 2 54 295m 55.0k 25 15 30 6.9 10 0
60
+ 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
64
64
  no reports today
65
65
  ```
66
66
 
@@ -113,6 +113,7 @@ This one comes from the same `busy-week` fixture as the pictures above. The pict
113
113
  | `zapara 2026-09-14 --explain` | One day, with the six weighted components behind each index. |
114
114
  | `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in the current directory. |
115
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). |
116
117
 
117
118
  | Flag | What it does |
118
119
  |---|---|
@@ -127,26 +128,49 @@ This one comes from the same `busy-week` fixture as the pictures above. The pict
127
128
 
128
129
  Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
129
130
 
130
- 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.
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.
131
132
 
132
- Exit codes are 0 on success, 1 when the projects directory is missing or cannot be read (two different messages, neither 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 window with no data prints an empty grid and exits 0.
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.
134
+
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. A field that keeps its name, unit and range but is measured differently (the presence rule of 2026-09-19 for `activeMin` and `streakMin`) does not bump it either: a reader shows the corrected number, and the change is a CHANGELOG entry. |
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` | 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. |
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.
133
156
 
134
157
  ## Privacy
135
158
 
136
- zapara reads `~/.claude/projects/**/*.jsonl`, taking only files modified inside the window and skipping subagent transcripts under `subagents/`. 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.
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.
137
160
 
138
- 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.
139
162
 
140
163
  ## Limits
141
164
 
142
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.
143
166
  - Only Claude Code transcripts are read. Work in other tools, and time away from the keyboard, is invisible.
144
- - 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.
145
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.
146
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.
147
170
  - The 98-column grid does not adapt to a narrow terminal.
148
171
  - For the grid and the day a pipe always gets JSON, and there is no flag to ask for text instead.
149
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.
150
174
 
151
175
  ## Under the hood
152
176
 
@@ -197,7 +221,7 @@ Weights and norms live in one exported constant in `src/score.ts`, so a recalibr
197
221
  | `plan` | `ExitPlanMode` tool calls in an assistant message, one per block. |
198
222
  | `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. |
199
223
  | `ctx-sw` | Context switches. Over the hour's prompts in time order, the number of consecutive pairs that came from different sessions. |
200
- | `streak` | Minutes since the current activity streak began, which may reach back before the hour. A gap longer than 10 minutes between records breaks the streak. |
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. |
201
225
  | `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. |
202
226
 
203
227
  `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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/derive.ts CHANGED
@@ -30,12 +30,20 @@ const emptyMetrics = (): Metrics => ({
30
30
  decisions: 0, contextSwitches: 0, activeMin: 0, streakMin: 0, lateNight: false,
31
31
  });
32
32
 
33
- type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; lastActivity: { ts: number; streakStart: number } | null };
33
+ type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; lastPrompt: { ts: number; streakStart: number } | null };
34
34
 
35
35
  // Walks the sorted, look-back-filtered events once, keyed by "date|hour",
36
- // tracking the running activity streak (which may start before startMs) and
37
- // accumulating each bucket's raw counts. Events before startMs update the
38
- // streak only; they are never attributed to a bucket.
36
+ // tracking the running presence streak (which may start before startMs) and
37
+ // accumulating each bucket's raw counts.
38
+ //
39
+ // Presence is the human's: both the streak and the covered slots are built from
40
+ // `prompt` events alone, because both answer "is it time to rest?". Agents that
41
+ // work on while the human is away must not keep a streak alive or fill the day,
42
+ // so `activity` is left with session liveness and nothing else.
43
+ //
44
+ // Prompts before startMs update presence only: they are never counted in a
45
+ // bucket, and neither are the slots they cover before startMs, but a span from
46
+ // such a prompt into the window still covers the window's first slots.
39
47
  function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
40
48
  const acc = new Map<string, Acc>(); // key "date|hour"
41
49
  const key = (ts: number): string => {
@@ -44,29 +52,38 @@ function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
44
52
  };
45
53
  const get = (k: string): Acc => {
46
54
  let a = acc.get(k);
47
- if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, lastActivity: null }; acc.set(k, a); }
55
+ if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, lastPrompt: null }; acc.set(k, a); }
48
56
  return a;
49
57
  };
50
58
 
51
- let prevActivityTs: number | null = null;
52
- let streakStart: number | null = null;
59
+ let prevPromptTs: number | null = null;
60
+ let streakStart = 0; // always set by the first prompt, which starts a streak
53
61
  for (const e of sorted) {
54
- if (e.kind === "activity") {
55
- streakStart = prevActivityTs === null || e.ts - prevActivityTs > GAP_MS ? e.ts : streakStart;
56
- prevActivityTs = e.ts;
62
+ if (e.kind === "prompt") {
63
+ const slot = Math.floor(e.ts / SLOT_MS);
64
+ // A prompt covers its own slot. Inside a streak the human sat through the
65
+ // gap too, so the pair also covers every slot between them; a prompt that
66
+ // starts a streak covers nothing behind it.
67
+ let from = slot;
68
+ if (prevPromptTs !== null && e.ts - prevPromptTs <= GAP_MS) from = Math.floor(prevPromptTs / SLOT_MS);
69
+ else streakStart = e.ts;
70
+ for (let s = from; s <= slot; s++) {
71
+ const slotStart = s * SLOT_MS;
72
+ if (slotStart >= startMs) get(key(slotStart)).slots.add(s); // a slot belongs to the bucket of its start
73
+ }
74
+ prevPromptTs = e.ts;
57
75
  }
58
- if (e.ts < startMs) continue; // look-back: streak bookkeeping only
76
+ if (e.ts < startMs) continue; // look-back: presence bookkeeping only
59
77
  const a = get(key(e.ts));
60
78
  switch (e.kind) {
61
79
  case "activity":
62
80
  a.sessions.add(e.sessionId);
63
- a.slots.add(Math.floor(e.ts / SLOT_MS));
64
- a.lastActivity = { ts: e.ts, streakStart: streakStart! };
65
81
  break;
66
82
  case "prompt":
67
83
  a.m.prompts++;
68
84
  if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
69
85
  a.lastPromptSession = e.sessionId;
86
+ a.lastPrompt = { ts: e.ts, streakStart };
70
87
  break;
71
88
  case "report": a.m.reports++; break;
72
89
  case "output": a.m.outputTokens += e.tokens ?? 0; break;
@@ -90,7 +107,7 @@ function buildDay(date: string, acc: Map<string, Acc>): Day {
90
107
  if (a) {
91
108
  m.sessions = a.sessions.size;
92
109
  m.activeMin = a.slots.size * 5;
93
- m.streakMin = a.lastActivity ? Math.round((a.lastActivity.ts - a.lastActivity.streakStart) / 60000) : 0;
110
+ m.streakMin = a.lastPrompt ? Math.round((a.lastPrompt.ts - a.lastPrompt.streakStart) / 60000) : 0;
94
111
  }
95
112
  m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
96
113
  // lateNight is a property of the hour label, so it is set on every bucket,
@@ -121,9 +138,14 @@ export function derive(events: Event[], w: Window): Day[] {
121
138
  const { startMs, endMs, cutoffMs, dates } = windowBounds(w);
122
139
  const sorted = events
123
140
  .map((e, i) => ({ e, i }))
124
- .filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs)
141
+ .filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
125
142
  .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
143
  .map(({ e }) => e);
127
144
  const acc = foldEvents(sorted, startMs);
128
- return dates.map((date) => buildDay(date, acc));
145
+ const days = dates.map((date) => buildDay(date, acc));
146
+ if (w.now) {
147
+ const today = localDate(w.now);
148
+ for (const d of days) if (d.date === today) d.asOf = w.now.toISOString();
149
+ }
150
+ return days;
129
151
  }
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
@@ -25,6 +27,7 @@ function version(): string {
25
27
  const USAGE = `usage: zapara [window] the last 7 days, one cell per hour
26
28
  zapara today|yesterday|<date> one day, one row per active hour
27
29
  zapara card [window] [--out] the last 14 days as one picture
30
+ zapara status write today's load for a status line
28
31
 
29
32
  window:
30
33
  --days <N> the last N days, 1..90; with --to, N days ending there
@@ -42,8 +45,9 @@ options:
42
45
  levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
43
46
  const HINT = "run 'zapara --help' for usage";
44
47
 
45
- // A grid is the window as one cell per hour; a day is one date as one row per hour.
46
- type Args = { command: "grid" | "day" | "card"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
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 };
47
51
 
48
52
  class UsageError extends Error {}
49
53
  // Thrown only at a flag position (never when a token was consumed as another
@@ -106,6 +110,9 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
106
110
  let jsonFlag = false;
107
111
  let outGiven = false;
108
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>();
109
116
  for (let i = 0; i < argv.length; i++) {
110
117
  let arg = argv[i]!;
111
118
  // `--days=30` is `--days 30`. A flag that takes no value refuses an inline one.
@@ -117,9 +124,15 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
117
124
  // never treated as this flag's value (e.g. `--projects --json`). Only --days
118
125
  // takes a negative number as a value, so `--days -1` reaches the range check.
119
126
  const value = (negativeNumberIsValue = false): string => {
120
- if (inline !== null) return inline;
121
- const v = argv[++i];
122
- if (v === undefined || (v.startsWith("-") && !(negativeNumberIsValue && /^-\d/.test(v)))) throw new UsageError(`${arg} needs a value`);
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);
123
136
  return v;
124
137
  };
125
138
  switch (arg) {
@@ -144,11 +157,13 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
144
157
  const [word] = positional;
145
158
  if (word === undefined) a.command = "grid";
146
159
  else if (word === "card") a.command = "card";
160
+ else if (word === "status") { a.command = "status"; a.to = localDate(now); a.days = 1; }
147
161
  else if (word === "today" || word === "yesterday" || DATE.test(word)) { a.command = "day"; a.to = resolveDate("date", word, now); a.days = 1; }
148
- else throw new UsageError(`unknown command${named(word)} (try today, yesterday, a date or card)`);
162
+ else throw new UsageError(`unknown command${named(word)} (try today, yesterday, a date, card or status)`);
149
163
 
150
- if (a.command === "day") {
151
- if (days !== null || from !== null || to !== null) throw new UsageError("--days, --from and --to do not apply to a named day");
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"}`);
152
167
  } else {
153
168
  // Two weeks make a pattern; a week makes a picture of one week.
154
169
  ({ to: a.to, days: a.days } = windowOf(days, from, to, a.command === "card" ? 14 : 7, now));
@@ -166,9 +181,11 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
166
181
 
167
182
  async function main(): Promise<number> {
168
183
  const argv = process.argv.slice(2);
169
- const a = parseArgs(argv, new Date(), process.env, process.stdout.isTTY === true);
184
+ const now = new Date();
185
+ const a = parseArgs(argv, now, process.env, process.stdout.isTTY === true);
170
186
  if (a.command === "card") return card(a);
171
- const days: Day[] = 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 });
172
189
  const data = a.command === "day" ? days[0] : days;
173
190
  if (a.json) console.log(renderJson(data!));
174
191
  else if (a.command === "day") console.log(renderDay(days[0]!, { explain: a.explain, color: a.color }));
@@ -176,6 +193,17 @@ async function main(): Promise<number> {
176
193
  return 0;
177
194
  }
178
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
+
179
207
  async function card(a: Args): Promise<number> {
180
208
  const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days });
181
209
  const data = cardData(days, { days: a.days });
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: today's plain behavior, the full header and nothing else.
87
- if (active.length === 0) return line(cols, cols.map(([name]) => name));
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,10 +1,10 @@
1
1
  import type { Dirent } from "node:fs";
2
- import { readdir, stat } from "node:fs/promises";
2
+ import { open, readdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Lists transcript files worth reading. Subagent transcripts live under a `subagents`
6
- // directory and are the parent agent's conversation, not the human's; a file whose mtime
7
- // is before the cutoff cannot hold events the report needs.
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.
8
8
  export async function scan(projects: string, cutoffMs: number): Promise<string[]> {
9
9
  // No path in either message: it may be a value the user typed, or the
10
10
  // homedir-derived default, and the CLI must never print a filesystem path.
@@ -35,8 +35,32 @@ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: st
35
35
  } catch {}
36
36
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
37
37
  try {
38
- if ((await stat(full)).mtimeMs >= cutoffMs) out.push(full);
38
+ if ((await stat(full)).mtimeMs >= cutoffMs || (await lastTimestampMs(full)) >= cutoffMs) out.push(full);
39
39
  } catch {}
40
40
  }
41
41
  }
42
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 }; // to = "YYYY-MM-DD" local
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
- export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; totals: Totals; buckets: HourBucket[] };
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 };