metheus-governance-mcp-cli 0.2.60 → 0.2.61

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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/cli.mjs +742 -48
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -190,6 +190,7 @@ Checks:
190
190
  - local bot runner v2 config validity
191
191
  - project workspace mapping presence
192
192
  - role profile -> local CLI availability
193
+ - route dry-run / trigger-policy safety warnings
193
194
  - local provider token presence for active project destinations
194
195
  - codex/claude/gemini/antigravity/cursor registration state
195
196
  - gateway `tools/list` reachability
@@ -202,6 +203,7 @@ Direct bot posting:
202
203
  - it does not use a server-stored bot token
203
204
  - the destination identifier is resolved from the current project's saved Chat Destinations on the Metheus server
204
205
  - if multiple active destinations exist for the same provider, pass `destination_id` or `destination_label`
206
+ - pass `dry_run_delivery=true` to preview the resolved bot/destination locally without sending the provider message
205
207
  - direct local delivery is implemented today for:
206
208
  - Telegram
207
209
  - Slack
@@ -239,6 +241,7 @@ Common flags:
239
241
  metheus-governance-mcp-cli runner once --project-id <project_uuid> --provider telegram --role monitor
240
242
  metheus-governance-mcp-cli runner start --project-id <project_uuid> --provider telegram --role monitor --poll-interval-ms 5000
241
243
  metheus-governance-mcp-cli runner start --project-id <project_uuid> --provider telegram --role monitor --mentions-only true
244
+ metheus-governance-mcp-cli runner once --project-id <project_uuid> --provider telegram --role monitor --dry-run-delivery true
242
245
  metheus-governance-mcp-cli runner once --project-id <project_uuid> --provider telegram --role monitor --role-profile review
243
246
  metheus-governance-mcp-cli runner once --project-id <project_uuid> --provider telegram --role monitor --command "python C:\\path\\to\\reply.py"
244
247
  ```
@@ -299,6 +302,9 @@ Notes:
299
302
  - `runner once` processes the most recent pending archived inbound message
300
303
  - `runner start` keeps polling and stores per-route cursor state in `~/.metheus/bot-runner-state.json`
301
304
  - first start primes the cursor to the latest inbound message and does not reply to old backlog
305
+ - when inline filters match a configured route in `~/.metheus/bot-runner.json`, the runner reuses that route's canonical name/destination and state cursor instead of creating a new anonymous route key
306
+ - stale anonymous route keys in `~/.metheus/bot-runner-state.json` are auto-migrated to the matching configured route when possible; `doctor` warns if ambiguous legacy keys still remain
307
+ - `--dry-run-delivery true` resolves the real bot and destination but skips provider send and archive mirror writes
302
308
  - when `trigger_policy.mentions_only=true`, unmentioned group messages are archived but skipped for reply generation
303
309
  - mirrored bot replies are deduped by `chat_id + message_id`
304
310
  - provider bot messages are ignored during inbound import by default
package/cli.mjs CHANGED
@@ -78,8 +78,8 @@ function printUsage() {
78
78
  ` ${cmd} proxy [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--workspace-dir <path|auto>] [--include-drafts <true|false>] [--auto-pull-on-conflict <true|false>] [--timeout-seconds <n>]`,
79
79
  ` ${cmd} selftest [--json <true|false>]`,
80
80
  ` ${cmd} local-bot-bridge [--client <codex|claude|gemini|sample>] [--cwd <path>] [--model <name>] [--permission-mode <read_only|workspace_write|danger_full_access>] [--reasoning-effort <low|medium|high>]`,
81
- ` ${cmd} runner once [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--command <shell command>] [--context-comments <n>] [--archive-replies <true|false>]`,
82
- ` ${cmd} runner start [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--command <shell command>] [--poll-interval-ms <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
81
+ ` ${cmd} runner once [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--command <shell command>] [--context-comments <n>] [--archive-replies <true|false>]`,
82
+ ` ${cmd} runner start [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--command <shell command>] [--poll-interval-ms <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
83
83
  ` ${cmd} ctxpack pull [--project-id <uuid>] [--base-url <url>] [--workspace-dir <path|auto>] [--paths <csv>] [--timeout-seconds <n>]`,
84
84
  ` ${cmd} auth status`,
85
85
  ` ${cmd} auth login [--base-url <url>] [--flow <auto|device|callback|manual>] [--keycloak-url <url>] [--realm <name>] [--client-id <id>] [--open-browser <true|false>] [--callback-port <n>] [--timeout-seconds <n>] [--manual <true|false>]`,
@@ -823,6 +823,7 @@ function botRunnerConfigTemplate() {
823
823
  dedupe_outbound: defaultArchivePolicy.dedupeOutbound,
824
824
  skip_bot_messages: defaultArchivePolicy.skipBotMessages,
825
825
  },
826
+ dry_run_delivery: false,
826
827
  poll_interval_ms: 5000,
827
828
  context_comments: 8,
828
829
  archive_replies: true,
@@ -848,6 +849,7 @@ function serializeRunnerRoute(route) {
848
849
  archive_work_item_id: normalized.archiveWorkItemID,
849
850
  trigger_policy: serializeRunnerTriggerPolicy(normalized.triggerPolicy),
850
851
  archive_policy: serializeRunnerArchivePolicy(normalized.archivePolicy),
852
+ ...(normalized.dryRunDelivery ? { dry_run_delivery: true } : {}),
851
853
  ...(normalized.workspaceDir ? { workspace_dir: normalized.workspaceDir } : {}),
852
854
  ...(normalized.command ? { command: normalized.command } : {}),
853
855
  poll_interval_ms: normalized.pollIntervalMs,
@@ -1060,17 +1062,239 @@ function rememberProjectWorkspaceMapping({ projectID, workspaceDir, source }) {
1060
1062
  };
1061
1063
  }
1062
1064
 
1065
+ function parseRunnerStateRouteKey(routeKey) {
1066
+ const text = String(routeKey || "").trim();
1067
+ if (!text) return null;
1068
+ const parts = text.split("::");
1069
+ if (parts.length < 6) return null;
1070
+ return {
1071
+ raw: text,
1072
+ name: String(parts[0] || "").trim(),
1073
+ projectID: String(parts[1] || "").trim(),
1074
+ provider: normalizeBotProvider(parts[2]),
1075
+ role: normalizeBotRole(parts[3]),
1076
+ botSelector: String(parts[4] || "").trim(),
1077
+ destinationSelector: parts.slice(5).join("::").trim(),
1078
+ };
1079
+ }
1080
+
1081
+ function runnerStateRouteKeyIsUnnamed(parsedKey) {
1082
+ const routeKey = safeObject(parsedKey);
1083
+ if (!routeKey.projectID || !routeKey.provider || !routeKey.role) {
1084
+ return false;
1085
+ }
1086
+ return routeKey.name === "-";
1087
+ }
1088
+
1089
+ function runnerStateRouteKeyLooksAnonymous(parsedKey) {
1090
+ const routeKey = safeObject(parsedKey);
1091
+ if (!runnerStateRouteKeyIsUnnamed(routeKey)) {
1092
+ return false;
1093
+ }
1094
+ return routeKey.botSelector !== "-" || routeKey.destinationSelector !== "-";
1095
+ }
1096
+
1097
+ function runnerStateSelectorMatchesRoute(selector, idValue, labelValue) {
1098
+ const normalizedSelector = String(selector || "").trim();
1099
+ if (!normalizedSelector || normalizedSelector === "-") {
1100
+ return true;
1101
+ }
1102
+ if (normalizedSelector === String(idValue || "").trim()) {
1103
+ return true;
1104
+ }
1105
+ return matchesRunnerRouteText(labelValue, normalizedSelector);
1106
+ }
1107
+
1108
+ function findConfiguredRoutesForAnonymousStateKey(parsedKey, runnerConfig) {
1109
+ const routeKey = safeObject(parsedKey);
1110
+ const enabledRoutes = ensureArray(runnerConfig?.routes)
1111
+ .map((route) => normalizeRunnerRoute(route))
1112
+ .filter((route) => route.enabled);
1113
+ return enabledRoutes.filter((route) => {
1114
+ if (route.projectID !== routeKey.projectID) return false;
1115
+ if (route.provider !== routeKey.provider) return false;
1116
+ if (route.role !== routeKey.role) return false;
1117
+ if (!runnerStateSelectorMatchesRoute(routeKey.botSelector, route.botID, route.botName)) {
1118
+ return false;
1119
+ }
1120
+ if (!runnerStateSelectorMatchesRoute(routeKey.destinationSelector, route.destinationID, route.destinationLabel)) {
1121
+ return false;
1122
+ }
1123
+ return true;
1124
+ });
1125
+ }
1126
+
1127
+ function configuredRunnerRouteKeys(runnerConfig) {
1128
+ return new Set(
1129
+ ensureArray(runnerConfig?.routes)
1130
+ .map((route) => normalizeRunnerRoute(route))
1131
+ .filter((route) => route.enabled)
1132
+ .map((route) => runnerRouteKey(route)),
1133
+ );
1134
+ }
1135
+
1136
+ function runnerStateCursorRank(state) {
1137
+ const record = safeObject(state);
1138
+ const processedAt = Date.parse(String(record.last_processed_created_at || "").trim());
1139
+ const updatedAt = Date.parse(String(record.updated_at || "").trim());
1140
+ return {
1141
+ processedAt: Number.isFinite(processedAt) ? processedAt : 0,
1142
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
1143
+ providerUpdateID: intFromRawAllowZero(record.last_provider_update_id, 0),
1144
+ };
1145
+ }
1146
+
1147
+ function prefersRunnerStateRecord(candidate, current) {
1148
+ const candidateRank = runnerStateCursorRank(candidate);
1149
+ const currentRank = runnerStateCursorRank(current);
1150
+ if (candidateRank.processedAt !== currentRank.processedAt) {
1151
+ return candidateRank.processedAt > currentRank.processedAt;
1152
+ }
1153
+ if (candidateRank.providerUpdateID !== currentRank.providerUpdateID) {
1154
+ return candidateRank.providerUpdateID > currentRank.providerUpdateID;
1155
+ }
1156
+ if (candidateRank.updatedAt !== currentRank.updatedAt) {
1157
+ return candidateRank.updatedAt > currentRank.updatedAt;
1158
+ }
1159
+ return false;
1160
+ }
1161
+
1162
+ function mergeRunnerStateRecords(preferred, fallback) {
1163
+ const primary = safeObject(preferred);
1164
+ const secondary = safeObject(fallback);
1165
+ const pickString = (...values) => firstNonEmptyString(values);
1166
+ const pickNumber = (...values) => {
1167
+ for (const value of values) {
1168
+ if (value === 0 || value === "0") return 0;
1169
+ const parsed = intFromRawAllowZero(value, Number.NaN);
1170
+ if (Number.isFinite(parsed) && !Number.isNaN(parsed) && parsed > 0) {
1171
+ return parsed;
1172
+ }
1173
+ }
1174
+ return 0;
1175
+ };
1176
+ return {
1177
+ last_processed_comment_id: pickString(primary.last_processed_comment_id, secondary.last_processed_comment_id),
1178
+ last_processed_created_at: pickString(primary.last_processed_created_at, secondary.last_processed_created_at),
1179
+ last_source_message_id: pickNumber(primary.last_source_message_id, secondary.last_source_message_id) || undefined,
1180
+ last_source_kind: pickString(primary.last_source_kind, secondary.last_source_kind),
1181
+ last_error: pickString(primary.last_error, secondary.last_error),
1182
+ updated_at: pickString(primary.updated_at, secondary.updated_at, new Date().toISOString()),
1183
+ last_action: pickString(primary.last_action, secondary.last_action),
1184
+ last_reason: pickString(primary.last_reason, secondary.last_reason),
1185
+ last_reply_message_id: pickNumber(primary.last_reply_message_id, secondary.last_reply_message_id) || undefined,
1186
+ local_receive_mode: pickString(primary.local_receive_mode, secondary.local_receive_mode),
1187
+ local_telegram_polling_ready: Boolean(primary.local_telegram_polling_ready || secondary.local_telegram_polling_ready),
1188
+ local_telegram_webhook_cleared_url: pickString(primary.local_telegram_webhook_cleared_url, secondary.local_telegram_webhook_cleared_url),
1189
+ local_telegram_webhook_cleared_at: pickString(primary.local_telegram_webhook_cleared_at, secondary.local_telegram_webhook_cleared_at),
1190
+ last_provider_update_id: pickNumber(primary.last_provider_update_id, secondary.last_provider_update_id) || undefined,
1191
+ last_local_poll_at: pickString(primary.last_local_poll_at, secondary.last_local_poll_at),
1192
+ };
1193
+ }
1194
+
1195
+ function cleanupRunnerStateRecord(record) {
1196
+ const cleaned = {};
1197
+ for (const [key, value] of Object.entries(safeObject(record))) {
1198
+ if (value === undefined) continue;
1199
+ cleaned[key] = value;
1200
+ }
1201
+ return cleaned;
1202
+ }
1203
+
1204
+ function migrateBotRunnerStateRoutes(routes, runnerConfig) {
1205
+ const currentRoutes = safeObject(routes);
1206
+ const nextRoutes = { ...currentRoutes };
1207
+ const migratedKeys = [];
1208
+ const remainingAnonymousKeys = [];
1209
+ let changed = false;
1210
+ const configuredKeys = configuredRunnerRouteKeys(runnerConfig);
1211
+
1212
+ for (const [routeKey, routeState] of Object.entries(currentRoutes)) {
1213
+ const parsedKey = parseRunnerStateRouteKey(routeKey);
1214
+ if (!runnerStateRouteKeyLooksAnonymous(parsedKey)) {
1215
+ continue;
1216
+ }
1217
+ const matches = findConfiguredRoutesForAnonymousStateKey(parsedKey, runnerConfig);
1218
+ if (matches.length !== 1) {
1219
+ remainingAnonymousKeys.push(routeKey);
1220
+ continue;
1221
+ }
1222
+ const canonicalRoute = normalizeRunnerRoute(matches[0]);
1223
+ const canonicalKey = runnerRouteKey(canonicalRoute);
1224
+ if (!canonicalKey || canonicalKey === routeKey) {
1225
+ continue;
1226
+ }
1227
+ const existingCanonicalState = safeObject(nextRoutes[canonicalKey]);
1228
+ const sourceState = safeObject(routeState);
1229
+ const preferredState = prefersRunnerStateRecord(sourceState, existingCanonicalState)
1230
+ ? sourceState
1231
+ : existingCanonicalState;
1232
+ const fallbackState = preferredState === sourceState ? existingCanonicalState : sourceState;
1233
+ nextRoutes[canonicalKey] = cleanupRunnerStateRecord(
1234
+ mergeRunnerStateRecords(preferredState, fallbackState),
1235
+ );
1236
+ delete nextRoutes[routeKey];
1237
+ migratedKeys.push({
1238
+ from: routeKey,
1239
+ to: canonicalKey,
1240
+ });
1241
+ changed = true;
1242
+ }
1243
+
1244
+ for (const routeKey of Object.keys(nextRoutes)) {
1245
+ const parsedKey = parseRunnerStateRouteKey(routeKey);
1246
+ if (!runnerStateRouteKeyIsUnnamed(parsedKey)) {
1247
+ continue;
1248
+ }
1249
+ if (configuredKeys.has(routeKey)) {
1250
+ continue;
1251
+ }
1252
+ if (!remainingAnonymousKeys.includes(routeKey)) {
1253
+ remainingAnonymousKeys.push(routeKey);
1254
+ }
1255
+ }
1256
+
1257
+ return {
1258
+ routes: nextRoutes,
1259
+ changed,
1260
+ migratedKeys,
1261
+ remainingAnonymousKeys,
1262
+ };
1263
+ }
1264
+
1063
1265
  function loadBotRunnerState() {
1064
1266
  const filePath = botRunnerStateFilePath();
1065
1267
  try {
1066
1268
  if (!fs.existsSync(filePath)) {
1067
- return { filePath, routes: {} };
1269
+ return {
1270
+ filePath,
1271
+ routes: {},
1272
+ migrated: false,
1273
+ migratedKeys: [],
1274
+ remainingAnonymousKeys: [],
1275
+ };
1068
1276
  }
1069
1277
  const parsed = tryJsonParse(fs.readFileSync(filePath, "utf8"));
1070
- const routes = safeObject(parsed?.routes);
1071
- return { filePath, routes };
1278
+ const runnerConfig = loadBotRunnerConfig({ persistIfNeeded: true });
1279
+ const migratedState = migrateBotRunnerStateRoutes(safeObject(parsed?.routes), runnerConfig);
1280
+ if (migratedState.changed) {
1281
+ saveBotRunnerState({ routes: migratedState.routes });
1282
+ }
1283
+ return {
1284
+ filePath,
1285
+ routes: migratedState.routes,
1286
+ migrated: migratedState.changed,
1287
+ migratedKeys: migratedState.migratedKeys,
1288
+ remainingAnonymousKeys: migratedState.remainingAnonymousKeys,
1289
+ };
1072
1290
  } catch {
1073
- return { filePath, routes: {} };
1291
+ return {
1292
+ filePath,
1293
+ routes: {},
1294
+ migrated: false,
1295
+ migratedKeys: [],
1296
+ remainingAnonymousKeys: [],
1297
+ };
1074
1298
  }
1075
1299
  }
1076
1300
 
@@ -1121,6 +1345,10 @@ function normalizeRunnerRoute(rawRoute, overrides = {}) {
1121
1345
  const workspaceDir = String(route.workspace_dir || route.workspaceDir || "").trim();
1122
1346
  const triggerPolicy = normalizeRunnerTriggerPolicy(route.trigger_policy || route.triggerPolicy, route);
1123
1347
  const archivePolicy = normalizeRunnerArchivePolicy(route.archive_policy || route.archivePolicy, route);
1348
+ const dryRunDelivery = boolFromRaw(
1349
+ route.dry_run_delivery ?? route.dryRunDelivery ?? process.env.METHEUS_BOT_DELIVERY_DRY_RUN,
1350
+ false,
1351
+ );
1124
1352
  return {
1125
1353
  name,
1126
1354
  enabled: boolFromRaw(route.enabled, true),
@@ -1136,6 +1364,7 @@ function normalizeRunnerRoute(rawRoute, overrides = {}) {
1136
1364
  archiveWorkItemID,
1137
1365
  triggerPolicy,
1138
1366
  archivePolicy,
1367
+ dryRunDelivery,
1139
1368
  workspaceDir,
1140
1369
  command,
1141
1370
  pollIntervalMs: intFromRaw(route.poll_interval_ms || route.pollIntervalMs, 5000),
@@ -1195,6 +1424,161 @@ function validateRunnerRoute(route, { requireCommand = true, config = null } = {
1195
1424
  return errors;
1196
1425
  }
1197
1426
 
1427
+ function hasRunnerFlag(flags, flagName) {
1428
+ return Object.prototype.hasOwnProperty.call(flags, flagName);
1429
+ }
1430
+
1431
+ function matchesRunnerRouteText(candidate, expected) {
1432
+ const expectedText = String(expected || "").trim();
1433
+ if (!expectedText) {
1434
+ return true;
1435
+ }
1436
+ return String(candidate || "").trim().toLowerCase() === expectedText.toLowerCase();
1437
+ }
1438
+
1439
+ function configuredRunnerRouteMatchesInlineSelection(route, inlineRoute, flags) {
1440
+ const candidate = normalizeRunnerRoute(route);
1441
+ if (hasRunnerFlag(flags, "route-name") && !matchesRunnerRouteText(candidate.name, flags["route-name"])) {
1442
+ return false;
1443
+ }
1444
+ if (inlineRoute.projectID && candidate.projectID !== inlineRoute.projectID) {
1445
+ return false;
1446
+ }
1447
+ if (inlineRoute.provider && candidate.provider !== inlineRoute.provider) {
1448
+ return false;
1449
+ }
1450
+ if (inlineRoute.role && candidate.role !== inlineRoute.role) {
1451
+ return false;
1452
+ }
1453
+ if (hasRunnerFlag(flags, "role-profile") && candidate.roleProfile !== inlineRoute.roleProfile) {
1454
+ return false;
1455
+ }
1456
+ if (inlineRoute.botID && candidate.botID !== inlineRoute.botID) {
1457
+ return false;
1458
+ }
1459
+ if (inlineRoute.botName && !matchesRunnerRouteText(candidate.botName, inlineRoute.botName)) {
1460
+ return false;
1461
+ }
1462
+ if (inlineRoute.destinationID && candidate.destinationID !== inlineRoute.destinationID) {
1463
+ return false;
1464
+ }
1465
+ if (inlineRoute.destinationLabel && !matchesRunnerRouteText(candidate.destinationLabel, inlineRoute.destinationLabel)) {
1466
+ return false;
1467
+ }
1468
+ if (inlineRoute.archiveThreadID && candidate.archiveThreadID !== inlineRoute.archiveThreadID) {
1469
+ return false;
1470
+ }
1471
+ if (inlineRoute.archiveWorkItemID && candidate.archiveWorkItemID !== inlineRoute.archiveWorkItemID) {
1472
+ return false;
1473
+ }
1474
+ return true;
1475
+ }
1476
+
1477
+ function applyRunnerRouteFlagOverrides(route, flags) {
1478
+ const merged = normalizeRunnerRoute(route);
1479
+ if (hasRunnerFlag(flags, "route-name")) {
1480
+ merged.name = String(flags["route-name"] || "").trim();
1481
+ }
1482
+ if (hasRunnerFlag(flags, "project-id")) {
1483
+ merged.projectID = String(flags["project-id"] || "").trim();
1484
+ }
1485
+ if (hasRunnerFlag(flags, "provider")) {
1486
+ merged.provider = normalizeBotProvider(flags.provider);
1487
+ }
1488
+ if (hasRunnerFlag(flags, "role")) {
1489
+ merged.role = normalizeBotRole(flags.role);
1490
+ }
1491
+ if (hasRunnerFlag(flags, "role-profile")) {
1492
+ merged.roleProfile = normalizeRunnerRoleProfileName(flags["role-profile"]);
1493
+ }
1494
+ if (hasRunnerFlag(flags, "bot-name")) {
1495
+ merged.botName = String(flags["bot-name"] || "").trim();
1496
+ }
1497
+ if (hasRunnerFlag(flags, "bot-id")) {
1498
+ merged.botID = String(flags["bot-id"] || "").trim();
1499
+ }
1500
+ if (hasRunnerFlag(flags, "destination-id")) {
1501
+ merged.destinationID = String(flags["destination-id"] || "").trim();
1502
+ }
1503
+ if (hasRunnerFlag(flags, "destination-label")) {
1504
+ merged.destinationLabel = String(flags["destination-label"] || "").trim();
1505
+ }
1506
+ if (hasRunnerFlag(flags, "archive-thread-id")) {
1507
+ merged.archiveThreadID = String(flags["archive-thread-id"] || "").trim();
1508
+ }
1509
+ if (hasRunnerFlag(flags, "archive-work-item-id")) {
1510
+ merged.archiveWorkItemID = String(flags["archive-work-item-id"] || "").trim();
1511
+ }
1512
+ if (hasRunnerFlag(flags, "workspace-dir")) {
1513
+ merged.workspaceDir = String(flags["workspace-dir"] || "").trim();
1514
+ }
1515
+ if (hasRunnerFlag(flags, "command")) {
1516
+ merged.command = String(flags.command || "").trim();
1517
+ }
1518
+ if (hasRunnerFlag(flags, "dry-run-delivery")) {
1519
+ merged.dryRunDelivery = boolFromRaw(flags["dry-run-delivery"], merged.dryRunDelivery);
1520
+ }
1521
+ if (hasRunnerFlag(flags, "poll-interval-ms")) {
1522
+ merged.pollIntervalMs = intFromRaw(flags["poll-interval-ms"], merged.pollIntervalMs);
1523
+ }
1524
+ if (hasRunnerFlag(flags, "context-comments")) {
1525
+ merged.contextComments = intFromRaw(flags["context-comments"], merged.contextComments);
1526
+ }
1527
+ if (hasRunnerFlag(flags, "archive-replies")) {
1528
+ const archiveReplies = boolFromRaw(flags["archive-replies"], merged.archiveReplies);
1529
+ merged.archiveReplies = archiveReplies;
1530
+ merged.archivePolicy = {
1531
+ ...safeObject(merged.archivePolicy),
1532
+ mirrorReplies: archiveReplies,
1533
+ };
1534
+ }
1535
+ if (
1536
+ hasRunnerFlag(flags, "mentions-only")
1537
+ || hasRunnerFlag(flags, "direct-messages")
1538
+ || hasRunnerFlag(flags, "reply-to-bot-messages")
1539
+ || hasRunnerFlag(flags, "ignore-edited-messages")
1540
+ ) {
1541
+ merged.triggerPolicy = {
1542
+ ...safeObject(merged.triggerPolicy),
1543
+ ...(hasRunnerFlag(flags, "mentions-only")
1544
+ ? { mentionsOnly: boolFromRaw(flags["mentions-only"], merged.triggerPolicy?.mentionsOnly) }
1545
+ : {}),
1546
+ ...(hasRunnerFlag(flags, "direct-messages")
1547
+ ? { directMessages: boolFromRaw(flags["direct-messages"], merged.triggerPolicy?.directMessages) }
1548
+ : {}),
1549
+ ...(hasRunnerFlag(flags, "reply-to-bot-messages")
1550
+ ? { replyToBotMessages: boolFromRaw(flags["reply-to-bot-messages"], merged.triggerPolicy?.replyToBotMessages) }
1551
+ : {}),
1552
+ ...(hasRunnerFlag(flags, "ignore-edited-messages")
1553
+ ? { ignoreEditedMessages: boolFromRaw(flags["ignore-edited-messages"], merged.triggerPolicy?.ignoreEditedMessages) }
1554
+ : {}),
1555
+ };
1556
+ }
1557
+ if (
1558
+ hasRunnerFlag(flags, "dedupe-inbound")
1559
+ || hasRunnerFlag(flags, "dedupe-outbound")
1560
+ || hasRunnerFlag(flags, "skip-bot-messages")
1561
+ || hasRunnerFlag(flags, "archive-replies")
1562
+ ) {
1563
+ merged.archivePolicy = {
1564
+ ...safeObject(merged.archivePolicy),
1565
+ ...(hasRunnerFlag(flags, "archive-replies")
1566
+ ? { mirrorReplies: boolFromRaw(flags["archive-replies"], merged.archivePolicy?.mirrorReplies) }
1567
+ : {}),
1568
+ ...(hasRunnerFlag(flags, "dedupe-inbound")
1569
+ ? { dedupeInbound: boolFromRaw(flags["dedupe-inbound"], merged.archivePolicy?.dedupeInbound) }
1570
+ : {}),
1571
+ ...(hasRunnerFlag(flags, "dedupe-outbound")
1572
+ ? { dedupeOutbound: boolFromRaw(flags["dedupe-outbound"], merged.archivePolicy?.dedupeOutbound) }
1573
+ : {}),
1574
+ ...(hasRunnerFlag(flags, "skip-bot-messages")
1575
+ ? { skipBotMessages: boolFromRaw(flags["skip-bot-messages"], merged.archivePolicy?.skipBotMessages) }
1576
+ : {}),
1577
+ };
1578
+ }
1579
+ return merged;
1580
+ }
1581
+
1198
1582
  function resolveRunnerRoutes(flags, mode) {
1199
1583
  const inlineRoute = normalizeRunnerRoute({
1200
1584
  name: flags["route-name"],
@@ -1221,45 +1605,76 @@ function resolveRunnerRoutes(flags, mode) {
1221
1605
  dedupe_outbound: flags["dedupe-outbound"],
1222
1606
  skip_bot_messages: flags["skip-bot-messages"],
1223
1607
  },
1608
+ dry_run_delivery: flags["dry-run-delivery"],
1224
1609
  workspace_dir: flags["workspace-dir"],
1225
1610
  command: flags.command,
1226
1611
  poll_interval_ms: flags["poll-interval-ms"],
1227
1612
  context_comments: flags["context-comments"],
1228
1613
  archive_replies: flags["archive-replies"],
1229
1614
  });
1230
- const inlineRequested = Boolean(
1231
- inlineRoute.projectID
1232
- || inlineRoute.command
1615
+ const selectionRequested = Boolean(
1616
+ String(flags["route-name"] || "").trim()
1617
+ || inlineRoute.projectID
1233
1618
  || inlineRoute.botID
1234
1619
  || inlineRoute.botName
1235
- || inlineRoute.roleProfile
1236
- || inlineRoute.destinationID
1237
- || inlineRoute.destinationLabel
1238
- || inlineRoute.archiveThreadID
1239
- || inlineRoute.archiveWorkItemID
1240
- || Object.prototype.hasOwnProperty.call(flags, "mentions-only")
1241
- || Object.prototype.hasOwnProperty.call(flags, "direct-messages")
1242
- || Object.prototype.hasOwnProperty.call(flags, "reply-to-bot-messages")
1243
- || Object.prototype.hasOwnProperty.call(flags, "ignore-edited-messages")
1244
- || Object.prototype.hasOwnProperty.call(flags, "dedupe-inbound")
1245
- || Object.prototype.hasOwnProperty.call(flags, "dedupe-outbound")
1246
- || Object.prototype.hasOwnProperty.call(flags, "skip-bot-messages")
1247
- || inlineRoute.workspaceDir
1248
- || flags.provider
1249
- || flags.role,
1620
+ || inlineRoute.roleProfile
1621
+ || inlineRoute.destinationID
1622
+ || inlineRoute.destinationLabel
1623
+ || inlineRoute.archiveThreadID
1624
+ || inlineRoute.archiveWorkItemID
1625
+ || flags.provider
1626
+ || flags.role
1627
+ );
1628
+ const overrideRequested = Boolean(
1629
+ inlineRoute.command
1630
+ || inlineRoute.workspaceDir
1631
+ || hasRunnerFlag(flags, "mentions-only")
1632
+ || hasRunnerFlag(flags, "direct-messages")
1633
+ || hasRunnerFlag(flags, "reply-to-bot-messages")
1634
+ || hasRunnerFlag(flags, "ignore-edited-messages")
1635
+ || hasRunnerFlag(flags, "dry-run-delivery")
1636
+ || hasRunnerFlag(flags, "dedupe-inbound")
1637
+ || hasRunnerFlag(flags, "dedupe-outbound")
1638
+ || hasRunnerFlag(flags, "skip-bot-messages")
1639
+ || hasRunnerFlag(flags, "archive-replies")
1640
+ || hasRunnerFlag(flags, "poll-interval-ms")
1641
+ || hasRunnerFlag(flags, "context-comments")
1642
+ );
1643
+ const inlineRequested = Boolean(
1644
+ selectionRequested
1645
+ || overrideRequested
1646
+ || hasRunnerFlag(flags, "project-id")
1647
+ || inlineRoute.projectID
1250
1648
  );
1251
- if (inlineRequested) {
1252
- return [inlineRoute];
1253
- }
1254
1649
 
1255
1650
  const config = loadBotRunnerConfig({ persistIfNeeded: true });
1256
1651
  const routeNameFilter = String(flags["route-name"] || "").trim().toLowerCase();
1257
- const routes = ensureArray(config.routes)
1652
+ const configuredRoutes = ensureArray(config.routes)
1258
1653
  .map((rawRoute) => normalizeRunnerRoute(rawRoute))
1259
1654
  .filter((route) => route.enabled)
1260
1655
  .filter((route) => !routeNameFilter || String(route.name || "").trim().toLowerCase() === routeNameFilter);
1261
- if (routes.length > 0) {
1262
- return routes;
1656
+
1657
+ if (selectionRequested) {
1658
+ const matchedRoutes = configuredRoutes.filter((route) => configuredRunnerRouteMatchesInlineSelection(route, inlineRoute, flags));
1659
+ if (matchedRoutes.length === 1) {
1660
+ return [applyRunnerRouteFlagOverrides(matchedRoutes[0], flags)];
1661
+ }
1662
+ if (matchedRoutes.length > 1) {
1663
+ const matchList = matchedRoutes
1664
+ .map((route) => route.name || runnerRouteKey(route))
1665
+ .join(", ");
1666
+ throw new Error(
1667
+ `Multiple enabled runner routes matched the provided filters. Narrow with --route-name, --bot-name, or --destination-label. Matches: ${matchList}`,
1668
+ );
1669
+ }
1670
+ }
1671
+
1672
+ if (inlineRequested) {
1673
+ return [inlineRoute];
1674
+ }
1675
+
1676
+ if (configuredRoutes.length > 0) {
1677
+ return configuredRoutes;
1263
1678
  }
1264
1679
 
1265
1680
  const help = routeNameFilter
@@ -2474,6 +2889,7 @@ function buildRunnerInputPayload({
2474
2889
  workspace_dir: String(executionPlan?.workspaceDir || "").trim(),
2475
2890
  workspace_source: String(executionPlan?.workspaceSource || "").trim(),
2476
2891
  command_fallback: executionPlan?.usedCommandFallback === true,
2892
+ dry_run_delivery: Boolean(route.dryRunDelivery),
2477
2893
  },
2478
2894
  trigger_policy: serializeRunnerTriggerPolicy(route.triggerPolicy),
2479
2895
  archive_policy: serializeRunnerArchivePolicy(route.archivePolicy),
@@ -2513,6 +2929,9 @@ function printRunnerResult(mode, result, jsonMode) {
2513
2929
  if (result.detail) {
2514
2930
  process.stdout.write(` detail: ${result.detail}\n`);
2515
2931
  }
2932
+ if (result.delivery?.dry_run) {
2933
+ process.stdout.write(" dry_run_delivery: true\n");
2934
+ }
2516
2935
  if (result.thread_id) {
2517
2936
  process.stdout.write(` thread: ${result.thread_id}\n`);
2518
2937
  }
@@ -2692,13 +3111,17 @@ async function processRunnerRouteOnce(route, runtime, mode) {
2692
3111
  contextWindow,
2693
3112
  executionPlan,
2694
3113
  });
2695
- const typingHeartbeat = startRunnerTypingHeartbeat({
2696
- provider: normalizedRoute.provider,
2697
- destination,
2698
- timeoutSeconds: runtime.timeoutSeconds,
2699
- action: "typing",
2700
- intervalMs: 4000,
2701
- });
3114
+ const typingHeartbeat = normalizedRoute.dryRunDelivery
3115
+ ? {
3116
+ async stop() {},
3117
+ }
3118
+ : startRunnerTypingHeartbeat({
3119
+ provider: normalizedRoute.provider,
3120
+ destination,
3121
+ timeoutSeconds: runtime.timeoutSeconds,
3122
+ action: "typing",
3123
+ intervalMs: 4000,
3124
+ });
2702
3125
  let aiResult;
2703
3126
  try {
2704
3127
  aiResult = await runRunnerAIExecution({
@@ -2752,6 +3175,7 @@ async function processRunnerRouteOnce(route, runtime, mode) {
2752
3175
  archiveDedupeOutbound: normalizedRoute.archivePolicy.dedupeOutbound,
2753
3176
  archiveThreadID: archiveThread.threadID,
2754
3177
  archiveWorkItemID: archiveThread.workItemID,
3178
+ dryRun: normalizedRoute.dryRunDelivery,
2755
3179
  });
2756
3180
  saveRunnerRouteState(
2757
3181
  routeKey,
@@ -2766,8 +3190,8 @@ async function processRunnerRouteOnce(route, runtime, mode) {
2766
3190
  return {
2767
3191
  route_key: routeKey,
2768
3192
  route_name: normalizedRoute.name,
2769
- outcome: "replied",
2770
- detail: `replied as ${bot.name || bot.id}${executionPlan.usedCommandFallback ? " (legacy command fallback)" : ""}`,
3193
+ outcome: deliveryResult.delivery.dryRun ? "dry_run" : "replied",
3194
+ detail: `${deliveryResult.delivery.dryRun ? "dry-run prepared" : "replied"} as ${bot.name || bot.id}${executionPlan.usedCommandFallback ? " (legacy command fallback)" : ""}`,
2771
3195
  thread_id: archiveThread.threadID,
2772
3196
  comment_id: selectedRecord.id,
2773
3197
  execution_mode: executionPlan.mode,
@@ -4857,6 +5281,9 @@ function collectRunnerRouteDiagnostics(route, runnerConfig) {
4857
5281
  if (normalizedRoute.command) {
4858
5282
  warnings.push("legacy command fallback is still configured");
4859
5283
  }
5284
+ if (normalizedRoute.dryRunDelivery) {
5285
+ warnings.push("dry_run_delivery is enabled");
5286
+ }
4860
5287
  if (normalizedRoute.workspaceDir) {
4861
5288
  warnings.push("legacy route.workspace_dir override is still configured");
4862
5289
  }
@@ -4940,6 +5367,7 @@ async function runDoctor(flags) {
4940
5367
  addDoctorCheck(rows, "warn", "local bot runner config", `${runnerTemplate.error} (${runnerTemplate.filePath})`);
4941
5368
  } else {
4942
5369
  const runnerConfig = loadBotRunnerConfig({ persistIfNeeded: true });
5370
+ const runnerState = loadBotRunnerState();
4943
5371
  const enabledRoutes = ensureArray(runnerConfig.routes).filter((routeRaw) => safeObject(routeRaw).enabled);
4944
5372
  if (!enabledRoutes.length) {
4945
5373
  addDoctorCheck(rows, "warn", "local bot runner config", `template ready (${runnerTemplate.filePath}), no enabled routes`);
@@ -4996,6 +5424,24 @@ async function runDoctor(flags) {
4996
5424
  }
4997
5425
  }
4998
5426
  }
5427
+ if (runnerState.migrated && runnerState.migratedKeys.length > 0) {
5428
+ addDoctorCheck(
5429
+ rows,
5430
+ "ok",
5431
+ "runner state migration",
5432
+ `migrated ${runnerState.migratedKeys.length} stale route key(s) in ${runnerState.filePath}`,
5433
+ );
5434
+ }
5435
+ if (runnerState.remainingAnonymousKeys.length > 0) {
5436
+ addDoctorCheck(
5437
+ rows,
5438
+ "warn",
5439
+ "runner state migration",
5440
+ `anonymous route keys remain in ${runnerState.filePath}: ${runnerState.remainingAnonymousKeys.join(", ")}`,
5441
+ );
5442
+ } else {
5443
+ addDoctorCheck(rows, "ok", "runner state migration", "no stale anonymous route keys detected");
5444
+ }
4999
5445
  }
5000
5446
 
5001
5447
  const resolved = await resolveAccessTokenForCommand(context.baseURL, timeoutSeconds);
@@ -6855,6 +7301,10 @@ function patchServerToolSpecsForLocalRouting(tools) {
6855
7301
  type: "string",
6856
7302
  description: "Optional project chat destination label when a project has multiple active destinations.",
6857
7303
  },
7304
+ dry_run_delivery: {
7305
+ type: "boolean",
7306
+ description: "If true, resolve bot and destination locally but do not send the provider message or mirror an archive reply.",
7307
+ },
6858
7308
  },
6859
7309
  };
6860
7310
  return {
@@ -6966,6 +7416,7 @@ async function performLocalBotDelivery({
6966
7416
  archiveDedupeOutbound = true,
6967
7417
  archiveThreadID = "",
6968
7418
  archiveWorkItemID = "",
7419
+ dryRun = false,
6969
7420
  }) {
6970
7421
  const normalizedProvider = normalizeBotProvider(provider);
6971
7422
  const destinations = await listProjectChatDestinations({
@@ -6980,15 +7431,35 @@ async function performLocalBotDelivery({
6980
7431
  throw new Error(`local ${normalizedProvider} env is not ready (${providerEnv.error})`);
6981
7432
  }
6982
7433
 
6983
- const delivery = await deliverLocalProviderMessage({
6984
- provider: normalizedProvider,
6985
- token: providerEnv.token,
6986
- destination,
6987
- text,
6988
- disableWebPagePreview,
6989
- replyToMessageID,
6990
- timeoutSeconds,
6991
- });
7434
+ let delivery;
7435
+ if (dryRun) {
7436
+ delivery = {
7437
+ statusCode: 204,
7438
+ body: {
7439
+ ok: true,
7440
+ dry_run: true,
7441
+ provider: normalizedProvider,
7442
+ text,
7443
+ disable_web_page_preview: Boolean(disableWebPagePreview),
7444
+ reply_to_message_id: intFromRawAllowZero(replyToMessageID, 0),
7445
+ },
7446
+ ok: true,
7447
+ url: "",
7448
+ dryRun: true,
7449
+ effectiveReplyToMessageID: intFromRawAllowZero(replyToMessageID, 0),
7450
+ replySupported: normalizedProvider === "telegram",
7451
+ };
7452
+ } else {
7453
+ delivery = await deliverLocalProviderMessage({
7454
+ provider: normalizedProvider,
7455
+ token: providerEnv.token,
7456
+ destination,
7457
+ text,
7458
+ disableWebPagePreview,
7459
+ replyToMessageID,
7460
+ timeoutSeconds,
7461
+ });
7462
+ }
6992
7463
  if (!delivery.ok) {
6993
7464
  const responseJSON = safeObject(delivery.body);
6994
7465
  const errorDetail =
@@ -7016,6 +7487,22 @@ async function performLocalBotDelivery({
7016
7487
  archiveThreadID,
7017
7488
  archiveWorkItemID,
7018
7489
  });
7490
+ if (dryRun) {
7491
+ archive = {
7492
+ ok: true,
7493
+ dry_run: true,
7494
+ skipped: true,
7495
+ thread_id: thread.threadID,
7496
+ work_item_id: thread.workItemID,
7497
+ reason: "dry_run_delivery",
7498
+ };
7499
+ return {
7500
+ provider: normalizedProvider,
7501
+ destination,
7502
+ delivery,
7503
+ archive,
7504
+ };
7505
+ }
7019
7506
  const deliveredBody = safeObject(delivery.body);
7020
7507
  const deliveredResult = safeObject(deliveredBody.result || deliveredBody.message || deliveredBody.data);
7021
7508
  const deliveredMessageID = intFromRawAllowZero(
@@ -7154,6 +7641,12 @@ async function handleLocalBotMessageToolCall({
7154
7641
  : toolArgs.archiveReplies,
7155
7642
  true,
7156
7643
  );
7644
+ const dryRunDelivery = boolFromRaw(
7645
+ Object.prototype.hasOwnProperty.call(toolArgs, "dry_run_delivery")
7646
+ ? toolArgs.dry_run_delivery
7647
+ : toolArgs.dryRunDelivery,
7648
+ false,
7649
+ );
7157
7650
  const result = await performLocalBotDelivery({
7158
7651
  siteBaseURL,
7159
7652
  token,
@@ -7174,6 +7667,7 @@ async function handleLocalBotMessageToolCall({
7174
7667
  disableWebPagePreview,
7175
7668
  replyToMessageID,
7176
7669
  archiveReplies,
7670
+ dryRun: dryRunDelivery,
7177
7671
  });
7178
7672
 
7179
7673
  return jsonRpcResult(
@@ -7192,6 +7686,7 @@ async function handleLocalBotMessageToolCall({
7192
7686
  status: result.delivery.statusCode,
7193
7687
  url: result.delivery.url,
7194
7688
  body: result.delivery.body,
7689
+ dry_run_delivery: Boolean(result.delivery.dryRun),
7195
7690
  reply_supported: result.delivery.replySupported,
7196
7691
  archive: result.archive,
7197
7692
  }),
@@ -9122,6 +9617,176 @@ async function runSelftest(flags = {}) {
9122
9617
  `mode=${commandFallbackPlan.mode} fallback=${String(commandFallbackPlan.fallbackReason || "(none)")}`,
9123
9618
  );
9124
9619
 
9620
+ let runnerRouteResolveTempRoot = "";
9621
+ let originalResolveHome;
9622
+ let originalResolveUserProfile;
9623
+ try {
9624
+ runnerRouteResolveTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-runner-route-selftest-"));
9625
+ originalResolveHome = process.env.HOME;
9626
+ originalResolveUserProfile = process.env.USERPROFILE;
9627
+ const resolveHome = path.join(runnerRouteResolveTempRoot, "home");
9628
+ const resolveMetheusDir = path.join(resolveHome, ".metheus");
9629
+ fs.mkdirSync(resolveMetheusDir, { recursive: true });
9630
+ fs.writeFileSync(
9631
+ path.join(resolveMetheusDir, "bot-runner.json"),
9632
+ `${JSON.stringify({
9633
+ version: 2,
9634
+ project_mappings: {
9635
+ [selftestProjectID]: {
9636
+ workspace_dir: path.join(runnerRouteResolveTempRoot, "workspace"),
9637
+ source: "selftest",
9638
+ },
9639
+ },
9640
+ role_profiles: defaultBotRunnerRoleProfiles(),
9641
+ routes: [
9642
+ {
9643
+ name: "telegram-monitor",
9644
+ enabled: true,
9645
+ project_id: selftestProjectID,
9646
+ provider: "telegram",
9647
+ role: "monitor",
9648
+ role_profile: "monitor",
9649
+ bot_name: "RyoAI_bot",
9650
+ destination_label: "AI incubating CHAT ROOM",
9651
+ archive_work_item_id: "304ce77a-7032-421c-aeda-bc54daf088dd",
9652
+ },
9653
+ ],
9654
+ }, null, 2)}\n`,
9655
+ "utf8",
9656
+ );
9657
+ process.env.HOME = resolveHome;
9658
+ process.env.USERPROFILE = resolveHome;
9659
+ const resolvedRunnerRoutes = resolveRunnerRoutes(
9660
+ {
9661
+ "project-id": selftestProjectID,
9662
+ provider: "telegram",
9663
+ role: "monitor",
9664
+ "role-profile": "monitor",
9665
+ "bot-name": "RyoAI_bot",
9666
+ "dry-run-delivery": true,
9667
+ },
9668
+ "once",
9669
+ );
9670
+ const resolvedRunnerRoute = normalizeRunnerRoute(resolvedRunnerRoutes[0]);
9671
+ push(
9672
+ "bot_runner_inline_filters_reuse_configured_route",
9673
+ resolvedRunnerRoutes.length === 1
9674
+ && resolvedRunnerRoute.name === "telegram-monitor"
9675
+ && resolvedRunnerRoute.destinationLabel === "AI incubating CHAT ROOM"
9676
+ && resolvedRunnerRoute.dryRunDelivery === true
9677
+ && runnerRouteKey(resolvedRunnerRoute) === "telegram-monitor::11111111-1111-1111-1111-111111111111::telegram::monitor::RyoAI_bot::AI incubating CHAT ROOM",
9678
+ `name=${resolvedRunnerRoute.name || "(none)"} destination=${resolvedRunnerRoute.destinationLabel || "(none)"} key=${runnerRouteKey(resolvedRunnerRoute)}`,
9679
+ );
9680
+ } catch (err) {
9681
+ push("bot_runner_inline_filters_reuse_configured_route", false, String(err?.message || err));
9682
+ } finally {
9683
+ if (typeof originalResolveHome === "string") {
9684
+ process.env.HOME = originalResolveHome;
9685
+ } else {
9686
+ delete process.env.HOME;
9687
+ }
9688
+ if (typeof originalResolveUserProfile === "string") {
9689
+ process.env.USERPROFILE = originalResolveUserProfile;
9690
+ } else {
9691
+ delete process.env.USERPROFILE;
9692
+ }
9693
+ if (runnerRouteResolveTempRoot) {
9694
+ try {
9695
+ fs.rmSync(runnerRouteResolveTempRoot, { recursive: true, force: true });
9696
+ } catch {}
9697
+ }
9698
+ }
9699
+
9700
+ let runnerStateMigrationTempRoot = "";
9701
+ let originalStateMigrationHome;
9702
+ let originalStateMigrationUserProfile;
9703
+ try {
9704
+ runnerStateMigrationTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-runner-state-selftest-"));
9705
+ originalStateMigrationHome = process.env.HOME;
9706
+ originalStateMigrationUserProfile = process.env.USERPROFILE;
9707
+ const migrationHome = path.join(runnerStateMigrationTempRoot, "home");
9708
+ const migrationMetheusDir = path.join(migrationHome, ".metheus");
9709
+ fs.mkdirSync(migrationMetheusDir, { recursive: true });
9710
+ fs.writeFileSync(
9711
+ path.join(migrationMetheusDir, "bot-runner.json"),
9712
+ `${JSON.stringify({
9713
+ version: 2,
9714
+ project_mappings: {
9715
+ [selftestProjectID]: {
9716
+ workspace_dir: path.join(runnerStateMigrationTempRoot, "workspace"),
9717
+ source: "selftest",
9718
+ },
9719
+ },
9720
+ role_profiles: defaultBotRunnerRoleProfiles(),
9721
+ routes: [
9722
+ {
9723
+ name: "telegram-monitor",
9724
+ enabled: true,
9725
+ project_id: selftestProjectID,
9726
+ provider: "telegram",
9727
+ role: "monitor",
9728
+ role_profile: "monitor",
9729
+ bot_name: "RyoAI_bot",
9730
+ destination_label: "AI incubating CHAT ROOM",
9731
+ },
9732
+ ],
9733
+ }, null, 2)}\n`,
9734
+ "utf8",
9735
+ );
9736
+ fs.writeFileSync(
9737
+ path.join(migrationMetheusDir, "bot-runner-state.json"),
9738
+ `${JSON.stringify({
9739
+ version: 1,
9740
+ routes: {
9741
+ "-::11111111-1111-1111-1111-111111111111::telegram::monitor::RyoAI_bot::-": {
9742
+ last_processed_comment_id: "comment-a",
9743
+ last_processed_created_at: "2026-03-13T06:00:00.000Z",
9744
+ last_source_message_id: 41,
9745
+ last_source_kind: "telegram_message",
9746
+ updated_at: "2026-03-13T06:00:01.000Z",
9747
+ last_action: "replied",
9748
+ last_reply_message_id: 501,
9749
+ },
9750
+ },
9751
+ }, null, 2)}\n`,
9752
+ "utf8",
9753
+ );
9754
+ process.env.HOME = migrationHome;
9755
+ process.env.USERPROFILE = migrationHome;
9756
+ const migratedRunnerState = loadBotRunnerState();
9757
+ const canonicalRunnerStateKey = "telegram-monitor::11111111-1111-1111-1111-111111111111::telegram::monitor::RyoAI_bot::AI incubating CHAT ROOM";
9758
+ const persistedRunnerState = tryJsonParse(
9759
+ fs.readFileSync(path.join(migrationMetheusDir, "bot-runner-state.json"), "utf8"),
9760
+ );
9761
+ push(
9762
+ "bot_runner_state_auto_migrates_anonymous_route_key",
9763
+ migratedRunnerState.migrated === true
9764
+ && migratedRunnerState.migratedKeys.length === 1
9765
+ && Boolean(migratedRunnerState.routes[canonicalRunnerStateKey])
9766
+ && !migratedRunnerState.routes["-::11111111-1111-1111-1111-111111111111::telegram::monitor::RyoAI_bot::-"]
9767
+ && Boolean(safeObject(persistedRunnerState.routes)[canonicalRunnerStateKey]),
9768
+ `migrated=${String(migratedRunnerState.migrated)} key=${canonicalRunnerStateKey}`,
9769
+ );
9770
+ } catch (err) {
9771
+ push("bot_runner_state_auto_migrates_anonymous_route_key", false, String(err?.message || err));
9772
+ } finally {
9773
+ if (typeof originalStateMigrationHome === "string") {
9774
+ process.env.HOME = originalStateMigrationHome;
9775
+ } else {
9776
+ delete process.env.HOME;
9777
+ }
9778
+ if (typeof originalStateMigrationUserProfile === "string") {
9779
+ process.env.USERPROFILE = originalStateMigrationUserProfile;
9780
+ } else {
9781
+ delete process.env.USERPROFILE;
9782
+ }
9783
+ if (runnerStateMigrationTempRoot) {
9784
+ try {
9785
+ fs.rmSync(runnerStateMigrationTempRoot, { recursive: true, force: true });
9786
+ } catch {}
9787
+ }
9788
+ }
9789
+
9125
9790
  const defaultMonitorTriggerPolicy = normalizeRunnerTriggerPolicy({}, { role: "monitor" });
9126
9791
  push(
9127
9792
  "bot_runner_default_monitor_trigger_policy",
@@ -9356,6 +10021,35 @@ async function runSelftest(flags = {}) {
9356
10021
  && intFromRawAllowZero(e2eState.last_reply_message_id, 0) === 501,
9357
10022
  `outcome=${e2eResult.outcome} sent=${telegramE2EServer.state.sentMessages.length} mirrored=${String(mirroredReply?.id || "(none)")} state_reply=${String(e2eState.last_reply_message_id || "(none)")}`,
9358
10023
  );
10024
+ const sentCountBeforeDryRun = telegramE2EServer.state.sentMessages.length;
10025
+ const commentCountBeforeDryRun = telegramE2EServer.state.comments.length;
10026
+ const dryRunResult = await performLocalBotDelivery({
10027
+ siteBaseURL: telegramE2EServer.baseURL,
10028
+ token: e2eToken,
10029
+ timeoutSeconds: 10,
10030
+ actorUserID: e2eActorUserID,
10031
+ bot: e2eBot,
10032
+ projectID: selftestProjectID,
10033
+ provider: "telegram",
10034
+ destinationSelectors: {
10035
+ destinationLabel: e2eDestination.label,
10036
+ },
10037
+ text: "dry-run smoke",
10038
+ disableWebPagePreview: true,
10039
+ replyToMessageID: 41,
10040
+ archiveReplies: true,
10041
+ archiveThreadID: e2eThreadID,
10042
+ archiveWorkItemID: e2eWorkItemID,
10043
+ dryRun: true,
10044
+ });
10045
+ push(
10046
+ "telegram_delivery_dry_run_skips_send_and_archive",
10047
+ Boolean(dryRunResult.delivery?.dryRun)
10048
+ && String(dryRunResult.archive?.reason || "") === "dry_run_delivery"
10049
+ && telegramE2EServer.state.sentMessages.length === sentCountBeforeDryRun
10050
+ && telegramE2EServer.state.comments.length === commentCountBeforeDryRun,
10051
+ `dry_run=${String(dryRunResult.delivery?.dryRun || false)} sent=${telegramE2EServer.state.sentMessages.length} comments=${telegramE2EServer.state.comments.length}`,
10052
+ );
9359
10053
  } catch (err) {
9360
10054
  push("telegram_runner_e2e_local_mock", false, String(err?.message || err));
9361
10055
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.60",
3
+ "version": "0.2.61",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [