@analyticscli/growth-engineer 0.1.1-preview.0 → 0.1.1-preview.11

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.
@@ -11,7 +11,11 @@ const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
11
11
  const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
12
12
  const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
13
13
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
14
+ const DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MULTIPLIER = 2;
15
+ const DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MIN_DELTA = 10;
16
+ const DEFAULT_DAILY_RUNNER_FAILURE_RETENTION_DAYS = 14;
14
17
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
+ const SELF_UPDATE_SKILL_SLUG_CANDIDATES = ['growth-engineer', 'openclaw-growth-engineer'];
15
19
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
20
  let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
17
21
  const DEFAULT_CADENCES = [
@@ -144,6 +148,7 @@ function resolveRuntimeScriptPath(scriptName) {
144
148
  const candidates = [
145
149
  path.join(RUNTIME_DIR, scriptName),
146
150
  path.resolve('scripts', scriptName),
151
+ path.resolve('skills/growth-engineer/scripts', scriptName),
147
152
  path.resolve('skills/openclaw-growth-engineer/scripts', scriptName),
148
153
  ];
149
154
  return candidates.find((candidate) => existsSync(candidate)) || path.join(RUNTIME_DIR, scriptName);
@@ -164,7 +169,7 @@ function commandIsBuiltinExporter(command) {
164
169
  return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-analytics-summary|export-revenuecat-summary|export-paddle-summary|export-seo-summary|export-sentry-summary|export-coolify-summary|export-asc-summary)\.mjs(?:\s|$)/.test(String(command || ''));
165
170
  }
166
171
  function commandSupportsActiveConfig(command) {
167
- return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-sentry-summary|export-coolify-summary)\.mjs(?:\s|$)/.test(String(command || ''));
172
+ return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-paddle-summary|export-sentry-summary|export-coolify-summary)\.mjs(?:\s|$)/.test(String(command || ''));
168
173
  }
169
174
  function withActiveConfigArg(command, configPath) {
170
175
  const trimmed = String(command || '').trim();
@@ -247,9 +252,13 @@ function isSentryCompatibleSource(sourceConfig, sourceName) {
247
252
  function shouldDegradeTransientSourceFailure(sourceConfig, sourceName, retried) {
248
253
  if (!retried)
249
254
  return false;
255
+ if (sourceConfig?.degradeTransientFailures === false)
256
+ return false;
250
257
  if (!isRequiredSource(sourceConfig, sourceName))
251
258
  return true;
252
- return isSentryCompatibleSource(sourceConfig, sourceName);
259
+ if (isSentryCompatibleSource(sourceConfig, sourceName))
260
+ return true;
261
+ return sourceConfig?.degradeRequiredTransientFailures !== false;
253
262
  }
254
263
  function isTruthyEnv(value) {
255
264
  return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
@@ -327,6 +336,23 @@ async function rerunCurrentProcessWithoutSelfUpdate() {
327
336
  child.on('close', (code) => resolve(code));
328
337
  });
329
338
  }
339
+ function getSelfUpdateSkillCandidates(workspaceRoot) {
340
+ const explicit = String(process.env.OPENCLAW_GROWTH_SKILL_SLUG || '').trim();
341
+ const uniqueSlugs = [...new Set([explicit, ...SELF_UPDATE_SKILL_SLUG_CANDIDATES].filter(Boolean))];
342
+ return uniqueSlugs.map((slug) => {
343
+ const skillRoot = path.join(workspaceRoot, 'skills', slug);
344
+ return {
345
+ slug,
346
+ skillRoot,
347
+ originPath: path.join(skillRoot, '.clawhub/origin.json'),
348
+ runnerPath: path.join(skillRoot, 'scripts/openclaw-growth-runner.mjs'),
349
+ bootstrapPath: path.join(skillRoot, 'scripts/bootstrap-openclaw-workspace.sh'),
350
+ };
351
+ });
352
+ }
353
+ function resolveInstalledSelfUpdateSkill(workspaceRoot) {
354
+ return getSelfUpdateSkillCandidates(workspaceRoot).find((candidate) => existsSync(candidate.originPath)) || null;
355
+ }
330
356
  async function maybeSelfUpdateFromClawHub(args) {
331
357
  if (args.noSelfUpdate)
332
358
  return false;
@@ -337,26 +363,27 @@ async function maybeSelfUpdateFromClawHub(args) {
337
363
  if (isFalseyEnv(process.env.OPENCLAW_GROWTH_SELF_UPDATE))
338
364
  return false;
339
365
  const workspaceRoot = process.cwd();
340
- const skillOriginPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/.clawhub/origin.json');
341
- if (!existsSync(skillOriginPath))
366
+ const installedSkill = resolveInstalledSelfUpdateSkill(workspaceRoot);
367
+ if (!installedSkill)
342
368
  return false;
343
369
  if (!(await commandExists('npx')))
344
370
  return false;
345
371
  const force = String(process.env.OPENCLAW_GROWTH_SELF_UPDATE || '').trim().toLowerCase() === 'always';
346
372
  if (!(await shouldRunSelfUpdate(workspaceRoot, force)))
347
373
  return false;
348
- const beforeOrigin = await readJsonOptional(skillOriginPath, null);
374
+ const beforeOrigin = await readJsonOptional(installedSkill.originPath, null);
349
375
  const beforeVersion = String(beforeOrigin?.installedVersion || '');
350
- process.stdout.write('Checking for OpenClaw Growth Engineer skill updates...\n');
351
- const updateResult = await runShellCommand('npx -y clawhub --no-input --dir skills update openclaw-growth-engineer --force', 120_000);
352
- const afterOrigin = await readJsonOptional(skillOriginPath, null);
376
+ process.stdout.write(`Checking for Growth Engineer skill updates (${installedSkill.slug})...\n`);
377
+ const updateResult = await runShellCommand(`npx -y clawhub --no-input --dir skills update ${quote(installedSkill.slug)} --force`, 120_000);
378
+ const afterOrigin = await readJsonOptional(installedSkill.originPath, null);
353
379
  const afterVersion = String(afterOrigin?.installedVersion || beforeVersion || '');
354
380
  const workspaceRunnerPath = path.resolve(process.argv[1] || 'scripts/openclaw-growth-runner.mjs');
355
- const skillRunnerPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/scripts/openclaw-growth-runner.mjs');
356
- const runtimeOutdated = !(await filesHaveSameContent(workspaceRunnerPath, skillRunnerPath));
381
+ const runtimeOutdated = !(await filesHaveSameContent(workspaceRunnerPath, installedSkill.runnerPath));
357
382
  await writeSelfUpdateState(workspaceRoot, {
358
383
  lastCheckedAt: new Date().toISOString(),
359
384
  ok: updateResult.ok,
385
+ skillSlug: installedSkill.slug,
386
+ skillRoot: installedSkill.skillRoot,
360
387
  previousVersion: beforeVersion || null,
361
388
  installedVersion: afterVersion || null,
362
389
  }).catch(() => { });
@@ -370,7 +397,7 @@ async function maybeSelfUpdateFromClawHub(args) {
370
397
  process.stdout.write(afterVersion && afterVersion !== beforeVersion
371
398
  ? `Updated OpenClaw Growth Engineer skill ${beforeVersion || 'unknown'} -> ${afterVersion}. Refreshing workspace runtime...\n`
372
399
  : 'Refreshing workspace runtime from the installed OpenClaw Growth Engineer skill...\n');
373
- const bootstrapResult = await runShellCommand('bash skills/openclaw-growth-engineer/scripts/bootstrap-openclaw-workspace.sh', 60_000);
400
+ const bootstrapResult = await runShellCommand(`bash ${quote(installedSkill.bootstrapPath)}`, 60_000);
374
401
  if (!bootstrapResult.ok) {
375
402
  process.stdout.write('Workspace runtime refresh failed; continuing with current process.\n');
376
403
  return false;
@@ -399,6 +426,27 @@ function resolveShellCommand() {
399
426
  function hardenUnattendedShellCommand(command) {
400
427
  return String(command || '').replace(/(^|[;&|]\s*)sudo(?!\s+-n(?:\s|$))(?=\s|$)/g, '$1sudo -n');
401
428
  }
429
+ function redactCommandForDiagnostics(command) {
430
+ const raw = String(command || '').trim();
431
+ if (!raw)
432
+ return '';
433
+ return raw
434
+ .replace(/((?:^|\s)(?:[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY|AUTH|CREDENTIAL)[A-Z0-9_]*)=)(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]')
435
+ .replace(/((?:^|\s)--(?:token|access-token|api-key|key|secret|password|pass|authorization|auth|bearer)(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]');
436
+ }
437
+ function truncateDiagnosticText(value, maxLength = 2000) {
438
+ const text = String(value || '').trim();
439
+ if (text.length <= maxLength)
440
+ return text;
441
+ return `${text.slice(0, maxLength - 1)}…`;
442
+ }
443
+ function buildSourceCommandFailureMessage(sourceName, resolvedCommand, detail) {
444
+ const safeCommand = redactCommandForDiagnostics(resolvedCommand);
445
+ const safeDetail = truncateDiagnosticText(detail);
446
+ return safeCommand
447
+ ? `Source "${sourceName}" command failed: command \`${safeCommand}\`; ${safeDetail}`
448
+ : `Source "${sourceName}" command failed: ${safeDetail}`;
449
+ }
402
450
  function isSudoPasswordPrompt(stderr) {
403
451
  return /sudo: (?:a password is required|a terminal is required to read the password|no tty present)/i.test(String(stderr || ''));
404
452
  }
@@ -410,6 +458,7 @@ function runShellCommand(command, timeoutMs = 120_000, options = {}) {
410
458
  cwd: options.cwd,
411
459
  env: {
412
460
  ...process.env,
461
+ ...(options.env || {}),
413
462
  DEBIAN_FRONTEND: 'noninteractive',
414
463
  SUDO_ASKPASS: '/bin/false',
415
464
  SUDO_PROMPT: '',
@@ -644,7 +693,8 @@ function buildConnectorWizardCommand(configPath, entry) {
644
693
  const connector = connectorWizardKey(entry.key);
645
694
  if (!connector)
646
695
  return null;
647
- return `npx -y @analyticscli/growth-engineer@preview wizard --connectors ${quote(connector)}`;
696
+ const configArg = configPath ? ` --config ${quote(configPath)}` : '';
697
+ return `npx -y @analyticscli/growth-engineer@preview wizard${configArg} --connectors ${quote(connector)}`;
648
698
  }
649
699
  function conciseConnectorDetail(entry) {
650
700
  const detail = String(entry?.detail || '').replace(/\s+/g, ' ').trim();
@@ -851,6 +901,8 @@ function notificationChannelKey(channel) {
851
901
  return `slack:${channel?.label || channel?.webhookEnv || 'slack'}`;
852
902
  if (type === 'webhook')
853
903
  return `webhook:${channel?.label || channel?.urlEnv || channel?.webhookEnv || 'webhook'}`;
904
+ if (type === 'discord')
905
+ return `discord:${channel?.label || channel?.command || 'discord'}`;
854
906
  if (type === 'command')
855
907
  return `command:${channel?.label || channel?.command || 'command'}`;
856
908
  return `${type}:${channel?.label || type}`;
@@ -907,7 +959,7 @@ function getDeliveryNotificationChannels(config, kind) {
907
959
  }
908
960
  if (deliveries.discord?.enabled) {
909
961
  channels.push({
910
- type: 'command',
962
+ type: 'discord',
911
963
  label: deliveries.discord.label || 'discord',
912
964
  command: deliveries.discord.command || '',
913
965
  });
@@ -1001,7 +1053,23 @@ async function sendCommandConnectorHealthAlert(channel, message) {
1001
1053
  sent: result.ok,
1002
1054
  external: true,
1003
1055
  target: channel.label || 'command',
1004
- detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
1056
+ detail: result.ok ? 'sent' : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
1057
+ };
1058
+ }
1059
+ async function sendDiscordConnectorHealthAlert(channel, message, statusPayload, unhealthyConnectors, fingerprint) {
1060
+ if (!channel.command) {
1061
+ return { sent: false, target: channel.label || 'discord', detail: 'discord command not configured' };
1062
+ }
1063
+ const payload = buildDiscordConnectorHealthPayload(message, statusPayload, unhealthyConnectors, fingerprint);
1064
+ const result = await runShellCommand(String(channel.command), 60_000, {
1065
+ input: JSON.stringify(payload),
1066
+ env: { OPENCLAW_DISCORD_DELIVERY_FORMAT: 'embed' },
1067
+ });
1068
+ return {
1069
+ sent: result.ok,
1070
+ external: true,
1071
+ target: channel.label || 'discord',
1072
+ detail: result.ok ? 'sent' : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
1005
1073
  };
1006
1074
  }
1007
1075
  function hasExternalNotificationChannel(channels) {
@@ -1010,6 +1078,57 @@ function hasExternalNotificationChannel(channels) {
1010
1078
  function hasSuccessfulExternalDelivery(results) {
1011
1079
  return results.some((result) => result?.sent === true && result?.external === true);
1012
1080
  }
1081
+ function discordTruncate(value, maxLength) {
1082
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
1083
+ if (text.length <= maxLength)
1084
+ return text;
1085
+ return `${text.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
1086
+ }
1087
+ function discordField(name, value, inline = false) {
1088
+ return {
1089
+ name: discordTruncate(name, 256) || 'Detail',
1090
+ value: discordTruncate(value, 1024) || '-',
1091
+ inline,
1092
+ };
1093
+ }
1094
+ function connectorStatusColor(unhealthyConnectors) {
1095
+ return unhealthyConnectors.some((entry) => String(entry?.status || '').toLowerCase() === 'blocked')
1096
+ ? 0xd92d20
1097
+ : 0xf79009;
1098
+ }
1099
+ function buildDiscordConnectorHealthPayload(message, statusPayload, unhealthyConnectors, fingerprint) {
1100
+ const fields = unhealthyConnectors.slice(0, 10).map((entry) => {
1101
+ const command = buildConnectorWizardCommand(statusPayload?.configPath || DEFAULT_CONFIG_PATH, entry);
1102
+ const parts = [
1103
+ `Status: ${entry.status || 'blocked'}`,
1104
+ conciseConnectorDetail(entry),
1105
+ command ? `Fix: \`${command}\`` : null,
1106
+ isAscWebAuthIssue(entry)
1107
+ ? 'ASC web-auth only: `ASC_WEB_APPLE_ID="<apple-id>" asc web auth login --apple-id "$ASC_WEB_APPLE_ID"`'
1108
+ : null,
1109
+ ].filter(Boolean);
1110
+ return discordField(humanConnectorName(entry.key), parts.join('\n'));
1111
+ });
1112
+ if (unhealthyConnectors.length > 10) {
1113
+ fields.push(discordField('More issues', `${unhealthyConnectors.length - 10} additional connector(s) need attention.`));
1114
+ }
1115
+ return {
1116
+ content: '',
1117
+ embeds: [
1118
+ {
1119
+ title: `OpenClaw connector health: ${unhealthyConnectors.length} issue(s)`,
1120
+ description: 'Secrets stay in the host terminal or secret store.',
1121
+ color: connectorStatusColor(unhealthyConnectors),
1122
+ fields,
1123
+ footer: {
1124
+ text: `CONNECTOR_HEALTH_ALERT • ${String(fingerprint || '').slice(0, 12)}`,
1125
+ },
1126
+ timestamp: statusPayload?.generatedAt || new Date().toISOString(),
1127
+ },
1128
+ ],
1129
+ fallbackText: message,
1130
+ };
1131
+ }
1013
1132
  function truncateMessageText(value, maxLength = 96) {
1014
1133
  const text = String(value || '').replace(/\s+/g, ' ').trim();
1015
1134
  if (text.length <= maxLength)
@@ -1042,6 +1161,323 @@ function groupIssuesByProject(issues, maxIssues = 4) {
1042
1161
  }
1043
1162
  return [...grouped.entries()];
1044
1163
  }
1164
+ function getDailyIssueDedupeConfig(config) {
1165
+ const raw = config?.schedule?.dailyIssueDedupe || config?.notifications?.growthRun?.dailyIssueDedupe || {};
1166
+ const multiplier = Number(raw.eventGrowthMultiplier ?? raw.multiplier);
1167
+ const minDelta = Number(raw.eventGrowthMinDelta ?? raw.minDelta);
1168
+ return {
1169
+ enabled: raw.enabled !== false,
1170
+ eventGrowthMultiplier: Number.isFinite(multiplier) && multiplier > 1
1171
+ ? multiplier
1172
+ : DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MULTIPLIER,
1173
+ eventGrowthMinDelta: Number.isFinite(minDelta) && minDelta > 0
1174
+ ? minDelta
1175
+ : DEFAULT_DAILY_ISSUE_EVENT_GROWTH_MIN_DELTA,
1176
+ };
1177
+ }
1178
+ function getDailyRunnerFailureDedupeConfig(config) {
1179
+ const raw = config?.schedule?.dailyRunnerFailureDedupe || config?.notifications?.growthRun?.dailyRunnerFailureDedupe || {};
1180
+ const retentionDays = Number(raw.retentionDays);
1181
+ return {
1182
+ enabled: raw.enabled !== false,
1183
+ retentionDays: Number.isFinite(retentionDays) && retentionDays > 0
1184
+ ? retentionDays
1185
+ : DEFAULT_DAILY_RUNNER_FAILURE_RETENTION_DAYS,
1186
+ };
1187
+ }
1188
+ function resolveDailyIssueDedupeTimeZone(config) {
1189
+ return String(config?.schedule?.timezone ||
1190
+ config?.automation?.openclawCron?.timezone ||
1191
+ process.env.TZ ||
1192
+ 'UTC').trim() || 'UTC';
1193
+ }
1194
+ function normalizeRunnerFailureForFingerprint(errorMessage) {
1195
+ return redactCommandForDiagnostics(errorMessage)
1196
+ .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, '[timestamp]')
1197
+ .replace(/--since(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/g, '--since [timestamp]')
1198
+ .replace(/--until(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/g, '--until [timestamp]')
1199
+ .replace(/\bpid\s+\d+\b/gi, 'pid [pid]')
1200
+ .replace(/\s+/g, ' ')
1201
+ .trim();
1202
+ }
1203
+ function buildRunnerFailureFingerprint(errorMessage) {
1204
+ return sha256(normalizeRunnerFailureForFingerprint(errorMessage));
1205
+ }
1206
+ function pruneDailyRunnerFailures(failures, now, retentionDays) {
1207
+ const cutoffMs = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
1208
+ return Object.fromEntries(Object.entries(failures || {}).filter(([, entry]) => {
1209
+ const lastSeenMs = Date.parse(String(entry?.lastSeenAt || entry?.firstSeenAt || ''));
1210
+ return !Number.isFinite(lastSeenMs) || lastSeenMs >= cutoffMs;
1211
+ }));
1212
+ }
1213
+ function parseFailureArgs(argv) {
1214
+ const args = {
1215
+ config: DEFAULT_CONFIG_PATH,
1216
+ state: DEFAULT_STATE_PATH,
1217
+ };
1218
+ for (let i = 0; i < argv.length; i += 1) {
1219
+ const token = argv[i];
1220
+ const next = argv[i + 1];
1221
+ if (token === '--config' && next) {
1222
+ args.config = next;
1223
+ i += 1;
1224
+ }
1225
+ else if (token === '--state' && next) {
1226
+ args.state = next;
1227
+ i += 1;
1228
+ }
1229
+ }
1230
+ return args;
1231
+ }
1232
+ async function recordRunnerFailure({ configPath, statePath, error, argv = [], now = new Date() }) {
1233
+ const errorMessage = truncateDiagnosticText(error instanceof Error ? error.message : String(error));
1234
+ const config = await readJsonOptional(configPath, {});
1235
+ const state = await readJsonOptional(statePath, {
1236
+ sourceHashes: {},
1237
+ lastIssueFingerprint: null,
1238
+ lastRunAt: null,
1239
+ sourceCursors: {},
1240
+ });
1241
+ const dedupeConfig = getDailyRunnerFailureDedupeConfig(config);
1242
+ const timeZone = resolveDailyIssueDedupeTimeZone(config);
1243
+ const date = formatDateInTimeZone(now, timeZone);
1244
+ const fingerprint = buildRunnerFailureFingerprint(errorMessage);
1245
+ const nowIso = now.toISOString();
1246
+ const previousDailyFailures = state?.dailyRunnerFailures?.date === date
1247
+ ? state.dailyRunnerFailures
1248
+ : null;
1249
+ const previousFailures = previousDailyFailures?.failures && typeof previousDailyFailures.failures === 'object'
1250
+ ? previousDailyFailures.failures
1251
+ : {};
1252
+ const failures = pruneDailyRunnerFailures(previousFailures, now, dedupeConfig.retentionDays);
1253
+ const previousEntry = failures[fingerprint] || null;
1254
+ const suppressed = dedupeConfig.enabled && Boolean(previousEntry);
1255
+ const nextEntry = {
1256
+ ...(previousEntry || {}),
1257
+ fingerprint,
1258
+ error: errorMessage,
1259
+ normalizedError: normalizeRunnerFailureForFingerprint(errorMessage),
1260
+ firstSeenAt: previousEntry?.firstSeenAt || nowIso,
1261
+ lastSeenAt: nowIso,
1262
+ };
1263
+ if (suppressed) {
1264
+ nextEntry.suppressedCount = Number(previousEntry?.suppressedCount || 0) + 1;
1265
+ }
1266
+ else {
1267
+ nextEntry.lastReportedAt = nowIso;
1268
+ nextEntry.reportCount = Number(previousEntry?.reportCount || 0) + 1;
1269
+ }
1270
+ failures[fingerprint] = nextEntry;
1271
+ const nextState = {
1272
+ ...state,
1273
+ dailyRunnerFailures: {
1274
+ date,
1275
+ timeZone,
1276
+ failures,
1277
+ updatedAt: nowIso,
1278
+ },
1279
+ lastRunnerFailure: {
1280
+ fingerprint,
1281
+ error: errorMessage,
1282
+ failedAt: nowIso,
1283
+ suppressed,
1284
+ },
1285
+ };
1286
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
1287
+ await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1288
+ await appendSchedulerProof(suppressed ? 'runner_failed_suppressed' : 'runner_failed', {
1289
+ configPath,
1290
+ statePath,
1291
+ error: errorMessage,
1292
+ errorFingerprint: fingerprint,
1293
+ date,
1294
+ timeZone,
1295
+ argv,
1296
+ suppressed,
1297
+ reportCount: nextEntry.reportCount || 0,
1298
+ suppressedCount: nextEntry.suppressedCount || 0,
1299
+ socialOutput: suppressed ? 'HEARTBEAT_OK' : 'RUNNER_FAILED',
1300
+ socialReason: suppressed
1301
+ ? 'runner failure unchanged and already reported today'
1302
+ : 'new runner failure for current day',
1303
+ });
1304
+ return {
1305
+ suppressed,
1306
+ exitCode: suppressed ? 0 : 1,
1307
+ fingerprint,
1308
+ };
1309
+ }
1310
+ function formatDateInTimeZone(date, timeZone) {
1311
+ try {
1312
+ const parts = new Intl.DateTimeFormat('en-CA', {
1313
+ timeZone,
1314
+ year: 'numeric',
1315
+ month: '2-digit',
1316
+ day: '2-digit',
1317
+ }).formatToParts(date);
1318
+ const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
1319
+ if (byType.year && byType.month && byType.day) {
1320
+ return `${byType.year}-${byType.month}-${byType.day}`;
1321
+ }
1322
+ }
1323
+ catch {
1324
+ // Fall back to UTC below for invalid host timezone settings.
1325
+ }
1326
+ return date.toISOString().slice(0, 10);
1327
+ }
1328
+ function normalizeIssueIdentityPart(value) {
1329
+ return String(value || '')
1330
+ .trim()
1331
+ .toLowerCase()
1332
+ .replace(/\s+/g, ' ');
1333
+ }
1334
+ function buildDailyIssueKey(issue) {
1335
+ const stableIdentity = [
1336
+ issueSourceUrl(issue),
1337
+ issue?.source_url,
1338
+ issue?.sourceUrl,
1339
+ issue?.issue_url,
1340
+ issue?.issueUrl,
1341
+ issue?.signal_id,
1342
+ issue?.signalId,
1343
+ issue?.id,
1344
+ ]
1345
+ .map((value) => String(value || '').trim())
1346
+ .find(Boolean);
1347
+ const fallbackIdentity = [
1348
+ issueProjectLabel(issue),
1349
+ issue?.source,
1350
+ issue?.area,
1351
+ issue?.title,
1352
+ ]
1353
+ .map(normalizeIssueIdentityPart)
1354
+ .filter(Boolean)
1355
+ .join('|');
1356
+ return sha256(stableIdentity || fallbackIdentity || stableStringify(issue));
1357
+ }
1358
+ function coerceIssueNumber(value) {
1359
+ if (value === null || value === undefined || value === '')
1360
+ return null;
1361
+ if (typeof value === 'number')
1362
+ return Number.isFinite(value) ? value : null;
1363
+ const normalized = String(value).replace(/,/g, '').trim();
1364
+ if (!normalized)
1365
+ return null;
1366
+ const number = Number(normalized);
1367
+ return Number.isFinite(number) ? number : null;
1368
+ }
1369
+ function issueEventCount(issue) {
1370
+ const direct = [
1371
+ issue?.events,
1372
+ issue?.eventCount,
1373
+ issue?.event_count,
1374
+ issue?.current_value,
1375
+ issue?.currentValue,
1376
+ issue?.count,
1377
+ ];
1378
+ for (const value of direct) {
1379
+ const number = coerceIssueNumber(value);
1380
+ if (number !== null)
1381
+ return number;
1382
+ }
1383
+ const text = [
1384
+ issue?.impact,
1385
+ issue?.summary,
1386
+ ...(Array.isArray(issue?.evidence) ? issue.evidence : []),
1387
+ issue?.body,
1388
+ ]
1389
+ .filter(Boolean)
1390
+ .join('\n');
1391
+ const match = text.match(/\bEvents?:\s*([0-9][0-9,]*(?:\.[0-9]+)?)/i);
1392
+ return match ? coerceIssueNumber(match[1]) : null;
1393
+ }
1394
+ function isDrasticDailyIssueEventGrowth(currentEvents, previousEntry, dedupeConfig) {
1395
+ if (currentEvents === null || currentEvents === undefined)
1396
+ return false;
1397
+ const previousEvents = coerceIssueNumber(previousEntry?.lastReportedEvents ?? previousEntry?.lastSeenEvents);
1398
+ if (previousEvents === null || previousEvents < 0)
1399
+ return false;
1400
+ const requiredEvents = Math.max(previousEvents * dedupeConfig.eventGrowthMultiplier, previousEvents + dedupeConfig.eventGrowthMinDelta);
1401
+ return currentEvents >= requiredEvents;
1402
+ }
1403
+ function applyDailyIssueDedupe(issuesPayload, state, config, activeCadences, now = new Date()) {
1404
+ const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [];
1405
+ const dedupeConfig = getDailyIssueDedupeConfig(config);
1406
+ if (!dedupeConfig.enabled || !isShortOperationalCadence(activeCadences) || issues.length === 0) {
1407
+ return {
1408
+ issuesPayload,
1409
+ dailyIssueReports: state?.dailyIssueReports || null,
1410
+ suppressedCount: 0,
1411
+ reportedCount: issues.length,
1412
+ hasDrasticEventGrowth: false,
1413
+ };
1414
+ }
1415
+ const timeZone = resolveDailyIssueDedupeTimeZone(config);
1416
+ const date = formatDateInTimeZone(now, timeZone);
1417
+ const nowIso = now.toISOString();
1418
+ const previousState = state?.dailyIssueReports?.date === date ? state.dailyIssueReports : null;
1419
+ const previousIssues = previousState?.issues && typeof previousState.issues === 'object'
1420
+ ? previousState.issues
1421
+ : {};
1422
+ const nextIssues = { ...previousIssues };
1423
+ const reportableIssues = [];
1424
+ let suppressedCount = 0;
1425
+ let hasDrasticEventGrowth = false;
1426
+ for (const issue of issues) {
1427
+ const key = buildDailyIssueKey(issue);
1428
+ const previousEntry = previousIssues[key] || null;
1429
+ const events = issueEventCount(issue);
1430
+ const shouldReport = !previousEntry || isDrasticDailyIssueEventGrowth(events, previousEntry, dedupeConfig);
1431
+ const reportReason = !previousEntry ? 'new_daily_issue' : 'event_growth';
1432
+ const nextEntry = {
1433
+ ...(previousEntry || {}),
1434
+ title: String(issue?.title || previousEntry?.title || '').slice(0, 240),
1435
+ app: issueProjectLabel(issue),
1436
+ sourceUrl: issueSourceUrl(issue) || previousEntry?.sourceUrl || null,
1437
+ lastSeenAt: nowIso,
1438
+ lastSeenEvents: events ?? previousEntry?.lastSeenEvents ?? null,
1439
+ };
1440
+ if (shouldReport) {
1441
+ reportableIssues.push(issue);
1442
+ if (previousEntry)
1443
+ hasDrasticEventGrowth = true;
1444
+ nextEntry.lastReportedAt = nowIso;
1445
+ nextEntry.lastReportedEvents = events ?? previousEntry?.lastReportedEvents ?? null;
1446
+ nextEntry.lastReportReason = reportReason;
1447
+ nextEntry.reportCount = Number(previousEntry?.reportCount || 0) + 1;
1448
+ }
1449
+ else {
1450
+ suppressedCount += 1;
1451
+ nextEntry.suppressedCount = Number(previousEntry?.suppressedCount || 0) + 1;
1452
+ }
1453
+ nextIssues[key] = nextEntry;
1454
+ }
1455
+ return {
1456
+ issuesPayload: {
1457
+ ...issuesPayload,
1458
+ issue_count: reportableIssues.length,
1459
+ issues: reportableIssues,
1460
+ suppressed_issue_count: suppressedCount,
1461
+ daily_issue_dedupe: {
1462
+ date,
1463
+ timeZone,
1464
+ suppressedCount,
1465
+ reportedCount: reportableIssues.length,
1466
+ eventGrowthMultiplier: dedupeConfig.eventGrowthMultiplier,
1467
+ eventGrowthMinDelta: dedupeConfig.eventGrowthMinDelta,
1468
+ },
1469
+ },
1470
+ dailyIssueReports: {
1471
+ date,
1472
+ timeZone,
1473
+ issues: nextIssues,
1474
+ updatedAt: nowIso,
1475
+ },
1476
+ suppressedCount,
1477
+ reportedCount: reportableIssues.length,
1478
+ hasDrasticEventGrowth,
1479
+ };
1480
+ }
1045
1481
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
1046
1482
  const channels = getConnectorHealthChannels(config);
1047
1483
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -1062,6 +1498,9 @@ async function deliverConnectorHealthAlert({ config, configPath, message, status
1062
1498
  else if (channel.type === 'webhook') {
1063
1499
  results.push(await sendWebhookConnectorHealthAlert(channel, message, statusPayload, unhealthyConnectors, fingerprint));
1064
1500
  }
1501
+ else if (channel.type === 'discord') {
1502
+ results.push(await sendDiscordConnectorHealthAlert(channel, message, statusPayload, unhealthyConnectors, fingerprint));
1503
+ }
1065
1504
  else if (channel.type === 'command') {
1066
1505
  results.push(await sendCommandConnectorHealthAlert(channel, message));
1067
1506
  }
@@ -1142,6 +1581,10 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
1142
1581
  ? 'Action: GitHub artifact attempted.'
1143
1582
  : 'Action: external alert only.');
1144
1583
  }
1584
+ const suppressedIssueCount = Number(issuesPayload?.suppressed_issue_count || 0);
1585
+ if (suppressedIssueCount > 0) {
1586
+ lines.push(`Suppressed today: ${suppressedIssueCount} previously reported finding(s).`);
1587
+ }
1145
1588
  if (charts.length > 0) {
1146
1589
  lines.push(`Charts: ${charts.length}`);
1147
1590
  }
@@ -1187,6 +1630,67 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
1187
1630
  : 'No secrets were included.');
1188
1631
  return `${lines.join('\n')}\n`;
1189
1632
  }
1633
+ function growthRunTitle(activeCadences) {
1634
+ if (isShortOperationalCadence(activeCadences)) {
1635
+ return activeCadences.some((cadence) => String(cadence?.key) === 'healthcheck')
1636
+ ? 'OpenClaw healthcheck'
1637
+ : 'OpenClaw daily';
1638
+ }
1639
+ if (isDeepAnalysisCadence(activeCadences))
1640
+ return 'OpenClaw growth review';
1641
+ return 'OpenClaw growth run';
1642
+ }
1643
+ function buildDiscordGrowthRunPayload(message, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, charts = []) {
1644
+ const issues = Array.isArray(issuesPayload?.issues) ? issuesPayload.issues : [];
1645
+ const issueCount = Number(issuesPayload?.issue_count || 0);
1646
+ const fields = [
1647
+ discordField('Cadence', activeCadences.length > 0
1648
+ ? activeCadences.map((cadence) => cadence.title || cadence.key).join(', ')
1649
+ : 'ad-hoc growth pass', false),
1650
+ discordField('Sources', Object.keys(sourceFiles || {}).sort().join(', ') || 'none', true),
1651
+ discordField('Findings', String(issueCount), true),
1652
+ ];
1653
+ if (createdGitHubArtifact) {
1654
+ fields.push(discordField('Action', 'GitHub artifact creation was attempted.', true));
1655
+ }
1656
+ const suppressedIssueCount = Number(issuesPayload?.suppressed_issue_count || 0);
1657
+ if (suppressedIssueCount > 0) {
1658
+ fields.push(discordField('Suppressed today', `${suppressedIssueCount} previously reported finding(s).`, true));
1659
+ }
1660
+ if (charts.length > 0) {
1661
+ fields.push(discordField('Charts', String(charts.length), true));
1662
+ }
1663
+ const groupedIssues = isShortOperationalCadence(activeCadences)
1664
+ ? groupIssuesByProject(issues, 4).map(([project, projectIssues]) => ({
1665
+ name: project,
1666
+ value: projectIssues.map((issue) => formatIssueSummaryLine(issue, 84)).filter(Boolean).join('\n'),
1667
+ }))
1668
+ : issues.slice(0, isDeepAnalysisCadence(activeCadences) ? 5 : 3).map((issue) => ({
1669
+ name: `${issue.priority || 'medium'} • ${issue.area || 'general'}`,
1670
+ value: formatIssueSummaryLine(issue, 96),
1671
+ }));
1672
+ for (const entry of groupedIssues) {
1673
+ if (entry.value)
1674
+ fields.push(discordField(entry.name, entry.value));
1675
+ }
1676
+ const summary = String(issuesPayload?.summary || '').trim();
1677
+ return {
1678
+ content: '',
1679
+ embeds: [
1680
+ {
1681
+ title: `${growthRunTitle(activeCadences)}: ${issueCount > 0 ? `${issueCount} finding(s)` : 'OK'}`,
1682
+ description: discordTruncate(summary || 'No secrets were included.', 500),
1683
+ color: issueCount > 0 ? 0xf79009 : 0x12b76a,
1684
+ fields: fields.slice(0, 20),
1685
+ footer: {
1686
+ text: `GROWTH_RUN • ${String(fingerprint || '').slice(0, 12)}`,
1687
+ },
1688
+ timestamp: new Date().toISOString(),
1689
+ },
1690
+ ],
1691
+ fallbackText: message,
1692
+ };
1693
+ }
1190
1694
  async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts) {
1191
1695
  const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/growth-summary.md');
1192
1696
  const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/growth-summary.json');
@@ -1276,7 +1780,23 @@ async function sendCommandGrowthSummary(channel, message) {
1276
1780
  sent: result.ok,
1277
1781
  external: true,
1278
1782
  target: channel.label || 'command',
1279
- detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
1783
+ detail: result.ok ? 'sent' : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
1784
+ };
1785
+ }
1786
+ async function sendDiscordGrowthSummary(channel, message, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, charts) {
1787
+ if (!channel.command) {
1788
+ return { sent: false, target: channel.label || 'discord', detail: 'discord command not configured' };
1789
+ }
1790
+ const payload = buildDiscordGrowthRunPayload(message, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, charts);
1791
+ const result = await runShellCommand(String(channel.command), 60_000, {
1792
+ input: JSON.stringify(payload),
1793
+ env: { OPENCLAW_DISCORD_DELIVERY_FORMAT: 'embed' },
1794
+ });
1795
+ return {
1796
+ sent: result.ok,
1797
+ external: true,
1798
+ target: channel.label || 'discord',
1799
+ detail: result.ok ? 'sent' : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
1280
1800
  };
1281
1801
  }
1282
1802
  async function deliverGrowthRunSummary({ config, configPath, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, chartManifestPath, }) {
@@ -1307,6 +1827,9 @@ async function deliverGrowthRunSummary({ config, configPath, issuesPayload, acti
1307
1827
  else if (channel.type === 'webhook') {
1308
1828
  results.push(await sendWebhookGrowthSummary(channel, message, issuesPayload, activeCadences, fingerprint, charts));
1309
1829
  }
1830
+ else if (channel.type === 'discord') {
1831
+ results.push(await sendDiscordGrowthSummary(channel, message, issuesPayload, activeCadences, sourceFiles, fingerprint, createdGitHubArtifact, charts));
1832
+ }
1310
1833
  else if (channel.type === 'command') {
1311
1834
  results.push(await sendCommandGrowthSummary(channel, message));
1312
1835
  }
@@ -1686,7 +2209,7 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1686
2209
  },
1687
2210
  };
1688
2211
  }
1689
- throw new Error(`Source "${sourceName}" command failed: ${detail}`);
2212
+ throw new Error(buildSourceCommandFailureMessage(sourceName, resolvedCommand, detail));
1690
2213
  }
1691
2214
  const fetchedAt = new Date().toISOString();
1692
2215
  try {
@@ -1893,8 +2416,52 @@ async function runOnce(configPath, statePath) {
1893
2416
  chartManifestPath,
1894
2417
  cadencePlanPath,
1895
2418
  });
1896
- const issueFingerprint = buildIssueFingerprint(dryRun.issuesPayload);
1897
- const unchangedIssueSet = issueFingerprint === stateAfterSourceCollection.lastIssueFingerprint;
2419
+ const dailyIssueDedupe = applyDailyIssueDedupe(dryRun.issuesPayload, stateAfterSourceCollection, config, activeCadences);
2420
+ const deliverableIssuesPayload = dailyIssueDedupe.issuesPayload;
2421
+ const issueFingerprint = buildIssueFingerprint(deliverableIssuesPayload);
2422
+ const unchangedIssueSet = issueFingerprint === stateAfterSourceCollection.lastIssueFingerprint &&
2423
+ !dailyIssueDedupe.hasDrasticEventGrowth;
2424
+ if (Number(dryRun.issuesPayload?.issue_count || 0) > 0 &&
2425
+ Number(deliverableIssuesPayload?.issue_count || 0) === 0 &&
2426
+ dailyIssueDedupe.suppressedCount > 0) {
2427
+ process.stdout.write(`[${new Date().toISOString()}] All findings were already reported today. Skip GitHub creation and external growth notification.\n`);
2428
+ const completedAt = new Date().toISOString();
2429
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
2430
+ await fs.writeFile(statePath, JSON.stringify({
2431
+ ...stateAfterHealthCheck,
2432
+ ...stateAfterSourceCollection,
2433
+ sourceHashes: currentHashes,
2434
+ sourceCursors,
2435
+ lastSourceFailures: sourceFailures,
2436
+ dailyIssueReports: dailyIssueDedupe.dailyIssueReports,
2437
+ lastIssueFingerprint: issueFingerprint,
2438
+ lastRunAt: completedAt,
2439
+ lastOutFile: dryRun.outFile,
2440
+ cadences: markCadencesRan(stateAfterSourceCollection, activeCadences, completedAt),
2441
+ lastGrowthRunNotifications: [
2442
+ {
2443
+ sent: false,
2444
+ target: 'growth_run',
2445
+ detail: `all ${dailyIssueDedupe.suppressedCount} finding(s) already reported today; external growth notification suppressed`,
2446
+ },
2447
+ ],
2448
+ skippedReason: 'daily_issue_dedupe',
2449
+ }, null, 2), 'utf8');
2450
+ await appendSchedulerProof('runner_completed', {
2451
+ configPath,
2452
+ statePath,
2453
+ completedAt,
2454
+ skippedReason: 'daily_issue_dedupe',
2455
+ activeCadences: activeCadences.map((cadence) => cadence.key),
2456
+ outFile: dryRun.outFile,
2457
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
2458
+ suppressedIssueCount: dailyIssueDedupe.suppressedCount,
2459
+ sourceFailures,
2460
+ externalGrowthNotification: 'suppressed_daily_issue_dedupe',
2461
+ socialOutput: 'HEARTBEAT_OK',
2462
+ });
2463
+ return;
2464
+ }
1898
2465
  if (unchangedIssueSet &&
1899
2466
  config.schedule?.skipIfIssueSetUnchanged !== false) {
1900
2467
  process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation and external growth notification.\n`);
@@ -1906,6 +2473,7 @@ async function runOnce(configPath, statePath) {
1906
2473
  sourceHashes: currentHashes,
1907
2474
  sourceCursors,
1908
2475
  lastSourceFailures: sourceFailures,
2476
+ dailyIssueReports: dailyIssueDedupe.dailyIssueReports,
1909
2477
  lastIssueFingerprint: issueFingerprint,
1910
2478
  lastRunAt: completedAt,
1911
2479
  lastOutFile: dryRun.outFile,
@@ -1935,7 +2503,7 @@ async function runOnce(configPath, statePath) {
1935
2503
  }
1936
2504
  const issueSetChangedOrExplicitlyAllowed = !unchangedIssueSet || config.schedule?.skipIfIssueSetUnchanged === false;
1937
2505
  const shouldCreateGitHubArtifact = createGitHubArtifact &&
1938
- Number(dryRun.issuesPayload?.issue_count || 0) > 0 &&
2506
+ Number(deliverableIssuesPayload?.issue_count || 0) > 0 &&
1939
2507
  issueSetChangedOrExplicitlyAllowed;
1940
2508
  if (shouldCreateGitHubArtifact) {
1941
2509
  for (const githubArtifactMode of githubArtifactModes) {
@@ -1962,6 +2530,7 @@ async function runOnce(configPath, statePath) {
1962
2530
  sourceHashes: currentHashes,
1963
2531
  sourceCursors,
1964
2532
  lastSourceFailures: sourceFailures,
2533
+ dailyIssueReports: dailyIssueDedupe.dailyIssueReports,
1965
2534
  lastIssueFingerprint: issueFingerprint,
1966
2535
  lastRunAt: completedAt,
1967
2536
  lastOutFile: dryRun.outFile,
@@ -1969,7 +2538,7 @@ async function runOnce(configPath, statePath) {
1969
2538
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1970
2539
  config,
1971
2540
  configPath,
1972
- issuesPayload: dryRun.issuesPayload,
2541
+ issuesPayload: deliverableIssuesPayload,
1973
2542
  activeCadences,
1974
2543
  sourceFiles,
1975
2544
  fingerprint: issueFingerprint,
@@ -2010,22 +2579,42 @@ async function main() {
2010
2579
  await runOnce(configPath, statePath);
2011
2580
  }
2012
2581
  catch (error) {
2013
- await appendSchedulerProof('runner_failed', {
2582
+ const failureDecision = await recordRunnerFailure({
2014
2583
  configPath,
2015
2584
  statePath,
2016
- error: error instanceof Error ? error.message : String(error),
2017
- }).catch(() => { });
2018
- process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
2585
+ error,
2586
+ argv: process.argv.slice(2),
2587
+ }).catch(async () => {
2588
+ await appendSchedulerProof('runner_failed', {
2589
+ configPath,
2590
+ statePath,
2591
+ error: error instanceof Error ? error.message : String(error),
2592
+ }).catch(() => { });
2593
+ return null;
2594
+ });
2595
+ process.stderr.write(`[${new Date().toISOString()}] Run failed${failureDecision?.suppressed ? ' (already reported today)' : ''}: ${error instanceof Error ? error.message : String(error)}\n`);
2019
2596
  }
2020
2597
  await sleep(intervalMinutes * 60_000);
2021
2598
  }
2022
2599
  }
2023
2600
  main().catch(async (error) => {
2024
- await appendSchedulerProof('runner_failed', {
2025
- error: error instanceof Error ? error.message : String(error),
2601
+ const fallbackArgs = parseFailureArgs(process.argv.slice(2));
2602
+ const configPath = path.resolve(fallbackArgs.config);
2603
+ const statePath = path.resolve(fallbackArgs.state);
2604
+ useSchedulerProofPathForStatePath(statePath);
2605
+ const failureDecision = await recordRunnerFailure({
2606
+ configPath,
2607
+ statePath,
2608
+ error,
2026
2609
  argv: process.argv.slice(2),
2027
- }).catch(() => { });
2610
+ }).catch(async () => {
2611
+ await appendSchedulerProof('runner_failed', {
2612
+ error: error instanceof Error ? error.message : String(error),
2613
+ argv: process.argv.slice(2),
2614
+ }).catch(() => { });
2615
+ return null;
2616
+ });
2028
2617
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
2029
- process.exitCode = 1;
2618
+ process.exitCode = failureDecision?.exitCode ?? 1;
2030
2619
  });
2031
2620
  //# sourceMappingURL=openclaw-growth-runner.mjs.map