@drakulavich/zapara 0.5.0 → 0.7.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,23 @@ All notable changes to this project are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-09-23
9
+
10
+ ### Added
11
+ - In a terminal, `zapara card` asks `open it? [Y/n]` and opens the picture in
12
+ the default viewer on Enter. Pipes, scripts and Windows are never asked.
13
+
14
+ ### Changed
15
+ - `zapara card` writes to `~/Downloads/zapara-card.png` instead of the current
16
+ directory, and says `wrote zapara-card.png to Downloads`. Without that
17
+ folder it asks for `--out`.
18
+
19
+ ## [0.6.0] - 2026-09-22
20
+
21
+ ### Changed
22
+ - The week grid dims a day no hour scored in, so a weekend recedes behind the
23
+ working days around it.
24
+
8
25
  ## [0.5.0] - 2026-09-22
9
26
 
10
27
  ### Changed
package/README.md CHANGED
@@ -96,19 +96,16 @@ The grid is a fixed 98 columns wide, 100 with its hour header, and does not refl
96
96
  `zapara card` turns your last two weeks into one picture: a character named after the kind of load that dominates your hours, the sentence behind it, the peak hour, the share of calm, warming, heating and fried hours, and three highlights. It carries no dates and no hour totals, so it does not read as a timesheet.
97
97
 
98
98
  ```bash
99
- zapara card # writes zapara-card.png in the current directory
99
+ zapara card # writes zapara-card.png to ~/Downloads
100
100
  zapara card --out card.webp # WebP instead; --out card.html writes the page itself
101
101
  ```
102
102
 
103
- On macOS, one more command puts the picture on the clipboard, ready to paste into a chat:
104
-
105
- ```bash
106
- zapara card && osascript -e 'set the clipboard to (read (POSIX file "zapara-card.png") as «class PNGf»)'
107
- ```
103
+ In a terminal it then asks `open it? [Y/n]`: Enter opens the picture in the default viewer, where ⌘C copies it for a chat. A pipe or a script is never asked.
108
104
 
109
105
  ```
110
106
  The Marathoner: Longest streak 7h53m without a break, 68% of your hours calm.
111
- wrote zapara-card.png
107
+ wrote zapara-card.png to Downloads
108
+ open it? [Y/n]
112
109
  ```
113
110
 
114
111
  <p align="center"><img src="https://raw.githubusercontent.com/drakulavich/zapara/main/assets/card.webp" alt="zapara card: The Marathoner, longest streak 7h53m, 68% of hours calm" width="800"></p>
@@ -124,7 +121,7 @@ This one comes from the same `busy-week` fixture as the pictures above. A headle
124
121
  | `zapara --from 2026-09-01 --to 2026-09-14` | Any window, both days inclusive, at most 90 days. `--to` alone is 7 days ending there, `--days 30 --to 2026-09-14` is 30 days ending there. |
125
122
  | `zapara today` | Today, one row per hour that had activity. `yesterday` likewise. |
126
123
  | `zapara 2026-09-14 --explain` | One day, with the six weighted components behind each index. |
127
- | `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in the current directory. |
124
+ | `zapara card` | The last 14 days as one shareable picture, `zapara-card.png` in `~/Downloads`. |
128
125
  | `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. |
129
126
  | `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). |
130
127
 
@@ -134,16 +131,16 @@ This one comes from the same `busy-week` fixture as the pictures above. A headle
134
131
  | `--explain` | With a day: the six weighted parts behind each index. |
135
132
  | `--json` | Print the whole window as one JSON document instead of a table. |
136
133
  | `--projects <dir>` | Read this directory instead of `~/.claude/projects`. |
137
- | `--out <path>` | Where `card` writes; the extension picks the format. |
134
+ | `--out <path>` | Where `card` writes instead of `~/Downloads`; the extension picks the format. |
138
135
  | `--no-color` | Plain glyphs and peaks with no ANSI codes. `NO_COLOR` in the environment does the same. |
139
136
  | `-h`, `--help` | Usage, exit 0. |
140
137
  | `-V`, `--version` | The version from `package.json`, exit 0. |
141
138
 
142
139
  Levels: calm 0–29, warming 30–59, heating 60–84, fried 85–100.
143
140
 
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.
141
+ 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` asks to open the picture only when stdin and stdout are both a terminal, and never on Windows. `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
142
 
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.
143
+ A run that works exits 0, and so does a window with no data, which prints an empty grid. Exit 1 is a failure zapara can name, printed as one line to stderr that never contains 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, `card` without `--out` on a machine with no `~/Downloads` folder, and whatever else goes wrong below the command line. Exit 2 is a usage error, such as a bad date, an unknown flag or a value flag given twice; it prints one line and a hint to `--help`.
147
144
 
148
145
  ## Status line
149
146
 
@@ -214,7 +211,7 @@ index = round(25*parallel + 15*pace + 30*supervision + 10*reading + 10*streak +
214
211
  | Heating | 60–84 |
215
212
  | Fried | 85–100 |
216
213
 
217
- The norms are the p90 of two weeks of real transcripts on two machines, 116 and 114 active hours. Why each one is what it is, and what surprised us in that data, is in [How the numbers are made](docs/how-the-numbers-are-made.md#5-the-index).
214
+ The norms are the p90 of two weeks of real transcripts on two machines, 116 and 114 active hours. Why each one is what it is, and what surprised me in that data, is in [How the numbers are made](docs/how-the-numbers-are-made.md#5-the-index).
218
215
 
219
216
  Weights and norms live in one exported constant in `src/score.ts`, so a recalibration is one diff there plus a line in `CHANGELOG.md`.
220
217
 
@@ -258,6 +255,16 @@ bun run check # tsc --noEmit, then the test suite under TZ=UTC
258
255
 
259
256
  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
257
 
258
+ ## Where it comes from
259
+
260
+ The question comes from Addy Osmani's [Your parallel Agent limit](https://addyosmani.com/blog/cognitive-parallel-agents/). More agents running does not make more of you available, because "your cognitive bandwidth doesn't parallelize", and the cost of the ones you are not looking at is what he calls the ambient anxiety tax: "the part of your mind that can't fully relax because it knows something might be silently going sideways in a thread you haven't checked in twenty minutes." His own ceiling is "somewhere around three to four threads depending on complexity", and his advice is to start with one thread less than feels right.
261
+
262
+ The index was calibrated before I read that, from the 90th percentile of two weeks on two machines, and it arrived at the same number: `NORMS.parallelSpan` in [src/score.ts](src/score.ts) is 4, so the fifth session running at once spends all 25 points for parallel work.
263
+
264
+ The post and this tool disagree about what to watch. Osmani's signal is the quality of your own review: you have passed your ceiling when your confidence in what you are accepting starts dropping. A transcript cannot see that. It can see how much model output went past you, which is the `out-tok` column and ten of the hundred points, and it can see the shape of the hour around it. The index is a proxy with a known blind spot, and the number is worth something only next to your memory of the hour it scores.
265
+
266
+ Most of the advice in this area stops at fewer threads, smaller scope and more breaks, with few numbers you can hold yourself to. An hour with a score on it is at least something you can disagree with.
267
+
261
268
  ---
262
269
 
263
270
  <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.5.0",
3
+ "version": "0.7.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/image.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The only module that reads the card assets, opens a Bun.WebView or Bun.Image,
2
- // and writes the card.
2
+ // writes the card, and starts another program: the opener that shows it.
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
4
5
  import type { CardAssets } from "./cardhtml.ts";
5
6
 
6
7
  const ASSETS = new URL("../assets/", import.meta.url);
@@ -72,3 +73,12 @@ async function write(out: string, data: string | Uint8Array): Promise<void> {
72
73
  throw new Error(WRITE_LINE);
73
74
  }
74
75
  }
76
+
77
+ // Absolute, so `-card.html` is never an option. `sh … &` with SIGHUP ignored:
78
+ // a detached Bun.spawn child, or one in a terminal zapara leads, dies with zapara.
79
+ export function openCard(path: string): void {
80
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
81
+ try {
82
+ Bun.spawnSync(["sh", "-c", 'trap "" HUP; "$0" "$@" </dev/null >/dev/null 2>&1 &', opener, resolve(path)], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
83
+ } catch {}
84
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  // Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { cardData, sentenceText } from "./card.ts";
7
7
  import { cardHtml } from "./cardhtml.ts";
8
8
  import { localDate } from "./derive.ts";
9
- import { loadAssets, renderCard } from "./image.ts";
9
+ import { loadAssets, openCard, renderCard } from "./image.ts";
10
10
  import { renderDay, renderJson, renderWeek } from "./render.ts";
11
11
  import { report } from "./report.ts";
12
12
  import { renderStatus, statusOf } from "./status.ts";
@@ -35,7 +35,8 @@ window:
35
35
 
36
36
  options:
37
37
  --explain with a day: the six weighted parts behind each index
38
- --out <path> with card: .png, .webp or .html (default zapara-card.png)
38
+ --out <path> with card: .png, .webp or .html
39
+ (default ~/Downloads/zapara-card.png)
39
40
  --json the same data as JSON; a pipe gets JSON without asking
40
41
  --projects <dir> read this directory instead of ~/.claude/projects
41
42
  --no-color no ANSI colors; NO_COLOR does the same
@@ -44,7 +45,7 @@ options:
44
45
  levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
45
46
  const HINT = "run 'zapara --help' for usage";
46
47
 
47
- type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
48
+ type Args = { command: "grid" | "day" | "card" | "status"; to: string; days: number; explain: boolean; json: boolean; out: string | null; projects: string; color: boolean };
48
49
 
49
50
  class UsageError extends Error {}
50
51
  // Thrown only at a flag position, never for a token consumed as another flag's
@@ -95,12 +96,11 @@ function windowOf(days: string | null, from: string | null, to: string | null, d
95
96
  }
96
97
 
97
98
  function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boolean): Args {
98
- const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: "zapara-card.png", projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
99
+ const a: Args = { command: "grid", to: localDate(now), days: 7, explain: false, json: false, out: null, projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
99
100
  let days: string | null = null;
100
101
  let from: string | null = null;
101
102
  let to: string | null = null;
102
103
  let jsonFlag = false;
103
- let outGiven = false;
104
104
  const positional: string[] = [];
105
105
  // A value flag given twice is a usage error; bare flags are idempotent and untracked.
106
106
  const seen = new Set<string>();
@@ -136,7 +136,7 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
136
136
  case "--from": from = value(); break;
137
137
  case "--to": to = value(); break;
138
138
  case "--days": days = value(true); break;
139
- case "--out": a.out = value(); outGiven = true; break;
139
+ case "--out": a.out = value(); break;
140
140
  default:
141
141
  if (arg.startsWith("-")) throw new UsageError(`unknown flag${named(arg)}`);
142
142
  positional.push(arg);
@@ -158,10 +158,12 @@ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boo
158
158
  // The card is a file either way, so only an explicit --json switches it.
159
159
  a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
160
160
  if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to a named day only");
161
- if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
162
- // Printed back verbatim in `wrote \u2026`, so it must be one plain line.
163
- if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
164
- if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
161
+ if (a.command !== "card" && a.out !== null) throw new UsageError("--out applies to card only");
162
+ if (a.out !== null) {
163
+ // Printed back verbatim in `wrote \u2026`, so it must be one plain line.
164
+ if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
165
+ if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
166
+ }
165
167
  return a;
166
168
  }
167
169
 
@@ -188,6 +190,16 @@ async function status(a: Args, now: Date): Promise<number> {
188
190
  return 0;
189
191
  }
190
192
 
193
+ // The label names the folder, not the path: the CLI never prints a derived path.
194
+ function cardTarget(out: string | null): { path: string; label: string } {
195
+ if (out !== null) return { path: out, label: out };
196
+ const dir = join(homedir(), "Downloads");
197
+ let isDir = false;
198
+ try { isDir = statSync(dir).isDirectory(); } catch {}
199
+ if (!isDir) throw new Error("no Downloads folder: pass --out <path>");
200
+ return { path: join(dir, "zapara-card.png"), label: "zapara-card.png to Downloads" };
201
+ }
202
+
191
203
  async function card(a: Args): Promise<number> {
192
204
  const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days });
193
205
  const data = cardData(days, { days: a.days });
@@ -203,8 +215,15 @@ async function card(a: Args): Promise<number> {
203
215
  console.log(JSON.stringify(json, null, 2));
204
216
  return 0;
205
217
  }
206
- await renderCard(cardHtml(data, await loadAssets()), a.out);
207
- console.log(`${data.name}: ${sentenceText(data.sentence)}\nwrote ${a.out}`);
218
+ const target = cardTarget(a.out);
219
+ await renderCard(cardHtml(data, await loadAssets()), target.path);
220
+ console.log(`${data.name}: ${sentenceText(data.sentence)}\nwrote ${target.label}`);
221
+ if (process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32") {
222
+ process.stdout.write("open it? [Y/n] ");
223
+ let answer: string | null = null;
224
+ for await (const line of console) { answer = line; break; }
225
+ if (answer !== null && /^(y|yes)?$/i.test(answer.trim())) openCard(target.path);
226
+ }
208
227
  return 0;
209
228
  }
210
229
 
package/src/render.ts CHANGED
@@ -28,7 +28,10 @@ export function renderWeek(days: Day[], color: boolean): string {
28
28
  const cells = d.buckets.map((b) => ` ${b.score ? paint(GLYPH[b.score.level], b.score.level, color) : "·"} `).join("");
29
29
  // Padding stays outside the paint so the escape codes add no width.
30
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)}`;
31
+ const row = `${label(d.date).padEnd(12)}${cells}${peak}${hm(d.activeMin).padStart(8)}`;
32
+ // A day no hour scored in is 24 dots and a dash: no escape of its own, so
33
+ // one dim wrapper is enough.
34
+ return d.peak === null ? dim(row, color) : row;
32
35
  });
33
36
  // A painted glyph's own \x1b[0m cancels the line's dim; re-emit it after.
34
37
  const dimGlyph = (level: Level) => paint(GLYPH[level], level, color) + (color ? "\x1b[2m" : "");