coder-config 0.50.3 → 0.50.5-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.3';
5
+ const VERSION = '0.50.5-beta';
6
6
 
7
7
  // Tool-specific path configurations
8
8
  const TOOL_PATHS = {
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Heartbeat module - monitors Ralph Loop health
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { loadLoops, loadLoopState, getLoopsPath } = require('./loops.js');
8
+
9
+ /**
10
+ * Get default heartbeat configuration
11
+ */
12
+ function getDefaultHeartbeatConfig() {
13
+ return {
14
+ staleThresholdMinutes: 30,
15
+ iterationLimitPercent: 80,
16
+ cooldownMinutes: 15,
17
+ notifications: {
18
+ macos: { enabled: true },
19
+ slack: { enabled: false }
20
+ }
21
+ };
22
+ }
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
+
140
+ /**
141
+ * Evaluate heartbeat for all active loops
142
+ * @param {string} installDir - path to coder-config install dir
143
+ * @param {object} [config] - optional heartbeat config overrides
144
+ * @returns {object} report with alerts and healthy loops
145
+ */
146
+ function heartbeat(installDir, config) {
147
+ const hbConfig = Object.assign({}, getDefaultHeartbeatConfig(), config || {});
148
+
149
+ const registry = loadLoops(installDir);
150
+ const loops = registry.loops || [];
151
+
152
+ const alerts = [];
153
+ const healthy = [];
154
+ const terminalStates = new Set(['completed', 'cancelled']);
155
+ const now = new Date();
156
+
157
+ for (const entry of loops) {
158
+ const state = loadLoopState(installDir, entry.id);
159
+ if (!state) continue;
160
+
161
+ const status = state.status || 'unknown';
162
+ if (terminalStates.has(status)) continue;
163
+
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 };
167
+
168
+ const loopAlerts = evaluateLoop(stateWithMeta, hbConfig, now);
169
+
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;
175
+ healthy.push({
176
+ loopId: entry.id,
177
+ name,
178
+ phase: state.phase || null,
179
+ iteration: current
180
+ });
181
+ }
182
+ }
183
+
184
+ const activeLoops = loops.filter(e => {
185
+ const state = loadLoopState(installDir, e.id);
186
+ if (!state) return false;
187
+ const status = state.status || 'unknown';
188
+ return !terminalStates.has(status);
189
+ }).length;
190
+
191
+ const summary = buildSummary(activeLoops, alerts, healthy);
192
+
193
+ return {
194
+ timestamp: now.toISOString(),
195
+ activeLoops,
196
+ alerts,
197
+ healthy,
198
+ summary
199
+ };
200
+ }
201
+
202
+ module.exports = {
203
+ heartbeat,
204
+ getDefaultHeartbeatConfig,
205
+ loadHeartbeatConfig,
206
+ evaluateLoop,
207
+ buildSummary
208
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coder-config",
3
- "version": "0.50.3",
3
+ "version": "0.50.5-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",