coder-config 0.50.4-beta → 0.50.6-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/lib/constants.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Constants and tool path configurations
3
3
  */
4
4
 
5
- const VERSION = '0.50.4-beta';
5
+ const VERSION = '0.50.6-beta';
6
6
 
7
7
  // Tool-specific path configurations
8
8
  const TOOL_PATHS = {
package/lib/heartbeat.js CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { loadLoops, loadLoopState } = require('./loops.js');
7
+ const { loadLoops, loadLoopState, getLoopsPath } = require('./loops.js');
8
8
 
9
9
  /**
10
10
  * Get default heartbeat configuration
@@ -21,6 +21,122 @@ function getDefaultHeartbeatConfig() {
21
21
  };
22
22
  }
23
23
 
24
+ /**
25
+ * Load heartbeat config from loops.json, merged with defaults
26
+ * @param {string} installDir - path to coder-config install dir
27
+ * @returns {object} merged heartbeat config
28
+ */
29
+ function loadHeartbeatConfig(installDir) {
30
+ const data = loadLoops(installDir);
31
+ return { ...getDefaultHeartbeatConfig(), ...data.config?.heartbeat };
32
+ }
33
+
34
+ /**
35
+ * Evaluate a single loop state and return any alerts
36
+ * @param {object} state - loop state object
37
+ * @param {object} globalConfig - heartbeat config (merged defaults)
38
+ * @param {Date} now - current date/time
39
+ * @returns {Array} array of alert objects for this loop
40
+ */
41
+ function evaluateLoop(state, globalConfig, now) {
42
+ const alerts = [];
43
+ const status = state.status || 'unknown';
44
+ const name = state.name || state.id;
45
+
46
+ // Check: failed
47
+ if (status === 'failed') {
48
+ alerts.push({
49
+ loopId: state.id,
50
+ name,
51
+ type: 'failed',
52
+ message: `Loop "${name}" has failed`,
53
+ severity: 'critical'
54
+ });
55
+ return alerts;
56
+ }
57
+
58
+ // Check: stale
59
+ const perLoopThreshold = state.heartbeat && state.heartbeat.staleThresholdMinutes != null
60
+ ? state.heartbeat.staleThresholdMinutes
61
+ : globalConfig.staleThresholdMinutes;
62
+
63
+ if (state.updatedAt) {
64
+ const updatedAt = new Date(state.updatedAt);
65
+ const ageMinutes = ((now || new Date()).getTime() - updatedAt.getTime()) / 60000;
66
+ if (ageMinutes >= perLoopThreshold) {
67
+ alerts.push({
68
+ loopId: state.id,
69
+ name,
70
+ type: 'stale',
71
+ message: `Loop "${name}" has not updated in ${Math.floor(ageMinutes)} minutes`,
72
+ severity: 'warning'
73
+ });
74
+ }
75
+ }
76
+
77
+ // Check: iteration_limit
78
+ const iterations = state.iterations || {};
79
+ const current = iterations.current != null ? iterations.current : 0;
80
+ const max = iterations.max != null ? iterations.max : 0;
81
+ if (max > 0) {
82
+ const pct = (current / max) * 100;
83
+ if (pct >= globalConfig.iterationLimitPercent) {
84
+ alerts.push({
85
+ loopId: state.id,
86
+ name,
87
+ type: 'iteration_limit',
88
+ message: `Loop "${name}" is at ${Math.round(pct)}% of iteration limit (${current}/${max})`,
89
+ severity: 'warning'
90
+ });
91
+ }
92
+ }
93
+
94
+ // Check: phase_gate (plan phase + running)
95
+ if (state.phase === 'plan' && status === 'running') {
96
+ alerts.push({
97
+ loopId: state.id,
98
+ name,
99
+ type: 'phase_gate',
100
+ message: `Loop "${name}" is waiting at plan phase gate`,
101
+ severity: 'info'
102
+ });
103
+ }
104
+
105
+ // Check: blocked (paused with pauseReason)
106
+ if (status === 'paused' && state.pauseReason) {
107
+ alerts.push({
108
+ loopId: state.id,
109
+ name,
110
+ type: 'blocked',
111
+ message: `Loop "${name}" is blocked: ${state.pauseReason}`,
112
+ severity: 'info'
113
+ });
114
+ }
115
+
116
+ return alerts;
117
+ }
118
+
119
+ /**
120
+ * Build a summary string from heartbeat results
121
+ * @param {number} activeLoops - count of active (non-terminal) loops
122
+ * @param {Array} alerts - array of alert objects
123
+ * @param {Array} healthy - array of healthy loop objects
124
+ * @returns {string} summary string
125
+ */
126
+ function buildSummary(activeLoops, alerts, healthy) {
127
+ if (alerts.length === 0) {
128
+ return `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, all healthy`;
129
+ }
130
+ const criticalCount = alerts.filter(a => a.severity === 'critical').length;
131
+ const warningCount = alerts.filter(a => a.severity === 'warning').length;
132
+ const infoCount = alerts.filter(a => a.severity === 'info').length;
133
+ const parts = [];
134
+ if (criticalCount > 0) parts.push(`${criticalCount} critical`);
135
+ if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
136
+ if (infoCount > 0) parts.push(`${infoCount} info`);
137
+ return `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, ${parts.join(', ')}`;
138
+ }
139
+
24
140
  /**
25
141
  * Evaluate heartbeat for all active loops
26
142
  * @param {string} installDir - path to coder-config install dir
@@ -36,6 +152,7 @@ function heartbeat(installDir, config) {
36
152
  const alerts = [];
37
153
  const healthy = [];
38
154
  const terminalStates = new Set(['completed', 'cancelled']);
155
+ const now = new Date();
39
156
 
40
157
  for (const entry of loops) {
41
158
  const state = loadLoopState(installDir, entry.id);
@@ -45,84 +162,16 @@ function heartbeat(installDir, config) {
45
162
  if (terminalStates.has(status)) continue;
46
163
 
47
164
  const name = state.name || entry.name || entry.id;
165
+ // Ensure state has id and name for evaluateLoop
166
+ const stateWithMeta = { ...state, id: entry.id, name };
48
167
 
49
- // Check: failed
50
- if (status === 'failed') {
51
- alerts.push({
52
- loopId: entry.id,
53
- name,
54
- type: 'failed',
55
- message: `Loop "${name}" has failed`,
56
- severity: 'critical'
57
- });
58
- continue;
59
- }
168
+ const loopAlerts = evaluateLoop(stateWithMeta, hbConfig, now);
60
169
 
61
- let hasAlert = false;
62
-
63
- // Check: stale
64
- const perLoopThreshold = state.heartbeat && state.heartbeat.staleThresholdMinutes != null
65
- ? state.heartbeat.staleThresholdMinutes
66
- : hbConfig.staleThresholdMinutes;
67
-
68
- if (state.updatedAt) {
69
- const updatedAt = new Date(state.updatedAt);
70
- const ageMinutes = (Date.now() - updatedAt.getTime()) / 60000;
71
- if (ageMinutes >= perLoopThreshold) {
72
- alerts.push({
73
- loopId: entry.id,
74
- name,
75
- type: 'stale',
76
- message: `Loop "${name}" has not updated in ${Math.floor(ageMinutes)} minutes`,
77
- severity: 'warning'
78
- });
79
- hasAlert = true;
80
- }
81
- }
82
-
83
- // Check: iteration_limit
84
- const iterations = state.iterations || {};
85
- const current = iterations.current != null ? iterations.current : 0;
86
- const max = iterations.max != null ? iterations.max : 0;
87
- if (max > 0) {
88
- const pct = (current / max) * 100;
89
- if (pct >= hbConfig.iterationLimitPercent) {
90
- alerts.push({
91
- loopId: entry.id,
92
- name,
93
- type: 'iteration_limit',
94
- message: `Loop "${name}" is at ${Math.round(pct)}% of iteration limit (${current}/${max})`,
95
- severity: 'warning'
96
- });
97
- hasAlert = true;
98
- }
99
- }
100
-
101
- // Check: phase_gate (plan phase + running)
102
- if (state.phase === 'plan' && status === 'running') {
103
- alerts.push({
104
- loopId: entry.id,
105
- name,
106
- type: 'phase_gate',
107
- message: `Loop "${name}" is waiting at plan phase gate`,
108
- severity: 'info'
109
- });
110
- hasAlert = true;
111
- }
112
-
113
- // Check: blocked (paused with pauseReason)
114
- if (status === 'paused' && state.pauseReason) {
115
- alerts.push({
116
- loopId: entry.id,
117
- name,
118
- type: 'blocked',
119
- message: `Loop "${name}" is blocked: ${state.pauseReason}`,
120
- severity: 'info'
121
- });
122
- hasAlert = true;
123
- }
124
-
125
- if (!hasAlert) {
170
+ if (loopAlerts.length > 0) {
171
+ alerts.push(...loopAlerts);
172
+ } else {
173
+ const iterations = state.iterations || {};
174
+ const current = iterations.current != null ? iterations.current : 0;
126
175
  healthy.push({
127
176
  loopId: entry.id,
128
177
  name,
@@ -139,23 +188,10 @@ function heartbeat(installDir, config) {
139
188
  return !terminalStates.has(status);
140
189
  }).length;
141
190
 
142
- const criticalCount = alerts.filter(a => a.severity === 'critical').length;
143
- const warningCount = alerts.filter(a => a.severity === 'warning').length;
144
-
145
- let summary;
146
- if (alerts.length === 0) {
147
- summary = `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, all healthy`;
148
- } else {
149
- const parts = [];
150
- if (criticalCount > 0) parts.push(`${criticalCount} critical`);
151
- if (warningCount > 0) parts.push(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`);
152
- const infoCount = alerts.filter(a => a.severity === 'info').length;
153
- if (infoCount > 0) parts.push(`${infoCount} info`);
154
- summary = `${activeLoops} active loop${activeLoops !== 1 ? 's' : ''}, ${parts.join(', ')}`;
155
- }
191
+ const summary = buildSummary(activeLoops, alerts, healthy);
156
192
 
157
193
  return {
158
- timestamp: new Date().toISOString(),
194
+ timestamp: now.toISOString(),
159
195
  activeLoops,
160
196
  alerts,
161
197
  healthy,
@@ -163,7 +199,93 @@ function heartbeat(installDir, config) {
163
199
  };
164
200
  }
165
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
+
166
280
  module.exports = {
281
+ heartbeat,
167
282
  getDefaultHeartbeatConfig,
168
- heartbeat
283
+ loadHeartbeatConfig,
284
+ evaluateLoop,
285
+ buildSummary,
286
+ saveLastHeartbeat,
287
+ loadLastHeartbeat,
288
+ shouldNotify,
289
+ buildMacosNotification,
290
+ getExitCode
169
291
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coder-config",
3
- "version": "0.50.4-beta",
3
+ "version": "0.50.6-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",