omp-conductor 0.20.0 → 0.20.2

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/src/diff-flags.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  * repository.
38
38
  */
39
39
 
40
- import type { FileLane, PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
40
+ import type { FileLane, PrDiff, PrDiffFile, SettlementFlag, SettlementFlagKind } from "./types.ts";
41
41
 
42
42
  // ------------------------------------------------------------------ diff parse
43
43
 
@@ -438,20 +438,125 @@ const ASSERTION =
438
438
  /^(?:await\s+)?(?:expect|assert|assert_[a-z_]+|assertEquals?|assertTrue|assertFalse|assertThat|assertRaises|assertRaisesRegex|self\.assert[A-Za-z]*|should|chai\.|t\.(?:Error|Fatal)f?|require\.[A-Z][A-Za-z]*|Expect)\s*[.(]/;
439
439
 
440
440
  /**
441
- * A named timeout and its value. Only a *raised* one is a finding — a brand-new
442
- * timeout on a new test is not a weakening — so the value is compared against
443
- * the same key on the pre-image side and silence is the answer whenever the key
444
- * appears on only one side.
441
+ * A named timeout and its value, but only on the test runner's own timeout
442
+ * surface ({@link onRunnerSurface}) a matching key handed to the code under
443
+ * test is a domain parameter, not a runner deadline (#1062). Only a *raised*
444
+ * one is a finding — a brand-new timeout on a new test is not a weakening — so
445
+ * the value is compared against the same key on the pre-image side and silence
446
+ * is the answer whenever the key appears on only one side.
445
447
  */
446
448
  const TIMEOUT =
447
- /\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*[:=(]\s*(\d[\d_]*)/gi;
449
+ /\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*([:=(])\s*(\d[\d_]*)/gi;
450
+
451
+ /** Identifiers that name the test runner itself when a timeout-shaped key is
452
+ * called on or passed to them. `t` is the test context (vitest, node:test, a
453
+ * Go `*testing.T` helper); `pytest` names the Python runner module. */
454
+ const RUNNER_BINDINGS: Record<string, true> = {
455
+ test: true,
456
+ it: true,
457
+ describe: true,
458
+ context: true,
459
+ suite: true,
460
+ bench: true,
461
+ jest: true,
462
+ t: true,
463
+ pytest: true,
464
+ };
465
+
466
+ /**
467
+ * Whether a {@link TIMEOUT} match sits on the runner's own timeout surface of
468
+ * its line, as opposed to an argument handed to the code under test.
469
+ *
470
+ * The audit sees hunks, not files, so the judgement is structural on the
471
+ * changed line alone, and anything it cannot vouch for stays silent: a flag is
472
+ * advisory, and a false positive costs the trust in every flag after it
473
+ * (measured: #614/#896 read a raised `timeoutMs` passed to `arm(...)` — the
474
+ * challenge window of the code under test — as a weakened test).
475
+ *
476
+ * Two positions qualify, both on the runner's own call:
477
+ *
478
+ * - the key is called on the runner — `jest.setTimeout(...)`,
479
+ * `t.timeout(...)`, `test.setTimeout(...)`, `pytest.mark.timeout(...)` —
480
+ * the dotted receiver before the key starts with a runner binding;
481
+ * - the key is a property of an object literal that is a direct argument of a
482
+ * runner call — the trailing per-test configuration: `test("x", fn, {
483
+ * timeout: 45_000 })`, `test.use({ retries: 3 })`,
484
+ * `describe.configure({ retries: 3 })`.
485
+ *
486
+ * Everything else is the code under test: `await arm({ timeoutMs: 60_000 })`,
487
+ * `const opts = { timeout: 45000 }`, `server.setTimeout(30_000)`. A shape
488
+ * whose decisive frame is on another line — `}, { timeout: 45000 });` after a
489
+ * multi-line `test(` — cannot be vouched for from the line alone and also
490
+ * stays silent.
491
+ */
492
+ function onRunnerSurface(code: string, match: RegExpMatchArray): boolean {
493
+ const key = match[1]?.toLowerCase() ?? "";
494
+ if (RUNNER_BINDINGS[key.split(".")[0] ?? ""] === true) return true;
495
+
496
+ const before = code.slice(0, match.index);
497
+ // The dotted receiver the key is called on: `t.timeout(5000)` reads `t.`,
498
+ // `pytest.mark.timeout(500)` reads `pytest.mark.`. No receiver — a bare
499
+ // `timeout: 5000` property — falls through to the config-object rule.
500
+ let at = before.length - 1;
501
+ while (at >= 0 && /[A-Za-z0-9_$.]/.test(before[at] ?? "")) at--;
502
+ const receiver = before.slice(at + 1).replace(/\.$/, "").split(".")[0];
503
+ if (RUNNER_BINDINGS[receiver ?? ""] === true) return true;
504
+
505
+ // A property key (`key: value`) needs the object around it to be the
506
+ // runner's own configuration; a `key = value` assignment or a bare
507
+ // `key(5000)` call on the line is never that.
508
+ if (match[2] !== ":") return false;
509
+ return inRunnerConfigObject(before);
510
+ }
511
+
512
+ type RunnerFrame = { kind: "call"; base: string | undefined } | { kind: "obj" };
513
+
514
+ /** Whether the key sits in an object literal that is a direct argument of a
515
+ * call on a runner binding — the trailing per-test configuration:
516
+ * `test("x", fn, { timeout: 45_000 })`, `test.use({ retries: 3 })`,
517
+ * `describe.configure({ retries: 3 })`. The frames are walked over the line
518
+ * prefix only, with strings already stripped by {@link splitCode}, so the
519
+ * object's nesting inside the call — a direct argument versus a property of
520
+ * a nested object or of the callback's own body — decides the verdict. */
521
+ function inRunnerConfigObject(before: string): boolean {
522
+ const frames: RunnerFrame[] = [];
523
+ for (let at = 0; at < before.length; at++) {
524
+ const ch = before[at];
525
+ if (ch === "(") {
526
+ // The dotted name the call was opened on, if any: `test.use(` reads
527
+ // `test.use`. No name — an arrow's parameter list, or a call on a
528
+ // previous call's result — means the object below it is not a runner
529
+ // surface.
530
+ let end = at;
531
+ while (end > 0 && /[A-Za-z0-9_$.]/.test(before[end - 1] ?? "")) end--;
532
+ const chain = before.slice(end, at);
533
+ frames.push({
534
+ kind: "call",
535
+ base: chain.length > 0 && !chain.endsWith(".") ? chain : undefined,
536
+ });
537
+ continue;
538
+ }
539
+ if (ch === "{" || ch === "[") {
540
+ frames.push({ kind: "obj" });
541
+ continue;
542
+ }
543
+ if (ch === ")" || ch === "}" || ch === "]") frames.pop();
544
+ }
545
+ const object = frames.at(-1);
546
+ if (object?.kind !== "obj") return false;
547
+ const enclosing = frames.at(-2);
548
+ if (enclosing?.kind !== "call") return false;
549
+ const firstSegment = enclosing.base?.split(".")[0];
550
+ return firstSegment !== undefined && RUNNER_BINDINGS[firstSegment] === true;
551
+ }
448
552
 
449
553
  function timeouts(text: string): { key: string; value: number }[] {
450
554
  const found: { key: string; value: number }[] = [];
451
555
  for (const match of text.matchAll(TIMEOUT)) {
452
556
  const key = match[1]?.toLowerCase();
453
- const raw = match[2]?.replaceAll("_", "");
557
+ const raw = match[3]?.replaceAll("_", "");
454
558
  if (key === undefined || raw === undefined) continue;
559
+ if (!onRunnerSurface(text, match)) continue;
455
560
  const value = Number(raw);
456
561
  if (Number.isSafeInteger(value)) found.push({ key, value });
457
562
  }
@@ -552,21 +657,24 @@ export const UNREADABLE_TREE_FLAG: SettlementFlag = {
552
657
 
553
658
  /**
554
659
  * Whether a diff path counts as inside the declared lane: an explicitly
555
- * declared path, or the co-located test of one `foo.ts` vouches for
556
- * `foo.test.ts`, which is the "obviously intended" case. Other test shapes
557
- * (`.spec.ts`, pytest's `test_` prefix) are not vouched for: the rule is the
558
- * shape the fleet actually uses, and a lane that wants a differently-shaped
559
- * sibling declares it. Deliberately one-directional: a lane that declares a
560
- * *test* file does not vouch for its source, because declaring the test alone
561
- * is a narrower promise and widening it silently is exactly what this flag
562
- * exists to name. A containing directory never vouches for its contents
563
- * either the lane grammar names files, and a lane that means "everything
564
- * under `src/`" fails open exactly as an undeclared one would if it cannot
565
- * name them.
660
+ * declared path, the co-located test of one, or a descendant of a declared
661
+ * directory (#1091) admission's grammar accepts a trailing-`/` entry
662
+ * ("everything under `src/`"), so settlement honours the same contract rather
663
+ * than narrowing it back to exact files. The co-located allowance is the
664
+ * "obviously intended" case: `foo.ts` vouches for `foo.test.ts`. Other test
665
+ * shapes (`.spec.ts`, pytest's `test_` prefix) are not vouched for: the rule
666
+ * is the shape the fleet actually uses, and a lane that wants a
667
+ * differently-shaped sibling declares it. Deliberately one-directional: a lane
668
+ * that declares a *test* file does not vouch for its source, because declaring
669
+ * the test alone is a narrower promise and widening it silently is exactly
670
+ * what this flag exists to name. The directory rule keeps its trailing-slash
671
+ * boundary explicit: the entry already carries the separator, so `foo/`
672
+ * covers `foo/bar.ts` at any depth and can never vouch for `foobar/x.ts`.
566
673
  */
567
674
  function withinLane(path: string, declared: readonly string[]): boolean {
568
675
  if (declared.includes(path)) return true;
569
676
  for (const d of declared) {
677
+ if (d.endsWith("/") && path.startsWith(d)) return true;
570
678
  if (coLocatedTest(d) === path) return true;
571
679
  }
572
680
  return false;
@@ -588,11 +696,22 @@ function coLocatedTest(declared: string): string | undefined {
588
696
  * The lane is the effective declaration admission resolved at dispatch —
589
697
  * `effectiveLane(body, comments)`, so a pre-dispatch comment beats an older
590
698
  * body declaration — and the flag uses that resolved snapshot, never a re-parse
591
- * of the body. The finding names every delivered file outside it, which is the
699
+ * of the body. Each finding names the delivered files outside it, which is the
592
700
  * part a reviewer is worst placed to notice: the diff's own file list is the
593
701
  * only surface that shows the escape, and reading PR file lists by hand is
594
702
  * exactly what nothing else in the loop does.
595
703
  *
704
+ * An escape splits into up to two findings because it is up to two different
705
+ * events (#1096): an undeclared production module is scope escaping into code,
706
+ * while an undeclared test file is usually coverage arriving beside the
707
+ * behaviour it pins — the #1064/#1080 shape, required work the declaration
708
+ * could not have named. Naming the class on each finding keeps the second
709
+ * readable as what it usually is without letting it disguise the first. The
710
+ * declared source's own `.test` twin is admitted by {@link withinLane}, so
711
+ * the obvious case raises nothing — and the rule stays one-directional, so a
712
+ * lane declaring only the test still names its source when the worker edits
713
+ * it.
714
+ *
596
715
  * Fail-open, like admission: an issue with no lane declaration has nothing to
597
716
  * escape, so no flag — a flag on every undeclared run would be noise within a
598
717
  * day, worse than no flag. Advisory like every other flag here: a widened lane
@@ -605,11 +724,23 @@ function detectLaneEscape(audit: SettlementAudit, flags: SettlementFlag[]): void
605
724
  .map((f) => f.path)
606
725
  .filter((path) => !withinLane(path, lane.files));
607
726
  if (outside.length === 0) return;
608
- flags.push({
609
- kind: "lane-escape",
610
- file: "(lane)",
611
- detail: `PR diff touches files outside the declared file lane: ${outside.join(", ")}`,
612
- });
727
+ // Production first: the rarer, heavier event leads the report block.
728
+ const production = outside.filter((path) => !isTestPath(path));
729
+ const otherTests = outside.filter((path) => isTestPath(path));
730
+ if (production.length > 0) {
731
+ flags.push({
732
+ kind: "lane-escape",
733
+ file: "(lane)",
734
+ detail: `PR diff touches production files outside the declared file lane: ${production.join(", ")}`,
735
+ });
736
+ }
737
+ if (otherTests.length > 0) {
738
+ flags.push({
739
+ kind: "lane-escape",
740
+ file: "(lane)",
741
+ detail: `PR diff touches unrelated test files outside the declared file lane: ${otherTests.join(", ")}`,
742
+ });
743
+ }
613
744
  }
614
745
 
615
746
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
@@ -1124,6 +1255,21 @@ const RENDERED_FLAGS = 20;
1124
1255
 
1125
1256
  const HEADING = "settlement audit";
1126
1257
 
1258
+ /**
1259
+ * The kinds that mean coverage got weaker — the quiet signals the audit
1260
+ * exists to deliver, and the family mining.ts mines. Presentation keeps them
1261
+ * ahead of everything else and lane escapes last (#1096): a check that fires
1262
+ * on half of all merges gets skimmed, and a skimmed finding must not sit
1263
+ * between the reviewer and these. Kept in step with `WEAKENING_KINDS` in
1264
+ * mining.ts, which is the same four kinds spelled for mining.
1265
+ */
1266
+ const TEST_INTEGRITY_KINDS: Partial<Record<SettlementFlagKind, true>> = {
1267
+ "test-file-deleted": true,
1268
+ "test-disabled": true,
1269
+ "assertions-removed": true,
1270
+ "test-timeout-raised": true,
1271
+ };
1272
+
1127
1273
  /**
1128
1274
  * The flag block appended to a settlement report, or no lines at all.
1129
1275
  *
@@ -1148,7 +1294,17 @@ export function formatSettlementFlags(
1148
1294
  `${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
1149
1295
  (diff.truncated ? ", and the PR diff was too large to read in full" : ""),
1150
1296
  ];
1151
- for (const flag of flags.slice(0, RENDERED_FLAGS)) {
1297
+ // Presentation order is structural, not incidental: test-integrity flags
1298
+ // first, then everything else, lane escapes last (#1096). The analyser
1299
+ // emits them in roughly this order already, but this surface also renders
1300
+ // stored rows and post-settlement appends, whose order nobody chose.
1301
+ const rank = (flag: SettlementFlag): number =>
1302
+ TEST_INTEGRITY_KINDS[flag.kind] === true ? 0 : flag.kind === "lane-escape" ? 2 : 1;
1303
+ const ordered = flags
1304
+ .map((flag, index) => ({ flag, index }))
1305
+ .sort((a, b) => rank(a.flag) - rank(b.flag) || a.index - b.index)
1306
+ .map((entry) => entry.flag);
1307
+ for (const flag of ordered.slice(0, RENDERED_FLAGS)) {
1152
1308
  if (flag.kind === "pr-adopted") {
1153
1309
  lines.push(` ${flag.kind} — ${flag.detail}`);
1154
1310
  continue;
@@ -1165,10 +1321,30 @@ export function formatSettlementFlags(
1165
1321
  }
1166
1322
 
1167
1323
  /** The one-line form for `omp-conductor status`, where a flagged run has to be
1168
- * visible long after its escalation was delivered and deduplicated. */
1324
+ * visible long after its escalation was delivered and deduplicated. Lane
1325
+ * escapes are presented apart from the test-integrity family (#1096), so the
1326
+ * noisy check cannot bury the quiet one in one comma join. */
1169
1327
  export function settlementFlagSummary(flags: readonly SettlementFlag[] | undefined): string | undefined {
1170
1328
  if (flags === undefined || flags.length === 0) return undefined;
1171
- const kinds = [...new Set(flags.map((f) => f.kind))].join(", ");
1329
+ const integrity = new Set<SettlementFlagKind>();
1330
+ const lane = new Set<SettlementFlagKind>();
1331
+ const rest = new Set<SettlementFlagKind>();
1332
+ for (const { kind } of flags) {
1333
+ if (TEST_INTEGRITY_KINDS[kind] === true) integrity.add(kind);
1334
+ else if (kind === "lane-escape") lane.add(kind);
1335
+ else rest.add(kind);
1336
+ }
1337
+ const segments: { label?: string; kinds: SettlementFlagKind[] }[] = [];
1338
+ if (integrity.size > 0) segments.push({ label: "test-integrity", kinds: [...integrity] });
1339
+ if (rest.size > 0) segments.push({ kinds: [...rest] });
1340
+ if (lane.size > 0) segments.push({ label: "lane", kinds: [...lane] });
1341
+ // One family alone reads exactly as before — a label with nothing to
1342
+ // separate is furniture.
1343
+ const parts = segments.map((segment) =>
1344
+ segments.length > 1 && segment.label !== undefined
1345
+ ? `${segment.label}: ${segment.kinds.join(", ")}`
1346
+ : segment.kinds.join(", "),
1347
+ );
1172
1348
  const loud = flags.some((f) => f.unattributed === true) ? ", some unattributed" : "";
1173
- return `${HEADING}: ${flags.length} flag(s) — ${kinds}${loud}`;
1349
+ return `${HEADING}: ${flags.length} flag(s) — ${parts.join("; ")}${loud}`;
1174
1350
  }
package/src/doctor.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  killOf,
72
72
  readTelegramChannel,
73
73
  readTelegramDmOwner,
74
+ readTelegramMisroutes,
74
75
  readTelegramPollState,
75
76
  resolveClaimedSessionFile,
76
77
  resolveProjectClaim,
@@ -82,6 +83,7 @@ import {
82
83
  type TelegramChannelState,
83
84
  type TelegramDmOwnerState,
84
85
  type TelegramPollState,
86
+ type TelegramMisroute,
85
87
  } from "./escalate.ts";
86
88
  import {
87
89
  herdrConductorPluginConfigDir,
@@ -286,6 +288,11 @@ export interface DoctorDeps {
286
288
  * `unavailable` when the registry cannot be read or does not parse — the
287
289
  * caller must not read that as "no claims" (#626). */
288
290
  claimedTopics?: () => ClaimedTopicsResult;
291
+ /** Recorded flat-chat deliveries this project's stale pin already caused
292
+ * (#1094), read from conductor state. The topic-pin row renders them as
293
+ * observed evidence until the pin is repaired — rows match the *current*
294
+ * pin id, so re-pinning is what retires them. */
295
+ topicMisroutes?: () => readonly TelegramMisroute[];
289
296
  /** Whether the bridge's access.json runs per-session topic tidy on. */
290
297
  topicsTidy?: () => boolean;
291
298
  /** The typed bot.lock poll-ownership read, told apart so absent/malformed
@@ -1345,8 +1352,8 @@ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
1345
1352
  if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
1346
1353
  return warnFinding(
1347
1354
  "arm-ack",
1348
- `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
1349
- `no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
1355
+ `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused); the fleet session notifies the operator and clears it once its notice goes out, or the next arm replaces it`,
1356
+ `the code is dead no reply to it can arm. Re-run the ceremony: \`omp-conductor arm --project ${p.name}\``,
1350
1357
  );
1351
1358
  }
1352
1359
  if (sighting.acknowledgedAt !== undefined) {
@@ -1390,14 +1397,24 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1390
1397
  `[${p.name}] no pinned topic id — sends go to the flat chat (live-claim substitution only follows a stale pin)`,
1391
1398
  );
1392
1399
  }
1400
+ // #1094: sends that already rode the flat chat because of THIS pin turn the
1401
+ // warnings below from hypothetical into observed. Rows are keyed to the pin
1402
+ // id, so re-pinning retires them — nothing deletes them.
1403
+ const misroutes = probes.topicMisroutes().filter(
1404
+ (row) => row.project === p.name && row.staleTopicId === pinned,
1405
+ );
1406
+ const misrouteNote =
1407
+ misroutes.length === 0
1408
+ ? ""
1409
+ : ` It has happened: ${misroutes.length} send(s) were delivered to the flat chat because of this pin, last at ${new Date(misroutes[misroutes.length - 1]!.at).toISOString()}`;
1393
1410
  const result = probes.claimedTopics();
1394
1411
  if (result.kind !== "ok") {
1395
1412
  return warnFinding(
1396
1413
  "topic-pin",
1397
1414
  `[${p.name}] pinned topic ${pinned} — ` +
1398
1415
  (result.kind === "missing"
1399
- ? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project`
1400
- : `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project`),
1416
+ ? `omp-telegram has no claim registry (threads.json in its state dir), so doctor cannot tell whether the pin is live or a claim answers to this project${misrouteNote}`
1417
+ : `omp-telegram's claim registry is unreadable (${result.problem}), so doctor cannot tell whether the pin is live or a claim answers to this project${misrouteNote}`),
1401
1418
  `check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor — until then sends keep the pinned topic and degrade to the flat chat on a missing thread (#318)`,
1402
1419
  );
1403
1420
  }
@@ -1423,7 +1440,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1423
1440
  if (dead.length > 0) {
1424
1441
  return warnFinding(
1425
1442
  "topic-pin",
1426
- `[${p.name}] pinned topic ${pinned} — every claim in omp-telegram's registry is dead${deadNote}`,
1443
+ `[${p.name}] pinned topic ${pinned} — every claim in omp-telegram's registry is dead${deadNote}${misrouteNote}`,
1427
1444
  `run /cleanup in Telegram so the bridge closes those topics and drops their rows itself, then re-run setup to re-pin escalation.telegramTopicId to a live claim — never delete rows from threads.json by hand, because the row is the only index to the remote topic (#987)`,
1428
1445
  );
1429
1446
  }
@@ -1437,7 +1454,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1437
1454
  if (claims.some((claim) => claim.threadId === pinned)) {
1438
1455
  return passFinding("topic-pin", `[${p.name}] pinned topic ${pinned} is a live claim${deadNote}`);
1439
1456
  }
1440
- const match = resolveProjectClaim(claims, p.name, probes.pidAlive);
1457
+ const match = resolveProjectClaim(claims, p.name, probes.pidAlive, p.workspaceRoot);
1441
1458
  if (match.kind === "match") {
1442
1459
  return passFinding(
1443
1460
  "topic-pin",
@@ -1454,7 +1471,7 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1454
1471
  return warnFinding(
1455
1472
  "topic-pin",
1456
1473
  `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, and several live claims answer to this project ` +
1457
- `(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin`,
1474
+ `(topics ${match.claimants.map((c) => c.threadId).join(", ")}) — substitution would be a coin toss, so sends keep the stale pin${misrouteNote}`,
1458
1475
  `make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — live-claim substitution follows the unique claim from there`,
1459
1476
  );
1460
1477
  }
@@ -1463,14 +1480,14 @@ function topicPinProbe(probes: Probes, p: ProjectConfig): Finding {
1463
1480
  "topic-pin",
1464
1481
  `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims, no live claim answers ` +
1465
1482
  `to this project, and this host runs topicsTidy — the pin is vestigial and can never be live again ` +
1466
- `(every pane exit closes its topic); sends keep the stale pin`,
1483
+ `(every pane exit closes its topic); sends keep the stale pin${misrouteNote}`,
1467
1484
  `turn topicsTidy off, then re-run setup to re-pin escalation.telegramTopicId to a currently claimed topic`,
1468
1485
  );
1469
1486
  }
1470
1487
  return warnFinding(
1471
1488
  "topic-pin",
1472
1489
  `[${p.name}] pinned topic ${pinned} is not among omp-telegram's live claims (${claims.map((c) => c.threadId).join(", ")}), ` +
1473
- `and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat`,
1490
+ `and no live claim answers to this project — sends keep the stale pin and degrade to the flat chat${misrouteNote}`,
1474
1491
  `update escalation.telegramTopicId to a currently claimed topic, or re-run setup to re-pin`,
1475
1492
  );
1476
1493
  }
@@ -2163,6 +2180,7 @@ export function defaultProbes(): Probes {
2163
2180
  herdrConfig: defaultHerdrConfig,
2164
2181
  herdrEnv: defaultHerdrEnv,
2165
2182
  claimedTopics: () => claimedTelegramTopics(),
2183
+ topicMisroutes: () => readTelegramMisroutes(),
2166
2184
  topicsTidy: () => telegramTopicsTidy(),
2167
2185
  pollState: () => readTelegramPollState(),
2168
2186
  dmOwner: () => readTelegramDmOwner(),