@mikitasazan/notify 1.11.0 → 1.12.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
@@ -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-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) {
@@ -103,7 +122,13 @@ const num = (key) => {
103
122
  return n;
104
123
  };
105
124
  /**
106
- * `--item "text"` or `--item "text|https://link"`.
125
+ * `--item "text"`, `--item "text|https://link"`, or, since 26.08.2026,
126
+ * `--item "LABEL::text"` (and `LABEL::text|https://link`) for a bold label
127
+ * in front of the row — the same shape a search query's `facts` render,
128
+ * so a list of independent findings (vault's BAD/STALE/DIVERGED lines) does
129
+ * not have to smuggle its own heading inside the text. `::` and not `|`,
130
+ * which the link already owns, and not `:`, which shows up inside real
131
+ * findings ("STALE IN ARCHIVE: ssh-keys.tar.gz.age" already has one).
107
132
  *
108
133
  * An item's group name cannot be passed the same way `--stat` does it: the
109
134
  * bar here is already taken by the link. So `--item-group "Red checks"` is
@@ -114,9 +139,12 @@ const num = (key) => {
114
139
  const items = () => {
115
140
  const name = flags.get('item-group')?.[0];
116
141
  return (flags.get('item') ?? []).map((raw) => {
117
- const idx = raw.lastIndexOf('|');
118
- const base = idx === -1 ? { text: raw } : { text: raw.slice(0, idx), url: raw.slice(idx + 1) };
119
- return name ? { ...base, group: name } : base;
142
+ const labelEnd = raw.indexOf('::');
143
+ const label = labelEnd === -1 ? undefined : raw.slice(0, labelEnd);
144
+ const rest = labelEnd === -1 ? raw : raw.slice(labelEnd + 2);
145
+ const idx = rest.lastIndexOf('|');
146
+ const base = idx === -1 ? { text: rest } : { text: rest.slice(0, idx), url: rest.slice(idx + 1) };
147
+ return { ...base, ...(label ? { label } : {}), ...(name ? { group: name } : {}) };
120
148
  });
121
149
  };
122
150
  /**
@@ -228,6 +256,7 @@ else {
228
256
  commitUrl: one('commit-url'),
229
257
  commitTitle: one('commit-title'),
230
258
  commitBody: one('commit-body'),
259
+ commitAuthor: one('commit-author'),
231
260
  workflowUrl: one('workflow-url'),
232
261
  workflowName: one('workflow-name'),
233
262
  url: one('url'),
@@ -251,6 +280,8 @@ else {
251
280
  command: one('command'),
252
281
  commandNote: one('command-note'),
253
282
  logs: one('logs'),
283
+ detail: one('detail'),
284
+ detailLabel: one('detail-label'),
254
285
  workflowUrl: one('workflow-url'),
255
286
  workflowName: one('workflow-name'),
256
287
  url: one('url')
@@ -279,6 +310,7 @@ else {
279
310
  commitUrl: one('commit-url'),
280
311
  commitTitle: one('commit-title'),
281
312
  commitBody: one('commit-body'),
313
+ commitAuthor: one('commit-author'),
282
314
  actor: one('actor'),
283
315
  note: one('note'),
284
316
  workflowUrl: one('workflow-url'),
@@ -333,6 +365,7 @@ else {
333
365
  project: project(),
334
366
  title: one('title') ?? '(no title)',
335
367
  detail: one('detail'),
368
+ items: items(),
336
369
  logs: one('logs'),
337
370
  url: one('url')
338
371
  };
@@ -357,8 +390,10 @@ else {
357
390
  type: 'report',
358
391
  project: project(),
359
392
  title: one('title') ?? '(no title)',
360
- // A file card has no period; the caption is its title.
361
- lines: []
393
+ aside: one('aside') ?? one('period'),
394
+ lines: pairs('line'),
395
+ items: items(),
396
+ url: one('url')
362
397
  };
363
398
  break;
364
399
  default:
@@ -376,6 +411,9 @@ if (event) {
376
411
  // --path is applicable to any type too, for the same reason --key is.
377
412
  event.path = one('path') ?? event.path;
378
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;
379
417
  }
380
418
  if (event?.filename && !event.path) {
381
419
  parseErrors.push('--filename: given without --path, so there is no file to name');
package/dist/events.d.ts CHANGED
@@ -12,9 +12,10 @@
12
12
  */
13
13
  export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault' | 'mac-config' | 'alitools';
14
14
  /**
15
- * A stable machine key for the task — the last line of every card, shaped
16
- * as `#key` (with no project name: the card already sits in its own
17
- * project's forum — `targets()` never sends it to someone else's). The
15
+ * A stable machine key for the task — the instance tag on the FIRST line of
16
+ * every card, shaped as `#key` (with no project name: the card already sits
17
+ * in its own project's forum — `targets()` never sends it to someone else's).
18
+ * The
18
19
  * daily parser uses it to check "is this 🔴 already closed by a later card
19
20
  * with the same key?" without comparing human wording, which changes. It
20
21
  * is optional: without it, the key is derived from the type and the title
@@ -36,6 +37,18 @@ type Keyed = {
36
37
  path?: string;
37
38
  /** The file's name in the chat; defaults to the name from `path`. */
38
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;
39
52
  };
40
53
  /**
41
54
  * A list item inside a message: a task from a digest, a failed check, a
@@ -57,11 +70,19 @@ type Keyed = {
57
70
  * 🆕 came out today, 🔁 came out of the queue, ⚠ did not come out at all.
58
71
  * The icon was doing a heading's job.
59
72
  */
73
+ /**
74
+ * `facts` — sub-rows under an item, indented, each `label: value`. For an
75
+ * item whose own value is not one number but several (a search query with
76
+ * its own clicks AND position) — a nested list, not a sentence stuffed into
77
+ * `text`: "0 clicks, pos. 55" was two facts hand-joined into a string, the
78
+ * exact shape that gets pulled apart into its own field everywhere else.
79
+ */
60
80
  export type Item = {
61
81
  text: string;
62
82
  url?: string;
63
83
  label?: string;
64
84
  group?: string;
85
+ facts?: Array<[label: string, value: string | number]>;
65
86
  };
66
87
  export type NotifyEvent = Keyed & (
67
88
  /** Shipping code to the server. */
@@ -72,12 +93,14 @@ export type NotifyEvent = Keyed & (
72
93
  commit?: string;
73
94
  /** A link to the commit — the "commit" row becomes clickable. */
74
95
  commitUrl?: string;
75
- /** The commit's title — renders as the `Title:` field, the body follows as a quote. */
96
+ /** The commit's title — joins the `Commit:` row after the hash, the body follows as a quote. */
76
97
  commitTitle?: string;
77
98
  /** The commit's body, if there is one — the same quote shape as the title. */
78
99
  commitBody?: string;
100
+ /** The commit's author (GitHub login) — the `Author:` row links to their profile. */
101
+ commitAuthor?: string;
79
102
  workflowUrl?: string;
80
- /** The run's name, for the link's visible text (defaults to `open`). */
103
+ /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
81
104
  workflowName?: string;
82
105
  url?: string;
83
106
  /**
@@ -161,10 +184,22 @@ export type NotifyEvent = Keyed & (
161
184
  * A local log path — monospaced, not a link, same as on an incident.
162
185
  * It used to be glued onto the end of the reason sentence behind a
163
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.
164
189
  */
165
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;
166
201
  workflowUrl?: string;
167
- /** The run's name, for the link's visible text (defaults to `open`). */
202
+ /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
168
203
  workflowName?: string;
169
204
  /**
170
205
  * A fallback name for the run link: half the senders send it as
@@ -185,19 +220,25 @@ export type NotifyEvent = Keyed & (
185
220
  * accepts `--period` for it.
186
221
  */
187
222
  aside?: string;
188
- /** Empty/not passed when `groups` is used — the two kinds of report are not mixed in one event. */
223
+ /**
224
+ * Renders even when `groups` is also set — a report's headline numbers
225
+ * (Pages, People, Impressions…) sit above the grouped section, not
226
+ * replaced by it. `lines` and `groups` answer different questions:
227
+ * "what changed" and "what's in each list".
228
+ */
189
229
  lines?: Array<[label: string, value: string | number, group?: string]>;
190
230
  /**
191
231
  * A list of items with links — for task digests, where the value is
192
232
  * in the names themselves, not in a number. Renders as a separate
193
- * block after `lines`.
233
+ * block after `lines`. Ignored (not merged, not an error) when `groups`
234
+ * is also set — no live sender sets both today.
194
235
  */
195
236
  items?: Item[];
196
237
  /**
197
238
  * Named groups (a task board: Ready/In Progress/Not on the board;
198
- * analytics: Metrics/Links) — each with its own heading and list of
199
- * items. Replaces `lines`/`items` when set: different reports use
200
- * either the flat form or groups, never both at once.
239
+ * analytics: Top search queries) — each with its own heading and list
240
+ * of items. Replaces `items` when set, but `lines` still renders above
241
+ * it see that field's own doc.
201
242
  */
202
243
  groups?: Array<{
203
244
  name: string;
@@ -214,10 +255,12 @@ export type NotifyEvent = Keyed & (
214
255
  commit?: string;
215
256
  /** A link to the commit — the hash becomes clickable. */
216
257
  commitUrl?: string;
217
- /** The commit's title (subject) — a separate `Title:` field, not a quote. */
258
+ /** The commit's title (subject) — joins the `Commit:` row after the hash, not a quote. */
218
259
  commitTitle?: string;
219
260
  /** The commit's body (after the subject) — the same quote shape as the title. */
220
261
  commitBody?: string;
262
+ /** The commit's author (GitHub login) — the `Author:` row links to their profile. Distinct from `actor`: on a scheduled run `actor` is whoever is on duty to fix it, not who wrote the code. */
263
+ commitAuthor?: string;
221
264
  actor?: string;
222
265
  /**
223
266
  * Why this run happened, when there is no commit to point at: a nightly
@@ -226,7 +269,7 @@ export type NotifyEvent = Keyed & (
226
269
  note?: string;
227
270
  /** A link to the run (workflow run) — separate from `url`, a fallback for `workflowUrl`. */
228
271
  workflowUrl?: string;
229
- /** The run's name, for the link's visible text (defaults to `open`). */
272
+ /** The run's name, for the link's visible text. Without it, the type line itself becomes the link. */
230
273
  workflowName?: string;
231
274
  url?: string;
232
275
  }
@@ -242,7 +285,12 @@ export type NotifyEvent = Keyed & (
242
285
  action: 'opened' | 'approved' | 'changes_requested' | 'merged' | 'closed';
243
286
  number: number;
244
287
  title: string;
245
- /** PR description — quoted on its own; the title is the `Title:` field above it. */
288
+ /**
289
+ * On `opened`: the PR's own description. On `approved`/
290
+ * `changes_requested`: the reviewer's comment, not the PR's
291
+ * description again — quoted on its own either way; the title joins
292
+ * the `PR:` type line above it, there is no separate field for it.
293
+ */
246
294
  body?: string;
247
295
  author?: string;
248
296
  reviewer?: string;
@@ -255,7 +303,7 @@ export type NotifyEvent = Keyed & (
255
303
  action: 'opened' | 'assigned' | 'closed';
256
304
  number: number;
257
305
  title: string;
258
- /** The issue's body — a quote under the `Title:` field, separate from the title. */
306
+ /** The issue's body — a quote under the `Issue:` type line, which already carries the title. */
259
307
  body?: string;
260
308
  author?: string;
261
309
  assignee?: string;
@@ -266,7 +314,16 @@ export type NotifyEvent = Keyed & (
266
314
  type: 'incident';
267
315
  project: Project;
268
316
  title: string;
317
+ /** One free-form paragraph — a diagnosis that is genuinely one thought, not several findings glued by newlines. */
269
318
  detail?: string;
319
+ /**
320
+ * Several INDEPENDENT findings (a self-check emitting up to three
321
+ * unrelated diagnostic lines, each starting with its own marker word
322
+ * — `BAD`, `STALE`, `DIVERGED`) go here, not into `detail`: a list
323
+ * squeezed into one blockquote read as a wall of text with no
324
+ * category, the marker words doing a label's job inside a value.
325
+ */
326
+ items?: Item[];
270
327
  /** A local path to the logs (not a URL — renders monospaced, to copy, not to click). */
271
328
  logs?: string;
272
329
  url?: string;
@@ -287,7 +344,7 @@ export type NotifyEvent = Keyed & (
287
344
  project: Project;
288
345
  /** What happened, as the second line reads it: `Session: burning the limit`. */
289
346
  action: string;
290
- /** The session's own id — the identifier field, first, as everywhere else. */
347
+ /** 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`. */
291
348
  id?: string;
292
349
  /** Working directory name, when several sessions opened with a similar line. */
293
350
  workdir?: string;
@@ -320,7 +377,7 @@ export type NotifyEvent = Keyed & (
320
377
  expected?: string;
321
378
  /** The task reported in again — same type, a green card instead of a red one, the key (for matching) does not change. */
322
379
  recovered?: boolean;
323
- /** A ready-made reason sentence; without it, one is built from lastSeen/expected. */
380
+ /** A ready-made reason sentence. Without it there is no `Reason:` row at all — `lastSeen`/`expected` still print, under their own `Schedule` group. */
324
381
  note?: string;
325
382
  });
326
383
  export type EventType = NotifyEvent['type'];
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 {};