@drakulavich/zapara 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ 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
+
8
14
  ## [0.4.0] - 2026-09-21
9
15
 
10
16
  ### Changed
package/README.md CHANGED
@@ -135,7 +135,7 @@ This one comes from the same `busy-week` fixture as the pictures above. A headle
135
135
  | `--json` | Print the whole window as one JSON document instead of a table. |
136
136
  | `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
137
137
  | `--out <path>` | Where `card` writes; the extension picks the format. |
138
- | `--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. |
139
139
  | `-h`, `--help` | Usage, exit 0. |
140
140
  | `-V`, `--version` | The version from `package.json`, exit 0. |
141
141
 
@@ -143,7 +143,7 @@ Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
143
143
 
144
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.
145
145
 
146
- Exit codes are 0 on success, 1 when the projects directory is missing or cannot be read, when `status` cannot write its file, or when `card` cannot write its picture (four different messages, none with a path), 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.
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.
147
147
 
148
148
  ## Status line
149
149
 
@@ -159,8 +159,8 @@ A status line wants one number every thirty seconds and cannot wait half a secon
159
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. |
160
160
  | `date` | The local calendar day the numbers describe, `YYYY-MM-DD`. |
161
161
  | `hour` | The local hour that contains `asOf`, `0`..`23`. |
162
- | `index` | That hour's load index, `0`..`100`, or `null` when the hour has no activity yet. |
163
- | `level` | That hour's level, `Calm`, `Warming`, `Heating` or `Fried`, or `null` with `index`. A reader colors by this field so it never needs the thresholds. |
162
+ | `index` | The load index of the sixty minutes ending at `asOf`, `0`..`100`, or `null` when they hold no session. It does not reset at an hour boundary: the hour buckets of `zapara today` are calendar hours, this one is the clock's last hour. |
163
+ | `level` | That index's level, `Calm`, `Warming`, `Heating` or `Fried`, or `null` with `index`. A reader colors by this field so it never needs the thresholds. |
164
164
  | `peak` | The day's peak index so far, or `null` on a day with no activity. |
165
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
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. |
@@ -258,6 +258,6 @@ bun run check # tsc --noEmit, then the test suite under TZ=UTC
258
258
 
259
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).
260
260
 
261
- ## License
261
+ ---
262
262
 
263
- MIT. See [LICENSE](LICENSE).
263
+ <p align="center">Made with ❤️ and 🥤 energy under <a href="LICENSE">MIT License</a></p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/zapara",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Cognitive load index for people driving Claude Code, computed locally from transcripts",
5
5
  "license": "MIT",
6
6
  "author": "Anton Yakutovich",
package/src/analyze.ts CHANGED
@@ -1,12 +1,11 @@
1
- import { derive } from "./derive.ts";
1
+ import { compareStrings, derive } from "./derive.ts";
2
2
  import { parseTranscript } from "./parse.ts";
3
3
  import type { Day, Transcript, Window } from "./types.ts";
4
4
 
5
- // The core's entry point: transcripts already in memory, in the real JSONL format, in.
6
- // Day[] out. Transcripts are sorted by path so file discovery order cannot change a result.
5
+ // Sorted by path so file discovery order cannot change a result.
7
6
  export function analyze(transcripts: Transcript[], w: Window): Day[] {
8
7
  const events = [...transcripts]
9
- .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
8
+ .sort((a, b) => compareStrings(a.path, b.path))
10
9
  .flatMap((t) => parseTranscript(t.text));
11
10
  return derive(events, w);
12
11
  }
package/src/card.ts CHANGED
@@ -1,7 +1,5 @@
1
- // Card data: which of four characters a window is, the sentence behind it, the
2
- // peak hour, the load spectrum and three highlights, every value already
3
- // formatted for the page. Pure: Day[] in, CardData out. Nothing here knows a
4
- // date, a path or a file, so nothing here can leak one.
1
+ // Pure: Day[] in, CardData out, every value already formatted for the page.
2
+ // Nothing here knows a date, a path or a file, so nothing here can leak one.
5
3
  import { formatCount, formatTokens, plural } from "./format.ts";
6
4
  import { NORMS, WEIGHTS } from "./score.ts";
7
5
  import type { Day, HourBucket, Level, Score } from "./types.ts";
@@ -34,9 +32,8 @@ const MOTTOS: Record<Character, string> = {
34
32
  marathoner: "You do not stop while it compiles.",
35
33
  nightOwl: "The best commits happen after midnight.",
36
34
  };
37
- // Ranking norms for the third highlight: a value over its norm says how remarkable
38
- // it is next to the others. Sums are per active hour. These rank a picture and
39
- // never touch the index; the index's own norms stay in score.ts.
35
+ // Rank the third highlight, per active hour. They never touch the index, whose
36
+ // norms stay in score.ts.
40
37
  const CARD_NORMS = { reportsPerHour: 12, tokensPerHour: 65_000, interruptsPerHour: 3, latePercent: 25 } as const;
41
38
  const OWNED: Record<Character, [HighlightKey, HighlightKey]> = {
42
39
  conductor: ["peakSessions", "contextSwitches"],
@@ -94,15 +91,15 @@ export function cardData(days: Day[], w: { days: number }): CardData | null {
94
91
  const max = (f: (b: Active) => number): number => active.reduce((a, b) => Math.max(a, f(b)), 0);
95
92
  const count = (level: Level): number => active.filter((b) => b.score.level === level).length;
96
93
 
97
- // Each share is the fraction of that character's maximum possible points the
98
- // window collected, so a 10-point component competes fairly with a 40-point one.
94
+ // A share is the fraction of the character's maximum points, so a 10-point
95
+ // component competes fairly with a 40-point one.
99
96
  const shares: Record<Character, number> = {
100
97
  conductor: sum((b) => b.score.parts.parallel + b.score.parts.pace) / ((WEIGHTS.parallel + WEIGHTS.pace) * n),
101
98
  supervisor: sum((b) => b.score.parts.supervision + b.score.parts.reading) / ((WEIGHTS.supervision + WEIGHTS.reading) * n),
102
99
  marathoner: sum((b) => b.score.parts.streak) / (WEIGHTS.streak * n),
103
100
  nightOwl: sum((b) => b.score.parts.late) / (WEIGHTS.late * n),
104
101
  };
105
- // Strict > keeps the earlier of equal shares: CHARACTERS is the tie order.
102
+ // Strict >: CHARACTERS is the tie order.
106
103
  const character = CHARACTERS.reduce((best, c) => (shares[c] > shares[best] ? c : best));
107
104
 
108
105
  const maxSessions = max((b) => b.sessions);
@@ -143,7 +140,7 @@ export function cardData(days: Day[], w: { days: number }): CardData | null {
143
140
  lateShare: late / CARD_NORMS.latePercent,
144
141
  };
145
142
  const owned = OWNED[character];
146
- // A late-night number on anyone but the Night Owl is the kind of thing they would hide.
143
+ // A late-night number belongs to the Night Owl alone.
147
144
  const rest = POOL.filter((k) => !owned.includes(k) && (k !== "lateShare" || character === "nightOwl"));
148
145
  const third = rest.reduce((best, k) => (norms[k] > norms[best] ? k : best));
149
146
  const highlights = [...owned, third].map((key) => ({ key, value: values[key], caption: CAPTIONS[key] }));
package/src/cardhtml.ts CHANGED
@@ -1,7 +1,6 @@
1
- // The card page: one self-contained HTML document from CardData and the embedded
2
- // assets. Pure: strings in, string out. Nothing is escaped because nothing from a
3
- // transcript reaches this file: only CardData's fixed strings and formatted numbers.
4
- // The look is the table in docs/superpowers/specs/2026-09-18-zapara-card-design.md.
1
+ // Nothing is escaped because nothing from a transcript reaches this file: only
2
+ // CardData's fixed strings and formatted numbers. The look is the table in
3
+ // docs/superpowers/specs/2026-09-18-zapara-card-design.md.
5
4
  import type { CardData, Character, Segment } from "./card.ts";
6
5
  import type { Level } from "./types.ts";
7
6
 
@@ -10,10 +9,8 @@ export type CardAssets = {
10
9
  characters: string;
11
10
  };
12
11
 
13
- // Where each character sits on the sheet, as fractions [x, y, w, h] of its side,
14
- // measured on the current assets/characters.webp. The box is scaled so the
15
- // rectangle's longer side is BOX px and centred at CENTRE, with CSS background-size
16
- // and background-position, so no cropping happens anywhere.
12
+ // Fractions [x, y, w, h] of the sheet's side, measured on assets/characters.webp.
13
+ // Scaled so the longer side is BOX px, centred at CENTRE; nothing is cropped.
17
14
  export const CHARACTER_RECTS: Record<Character, [number, number, number, number]> = {
18
15
  conductor: [0.02, 0.01, 0.53, 0.543],
19
16
  supervisor: [0.55, 0.07, 0.38, 0.505],
@@ -23,7 +20,7 @@ export const CHARACTER_RECTS: Record<Character, [number, number, number, number]
23
20
  const BOX = 360;
24
21
  const CENTRE = { x: 195, y: 300 };
25
22
 
26
- // Streak accent, its rgb for alpha gradients, and the light shade for the repo link.
23
+ // main, its rgb for alpha gradients, and the light shade for the repo link.
27
24
  const ACCENT: Record<Character, { main: string; rgb: string; light: string }> = {
28
25
  conductor: { main: "#8b5cf6", rgb: "139,92,246", light: "#c4b5fd" },
29
26
  supervisor: { main: "#22d3ee", rgb: "34,211,238", light: "#a5f3fc" },
package/src/derive.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { score } from "./score.ts";
2
- import type { Day, Event, EventKind, HourBucket, Metrics, Totals, Window } from "./types.ts";
2
+ import type { Day, Event, EventKind, HourBucket, LiveBucket, Metrics, Totals, Window } from "./types.ts";
3
3
 
4
4
  export const LOOKBACK_MS = 3 * 60 * 60 * 1000;
5
5
  export const GAP_MS = 10 * 60 * 1000;
6
6
  export const SLOT_MS = 5 * 60 * 1000;
7
+ const LIVE_MS = 60 * 60 * 1000;
7
8
  const LATE_HOURS = new Set([23, 0, 1, 2, 3, 4, 5]);
8
9
 
10
+ // Not localeCompare: a result must not depend on the locale.
11
+ export const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
9
12
  const pad2 = (n: number) => String(n).padStart(2, "0");
10
13
  export const localDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
11
14
  const parseDate = (s: string): Date => {
@@ -30,25 +33,64 @@ const emptyMetrics = (): Metrics => ({
30
33
  decisions: 0, contextSwitches: 0, activeMin: 0, streakMin: 0, lateNight: false,
31
34
  });
32
35
 
33
- // Presence is every action the human takes: what they typed, and the three ways
34
- // they answer or stop the agent. Agent records (`activity`, `report`, `output`)
35
- // and the agent's own asks (`question`, `plan_review`) are never presence.
36
+ // Presence is the human's own actions only: agent records and the agent's asks
37
+ // are never presence, because presence answers "is it time to rest?".
36
38
  const PRESENCE: ReadonlySet<EventKind> = new Set<EventKind>(["prompt", "interrupt", "reject", "answer"]);
37
39
 
38
40
  type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; maxStreakMs: number; lastPresence: { ts: number; streakStart: number } | null };
41
+ const newAcc = (): Acc => ({ m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, maxStreakMs: 0, lastPresence: null });
39
42
 
40
- // Walks the sorted, look-back-filtered events once, keyed by "date|hour",
41
- // tracking the running presence streak (which may start before startMs) and
42
- // accumulating each bucket's raw counts.
43
- //
44
- // Presence is the human's: both the streak and the covered slots are built from
45
- // the PRESENCE kinds alone, because both answer "is it time to rest?". Agents
46
- // that work on while the human is away must not keep a streak alive or fill the
47
- // day, so `activity` is left with session liveness and nothing else.
48
- //
49
- // Presence events before startMs update presence only: they are never counted in
50
- // a bucket, and neither are the slots they cover before startMs, but a span from
51
- // such an event into the window still covers the window's first slots.
43
+ // Inside a streak the human sat through the gap, so a pair of actions covers
44
+ // every slot between them; an action that starts a streak covers only its own.
45
+ const coverage = (prevTs: number | null, streakStart: number, e: Event): { from: number; to: number; streakStart: number } => {
46
+ const to = Math.floor(e.ts / SLOT_MS);
47
+ if (prevTs !== null && e.ts - prevTs <= GAP_MS) return { from: Math.floor(prevTs / SLOT_MS), to, streakStart };
48
+ return { from: to, to, streakStart: e.ts };
49
+ };
50
+
51
+ // The counts an hour bucket and the live bucket share. `streakStart` is the
52
+ // caller's: a streak may have begun before this accumulator's window. The streak
53
+ // kept is the longest seen, not the one the window ended on, or a single prompt
54
+ // after a break would erase the run.
55
+ function accumulate(a: Acc, e: Event, streakStart: number): void {
56
+ if (PRESENCE.has(e.kind)) {
57
+ a.maxStreakMs = Math.max(a.maxStreakMs, e.ts - streakStart);
58
+ a.lastPresence = { ts: e.ts, streakStart }; // for the day's live streak
59
+ }
60
+ switch (e.kind) {
61
+ case "activity":
62
+ a.sessions.add(e.sessionId);
63
+ break;
64
+ case "prompt":
65
+ a.m.prompts++;
66
+ if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
67
+ a.lastPromptSession = e.sessionId;
68
+ break;
69
+ case "report": a.m.reports++; break;
70
+ case "output": a.m.outputTokens += e.tokens ?? 0; break;
71
+ case "interrupt": a.m.interrupts++; break;
72
+ case "reject": a.m.rejects++; break;
73
+ case "question": a.m.questions++; break;
74
+ case "plan_review": a.m.plans++; break;
75
+ case "mode_change": a.m.modeSwitches++; break;
76
+ }
77
+ }
78
+
79
+ // `lateNight` is the caller's: an hour's own label, or the hour of `now` for the
80
+ // live bucket. A fresh accumulator is an empty hour and scores null.
81
+ function finish(a: Acc, lateNight: boolean): LiveBucket {
82
+ const m = a.m;
83
+ m.sessions = a.sessions.size;
84
+ m.activeMin = a.slots.size * 5;
85
+ m.streakMin = Math.round(a.maxStreakMs / 60000);
86
+ m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
87
+ m.lateNight = lateNight;
88
+ return { ...m, score: score(m) };
89
+ }
90
+
91
+ // The 24 hour buckets, keyed "date|hour". Events before startMs are look-back:
92
+ // they move the streak but enter no bucket, though a span from one into the
93
+ // window still covers the window's first slots.
52
94
  function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>; carried: { ts: number; streakStart: number } | null } {
53
95
  const acc = new Map<string, Acc>(); // key "date|hour"
54
96
  const key = (ts: number): string => {
@@ -57,7 +99,7 @@ function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>;
57
99
  };
58
100
  const get = (k: string): Acc => {
59
101
  let a = acc.get(k);
60
- if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, maxStreakMs: 0, lastPresence: null }; acc.set(k, a); }
102
+ if (!a) { a = newAcc(); acc.set(k, a); }
61
103
  return a;
62
104
  };
63
105
 
@@ -66,76 +108,53 @@ function foldEvents(sorted: Event[], startMs: number): { acc: Map<string, Acc>;
66
108
  let carried: { ts: number; streakStart: number } | null = null;
67
109
  for (const e of sorted) {
68
110
  if (PRESENCE.has(e.kind)) {
69
- const slot = Math.floor(e.ts / SLOT_MS);
70
- // A presence event covers its own slot. Inside a streak the human sat
71
- // through the gap too, so the pair also covers every slot between them; an
72
- // event that starts a streak covers nothing behind it.
73
- let from = slot;
74
- if (prevPresenceTs !== null && e.ts - prevPresenceTs <= GAP_MS) from = Math.floor(prevPresenceTs / SLOT_MS);
75
- else streakStart = e.ts;
76
- for (let s = from; s <= slot; s++) {
111
+ const c = coverage(prevPresenceTs, streakStart, e);
112
+ streakStart = c.streakStart;
113
+ for (let s = c.from; s <= c.to; s++) {
77
114
  const slotStart = s * SLOT_MS;
78
115
  if (slotStart >= startMs) get(key(slotStart)).slots.add(s); // a slot belongs to the bucket of its start
79
116
  }
80
117
  prevPresenceTs = e.ts;
81
- // The last human action before the window opens, kept for the first day:
82
- // a person still at the keyboard at 23:58 is still in that streak at
83
- // 00:03, and the day they are looking at holds nothing yet.
118
+ // A person at the keyboard at 23:58 is still in that streak at 00:03,
119
+ // and the day they are looking at holds nothing yet.
84
120
  if (e.ts < startMs) carried = { ts: e.ts, streakStart };
85
121
  }
86
122
  if (e.ts < startMs) continue; // look-back: presence bookkeeping only
87
- const a = get(key(e.ts));
88
- // The hour's streak is the longest it saw, not the one it happened to end
89
- // on: a single prompt after a break would otherwise erase a run of hours.
90
- // A streak is longest at its last event, and that event lies in some hour,
91
- // so the maximum over hours is the run's true length.
123
+ accumulate(get(key(e.ts)), e, streakStart);
124
+ }
125
+ return { acc, carried };
126
+ }
127
+
128
+ // The sixty minutes ending at `now`, by the rule an hour bucket uses. Presence
129
+ // runs from the first event, so a streak older than the window is measured from
130
+ // where it began. Events before the day's start count here, unlike in a bucket:
131
+ // the window is a clock's hour, not a calendar's.
132
+ function foldLive(sorted: Event[], nowMs: number): Acc {
133
+ const a = newAcc();
134
+ const fromMs = nowMs - LIVE_MS;
135
+ const inWindow = (t: number): boolean => t > fromMs && t <= nowMs;
136
+ let prevPresenceTs: number | null = null;
137
+ let streakStart = 0;
138
+ for (const e of sorted) {
92
139
  if (PRESENCE.has(e.kind)) {
93
- a.maxStreakMs = Math.max(a.maxStreakMs, e.ts - streakStart);
94
- a.lastPresence = { ts: e.ts, streakStart }; // the bucket's last, for the day's live streak
95
- }
96
- switch (e.kind) {
97
- case "activity":
98
- a.sessions.add(e.sessionId);
99
- break;
100
- case "prompt":
101
- a.m.prompts++;
102
- if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
103
- a.lastPromptSession = e.sessionId;
104
- break;
105
- case "report": a.m.reports++; break;
106
- case "output": a.m.outputTokens += e.tokens ?? 0; break;
107
- case "interrupt": a.m.interrupts++; break;
108
- case "reject": a.m.rejects++; break;
109
- case "question": a.m.questions++; break;
110
- case "plan_review": a.m.plans++; break;
111
- case "mode_change": a.m.modeSwitches++; break;
140
+ const c = coverage(prevPresenceTs, streakStart, e);
141
+ streakStart = c.streakStart;
142
+ for (let s = c.from; s <= c.to; s++) if (inWindow(s * SLOT_MS)) a.slots.add(s);
143
+ prevPresenceTs = e.ts;
112
144
  }
145
+ if (inWindow(e.ts)) accumulate(a, e, streakStart);
113
146
  }
114
- return { acc, carried };
147
+ return a;
115
148
  }
116
149
 
117
- // Builds one Day's 24 hour buckets from the accumulated counts, scores each,
118
- // and rolls up totals, peak and mean.
119
150
  function buildDay(date: string, acc: Map<string, Acc>): Day {
120
151
  const buckets: HourBucket[] = [];
121
- // The day's last human action: the last one of the highest hour that saw any.
122
- // Hours run in time order, so the last write wins.
152
+ // Hours run in time order, so the last write is the day's last action.
123
153
  let lastPresence: { ts: number; streakStart: number } | null = null;
124
154
  for (let hour = 0; hour < 24; hour++) {
125
155
  const a = acc.get(`${date}|${hour}`);
126
- const m = a ? a.m : emptyMetrics();
127
- if (a) {
128
- if (a.lastPresence) lastPresence = a.lastPresence;
129
- m.sessions = a.sessions.size;
130
- m.activeMin = a.slots.size * 5;
131
- m.streakMin = Math.round(a.maxStreakMs / 60000);
132
- }
133
- m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
134
- // lateNight is a property of the hour label, so it is set on every bucket,
135
- // including empty ones; score() returns null for buckets without
136
- // sessions, so an empty late hour scores nothing.
137
- m.lateNight = LATE_HOURS.has(hour);
138
- buckets.push({ ...m, hour, score: score(m) });
156
+ if (a?.lastPresence) lastPresence = a.lastPresence;
157
+ buckets.push({ ...finish(a ?? newAcc(), LATE_HOURS.has(hour)), hour });
139
158
  }
140
159
  const scored = buckets.filter((b) => b.score !== null);
141
160
  const totals: Totals = buckets.reduce((t, b) => ({
@@ -158,24 +177,25 @@ function buildDay(date: string, acc: Map<string, Acc>): Day {
158
177
 
159
178
  export function derive(events: Event[], w: Window): Day[] {
160
179
  const { startMs, endMs, cutoffMs, dates } = windowBounds(w);
180
+ // sort() is stable, so events equal on both keys keep their input order.
161
181
  const sorted = events
162
- .map((e, i) => ({ e, i }))
163
- .filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
164
- .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)
165
- .map(({ e }) => e);
182
+ .filter((e) => e.ts >= cutoffMs && e.ts < endMs && (!w.now || e.ts <= w.now.getTime()))
183
+ .sort((a, b) => a.ts - b.ts || compareStrings(a.sessionId, b.sessionId));
166
184
  const { acc, carried } = foldEvents(sorted, startMs);
167
185
  const days = dates.map((date) => buildDay(date, acc));
168
- // Only the first day can be preceded by the look-back, and only a day with no
169
- // action of its own needs it: the streak that was running when the window
170
- // opened is still the one the person is in. It reaches `presence` and nothing
171
- // else — no bucket, no total, no active minute belongs to a day before this one.
186
+ // Only a first day with no action of its own borrows the look-back's streak,
187
+ // and only into `presence`: no bucket, total or active minute crosses back.
172
188
  const first = days[0];
173
189
  if (first && first.presence === null && carried) {
174
190
  first.presence = { lastAt: new Date(carried.ts).toISOString(), streakStartAt: new Date(carried.streakStart).toISOString() };
175
191
  }
176
192
  if (w.now) {
177
193
  const today = localDate(w.now);
178
- for (const d of days) if (d.date === today) d.asOf = w.now.toISOString();
194
+ const open = days.find((d) => d.date === today);
195
+ if (open) {
196
+ open.asOf = w.now.toISOString();
197
+ open.live = finish(foldLive(sorted, w.now.getTime()), LATE_HOURS.has(w.now.getHours()));
198
+ }
179
199
  }
180
200
  return days;
181
201
  }
package/src/format.ts CHANGED
@@ -1,8 +1,5 @@
1
- // Compact number formats shared by the card and the week footer. Pure: no
2
- // imports from node:/Bun, no clock, no file system.
3
-
4
- // Compact formats with a fixed longest form of five characters, so the layout
5
- // is sized once. Decimals are truncated, not rounded: 9.96M stays "9.9M".
1
+ // Longest form is five characters, so the layout is sized once. Decimals are
2
+ // truncated, not rounded: 9.96M stays "9.9M".
6
3
  function ladder(n: number): string {
7
4
  if (n >= 1e12) return "999B+";
8
5
  for (const [unit, size] of [["B", 1e9], ["M", 1e6], ["k", 1e3]] as const) {
@@ -16,5 +13,4 @@ function ladder(n: number): string {
16
13
  export const formatCount = (n: number): string => (n < 10_000 ? String(n) : ladder(n));
17
14
  export const formatTokens = (n: number): string => (n < 1000 ? String(n) : ladder(n));
18
15
 
19
- // "1 session", "5 sessions": a count with its noun, singular only for exactly one.
20
16
  export const plural = (n: number, one: string, many = `${one}s`, count = formatCount): string => `${count(n)} ${n === 1 ? one : many}`;
package/src/image.ts CHANGED
@@ -1,14 +1,12 @@
1
- // The card's shell: the only module that reads the card assets, opens a
2
- // Bun.WebView or a Bun.Image, and writes a file. Everything it writes is the one
3
- // file the person named; nothing here prints.
1
+ // The only module that reads the card assets, opens a Bun.WebView or Bun.Image,
2
+ // and writes the card.
4
3
  import { readFile, writeFile } from "node:fs/promises";
5
4
  import type { CardAssets } from "./cardhtml.ts";
6
5
 
7
6
  const ASSETS = new URL("../assets/", import.meta.url);
8
7
  const FILES = ["fonts/inter-400.woff2", "fonts/inter-700.woff2", "fonts/inter-800.woff2", "fonts/jetbrains-mono-500.woff2", "characters.webp"] as const;
9
8
 
10
- // Reads the five files next to the source. A missing, unreadable or empty one is
11
- // a broken install, reported without a path: the CLI never prints one.
9
+ // A missing or empty asset is a broken install, reported without a path.
12
10
  export async function loadAssets(): Promise<CardAssets> {
13
11
  let parts: string[];
14
12
  try {
@@ -28,14 +26,9 @@ const WIDTH = 2400;
28
26
  const HEIGHT = 1260;
29
27
  const READY = 'document.fonts.ready.then(() => document.fonts.status === "loaded" && Array.from(document.images).every((i) => i.complete))';
30
28
 
31
- // Writes the page as is for `.html`; otherwise photographs it at 2400x1260 and
32
- // writes PNG or WebP. The 15s budget bounds the whole render (construct, navigate,
33
- // poll, screenshot, resize, encode), raced against a single timer; the view is
34
- // closed on every path. Any engine failure (constructor, navigate, evaluate,
35
- // screenshot) is mapped to one line that never quotes the engine's own text; the
36
- // timeout error passes through unchanged. A failed write (missing directory, no
37
- // permission, a directory in the way, a read-only file system) becomes one line
38
- // that names no path, because the node error quotes the whole path.
29
+ // The budget bounds the whole render, raced against one timer; the view is
30
+ // closed on every path. An engine failure becomes one line that never quotes
31
+ // the engine's text; the timeout error passes through unchanged.
39
32
  export async function renderCard(html: string, out: string, timeoutMs = 15_000): Promise<void> {
40
33
  const lower = out.toLowerCase();
41
34
  if (lower.endsWith(".html")) {
@@ -71,8 +64,7 @@ export async function renderCard(html: string, out: string, timeoutMs = 15_000):
71
64
  await write(out, bytes);
72
65
  }
73
66
 
74
- // The only place the card is written. Every failure is the same one line: the
75
- // error node raises quotes the path in full, and the CLI never prints one.
67
+ // One line for every failure: node's error quotes the path, and the CLI never prints one.
76
68
  async function write(out: string, data: string | Uint8Array): Promise<void> {
77
69
  try {
78
70
  await writeFile(out, data);
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // zapara CLI. Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
2
+ // Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
3
3
  import { readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
@@ -13,9 +13,8 @@ import { renderStatus, statusOf } from "./status.ts";
13
13
  import { writeStatus } from "./statusfile.ts";
14
14
  import type { Day } from "./types.ts";
15
15
 
16
- // Read lazily, only when --version is actually handled, so a broken install
17
- // (missing or corrupt package.json) fails inside the guarded catch below
18
- // instead of throwing at module load, before any try/catch is in place.
16
+ // Read only when --version is handled, so a broken package.json fails inside
17
+ // the guarded catch instead of at module load.
19
18
  function version(): string {
20
19
  const parsed: unknown = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
20
  if (typeof parsed === "object" && parsed !== null && typeof (parsed as { version?: unknown }).version === "string") {
@@ -45,22 +44,18 @@ options:
45
44
  levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
46
45
  const HINT = "run 'zapara --help' for usage";
47
46
 
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
47
  type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
51
48
 
52
49
  class UsageError extends Error {}
53
- // Thrown only at a flag position (never when a token was consumed as another
54
- // flag's value, e.g. `--to --help`), so `main()` can short-circuit to exit 0
55
- // without parseArgs having to also validate the rest of a help/version call.
50
+ // Thrown only at a flag position, never for a token consumed as another flag's
51
+ // value (`--to --help`).
56
52
  class HelpRequested extends Error {}
57
53
  class VersionRequested extends Error {}
58
54
 
59
55
  const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
60
56
 
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.
57
+ // A usage error quotes the value only when it is short, printable ASCII with no
58
+ // path separator: the CLI never prints a path or an escape, even one typed in.
64
59
  const quotable = (v: string): boolean => /^[\x21-\x7e]{1,24}$/.test(v) && !/[\/\\]/.test(v);
65
60
  const got = (v: string): string => (quotable(v) ? `, got ${v}` : "");
66
61
  const named = (v: string): string => (quotable(v) ? ` ${v}` : "");
@@ -73,7 +68,6 @@ function validDate(s: string): boolean {
73
68
  return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
74
69
  }
75
70
 
76
- // A date on the command line: YYYY-MM-DD, today or yesterday, in local time.
77
71
  function resolveDate(what: string, s: string, now: Date): string {
78
72
  if (s === "today") return localDate(now);
79
73
  if (s === "yesterday") return localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
@@ -81,15 +75,13 @@ function resolveDate(what: string, s: string, now: Date): string {
81
75
  return s;
82
76
  }
83
77
 
84
- // Calendar days from one date to another, inclusive; UTC arithmetic so a DST day is still one day.
78
+ // UTC arithmetic, so a DST day is still one day.
85
79
  function spanDays(from: string, to: string): number {
86
80
  const utc = (s: string): number => { const [y = 0, m = 0, d = 0] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
87
81
  return Math.round((utc(to) - utc(from)) / 86_400_000) + 1;
88
82
  }
89
83
 
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.
84
+ // Checks run in the order a reader meets the flags in --help.
93
85
  function windowOf(days: string | null, from: string | null, to: string | null, defaultDays: number, now: Date): { to: string; days: number } {
94
86
  if (from !== null && days !== null) throw new UsageError("--from sets the length; drop --days");
95
87
  if (days !== null && (!/^\d+$/.test(days) || Number(days) < 1 || Number(days) > 90)) throw new UsageError(`--days must be 1..90${got(days)}`);
@@ -110,19 +102,16 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
110
102
  let jsonFlag = false;
111
103
  let outGiven = false;
112
104
  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.
105
+ // A value flag given twice is a usage error; bare flags are idempotent and untracked.
115
106
  const seen = new Set<string>();
116
107
  for (let i = 0; i < argv.length; i++) {
117
108
  let arg = argv[i]!;
118
- // `--days=30` is `--days 30`. A flag that takes no value refuses an inline one.
119
109
  let inline: string | null = null;
120
110
  const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
121
111
  if (eq > 0) { inline = arg.slice(eq + 1); arg = arg.slice(0, eq); }
122
112
  const bare = (): void => { if (inline !== null) throw new UsageError(`unknown flag${named(argv[i]!)}`); };
123
- // A missing value or one that looks like another flag is a usage error,
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.
113
+ // A value that looks like another flag is a usage error (`--projects --json`);
114
+ // only --days accepts a negative number, so `--days -1` reaches the range check.
126
115
  const value = (negativeNumberIsValue = false): string => {
127
116
  let v: string;
128
117
  if (inline !== null) v = inline;
@@ -161,19 +150,16 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
161
150
  else if (word === "today" || word === "yesterday" || DATE.test(word)) { a.command = "day"; a.to = resolveDate("date", word, now); a.days = 1; }
162
151
  else throw new UsageError(`unknown command${named(word)} (try today, yesterday, a date, card or status)`);
163
152
 
164
- // Both commands fix their own window: a named day is the date given, status is today.
165
153
  if (a.command === "day" || a.command === "status") {
166
154
  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
155
  } else {
168
- // Two weeks make a pattern; a week makes a picture of one week.
169
156
  ({ to: a.to, days: a.days } = windowOf(days, from, to, a.command === "card" ? 14 : 7, now));
170
157
  }
171
- // Tables turn into JSON in a pipe; the card is a file either way, so only an explicit --json switches it.
158
+ // The card is a file either way, so only an explicit --json switches it.
172
159
  a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
173
160
  if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to a named day only");
174
161
  if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
175
- // The value is printed back verbatim in `wrote \u2026`, so it must be one plain line:
176
- // no control character, and the message never quotes it.
162
+ // Printed back verbatim in `wrote \u2026`, so it must be one plain line.
177
163
  if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
178
164
  if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
179
165
  return a;
@@ -193,9 +179,7 @@ async function main(): Promise<number> {
193
179
  return 0;
194
180
  }
195
181
 
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.
182
+ // The write comes first: a caller never reads a line that was not saved.
199
183
  async function status(a: Args, now: Date): Promise<number> {
200
184
  const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days, now });
201
185
  const line = renderStatus(statusOf(days[0]!, now));
package/src/parse.ts CHANGED
@@ -5,9 +5,8 @@ const INTERRUPT_PREFIX = "[Request interrupted by user";
5
5
  const REJECT_PREFIX = "The user doesn't want to proceed with this tool use";
6
6
  const QUESTION_TOOL = "AskUserQuestion";
7
7
  const PLAN_TOOL = "ExitPlanMode";
8
- // Inbound messages from subagents, other sessions and background tasks: the human
9
- // has to read and react to these, but did not type them, so they are `report`
10
- // events, never `prompt`. An interrupt marker is checked first and always wins.
8
+ // Inbound agent messages: read and reacted to, but not typed, so `report`, never
9
+ // `prompt`. An interrupt marker is checked first and wins.
11
10
  const AGENT_MARKERS = [
12
11
  "Another Claude session sent a message:",
13
12
  "<teammate-message",
@@ -20,10 +19,8 @@ type Rec = Record<string, unknown>;
20
19
  const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null;
21
20
  const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
22
21
 
23
- // The text a marker is compared against: the content itself when it is a string,
24
- // otherwise the first `{type:"text"}` block anywhere in the array. A pasted
25
- // screenshot puts an `image` block in front of what the person typed, so the
26
- // text is not always the first block.
22
+ // A pasted screenshot puts an `image` block before the typed text, so the text
23
+ // block is searched for, not taken from content[0].
27
24
  const firstText = (content: unknown): string | null => {
28
25
  if (typeof content === "string") return content;
29
26
  if (!Array.isArray(content)) return null;
@@ -35,16 +32,12 @@ export function parseTranscript(text: string): Event[] {
35
32
  const events: Event[] = [];
36
33
  let lastTs: number | null = null;
37
34
  let lastMode: string | null = null;
38
- // Mode switches seen before this file's first timestamped record, waiting for
39
- // a time to belong to.
40
35
  let pendingModeChanges = 0;
41
- // Dedupes `output` events by requestId within this one file: Claude Code
42
- // writes one record per content block of a response, repeating the same
43
- // requestId and usage, so only the first qualifying record counts.
36
+ // Claude Code writes one record per content block of a response, repeating
37
+ // the requestId and usage; only the first qualifying record counts.
44
38
  const seenRequestIds = new Set<string>();
45
- // `tool_use` ids of the two tools whose result the human writes: the option
46
- // they picked, or their verdict on a plan. Every other `tool_result` is the
47
- // machine reporting back, so the pairing is what tells the two apart.
39
+ // Ids of the two tools whose result the human writes; every other
40
+ // `tool_result` is the machine reporting back.
48
41
  const askedIds = new Set<string>();
49
42
 
50
43
  for (const line of text.split("\n")) {
@@ -60,11 +53,8 @@ export function parseTranscript(text: string): Event[] {
60
53
  if (type === "permission-mode") {
61
54
  const m = str(rec.permissionMode);
62
55
  if (m === null) continue;
63
- // The baseline mode is tracked as soon as it is seen, even before any
64
- // timestamp exists, so a later switch away from it can be detected. A
65
- // switch before any timestamp waits: switching the mode before typing
66
- // anything is how a session often starts, and it is attributed to the
67
- // first timestamped record that follows. A file with none drops them.
56
+ // A switch before the first timestamped record is how a session often
57
+ // starts; it waits and is attributed to that record.
68
58
  if (lastMode !== null && m !== lastMode) {
69
59
  if (lastTs === null) pendingModeChanges++;
70
60
  else events.push({ ts: lastTs, sessionId, kind: "mode_change" });
@@ -106,14 +96,9 @@ export function parseTranscript(text: string): Event[] {
106
96
  const id = str(b.id);
107
97
  if (id !== null) askedIds.add(id);
108
98
  }
109
- // `output`: one event per distinct requestId per file, the first record seen
110
- // that has a string requestId, numeric usage.output_tokens and at least one
111
- // text block. A response holding only tool_use blocks is not text the human
112
- // reads, so it never triggers this, even on its first (and only) record.
113
- // A count only counts when it is one a token count can be: a finite
114
- // non-negative integer. `1e309` parses to Infinity and a negative count
115
- // to -Infinity, and a day holding both summed to NaN; such a record is
116
- // still `activity`, it just carries no tokens.
99
+ // A response of only tool_use blocks is not text the human reads and carries
100
+ // no output tokens. A count must be a finite non-negative integer: `1e309`
101
+ // parses to Infinity, and a day once summed to NaN.
117
102
  const requestId = str(rec.requestId);
118
103
  const usage = isObj(rec.message) ? rec.message.usage : undefined;
119
104
  const outputTokens = isObj(usage) ? usage.output_tokens : undefined;
package/src/render.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { formatCount, plural } from "./format.ts";
2
+ import { levelOf } from "./score.ts";
2
3
  import type { Day, HourBucket, Level } from "./types.ts";
3
4
 
4
5
  const GLYPH: Record<Level, string> = { Calm: "░", Warming: "▒", Heating: "▓", Fried: "█" };
@@ -11,9 +12,8 @@ const paint = (s: string, level: Level, color: boolean) => (color ? `\x1b[${ANSI
11
12
  const dim = (s: string, color: boolean) => (color ? `\x1b[2m${s}\x1b[0m` : s);
12
13
  const hm = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
13
14
  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.
15
+ // Marks the snapshot time on the open day, so two runs minutes apart are
16
+ // explained rather than silently disagreeing.
17
17
  const snapshotLine = (d: Day | undefined, color: boolean): string[] =>
18
18
  d?.asOf ? [dim(` as of ${hhmm(new Date(d.asOf))}, this hour is still running`, color)] : [];
19
19
  const label = (date: string) => {
@@ -22,14 +22,15 @@ const label = (date: string) => {
22
22
  };
23
23
 
24
24
  export function renderWeek(days: Day[], color: boolean): string {
25
- // Geometry: 12-char label, 24 cells of 3 chars (glyph in the middle), peak in 6, active in 8. Header hours are `HH ` so they sit over the cells.
25
+ // 12-char label, 24 cells of 3 chars, peak in 6, active in 8.
26
26
  const header = " " + Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")} `).join("") + " peak active";
27
27
  const rows = days.map((d) => {
28
28
  const cells = d.buckets.map((b) => ` ${b.score ? paint(GLYPH[b.score.level], b.score.level, color) : "·"} `).join("");
29
- return `${label(d.date).padEnd(12)}${cells}${String(d.peak ?? "-").padStart(6)}${hm(d.activeMin).padStart(8)}`;
29
+ // Padding stays outside the paint so the escape codes add no width.
30
+ const peak = d.peak === null ? "-".padStart(6) : " ".repeat(6 - String(d.peak).length) + paint(String(d.peak), levelOf(d.peak), color);
31
+ return `${label(d.date).padEnd(12)}${cells}${peak}${hm(d.activeMin).padStart(8)}`;
30
32
  });
31
- // A painted glyph's own \x1b[0m would cancel the line's outer dim, so in
32
- // color mode re-emit \x1b[2m right after it to keep the label dim too.
33
+ // A painted glyph's own \x1b[0m cancels the line's dim; re-emit it after.
33
34
  const dimGlyph = (level: Level) => paint(GLYPH[level], level, color) + (color ? "\x1b[2m" : "");
34
35
  const legend = dim(" " + LEVELS.map((l) => `${dimGlyph(l)} ${LEVEL_NAME[l]}`).join(" "), color);
35
36
  const active = days.reduce((s, d) => s + d.activeMin, 0);
@@ -37,18 +38,16 @@ export function renderWeek(days: Day[], color: boolean): string {
37
38
  const reports = days.reduce((s, d) => s + d.totals.reports, 0);
38
39
  const decisions = days.reduce((s, d) => s + d.totals.decisions, 0);
39
40
  const maxSessions = Math.max(0, ...days.map((d) => d.totals.maxSessions));
40
- // Counts go through formatCount so a very active window (999 999 999 prompts) still
41
- // fits inside the grid's 100 columns; hm(active) has no compact form, so it stays as is.
41
+ // Compact counts keep a very active window inside the grid's 100 columns.
42
42
  const totals = dim(` ${hm(active)} active ${plural(prompts, "prompt")} ${plural(reports, "report")} ${plural(decisions, "decision")} ${plural(maxSessions, "session")} at once`, color);
43
43
  return [header, ...rows, "", legend, totals, ...snapshotLine(days[days.length - 1], color)].join("\n");
44
44
  }
45
45
 
46
- // Values at or above 1000 are shown as one decimal of a thousand (e.g. "41.2k"); smaller values print as-is.
47
46
  const fmtTokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
48
47
 
49
48
  type Col = [string, number, (b: HourBucket) => string];
50
49
 
51
- // Widths reproduce the header the test pins: hour is left-aligned, level is left-aligned inside a 9-wide cell with two leading spaces, everything else right-aligned.
50
+ // Hour left-aligned, level left-aligned after two spaces, the rest right-aligned.
52
51
  const COLS: Col[] = [
53
52
  ["hour", 5, (b) => `${String(b.hour).padStart(2, "0")}:00`],
54
53
  ["index", 7, (b) => String(b.score?.index ?? "")],
@@ -73,10 +72,8 @@ const EXPLAIN: Col[] = [
73
72
  ["strk", 6, (b) => String(b.score?.parts.streak ?? "")],
74
73
  ["late", 6, (b) => String(b.score?.parts.late ?? "")],
75
74
  ];
76
- // Event columns: left out of the table for a day where every active bucket reads
77
- // zero (a quiet day is mostly zeros, and the eye hunts for the non-zero cell).
78
- // The skeleton columns (hour, index, level, sess, prompts, streak, out-tok, and
79
- // the six --explain parts) always show, so two days still line up.
75
+ // Left out of the table for a day where every active bucket reads zero; the
76
+ // other columns always show, so two days still line up.
80
77
  const EVENT_COLS = new Set(["rep", "intr", "rej", "quest", "plan", "mode", "ctx-sw"]);
81
78
  const FULL_NAME: Record<string, string> = {
82
79
  rep: "reports", intr: "interrupts", rej: "rejects", quest: "questions",
@@ -89,9 +86,8 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
89
86
  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();
90
87
 
91
88
  const active = day.buckets.filter((b) => b.score !== null);
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.
89
+ // The snapshot line still shows on an empty open day, or a run at 09:00 and
90
+ // one at 18:00 would print the identical line.
95
91
  if (active.length === 0) return [line(cols, cols.map(([name]) => name)), ...snapshotLine(day, opts.color)].join("\n");
96
92
 
97
93
  const visible = cols.filter(([name, , f]) => !EVENT_COLS.has(name) || active.some((b) => f(b) !== "0"));
@@ -101,8 +97,7 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
101
97
  const rows = active.map((b) => {
102
98
  const cells = visible.map(([, , f]) => f(b));
103
99
  const text = line(visible, cells);
104
- // Safe only because no other column can contain a level word (Calm/Warming/Heating/Fried);
105
- // if one ever could, this would need to target the level column's slice, not a string search.
100
+ // Safe only while no other column can contain a level word.
106
101
  return opts.color && b.score ? text.replace(b.score.level, paint(b.score.level, b.score.level, true)) : text;
107
102
  });
108
103
  const lines = [header, ...rows];
package/src/report.ts CHANGED
@@ -6,7 +6,6 @@ import type { Day, Transcript } from "./types.ts";
6
6
 
7
7
  export type ReportOptions = { projects: string; to: string; days: number; now?: Date };
8
8
 
9
- // The shell's seam: lists and reads files, then hands the text to the pure core.
10
9
  export async function report(o: ReportOptions): Promise<Day[]> {
11
10
  const window = { to: o.to, days: o.days, now: o.now };
12
11
  const paths = await scan(o.projects, windowBounds(window).cutoffMs);
package/src/scan.ts CHANGED
@@ -2,12 +2,9 @@ import type { Dirent } from "node:fs";
2
2
  import { open, readdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
 
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; mtime is checked
7
- // first, and for a file mtime would drop, the last timestamp in its tail decides.
5
+ // A `subagents` directory holds the parent agent's conversation, not the human's.
8
6
  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.
7
+ // No path in either message: the CLI never prints one.
11
8
  let root;
12
9
  try {
13
10
  if (!(await stat(projects)).isDirectory()) throw new Error("not a directory");
@@ -22,9 +19,8 @@ export async function scan(projects: string, cutoffMs: number): Promise<string[]
22
19
  return out.sort();
23
20
  }
24
21
 
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.
22
+ // One unreadable directory costs only itself. Symlinks are not followed: Claude
23
+ // Code never writes one, and following one is how a loop or $HOME would get in.
28
24
  async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: string[]): Promise<void> {
29
25
  for (const entry of entries) {
30
26
  const full = join(dir, entry.name);
@@ -41,12 +37,9 @@ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: st
41
37
  }
42
38
  }
43
39
 
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 64 KB
47
- // decides. 64 KB, not a few, because the last record of a conversation is often a big tool
48
- // result (a file read, grep output) and its own timestamp field sits in front of all that
49
- // text. The tail is matched for that one field and discarded; nothing else is read.
40
+ // mtime can be older than the records inside (sync, restore, clock skew), so for
41
+ // a file mtime would drop, the last "timestamp" in its final 64 KB decides. 64 KB
42
+ // because the last record is often a big tool result with its timestamp in front.
50
43
  const TAIL_BYTES = 65_536;
51
44
  async function lastTimestampMs(path: string): Promise<number> {
52
45
  const fh = await open(path, "r");
package/src/score.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { Level, Metrics, Parts, Score } from "./types.ts";
2
2
 
3
- // Calibration lives here and nowhere else. A change is one diff plus a CHANGELOG line.
4
- // Points of 100, kept as integers so 0.5 sums stay exact in floating point.
5
- // Norms are the p90 of two weeks of real data on two machines (see CHANGELOG).
3
+ // Calibration lives here and nowhere else. Integer points of 100, so 0.5 sums
4
+ // stay exact; norms are the p90 of two weeks on two machines (see CHANGELOG).
6
5
  export const WEIGHTS = { parallel: 25, pace: 15, supervision: 30, reading: 10, streak: 10, late: 10 } as const;
7
6
  export const NORMS = { parallelSpan: 4, pacePerHour: 20, supervisionPerHour: 45, decisionWeight: 3, readingTokens: 80_000, streakMin: 40 } as const;
8
7
  export const LEVELS: readonly { max: number; level: Level }[] = [
@@ -23,8 +22,7 @@ export function score(m: Metrics): Score | null {
23
22
  if (m.sessions === 0) return null;
24
23
  const parallel = clamp01((m.sessions - 1) / NORMS.parallelSpan);
25
24
  const pace = clamp01(m.prompts / NORMS.pacePerHour);
26
- // Supervision is one load: an explicit decision costs `decisionWeight` times what
27
- // reacting to one agent report or one session hop costs, and the three share a norm.
25
+ // A decision costs `decisionWeight` reports or session hops; the three share a norm.
28
26
  const supervision = clamp01(
29
27
  (NORMS.decisionWeight * m.decisions + m.reports + m.contextSwitches) / NORMS.supervisionPerHour,
30
28
  );
@@ -40,7 +38,7 @@ export function score(m: Metrics): Score | null {
40
38
  streak: WEIGHTS.streak * streak,
41
39
  late: WEIGHTS.late * late,
42
40
  };
43
- // parts are the same raw weighted points, rounded to one decimal for display only.
41
+ // Rounded for display only.
44
42
  const parts: Parts = {
45
43
  parallel: Math.round(raw.parallel * 10) / 10,
46
44
  pace: Math.round(raw.pace * 10) / 10,
@@ -49,8 +47,7 @@ export function score(m: Metrics): Score | null {
49
47
  streak: Math.round(raw.streak * 10) / 10,
50
48
  late: Math.round(raw.late * 10) / 10,
51
49
  };
52
- // index is rounded once, from the unrounded raw points, so per-component
53
- // rounding (parts, above) can never tip it across a boundary raw didn't.
50
+ // Rounded once from the raw points, so parts' rounding can never tip it across a boundary.
54
51
  const index = Math.round(
55
52
  raw.parallel + raw.pace + raw.supervision + raw.reading + raw.streak + raw.late,
56
53
  );
package/src/status.ts CHANGED
@@ -1,11 +1,7 @@
1
1
  import { GAP_MS } from "./derive.ts";
2
2
  import type { Day, Level } from "./types.ts";
3
3
 
4
- // The status file's content, as data: today's load reduced to the nine values a
5
- // status line needs. Core, not shell — this file never reads the clock, the
6
- // environment or the file system, and never writes one; `now` arrives as an
7
- // argument and `src/statusfile.ts` does the writing. The field order below is
8
- // the file format (see the status-file design spec) and JSON.stringify keeps it.
4
+ // The status file's nine values. The field order is the file format.
9
5
  export type Status = {
10
6
  schema: 1;
11
7
  asOf: string;
@@ -18,34 +14,26 @@ export type Status = {
18
14
  streakMin: number;
19
15
  };
20
16
 
21
- // `hour` is the local hour containing `now`; index and level describe that
22
- // hour's bucket, peak and activeMin the whole day, and streakMin the streak the
23
- // person is in right now. A day the report did not mark as open has no asOf, so
24
- // `now` stands in and the function stays total.
25
- //
26
- // The live streak, not the bucket's: a status line answers "how long have I
27
- // been at this?", and the bucket's number drops to zero at every hour boundary
28
- // and stops growing between two actions. While the last action is no more than
29
- // GAP_MS behind `now`, the streak runs from its first action to `now`; once the
30
- // break is longer than that, it is over and reads 0.
17
+ // index and level are the day's `live` bucket, not the hour's: an hour's bucket
18
+ // is nearly empty just after the hour turns. streakMin is measured against `now`,
19
+ // not the bucket's: it must not reset on the hour or stop between two actions,
20
+ // and it is over once the last action is more than GAP_MS behind `now`.
31
21
  export function statusOf(day: Day, now: Date): Status {
32
22
  const hour = now.getHours();
33
- const bucket = day.buckets[hour];
34
23
  const live = day.presence !== null && now.getTime() - Date.parse(day.presence.lastAt) <= GAP_MS;
35
24
  return {
36
25
  schema: 1,
37
26
  asOf: day.asOf ?? now.toISOString(),
38
27
  date: day.date,
39
28
  hour,
40
- index: bucket?.score?.index ?? null,
41
- level: bucket?.score?.level ?? null,
29
+ index: day.live?.score?.index ?? null,
30
+ level: day.live?.score?.level ?? null,
42
31
  peak: day.peak,
43
32
  activeMin: day.activeMin,
44
33
  streakMin: live ? Math.round((now.getTime() - Date.parse(day.presence!.streakStartAt)) / 60000) : 0,
45
34
  };
46
35
  }
47
36
 
48
- // One line of JSON, the fields in the order declared above, newline at the end.
49
37
  export function renderStatus(s: Status): string {
50
38
  return JSON.stringify(s) + "\n";
51
39
  }
package/src/statusfile.ts CHANGED
@@ -1,41 +1,30 @@
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`.
1
+ // The only module that writes the status file.
4
2
  import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
5
3
  import { join } from "node:path";
6
4
 
7
5
  const FAILED = "cannot write the status file";
8
6
 
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.
7
+ // Atomic: a temporary file in the same directory, then a rename over the
8
+ // target, so a reader never sees a partial line and a symlink at the target is
9
+ // replaced, not written through. HOME comes from `env`, not os.homedir(), so an
10
+ // empty HOME is an error rather than a silent fallback.
15
11
  export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise<void> {
16
12
  const home = env.HOME;
17
13
  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.
14
+ // The umask narrows the modes below; set it first so `mkdir` and `open` come
15
+ // out 0700 and 0600 on the first try, with no window where a second run finds
16
+ // a parent it cannot enter. Nothing else is written from here on.
25
17
  process.umask(0o077);
26
18
  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.
19
+ // Unique per run: concurrent runs each rename their own complete file. Opened
20
+ // with `wx` so a file or symlink already at the name is an error, and never
21
+ // reused or deleted if not ours: a crashed run's leftover and a slow run's
22
+ // file in flight look the same from outside.
33
23
  const tmp = join(dir, `status.json.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`);
34
24
  let created = false;
35
25
  try {
36
26
  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.
27
+ // `mkdir` leaves an existing directory as it was; tighten it.
39
28
  await chmod(dir, 0o700);
40
29
  const handle = await open(tmp, "wx", 0o600);
41
30
  created = true;
@@ -43,8 +32,6 @@ export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise
43
32
  await chmod(tmp, 0o600);
44
33
  await rename(tmp, join(dir, "status.json"));
45
34
  } 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
35
  if (created) await unlink(tmp).catch(() => {});
49
36
  throw new Error(FAILED);
50
37
  }
package/src/types.ts CHANGED
@@ -10,12 +10,11 @@ export type Metrics = {
10
10
  export type Level = "Calm" | "Warming" | "Heating" | "Fried";
11
11
  export type Parts = { parallel: number; pace: number; supervision: number; reading: number; streak: number; late: number }; // weighted points, sum ≈ index
12
12
  export type Score = { index: number; level: Level; parts: Parts };
13
- export type HourBucket = Metrics & { hour: number; score: Score | null };
13
+ export type LiveBucket = Metrics & { score: Score | null };
14
+ export type HourBucket = LiveBucket & { hour: number };
14
15
  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
- // asOf is set only on the day that is still open when the report runs (the day
16
- // containing `now`), ISO 8601 UTC.
17
- // `presence` is the day's last human action and the start of the streak that
18
- // action belongs to, both ISO 8601 UTC. The start may lie on an earlier day or
19
- // in the look-back. `null` on a day with no human action. It is what lets a
20
- // reader measure the streak against its own clock instead of the hour bucket.
21
- export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; presence: { lastAt: string; streakStartAt: string } | null; totals: Totals; buckets: HourBucket[]; asOf?: string };
16
+ // `asOf` and `live` exist only on the day containing `now`: `live` is the sixty
17
+ // minutes `(asOf − 60 min, asOf]`, by the hour bucket's rule, look-back included.
18
+ // `presence` is the day's last human action and the start of its streak, which
19
+ // may lie on an earlier day; instants are ISO 8601 UTC.
20
+ export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; presence: { lastAt: string; streakStartAt: string } | null; totals: Totals; buckets: HourBucket[]; asOf?: string; live?: LiveBucket };