@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 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` | приложение сломалось прямо сейчас | `project`, `title` |
88
- | `session` | рабочая сессия на маке в беде | `project`, `action` |
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 --actor saz_sam
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: one('title') ?? '(no 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 qualifier the name needs to be readable on its own, printed in
145
- * brackets right after it: `Yandex game import (reporting again)`. It is
146
- * the same slot a report uses for the day it covers. Anything that is a
147
- * FACT about the job goes in a row of its own; this is for the word that
148
- * finishes the name.
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
- * A working session on this Mac is in trouble not a job, not a workflow.
333
- * It went out as `job` at first and read wrong: `#job` promises something
334
- * scheduled that ran and failed, and the owner rightly asked what a burning
335
- * session was doing under that heading.
336
- *
337
- * What makes it its own type rather than an `incident`: a session has an
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, as the second line reads it: `Session: burning the limit`. */
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
- /** `fail` red, `ok` green — a session that recovered is not an alarm. */
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', 'Via', 'Logs', 'Task', 'Id', 'Period'];
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
- * The owner himself, under the two names the senders know him by. A people
172
- * row that names the reader is not news: on the 49 PR and issue cards of the
173
- * week of 25.08.2026 `Author:` was him on every one, and the single row that
174
- * ever said something was `Assignee: Ilja-Prihach`. So a person row is printed
175
- * only when the person is someone else (03.09.2026).
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
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), sourceUrl(e), e.status === 'fail' ? 'Fail' : 'OK'),
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
- fieldPerson('Author', e.commitAuthor),
692
- bodyQuote(e.commitBody)
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
- typeLine(icon, 'Job', e.job, sourceUrl(e), e.aside),
719
- reason('Reason', e.note),
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
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined), sourceUrl(e), e.status === 'fail' ? 'Fail' : 'OK'),
779
- // `Actor` used to be read as "who wrote the commit," and on most runs it
780
- // is `github.actor` for a push IS the person who pushed. It stops being
781
- // that on a scheduled run: arvent's nightly rewrites it to whoever is on
782
- // duty to fix a red run, which can be someone other than the commit's
783
- // author. So Actor answers "who is responsible for this run," `Author`
784
- // below the commit answers "who wrote this code" two different people
785
- // on a nightly card, the same person everywhere else.
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
- fieldTelegram('Actor', e.actor),
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
- fieldPerson('Author', e.commitAuthor),
790
- bodyQuote(e.commitBody)
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
- return `${icon} <b>${esc(type)}</b> ${id}${name}`;
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
- typeLine(iconFor(e), 'Incident', e.title, e.url),
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
- // A session in trouble. Same law as every other card: identifier first, then
910
- // the facts as fields, then his own words as a quote never as a field, which
911
- // keeps one line and clipped the name of the very session the card is about.
912
- //
913
- // A session has no name, so line 2 says what happened to it. The 36-character
914
- // id is not printed: he cannot type it, cannot search it and cannot act on it.
915
- // It is still in the card inside the `rm` command at the bottom, which is the
916
- // one place it is of any use.
917
- const renderSession = (e) => join([
918
- typeLine(iconFor(e), 'Session', e.action),
919
- // No blank line under the type line: a blank means a new block, and here it
920
- // opened a block that had no heading. Facts about the session touch the
921
- // line that names it, the way they do on every job card.
922
- field('Project', e.workdir),
923
- reason('Reason', e.reason),
924
- field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
925
- e.opened ? '' : null,
926
- quoted('Opened with', e.opened),
927
- e.command ? '' : null,
928
- ...fieldRun(e.command, e.commandNote)
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
- // No bracket saying `ok` or `miss`: the icon says it, the third tag says
939
- // it, and `miss` is not one of the five words the outcome is allowed to
940
- // be. The bracket is for what finishes the NAME, never for a verdict.
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: '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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.15.2",
3
+ "version": "1.16.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",