coder-config 0.50.6-beta → 0.50.8-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
@@ -263,15 +263,31 @@ function runCli(manager) {
263
263
  manager.loopHistory();
264
264
  } else if (args[1] === 'config') {
265
265
  const updates = {};
266
- const maxIterIdx = args.indexOf('--max-iterations');
267
- if (maxIterIdx !== -1) updates.maxIterations = args[maxIterIdx + 1];
268
- if (args.includes('--auto-approve-plan')) updates.autoApprovePlan = true;
269
- if (args.includes('--no-auto-approve-plan')) updates.autoApprovePlan = false;
266
+ // Dot-notation key=value: coder-config loop config heartbeat.staleThresholdMinutes 30
267
+ if (args[2] && args[3] && !args[2].startsWith('--')) {
268
+ updates[args[2]] = args[3];
269
+ } else {
270
+ // Flag-based (backwards compatible)
271
+ const maxIterIdx = args.indexOf('--max-iterations');
272
+ if (maxIterIdx !== -1) updates.maxIterations = args[maxIterIdx + 1];
273
+ if (args.includes('--auto-approve-plan')) updates.autoApprovePlan = true;
274
+ if (args.includes('--no-auto-approve-plan')) updates.autoApprovePlan = false;
275
+ }
270
276
  if (Object.keys(updates).length > 0) {
271
277
  manager.loopConfig(updates);
272
278
  } else {
273
279
  manager.loopConfig();
274
280
  }
281
+ } else if (args[1] === 'heartbeat') {
282
+ const options = {
283
+ notify: args.includes('--notify'),
284
+ quiet: args.includes('--quiet'),
285
+ json: args.includes('--json')
286
+ };
287
+ const report = manager.loopHeartbeat(options);
288
+ if (options.quiet) {
289
+ process.exitCode = report.alerts.some(a => a.severity === 'critical' || a.severity === 'warning') ? 1 : 0;
290
+ }
275
291
  } else if (args[1] === 'inject') {
276
292
  const silent = args.includes('--silent') || args.includes('-s');
277
293
  manager.loopInject(silent);
@@ -456,6 +472,9 @@ ${chalk.dim('Configuration manager for AI coding tools (Claude Code, Gemini CLI,
456
472
  cmd('loop cancel <id>', 'Cancel loop'),
457
473
  cmd('loop status [id]', 'Show loop status'),
458
474
  cmd('loop config', 'Show/set loop config'),
475
+ cmd('loop heartbeat', 'Check loop health'),
476
+ cmd('loop heartbeat --notify', 'Check + send notifications'),
477
+ cmd('loop heartbeat --quiet', 'Silent unless alerts'),
459
478
  ]));
460
479
  console.log();
461
480
  }
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.6-beta';
5
+ const VERSION = '0.50.8-beta';
6
6
 
7
7
  // Tool-specific path configurations
8
8
  const TOOL_PATHS = {
package/lib/heartbeat.js CHANGED
@@ -277,6 +277,62 @@ function getExitCode(report) {
277
277
  return hasActionable ? 1 : 0;
278
278
  }
279
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
+
280
336
  module.exports = {
281
337
  heartbeat,
282
338
  getDefaultHeartbeatConfig,
@@ -287,5 +343,6 @@ module.exports = {
287
343
  loadLastHeartbeat,
288
344
  shouldNotify,
289
345
  buildMacosNotification,
290
- getExitCode
346
+ getExitCode,
347
+ formatReport
291
348
  };
package/lib/loops.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const { getDefaultHeartbeatConfig } = require('./heartbeat');
7
8
 
8
9
  /**
9
10
  * Get loops directory path
@@ -621,6 +622,32 @@ function archiveLoop(installDir, loopId) {
621
622
  }
622
623
  }
623
624
 
625
+ /**
626
+ * Set a nested value on an object using dot-notation path.
627
+ * Creates intermediate objects as needed.
628
+ */
629
+ function setNestedValue(obj, dotPath, value) {
630
+ const parts = dotPath.split('.');
631
+ let current = obj;
632
+ for (let i = 0; i < parts.length - 1; i++) {
633
+ if (current[parts[i]] === undefined || typeof current[parts[i]] !== 'object') {
634
+ current[parts[i]] = {};
635
+ }
636
+ current = current[parts[i]];
637
+ }
638
+ current[parts[parts.length - 1]] = value;
639
+ }
640
+
641
+ /**
642
+ * Auto-convert string values to appropriate types.
643
+ */
644
+ function coerceValue(value) {
645
+ if (value === 'true') return true;
646
+ if (value === 'false') return false;
647
+ if (typeof value === 'string' && /^\d+$/.test(value)) return parseInt(value, 10);
648
+ return value;
649
+ }
650
+
624
651
  /**
625
652
  * Get/set loop configuration
626
653
  */
@@ -634,11 +661,46 @@ function loopConfig(installDir, updates = null) {
634
661
  console.log(` Auto-approve Plan: ${data.config.autoApprovePlan}`);
635
662
  console.log(` Max Clarify Iterations: ${data.config.maxClarifyIterations}`);
636
663
  console.log(` Completion Promise: ${data.config.completionPromise || 'DONE'}`);
664
+ if (data.config.heartbeat) {
665
+ const hb = data.config.heartbeat;
666
+ const slack = hb.notifications && hb.notifications.slack;
667
+ const macos = hb.notifications && hb.notifications.macos;
668
+ console.log(' Heartbeat:');
669
+ console.log(` Stale Threshold: ${hb.staleThresholdMinutes !== undefined ? hb.staleThresholdMinutes + 'm' : '30m'}`);
670
+ console.log(` Iteration Limit: ${hb.iterationLimitPercent !== undefined ? hb.iterationLimitPercent + '%' : '80%'}`);
671
+ console.log(` Cooldown: ${hb.cooldownMinutes !== undefined ? hb.cooldownMinutes + 'm' : '15m'}`);
672
+ console.log(` macOS Notifications: ${macos && macos.enabled !== undefined ? (macos.enabled ? 'on' : 'off') : 'on'}`);
673
+ if (slack) {
674
+ const slackStatus = slack.enabled ? `on (${slack.channel || '#dev-loops'})` : 'off';
675
+ console.log(` Slack: ${slackStatus}`);
676
+ } else {
677
+ console.log(' Slack: off');
678
+ }
679
+ }
637
680
  console.log('');
638
681
  return data.config;
639
682
  }
640
683
 
641
- // Apply updates
684
+ // Check for dot-notation heartbeat keys
685
+ const dotKeys = Object.keys(updates).filter(k => k.includes('.'));
686
+ if (dotKeys.length > 0) {
687
+ // Initialize heartbeat config with defaults if not present
688
+ if (!data.config.heartbeat) {
689
+ data.config.heartbeat = getDefaultHeartbeatConfig();
690
+ }
691
+ for (const key of dotKeys) {
692
+ if (key.startsWith('heartbeat.')) {
693
+ const subPath = key.slice('heartbeat.'.length);
694
+ setNestedValue(data.config.heartbeat, subPath, coerceValue(updates[key]));
695
+ } else {
696
+ setNestedValue(data.config, key, coerceValue(updates[key]));
697
+ }
698
+ }
699
+ // Remove dot-notation keys so they don't fall through to flat handling
700
+ dotKeys.forEach(k => delete updates[k]);
701
+ }
702
+
703
+ // Apply flat updates
642
704
  if (updates.maxIterations !== undefined) {
643
705
  data.config.maxIterations = parseInt(updates.maxIterations, 10);
644
706
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coder-config",
3
- "version": "0.50.6-beta",
3
+ "version": "0.50.8-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",
@@ -598,7 +598,14 @@ function setupLoopHooks(projectPath) {
598
598
  return installLoopHooks(null, projectPath);
599
599
  }
600
600
 
601
+ function getHeartbeat(manager) {
602
+ if (!manager) return { error: 'Manager not available' };
603
+ const { heartbeat: runHeartbeat } = require('../../lib/heartbeat');
604
+ return runHeartbeat(manager.installDir);
605
+ }
606
+
601
607
  module.exports = {
608
+ getHeartbeat,
602
609
  getLoops,
603
610
  getActiveLoop,
604
611
  getLoop,
package/ui/server.cjs CHANGED
@@ -886,6 +886,10 @@ class ConfigUIServer {
886
886
  }
887
887
  break;
888
888
 
889
+ case '/api/loops/heartbeat':
890
+ if (req.method === 'GET') return this.json(res, routes.loops.getHeartbeat(this.manager));
891
+ break;
892
+
889
893
  case '/api/loops/tune-prompt':
890
894
  if (req.method === 'POST') {
891
895
  const result = await routes.loops.tunePrompt(