@mikitasazan/notify 1.11.1 → 1.12.1

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
@@ -158,6 +158,23 @@ notify report --project playhub --json < payload.json # весь объект
158
158
  никогда, только новый тип события**. Тогда старый вызывающий код и новый
159
159
  пакет всегда совместимы.
160
160
 
161
+ ## Тег сменился 18.08.2026 у шести отправителей
162
+
163
+ Коммит `85564fc` в mac-config дал явный `--key` шести местам вызова, которые
164
+ раньше отправляли карточку БЕЗ ключа — тег брался из `--job`/`--title`
165
+ автоматически (`slug()`, см. `src/render.ts`). Без этой таблицы старую
166
+ красную карточку в Telegram не найти поиском по новому тегу — она там под
167
+ старым.
168
+
169
+ | Отправитель | Старый тег (авто из русского названия) | Новый тег (из `--key`) |
170
+ |---|---|---|
171
+ | `arvent-eval-report.sh`, отчёт по эвалу | `#eval_качество_ответов_бота` | `#arvent_eval` |
172
+ | `arvent-eval-report.sh`, файл диалогов | нет — шёл мимо пакета, сырым `sendDocument` Bot API | `#arvent_eval_dialogues` |
173
+ | `daily-digest.sh`, дайджест задач (`job`) | `#дайджест_задач` | `#daily_digest` |
174
+ | `daily-digest.sh`, дайджест задач (`report`) | `#дайджест_задач` (то же слово, другой тип-тег: `#report` вместо `#job`) | `#daily_digest` |
175
+ | `daily-digest.sh`, отключённые Actions | `#github_actions_выключены` | `#actions_off` |
176
+ | `daily-digest.sh`, Config doctor | `#config_doctor` | `#config_doctor` — не изменился: `slug()` приводит и старое, и новое к одной строке |
177
+
161
178
  ## Чего в пакете нет и почему
162
179
 
163
180
  - Очереди, брокера, демона — десятки сообщений в день, ретрай в памяти
package/dist/cli-flags.js CHANGED
@@ -11,7 +11,8 @@
11
11
  export const KNOWN_FLAGS = new Set([
12
12
  'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
13
13
  'id', 'opened', 'reason', 'workdir',
14
- 'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url', 'detail',
14
+ 'check', 'command', 'command-note', 'commit-author', 'commit-body', 'commit-title', 'commit-url',
15
+ 'detail', 'detail-label',
15
16
  'expected', 'filename', 'item',
16
17
  'item-group', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
17
18
  'aside', 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
package/dist/cli.js CHANGED
@@ -24,7 +24,9 @@
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { KNOWN_FLAGS } from "./cli-flags.js";
26
26
  import { render } from "./render.js";
27
+ import { lintCard } from "./lint.js";
27
28
  import { notify } from "./send.js";
29
+ import { ROUTES } from "./routes.js";
28
30
  import { setupTopic } from "./setup.js";
29
31
  const log = (msg) => console.error(`[notify] ${msg}`);
30
32
  /**
@@ -41,6 +43,23 @@ const log = (msg) => console.error(`[notify] ${msg}`);
41
43
  const safe = (v) => String(v ?? '').replace(/\b(sent|failed|skipped)\b/gi, (w) => `${w[0]}·${w.slice(1)}`);
42
44
  const args = process.argv.slice(2);
43
45
  const command = args[0];
46
+ // `lint-text` and `routes` exist so the nightly audit asks the PACKAGE
47
+ // instead of keeping its own copy of the rules and the routing table — the
48
+ // copies had already drifted twice.
49
+ if (command === 'lint-text') {
50
+ // The finished card HTML on stdin; every fault on stdout, one per line.
51
+ // Empty output means the card obeys the standard. Exit code stays 0 —
52
+ // the CLI-wide contract.
53
+ const text = readFileSync(0, 'utf-8').replace(/\n$/, '');
54
+ for (const fault of lintCard(text)) {
55
+ process.stdout.write(`${fault}\n`);
56
+ }
57
+ process.exit(0);
58
+ }
59
+ if (command === 'routes') {
60
+ process.stdout.write(`${JSON.stringify(ROUTES, null, 2)}\n`);
61
+ process.exit(0);
62
+ }
44
63
  if (command === 'setup') {
45
64
  const [, chatId, projectKey] = args;
46
65
  if (!chatId || !projectKey) {
@@ -261,6 +280,8 @@ else {
261
280
  command: one('command'),
262
281
  commandNote: one('command-note'),
263
282
  logs: one('logs'),
283
+ detail: one('detail'),
284
+ detailLabel: one('detail-label'),
264
285
  workflowUrl: one('workflow-url'),
265
286
  workflowName: one('workflow-name'),
266
287
  url: one('url')
@@ -390,6 +411,9 @@ if (event) {
390
411
  // --path is applicable to any type too, for the same reason --key is.
391
412
  event.path = one('path') ?? event.path;
392
413
  event.filename = one('filename') ?? event.filename;
414
+ // --check applies to any type (rule S): the verification command for a
415
+ // card whose event has no canonical URL.
416
+ event.check = one('check') ?? event.check;
393
417
  }
394
418
  if (event?.filename && !event.path) {
395
419
  parseErrors.push('--filename: given without --path, so there is no file to name');
package/dist/events.d.ts CHANGED
@@ -37,6 +37,18 @@ type Keyed = {
37
37
  path?: string;
38
38
  /** The file's name in the chat; defaults to the name from `path`. */
39
39
  filename?: string;
40
+ /**
41
+ * The verification command — `Check:` in the card's last block, monospaced
42
+ * and tap-to-copy. The standard (v2.1, rule S) wants every card to say
43
+ * where to verify it: a `Source:` link when the event has a canonical URL,
44
+ * this command when the event is local. Usually `config jobs --log <key>`.
45
+ */
46
+ check?: string;
47
+ /**
48
+ * Set by the send-side suppression, never by a caller: which day of the
49
+ * same unresolved failure this is. Renders as `Still red: day N`.
50
+ */
51
+ stillRed?: number;
40
52
  };
41
53
  /**
42
54
  * A list item inside a message: a task from a digest, a failed check, a
@@ -172,8 +184,20 @@ export type NotifyEvent = Keyed & (
172
184
  * A local log path — monospaced, not a link, same as on an incident.
173
185
  * It used to be glued onto the end of the reason sentence behind a
174
186
  * colon, which is what made a red card read as one long run-on line.
187
+ * Under rule S it is an ADDITION to `check`/a link, never the card's
188
+ * only pointer — a path cannot be tapped, only copied.
175
189
  */
176
190
  logs?: string;
191
+ /**
192
+ * A multi-line quoted block with its own caption (`detailLabel`) — the
193
+ * shape the watchdog uses to show the offending card's first lines
194
+ * under `Offender:`. Generic on purpose: any job with a verbatim
195
+ * excerpt to show (someone else's text, not the sender's own words)
196
+ * uses this instead of stuffing it into `note`.
197
+ */
198
+ detail?: string;
199
+ /** The caption over `detail`; defaults to `Detail`. */
200
+ detailLabel?: string;
177
201
  workflowUrl?: string;
178
202
  /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
179
203
  workflowName?: string;
package/dist/lint.js CHANGED
@@ -32,8 +32,12 @@ const NOT_A_NAME = new Set([
32
32
  ]);
33
33
  /** The five words the third tag is allowed to be, and there is no sixth. */
34
34
  const OUTCOMES = new Set(['ok', 'fail', 'off', 'unknown', 'info']);
35
- /** Labels the template retired. Each one used to say what a neighbour said. */
36
- const RETIRED = ['Title', 'Number', 'State', 'Via', 'Check', 'Logs', 'Task', 'Id', 'Period'];
35
+ /**
36
+ * Labels the template retired. Each one used to say what a neighbour said.
37
+ * `Check` LEFT this list in v2.1: rule S brought it back as the standard
38
+ * verification-command row. `Logs` stays retired — the new spelling is `Log`.
39
+ */
40
+ const RETIRED = ['Title', 'Number', 'State', 'Via', 'Logs', 'Task', 'Id', 'Period'];
37
41
  /**
38
42
  * Reads a finished card and returns what is wrong with it, in the owner's
39
43
  * terms. An empty array means the card obeys the standard.
@@ -45,8 +49,23 @@ export const lintCard = (html) => {
45
49
  if (tags.length !== 3 || !tags.every((t) => t.startsWith('#'))) {
46
50
  found.push(`line 1 is "${rows[0] ?? ''}" — it must be exactly three tags`);
47
51
  }
48
- else if (!OUTCOMES.has(tags[2].slice(1))) {
49
- found.push(`the outcome tag is "${tags[2]}" — the vocabulary is ok, fail, off, unknown, info`);
52
+ else {
53
+ if (!OUTCOMES.has(tags[2].slice(1))) {
54
+ found.push(`the outcome tag is "${tags[2]}" — the vocabulary is ok, fail, off, unknown, info`);
55
+ }
56
+ // The charset is checked HERE, not trusted to slug(): slug keeps any
57
+ // Unicode letter, so a Russian-named job produces a Cyrillic tag with no
58
+ // complaint anywhere — confirmed against render.ts on 31.08.2026.
59
+ for (const t of tags) {
60
+ if (!/^#[a-z0-9_]+$/.test(t)) {
61
+ found.push(`the tag "${t}" carries characters outside [a-z0-9_] — tags are English, lowercase`);
62
+ }
63
+ }
64
+ // A dated tag groups nothing: every day mints a new one and the filter
65
+ // the tags exist for never collects two cards.
66
+ if (/(_|^#)20\d{2}_\d{2}_\d{2}$/.test(tags[1]) || /_20\d{6}$/.test(tags[1])) {
67
+ found.push(`the instance tag "${tags[1]}" ends in a date — a dated tag groups nothing`);
68
+ }
50
69
  }
51
70
  if (tags[1] === '#' || tags[1] === '#_') {
52
71
  found.push('the instance tag is empty — it groups nothing and pairs with nothing');
@@ -98,17 +117,39 @@ export const lintCard = (html) => {
98
117
  break;
99
118
  }
100
119
  }
101
- // A red card must say where to look. Here and only here that can be
102
- // judged completely: a log row, a command to run, or any address at all,
103
- // including the one riding on line 2 where the standard puts it. Read back
104
- // from Telegram the addresses are gone, so the same rule there accused a
105
- // deploy card whose own second line was the link to the failed run.
106
- // `#fail` only. A task that has gone silent is `#unknown`, and it has no log
107
- // by definition — it never ran. Demanding one there would be demanding a
108
- // thing that cannot exist.
109
- const broke = (rows[0] ?? '').includes('#fail');
110
- if (broke && !html.includes('<b>Log:</b>') && !html.includes('<b>To do:</b>') && !html.includes('<a href=')) {
111
- found.push('a red card with no log, no command and no link — nowhere to look');
120
+ // Rule L (v2.1): everything the SYSTEM says is English. Cyrillic is
121
+ // allowed only as QUOTED CONTENT text that exists in Russian outside the
122
+ // card: a blockquote (commit bodies, issue bodies, an offender's lines),
123
+ // the text of a link (issue titles in digests), and the title slot on
124
+ // line 2. Everywhere else a label, a bare field value, a tag, a command —
125
+ // it is system text and a fault.
126
+ const quotedStripped = html
127
+ .replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/g, '')
128
+ .replace(/<a href="[^"]*">[^<]*<\/a>/g, '')
129
+ // A commit's title rides the `Commit:` row after the hash — it is a
130
+ // commit message, the canonical quoted content.
131
+ .replace(/^<b>Commit:<\/b>.*$/gm, '')
132
+ // List items carry content (issue titles in a digest, findings) — the
133
+ // row's own text is the subject's, not the system's.
134
+ .replace(/^(?:•|\d+\.) .*$/gm, '');
135
+ const strippedRows = quotedStripped.split('\n');
136
+ // Line 2's value after the label is the one non-quoted slot allowed to
137
+ // carry a title written in Russian (issue/PR/report/incident titles).
138
+ if (strippedRows[1]) {
139
+ strippedRows[1] = strippedRows[1].replace(/(<b>[^<]+:<\/b>|<b>[^<]+<\/b>).*/, '$1');
140
+ }
141
+ if (/[а-яё]/i.test(strippedRows.join('\n'))) {
142
+ found.push('Cyrillic outside quoted content — system text is English (rule L)');
143
+ }
144
+ // Rule S (v2.1): a card that reports trouble must say where to verify it —
145
+ // a `Check:` command, a `Source:` link, a `To do:` command, or any link at
146
+ // all. A `Log:` path alone is not enough: a path cannot be tapped, and a
147
+ // card whose only pointer needs a file manager is a card with no pointer.
148
+ // `#fail` and `#unknown` both: a silent task has no log, but `config jobs
149
+ // --log <key>` answers it too.
150
+ const broke = (rows[0] ?? '').includes('#fail') || (rows[0] ?? '').includes('#unknown');
151
+ if (broke && !html.includes('<b>Check:</b>') && !html.includes('<b>To do:</b>') && !html.includes('<a href=')) {
152
+ found.push('a trouble card with no check command and no link — nowhere to look (rule S)');
112
153
  }
113
154
  return found;
114
155
  };
package/dist/render.d.ts CHANGED
@@ -36,7 +36,7 @@ export declare const esc: (v: unknown) => string;
36
36
  * Telegram replies `400 can't parse entities`, and we treat a 4xx as a
37
37
  * permanent error and do not retry — the message disappeared for good.
38
38
  */
39
- export declare const clampMessage: (text: string, limit?: number) => string;
39
+ export declare const clampMessage: (text: string, limit?: number, marker?: string) => string;
40
40
  export declare const slug: (raw: string) => string;
41
41
  /**
42
42
  * The instance tag: exactly which concrete event this is (branch,
@@ -81,10 +81,13 @@ export declare const OUTCOME_TAG: Readonly<Record<Icon, OutcomeTag>>;
81
81
  export declare const outcomeTag: (e: NotifyEvent) => OutcomeTag;
82
82
  /**
83
83
  * Renders an event into finished HTML text, cut to Telegram's limit.
84
- * Tags are the FIRST line, added before the cut (not after, as before):
85
- * they carry both the human filter and the parser's machine key a card
86
- * cut without them would be not only unclickable but invisible to the
87
- * parser on exactly the longest, meaning the most important, messages.
84
+ *
85
+ * Assembly is TAIL-FIRST (v2.1): the parts that must survive any cutthe
86
+ * tag line (the human filter and the parser's machine key), the pointer
87
+ * block (`Log`/`Check`/`Source`) and the cut marker are measured before
88
+ * the body is clamped, and the body gets what is left. Under the old order
89
+ * the pointer was part of the body, so the longest cards lost exactly the
90
+ * line saying where to look.
88
91
  */
89
92
  export declare const render: (e: NotifyEvent) => string;
90
93
  export {};
package/dist/render.js CHANGED
@@ -51,7 +51,7 @@ export const esc = (v) => String(v ?? '')
51
51
  * Telegram replies `400 can't parse entities`, and we treat a 4xx as a
52
52
  * permanent error and do not retry — the message disappeared for good.
53
53
  */
54
- export const clampMessage = (text, limit = 4000) => {
54
+ export const clampMessage = (text, limit = 4000, marker = '…') => {
55
55
  if (text.length <= limit) {
56
56
  return text;
57
57
  }
@@ -108,7 +108,12 @@ export const clampMessage = (text, limit = 4000) => {
108
108
  .reverse()
109
109
  .map((t) => `</${t}>`)
110
110
  .join('');
111
- return `${body}${tail}\n…`;
111
+ // The marker ANNOUNCES the cut instead of hiding it (v2.1): the caller
112
+ // reserves the marker's length out of `limit` before calling, so the
113
+ // marker itself can never be the thing that pushes the message over
114
+ // Telegram's hard cap — a free addition on top of 4000/4096 plus a long
115
+ // path was measured to earn a 400, and a 4xx is never retried.
116
+ return `${body}${tail}\n${marker}`;
112
117
  };
113
118
  // Only the first line: a field is single-line by contract (commit, branch,
114
119
  // author, a stat), not a place for a paragraph. A live case (18.08): a CI card
@@ -129,6 +134,21 @@ const firstLine = (value) => {
129
134
  * value serializes as `null`, not as a missing key.
130
135
  */
131
136
  const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
137
+ /**
138
+ * A Reason-shaped field: one line stays an ordinary field, several lines
139
+ * become a captioned quote — NOTHING is cut to the first line any more.
140
+ * `field`'s silent `firstLine` on a multi-line reason was the v1 contract,
141
+ * and it gutted every card whose failure did not fit one line (scp retries,
142
+ * a three-line diagnosis): the owner saw `Connection timed…` and nothing
143
+ * else. Confirmed live on four cards in the 14-day sweep, fixed in v2.1.
144
+ */
145
+ const reason = (label, value) => {
146
+ if (value === undefined || value === null || value === '') {
147
+ return null;
148
+ }
149
+ const text = String(value);
150
+ return text.includes('\n') ? quoted(label, text) : field(label, text);
151
+ };
132
152
  /**
133
153
  * An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
134
154
  * one exists, otherwise the plain text of the same field — an identifier
@@ -483,15 +503,15 @@ const mechanism = (workflowName, via) => workflowName ?? via;
483
503
  // unlinked, because a hand deploy has no run to open.
484
504
  const renderDeploy = (e) => {
485
505
  const icon = iconFor(e);
486
- const runUrl = e.workflowUrl ?? e.url;
487
506
  return join([
488
507
  // The name of what shipped the deploy sits on the type line, the outcome
489
508
  // in parens beside it — icon and tag alone were judged, live, not to be
490
509
  // enough: a plain 🔴 next to a workflow name still read as "something
491
510
  // happened," not "it failed," on a screen small enough to lose the color.
492
511
  // The `Via` row is gone: it used to carry this same name one floor below.
493
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), runUrl, e.status === 'fail' ? 'Fail' : 'OK'),
494
- ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [
512
+ // The run URL is gone from this line too it is the `Source:` row now.
513
+ typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
514
+ ...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
495
515
  commitRow(e.commit, e.commitUrl, e.commitTitle),
496
516
  fieldPerson('Author', e.commitAuthor),
497
517
  bodyQuote(e.commitBody)
@@ -517,27 +537,30 @@ const renderJob = (e) => {
517
537
  // icon says and what the third tag now says too. The icon table on the
518
538
  // catalogue page defines both marks.
519
539
  return join([
520
- typeLine(icon, 'Job', e.job, e.workflowUrl ?? e.url, e.aside),
521
- field('Reason', e.note),
540
+ // The URL moved off the type line into the `Source:` row of the pointer
541
+ // block (v2.1, rule S): the owner asked for a pointer he can SEE, and a
542
+ // link riding invisibly on the name is not one.
543
+ typeLine(icon, 'Job', e.job, undefined, e.aside),
544
+ reason('Reason', e.note),
545
+ field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
522
546
  // The timetable is a different subject from this event: how often the task
523
547
  // owes a sign of life and when it last gave one. It stood in a bare run
524
548
  // under `Reason:` and read as more of the same. `Last run` when the task
525
549
  // is alive, `Last seen` when it is not — one timestamp, two questions.
526
550
  ...schedule(e.expected, e.lastSeen, e.status === 'silent' ? 'Last seen' : 'Last run'),
527
551
  ...labelled(e.stats),
552
+ e.detail ? '' : null,
553
+ quoted(e.detailLabel ?? 'Detail', e.detail),
528
554
  hasItems ? '' : null,
529
555
  // Heading ONLY for `disabled`. It used to print for any job carrying a
530
556
  // list, so playhub's daily card of newly published games was headed
531
557
  // "Disabled workflows".
532
558
  disabledList ? group('Disabled workflows') : null,
533
559
  ...(hasItems ? bullets(e.items, disabledList) : []),
534
- e.command || e.logs ? '' : null,
535
- fieldCode('Log', e.logs),
560
+ e.command ? '' : null,
536
561
  ...fieldRun(e.command, e.commandNote)
537
- // No trailing `Workflow:` row. It pointed at `workflowUrl ?? url` the
538
- // exact address line 2 already carries so it was one destination
539
- // written twice, and it stood BELOW the `To do:` command, which is the
540
- // last thing the card is supposed to say.
562
+ // `Log:` left this body for the pointer block that `render` appends to
563
+ // every card the block a cut can never take.
541
564
  ]);
542
565
  };
543
566
  const renderReport = (e) => {
@@ -548,7 +571,7 @@ const renderReport = (e) => {
548
571
  // without a word.
549
572
  const numbers = labelled(e.lines);
550
573
  return join([
551
- typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
574
+ typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
552
575
  // Rows with no group of their own sit flush against the header instead of
553
576
  // forming a separate slab under a blank line. `labelled` puts the blank
554
577
  // line before the first group itself, so there is none here.
@@ -559,9 +582,8 @@ const renderReport = (e) => {
559
582
  }
560
583
  const items = bullets(e.items, false);
561
584
  return join([
562
- // Both analytics jobs send a link to the day's snapshot in docs/. It used to
563
- // hang off a trailing `Details: open` row; now it is the report's own name.
564
- typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
585
+ // The day's snapshot link is the `Source:` row now, same as every URL.
586
+ typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
565
587
  // Flush against the header — see the branch above.
566
588
  ...labelled(e.lines),
567
589
  items.length > 0 ? '' : null,
@@ -575,9 +597,9 @@ const renderReport = (e) => {
575
597
  // spoke (`nightly`, `Quality`) — but both are the name on the type line.
576
598
  const renderCi = (e) => {
577
599
  const icon = iconFor(e);
578
- const runUrl = e.workflowUrl ?? e.url;
579
600
  return join([
580
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined), runUrl, e.status === 'fail' ? 'Fail' : 'OK'),
601
+ // The run URL is the `Source:` row now, not an invisible link on the name.
602
+ typeLine(icon, 'CI', mechanism(e.workflowName, undefined), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
581
603
  // `Actor` used to be read as "who wrote the commit," and on most runs it
582
604
  // is — `github.actor` for a push IS the person who pushed. It stops being
583
605
  // that on a scheduled run: arvent's nightly rewrites it to whoever is on
@@ -585,7 +607,7 @@ const renderCi = (e) => {
585
607
  // author. So Actor answers "who is responsible for this run," `Author`
586
608
  // below the commit answers "who wrote this code" — two different people
587
609
  // on a nightly card, the same person everywhere else.
588
- ...twoBlocks([field('Reason', e.note)], [
610
+ ...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
589
611
  fieldTelegram('Actor', e.actor),
590
612
  commitRow(e.commit, e.commitUrl, e.commitTitle),
591
613
  fieldPerson('Author', e.commitAuthor),
@@ -622,21 +644,24 @@ const opening = (action, body) => action === 'opened' || VERDICT.has(action) ? b
622
644
  // old order, title then Author then Assignee then finally the body — "why
623
645
  // does the assignee cut apart what should be inseparable?"
624
646
  const renderPr = (e) => join([
625
- typeLine(iconFor(e), 'PR', named(e.number, e.title), e.url),
647
+ typeLine(iconFor(e), 'PR', named(e.number, e.title)),
626
648
  opening(e.action, e.body),
627
649
  (e.action === 'opened' || VERDICT.has(e.action)) && e.body ? '' : null,
628
650
  fieldPerson('Author', e.author),
629
651
  fieldPerson('Reviewer', e.reviewer)
630
652
  ]);
631
- // Unlike a PR, an issue's `body` means one thing on every action its own
632
- // description, never someone else's verdict so it prints whenever it is
633
- // there, not only on `opened`. The owner: hiding it on `assigned` read as
634
- // inconsistent with the same card type showing it moments earlier his call
635
- // to keep, 26.08.2026, over the earlier "he already saw it" reasoning.
653
+ // The issue's body prints ONLY on `opened`the same law the PR card
654
+ // follows. History of this line: hidden shown everywhere (the owner's call
655
+ // of 26.08.2026, "inconsistent with the same card showing it moments
656
+ // earlier") hidden again on 31.08.2026, when the owner, shown the live
657
+ // duplicate ("the full body arrives a second time in one day"), delegated
658
+ // the call ("сделай как лучше по оптимизации и простоте") and approved the
659
+ // short assigned card in the v2.1 mockups. On assigned/closed the card is
660
+ // the one new fact plus the `Source:` link to the full text.
636
661
  const renderIssue = (e) => join([
637
- typeLine(iconFor(e), 'Issue', named(e.number, e.title), e.url),
638
- bodyQuote(e.body),
639
- e.body ? '' : null,
662
+ typeLine(iconFor(e), 'Issue', named(e.number, e.title)),
663
+ e.action === 'opened' ? bodyQuote(e.body) : null,
664
+ e.action === 'opened' && e.body ? '' : null,
640
665
  fieldPerson('Author', e.author),
641
666
  fieldPerson('Assignee', e.assignee)
642
667
  ]);
@@ -651,12 +676,12 @@ const renderIssue = (e) => join([
651
676
  const renderIncident = (e) => {
652
677
  const findings = bullets(e.items, false);
653
678
  return join([
654
- typeLine(iconFor(e), 'Incident', e.title, e.url),
679
+ typeLine(iconFor(e), 'Incident', e.title),
655
680
  e.detail && e.detail !== e.title ? note(e.detail) : null,
681
+ field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
656
682
  findings.length > 0 ? '' : null,
657
- ...findings,
658
- e.logs ? '' : null,
659
- fieldCode('Log', e.logs)
683
+ ...findings
684
+ // `Log:` moved to the pointer block `render` appends — see renderJob.
660
685
  ]);
661
686
  };
662
687
  // A session in trouble. Same law as every other card: identifier first, then
@@ -673,7 +698,8 @@ const renderSession = (e) => join([
673
698
  // opened a block that had no heading. Facts about the session touch the
674
699
  // line that names it, the way they do on every job card.
675
700
  field('Project', e.workdir),
676
- field('Reason', e.reason),
701
+ reason('Reason', e.reason),
702
+ field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
677
703
  e.opened ? '' : null,
678
704
  quoted('Opened with', e.opened),
679
705
  e.command ? '' : null,
@@ -798,12 +824,62 @@ export const OUTCOME_TAG = {
798
824
  };
799
825
  export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
800
826
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
827
+ /**
828
+ * Rule S (v2.1): the card's last block says where to verify it. `Source:` is
829
+ * a hyperlink when the event has a canonical URL; `Check:` is a local
830
+ * command; `Log:` is a path and only ever an ADDITION — a path cannot be
831
+ * tapped, only copied. The link text is a short English noun naming what
832
+ * opens, never the click.
833
+ */
834
+ const SOURCE_NAME = {
835
+ deploy: 'workflow run',
836
+ ci: 'workflow run',
837
+ job: 'workflow run',
838
+ report: 'report',
839
+ pr: 'pull request',
840
+ issue: 'issue',
841
+ incident: 'details',
842
+ session: 'details',
843
+ heartbeat_miss: 'details'
844
+ };
845
+ const sourceUrl = (e) => {
846
+ const wf = 'workflowUrl' in e ? e.workflowUrl : undefined;
847
+ const url = 'url' in e ? e.url : undefined;
848
+ return wf ?? url;
849
+ };
850
+ const pointerBlock = (e) => {
851
+ const url = sourceUrl(e);
852
+ const logs = 'logs' in e ? e.logs : undefined;
853
+ const rows = [
854
+ fieldCode('Log', logs),
855
+ fieldCode('Check', e.check),
856
+ url ? `<b>Source:</b> <a href="${esc(url)}">${esc(SOURCE_NAME[e.type] ?? 'source')}</a>` : null
857
+ ].filter((r) => r !== null);
858
+ return rows.length > 0 ? `\n\n${rows.join('\n')}` : '';
859
+ };
860
+ /**
861
+ * The line that stands in for what the cut removed. It rides INSIDE the
862
+ * budget (the caller subtracts its length before clamping), so announcing
863
+ * the cut can never itself overflow the limit — with an attachment the
864
+ * limit is 1024, and the old flat 40-character margin did not fit a marker
865
+ * plus a path.
866
+ */
867
+ const cutMarker = (e) => {
868
+ if (e.path) {
869
+ return '⋯ cut, full text attached';
870
+ }
871
+ const logs = 'logs' in e ? e.logs : undefined;
872
+ return logs ? `⋯ cut, full: <code>${esc(logs)}</code>` : '⋯ cut';
873
+ };
801
874
  /**
802
875
  * Renders an event into finished HTML text, cut to Telegram's limit.
803
- * Tags are the FIRST line, added before the cut (not after, as before):
804
- * they carry both the human filter and the parser's machine key a card
805
- * cut without them would be not only unclickable but invisible to the
806
- * parser on exactly the longest, meaning the most important, messages.
876
+ *
877
+ * Assembly is TAIL-FIRST (v2.1): the parts that must survive any cutthe
878
+ * tag line (the human filter and the parser's machine key), the pointer
879
+ * block (`Log`/`Check`/`Source`) and the cut marker are measured before
880
+ * the body is clamped, and the body gets what is left. Under the old order
881
+ * the pointer was part of the body, so the longest cards lost exactly the
882
+ * line saying where to look.
807
883
  */
808
884
  export const render = (e) => {
809
885
  const renderer = RENDERERS[e.type];
@@ -814,11 +890,14 @@ export const render = (e) => {
814
890
  throw new Error(`unknown event type: ${String(e.type)}`);
815
891
  }
816
892
  const tags = tagsLine(e);
817
- // clampMessage can go past the passed limit for the tail of closing tags
818
- // and the ellipsis — minus 40 leaves it that margin. Messages already have
819
- // their own margin (4000 against Telegram's 4096); for a caption the 1024
820
- // limit is the real one. A card with an attachment is a caption, so the
821
- // budget is chosen by `path`.
822
- const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
823
- return `${tags}\n${clampMessage(renderer(e), budget)}`;
893
+ const pointer = pointerBlock(e);
894
+ const marker = cutMarker(e);
895
+ // clampMessage can go past the passed limit for the tail of closing tags —
896
+ // minus 40 leaves it that margin. Messages already have their own margin
897
+ // (4000 against Telegram's 4096); for a caption the 1024 limit is the real
898
+ // one. A card with an attachment is a caption, so the budget is chosen by
899
+ // `path`.
900
+ const limit = e.path ? 1024 : 4000;
901
+ const budget = Math.max(64, limit - tags.length - pointer.length - marker.length - 42);
902
+ return `${tags}\n${clampMessage(renderer(e), budget, marker)}${pointer}`;
824
903
  };
package/dist/send.d.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  import type { NotifyEvent } from './events.ts';
2
2
  export type SendResult = 'sent' | 'skipped' | 'failed';
3
+ /** Exported for tests only — the time is injectable so day counting is provable. */
4
+ export declare const dedupe: (e: NotifyEvent, now?: number) => {
5
+ action: "send" | "suppress";
6
+ stillRed?: number;
7
+ };
8
+ /** Exported for tests: the watchdog's own card must provably pass the lint. */
9
+ export declare const brokenCardEvent: (e: NotifyEvent, faults: string[], offenderHtml: string) => NotifyEvent;
3
10
  export declare const notify: (e: NotifyEvent) => Promise<SendResult>;
package/dist/send.js CHANGED
@@ -15,9 +15,10 @@
15
15
  * allowed to bring down the deploy or the scheduled task that called it.
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
- import { readFileSync } from 'node:fs';
19
- import { basename } from 'node:path';
20
- import { render } from "./render.js";
18
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { basename, dirname, join } from 'node:path';
21
+ import { eventKey, outcomeTag, render } from "./render.js";
21
22
  import { lintCard } from "./lint.js";
22
23
  import { ROUTES, targets } from "./routes.js";
23
24
  const log = (msg) => {
@@ -257,6 +258,99 @@ const sendFile = async (e) => {
257
258
  }
258
259
  return results.includes('sent') ? 'sent' : 'failed';
259
260
  };
261
+ /**
262
+ * Repeat suppression (v2.1). A failure that is still the same failure does
263
+ * not resend every run: the first card goes out, repeats inside the window
264
+ * are swallowed, and past the window ONE card a day goes out carrying
265
+ * `Still red: day N`. A green outcome clears the record — without that, a
266
+ * new failure would inherit the old one's day counter and the recovery
267
+ * itself would never be told.
268
+ *
269
+ * The key is project + type + instance — DELIBERATELY no free text: both
270
+ * external reviews independently showed that normalizing prose (stripping
271
+ * digits, hexes, paths) merges different failures — "connect to host A" and
272
+ * "connect to host B" become one key and the second failure goes silent. A
273
+ * job that covers several targets owes each target its own `--key`.
274
+ *
275
+ * Every failure of the mechanism itself fails OPEN: a broken state file, a
276
+ * held lock, an unwritable directory all mean "send". A duplicate card is a
277
+ * small cost; a swallowed alarm is not.
278
+ */
279
+ const WINDOW_MS = 20 * 3600_000;
280
+ const DAY_MS = 24 * 3600_000;
281
+ const statePath = () => process.env.NOTIFY_STATE?.trim() || join(homedir(), '.claude', '.runs', 'notify-sent.json');
282
+ /** Exported for tests only — the time is injectable so day counting is provable. */
283
+ export const dedupe = (e, now = Date.now()) => {
284
+ const file = statePath();
285
+ const lock = `${file}.lock`;
286
+ try {
287
+ mkdirSync(dirname(file), { recursive: true });
288
+ // The lock is a directory: mkdir is atomic on every filesystem this runs
289
+ // on. A held lock means another sender is mid-write — fail open.
290
+ mkdirSync(lock);
291
+ }
292
+ catch {
293
+ return { action: 'send' };
294
+ }
295
+ try {
296
+ let state = {};
297
+ try {
298
+ state = JSON.parse(readFileSync(file, 'utf-8'));
299
+ }
300
+ catch {
301
+ state = {}; // missing or broken JSON — start clean, never swallow
302
+ }
303
+ const key = `${String(e.project)}:${String(e.type)}:${eventKey(e)}`;
304
+ const rec = state[key];
305
+ const write = () => {
306
+ const tmp = `${file}.tmp`;
307
+ writeFileSync(tmp, JSON.stringify(state));
308
+ renameSync(tmp, file);
309
+ };
310
+ if (outcomeTag(e) !== 'fail') {
311
+ if (rec) {
312
+ delete state[key];
313
+ write();
314
+ }
315
+ return { action: 'send' };
316
+ }
317
+ if (!rec) {
318
+ state[key] = { first: new Date(now).toISOString(), last: new Date(now).toISOString(), count: 0 };
319
+ write();
320
+ return { action: 'send' };
321
+ }
322
+ const last = Date.parse(rec.last);
323
+ const first = Date.parse(rec.first);
324
+ if (Number.isNaN(last) || Number.isNaN(first)) {
325
+ delete state[key];
326
+ write();
327
+ return { action: 'send' };
328
+ }
329
+ if (now - last < WINDOW_MS) {
330
+ rec.count += 1;
331
+ write();
332
+ log(`suppressed: same failure "${key}" already reported ${rec.count} time(s) in the window`);
333
+ return { action: 'suppress' };
334
+ }
335
+ rec.last = new Date(now).toISOString();
336
+ write();
337
+ // Day 1 is the day the first card went out; the counter only appears
338
+ // from day 2 on — "Still red: day 1" would restate the card itself.
339
+ const day = Math.floor((now - first) / DAY_MS) + 1;
340
+ return { action: 'send', ...(day >= 2 ? { stillRed: day } : {}) };
341
+ }
342
+ catch {
343
+ return { action: 'send' };
344
+ }
345
+ finally {
346
+ try {
347
+ rmdirSync(lock);
348
+ }
349
+ catch {
350
+ // the lock directory is gone or never ours — nothing to release
351
+ }
352
+ }
353
+ };
260
354
  /**
261
355
  * Sends the event to all of its targets (the project topic, plus
262
356
  * `incidents` if needed, plus the team chat). Targets are handled one after
@@ -281,6 +375,7 @@ const reportLostProject = async (project, kind) => {
281
375
  job: 'notify: an event was lost',
282
376
  status: 'fail',
283
377
  note: `project "${String(project)}" is not in ROUTES — event "${kind}" went nowhere`,
378
+ check: 'npx --yes @mikitasazan/notify routes --json',
284
379
  key: 'notify-unknown-project'
285
380
  };
286
381
  await deliver(targets(lost), render(lost)).catch(() => undefined);
@@ -290,21 +385,76 @@ const reportLostProject = async (project, kind) => {
290
385
  * worth losing over its own formatting — and the breach is raised as its own
291
386
  * red card, the way a lost project is.
292
387
  *
388
+ * The watchdog card names the offender: the first two content lines of the
389
+ * card it complains about, quoted verbatim under `Offender:` so the owner
390
+ * can find it in the feed. Its `Check:` is a real command (the mockups'
391
+ * `lint-text < card.txt` pointed at a file the owner does not have — a fake
392
+ * pointer is worse than none), and the offender's full text goes to the
393
+ * failure log the session start already reads.
394
+ *
293
395
  * `key` carries the type, so a renderer that starts producing broken deploy
294
- * cards raises one running complaint rather than a new one every hour.
295
- * Recursion is not possible: this card is not linted.
396
+ * cards raises one running complaint rather than a new one every hour — and
397
+ * the card passes through the same `dedupe` as any failure, so it cannot
398
+ * loop daily on one unfixed offender.
399
+ *
400
+ * The watchdog lints ITSELF (v2.1 — the v1 card failed its own lint and
401
+ * nothing noticed). If its own card is at fault it still goes out, and the
402
+ * breach lands in the failure log: silence is the one thing it may not do.
296
403
  */
297
- const reportBrokenCard = async (e, faults) => {
298
- log(`card does not match the standard: ${faults.join('; ')}`);
299
- const broken = {
404
+ const failuresLog = () => process.env.NOTIFY_FAILLOG?.trim() || join(homedir(), '.claude', '.runs', 'notify-fail.failures.log');
405
+ const journal = (line) => {
406
+ try {
407
+ const file = failuresLog();
408
+ mkdirSync(dirname(file), { recursive: true });
409
+ appendFileSync(file, `${new Date().toISOString()} ${line}\n`);
410
+ }
411
+ catch {
412
+ // the journal is best-effort: a card must never be lost to bookkeeping
413
+ }
414
+ };
415
+ /** Exported for tests: the watchdog's own card must provably pass the lint. */
416
+ export const brokenCardEvent = (e, faults, offenderHtml) => {
417
+ const offender = offenderHtml
418
+ .split('\n')
419
+ .slice(1) // the tag line names nothing a human reads
420
+ .map((r) => r.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim())
421
+ .filter((r) => r !== '')
422
+ .slice(0, 2)
423
+ .join('\n');
424
+ return {
300
425
  type: 'job',
301
426
  project: 'mac-config',
302
427
  job: 'notify: a card broke the standard',
303
428
  status: 'fail',
304
- note: `${String(e.type)} card for ${String(e.project)}: ${faults.join('; ')}`,
429
+ // The faults go in the LIST, not in Reason: a fault message quotes the
430
+ // offending text verbatim ("the tag #аналитика carries…"), and quoted
431
+ // text inside a system field is exactly what rule L forbids — the
432
+ // watchdog was failing its own lint on any Russian-speaking offender
433
+ // (caught live by its own journal, 31.08.2026).
434
+ note: `a ${String(e.type)} card for ${String(e.project)} broke ${faults.length} rule(s)`,
435
+ items: faults.map((f) => ({ text: f, group: 'Faults' })),
436
+ detail: offender || undefined,
437
+ detailLabel: 'Offender',
438
+ check: 'config jobs --log notify-broken',
305
439
  key: `notify-broken-${String(e.type)}`
306
440
  };
307
- await deliver(targets(broken), render(broken)).catch(() => undefined);
441
+ };
442
+ const reportBrokenCard = async (e, faults, offenderHtml) => {
443
+ log(`card does not match the standard: ${faults.join('; ')}`);
444
+ const broken = brokenCardEvent(e, faults, offenderHtml);
445
+ const dup = dedupe(broken);
446
+ if (dup.action === 'suppress') {
447
+ return;
448
+ }
449
+ if (dup.stillRed) {
450
+ broken.stillRed = dup.stillRed;
451
+ }
452
+ const html = render(broken);
453
+ const ownFaults = lintCard(html);
454
+ if (ownFaults.length > 0) {
455
+ journal(`notify-watchdog: own card failed lint: ${ownFaults.join('; ')}`);
456
+ }
457
+ await deliver(targets(broken), html).catch(() => undefined);
308
458
  };
309
459
  export const notify = async (e) => {
310
460
  // Object.hasOwn, not `in`: `in` walks the prototype chain, and
@@ -313,6 +463,17 @@ export const notify = async (e) => {
313
463
  await reportLostProject(e.project, String(e.type));
314
464
  return 'skipped';
315
465
  }
466
+ // The same unresolved failure does not resend: the window swallows it, one
467
+ // card a day carries `Still red: day N`. Suppression reports 'sent' to the
468
+ // caller — the words sent/failed/skipped are a watchdog contract about the
469
+ // delivery PIPELINE, and a deliberate swallow is the pipeline working.
470
+ const dup = dedupe(e);
471
+ if (dup.action === 'suppress') {
472
+ return 'sent';
473
+ }
474
+ if (dup.stillRed) {
475
+ e.stillRed = dup.stillRed;
476
+ }
316
477
  // A card with a file becomes the caption of that file — one card, not two.
317
478
  if (e.path) {
318
479
  return sendFile(e);
@@ -323,7 +484,7 @@ export const notify = async (e) => {
323
484
  const result = await deliver(targets(e), html);
324
485
  const faults = lintCard(html);
325
486
  if (faults.length > 0) {
326
- await reportBrokenCard(e, faults);
487
+ await reportBrokenCard(e, faults, html);
327
488
  }
328
489
  return result;
329
490
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",