@analyticscli/growth-engineer 0.1.0-preview.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/config.d.ts +925 -45
  2. package/dist/config.js +58 -6
  3. package/dist/config.js.map +1 -1
  4. package/dist/index.js +134 -21
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/export-asc-summary.mjs +295 -4
  7. package/dist/runtime/export-asc-summary.mjs.map +1 -1
  8. package/dist/runtime/export-coolify-summary.d.mts +2 -0
  9. package/dist/runtime/export-coolify-summary.mjs +230 -0
  10. package/dist/runtime/export-coolify-summary.mjs.map +1 -0
  11. package/dist/runtime/export-paddle-summary.d.mts +2 -0
  12. package/dist/runtime/export-paddle-summary.mjs +170 -0
  13. package/dist/runtime/export-paddle-summary.mjs.map +1 -0
  14. package/dist/runtime/export-sentry-summary.mjs +265 -38
  15. package/dist/runtime/export-sentry-summary.mjs.map +1 -1
  16. package/dist/runtime/export-seo-summary.d.mts +2 -0
  17. package/dist/runtime/export-seo-summary.mjs +503 -0
  18. package/dist/runtime/export-seo-summary.mjs.map +1 -0
  19. package/dist/runtime/openclaw-exporters-lib.d.mts +51 -0
  20. package/dist/runtime/openclaw-exporters-lib.mjs +769 -63
  21. package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -1
  22. package/dist/runtime/openclaw-growth-engineer.mjs +163 -4
  23. package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -1
  24. package/dist/runtime/openclaw-growth-env.mjs +5 -0
  25. package/dist/runtime/openclaw-growth-env.mjs.map +1 -1
  26. package/dist/runtime/openclaw-growth-preflight.mjs +446 -30
  27. package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
  28. package/dist/runtime/openclaw-growth-runner.mjs +831 -146
  29. package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
  30. package/dist/runtime/openclaw-growth-shared.d.mts +158 -3
  31. package/dist/runtime/openclaw-growth-shared.mjs +574 -8
  32. package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
  33. package/dist/runtime/openclaw-growth-start.mjs +802 -39
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +85 -31
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1952 -217
  38. package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
  39. package/package.json +3 -1
  40. package/templates/config.example.json +128 -65
@@ -1,21 +1,47 @@
1
- const BUILTIN_SOURCE_NAMES = ['analytics', 'revenuecat', 'sentry', 'feedback'];
1
+ import path from 'node:path';
2
+ const BUILTIN_SOURCE_NAMES = ['analytics', 'revenuecat', 'paddle', 'seo', 'sentry', 'coolify', 'feedback'];
3
+ const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
4
+ function quote(value) {
5
+ const raw = String(value);
6
+ if (/^[a-zA-Z0-9_./:@-]+$/.test(raw)) {
7
+ return raw;
8
+ }
9
+ return `'${raw.replace(/'/g, `'\\''`)}'`;
10
+ }
2
11
  const SERVICE_KIND_ALIASES = {
3
12
  analytics: [
4
13
  'analytics',
5
14
  'analyticscli',
6
- 'mixpanel',
7
- 'amplitude',
8
- 'firebase',
9
- 'posthog',
10
15
  'telemetry',
11
16
  ],
12
- revenue: ['revenuecat', 'stripe', 'purchases', 'billing', 'adapty', 'superwall'],
17
+ revenue: [
18
+ 'revenuecat',
19
+ 'paddle',
20
+ 'stripe',
21
+ 'lemonsqueezy',
22
+ 'lemon-squeezy',
23
+ 'purchases',
24
+ 'billing',
25
+ 'adapty',
26
+ 'superwall',
27
+ ],
28
+ seo: [
29
+ 'seo',
30
+ 'gsc',
31
+ 'google-search-console',
32
+ 'search-console',
33
+ 'dataforseo',
34
+ 'organic-search',
35
+ 'search',
36
+ ],
13
37
  crash: ['sentry', 'glitchtip', 'crashlytics', 'bugsnag', 'datadog', 'rollbar'],
38
+ infrastructure: ['coolify', 'vercel', 'cloudflare', 'deployment', 'deployments', 'hosting', 'infrastructure', 'infra'],
14
39
  feedback: [
15
40
  'feedback',
16
41
  'support',
17
42
  'intercom',
18
43
  'zendesk',
44
+ 'linear',
19
45
  'app-store-reviews',
20
46
  'app_store_reviews',
21
47
  'play-store-reviews',
@@ -30,8 +56,25 @@ const SERVICE_KIND_ALIASES = {
30
56
  'play_console',
31
57
  'google-play',
32
58
  'google_play',
59
+ 'appfollow',
60
+ 'app-follow',
61
+ 'apptweak',
62
+ 'app-tweak',
33
63
  'aso',
34
64
  ],
65
+ acquisition: [
66
+ 'apple-search-ads',
67
+ 'apple-ads',
68
+ 'google-ads',
69
+ 'meta-ads',
70
+ 'facebook-ads',
71
+ 'tiktok-ads',
72
+ 'postiz',
73
+ 'postiz-api',
74
+ 'social-publishing',
75
+ 'social-scheduler',
76
+ ],
77
+ lifecycle: ['resend', 'customerio', 'customer-io', 'mailchimp'],
35
78
  };
36
79
  export function getBuiltinSourceNames() {
37
80
  return [...BUILTIN_SOURCE_NAMES];
@@ -67,15 +110,27 @@ export function getDefaultSourceHint(service) {
67
110
  if (kind === 'revenue') {
68
111
  return '- Revenue provider summary with monetization deltas, package/offering signals, and churn notes.\n- Command mode should output JSON in the shared signals[] shape.';
69
112
  }
113
+ if (kind === 'seo') {
114
+ return '- SEO/acquisition summary from Google Search Console, DataForSEO, or CSV exports.\n- Prefer GSC and cached CSVs; only use paid APIs with an explicit request cap.';
115
+ }
70
116
  if (kind === 'crash') {
71
117
  return '- Crash/error provider summary with top regressions, affected users, and issue evidence.\n- `issues[]` or shared `signals[]` payloads are both accepted.';
72
118
  }
119
+ if (kind === 'infrastructure') {
120
+ return '- Hosting/deployment summary with failed deploys, unhealthy resources, and health-check gaps.\n- Command mode should output JSON in the shared signals[] shape.';
121
+ }
73
122
  if (kind === 'feedback') {
74
123
  return '- Aggregate app reviews, support tickets, or in-app feedback into recurring themes.\n- `items[]` or shared `signals[]` payloads are both accepted.';
75
124
  }
76
125
  if (kind === 'store') {
77
126
  return '- Store/distribution summary from ASC CLI, Play Console exports, or release tooling.\n- Focus on review trends, release blockers, ratings, and ASO signals.';
78
127
  }
128
+ if (kind === 'acquisition') {
129
+ return '- Paid acquisition summary with spend, conversions, CAC, ROAS, campaign quality, and channel movement.\n- Keep campaign/ad/account IDs discoverable instead of hard-coded when possible.';
130
+ }
131
+ if (kind === 'lifecycle') {
132
+ return '- Lifecycle messaging summary with sends, opens/clicks where relevant, bounces, complaints, journeys, campaigns, and conversion signals.\n- Prefer account/workspace-level summaries over individual campaign pins.';
133
+ }
79
134
  return '- Any connector is supported when it can produce JSON in the shared `signals[]` shape.\n- Use `issues[]` for crash tools or `items[]` for feedback-like tools when that fits better.';
80
135
  }
81
136
  export function getDefaultSourceCommand(service) {
@@ -86,9 +141,22 @@ export function getDefaultSourceCommand(service) {
86
141
  if (normalized === 'revenuecat' || normalized === 'revenue-cat' || normalized === 'rc') {
87
142
  return 'node scripts/export-revenuecat-summary.mjs';
88
143
  }
144
+ if (normalized === 'paddle') {
145
+ return 'node scripts/export-paddle-summary.mjs';
146
+ }
147
+ if (normalized === 'seo' ||
148
+ normalized === 'gsc' ||
149
+ normalized === 'google-search-console' ||
150
+ normalized === 'search-console' ||
151
+ normalized === 'dataforseo') {
152
+ return 'node scripts/export-seo-summary.mjs';
153
+ }
89
154
  if (normalized === 'sentry') {
90
155
  return 'node scripts/export-sentry-summary.mjs';
91
156
  }
157
+ if (normalized === 'coolify') {
158
+ return 'node scripts/export-coolify-summary.mjs';
159
+ }
92
160
  if (normalized === 'feedback') {
93
161
  return 'analyticscli feedback summary --format json';
94
162
  }
@@ -100,6 +168,474 @@ export function getDefaultSourceCommand(service) {
100
168
  }
101
169
  return null;
102
170
  }
171
+ export function getAutomationConfig(config) {
172
+ const automation = config?.automation && typeof config.automation === 'object' ? config.automation : {};
173
+ const openclawCron = automation.openclawCron && typeof automation.openclawCron === 'object' ? automation.openclawCron : {};
174
+ const openclawCronDelivery = openclawCron.delivery && typeof openclawCron.delivery === 'object' ? openclawCron.delivery : {};
175
+ const openclawCronDeliveryMode = String(openclawCronDelivery.mode || 'announce').trim() || 'announce';
176
+ const hermesCron = automation.hermesCron && typeof automation.hermesCron === 'object' ? automation.hermesCron : {};
177
+ return {
178
+ ...automation,
179
+ openclawCron: {
180
+ enabled: openclawCron.enabled !== false,
181
+ mode: String(openclawCron.mode || 'main').trim() || 'main',
182
+ schedule: String(openclawCron.schedule || '*/30 * * * *').trim() || '*/30 * * * *',
183
+ timezone: String(openclawCron.timezone || process.env.TZ || 'UTC').trim() || 'UTC',
184
+ name: String(openclawCron.name || 'OpenClaw Growth Engineer scheduler').trim() ||
185
+ 'OpenClaw Growth Engineer scheduler',
186
+ delivery: {
187
+ enabled: openclawCronDelivery.enabled !== false && openclawCronDeliveryMode !== 'none',
188
+ mode: openclawCronDeliveryMode,
189
+ channel: String(openclawCronDelivery.channel || 'last').trim() || 'last',
190
+ to: String(openclawCronDelivery.to || '').trim(),
191
+ },
192
+ },
193
+ hermesCron: {
194
+ enabled: hermesCron.enabled !== false,
195
+ schedule: String(hermesCron.schedule || openclawCron.schedule || '*/30 * * * *').trim() || '*/30 * * * *',
196
+ name: String(hermesCron.name || 'Hermes Growth Engineer scheduler').trim() ||
197
+ 'Hermes Growth Engineer scheduler',
198
+ skill: String(hermesCron.skill || 'growth-engineer').trim() || 'growth-engineer',
199
+ deliver: String(hermesCron.deliver || 'local').trim() || 'local',
200
+ workdir: typeof hermesCron.workdir === 'string' ? hermesCron.workdir.trim() : '',
201
+ },
202
+ };
203
+ }
204
+ export function deriveStatePathFromConfigPath(configPath) {
205
+ const normalized = String(configPath || DEFAULT_CONFIG_PATH).trim() || DEFAULT_CONFIG_PATH;
206
+ return path.join(path.dirname(normalized), 'state.json');
207
+ }
208
+ export function deriveRuntimeDirFromStatePath(statePath) {
209
+ const normalized = String(statePath || deriveStatePathFromConfigPath(DEFAULT_CONFIG_PATH)).trim() ||
210
+ deriveStatePathFromConfigPath(DEFAULT_CONFIG_PATH);
211
+ return path.join(path.dirname(normalized), 'runtime');
212
+ }
213
+ export function deriveSchedulerProofPathFromStatePath(statePath) {
214
+ return path.join(deriveRuntimeDirFromStatePath(statePath), 'scheduler-proof.jsonl');
215
+ }
216
+ export function buildGrowthRunnerCommand(configPath, statePath = deriveStatePathFromConfigPath(configPath)) {
217
+ const normalizedConfigPath = String(configPath || DEFAULT_CONFIG_PATH).trim() || DEFAULT_CONFIG_PATH;
218
+ return `node scripts/openclaw-growth-runner.mjs --config ${quote(normalizedConfigPath)} --state ${quote(statePath)}`;
219
+ }
220
+ export function buildOpenClawGrowthSystemEvent(configPath, config = {}) {
221
+ const statePath = deriveStatePathFromConfigPath(configPath);
222
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
223
+ const command = buildGrowthRunnerCommand(configPath, statePath);
224
+ const automation = getAutomationConfig(config);
225
+ return [
226
+ 'Run OpenClaw Growth Engineer for this workspace.',
227
+ `Execute: ${command}`,
228
+ 'Execute only that runner command. Do not run sudo, setup, install, cron repair, or other shell commands from this scheduled event.',
229
+ 'If any dependency asks for sudo or a password, stop and report the blocked non-interactive command instead of prompting.',
230
+ 'The runner is the source of truth for connector health, 90-minute production healthchecks, daily, weekly, monthly, 3-month, six-month, and yearly cadence decisions.',
231
+ `After the command finishes, inspect ${statePath} and ${proofPath}.`,
232
+ 'Always let the runner write state and proof logs. For social/chat output, only summarize new or changed findings, connector-health changes, delivery failures, or runner failures.',
233
+ 'If the runner completes with skippedReason cadence_not_due, issue_set_unchanged, or no_data_change, reply exactly HEARTBEAT_OK and do not repeat old findings.',
234
+ 'Persisted connectorHealth.lastStatusOk=false is not by itself a new event. If the latest proof says connector_health_not_due, connector_health_unchanged, or socialOutput HEARTBEAT_OK, reply exactly HEARTBEAT_OK.',
235
+ 'If connector health is healthy, no production issue is found, and no actionable growth finding was generated, reply HEARTBEAT_OK.',
236
+ `Expected OpenClaw cron schedule: ${automation.openclawCron.schedule} ${automation.openclawCron.timezone}.`,
237
+ ].join(' ');
238
+ }
239
+ export function buildOpenClawCronAddCommand(configPath, config = {}) {
240
+ const automation = getAutomationConfig(config).openclawCron;
241
+ const eventText = buildOpenClawGrowthSystemEvent(configPath, config);
242
+ const command = [
243
+ 'openclaw cron add',
244
+ '--name',
245
+ quote(automation.name),
246
+ '--cron',
247
+ quote(automation.schedule),
248
+ '--tz',
249
+ quote(automation.timezone),
250
+ '--session',
251
+ automation.mode === 'isolated' ? 'isolated' : 'main',
252
+ automation.mode === 'isolated' ? '--message' : '--system-event',
253
+ quote(eventText),
254
+ ];
255
+ if (automation.delivery.enabled) {
256
+ command.push('--announce', '--channel', quote(automation.delivery.channel));
257
+ if (automation.delivery.to) {
258
+ command.push('--to', quote(automation.delivery.to));
259
+ }
260
+ }
261
+ else {
262
+ command.push('--no-deliver');
263
+ }
264
+ if (automation.mode !== 'isolated') {
265
+ command.push('--wake now');
266
+ }
267
+ return command.join(' ');
268
+ }
269
+ function getOpenClawCronJobId(job) {
270
+ if (!job || typeof job !== 'object')
271
+ return '';
272
+ for (const key of ['id', 'jobId', 'job_id', 'uuid']) {
273
+ const value = job[key];
274
+ if (typeof value === 'string' && value.trim())
275
+ return value.trim();
276
+ }
277
+ return '';
278
+ }
279
+ export function buildOpenClawCronEditDeliveryCommand(job, config = {}) {
280
+ const automation = getAutomationConfig(config).openclawCron;
281
+ const jobId = typeof job === 'string' ? job.trim() : getOpenClawCronJobId(job);
282
+ if (!jobId)
283
+ return '';
284
+ const command = ['openclaw cron edit', quote(jobId)];
285
+ if (automation.delivery.enabled) {
286
+ command.push('--announce', '--channel', quote(automation.delivery.channel));
287
+ if (automation.delivery.to) {
288
+ command.push('--to', quote(automation.delivery.to));
289
+ }
290
+ command.push('--best-effort-deliver');
291
+ }
292
+ else {
293
+ command.push('--no-deliver');
294
+ }
295
+ return command.join(' ');
296
+ }
297
+ export function getOpenClawCronEditDeliveryCommandFromInspection(inspection, config = {}) {
298
+ const jobs = Array.isArray(inspection?.jobs) ? inspection.jobs : [];
299
+ for (const job of jobs) {
300
+ const command = buildOpenClawCronEditDeliveryCommand(job, config);
301
+ if (command)
302
+ return command;
303
+ }
304
+ return '';
305
+ }
306
+ function normalizeCronComparable(value) {
307
+ return String(value || '')
308
+ .replace(/\\"/g, '"')
309
+ .replace(/\\'/g, "'")
310
+ .replace(/\s+/g, ' ')
311
+ .trim();
312
+ }
313
+ function collectObjects(value, result = []) {
314
+ if (!value || typeof value !== 'object')
315
+ return result;
316
+ if (Array.isArray(value)) {
317
+ for (const item of value)
318
+ collectObjects(item, result);
319
+ return result;
320
+ }
321
+ result.push(value);
322
+ for (const item of Object.values(value))
323
+ collectObjects(item, result);
324
+ return result;
325
+ }
326
+ function parseJsonMaybe(value) {
327
+ const text = String(value || '').trim();
328
+ if (!text)
329
+ return null;
330
+ try {
331
+ return JSON.parse(text);
332
+ }
333
+ catch {
334
+ const starts = [text.indexOf('{'), text.indexOf('[')].filter((index) => index >= 0);
335
+ if (starts.length === 0)
336
+ return null;
337
+ try {
338
+ return JSON.parse(text.slice(Math.min(...starts)));
339
+ }
340
+ catch {
341
+ return null;
342
+ }
343
+ }
344
+ }
345
+ export function buildOpenClawCronVerification(configPath, config = {}) {
346
+ const automation = getAutomationConfig(config).openclawCron;
347
+ const statePath = deriveStatePathFromConfigPath(configPath);
348
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
349
+ return {
350
+ name: automation.name,
351
+ schedule: automation.schedule,
352
+ timezone: automation.timezone,
353
+ delivery: automation.delivery,
354
+ statePath,
355
+ proofPath,
356
+ requiredFragments: [
357
+ automation.name,
358
+ automation.schedule,
359
+ automation.timezone,
360
+ 'Run OpenClaw Growth Engineer for this workspace',
361
+ 'openclaw-growth-runner.mjs',
362
+ '--config',
363
+ configPath,
364
+ '--state',
365
+ statePath,
366
+ proofPath,
367
+ 'HEARTBEAT_OK',
368
+ ],
369
+ };
370
+ }
371
+ function hasExpectedOpenClawCronDelivery(job, verification) {
372
+ const expected = verification?.delivery || {};
373
+ const delivery = job && typeof job === 'object' && job.delivery && typeof job.delivery === 'object'
374
+ ? job.delivery
375
+ : null;
376
+ if (!expected.enabled) {
377
+ if (!delivery)
378
+ return true;
379
+ const mode = String(delivery.mode || '').trim().toLowerCase();
380
+ return delivery.enabled === false || mode === 'none' || mode === 'disabled';
381
+ }
382
+ if (delivery) {
383
+ const mode = String(delivery.mode || '').trim().toLowerCase();
384
+ if (delivery.enabled === false || mode === 'none' || mode === 'disabled')
385
+ return false;
386
+ if (mode && mode !== 'announce')
387
+ return false;
388
+ const expectedChannel = String(expected.channel || '').trim();
389
+ const actualChannel = String(delivery.channel || '').trim();
390
+ if (expectedChannel && expectedChannel !== 'last' && actualChannel !== expectedChannel)
391
+ return false;
392
+ const expectedTo = String(expected.to || '').trim();
393
+ if (expectedTo && String(delivery.to || '').trim() !== expectedTo)
394
+ return false;
395
+ return true;
396
+ }
397
+ const blob = normalizeCronComparable(JSON.stringify(job || {}));
398
+ const required = ['announce'];
399
+ const expectedChannel = String(expected.channel || '').trim();
400
+ if (expectedChannel && expectedChannel !== 'last')
401
+ required.push(expectedChannel);
402
+ if (expected.to)
403
+ required.push(String(expected.to));
404
+ return required.every((fragment) => blob.includes(normalizeCronComparable(fragment)));
405
+ }
406
+ export function evaluateOpenClawCronRecords(records, verification) {
407
+ const objects = collectObjects(records);
408
+ const directJobs = objects.filter((job) => {
409
+ const directName = typeof job.name === 'string' ? job.name : typeof job.title === 'string' ? job.title : '';
410
+ return directName === verification.name;
411
+ });
412
+ const jobs = directJobs.length > 0 ? directJobs : objects.filter((job) => {
413
+ return normalizeCronComparable(JSON.stringify(job)).includes(normalizeCronComparable(verification.name));
414
+ });
415
+ if (jobs.length === 0) {
416
+ return { exists: false, verified: false, reason: 'not_found', jobs: [] };
417
+ }
418
+ let deliveryMismatch = false;
419
+ for (const job of jobs) {
420
+ const blob = normalizeCronComparable(JSON.stringify(job));
421
+ const missing = verification.requiredFragments.filter((fragment) => !blob.includes(normalizeCronComparable(fragment)));
422
+ if (missing.length === 0) {
423
+ if (hasExpectedOpenClawCronDelivery(job, verification)) {
424
+ return { exists: true, verified: true, reason: 'verified', jobs };
425
+ }
426
+ deliveryMismatch = true;
427
+ }
428
+ }
429
+ return { exists: true, verified: false, reason: deliveryMismatch ? 'delivery_mismatch' : 'missing_required_fragments', jobs };
430
+ }
431
+ export function evaluateOpenClawCronText(text, verification) {
432
+ const blob = normalizeCronComparable(text);
433
+ if (!blob.includes(normalizeCronComparable(verification.name))) {
434
+ return { exists: false, verified: false, reason: 'not_found' };
435
+ }
436
+ const missing = verification.requiredFragments.filter((fragment) => !blob.includes(normalizeCronComparable(fragment)));
437
+ return {
438
+ exists: true,
439
+ verified: missing.length === 0,
440
+ reason: missing.length === 0 ? 'verified' : 'text_listing_unverified',
441
+ };
442
+ }
443
+ export async function inspectOpenClawCronInstall({ configPath, config = {}, runCommand, readFile, home = process.env.HOME, }) {
444
+ const verification = buildOpenClawCronVerification(configPath, config);
445
+ for (const command of ['openclaw cron list --json', 'openclaw cron list --format json']) {
446
+ const result = await runCommand(command, 30_000);
447
+ if (!result?.ok)
448
+ continue;
449
+ const parsed = parseJsonMaybe(result.stdout);
450
+ if (!parsed)
451
+ continue;
452
+ const evaluated = evaluateOpenClawCronRecords(parsed, verification);
453
+ if (evaluated.exists) {
454
+ return { ...evaluated, source: command, verification };
455
+ }
456
+ }
457
+ if (readFile && home) {
458
+ const jobStorePaths = [
459
+ path.join(home, '.openclaw', 'cron', 'jobs.json'),
460
+ path.join(home, '.config', 'openclaw', 'cron', 'jobs.json'),
461
+ ];
462
+ for (const filePath of jobStorePaths) {
463
+ try {
464
+ const parsed = parseJsonMaybe(await readFile(filePath, 'utf8'));
465
+ if (!parsed)
466
+ continue;
467
+ const evaluated = evaluateOpenClawCronRecords(parsed, verification);
468
+ if (evaluated.exists) {
469
+ return { ...evaluated, source: filePath, verification };
470
+ }
471
+ }
472
+ catch {
473
+ // Ignore missing or unreadable implementation-specific stores.
474
+ }
475
+ }
476
+ }
477
+ const list = await runCommand('openclaw cron list', 30_000);
478
+ if (list?.ok) {
479
+ const evaluated = evaluateOpenClawCronText(list.stdout, verification);
480
+ if (evaluated.exists) {
481
+ return { ...evaluated, source: 'openclaw cron list', verification };
482
+ }
483
+ }
484
+ return { exists: false, verified: false, reason: 'not_found', source: 'openclaw cron list', verification };
485
+ }
486
+ function normalizeOpenClawCronDeliveryForStore(delivery) {
487
+ return {
488
+ mode: 'announce',
489
+ channel: String(delivery?.channel || 'last').trim() || 'last',
490
+ to: String(delivery?.to || '').trim(),
491
+ };
492
+ }
493
+ function repairOpenClawCronDeliveryRecords(records, verification) {
494
+ let repaired = 0;
495
+ const objects = collectObjects(records);
496
+ for (const job of objects) {
497
+ if (!job || typeof job !== 'object')
498
+ continue;
499
+ const directName = typeof job.name === 'string' ? job.name : typeof job.title === 'string' ? job.title : '';
500
+ if (directName !== verification.name)
501
+ continue;
502
+ const blob = normalizeCronComparable(JSON.stringify(job));
503
+ const missing = verification.requiredFragments.filter((fragment) => !blob.includes(normalizeCronComparable(fragment)));
504
+ if (missing.length > 0 || hasExpectedOpenClawCronDelivery(job, verification))
505
+ continue;
506
+ job.delivery = normalizeOpenClawCronDeliveryForStore(verification.delivery);
507
+ repaired += 1;
508
+ }
509
+ return repaired;
510
+ }
511
+ export async function repairOpenClawCronDeliveryStore({ configPath, config = {}, readFile, writeFile, home = process.env.HOME, }) {
512
+ if (!readFile || !writeFile || !home) {
513
+ return { ok: false, repaired: false, reason: 'missing_io' };
514
+ }
515
+ const verification = buildOpenClawCronVerification(configPath, config);
516
+ const jobStorePaths = [
517
+ path.join(home, '.openclaw', 'cron', 'jobs.json'),
518
+ path.join(home, '.config', 'openclaw', 'cron', 'jobs.json'),
519
+ ];
520
+ for (const filePath of jobStorePaths) {
521
+ let raw = '';
522
+ let parsed = null;
523
+ try {
524
+ raw = await readFile(filePath, 'utf8');
525
+ parsed = parseJsonMaybe(raw);
526
+ }
527
+ catch {
528
+ continue;
529
+ }
530
+ if (!parsed)
531
+ continue;
532
+ const repairedCount = repairOpenClawCronDeliveryRecords(parsed, verification);
533
+ if (repairedCount === 0)
534
+ continue;
535
+ await writeFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
536
+ return { ok: true, repaired: true, repairedCount, path: filePath, verification };
537
+ }
538
+ return { ok: true, repaired: false, reason: 'not_found', verification };
539
+ }
540
+ export function buildHermesGrowthPrompt(configPath, config = {}) {
541
+ const statePath = deriveStatePathFromConfigPath(configPath);
542
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
543
+ const command = buildGrowthRunnerCommand(configPath, statePath);
544
+ const automation = getAutomationConfig(config);
545
+ return [
546
+ 'Run Growth Engineer for this workspace.',
547
+ `Execute: ${command}`,
548
+ 'The runner is the source of truth for connector health, daily, weekly, monthly, quarterly, six-month, and yearly cadence decisions.',
549
+ `After the command finishes, inspect ${statePath} and ${proofPath}.`,
550
+ 'For social/chat output, only summarize new or changed findings, connector-health changes, delivery failures, or runner failures.',
551
+ 'Persisted connectorHealth.lastStatusOk=false is not by itself a new event. If the latest proof says issue_set_unchanged, no_data_change, connector_health_not_due, connector_health_unchanged, or socialOutput HEARTBEAT_OK, reply exactly HEARTBEAT_OK.',
552
+ 'If connector health is healthy, no production issue is found, and no actionable growth finding was generated, reply HEARTBEAT_OK.',
553
+ `Expected Hermes cron schedule: ${automation.hermesCron.schedule}.`,
554
+ ].join(' ');
555
+ }
556
+ export function buildHermesCronCreateCommand(configPath, config = {}, options = {}) {
557
+ const automation = getAutomationConfig(config).hermesCron;
558
+ const workdir = path.resolve(options.workdir || automation.workdir || process.cwd());
559
+ const prompt = buildHermesGrowthPrompt(configPath, {
560
+ ...config,
561
+ automation: {
562
+ ...config?.automation,
563
+ hermesCron: automation,
564
+ },
565
+ });
566
+ return [
567
+ 'hermes cron create',
568
+ quote(automation.schedule),
569
+ quote(prompt),
570
+ '--name',
571
+ quote(automation.name),
572
+ '--skill',
573
+ quote(automation.skill),
574
+ '--deliver',
575
+ quote(automation.deliver),
576
+ '--workdir',
577
+ quote(workdir),
578
+ ].join(' ');
579
+ }
580
+ export function buildHermesCronVerification(configPath, config = {}, options = {}) {
581
+ const automation = getAutomationConfig(config).hermesCron;
582
+ const statePath = deriveStatePathFromConfigPath(configPath);
583
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
584
+ const workdir = path.resolve(options.workdir || automation.workdir || process.cwd());
585
+ return {
586
+ name: automation.name,
587
+ schedule: automation.schedule,
588
+ workdir,
589
+ statePath,
590
+ proofPath,
591
+ requiredFragments: [
592
+ automation.name,
593
+ automation.schedule,
594
+ automation.skill,
595
+ automation.deliver,
596
+ workdir,
597
+ 'Run Growth Engineer for this workspace',
598
+ 'openclaw-growth-runner.mjs',
599
+ '--config',
600
+ configPath,
601
+ '--state',
602
+ statePath,
603
+ proofPath,
604
+ 'HEARTBEAT_OK',
605
+ ],
606
+ };
607
+ }
608
+ export async function inspectHermesCronInstall({ configPath, config = {}, runCommand, readFile, home = process.env.HOME, workdir = process.cwd(), }) {
609
+ const verification = buildHermesCronVerification(configPath, config, { workdir });
610
+ if (readFile && home) {
611
+ const jobStorePaths = [
612
+ path.join(home, '.hermes', 'cron', 'jobs.json'),
613
+ path.join(home, '.config', 'hermes', 'cron', 'jobs.json'),
614
+ ];
615
+ for (const filePath of jobStorePaths) {
616
+ try {
617
+ const parsed = parseJsonMaybe(await readFile(filePath, 'utf8'));
618
+ if (!parsed)
619
+ continue;
620
+ const evaluated = evaluateOpenClawCronRecords(parsed, verification);
621
+ if (evaluated.exists) {
622
+ return { ...evaluated, source: filePath, verification };
623
+ }
624
+ }
625
+ catch {
626
+ // Ignore missing or unreadable implementation-specific stores.
627
+ }
628
+ }
629
+ }
630
+ const list = await runCommand('hermes cron list', 30_000);
631
+ if (list?.ok) {
632
+ const evaluated = evaluateOpenClawCronText(list.stdout, verification);
633
+ if (evaluated.exists) {
634
+ return { ...evaluated, source: 'hermes cron list', verification };
635
+ }
636
+ }
637
+ return { exists: false, verified: false, reason: 'not_found', source: 'hermes cron list', verification };
638
+ }
103
639
  export function buildExtraSourceConfig(service, options = {}) {
104
640
  const normalizedService = normalizeServiceType(service);
105
641
  const key = normalizeSourceKey(options.key || normalizedService || `extra_${Date.now()}`);
@@ -173,11 +709,41 @@ export function getActionMode(config) {
173
709
  }
174
710
  return 'issue';
175
711
  }
176
- export function shouldAutoCreateGitHubArtifact(config) {
712
+ export function getGitHubArtifactModes(config) {
713
+ const modes = [];
714
+ const hasExplicitDestinations = Array.isArray(config?.actions?.outputDestinations);
715
+ const destinations = hasExplicitDestinations
716
+ ? config.actions.outputDestinations.map((value) => normalizeServiceType(value))
717
+ : [];
718
+ const deliveryModes = Array.isArray(config?.deliveries?.github?.modes)
719
+ ? config.deliveries.github.modes.map((value) => normalizeServiceType(value))
720
+ : [];
721
+ if (config?.actions?.autoCreateIssues === true ||
722
+ destinations.includes('github_issue') ||
723
+ deliveryModes.includes('issue')) {
724
+ modes.push('issue');
725
+ }
726
+ if (config?.actions?.autoCreatePullRequests === true ||
727
+ destinations.includes('github_pull_request') ||
728
+ destinations.includes('github-pr') ||
729
+ destinations.includes('draft_pr') ||
730
+ deliveryModes.includes('pull_request') ||
731
+ deliveryModes.includes('pull-request')) {
732
+ modes.push('pull_request');
733
+ }
734
+ if (modes.length === 0 && !hasExplicitDestinations) {
735
+ modes.push(getActionMode(config));
736
+ }
737
+ return [...new Set(modes)];
738
+ }
739
+ export function shouldAutoCreateGitHubArtifact(config, requestedMode = null) {
177
740
  if (config?.actions?.disableAutoCreateGitHubArtifacts === true) {
178
741
  return false;
179
742
  }
180
- const mode = getActionMode(config);
743
+ if (!requestedMode && Array.isArray(config?.actions?.outputDestinations) && getGitHubArtifactModes(config).length === 0) {
744
+ return false;
745
+ }
746
+ const mode = requestedMode || getActionMode(config);
181
747
  if (mode === 'pull_request') {
182
748
  return config?.actions?.autoCreatePullRequests === true;
183
749
  }