@ze-norm/cli 0.11.2 → 0.11.5

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.
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Agent-output renderers for `zenorm work`.
3
+ *
4
+ * Background: `zenorm work` runs each claimed task by spawning a headless coding
5
+ * agent (`claude` / `codex`). Those agents' *interactive* TUIs show a clean,
6
+ * readable transcript (assistant prose + one-line tool calls). Their headless
7
+ * modes do NOT: `claude -p` text mode hides every tool call, and the JSON event
8
+ * streams (`--output-format stream-json`, `codex exec --json`) are raw JSONL —
9
+ * unreadable noise.
10
+ *
11
+ * These renderers consume the JSON event streams and re-emit a compact transcript
12
+ * that mirrors what you'd see in each agent's interactive session — a read-only
13
+ * view of the agent working — while dropping protocol/metadata noise (hooks,
14
+ * init banners, token counts, rate-limit pings, raw tool-result bodies).
15
+ *
16
+ * Pure functions over single events so they can be unit-tested without spawning
17
+ * anything. The caller (`AgentRunner`) feeds newline-delimited stdout chunks to a
18
+ * `LineSplitter` + `renderClaudeEvent` / `renderCodexEvent`.
19
+ */
20
+ const noColor = "NO_COLOR" in process.env;
21
+ const RESET = noColor ? "" : "\x1b[0m";
22
+ const DIM = noColor ? "" : "\x1b[2m";
23
+ const CYAN = noColor ? "" : "\x1b[36m";
24
+ const BRIGHT_CYAN = noColor ? "" : "\x1b[96m";
25
+ const BLUE = noColor ? "" : "\x1b[34m";
26
+ const GREEN = noColor ? "" : "\x1b[32m";
27
+ const YELLOW = noColor ? "" : "\x1b[33m";
28
+ const RED = noColor ? "" : "\x1b[31m";
29
+ const MAGENTA = noColor ? "" : "\x1b[35m";
30
+ const BOLD = noColor ? "" : "\x1b[1m";
31
+ // A muted grey for the metadata box border (Qwen-style framing). 256-color so
32
+ // it degrades on basic terminals; collapses to "" under NO_COLOR.
33
+ const BORDER = noColor ? "" : "\x1b[38;5;240m";
34
+ // Teal line color for the logo's extruded "3D" outline (the silhouette traced
35
+ // one cell down-right behind the solid bright-cyan glyph faces, Qwen-style).
36
+ const OUTLINE = noColor ? "" : "\x1b[38;5;30m";
37
+ const TOOL_STYLES = {
38
+ // ◆ write/mutation — green, the calls that change the repo.
39
+ edit: { glyph: "◆", color: GREEN },
40
+ // ▸ shell — yellow, commands with side effects worth noticing.
41
+ run: { glyph: "▸", color: YELLOW },
42
+ // ○ read — blue, low-stakes inspection.
43
+ read: { glyph: "○", color: BLUE },
44
+ // ◇ search — cyan, glob/grep discovery.
45
+ search: { glyph: "◇", color: CYAN },
46
+ // ◈ planning — magenta, TodoWrite/task bookkeeping.
47
+ plan: { glyph: "◈", color: MAGENTA },
48
+ // ● everything else — plain cyan bullet (the old default).
49
+ other: { glyph: "●", color: CYAN },
50
+ };
51
+ /** Classify a tool by name into a display bucket (see `ToolKind`). */
52
+ function toolKind(name) {
53
+ switch (name) {
54
+ case "Read":
55
+ case "NotebookRead":
56
+ return "read";
57
+ case "Edit":
58
+ case "Write":
59
+ case "NotebookEdit":
60
+ case "MultiEdit":
61
+ return "edit";
62
+ case "Bash":
63
+ case "BashOutput":
64
+ return "run";
65
+ case "Glob":
66
+ case "Grep":
67
+ return "search";
68
+ case "TodoWrite":
69
+ return "plan";
70
+ default:
71
+ return "other";
72
+ }
73
+ }
74
+ /** Format a classified tool summary as a colored, glyph-prefixed bullet line. */
75
+ function toolLine(name, summary) {
76
+ const { glyph, color } = TOOL_STYLES[toolKind(name)];
77
+ return `${color}${glyph}${RESET} ${summary}`;
78
+ }
79
+ /**
80
+ * Accumulates raw stdout bytes and yields complete lines. The agent JSON streams
81
+ * are newline-delimited JSON; a chunk may split a line, so we buffer the tail.
82
+ */
83
+ export class LineSplitter {
84
+ buffer = "";
85
+ /** Push a chunk; returns any newly-completed lines (without the newline). */
86
+ push(chunk) {
87
+ this.buffer += chunk;
88
+ const parts = this.buffer.split("\n");
89
+ // The last element is an incomplete line (or "") — keep it buffered.
90
+ this.buffer = parts.pop() ?? "";
91
+ return parts;
92
+ }
93
+ /** Flush any buffered partial line at end-of-stream. */
94
+ flush() {
95
+ if (this.buffer.length === 0)
96
+ return [];
97
+ const last = this.buffer;
98
+ this.buffer = "";
99
+ return [last];
100
+ }
101
+ }
102
+ /** Truncate a single-line summary so tool calls stay one line each. */
103
+ function clip(value, max = 80) {
104
+ const oneLine = value.replace(/\s+/g, " ").trim();
105
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
106
+ }
107
+ /**
108
+ * Visible (printed) width of a string: its length minus any ANSI SGR escapes,
109
+ * which occupy zero columns. Used to lay out the splash panel + metadata box,
110
+ * where padding has to align on what's *shown*, not the raw string length. The
111
+ * logo/box use only width-1 glyphs (`█`, box-drawing), so a codepoint count is
112
+ * exact here — this is not a general East-Asian-width measure.
113
+ */
114
+ // eslint-disable-next-line no-control-regex
115
+ const ANSI_SGR = /\x1b\[[0-9;]*m/g;
116
+ function visibleWidth(text) {
117
+ return [...text.replace(ANSI_SGR, "")].length;
118
+ }
119
+ /** Indent a multi-line assistant message block under a bullet. */
120
+ function indentBlock(text) {
121
+ return text
122
+ .trimEnd()
123
+ .split("\n")
124
+ .map((line) => ` ${line}`);
125
+ }
126
+ /**
127
+ * Build a one-line summary of a tool call from its name + input, matching the
128
+ * gist of what the interactive TUIs print (e.g. `Bash(ls -la)`,
129
+ * `Edit(src/app.ts)`). Unknown tools fall back to the tool name + clipped JSON.
130
+ */
131
+ function summarizeTool(name, input) {
132
+ const obj = (input && typeof input === "object" ? input : {});
133
+ const str = (k) => typeof obj[k] === "string" ? obj[k] : undefined;
134
+ switch (name) {
135
+ case "Bash": {
136
+ const cmd = str("command");
137
+ return cmd ? `Bash(${clip(cmd)})` : "Bash";
138
+ }
139
+ case "Read":
140
+ case "NotebookEdit": {
141
+ const path = str("file_path") ?? str("path") ?? str("notebook_path");
142
+ return path ? `${name}(${clip(path)})` : name;
143
+ }
144
+ case "Edit":
145
+ case "Write": {
146
+ const path = str("file_path") ?? str("path");
147
+ if (!path)
148
+ return name;
149
+ // Surface a coarse size hint for writes/edits so the transcript conveys
150
+ // "small tweak" vs "wrote a big file" without dumping the diff. Edit uses
151
+ // new_string length; Write uses content length.
152
+ const body = str("new_string") ?? str("content");
153
+ const lines = body ? body.split("\n").length : undefined;
154
+ const suffix = lines !== undefined ? ` ${DIM}+${lines}L${RESET}` : "";
155
+ return `${name}(${clip(path)})${suffix}`;
156
+ }
157
+ case "Glob":
158
+ case "Grep": {
159
+ const pattern = str("pattern") ?? str("query");
160
+ return pattern ? `${name}(${clip(pattern)})` : name;
161
+ }
162
+ case "TodoWrite":
163
+ return "TodoWrite";
164
+ default: {
165
+ // Generic: show the tool name and a clipped scalar arg if there's an
166
+ // obvious one, else just the name.
167
+ const firstScalar = Object.values(obj).find((v) => typeof v === "string" || typeof v === "number");
168
+ return firstScalar !== undefined ? `${name}(${clip(String(firstScalar))})` : name;
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Render ONE parsed Claude `--output-format stream-json` event into transcript
174
+ * lines (possibly empty). We render off the *complete* `assistant` events (not
175
+ * the partial `stream_event` deltas) so each block is whole; everything else —
176
+ * `system`/`stream_event`/`rate_limit_event`/`user` tool-result bodies — is
177
+ * dropped as noise. The final `result` event is summarized as a dim footer.
178
+ */
179
+ export function renderClaudeEvent(event) {
180
+ if (!event || typeof event !== "object")
181
+ return [];
182
+ const e = event;
183
+ const type = e["type"];
184
+ if (type === "assistant") {
185
+ const message = e["message"];
186
+ const content = Array.isArray(message?.["content"]) ? message["content"] : [];
187
+ const out = [];
188
+ for (const block of content) {
189
+ if (block["type"] === "text" && typeof block["text"] === "string") {
190
+ const text = block["text"].trim();
191
+ if (text)
192
+ out.push(...indentBlock(text));
193
+ }
194
+ else if (block["type"] === "tool_use" && typeof block["name"] === "string") {
195
+ const name = block["name"];
196
+ out.push(toolLine(name, summarizeTool(name, block["input"])));
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ if (type === "result") {
202
+ const isError = e["is_error"] === true;
203
+ const turns = typeof e["num_turns"] === "number" ? e["num_turns"] : undefined;
204
+ const ms = typeof e["duration_ms"] === "number" ? e["duration_ms"] : undefined;
205
+ const meta = [
206
+ turns !== undefined ? `${turns} turn${turns === 1 ? "" : "s"}` : undefined,
207
+ ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : undefined,
208
+ ].filter((b) => b !== undefined);
209
+ return [transcriptFooter(isError, meta)];
210
+ }
211
+ // system / stream_event / rate_limit_event / user tool-results → noise.
212
+ return [];
213
+ }
214
+ /**
215
+ * Render ONE parsed Codex `exec --json` event (JSONL) into transcript lines.
216
+ * Codex emits `thread.started` / `turn.started` / `item.*` / `turn.completed` /
217
+ * `error`. We surface assistant messages, command/file-change items, and a dim
218
+ * completion/error footer; thread/turn lifecycle + token metadata are dropped.
219
+ */
220
+ export function renderCodexEvent(event) {
221
+ if (!event || typeof event !== "object")
222
+ return [];
223
+ const e = event;
224
+ const type = e["type"];
225
+ if (typeof type !== "string")
226
+ return [];
227
+ if (type === "error") {
228
+ const msg = typeof e["message"] === "string" ? e["message"] : "unknown error";
229
+ return [transcriptFooter(true, [clip(msg, 120)])];
230
+ }
231
+ if (type === "turn.failed") {
232
+ const err = e["error"];
233
+ const msg = typeof err?.["message"] === "string" ? err["message"] : "turn failed";
234
+ return [transcriptFooter(true, [clip(msg, 120)])];
235
+ }
236
+ if (type === "turn.completed") {
237
+ return [transcriptFooter(false, [])];
238
+ }
239
+ // Codex item events carry the actual work. Shape:
240
+ // {"type":"item.completed","item":{"type":"agent_message"|"command_execution"
241
+ // |"file_change"|"reasoning",...}}
242
+ if (type === "item.completed" || type === "item.started") {
243
+ const item = e["item"];
244
+ if (!item)
245
+ return [];
246
+ const itemType = item["type"];
247
+ if (itemType === "agent_message") {
248
+ const text = typeof item["text"] === "string" ? item["text"].trim() : "";
249
+ // Only emit the full message once (on completion) to avoid duplicate prose.
250
+ return type === "item.completed" && text ? indentBlock(text) : [];
251
+ }
252
+ if (itemType === "command_execution") {
253
+ // Show the command when it starts; skip the completion echo.
254
+ if (type !== "item.started")
255
+ return [];
256
+ const cmd = typeof item["command"] === "string" ? item["command"] : undefined;
257
+ return cmd ? [toolLine("Bash", `Bash(${clip(cmd)})`)] : [];
258
+ }
259
+ if (itemType === "file_change") {
260
+ if (type !== "item.completed")
261
+ return [];
262
+ // Codex file_change items carry a `changes: [{path, kind}]` array; older
263
+ // shapes used a flat `path`/`file`. Summarize either form as an Edit line.
264
+ const changes = Array.isArray(item["changes"]) ? item["changes"] : [];
265
+ const paths = changes
266
+ .map((c) => (typeof c["path"] === "string" ? c["path"] : undefined))
267
+ .filter((p) => p !== undefined);
268
+ if (paths.length === 1)
269
+ return [toolLine("Edit", `Edit(${clip(paths[0])})`)];
270
+ if (paths.length > 1)
271
+ return [toolLine("Edit", `Edit(${paths.length} files)`)];
272
+ const flat = typeof item["path"] === "string"
273
+ ? item["path"]
274
+ : typeof item["file"] === "string"
275
+ ? item["file"]
276
+ : undefined;
277
+ return flat ? [toolLine("Edit", `Edit(${clip(flat)})`)] : [];
278
+ }
279
+ // reasoning / other item kinds → noise.
280
+ return [];
281
+ }
282
+ // thread.started / turn.started / token metadata → noise.
283
+ return [];
284
+ }
285
+ /**
286
+ * Wrap a typed event renderer with JSON parsing. Non-JSON lines (rare stray
287
+ * stderr-on-stdout) are passed through verbatim so nothing is silently eaten,
288
+ * but empty/whitespace lines are dropped.
289
+ */
290
+ function makeRenderer(render) {
291
+ return (jsonLine) => {
292
+ const trimmed = jsonLine.trim();
293
+ if (!trimmed)
294
+ return [];
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(trimmed);
298
+ }
299
+ catch {
300
+ // Not JSON — likely a plain status line the agent wrote to stdout. Keep it
301
+ // rather than drop it, so unexpected output is still visible.
302
+ return [trimmed];
303
+ }
304
+ return render(parsed);
305
+ };
306
+ }
307
+ export const claudeRenderer = makeRenderer(renderClaudeEvent);
308
+ export const codexRenderer = makeRenderer(renderCodexEvent);
309
+ /**
310
+ * The dim footer that closes each agent's transcript. Done turns green with a
311
+ * check; an errored/aborted turn turns red with a cross. `meta` is the trailing
312
+ * detail (turns/duration, or an error message). Kept as a shared helper so the
313
+ * Claude `result` and Codex `turn.completed`/`error` footers render alike.
314
+ *
315
+ * NOTE: the literal words "done"/"error" are asserted by the renderer tests, so
316
+ * keep them as the leading token.
317
+ */
318
+ export function transcriptFooter(isError, meta) {
319
+ const label = isError ? `${RED}✗ error${RESET}` : `${GREEN}✓ done${RESET}`;
320
+ const tail = meta.length > 0 ? `${DIM} · ${meta.join(" · ")}${RESET}` : "";
321
+ return `${label}${tail}`;
322
+ }
323
+ /** Width of the horizontal rules used to frame the daemon session + tasks. */
324
+ const RULE_WIDTH = 60;
325
+ const RULE = "─".repeat(RULE_WIDTH);
326
+ /**
327
+ * Heavy task header printed once when a task starts. The leading rule + blank
328
+ * line give multi-task runs clear visual separation (tasks stop blurring into
329
+ * one another); `index` is the 1-based run counter within this session.
330
+ */
331
+ export function taskHeader(shortTaskId, title, agentName, index) {
332
+ const counter = index !== undefined ? `${BOLD}${CYAN}#${index}${RESET} ` : "";
333
+ return (`\n${DIM}${RULE}${RESET}\n` +
334
+ `${counter}${BOLD}▌ ${title}${RESET} ${DIM}(${shortTaskId} · ${agentName})${RESET}`);
335
+ }
336
+ /**
337
+ * The ZeNorm splash logo: the brand mark (the interlocking Z / hourglass from
338
+ * apps/web/public/zenorm-icon.svg) on the LEFT, the `ZeNorm` wordmark on the
339
+ * RIGHT. Rendered once at daemon launch.
340
+ *
341
+ * Built from a 7-row, solid-fill (`█`) pixel font — no box-drawing "shadow"
342
+ * frame (that earlier version rendered as illegible noise). The wordmark is
343
+ * mixed-case: `Z` and `N` are full 7-row caps; `e o r m` are lowercase glyphs
344
+ * that occupy the lower five rows (x-height — top two rows blank), so it reads
345
+ * as `ZeNorm`. Glyph widths vary (caps wider than the round lowercase); rows are
346
+ * concatenated with a 1-col gap.
347
+ */
348
+ const LOGO_ROWS = 7;
349
+ /**
350
+ * 7-row glyphs for the wordmark. Each glyph's rows are equal width (the renderer
351
+ * pads with a trailing space); caps fill all 7 rows, lowercase the lower 5.
352
+ */
353
+ const LOGO_GLYPHS = {
354
+ Z: ["██████", " ██", " ██ ", " ██ ", " ██ ", "██ ", "██████"],
355
+ e: [" ", " ", " ████ ", "██ ██", "██████", "██ ", " ████ "],
356
+ N: ["██ ██", "███ ██", "████ ██", "██ ████", "██ ███", "██ ██", "██ ██"],
357
+ o: [" ", " ", " ████ ", "██ ██", "██ ██", "██ ██", " ████ "],
358
+ r: [" ", " ", "██ ███", "███ ", "██ ", "██ ", "██ "],
359
+ m: [" ", " ", "███ ███ ", "██ ██ ██", "██ ██ ██", "██ ██ ██", "██ ██ ██"],
360
+ };
361
+ /**
362
+ * The brand mark — the actual `zenorm-icon.svg`, rasterized. Hand-drawn block
363
+ * ASCII of this hourglass read as mush at terminal sizes, so instead the SVG's
364
+ * alpha mask was rendered to a 25×16-pixel bitmap and packed into half-block
365
+ * cells (`▀`/`▄`/`█`): each character is two stacked pixels, so 9 text rows carry
366
+ * 18 px of vertical detail — the floor at which all four features (the two outer
367
+ * interlocking triangles AND the two nested solid wedges) survive; at 8 rows the
368
+ * inner wedges merge into the diagonals or drop out. Sized to roughly the
369
+ * wordmark's height (the 9-row icon is just one row taller than the 8-row
370
+ * wordmark, which is vertically centered beside it) so the mark and the `ZeNorm`
371
+ * text read at the same scale. The horizontal axis is pre-stretched ~1.2× (to
372
+ * undo the terminal's ~2:1 cell aspect without fattening the mark) so it keeps the
373
+ * SVG's taller-than-wide proportions. Regenerate from the SVG with
374
+ * `scripts/render-icon.py` if the brand mark changes. Every glyph here is width-1,
375
+ * so `visibleWidth`'s codepoint count stays exact for the splash layout.
376
+ */
377
+ const LOGO_ICON = [
378
+ "▀█████████████████",
379
+ " ▀█▄ ▄▄▄▄ ▄██▀",
380
+ " ██▄ ▀▀ ▄██▀",
381
+ " ▀█▀ ▄██▀",
382
+ " ▄██▀",
383
+ " ▄██▀ ▄█▄",
384
+ " ██▀ ▄▄ ▀██",
385
+ " ▄█▀ ▀▀▀▀ ▀█▄",
386
+ "▄████████████████▄",
387
+ ];
388
+ /** Render the wordmark `ZeNorm` as 7 parallel rows of glyph art. */
389
+ function wordmarkRows() {
390
+ const rows = Array.from({ length: LOGO_ROWS }, () => "");
391
+ for (const ch of "ZeNorm") {
392
+ const glyph = LOGO_GLYPHS[ch];
393
+ if (!glyph)
394
+ throw new Error(`No splash glyph defined for "${ch}".`);
395
+ for (let i = 0; i < LOGO_ROWS; i += 1)
396
+ rows[i] += `${glyph[i]} `;
397
+ }
398
+ return rows;
399
+ }
400
+ /** Extrusion offset (cells) of the 3D outline behind the glyph faces. */
401
+ const SHADOW_DX = 1;
402
+ const SHADOW_DY = 1;
403
+ /**
404
+ * Box-drawing line for an outline cell, chosen from which of its 4 orthogonal
405
+ * neighbours are *also* outline cells, so the extruded silhouette connects into
406
+ * continuous strokes (── runs, │ verticals, ╭╮╰╯ corners) instead of dots. Keyed
407
+ * by a 4-bit mask: up=8, down=4, left=2, right=1.
408
+ */
409
+ const OUTLINE_LINES = {
410
+ 0: "·", 1: "─", 2: "─", 3: "─", 4: "│", 5: "╭", 6: "╮", 7: "┬",
411
+ 8: "│", 9: "╰", 10: "╯", 11: "┴", 12: "│", 13: "├", 14: "┤", 15: "┼",
412
+ };
413
+ /**
414
+ * Composite the logo art into colored rows with a Qwen-style extruded 3D edge:
415
+ * solid bright-cyan glyph faces, plus a teal line outline tracing the glyph
416
+ * silhouette one cell down-right. `art` rows are plain `█`/space strings.
417
+ *
418
+ * The face is every `█` cell. The outline is every cell that is empty in the
419
+ * face layer but filled in the face layer shifted up-left by the extrusion
420
+ * offset — i.e. the bottom-right contour that an extrusion would expose. Each
421
+ * outline cell picks a connecting box-drawing glyph from its outline neighbours.
422
+ */
423
+ function extrudeRows(art) {
424
+ const width = Math.max(...art.map((r) => r.length));
425
+ const face = art.map((row) => [...row.padEnd(width)].map((c) => c === "█"));
426
+ const cell = (x, y) => y >= 0 && y < face.length && x >= 0 && x < width && face[y][x] === true;
427
+ // Outline cell: empty in the face, but the source cell (offset up-left) is a
428
+ // face — the silhouette the extrusion reveals to the down-right.
429
+ const isOutline = (x, y) => !cell(x, y) && cell(x - SHADOW_DX, y - SHADOW_DY);
430
+ const outWidth = width + SHADOW_DX;
431
+ const outHeight = art.length + SHADOW_DY;
432
+ const rows = [];
433
+ for (let y = 0; y < outHeight; y += 1) {
434
+ let line = "";
435
+ let style = "";
436
+ const setStyle = (next) => {
437
+ if (next !== style) {
438
+ line += RESET + next;
439
+ style = next;
440
+ }
441
+ };
442
+ for (let x = 0; x < outWidth; x += 1) {
443
+ if (cell(x, y)) {
444
+ setStyle(`${BOLD}${BRIGHT_CYAN}`);
445
+ line += "█";
446
+ }
447
+ else if (isOutline(x, y)) {
448
+ setStyle(OUTLINE);
449
+ const mask = (isOutline(x, y - 1) ? 8 : 0) |
450
+ (isOutline(x, y + 1) ? 4 : 0) |
451
+ (isOutline(x - 1, y) ? 2 : 0) |
452
+ (isOutline(x + 1, y) ? 1 : 0);
453
+ line += OUTLINE_LINES[mask];
454
+ }
455
+ else {
456
+ setStyle("");
457
+ line += " ";
458
+ }
459
+ }
460
+ rows.push(line + RESET);
461
+ }
462
+ return rows;
463
+ }
464
+ /** The icon's printed width (its widest row), for padding the splash gutter. */
465
+ const ICON_WIDTH = Math.max(...LOGO_ICON.map((r) => [...r].length));
466
+ /**
467
+ * Render the rasterized brand mark in brand cyan. Each row is right-padded to the
468
+ * icon's full width (so the gutter to the wordmark stays aligned), then the whole
469
+ * row is wrapped in one color run — the half-block glyphs are the ink, the spaces
470
+ * are transparent background, so a single SGR span per line both colors the mark
471
+ * and keeps the escape count tiny. Under NO_COLOR the span collapses to "" and
472
+ * the half-blocks still draw.
473
+ */
474
+ function iconRows() {
475
+ return LOGO_ICON.map((row) => {
476
+ const padded = [...row].join("").padEnd(ICON_WIDTH);
477
+ return `${BOLD}${BRIGHT_CYAN}${padded}${RESET}`;
478
+ });
479
+ }
480
+ /**
481
+ * The full multi-line splash logo (no surrounding blank lines): the rasterized
482
+ * brand mark on the left (brand-cyan half-blocks), the `ZeNorm` wordmark on the
483
+ * right (3D-extruded, 5-space gutter). The icon stands one row taller than the
484
+ * wordmark (9 vs 8), so the shorter wordmark is vertically centered against it
485
+ * with blank padding. Under NO_COLOR the color escapes collapse to "" (the
486
+ * half-block mark and the wordmark's box outline still draw). Exported so it can
487
+ * be rendered/asserted on its own.
488
+ */
489
+ export function splashLogo() {
490
+ const icon = iconRows();
491
+ const word = extrudeRows(wordmarkRows());
492
+ const blankIconRow = " ".repeat(ICON_WIDTH);
493
+ const height = Math.max(icon.length, word.length);
494
+ // Center the shorter block vertically against the taller one.
495
+ const wordPad = Math.floor((height - word.length) / 2);
496
+ const rows = [];
497
+ for (let i = 0; i < height; i += 1) {
498
+ const left = icon[i] ?? blankIconRow;
499
+ const wordIdx = i - wordPad;
500
+ const right = wordIdx >= 0 && wordIdx < word.length ? word[wordIdx] : "";
501
+ rows.push(` ${left} ${right}`);
502
+ }
503
+ return rows.join("\n");
504
+ }
505
+ /**
506
+ * Frame a block of already-styled lines in a rounded box (Qwen-style metadata
507
+ * panel). `lines` may contain ANSI escapes; padding aligns on visible width. One
508
+ * cell of inner padding on each side. Under NO_COLOR the border greys collapse
509
+ * but the box-drawing characters still render, so the frame survives.
510
+ */
511
+ function metaBox(lines) {
512
+ const inner = Math.max(...lines.map(visibleWidth));
513
+ const horizontal = "─".repeat(inner + 2);
514
+ const out = [`${BORDER}╭${horizontal}╮${RESET}`];
515
+ for (const line of lines) {
516
+ const fill = " ".repeat(inner - visibleWidth(line));
517
+ out.push(`${BORDER}│${RESET} ${line}${fill} ${BORDER}│${RESET}`);
518
+ }
519
+ out.push(`${BORDER}╰${horizontal}╯${RESET}`);
520
+ return out;
521
+ }
522
+ /**
523
+ * The startup banner for the daemon. Shown once at launch (not just under
524
+ * `--dry-run`) so a real run announces what it's doing instead of sitting
525
+ * silently until the first task lands. Leads with the ZeNorm splash logo, then
526
+ * the run parameters.
527
+ */
528
+ export function daemonBanner(opts) {
529
+ const mode = [opts.once ? "once" : undefined, opts.dryRun ? "dry-run" : undefined]
530
+ .filter((m) => m !== undefined)
531
+ .join(", ");
532
+ const modeSuffix = mode ? ` ${DIM}(${mode})${RESET}` : "";
533
+ // The metadata sits in a rounded box beside the same `>_ ZeNorm` header
534
+ // treatment Qwen uses, with the run parameters under it.
535
+ const box = metaBox([
536
+ `${BOLD}${BRIGHT_CYAN}>_${RESET} ${BOLD}ZeNorm${RESET}${modeSuffix}`,
537
+ `${DIM}spec-authoring agent · claim-and-run daemon${RESET}`,
538
+ "",
539
+ `${DIM}agent ${RESET}${opts.agent}`,
540
+ `${DIM}repo ${RESET}${opts.repo}`,
541
+ `${DIM}interval ${RESET}${opts.intervalSeconds}s`,
542
+ ]);
543
+ return [
544
+ "",
545
+ splashLogo(),
546
+ "",
547
+ ...box,
548
+ `${DIM} Polling for ready tasks — Ctrl-C to stop.${RESET}`,
549
+ ].join("\n");
550
+ }
551
+ /** A spinner-free, dim status line for the daemon's idle/lifecycle states. */
552
+ export function statusLine(message) {
553
+ return `${DIM}· ${message}${RESET}`;
554
+ }
555
+ /**
556
+ * The per-task outcome line printed after a task's transcript closes: a bold
557
+ * green check for success or a bold red cross for failure, with the title, id,
558
+ * and wall-clock duration. This is the daemon's own verdict (distinct from the
559
+ * agent's transcript footer) so the run log reads as a sequence of outcomes.
560
+ */
561
+ export function taskOutcome(opts) {
562
+ const seconds = (opts.durationMs / 1000).toFixed(1);
563
+ const mark = opts.ok ? `${GREEN}${BOLD}✓${RESET}` : `${RED}${BOLD}✗${RESET}`;
564
+ const verb = opts.ok ? `${GREEN}completed${RESET}` : `${RED}failed${RESET}`;
565
+ return `${mark} ${verb} ${opts.title} ${DIM}(${opts.shortTaskId} · ${seconds}s)${RESET}`;
566
+ }
567
+ /**
568
+ * A loud, framed failure banner for a task that threw. Spells out which task
569
+ * failed, the error, and that it was released back to `todo` so the failure is
570
+ * impossible to miss in a scrolling daemon log (vs. a single dim line).
571
+ */
572
+ export function failureBanner(opts) {
573
+ return [
574
+ `${RED}${BOLD}✗ task failed${RESET} ${opts.title} ${DIM}(${opts.shortTaskId})${RESET}`,
575
+ `${RED} ${opts.error}${RESET}`,
576
+ `${DIM} released back to todo — it can be re-claimed.${RESET}`,
577
+ ].join("\n");
578
+ }
579
+ /**
580
+ * The session summary printed when the daemon shuts down (Ctrl-C or `--once`
581
+ * exhausted). Closes the run with a rule + a one-line tally so the user sees the
582
+ * total instead of having to count outcome lines by eye.
583
+ */
584
+ export function sessionSummary(opts) {
585
+ const parts = [`${GREEN}${opts.completed} completed${RESET}`];
586
+ if (opts.failed > 0)
587
+ parts.push(`${RED}${opts.failed} failed${RESET}`);
588
+ const tally = opts.completed === 0 && opts.failed === 0 ? `${DIM}no tasks run${RESET}` : parts.join(`${DIM} · ${RESET}`);
589
+ return `\n${DIM}${RULE}${RESET}\n${BOLD}◣ session ended${RESET} ${DIM}·${RESET} ${tally}`;
590
+ }