@analyticscli/growth-engineer 0.1.0-preview.12 → 0.1.0-preview.14

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.
@@ -5,14 +5,15 @@ import process from 'node:process';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
8
+ import { deriveRuntimeDirFromStatePath, deriveSchedulerProofPathFromStatePath, getActionMode, getAllSourceEntries, getGitHubArtifactModes, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
9
9
  import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
10
10
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
11
11
  const DEFAULT_STATE_PATH = 'data/openclaw-growth-engineer/state.json';
12
- const DEFAULT_RUNTIME_DIR = 'data/openclaw-growth-engineer/runtime';
12
+ const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
13
13
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
14
14
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
15
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
16
+ let schedulerProofPath = path.resolve(DEFAULT_SCHEDULER_PROOF_PATH);
16
17
  const DEFAULT_CADENCES = [
17
18
  {
18
19
  key: 'daily',
@@ -146,6 +147,24 @@ function replaceLegacyRuntimeScriptCommand(command) {
146
147
  return trimmed;
147
148
  return trimmed.replace(/^node\s+scripts\/(export-analytics-summary\.mjs|export-revenuecat-summary\.mjs|export-sentry-summary\.mjs|export-asc-summary\.mjs|openclaw-growth-engineer\.mjs|openclaw-growth-status\.mjs|openclaw-growth-preflight\.mjs|openclaw-growth-runner\.mjs)(?=\s|$)/, (_match, scriptName) => nodeRuntimeScriptCommand(scriptName));
148
149
  }
150
+ function commandHasConfigArg(command) {
151
+ return /(?:^|\s)--config(?:=|\s|$)/.test(String(command || ''));
152
+ }
153
+ function commandShouldReceiveActiveConfig(command) {
154
+ return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-analytics-summary|export-revenuecat-summary|export-sentry-summary|export-asc-summary)\.mjs(?:\s|$)/.test(String(command || ''));
155
+ }
156
+ function withActiveConfigArg(command, configPath) {
157
+ const trimmed = String(command || '').trim();
158
+ if (!trimmed || !configPath || !commandShouldReceiveActiveConfig(trimmed)) {
159
+ return trimmed;
160
+ }
161
+ if (commandHasConfigArg(trimmed)) {
162
+ return trimmed
163
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
164
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`);
165
+ }
166
+ return `${trimmed} --config ${quote(configPath)}`;
167
+ }
149
168
  async function readJson(filePath) {
150
169
  const raw = await fs.readFile(filePath, 'utf8');
151
170
  return JSON.parse(raw);
@@ -161,6 +180,22 @@ async function readJsonOptional(filePath, fallback) {
161
180
  async function ensureDir(dirPath) {
162
181
  await fs.mkdir(dirPath, { recursive: true });
163
182
  }
183
+ async function appendSchedulerProof(event, details = {}) {
184
+ const proofPath = schedulerProofPath;
185
+ const entry = {
186
+ ts: new Date().toISOString(),
187
+ event,
188
+ pid: process.pid,
189
+ cwd: process.cwd(),
190
+ ...details,
191
+ };
192
+ await fs.mkdir(path.dirname(proofPath), { recursive: true });
193
+ await fs.appendFile(proofPath, `${JSON.stringify(entry)}\n`, 'utf8');
194
+ }
195
+ function useSchedulerProofPathForStatePath(statePath) {
196
+ schedulerProofPath = path.resolve(deriveSchedulerProofPathFromStatePath(statePath));
197
+ return schedulerProofPath;
198
+ }
164
199
  function sha256(input) {
165
200
  return createHash('sha256').update(input).digest('hex');
166
201
  }
@@ -529,7 +564,7 @@ function buildConnectorHealthAlert(statusPayload, unhealthyConnectors) {
529
564
  lines.push(` Next: ${entry.nextAction}`);
530
565
  }
531
566
  if (entry.key === 'appStoreConnect' && entry.status === 'partial') {
532
- lines.push(' Note: ASC web analytics uses a user-owned web session. If Apple expires it after a few hours, refresh it with `asc web auth login`; API-key ASC auth cannot replace this web session.');
567
+ lines.push(' Note: ASC uses API-key batch reports by default. Experimental ASC web analytics should only be requested when a needed metric is unavailable through API reports.');
533
568
  }
534
569
  }
535
570
  lines.push('');
@@ -550,20 +585,43 @@ async function writeConnectorHealthAlert(runtimeDir, message, statusPayload, unh
550
585
  }, null, 2), 'utf8');
551
586
  return { markdownPath, jsonPath };
552
587
  }
553
- function getConnectorHealthChannels(config) {
554
- const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
555
- ? config.notifications.connectorHealth.channels.filter((channel) => channel?.enabled !== false)
556
- : [];
557
- if (configuredChannels.length > 0)
558
- return configuredChannels;
588
+ function notificationChannelKey(channel) {
589
+ const type = String(channel?.type || 'openclaw-chat');
590
+ if (type === 'openclaw-chat')
591
+ return 'openclaw-chat';
592
+ if (type === 'slack')
593
+ return `slack:${channel?.label || channel?.webhookEnv || 'slack'}`;
594
+ if (type === 'webhook')
595
+ return `webhook:${channel?.label || channel?.urlEnv || channel?.webhookEnv || 'webhook'}`;
596
+ if (type === 'command')
597
+ return `command:${channel?.label || channel?.command || 'command'}`;
598
+ return `${type}:${channel?.label || type}`;
599
+ }
600
+ function mergeNotificationChannelsWithDeliveries(configuredChannels, deliveryChannels) {
601
+ const configured = Array.isArray(configuredChannels) ? configuredChannels : [];
602
+ const seen = new Set(configured.map((channel) => notificationChannelKey(channel)));
603
+ const channels = configured.filter((channel) => channel?.enabled !== false);
604
+ for (const channel of deliveryChannels) {
605
+ if (!seen.has(notificationChannelKey(channel))) {
606
+ channels.push(channel);
607
+ }
608
+ }
609
+ return channels;
610
+ }
611
+ function getDeliveryNotificationChannels(config, kind) {
559
612
  const channels = [];
560
613
  const deliveries = config?.deliveries || {};
561
614
  if (deliveries.openclawChat?.enabled) {
615
+ const isConnectorHealth = kind === 'connectorHealth';
562
616
  channels.push({
563
617
  type: 'openclaw-chat',
564
618
  label: 'openclaw_chat',
565
- markdownPath: deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath,
566
- jsonPath: deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath,
619
+ markdownPath: isConnectorHealth
620
+ ? deliveries.openclawChat.connectorHealthMarkdownPath || deliveries.openclawChat.markdownPath
621
+ : deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
622
+ jsonPath: isConnectorHealth
623
+ ? deliveries.openclawChat.connectorHealthJsonPath || deliveries.openclawChat.jsonPath
624
+ : deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
567
625
  });
568
626
  }
569
627
  if (deliveries.slack?.enabled) {
@@ -582,19 +640,37 @@ function getConnectorHealthChannels(config) {
582
640
  headers: deliveries.webhook.headers || {},
583
641
  });
584
642
  }
643
+ if (deliveries.command?.enabled) {
644
+ channels.push({
645
+ type: 'command',
646
+ label: deliveries.command.label || 'command',
647
+ command: deliveries.command.command || '',
648
+ });
649
+ }
585
650
  if (deliveries.discord?.enabled) {
586
651
  channels.push({
587
652
  type: 'command',
588
- label: 'discord',
589
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
653
+ label: deliveries.discord.label || 'discord',
654
+ command: deliveries.discord.command || '',
590
655
  });
591
656
  }
592
657
  return channels;
593
658
  }
659
+ function getConnectorHealthChannels(config) {
660
+ const configuredChannels = Array.isArray(config?.notifications?.connectorHealth?.channels)
661
+ ? config.notifications.connectorHealth.channels
662
+ : [];
663
+ return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'connectorHealth'));
664
+ }
665
+ function resolveOpenClawChatDeliveryPath(channelPath, fallbackPath) {
666
+ const targetPath = String(channelPath || fallbackPath || '').trim();
667
+ if (!targetPath)
668
+ return path.resolve(process.cwd(), fallbackPath);
669
+ return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath);
670
+ }
594
671
  async function writeConfiguredOpenClawChatAlert(configPath, channel, message, statusPayload, unhealthyConnectors, fingerprint) {
595
- const baseDir = path.dirname(path.resolve(configPath));
596
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/connector-health.md');
597
- const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/connector-health.json');
672
+ const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/connector-health.md');
673
+ const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/connector-health.json');
598
674
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
599
675
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
600
676
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -607,8 +683,9 @@ async function writeConfiguredOpenClawChatAlert(configPath, channel, message, st
607
683
  }, null, 2), 'utf8');
608
684
  return {
609
685
  sent: true,
686
+ external: false,
610
687
  target: channel.label || 'openclaw_chat',
611
- detail: `wrote ${markdownPath} and ${jsonPath}`,
688
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
612
689
  };
613
690
  }
614
691
  async function sendSlackConnectorHealthAlert(channel, message) {
@@ -624,6 +701,7 @@ async function sendSlackConnectorHealthAlert(channel, message) {
624
701
  });
625
702
  return {
626
703
  sent: response.ok,
704
+ external: true,
627
705
  target: channel.label || 'slack',
628
706
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
629
707
  };
@@ -651,6 +729,7 @@ async function sendWebhookConnectorHealthAlert(channel, message, statusPayload,
651
729
  });
652
730
  return {
653
731
  sent: response.ok,
732
+ external: true,
654
733
  target: channel.label || 'webhook',
655
734
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
656
735
  };
@@ -662,10 +741,17 @@ async function sendCommandConnectorHealthAlert(channel, message) {
662
741
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
663
742
  return {
664
743
  sent: result.ok,
744
+ external: true,
665
745
  target: channel.label || 'command',
666
746
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
667
747
  };
668
748
  }
749
+ function hasExternalNotificationChannel(channels) {
750
+ return channels.some((channel) => channel?.type && channel.type !== 'openclaw-chat');
751
+ }
752
+ function hasSuccessfulExternalDelivery(results) {
753
+ return results.some((result) => result?.sent === true && result?.external === true);
754
+ }
669
755
  async function deliverConnectorHealthAlert({ config, configPath, message, statusPayload, unhealthyConnectors, fingerprint }) {
670
756
  const channels = getConnectorHealthChannels(config);
671
757
  if (config?.notifications?.connectorHealth?.enabled === false) {
@@ -701,48 +787,23 @@ async function deliverConnectorHealthAlert({ config, configPath, message, status
701
787
  });
702
788
  }
703
789
  }
790
+ if (!hasSuccessfulExternalDelivery(results)) {
791
+ results.push({
792
+ sent: false,
793
+ external: true,
794
+ target: 'external_notification',
795
+ detail: hasExternalNotificationChannel(channels)
796
+ ? 'No external notification channel successfully sent the alert.'
797
+ : 'Alert written locally, but no external notification channel configured.',
798
+ });
799
+ }
704
800
  return results;
705
801
  }
706
802
  function getGrowthRunChannels(config) {
707
803
  const configuredChannels = Array.isArray(config?.notifications?.growthRun?.channels)
708
- ? config.notifications.growthRun.channels.filter((channel) => channel?.enabled !== false)
804
+ ? config.notifications.growthRun.channels
709
805
  : [];
710
- if (configuredChannels.length > 0)
711
- return configuredChannels;
712
- const channels = [];
713
- const deliveries = config?.deliveries || {};
714
- if (deliveries.openclawChat?.enabled) {
715
- channels.push({
716
- type: 'openclaw-chat',
717
- label: 'openclaw_chat',
718
- markdownPath: deliveries.openclawChat.growthRunMarkdownPath || '.openclaw/chat/growth-summary.md',
719
- jsonPath: deliveries.openclawChat.growthRunJsonPath || '.openclaw/chat/growth-summary.json',
720
- });
721
- }
722
- if (deliveries.slack?.enabled) {
723
- channels.push({
724
- type: 'slack',
725
- label: 'slack',
726
- webhookEnv: deliveries.slack.webhookEnv || 'SLACK_WEBHOOK_URL',
727
- });
728
- }
729
- if (deliveries.webhook?.enabled) {
730
- channels.push({
731
- type: 'webhook',
732
- label: 'webhook',
733
- urlEnv: deliveries.webhook.urlEnv || 'OPENCLAW_WEBHOOK_URL',
734
- method: deliveries.webhook.method || 'POST',
735
- headers: deliveries.webhook.headers || {},
736
- });
737
- }
738
- if (deliveries.discord?.enabled) {
739
- channels.push({
740
- type: 'command',
741
- label: 'discord',
742
- command: deliveries.discord.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
743
- });
744
- }
745
- return channels;
806
+ return mergeNotificationChannelsWithDeliveries(configuredChannels, getDeliveryNotificationChannels(config, 'growthRun'));
746
807
  }
747
808
  async function readChartAttachments(chartManifestPath) {
748
809
  if (!chartManifestPath)
@@ -800,9 +861,8 @@ function buildGrowthRunSummaryMessage({ issuesPayload, activeCadences, sourceFil
800
861
  return `${lines.join('\n')}\n`;
801
862
  }
802
863
  async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, message, issuesPayload, activeCadences, fingerprint, charts) {
803
- const baseDir = path.dirname(path.resolve(configPath));
804
- const markdownPath = path.resolve(baseDir, channel.markdownPath || '.openclaw/chat/growth-summary.md');
805
- const jsonPath = path.resolve(baseDir, channel.jsonPath || '.openclaw/chat/growth-summary.json');
864
+ const markdownPath = resolveOpenClawChatDeliveryPath(channel.markdownPath, '.openclaw/chat/growth-summary.md');
865
+ const jsonPath = resolveOpenClawChatDeliveryPath(channel.jsonPath, '.openclaw/chat/growth-summary.json');
806
866
  await fs.mkdir(path.dirname(markdownPath), { recursive: true });
807
867
  await fs.mkdir(path.dirname(jsonPath), { recursive: true });
808
868
  await fs.writeFile(markdownPath, message, 'utf8');
@@ -822,8 +882,9 @@ async function writeConfiguredOpenClawChatGrowthSummary(configPath, channel, mes
822
882
  }, null, 2), 'utf8');
823
883
  return {
824
884
  sent: true,
885
+ external: false,
825
886
  target: channel.label || 'openclaw_chat',
826
- detail: `wrote ${markdownPath} and ${jsonPath}`,
887
+ detail: `wrote local OpenClaw chat outbox ${markdownPath} and ${jsonPath}`,
827
888
  };
828
889
  }
829
890
  async function sendSlackGrowthSummary(channel, message) {
@@ -839,6 +900,7 @@ async function sendSlackGrowthSummary(channel, message) {
839
900
  });
840
901
  return {
841
902
  sent: response.ok,
903
+ external: true,
842
904
  target: channel.label || 'slack',
843
905
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
844
906
  };
@@ -873,6 +935,7 @@ async function sendWebhookGrowthSummary(channel, message, issuesPayload, activeC
873
935
  });
874
936
  return {
875
937
  sent: response.ok,
938
+ external: true,
876
939
  target: channel.label || 'webhook',
877
940
  detail: response.ok ? `HTTP ${response.status}` : `HTTP ${response.status}: ${await response.text()}`,
878
941
  };
@@ -884,6 +947,7 @@ async function sendCommandGrowthSummary(channel, message) {
884
947
  const result = await runShellCommand(String(channel.command), 60_000, { input: message });
885
948
  return {
886
949
  sent: result.ok,
950
+ external: true,
887
951
  target: channel.label || 'command',
888
952
  detail: result.ok ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`,
889
953
  };
@@ -937,6 +1001,12 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
937
1001
  const healthState = state?.connectorHealth || {};
938
1002
  const intervalMinutes = getConnectorHealthIntervalMinutes(config);
939
1003
  if (!isDue(healthState.lastCheckedAt, intervalMinutes)) {
1004
+ await appendSchedulerProof('connector_health_not_due', {
1005
+ configPath,
1006
+ statePath,
1007
+ intervalMinutes,
1008
+ lastCheckedAt: healthState.lastCheckedAt || null,
1009
+ });
940
1010
  return state;
941
1011
  }
942
1012
  await ensureDir(runtimeDir);
@@ -962,6 +1032,13 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
962
1032
  };
963
1033
  await fs.mkdir(path.dirname(statePath), { recursive: true });
964
1034
  await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1035
+ await appendSchedulerProof('connector_health_check_failed', {
1036
+ configPath,
1037
+ statePath,
1038
+ intervalMinutes,
1039
+ checkedAt,
1040
+ error: nextState.connectorHealth.lastError,
1041
+ });
965
1042
  return nextState;
966
1043
  }
967
1044
  const unhealthyConnectors = getUnhealthyConfiguredConnectors(statusPayload);
@@ -975,11 +1052,10 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
975
1052
  connectedConnectors,
976
1053
  lastError: null,
977
1054
  };
978
- const previousIncidentFingerprint = healthState.lastStatusOk === false
979
- ? healthState.activeIncidentFingerprint || healthState.lastAlertedFingerprint || null
980
- : null;
1055
+ const previousExternallyDeliveredFingerprint = healthState.lastExternalAlertedFingerprint || null;
981
1056
  if (unhealthyConnectors.length === 0) {
982
1057
  nextHealthState.activeIncidentFingerprint = null;
1058
+ nextHealthState.lastExternalAlertedFingerprint = null;
983
1059
  if (healthState.lastStatusOk === false) {
984
1060
  nextHealthState.lastRecoveredAt = checkedAt;
985
1061
  }
@@ -988,7 +1064,7 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
988
1064
  nextHealthState.activeIncidentFingerprint = fingerprint;
989
1065
  }
990
1066
  if (unhealthyConnectors.length > 0 &&
991
- previousIncidentFingerprint !== fingerprint) {
1067
+ previousExternallyDeliveredFingerprint !== fingerprint) {
992
1068
  const message = buildConnectorHealthAlert(statusPayload, unhealthyConnectors);
993
1069
  const paths = await writeConnectorHealthAlert(runtimeDir, message, statusPayload, unhealthyConnectors, fingerprint);
994
1070
  const deliveries = await deliverConnectorHealthAlert({
@@ -1004,6 +1080,11 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1004
1080
  nextHealthState.lastAlertMarkdownPath = paths.markdownPath;
1005
1081
  nextHealthState.lastAlertJsonPath = paths.jsonPath;
1006
1082
  nextHealthState.lastAlertDeliveries = deliveries;
1083
+ nextHealthState.lastAlertExternalSent = hasSuccessfulExternalDelivery(deliveries);
1084
+ if (nextHealthState.lastAlertExternalSent) {
1085
+ nextHealthState.lastExternalAlertedAt = checkedAt;
1086
+ nextHealthState.lastExternalAlertedFingerprint = fingerprint;
1087
+ }
1007
1088
  }
1008
1089
  const nextState = {
1009
1090
  ...state,
@@ -1011,6 +1092,22 @@ async function maybeRunConnectorHealthCheck({ config, configPath, state, statePa
1011
1092
  };
1012
1093
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1013
1094
  await fs.writeFile(statePath, JSON.stringify(nextState, null, 2), 'utf8');
1095
+ await appendSchedulerProof('connector_health_checked', {
1096
+ configPath,
1097
+ statePath,
1098
+ intervalMinutes,
1099
+ checkedAt,
1100
+ lastStatusOk: nextHealthState.lastStatusOk,
1101
+ connectedConnectors,
1102
+ unhealthyConnectors: unhealthyConnectors.map((entry) => ({
1103
+ key: entry.key,
1104
+ status: entry.status,
1105
+ detail: entry.detail,
1106
+ })),
1107
+ alertMarkdownPath: nextHealthState.lastAlertMarkdownPath || null,
1108
+ deliveryCount: Array.isArray(nextHealthState.lastAlertDeliveries) ? nextHealthState.lastAlertDeliveries.length : 0,
1109
+ externalDeliverySent: nextHealthState.lastAlertExternalSent === true,
1110
+ });
1014
1111
  return nextState;
1015
1112
  }
1016
1113
  function buildIssueFingerprint(issuesPayload) {
@@ -1159,7 +1256,7 @@ function resolveCursorAwareCommand(command, sourceConfig, cursorState) {
1159
1256
  const lookback = normalizeLookback(sourceConfig?.initialLookback, '30d');
1160
1257
  return `${rawCommand} --last ${quote(lookback)}`;
1161
1258
  }
1162
- async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd()) {
1259
+ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorState, commandCwd = process.cwd(), configPath = null) {
1163
1260
  if (!sourceConfig || sourceConfig.enabled === false) {
1164
1261
  return {
1165
1262
  payload: null,
@@ -1171,7 +1268,7 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1171
1268
  if (!sourceConfig.command) {
1172
1269
  throw new Error(`Source "${sourceName}" has mode=command but no command configured.`);
1173
1270
  }
1174
- const resolvedCommand = resolveCursorAwareCommand(replaceLegacyRuntimeScriptCommand(sourceConfig.command), sourceConfig, cursorState);
1271
+ const resolvedCommand = resolveCursorAwareCommand(withActiveConfigArg(replaceLegacyRuntimeScriptCommand(sourceConfig.command), configPath), sourceConfig, cursorState);
1175
1272
  const result = await runShellCommand(String(resolvedCommand), 120_000, { cwd: commandCwd });
1176
1273
  if (!result.ok) {
1177
1274
  throw new Error(`Source "${sourceName}" command failed: ${result.stderr || `exit ${result.code}`}`);
@@ -1203,13 +1300,13 @@ async function resolveSourcePayloadWithCursor(sourceConfig, sourceName, cursorSt
1203
1300
  resolvedCommand: null,
1204
1301
  };
1205
1302
  }
1206
- async function loadSourcePayloads(config, state) {
1303
+ async function loadSourcePayloads(config, state, configPath) {
1207
1304
  const payloads = {};
1208
1305
  const sourceCursors = { ...(state?.sourceCursors || {}) };
1209
1306
  const commandCwd = getProjectCommandCwd(config);
1210
1307
  for (const source of getAllSourceEntries(config)) {
1211
1308
  const currentCursor = sourceCursors[source.key] || null;
1212
- const result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd);
1309
+ const result = await resolveSourcePayloadWithCursor(source, source.key, currentCursor, commandCwd, configPath);
1213
1310
  const payload = result.payload;
1214
1311
  if (payload) {
1215
1312
  payloads[source.key] = payload;
@@ -1247,6 +1344,11 @@ function hasSourceChanges(previousHashes, currentHashes) {
1247
1344
  return false;
1248
1345
  }
1249
1346
  async function runOnce(configPath, statePath) {
1347
+ await appendSchedulerProof('runner_invoked', {
1348
+ configPath,
1349
+ statePath,
1350
+ argv: process.argv.slice(2),
1351
+ });
1250
1352
  const config = await readJson(configPath);
1251
1353
  await applyOpenClawSecretRefs(config);
1252
1354
  const inferredGitHubRepo = await inferGitHubRepo(config);
@@ -1263,7 +1365,7 @@ async function runOnce(configPath, statePath) {
1263
1365
  lastRunAt: null,
1264
1366
  sourceCursors: {},
1265
1367
  });
1266
- const runtimeDir = path.resolve(DEFAULT_RUNTIME_DIR);
1368
+ const runtimeDir = path.resolve(deriveRuntimeDirFromStatePath(statePath));
1267
1369
  const stateAfterHealthCheck = await maybeRunConnectorHealthCheck({
1268
1370
  config,
1269
1371
  configPath,
@@ -1272,19 +1374,27 @@ async function runOnce(configPath, statePath) {
1272
1374
  runtimeDir,
1273
1375
  });
1274
1376
  const activeCadences = getDueCadences(config, stateAfterHealthCheck);
1275
- const { payloads, sourceCursors } = await loadSourcePayloads(config, stateAfterHealthCheck);
1377
+ const { payloads, sourceCursors } = await loadSourcePayloads(config, stateAfterHealthCheck, configPath);
1276
1378
  const currentHashes = computeSourceHashes(payloads);
1277
1379
  const changed = hasSourceChanges(stateAfterHealthCheck.sourceHashes, currentHashes);
1278
1380
  if (!changed && config.schedule?.skipIfNoDataChange !== false) {
1279
1381
  process.stdout.write(`[${new Date().toISOString()}] No data changes. Skip run.\n`);
1382
+ const completedAt = new Date().toISOString();
1280
1383
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1281
1384
  await fs.writeFile(statePath, JSON.stringify({
1282
1385
  ...stateAfterHealthCheck,
1283
1386
  sourceHashes: currentHashes,
1284
1387
  sourceCursors,
1285
- lastRunAt: new Date().toISOString(),
1388
+ lastRunAt: completedAt,
1286
1389
  skippedReason: 'no_data_change',
1287
1390
  }, null, 2), 'utf8');
1391
+ await appendSchedulerProof('runner_completed', {
1392
+ configPath,
1393
+ statePath,
1394
+ completedAt,
1395
+ skippedReason: 'no_data_change',
1396
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1397
+ });
1288
1398
  return;
1289
1399
  }
1290
1400
  const githubArtifactModes = getGitHubArtifactModes(config).filter((mode) => shouldAutoCreateGitHubArtifact(config, mode));
@@ -1312,17 +1422,26 @@ async function runOnce(configPath, statePath) {
1312
1422
  const unchangedIssueSet = issueFingerprint === stateAfterHealthCheck.lastIssueFingerprint;
1313
1423
  if (unchangedIssueSet && config.schedule?.skipIfIssueSetUnchanged !== false) {
1314
1424
  process.stdout.write(`[${new Date().toISOString()}] Issue set unchanged. Skip GitHub creation.\n`);
1425
+ const completedAt = new Date().toISOString();
1315
1426
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1316
1427
  await fs.writeFile(statePath, JSON.stringify({
1317
1428
  ...stateAfterHealthCheck,
1318
1429
  sourceHashes: currentHashes,
1319
1430
  sourceCursors,
1320
1431
  lastIssueFingerprint: issueFingerprint,
1321
- lastRunAt: new Date().toISOString(),
1432
+ lastRunAt: completedAt,
1322
1433
  lastOutFile: dryRun.outFile,
1323
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
1434
+ cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, completedAt),
1324
1435
  skippedReason: 'issue_set_unchanged',
1325
1436
  }, null, 2), 'utf8');
1437
+ await appendSchedulerProof('runner_completed', {
1438
+ configPath,
1439
+ statePath,
1440
+ completedAt,
1441
+ skippedReason: 'issue_set_unchanged',
1442
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1443
+ outFile: dryRun.outFile,
1444
+ });
1326
1445
  return;
1327
1446
  }
1328
1447
  const shouldCreateGitHubArtifact = createGitHubArtifact && Number(dryRun.issuesPayload?.issue_count || 0) > 0;
@@ -1343,15 +1462,16 @@ async function runOnce(configPath, statePath) {
1343
1462
  else {
1344
1463
  process.stdout.write(`[${new Date().toISOString()}] Drafts generated only (${getActionMode(config)} auto-create disabled).\n`);
1345
1464
  }
1465
+ const completedAt = new Date().toISOString();
1346
1466
  await fs.mkdir(path.dirname(statePath), { recursive: true });
1347
1467
  await fs.writeFile(statePath, JSON.stringify({
1348
1468
  ...stateAfterHealthCheck,
1349
1469
  sourceHashes: currentHashes,
1350
1470
  sourceCursors,
1351
1471
  lastIssueFingerprint: issueFingerprint,
1352
- lastRunAt: new Date().toISOString(),
1472
+ lastRunAt: completedAt,
1353
1473
  lastOutFile: dryRun.outFile,
1354
- cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, new Date().toISOString()),
1474
+ cadences: markCadencesRan(stateAfterHealthCheck, activeCadences, completedAt),
1355
1475
  lastGrowthRunNotifications: await deliverGrowthRunSummary({
1356
1476
  config,
1357
1477
  configPath,
@@ -1364,6 +1484,16 @@ async function runOnce(configPath, statePath) {
1364
1484
  }),
1365
1485
  skippedReason: null,
1366
1486
  }, null, 2), 'utf8');
1487
+ await appendSchedulerProof('runner_completed', {
1488
+ configPath,
1489
+ statePath,
1490
+ completedAt,
1491
+ skippedReason: null,
1492
+ activeCadences: activeCadences.map((cadence) => cadence.key),
1493
+ outFile: dryRun.outFile,
1494
+ issueCount: Number(dryRun.issuesPayload?.issue_count || 0),
1495
+ createdGitHubArtifact: shouldCreateGitHubArtifact,
1496
+ });
1367
1497
  }
1368
1498
  async function main() {
1369
1499
  await loadOpenClawGrowthSecrets();
@@ -1371,6 +1501,7 @@ async function main() {
1371
1501
  await maybeSelfUpdateFromClawHub(args);
1372
1502
  const configPath = path.resolve(args.config);
1373
1503
  const statePath = path.resolve(args.state);
1504
+ useSchedulerProofPathForStatePath(statePath);
1374
1505
  if (!args.loop) {
1375
1506
  await runOnce(configPath, statePath);
1376
1507
  return;
@@ -1384,12 +1515,21 @@ async function main() {
1384
1515
  await runOnce(configPath, statePath);
1385
1516
  }
1386
1517
  catch (error) {
1518
+ await appendSchedulerProof('runner_failed', {
1519
+ configPath,
1520
+ statePath,
1521
+ error: error instanceof Error ? error.message : String(error),
1522
+ }).catch(() => { });
1387
1523
  process.stderr.write(`[${new Date().toISOString()}] Run failed: ${error instanceof Error ? error.message : String(error)}\n`);
1388
1524
  }
1389
1525
  await sleep(intervalMinutes * 60_000);
1390
1526
  }
1391
1527
  }
1392
- main().catch((error) => {
1528
+ main().catch(async (error) => {
1529
+ await appendSchedulerProof('runner_failed', {
1530
+ error: error instanceof Error ? error.message : String(error),
1531
+ argv: process.argv.slice(2),
1532
+ }).catch(() => { });
1393
1533
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1394
1534
  process.exitCode = 1;
1395
1535
  });