coder-config 0.50.5-beta → 0.50.7-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config-loader.js CHANGED
@@ -31,6 +31,7 @@ const { getProjectsRegistryPath, loadProjectsRegistry, saveProjectsRegistry, pro
31
31
  const { getWorkstreamsPath, loadWorkstreams, saveWorkstreams, workstreamList, workstreamCreate, workstreamUpdate, workstreamDelete, workstreamUse, workstreamActive, workstreamAddProject, workstreamRemoveProject, workstreamInject, workstreamDetect, workstreamGet, getActiveWorkstream, countWorkstreamsForProject, workstreamInstallHook, workstreamInstallHookGemini, workstreamInstallHookCodex, workstreamDeactivate, workstreamCheckPath, getSettingsPath, loadSettings, saveSettings, workstreamAddTrigger, workstreamRemoveTrigger, workstreamSetAutoActivate, setGlobalAutoActivate, shouldAutoActivate, workstreamCheckFolder, workstreamInstallCdHook, workstreamUninstallCdHook, workstreamCdHookStatus, discoverSubProjects, generateRulesFromRepos, generateRulesWithClaude, generateRulesWithAI, getAvailableAITools, findAIBinary, AI_TOOLS, workstreamSetSandbox } = require('./lib/workstreams');
32
32
  const { getActivityPath, getDefaultActivity, loadActivity, saveActivity, detectProjectRoot, activityLog, activitySummary, generateWorkstreamName, activitySuggestWorkstreams, activityClear } = require('./lib/activity');
33
33
  const { getLoopsPath, loadLoops, saveLoops, loadLoopState, saveLoopState, loadHistory, saveHistory, loopList, loopCreate, loopGet, loopUpdate, loopDelete, loopStart, loopPause, loopResume, loopCancel, loopApprove, loopComplete, loopFail, loopStatus, loopHistory, loopConfig, getActiveLoop, recordIteration, saveClarifications, savePlan, loadClarifications, loadPlan, loopInject, archiveLoop } = require('./lib/loops');
34
+ const { heartbeat: runHeartbeat, formatReport, getExitCode, saveLastHeartbeat, shouldNotify, buildMacosNotification, loadHeartbeatConfig, getDefaultHeartbeatConfig } = require('./lib/heartbeat');
34
35
  const { getSessionStatus, showSessionStatus, flushContext, clearContext, installHooks: sessionInstallHooks, getFlushedContext, installFlushCommand, installAll: sessionInstallAll, SESSION_DIR, FLUSHED_CONTEXT_FILE } = require('./lib/sessions');
35
36
  const { runCli } = require('./lib/cli');
36
37
  const { shellStatus, shellInstall, shellUninstall, printShellStatus } = require('./lib/shell');
@@ -253,6 +254,54 @@ class ClaudeConfigManager {
253
254
  loopInject(silent) { return loopInject(this.installDir, silent); }
254
255
  archiveLoop(loopId) { return archiveLoop(this.installDir, loopId); }
255
256
 
257
+ loopHeartbeat(options = {}) {
258
+ const report = runHeartbeat(this.installDir);
259
+
260
+ if (options.json) {
261
+ console.log(JSON.stringify(report, null, 2));
262
+ } else if (options.quiet) {
263
+ if (getExitCode(report) === 1) {
264
+ console.log(formatReport(report));
265
+ }
266
+ } else {
267
+ console.log(formatReport(report));
268
+ }
269
+
270
+ if (options.notify) {
271
+ const config = loadHeartbeatConfig(this.installDir);
272
+ const newAlerts = report.alerts.filter(a =>
273
+ (a.severity === 'critical' || a.severity === 'warning') &&
274
+ shouldNotify(this.installDir, a, config.cooldownMinutes || 15)
275
+ );
276
+
277
+ if (newAlerts.length > 0) {
278
+ if (config.notifications?.macos?.enabled !== false) {
279
+ const cmd = buildMacosNotification(report);
280
+ try {
281
+ require('child_process').execSync(cmd);
282
+ } catch (e) {
283
+ // osascript may fail in non-GUI contexts
284
+ }
285
+ }
286
+
287
+ if (config.notifications?.slack?.enabled && config.notifications?.slack?.webhookUrl) {
288
+ const https = require('https');
289
+ const url = new URL(config.notifications.slack.webhookUrl);
290
+ const payload = JSON.stringify({ text: `Ralph Heartbeat: ${report.summary}` });
291
+ const req = https.request({ hostname: url.hostname, path: url.pathname, method: 'POST',
292
+ headers: { 'Content-Type': 'application/json' }
293
+ });
294
+ req.write(payload);
295
+ req.end();
296
+ }
297
+
298
+ saveLastHeartbeat(this.installDir, report);
299
+ }
300
+ }
301
+
302
+ return report;
303
+ }
304
+
256
305
  // Activity
257
306
  getActivityPath() { return getActivityPath(this.installDir); }
258
307
  loadActivity() { return loadActivity(this.installDir); }
package/lib/cli.js CHANGED
@@ -272,6 +272,16 @@ function runCli(manager) {
272
272
  } else {
273
273
  manager.loopConfig();
274
274
  }
275
+ } else if (args[1] === 'heartbeat') {
276
+ const options = {
277
+ notify: args.includes('--notify'),
278
+ quiet: args.includes('--quiet'),
279
+ json: args.includes('--json')
280
+ };
281
+ const report = manager.loopHeartbeat(options);
282
+ if (options.quiet) {
283
+ process.exitCode = report.alerts.some(a => a.severity === 'critical' || a.severity === 'warning') ? 1 : 0;
284
+ }
275
285
  } else if (args[1] === 'inject') {
276
286
  const silent = args.includes('--silent') || args.includes('-s');
277
287
  manager.loopInject(silent);
@@ -456,6 +466,9 @@ ${chalk.dim('Configuration manager for AI coding tools (Claude Code, Gemini CLI,
456
466
  cmd('loop cancel <id>', 'Cancel loop'),
457
467
  cmd('loop status [id]', 'Show loop status'),
458
468
  cmd('loop config', 'Show/set loop config'),
469
+ cmd('loop heartbeat', 'Check loop health'),
470
+ cmd('loop heartbeat --notify', 'Check + send notifications'),
471
+ cmd('loop heartbeat --quiet', 'Silent unless alerts'),
459
472
  ]));
460
473
  console.log();
461
474
  }
package/lib/constants.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Constants and tool path configurations
3
3
  */
4
4
 
5
- const VERSION = '0.50.5-beta';
5
+ const VERSION = '0.50.7-beta';
6
6
 
7
7
  // Tool-specific path configurations
8
8
  const TOOL_PATHS = {
package/lib/heartbeat.js CHANGED
@@ -199,10 +199,150 @@ function heartbeat(installDir, config) {
199
199
  };
200
200
  }
201
201
 
202
+ /**
203
+ * Save the last heartbeat report to disk for deduplication
204
+ * @param {string} installDir - path to coder-config install dir
205
+ * @param {object} report - heartbeat report with alerts array
206
+ */
207
+ function saveLastHeartbeat(installDir, report) {
208
+ const loopsDir = path.join(installDir, 'loops');
209
+ fs.mkdirSync(loopsDir, { recursive: true });
210
+ const alertHashes = {};
211
+ const now = Date.now();
212
+ for (const alert of (report.alerts || [])) {
213
+ const key = `${alert.loopId}:${alert.type}`;
214
+ alertHashes[key] = now;
215
+ }
216
+ const data = { alertHashes, timestamp: now };
217
+ fs.writeFileSync(path.join(loopsDir, 'last-heartbeat.json'), JSON.stringify(data, null, 2));
218
+ }
219
+
220
+ /**
221
+ * Load the last saved heartbeat from disk
222
+ * @param {string} installDir - path to coder-config install dir
223
+ * @returns {object|null} saved heartbeat data or null if not found
224
+ */
225
+ function loadLastHeartbeat(installDir) {
226
+ const filePath = path.join(installDir, 'loops', 'last-heartbeat.json');
227
+ try {
228
+ const raw = fs.readFileSync(filePath, 'utf8');
229
+ return JSON.parse(raw);
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Determine whether an alert should fire (not within cooldown)
237
+ * @param {string} installDir - path to coder-config install dir
238
+ * @param {object} alert - alert object with loopId and type
239
+ * @param {number} cooldownMinutes - cooldown in minutes
240
+ * @returns {boolean} true if should notify, false if within cooldown
241
+ */
242
+ function shouldNotify(installDir, alert, cooldownMinutes) {
243
+ const last = loadLastHeartbeat(installDir);
244
+ if (!last || !last.alertHashes) return true;
245
+ const key = `${alert.loopId}:${alert.type}`;
246
+ const lastTime = last.alertHashes[key];
247
+ if (lastTime == null) return true;
248
+ const ageMinutes = (Date.now() - lastTime) / 60000;
249
+ return ageMinutes >= cooldownMinutes;
250
+ }
251
+
252
+ /**
253
+ * Build a macOS osascript notification command for a heartbeat report
254
+ * @param {object} report - heartbeat report
255
+ * @returns {string} shell command string
256
+ */
257
+ function buildMacosNotification(report) {
258
+ const alerts = report.alerts || [];
259
+ const criticalCount = alerts.filter(a => a.severity === 'critical').length;
260
+ const warningCount = alerts.filter(a => a.severity === 'warning').length;
261
+ const parts = [];
262
+ if (criticalCount > 0) parts.push(`${criticalCount} critical`);
263
+ if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
264
+ const subtitle = parts.length > 0 ? parts.join(', ') : 'all healthy';
265
+ const body = (report.summary || subtitle).replace(/'/g, "\\'");
266
+ return `osascript -e 'display notification "${body}" with title "Ralph Heartbeat" subtitle "${subtitle}"'`;
267
+ }
268
+
269
+ /**
270
+ * Get exit code for a heartbeat report
271
+ * @param {object} report - heartbeat report with alerts array
272
+ * @returns {number} 0 if healthy or only info, 1 if any warning or critical
273
+ */
274
+ function getExitCode(report) {
275
+ const alerts = report.alerts || [];
276
+ const hasActionable = alerts.some(a => a.severity === 'critical' || a.severity === 'warning');
277
+ return hasActionable ? 1 : 0;
278
+ }
279
+
280
+ /**
281
+ * Format a heartbeat report for terminal display
282
+ * @param {object} report - heartbeat report object
283
+ * @returns {string} formatted string for console output
284
+ */
285
+ function formatReport(report) {
286
+ const chalk = require('chalk');
287
+ const lines = [];
288
+
289
+ lines.push(`♥ Loop Heartbeat — ${report.timestamp}`);
290
+ lines.push('');
291
+
292
+ const alerts = report.alerts || [];
293
+ const critical = alerts.filter(a => a.severity === 'critical');
294
+ const warning = alerts.filter(a => a.severity === 'warning');
295
+ const info = alerts.filter(a => a.severity === 'info');
296
+ const healthy = report.healthy || [];
297
+
298
+ if (critical.length > 0) {
299
+ lines.push(chalk.red('🔴 CRITICAL'));
300
+ for (const a of critical) {
301
+ lines.push(` ${chalk.red('✗')} ${a.name} — ${a.message}`);
302
+ }
303
+ lines.push('');
304
+ }
305
+
306
+ if (warning.length > 0) {
307
+ lines.push(chalk.yellow('🟡 WARNING'));
308
+ for (const a of warning) {
309
+ lines.push(` ${chalk.yellow('⚠')} ${a.name} — ${a.message}`);
310
+ }
311
+ lines.push('');
312
+ }
313
+
314
+ if (info.length > 0) {
315
+ lines.push(chalk.blue('🔵 INFO'));
316
+ for (const a of info) {
317
+ lines.push(` ${chalk.blue('ℹ')} ${a.name} — ${a.message}`);
318
+ }
319
+ lines.push('');
320
+ }
321
+
322
+ if (healthy.length > 0) {
323
+ lines.push(chalk.green('🟢 HEALTHY'));
324
+ for (const h of healthy) {
325
+ const phase = h.phase ? ` [${h.phase}]` : '';
326
+ lines.push(` ${chalk.green('●')} ${h.name}${phase} ${h.iteration}`);
327
+ }
328
+ lines.push('');
329
+ }
330
+
331
+ lines.push(`Summary: ${report.summary}`);
332
+
333
+ return lines.join('\n');
334
+ }
335
+
202
336
  module.exports = {
203
337
  heartbeat,
204
338
  getDefaultHeartbeatConfig,
205
339
  loadHeartbeatConfig,
206
340
  evaluateLoop,
207
- buildSummary
341
+ buildSummary,
342
+ saveLastHeartbeat,
343
+ loadLastHeartbeat,
344
+ shouldNotify,
345
+ buildMacosNotification,
346
+ getExitCode,
347
+ formatReport
208
348
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coder-config",
3
- "version": "0.50.5-beta",
3
+ "version": "0.50.7-beta",
4
4
  "description": "Configuration manager for AI coding tools - Claude Code, Gemini CLI, Codex CLI, Antigravity. Manage MCPs, rules, permissions, memory, and workstreams.",
5
5
  "author": "regression.io",
6
6
  "main": "config-loader.js",