@mikitasazan/notify 1.15.2 → 1.16.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/README.md +15 -3
- package/dist/cli-flags.js +2 -2
- package/dist/cli.js +25 -19
- package/dist/events.d.ts +67 -16
- package/dist/events.js +2 -1
- package/dist/lint.js +4 -1
- package/dist/render.d.ts +11 -0
- package/dist/render.js +189 -77
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @mikitasazan/notify
|
|
2
2
|
|
|
3
3
|
Единая типизированная отправка Telegram-уведомлений: **форум на проект**,
|
|
4
|
-
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот,
|
|
4
|
+
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот, семь типов
|
|
5
5
|
событий. Заменяет 18 разных имён переменных и 4+ независимых реализации
|
|
6
6
|
`jq | curl`, что были в playhub, arvent, game-publisher до 26.07.2026.
|
|
7
7
|
|
|
@@ -84,10 +84,22 @@ notify report --project playhub --json < payload.json # весь объект
|
|
|
84
84
|
| `ci` | итог CI на master | `project`, `status` |
|
|
85
85
|
| `pr` | событие пул-реквеста | `project`, `action`, `number`, `title` |
|
|
86
86
|
| `issue` | событие задачи | `project`, `action`, `number`, `title` |
|
|
87
|
-
| `incident` |
|
|
88
|
-
| `session` |
|
|
87
|
+
| `incident` | что-то встало и ждёт тебя прямо сейчас: приложение, сейф, рабочая сессия на маке | `project`, `title` |
|
|
88
|
+
| `session` | УСТАРЕЛ, это `incident`: карточка выходит с тегом `#incident`, имя собирается из `action` — `Claude session is burning the limit` | `project`, `action` |
|
|
89
89
|
| `heartbeat_miss` | УСТАРЕЛ, молчание — это `job --status silent` | `project`, `job` |
|
|
90
90
|
|
|
91
|
+
Скобка у слова типа говорит одним словом, чем кончилось: `Deploy (OK)`,
|
|
92
|
+
`CI (Fail)`, `Job (Off)`, `Job (Silent)`, `Issue (Assigned)`, `PR (Merged)`.
|
|
93
|
+
Она стоит там, где у типа исходов больше одного. У `incident` состояние одно,
|
|
94
|
+
поэтому его скобка называет МЕСТО, где горит: `Incident (Vault)`,
|
|
95
|
+
`Incident (Session)` — слово из `--scope`, без него имя проекта. У `report`
|
|
96
|
+
исхода нет вовсе, и скобку занимает день: `Report (2026-08-23 / 2026-08-22)`.
|
|
97
|
+
|
|
98
|
+
У `job` есть ещё два необязательных флага: `--via` — где задача крутилась
|
|
99
|
+
(`mac`, `vps`, `actions`), это идёт строкой `Via: Mac` первой под второй
|
|
100
|
+
строкой; и `--took` — сколько прогон занял, твоими же словами (`4m 12s`),
|
|
101
|
+
строкой `Took:` под причиной. Не передал — строки нет.
|
|
102
|
+
|
|
91
103
|
Отдельного вида «файл» нет: `--path` применим к ЛЮБОМУ событию, и тогда
|
|
92
104
|
карточка едет подписью к вложению (подпись у Telegram ограничена 1024 знаками,
|
|
93
105
|
а не 4000). Слово `file` осталось псевдонимом и собирает `report` с вложением —
|
package/dist/cli-flags.js
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const KNOWN_FLAGS = new Set([
|
|
12
12
|
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
13
|
-
'id', 'opened', 'reason', 'workdir',
|
|
13
|
+
'id', 'opened', 'reason', 'scope', 'workdir',
|
|
14
14
|
'check', 'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url',
|
|
15
15
|
'detail', 'detail-label',
|
|
16
16
|
'expected', 'filename', 'item',
|
|
17
17
|
'item-group', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
18
18
|
'aside', 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
|
19
|
-
'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
19
|
+
'target', 'title', 'took', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
20
20
|
// Flags with no value. They live here too, so parsing and the list do not drift apart.
|
|
21
21
|
'json', 'recovered', 'dry-run'
|
|
22
22
|
]);
|
package/dist/cli.js
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
* is no scenario where a deploy should fail because a message did not send.
|
|
12
12
|
*
|
|
13
13
|
* notify deploy --project playhub --status ok --commit "msg" [--commit-url "..."] --url "..."
|
|
14
|
-
* notify job --project playhub --job "Game import" --status ok --stat "added=5"
|
|
14
|
+
* notify job --project playhub --job "Game import" --status ok --stat "added=5" [--via vps] [--took "4m 12s"]
|
|
15
15
|
* notify report --project playhub --title "Daily summary" --line "Games=1284"
|
|
16
|
-
* notify ci --project arvent --status fail --branch master
|
|
16
|
+
* notify ci --project arvent --status fail --branch master
|
|
17
17
|
* notify pr --project arvent --action opened --number 142 --title "..."
|
|
18
18
|
* notify incident --project arvent --title "Redis is unreachable" --detail "$ERR"
|
|
19
|
+
* notify session --project mac-config --action "burning the limit" # DEPRECATED alias of incident
|
|
19
20
|
* notify file --project arvent --title "Full dialogues" --path ./out.txt [--filename name.txt]
|
|
20
21
|
* notify <type> [--key stable-key] # the task's key on the card's last line
|
|
21
22
|
* notify <type> --json < payload.json # the whole event object on stdin
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
*/
|
|
24
25
|
import { readFileSync } from 'node:fs';
|
|
25
26
|
import { KNOWN_FLAGS } from "./cli-flags.js";
|
|
26
|
-
import { render } from "./render.js";
|
|
27
|
+
import { render, sessionTitle } from "./render.js";
|
|
27
28
|
import { lintCard } from "./lint.js";
|
|
28
29
|
import { notify } from "./send.js";
|
|
29
30
|
import { ROUTES } from "./routes.js";
|
|
@@ -272,6 +273,11 @@ else {
|
|
|
272
273
|
job: one('job') ?? '(no name)',
|
|
273
274
|
status: jobStatus(),
|
|
274
275
|
aside: one('aside'),
|
|
276
|
+
// Where it ran and how long it took: `--via mac`, `--took '4m 12s'`.
|
|
277
|
+
// `--took` fills `duration` — the flag is the sender's word for the
|
|
278
|
+
// question ("how long did it take?"), the field is the card's.
|
|
279
|
+
via: one('via'),
|
|
280
|
+
duration: one('took'),
|
|
275
281
|
expected: one('expected'),
|
|
276
282
|
lastSeen: one('last-seen'),
|
|
277
283
|
stats: pairs('stat'),
|
|
@@ -344,30 +350,30 @@ else {
|
|
|
344
350
|
url: one('url')
|
|
345
351
|
};
|
|
346
352
|
break;
|
|
353
|
+
// DEPRECATED since 1.16.0. `notify session` is an alias of `notify
|
|
354
|
+
// incident`: the two cards said one thing — something is stuck and waits
|
|
355
|
+
// for you — under two names and the same 🚨. The subcommand still works
|
|
356
|
+
// because the runaway guard on this Mac calls it, and it builds an
|
|
357
|
+
// incident whose title names the session: `Claude session is burning the
|
|
358
|
+
// limit`. `--status` is read no more; an incident has one state.
|
|
347
359
|
case 'session':
|
|
348
|
-
event = {
|
|
349
|
-
type: 'session',
|
|
350
|
-
project: project(),
|
|
351
|
-
action: one('action') ?? 'in trouble',
|
|
352
|
-
id: one('id'),
|
|
353
|
-
workdir: one('workdir'),
|
|
354
|
-
reason: one('reason'),
|
|
355
|
-
opened: one('opened'),
|
|
356
|
-
command: one('command'),
|
|
357
|
-
commandNote: one('command-note'),
|
|
358
|
-
// Only two states here, so `disabled` must not leak in from jobStatus.
|
|
359
|
-
status: jobStatus() === 'ok' ? 'ok' : 'fail'
|
|
360
|
-
};
|
|
361
|
-
break;
|
|
362
360
|
case 'incident':
|
|
363
361
|
event = {
|
|
364
362
|
type: 'incident',
|
|
365
363
|
project: project(),
|
|
366
|
-
title:
|
|
364
|
+
title: command === 'session'
|
|
365
|
+
? sessionTitle(one('action') ?? 'in trouble')
|
|
366
|
+
: (one('title') ?? '(no title)'),
|
|
367
|
+
scope: command === 'session' ? 'Session' : one('scope'),
|
|
367
368
|
detail: one('detail'),
|
|
368
369
|
items: items(),
|
|
369
370
|
logs: one('logs'),
|
|
370
|
-
url: one('url')
|
|
371
|
+
url: one('url'),
|
|
372
|
+
workdir: one('workdir'),
|
|
373
|
+
reason: one('reason'),
|
|
374
|
+
opened: one('opened'),
|
|
375
|
+
command: one('command'),
|
|
376
|
+
commandNote: one('command-note')
|
|
371
377
|
};
|
|
372
378
|
break;
|
|
373
379
|
case 'heartbeat_miss':
|
package/dist/events.d.ts
CHANGED
|
@@ -141,13 +141,32 @@ export type NotifyEvent = Keyed & (
|
|
|
141
141
|
*/
|
|
142
142
|
status: 'ok' | 'fail' | 'disabled' | 'silent';
|
|
143
143
|
/**
|
|
144
|
-
* The one
|
|
145
|
-
*
|
|
146
|
-
* the
|
|
147
|
-
*
|
|
148
|
-
*
|
|
144
|
+
* The one sentence that says why this card exists at all («reporting
|
|
145
|
+
* again»). It has no bracket of its own any more — the bracket on a job
|
|
146
|
+
* carries the outcome — and prints in the `Reason:` row, but only when
|
|
147
|
+
* the sender left `note` empty; a real reason is never duplicated.
|
|
148
|
+
* The field stays for the senders that still pass it.
|
|
149
149
|
*/
|
|
150
150
|
aside?: string;
|
|
151
|
+
/**
|
|
152
|
+
* WHERE the job ran, one word from the sender: `mac` (launchd on this
|
|
153
|
+
* Mac), `vps` (a cron on the server), `actions` (GitHub Actions). Any
|
|
154
|
+
* other word is passed through and capitalised. It prints as a `Via:`
|
|
155
|
+
* row directly under the type line — it is a fact ABOUT the run, and
|
|
156
|
+
* the bracket on the type word belongs to the outcome. Until now the
|
|
157
|
+
* Mac's launchd, a
|
|
158
|
+
* VPS cron, GitHub Actions and the silence watchdog all arrived under
|
|
159
|
+
* one bare `#job` tag and only memory told them apart; deploy has said
|
|
160
|
+
* «via GitHub Actions» from the start, and job now does too
|
|
161
|
+
* (03.09.2026).
|
|
162
|
+
*/
|
|
163
|
+
via?: string;
|
|
164
|
+
/**
|
|
165
|
+
* How long the run took, in the sender's own words: `4m 12s`, `38s`.
|
|
166
|
+
* Printed as a `Took:` row right under the reason. Absent — no row:
|
|
167
|
+
* a job that does not measure itself must not be made to say `0s`.
|
|
168
|
+
*/
|
|
169
|
+
duration?: string;
|
|
151
170
|
/** How often the task owes a check-in — for `silent` and for recovering from it. */
|
|
152
171
|
expected?: string;
|
|
153
172
|
/** When it was last seen. */
|
|
@@ -327,22 +346,50 @@ export type NotifyEvent = Keyed & (
|
|
|
327
346
|
/** A local path to the logs (not a URL — renders monospaced, to copy, not to click). */
|
|
328
347
|
logs?: string;
|
|
329
348
|
url?: string;
|
|
349
|
+
/**
|
|
350
|
+
* The rest of this block arrived with the `session` type, which was
|
|
351
|
+
* folded into `incident` on 03.09.2026. Two types said one thing —
|
|
352
|
+
* something is stuck and waits for you — under the same 🚨, and the
|
|
353
|
+
* owner cut it to one. The fields are not session-only in meaning: a
|
|
354
|
+
* stuck deploy has a working directory too, and any alarm can carry a
|
|
355
|
+
* command that gets you out of it.
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* WHERE it burns, one word, printed in the bracket on the type word:
|
|
359
|
+
* `Incident (Vault):`, `Incident (Session):`. The owner (03.09.2026):
|
|
360
|
+
* the bracket should throw the place at the eye before anything is
|
|
361
|
+
* read. Not an outcome — an incident has one — so this is the one
|
|
362
|
+
* bracket that names a place. Absent → the project name, capitalised.
|
|
363
|
+
*/
|
|
364
|
+
scope?: string;
|
|
365
|
+
/** Which working copy this is about, when several of them look alike. */
|
|
366
|
+
workdir?: string;
|
|
367
|
+
/** One line of measurement: what the guard saw. */
|
|
368
|
+
reason?: string;
|
|
369
|
+
/**
|
|
370
|
+
* A long quotation of someone's own writing — the line he opened a
|
|
371
|
+
* session with. Quoted, never a field: a field would clip it to one
|
|
372
|
+
* short line, which is exactly how the first version of that card lost
|
|
373
|
+
* it.
|
|
374
|
+
*/
|
|
375
|
+
opened?: string;
|
|
376
|
+
/** A command for him to run, monospaced so Telegram makes it copyable. */
|
|
377
|
+
command?: string;
|
|
378
|
+
/** WHAT that command does — see the note on `job.commandNote`. */
|
|
379
|
+
commandNote?: string;
|
|
330
380
|
}
|
|
331
381
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* session
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* identity nothing else here has — an id, a working directory, and the line
|
|
339
|
-
* he typed to start it, which is the ONLY thing that tells two of his open
|
|
340
|
-
* sessions apart.
|
|
382
|
+
* DEPRECATED since 1.16.0: send an `incident`. The type is still ACCEPTED
|
|
383
|
+
* and renders as one — the runaway guard on this Mac
|
|
384
|
+
* (`context-runaway-notify.sh`) sends exactly this and must keep working —
|
|
385
|
+
* but `#session` is never printed again: the card comes out `#incident`.
|
|
386
|
+
* It was a separate type because a session has an id, a working directory
|
|
387
|
+
* and the line he typed to open it; `incident` carries all three now.
|
|
341
388
|
*/
|
|
342
389
|
| {
|
|
343
390
|
type: 'session';
|
|
344
391
|
project: Project;
|
|
345
|
-
/** What happened
|
|
392
|
+
/** What happened. Line 2 builds the incident's name out of it: `burning the limit` → `Claude session is burning the limit`. */
|
|
346
393
|
action: string;
|
|
347
394
|
/** The session's own id. Never printed as a field — he cannot type it or search it; it only reaches the card inside the `command`'s `rm`. */
|
|
348
395
|
id?: string;
|
|
@@ -360,7 +407,11 @@ export type NotifyEvent = Keyed & (
|
|
|
360
407
|
command?: string;
|
|
361
408
|
/** WHAT that command does — see the note on `job.commandNote`. */
|
|
362
409
|
commandNote?: string;
|
|
363
|
-
/**
|
|
410
|
+
/**
|
|
411
|
+
* Kept for old senders and no longer read: an incident is an alarm by
|
|
412
|
+
* definition, and no sender ever passed `ok` here — the runaway guard
|
|
413
|
+
* only ever sends the bad news.
|
|
414
|
+
*/
|
|
364
415
|
status?: 'fail' | 'ok';
|
|
365
416
|
}
|
|
366
417
|
/**
|
package/dist/events.js
CHANGED
|
@@ -73,8 +73,9 @@ export const iconFor = (e) => {
|
|
|
73
73
|
// `undefined` at the head of line 2. Not knowing is itself a state the
|
|
74
74
|
// package already has a word and a sound for.
|
|
75
75
|
return JOB_ICON[e.status] ?? ICON.unknown;
|
|
76
|
+
// A session IS an incident since 03.09.2026 — same icon, same sound, same
|
|
77
|
+
// tag. It is listed separately only because the type name still exists.
|
|
76
78
|
case 'session':
|
|
77
|
-
return e.status === 'ok' ? ICON.ok : ICON.alarm;
|
|
78
79
|
case 'incident':
|
|
79
80
|
return ICON.alarm;
|
|
80
81
|
case 'heartbeat_miss':
|
package/dist/lint.js
CHANGED
|
@@ -36,8 +36,11 @@ const OUTCOMES = new Set(['ok', 'fail', 'off', 'unknown', 'info']);
|
|
|
36
36
|
* Labels the template retired. Each one used to say what a neighbour said.
|
|
37
37
|
* `Check` LEFT this list in v2.1: rule S brought it back as the standard
|
|
38
38
|
* verification-command row. `Logs` stays retired — the new spelling is `Log`.
|
|
39
|
+
* `Via` left it on 03.09.2026: the bracket on the type word now says how the
|
|
40
|
+
* job ENDED, so where it ran has nowhere else to go and is a row again — and
|
|
41
|
+
* it never duplicated a neighbour, which is what this list is for.
|
|
39
42
|
*/
|
|
40
|
-
const RETIRED = ['Title', 'Number', 'State', '
|
|
43
|
+
const RETIRED = ['Title', 'Number', 'State', 'Logs', 'Task', 'Id', 'Period'];
|
|
41
44
|
/**
|
|
42
45
|
* Reads a finished card and returns what is wrong with it, in the owner's
|
|
43
46
|
* terms. An empty array means the card obeys the standard.
|
package/dist/render.d.ts
CHANGED
|
@@ -59,6 +59,17 @@ export declare const clampMessage: (text: string, limit?: number, marker?: strin
|
|
|
59
59
|
* the patterns contain no `<>&"`, so nothing the author typed can become a tag.
|
|
60
60
|
*/
|
|
61
61
|
export declare const markdownToTelegram: (text: string) => string;
|
|
62
|
+
/**
|
|
63
|
+
* A session has no name of its own, so the incident's title is built out of
|
|
64
|
+
* what happened to it: `burning the limit` → `Claude session is burning the
|
|
65
|
+
* limit`. That is the whole of what the separate `session` card ever said
|
|
66
|
+
* differently — every other row it had is an incident row now.
|
|
67
|
+
*
|
|
68
|
+
* The 36-character id is still not printed: he cannot type it, cannot search
|
|
69
|
+
* it and cannot act on it. It reaches the card only inside the `rm` command at
|
|
70
|
+
* the bottom, which is the one place it is of any use.
|
|
71
|
+
*/
|
|
72
|
+
export declare const sessionTitle: (action: string | undefined) => string;
|
|
62
73
|
export declare const slug: (raw: string) => string;
|
|
63
74
|
/**
|
|
64
75
|
* The instance tag: exactly which concrete event this is (branch,
|
package/dist/render.js
CHANGED
|
@@ -127,6 +127,15 @@ const firstLine = (value) => {
|
|
|
127
127
|
}
|
|
128
128
|
return `${value.split('\n')[0]}…`;
|
|
129
129
|
};
|
|
130
|
+
/**
|
|
131
|
+
* The first value the sender actually filled in, trimmed; `undefined` when
|
|
132
|
+
* every one of them came in empty. Neither `??` nor `||` alone can do this:
|
|
133
|
+
* `??` keeps an empty string and so takes the fallback away, `||` still lets
|
|
134
|
+
* a whitespace-only one through to a visibly empty row or bracket. Both `''`
|
|
135
|
+
* and `' '` reach us for real — from a `--json` payload and from a shell
|
|
136
|
+
* flag whose variable expanded to nothing.
|
|
137
|
+
*/
|
|
138
|
+
const filled = (...values) => values.map((v) => String(v ?? '').trim()).find((v) => v !== '');
|
|
130
139
|
/**
|
|
131
140
|
* A field: `<b>Label:</b> value` — a bold, capitalized label, a plain value.
|
|
132
141
|
* `null` is dropped the same as `undefined`/`''` — the field's sources are
|
|
@@ -168,13 +177,14 @@ const fieldLink = (label, url, text) => {
|
|
|
168
177
|
/** A monospace field — a path/command to copy, not a link. */
|
|
169
178
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
170
179
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
180
|
+
* People rows are printed ALWAYS, the owner included. They used to be
|
|
181
|
+
* suppressed when the login/handle was his own (`mikitasazan`/`chelsnebes`),
|
|
182
|
+
* on the theory that a row naming the reader is not news. In practice a card
|
|
183
|
+
* whose people rows vanish only for him reads as a card with a hole: he saw
|
|
184
|
+
* Issue #322 and PR #118 with no `Author:` at all and asked where the people
|
|
185
|
+
* had gone. Every card now carries the same set of rows, so an absent row
|
|
186
|
+
* means an empty field and nothing else (03.09.2026).
|
|
176
187
|
*/
|
|
177
|
-
const OWNER = { github: 'mikitasazan', telegram: 'chelsnebes' };
|
|
178
188
|
/**
|
|
179
189
|
* A person field — Author, Assignee, Reviewer. Every one of them is a
|
|
180
190
|
* GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
|
|
@@ -192,31 +202,8 @@ const fieldPerson = (label, login) => {
|
|
|
192
202
|
return null;
|
|
193
203
|
}
|
|
194
204
|
const oneLine = firstLine(login);
|
|
195
|
-
if (oneLine.toLowerCase() === OWNER.github) {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
205
|
return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
|
|
199
206
|
};
|
|
200
|
-
/**
|
|
201
|
-
* `Actor` on a CI card is a Telegram handle, not a GitHub login
|
|
202
|
-
* (`nightly.yml`, step "Кто чинит" — "по «@chelsnebes» приходит уведомление
|
|
203
|
-
* тому, кто чинит, по «mikitasazan» — нет"), so it links to Telegram, not
|
|
204
|
-
* GitHub: `github.com/@chelsnebes` would open a page that does not exist.
|
|
205
|
-
* Telegram DOES auto-link a bare `@handle` on its own, but the owner asked
|
|
206
|
-
* for an explicit link like every other identifier on the card, not an
|
|
207
|
-
* implicit one riding on a client behavior he cannot see from here.
|
|
208
|
-
*/
|
|
209
|
-
const fieldTelegram = (label, handle) => {
|
|
210
|
-
if (!handle) {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
const oneLine = firstLine(handle);
|
|
214
|
-
const bare = oneLine.replace(/^@/, '');
|
|
215
|
-
if (bare.toLowerCase() === OWNER.telegram) {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(bare)}">${esc(oneLine)}</a>`;
|
|
219
|
-
};
|
|
220
207
|
/**
|
|
221
208
|
* A row that asks something FROM THE OWNER, rather than reports a fact. It
|
|
222
209
|
* already stood last, set off by a blank line, and still read as an
|
|
@@ -685,11 +672,18 @@ const renderDeploy = (e) => {
|
|
|
685
672
|
// The run URL rides on the name (03.09.2026): the thing you read is the
|
|
686
673
|
// thing you tap. It spent three days in a `Source:` row at the bottom, and
|
|
687
674
|
// the owner asked what that row was for when the name was right there.
|
|
688
|
-
|
|
675
|
+
// The outcome word comes from the icon, not from a second reading of
|
|
676
|
+
// `status`: `fail ? 'Fail' : 'OK'` defaulted the opposite way from
|
|
677
|
+
// `iconFor` (`ok ? 🟢 : 🔴`), so any status outside {ok,fail} arrived as
|
|
678
|
+
// 🔴 `#fail` with the word `(OK)` beside it.
|
|
679
|
+
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), sourceUrl(e), outcomeAside(e)),
|
|
689
680
|
...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
681
|
+
// The body sits straight under the commit line, the people below it —
|
|
682
|
+
// the same shape as a PR or an issue. With `Author:` wedged between
|
|
683
|
+
// the two, the quote read as the author's words (03.09.2026).
|
|
690
684
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
691
|
-
|
|
692
|
-
|
|
685
|
+
bodyQuote(e.commitBody),
|
|
686
|
+
fieldPerson('Author', e.commitAuthor)
|
|
693
687
|
])
|
|
694
688
|
]);
|
|
695
689
|
};
|
|
@@ -697,6 +691,35 @@ const schedule = (expected, lastSeen, lastLabel) => {
|
|
|
697
691
|
const rows = [field('Expected', expected), field(lastLabel, lastSeen)].filter((r) => r !== null);
|
|
698
692
|
return rows.length > 0 ? ['', group('Schedule'), ...rows] : [];
|
|
699
693
|
};
|
|
694
|
+
/**
|
|
695
|
+
* `via` for display. The three words the senders actually use get an explicit
|
|
696
|
+
* spelling — `vps` upper-cased whole would read as a shout, `VPS` lower-cased
|
|
697
|
+
* as a typo — and anything else just gets its first letter raised, so a word
|
|
698
|
+
* nobody foresaw still prints as a word rather than being dropped.
|
|
699
|
+
*/
|
|
700
|
+
const VIA_NAMES = { mac: 'Mac', vps: 'VPS', actions: 'Actions' };
|
|
701
|
+
const viaName = (via) => VIA_NAMES[via.trim().toLowerCase()] ?? via.trim().charAt(0).toUpperCase() + via.trim().slice(1);
|
|
702
|
+
/**
|
|
703
|
+
* The bracket on the type word says HOW IT ENDED, in one word — the owner's
|
|
704
|
+
* decision of 03.09.2026: one slot, one meaning, on every type. It used to
|
|
705
|
+
* mean three different things at once — the outcome on deploy and CI, the day
|
|
706
|
+
* on a report, and (for one afternoon) the place of the run on a job — so the
|
|
707
|
+
* bracket could not be read without first knowing which card you were looking
|
|
708
|
+
* at. A job's outcome now reads the same way a deploy's does.
|
|
709
|
+
*/
|
|
710
|
+
const JOB_OUTCOME = {
|
|
711
|
+
ok: 'OK',
|
|
712
|
+
fail: 'Fail',
|
|
713
|
+
disabled: 'Off',
|
|
714
|
+
silent: 'Silent'
|
|
715
|
+
};
|
|
716
|
+
/**
|
|
717
|
+
* Where the job ran, as its own row directly under the type line. It spent one
|
|
718
|
+
* afternoon inside the bracket on the type word, which cost the bracket its
|
|
719
|
+
* single meaning; `Via:` is a fact ABOUT the run, and facts about the subject
|
|
720
|
+
* are what the rows under line 2 are for.
|
|
721
|
+
*/
|
|
722
|
+
const viaRow = (via) => via?.trim() ? field('Via', viaName(via)) : null;
|
|
700
723
|
const renderJob = (e) => {
|
|
701
724
|
const icon = iconFor(e);
|
|
702
725
|
const hasItems = (e.items ?? []).length > 0;
|
|
@@ -715,8 +738,30 @@ const renderJob = (e) => {
|
|
|
715
738
|
// The URL is on the name (03.09.2026). It went down to a `Source:` row in
|
|
716
739
|
// v2.1 so the pointer could be SEEN — and came back, because the row's
|
|
717
740
|
// only text was `workflow run`, the same two words on every card.
|
|
718
|
-
|
|
719
|
-
|
|
741
|
+
// `?? outcomeAside(e)` for the same reason `iconFor` defends itself: an
|
|
742
|
+
// untyped `--json` payload can carry a status outside the map or none at
|
|
743
|
+
// all, and `cap(undefined)` threw a TypeError instead of rendering a card.
|
|
744
|
+
// The word comes from the icon, so ❓ and `#unknown` say `(Unknown)`.
|
|
745
|
+
typeLine(icon, 'Job', e.job, sourceUrl(e), JOB_OUTCOME[e.status] ?? outcomeAside(e)),
|
|
746
|
+
// Where it ran comes first among the facts: it qualifies everything below
|
|
747
|
+
// it — the same reason, the same duration mean different things on the Mac
|
|
748
|
+
// and on the server.
|
|
749
|
+
viaRow(e.via),
|
|
750
|
+
// `aside` no longer has a bracket to ride in. It was always a sentence
|
|
751
|
+
// about WHY this card exists at all («reporting again»), which is what the
|
|
752
|
+
// Reason row says; it fills that row only when the sender left it empty,
|
|
753
|
+
// and is dropped rather than duplicating a real reason. The field stays in
|
|
754
|
+
// the event type — the senders that still pass it must not start failing.
|
|
755
|
+
// `filled`, not `??`: a sender that passes `note: ''` (an empty variable
|
|
756
|
+
// in a shell script) left the row empty, and `??` read that as a reason
|
|
757
|
+
// given — dropping the aside and the whole row with it.
|
|
758
|
+
reason('Reason', filled(e.note, e.aside)),
|
|
759
|
+
// How long the run took, in the sender's own words. It sits under the
|
|
760
|
+
// reason and above the timetable: the reason says what happened, this
|
|
761
|
+
// says what it cost, and only then comes when it is due again.
|
|
762
|
+
// Trimmed by `filled`: a whitespace-only `--took` printed a bare
|
|
763
|
+
// `Took:` with nothing after it.
|
|
764
|
+
field('Took', filled(e.duration)),
|
|
720
765
|
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
721
766
|
// The timetable is a different subject from this event: how often the task
|
|
722
767
|
// owes a sign of life and when it last gave one. It stood in a bare run
|
|
@@ -775,19 +820,23 @@ const renderCi = (e) => {
|
|
|
775
820
|
const icon = iconFor(e);
|
|
776
821
|
return join([
|
|
777
822
|
// The run URL is on the gate's name — `CI: <a>nightly</a>` — since 03.09.2026.
|
|
778
|
-
|
|
779
|
-
// `
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
823
|
+
// Same source as the icon — see the deploy card: the two readings of
|
|
824
|
+
// `status` defaulted opposite ways and the bracket could contradict both
|
|
825
|
+
// the icon and the tag.
|
|
826
|
+
typeLine(icon, 'CI', mechanism(e.workflowName, undefined), sourceUrl(e), outcomeAside(e)),
|
|
827
|
+
// No `Actor:` row (03.09.2026). It was redundant on every kind of run: on
|
|
828
|
+
// a push the actor IS the commit's author, printed a line above; on a
|
|
829
|
+
// scheduled run GitHub names the workflow's owner, the same person every
|
|
830
|
+
// night; only a hand-pressed button carries news, and the run link on
|
|
831
|
+
// line 2 shows that. `actor` stays in the event for old senders and is
|
|
832
|
+
// simply not printed.
|
|
786
833
|
...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
|
|
787
|
-
|
|
834
|
+
// Commit line, its body, then the people — same shape as deploy, PR
|
|
835
|
+
// and issue; a people row between a title and its quote makes the
|
|
836
|
+
// quote read as that person's words.
|
|
788
837
|
commitRow(e.commit, e.commitUrl, e.commitTitle),
|
|
789
|
-
|
|
790
|
-
|
|
838
|
+
bodyQuote(e.commitBody),
|
|
839
|
+
fieldPerson('Author', e.commitAuthor)
|
|
791
840
|
])
|
|
792
841
|
]);
|
|
793
842
|
};
|
|
@@ -824,11 +873,34 @@ const renderCi = (e) => {
|
|
|
824
873
|
* twenty lines under a title that could not be tapped. With no url the number
|
|
825
874
|
* still prints, plain. With no title the line ends at the number.
|
|
826
875
|
*/
|
|
827
|
-
const numberedLine = (icon, type, number, title, url) => {
|
|
876
|
+
const numberedLine = (icon, type, number, title, url, outcome) => {
|
|
828
877
|
const id = url ? `<a href="${esc(firstLine(url))}">#${number}</a>` : `#${number}`;
|
|
829
878
|
const name = title?.trim() ? ` · ${esc(firstLine(title.trim()))}` : '';
|
|
830
|
-
|
|
879
|
+
// The bracket goes on the TYPE WORD, before the number — the same slot it
|
|
880
|
+
// takes on `Deploy (OK):`, so one glance finds the outcome on any card.
|
|
881
|
+
const label = outcome ? `${type} (${outcome})` : type;
|
|
882
|
+
return `${icon} <b>${esc(label)}</b> ${id}${name}`;
|
|
831
883
|
};
|
|
884
|
+
/**
|
|
885
|
+
* How a pull request or an issue ended, in one word. An action nobody
|
|
886
|
+
* foresaw — a new GitHub verb, or a missing one in an untyped `--json`
|
|
887
|
+
* payload — takes the word the icon already implies, `Unknown`; it used to
|
|
888
|
+
* take the literal `Info`, which is not an outcome and contradicted both the
|
|
889
|
+
* ❓ on the same line and the `#unknown` tag above it.
|
|
890
|
+
*/
|
|
891
|
+
const PR_OUTCOME = {
|
|
892
|
+
opened: 'Opened',
|
|
893
|
+
approved: 'Approved',
|
|
894
|
+
changes_requested: 'Changes',
|
|
895
|
+
merged: 'Merged',
|
|
896
|
+
closed: 'Closed'
|
|
897
|
+
};
|
|
898
|
+
const ISSUE_OUTCOME = {
|
|
899
|
+
opened: 'Opened',
|
|
900
|
+
assigned: 'Assigned',
|
|
901
|
+
closed: 'Closed'
|
|
902
|
+
};
|
|
903
|
+
const outcomeWord = (table, e) => table[e.action] ?? outcomeAside(e);
|
|
832
904
|
// The people come BEFORE the text, and the text comes only when it is the
|
|
833
905
|
// news. An `assigned` card carries one new fact — who took it — and it used to
|
|
834
906
|
// sit dead last, under the issue's entire description: the owner read a card
|
|
@@ -866,7 +938,7 @@ const prBody = (action, body) => {
|
|
|
866
938
|
// old order, title then Author then Assignee then finally the body — "why
|
|
867
939
|
// does the assignee cut apart what should be inseparable?"
|
|
868
940
|
const renderPr = (e) => join([
|
|
869
|
-
numberedLine(iconFor(e), 'PR', e.number, e.title, e.url),
|
|
941
|
+
numberedLine(iconFor(e), 'PR', e.number, e.title, e.url, outcomeWord(PR_OUTCOME, e)),
|
|
870
942
|
prBody(e.action, e.body),
|
|
871
943
|
e.body ? '' : null,
|
|
872
944
|
fieldPerson('Author', e.author),
|
|
@@ -881,7 +953,7 @@ const renderPr = (e) => join([
|
|
|
881
953
|
// Whole on opened and assigned (the text is still the news, or the person who
|
|
882
954
|
// took it needs the whole brief); the first section on closed — see prBody.
|
|
883
955
|
const renderIssue = (e) => join([
|
|
884
|
-
numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url),
|
|
956
|
+
numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url, outcomeWord(ISSUE_OUTCOME, e)),
|
|
885
957
|
markdownQuote(e.body, e.action !== 'closed'),
|
|
886
958
|
e.body ? '' : null,
|
|
887
959
|
fieldPerson('Author', e.author),
|
|
@@ -898,35 +970,55 @@ const renderIssue = (e) => join([
|
|
|
898
970
|
const renderIncident = (e) => {
|
|
899
971
|
const findings = bullets(e.items, false);
|
|
900
972
|
return join([
|
|
901
|
-
|
|
973
|
+
// The bracket on an incident names WHERE it burns, not how it ended —
|
|
974
|
+
// it has one ending. `Incident (Vault):`, `Incident (Session):`; with no
|
|
975
|
+
// word from the sender, the project name stands in.
|
|
976
|
+
// `filled`, not `??`: `scope: ''` and `scope: ' '` are a sender that
|
|
977
|
+
// named no place, and `??` let the first past the project fallback (no
|
|
978
|
+
// bracket at all) and the second into a visibly empty one.
|
|
979
|
+
typeLine(iconFor(e), 'Incident', e.title, e.url, filled(e.scope) ?? cap(e.project)),
|
|
902
980
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
981
|
+
// The rows below came from the `session` card when it was folded in
|
|
982
|
+
// (03.09.2026), in the order they had there: which copy, what the guard
|
|
983
|
+
// measured, then his own opening line as a quote, then the way out.
|
|
984
|
+
field('Project', e.workdir),
|
|
985
|
+
reason('Reason', e.reason),
|
|
903
986
|
field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
|
|
904
987
|
findings.length > 0 ? '' : null,
|
|
905
|
-
...findings
|
|
988
|
+
...findings,
|
|
989
|
+
e.opened ? '' : null,
|
|
990
|
+
quoted('Opened with', e.opened),
|
|
991
|
+
e.command ? '' : null,
|
|
992
|
+
...fieldRun(e.command, e.commandNote)
|
|
906
993
|
// `Log:` moved to the pointer block `render` appends — see renderJob.
|
|
907
994
|
]);
|
|
908
995
|
};
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
996
|
+
/**
|
|
997
|
+
* A session has no name of its own, so the incident's title is built out of
|
|
998
|
+
* what happened to it: `burning the limit` → `Claude session is burning the
|
|
999
|
+
* limit`. That is the whole of what the separate `session` card ever said
|
|
1000
|
+
* differently — every other row it had is an incident row now.
|
|
1001
|
+
*
|
|
1002
|
+
* The 36-character id is still not printed: he cannot type it, cannot search
|
|
1003
|
+
* it and cannot act on it. It reaches the card only inside the `rm` command at
|
|
1004
|
+
* the bottom, which is the one place it is of any use.
|
|
1005
|
+
*/
|
|
1006
|
+
export const sessionTitle = (action) => {
|
|
1007
|
+
const what = action?.trim();
|
|
1008
|
+
return what ? `Claude session is ${what}` : 'Claude session is in trouble';
|
|
1009
|
+
};
|
|
1010
|
+
/**
|
|
1011
|
+
* The folded `session` type (03.09.2026): accepted, never rendered as itself.
|
|
1012
|
+
* It is turned into the incident it always was and handed to the incident
|
|
1013
|
+
* renderer, so there is ONE card shape and no second copy to keep in step.
|
|
1014
|
+
*/
|
|
1015
|
+
const asIncident = (e) => ({
|
|
1016
|
+
...e,
|
|
1017
|
+
type: 'incident',
|
|
1018
|
+
scope: 'Session',
|
|
1019
|
+
title: sessionTitle(e.action)
|
|
1020
|
+
});
|
|
1021
|
+
const renderSession = (e) => renderIncident(asIncident(e));
|
|
930
1022
|
const renderHeartbeatMiss = (e) => {
|
|
931
1023
|
const icon = iconFor(e);
|
|
932
1024
|
return join([
|
|
@@ -935,10 +1027,10 @@ const renderHeartbeatMiss = (e) => {
|
|
|
935
1027
|
// repository builds this event any more — the silence watchdog sends an
|
|
936
1028
|
// ordinary job with `--status silent` — but a machine still running the old
|
|
937
1029
|
// copy of that watchdog can, and the card it gets must obey the template.
|
|
938
|
-
//
|
|
939
|
-
// it
|
|
940
|
-
//
|
|
941
|
-
typeLine(icon, 'Heartbeat', e.job),
|
|
1030
|
+
// The bracket takes an outcome word, the same as a job card does — and
|
|
1031
|
+
// it takes one of the job's own words: `Silent`, never `miss`, which is
|
|
1032
|
+
// not one of the words the outcome is allowed to be.
|
|
1033
|
+
typeLine(icon, 'Heartbeat', e.job, undefined, e.recovered ? 'OK' : 'Silent'),
|
|
942
1034
|
field('Reason', e.note),
|
|
943
1035
|
...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
|
|
944
1036
|
]);
|
|
@@ -978,7 +1070,8 @@ const TYPE_TAG = {
|
|
|
978
1070
|
pr: 'pr',
|
|
979
1071
|
issue: 'issue',
|
|
980
1072
|
incident: 'incident',
|
|
981
|
-
session
|
|
1073
|
+
// Folded into incident on 03.09.2026 — `#session` is never printed again.
|
|
1074
|
+
session: 'incident',
|
|
982
1075
|
heartbeat_miss: 'heartbeat'
|
|
983
1076
|
};
|
|
984
1077
|
/**
|
|
@@ -1045,6 +1138,25 @@ export const OUTCOME_TAG = {
|
|
|
1045
1138
|
[ICON.info]: 'info'
|
|
1046
1139
|
};
|
|
1047
1140
|
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
1141
|
+
/**
|
|
1142
|
+
* The bracket word for a status or an action no outcome table knows — a new
|
|
1143
|
+
* GitHub verb, a `--json` payload with a typo or with no status at all. It is
|
|
1144
|
+
* read off the SAME icon the card already shows, so line 2 can never
|
|
1145
|
+
* contradict line 1 or the third tag: an unmapped value renders ❓ `#unknown`
|
|
1146
|
+
* and now says `(Unknown)` too, where it used to say `(OK)` or `(Info)` — the
|
|
1147
|
+
* exact opposite of the icon beside it.
|
|
1148
|
+
*
|
|
1149
|
+
* `info` gets no word: nothing was decided on that card, and there is no
|
|
1150
|
+
* honest outcome to name. A missing bracket is a gap; a wrong one is a lie.
|
|
1151
|
+
*/
|
|
1152
|
+
const FALLBACK_OUTCOME = {
|
|
1153
|
+
ok: 'OK',
|
|
1154
|
+
fail: 'Fail',
|
|
1155
|
+
off: 'Off',
|
|
1156
|
+
unknown: 'Unknown',
|
|
1157
|
+
info: undefined
|
|
1158
|
+
};
|
|
1159
|
+
const outcomeAside = (e) => FALLBACK_OUTCOME[outcomeTag(e)];
|
|
1048
1160
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
1049
1161
|
/**
|
|
1050
1162
|
* Rule S (v2.1, amended 03.09.2026): the card says where to verify it. A
|
package/package.json
CHANGED