@covibes/zeroshot 5.2.1 → 5.3.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 (62) hide show
  1. package/CHANGELOG.md +174 -189
  2. package/README.md +199 -248
  3. package/cli/commands/providers.js +150 -0
  4. package/cli/index.js +214 -58
  5. package/cli/lib/first-run.js +40 -3
  6. package/cluster-templates/base-templates/debug-workflow.json +24 -78
  7. package/cluster-templates/base-templates/full-workflow.json +44 -145
  8. package/cluster-templates/base-templates/single-worker.json +23 -15
  9. package/cluster-templates/base-templates/worker-validator.json +47 -34
  10. package/cluster-templates/conductor-bootstrap.json +7 -5
  11. package/lib/docker-config.js +6 -1
  12. package/lib/provider-detection.js +59 -0
  13. package/lib/provider-names.js +56 -0
  14. package/lib/settings.js +191 -6
  15. package/lib/stream-json-parser.js +4 -238
  16. package/package.json +21 -5
  17. package/scripts/validate-templates.js +100 -0
  18. package/src/agent/agent-config.js +37 -13
  19. package/src/agent/agent-context-builder.js +64 -2
  20. package/src/agent/agent-hook-executor.js +82 -9
  21. package/src/agent/agent-lifecycle.js +53 -14
  22. package/src/agent/agent-task-executor.js +196 -194
  23. package/src/agent/output-extraction.js +200 -0
  24. package/src/agent/output-reformatter.js +175 -0
  25. package/src/agent/schema-utils.js +111 -0
  26. package/src/agent-wrapper.js +102 -30
  27. package/src/agents/git-pusher-agent.json +1 -1
  28. package/src/claude-task-runner.js +80 -30
  29. package/src/config-router.js +13 -13
  30. package/src/config-validator.js +231 -10
  31. package/src/github.js +36 -0
  32. package/src/isolation-manager.js +243 -154
  33. package/src/ledger.js +28 -6
  34. package/src/orchestrator.js +391 -96
  35. package/src/preflight.js +85 -82
  36. package/src/providers/anthropic/cli-builder.js +45 -0
  37. package/src/providers/anthropic/index.js +134 -0
  38. package/src/providers/anthropic/models.js +23 -0
  39. package/src/providers/anthropic/output-parser.js +159 -0
  40. package/src/providers/base-provider.js +181 -0
  41. package/src/providers/capabilities.js +51 -0
  42. package/src/providers/google/cli-builder.js +55 -0
  43. package/src/providers/google/index.js +116 -0
  44. package/src/providers/google/models.js +24 -0
  45. package/src/providers/google/output-parser.js +92 -0
  46. package/src/providers/index.js +75 -0
  47. package/src/providers/openai/cli-builder.js +122 -0
  48. package/src/providers/openai/index.js +135 -0
  49. package/src/providers/openai/models.js +21 -0
  50. package/src/providers/openai/output-parser.js +129 -0
  51. package/src/sub-cluster-wrapper.js +18 -3
  52. package/src/task-runner.js +8 -6
  53. package/src/tui/layout.js +20 -3
  54. package/task-lib/attachable-watcher.js +80 -78
  55. package/task-lib/claude-recovery.js +119 -0
  56. package/task-lib/commands/list.js +1 -1
  57. package/task-lib/commands/resume.js +3 -2
  58. package/task-lib/commands/run.js +12 -3
  59. package/task-lib/runner.js +59 -38
  60. package/task-lib/scheduler.js +2 -2
  61. package/task-lib/store.js +43 -30
  62. package/task-lib/watcher.js +81 -62
package/lib/settings.js CHANGED
@@ -7,6 +7,11 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const { validateMountConfig, validateEnvPassthrough } = require('./docker-config');
10
+ const {
11
+ VALID_PROVIDERS,
12
+ normalizeProviderName,
13
+ normalizeProviderSettings,
14
+ } = require('./provider-names');
10
15
 
11
16
  /**
12
17
  * Get settings file path (dynamically reads env var for testing)
@@ -14,7 +19,9 @@ const { validateMountConfig, validateEnvPassthrough } = require('./docker-config
14
19
  * @returns {string}
15
20
  */
16
21
  function getSettingsFile() {
17
- return process.env.ZEROSHOT_SETTINGS_FILE || path.join(os.homedir(), '.zeroshot', 'settings.json');
22
+ return (
23
+ process.env.ZEROSHOT_SETTINGS_FILE || path.join(os.homedir(), '.zeroshot', 'settings.json')
24
+ );
18
25
  }
19
26
 
20
27
  /**
@@ -28,15 +35,17 @@ const MODEL_HIERARCHY = {
28
35
  };
29
36
 
30
37
  const VALID_MODELS = Object.keys(MODEL_HIERARCHY);
38
+ const LEVEL_RANKS = { level1: 1, level2: 2, level3: 3 };
31
39
 
32
40
  /**
33
- * Validate a requested model against the maxModel ceiling
41
+ * Validate a requested model against the maxModel ceiling and minModel floor
34
42
  * @param {string} requestedModel - Model the agent wants to use
35
43
  * @param {string} maxModel - Maximum allowed model (cost ceiling)
44
+ * @param {string|null} minModel - Minimum required model (cost floor)
36
45
  * @returns {string} The validated model
37
- * @throws {Error} If requested model exceeds ceiling
46
+ * @throws {Error} If requested model exceeds ceiling or falls below floor
38
47
  */
39
- function validateModelAgainstMax(requestedModel, maxModel) {
48
+ function validateModelAgainstMax(requestedModel, maxModel, minModel = null) {
40
49
  if (!requestedModel) return maxModel; // Default to ceiling if unspecified
41
50
 
42
51
  if (!VALID_MODELS.includes(requestedModel)) {
@@ -52,12 +61,50 @@ function validateModelAgainstMax(requestedModel, maxModel) {
52
61
  `Either lower agent's model or raise maxModel.`
53
62
  );
54
63
  }
64
+
65
+ if (minModel) {
66
+ if (!VALID_MODELS.includes(minModel)) {
67
+ throw new Error(`Invalid minModel "${minModel}". Valid: ${VALID_MODELS.join(', ')}`);
68
+ }
69
+ if (MODEL_HIERARCHY[minModel] > MODEL_HIERARCHY[maxModel]) {
70
+ throw new Error(`minModel "${minModel}" cannot be higher than maxModel "${maxModel}".`);
71
+ }
72
+ if (MODEL_HIERARCHY[requestedModel] < MODEL_HIERARCHY[minModel]) {
73
+ throw new Error(
74
+ `Agent requests "${requestedModel}" but minModel is "${minModel}". ` +
75
+ `Either raise agent's model or lower minModel.`
76
+ );
77
+ }
78
+ }
79
+
55
80
  return requestedModel;
56
81
  }
57
82
 
58
83
  // Default settings
59
84
  const DEFAULT_SETTINGS = {
60
85
  maxModel: 'opus', // Cost ceiling - agents cannot use models above this
86
+ minModel: null, // Cost floor - agents cannot use models below this (null = no minimum)
87
+ defaultProvider: 'claude',
88
+ providerSettings: {
89
+ claude: {
90
+ maxLevel: 'level3',
91
+ minLevel: 'level1',
92
+ defaultLevel: 'level2',
93
+ levelOverrides: {},
94
+ },
95
+ codex: {
96
+ maxLevel: 'level3',
97
+ minLevel: 'level1',
98
+ defaultLevel: 'level2',
99
+ levelOverrides: {},
100
+ },
101
+ gemini: {
102
+ maxLevel: 'level3',
103
+ minLevel: 'level1',
104
+ defaultLevel: 'level2',
105
+ levelOverrides: {},
106
+ },
107
+ },
61
108
  defaultConfig: 'conductor-bootstrap',
62
109
  defaultDocker: false,
63
110
  strictSchema: true, // true = reliable json output (default), false = live streaming (may crash - see bold-meadow-11)
@@ -70,7 +117,7 @@ const DEFAULT_SETTINGS = {
70
117
  // Example: 'ccr code' for claude-code-router integration
71
118
  claudeCommand: 'claude',
72
119
  // Docker isolation mounts - preset names or {host, container, readonly?} objects
73
- // Valid presets: gh, git, ssh, aws, azure, kube, terraform, gcloud
120
+ // Valid presets: gh, git, ssh, aws, azure, kube, terraform, gcloud, claude, codex, gemini
74
121
  dockerMounts: ['gh', 'git', 'ssh'],
75
122
  // Extra env vars to pass to Docker container (in addition to preset-implied ones)
76
123
  // Supports: VAR (if set), VAR_* (pattern), VAR=value (forced), VAR= (empty)
@@ -80,6 +127,75 @@ const DEFAULT_SETTINGS = {
80
127
  dockerContainerHome: '/home/node',
81
128
  };
82
129
 
130
+ function mapLegacyModelToLevel(model) {
131
+ switch (model) {
132
+ case 'haiku':
133
+ return 'level1';
134
+ case 'sonnet':
135
+ return 'level2';
136
+ case 'opus':
137
+ return 'level3';
138
+ default:
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function mergeProviderSettings(current, overrides) {
144
+ const merged = { ...current };
145
+ for (const provider of VALID_PROVIDERS) {
146
+ merged[provider] = {
147
+ ...current[provider],
148
+ ...(overrides?.[provider] || {}),
149
+ };
150
+ if (!merged[provider].levelOverrides) {
151
+ merged[provider].levelOverrides = {};
152
+ }
153
+ }
154
+ return merged;
155
+ }
156
+
157
+ function applyLegacyModelBounds(settings) {
158
+ if (!settings.providerSettings) return settings;
159
+ const claude = settings.providerSettings.claude || {};
160
+ const legacyMaxLevel = mapLegacyModelToLevel(settings.maxModel);
161
+ const legacyMinLevel = mapLegacyModelToLevel(settings.minModel);
162
+
163
+ if (legacyMaxLevel) {
164
+ claude.maxLevel = legacyMaxLevel;
165
+ }
166
+
167
+ if (legacyMinLevel) {
168
+ claude.minLevel = legacyMinLevel;
169
+ }
170
+
171
+ const minRank = LEVEL_RANKS[claude.minLevel] || LEVEL_RANKS.level1;
172
+ const maxRank = LEVEL_RANKS[claude.maxLevel] || LEVEL_RANKS.level3;
173
+ const defaultRank = LEVEL_RANKS[claude.defaultLevel] || LEVEL_RANKS.level2;
174
+
175
+ if (minRank > maxRank) {
176
+ claude.minLevel = 'level1';
177
+ claude.maxLevel = 'level3';
178
+ } else if (defaultRank < minRank) {
179
+ claude.defaultLevel = claude.minLevel;
180
+ } else if (defaultRank > maxRank) {
181
+ claude.defaultLevel = claude.maxLevel;
182
+ }
183
+
184
+ settings.providerSettings.claude = claude;
185
+ return settings;
186
+ }
187
+
188
+ function normalizeLoadedSettings(parsed) {
189
+ const normalized = { ...parsed };
190
+ if (parsed.defaultProvider) {
191
+ normalized.defaultProvider = normalizeProviderName(parsed.defaultProvider);
192
+ }
193
+ if (parsed.providerSettings) {
194
+ normalized.providerSettings = normalizeProviderSettings(parsed.providerSettings);
195
+ }
196
+ return normalized;
197
+ }
198
+
83
199
  /**
84
200
  * Load settings from disk, merging with defaults
85
201
  */
@@ -90,7 +206,15 @@ function loadSettings() {
90
206
  }
91
207
  try {
92
208
  const data = fs.readFileSync(settingsFile, 'utf8');
93
- return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
209
+ const parsed = normalizeLoadedSettings(JSON.parse(data));
210
+ const merged = { ...DEFAULT_SETTINGS, ...parsed };
211
+ merged.defaultProvider =
212
+ normalizeProviderName(merged.defaultProvider) || DEFAULT_SETTINGS.defaultProvider;
213
+ merged.providerSettings = mergeProviderSettings(
214
+ DEFAULT_SETTINGS.providerSettings,
215
+ parsed.providerSettings
216
+ );
217
+ return applyLegacyModelBounds(merged);
94
218
  } catch {
95
219
  console.error('Warning: Could not load settings, using defaults');
96
220
  return { ...DEFAULT_SETTINGS };
@@ -122,6 +246,10 @@ function validateSetting(key, value) {
122
246
  return `Invalid model: ${value}. Valid models: ${VALID_MODELS.join(', ')}`;
123
247
  }
124
248
 
249
+ if (key === 'minModel' && value !== null && !VALID_MODELS.includes(value)) {
250
+ return `Invalid model: ${value}. Valid models: ${VALID_MODELS.join(', ')}, null`;
251
+ }
252
+
125
253
  if (key === 'logLevel' && !['quiet', 'normal', 'verbose'].includes(value)) {
126
254
  return `Invalid log level: ${value}. Valid levels: quiet, normal, verbose`;
127
255
  }
@@ -135,6 +263,43 @@ function validateSetting(key, value) {
135
263
  }
136
264
  }
137
265
 
266
+ if (key === 'defaultProvider') {
267
+ const normalized = normalizeProviderName(value);
268
+ if (!VALID_PROVIDERS.includes(normalized)) {
269
+ return `Invalid provider: ${value}. Valid providers: ${VALID_PROVIDERS.join(', ')}`;
270
+ }
271
+ }
272
+
273
+ if (key === 'providerSettings') {
274
+ const normalizedSettings = normalizeProviderSettings(value);
275
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
276
+ return 'providerSettings must be an object';
277
+ }
278
+ for (const [provider, settings] of Object.entries(normalizedSettings || {})) {
279
+ if (!VALID_PROVIDERS.includes(provider)) {
280
+ return `Unknown provider in providerSettings: ${provider}`;
281
+ }
282
+ if (typeof settings !== 'object' || settings === null) {
283
+ return `providerSettings.${provider} must be an object`;
284
+ }
285
+ if (settings.maxLevel && !LEVEL_RANKS[settings.maxLevel]) {
286
+ return `Invalid maxLevel for ${provider}: ${settings.maxLevel}`;
287
+ }
288
+ if (settings.minLevel && !LEVEL_RANKS[settings.minLevel]) {
289
+ return `Invalid minLevel for ${provider}: ${settings.minLevel}`;
290
+ }
291
+ if (settings.defaultLevel && !LEVEL_RANKS[settings.defaultLevel]) {
292
+ return `Invalid defaultLevel for ${provider}: ${settings.defaultLevel}`;
293
+ }
294
+ if (
295
+ settings.levelOverrides &&
296
+ (typeof settings.levelOverrides !== 'object' || Array.isArray(settings.levelOverrides))
297
+ ) {
298
+ return `levelOverrides for ${provider} must be an object`;
299
+ }
300
+ }
301
+ }
302
+
138
303
  if (key === 'dockerMounts') {
139
304
  return validateMountConfig(value);
140
305
  }
@@ -152,6 +317,11 @@ function validateSetting(key, value) {
152
317
  function coerceValue(key, value) {
153
318
  const defaultValue = DEFAULT_SETTINGS[key];
154
319
 
320
+ // Handle null values for minModel
321
+ if (key === 'minModel' && (value === 'null' || value === null)) {
322
+ return null;
323
+ }
324
+
155
325
  if (typeof defaultValue === 'boolean') {
156
326
  return value === 'true' || value === '1' || value === 'yes' || value === true;
157
327
  }
@@ -183,6 +353,21 @@ function coerceValue(key, value) {
183
353
  return value;
184
354
  }
185
355
 
356
+ if (key === 'providerSettings') {
357
+ if (typeof value === 'string') {
358
+ try {
359
+ return normalizeProviderSettings(JSON.parse(value));
360
+ } catch {
361
+ throw new Error(`Invalid JSON for providerSettings: ${value}`);
362
+ }
363
+ }
364
+ return normalizeProviderSettings(value);
365
+ }
366
+
367
+ if (key === 'defaultProvider') {
368
+ return normalizeProviderName(value);
369
+ }
370
+
186
371
  return value;
187
372
  }
188
373
 
@@ -1,244 +1,10 @@
1
1
  /**
2
- * Stream JSON Parser for Claude Code output
3
- *
4
- * Parses NDJSON (newline-delimited JSON) streaming output from Claude Code.
5
- * Extracts: text output, tool calls, tool results, thinking, errors.
6
- *
7
- * Event types from Claude Code stream-json format:
8
- * - system: Session initialization
9
- * - stream_event: Real-time streaming (content_block_start, content_block_delta, etc.)
10
- * - assistant: Complete assistant message with content array
11
- * - user: Tool results
12
- * - result: Final task result
2
+ * Compatibility wrapper for Claude stream-json parsing.
3
+ * Prefer provider-specific parsers in src/providers.
13
4
  */
14
-
15
- /**
16
- * Parse a single JSON line and extract displayable content
17
- * @param {string} line - Single line of NDJSON
18
- * @returns {Object|null} Parsed event with type and content, or null if not displayable
19
- */
20
- function parseStreamLine(line) {
21
- const trimmed = line.trim();
22
- if (!trimmed || !trimmed.startsWith('{') || !trimmed.endsWith('}')) {
23
- return null;
24
- }
25
-
26
- let event;
27
- try {
28
- event = JSON.parse(trimmed);
29
- } catch {
30
- return null;
31
- }
32
-
33
- // stream_event - real-time streaming updates
34
- if (event.type === 'stream_event' && event.event) {
35
- return parseStreamEvent(event.event);
36
- }
37
-
38
- // assistant - complete message with content blocks
39
- if (event.type === 'assistant' && event.message?.content) {
40
- return parseAssistantMessage(event.message);
41
- }
42
-
43
- // user - tool result
44
- if (event.type === 'user' && event.message?.content) {
45
- return parseUserMessage(event.message);
46
- }
47
-
48
- // result - final task result (includes token usage and cost)
49
- if (event.type === 'result') {
50
- const usage = event.usage || {};
51
- return {
52
- type: 'result',
53
- success: event.subtype === 'success',
54
- result: event.result,
55
- error: event.is_error ? event.result : null,
56
- cost: event.total_cost_usd,
57
- duration: event.duration_ms,
58
- // Token usage from Claude API
59
- inputTokens: usage.input_tokens || 0,
60
- outputTokens: usage.output_tokens || 0,
61
- cacheReadInputTokens: usage.cache_read_input_tokens || 0,
62
- cacheCreationInputTokens: usage.cache_creation_input_tokens || 0,
63
- // Per-model breakdown (for multi-model tasks)
64
- modelUsage: event.modelUsage || null,
65
- };
66
- }
67
-
68
- // system - session init (skip, not user-facing)
69
- if (event.type === 'system') {
70
- return null;
71
- }
72
-
73
- return null;
74
- }
75
-
76
- /**
77
- * Parse stream_event inner event
78
- */
79
- function parseStreamEvent(inner) {
80
- // content_block_start - tool use or text block starting
81
- if (inner.type === 'content_block_start' && inner.content_block) {
82
- const block = inner.content_block;
83
-
84
- if (block.type === 'tool_use') {
85
- return {
86
- type: 'tool_start',
87
- toolName: block.name,
88
- toolId: block.id,
89
- };
90
- }
91
-
92
- if (block.type === 'thinking') {
93
- return {
94
- type: 'thinking_start',
95
- };
96
- }
97
-
98
- // text block start - usually empty, skip
99
- return null;
100
- }
101
-
102
- // content_block_delta - incremental content
103
- if (inner.type === 'content_block_delta' && inner.delta) {
104
- const delta = inner.delta;
105
-
106
- if (delta.type === 'text_delta' && delta.text) {
107
- return {
108
- type: 'text',
109
- text: delta.text,
110
- };
111
- }
112
-
113
- if (delta.type === 'thinking_delta' && delta.thinking) {
114
- return {
115
- type: 'thinking',
116
- text: delta.thinking,
117
- };
118
- }
119
-
120
- if (delta.type === 'input_json_delta' && delta.partial_json) {
121
- return {
122
- type: 'tool_input',
123
- json: delta.partial_json,
124
- };
125
- }
126
- }
127
-
128
- // content_block_stop - block ended
129
- if (inner.type === 'content_block_stop') {
130
- return {
131
- type: 'block_end',
132
- index: inner.index,
133
- };
134
- }
135
-
136
- // message_start - skip text extraction here since it will come via text_delta events
137
- // (extracting here causes duplicate text output)
138
-
139
- return null;
140
- }
141
-
142
- /**
143
- * Parse assistant message content blocks
144
- * NOTE: Skip text blocks here since they were already streamed via text_delta events.
145
- * Extracting text here would cause duplicate output.
146
- */
147
- function parseAssistantMessage(message) {
148
- const results = [];
149
-
150
- for (const block of message.content) {
151
- // Skip text blocks - already streamed via text_delta events
152
- // Extracting here causes duplicate output
153
-
154
- if (block.type === 'tool_use') {
155
- results.push({
156
- type: 'tool_call',
157
- toolName: block.name,
158
- toolId: block.id,
159
- input: block.input,
160
- });
161
- }
162
-
163
- if (block.type === 'thinking' && block.thinking) {
164
- // Skip thinking blocks too - already streamed via thinking_delta
165
- // But keep for non-streaming contexts (direct API responses)
166
- // Only emit if we haven't seen streaming deltas (detected by having results already)
167
- }
168
- }
169
-
170
- if (results.length === 1) {
171
- return results[0];
172
- }
173
-
174
- if (results.length > 1) {
175
- return {
176
- type: 'multi',
177
- events: results,
178
- };
179
- }
180
-
181
- return null;
182
- }
183
-
184
- /**
185
- * Parse user message (tool results)
186
- */
187
- function parseUserMessage(message) {
188
- const results = [];
189
-
190
- for (const block of message.content) {
191
- if (block.type === 'tool_result') {
192
- results.push({
193
- type: 'tool_result',
194
- toolId: block.tool_use_id,
195
- content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
196
- isError: block.is_error || false,
197
- });
198
- }
199
- }
200
-
201
- if (results.length === 1) {
202
- return results[0];
203
- }
204
-
205
- if (results.length > 1) {
206
- return {
207
- type: 'multi',
208
- events: results,
209
- };
210
- }
211
-
212
- return null;
213
- }
214
-
215
- /**
216
- * Parse multiple lines of NDJSON
217
- * @param {string} chunk - Chunk of text potentially containing multiple JSON lines
218
- * @returns {Array} Array of parsed events
219
- */
220
- function parseChunk(chunk) {
221
- const events = [];
222
- const lines = chunk.split('\n');
223
-
224
- for (const line of lines) {
225
- const event = parseStreamLine(line);
226
- if (event) {
227
- if (event.type === 'multi') {
228
- events.push(...event.events);
229
- } else {
230
- events.push(event);
231
- }
232
- }
233
- }
234
-
235
- return events;
236
- }
5
+ const { parseEvent, parseChunk } = require('../src/providers/anthropic/output-parser');
237
6
 
238
7
  module.exports = {
239
- parseStreamLine,
8
+ parseEvent,
240
9
  parseChunk,
241
- parseStreamEvent,
242
- parseAssistantMessage,
243
- parseUserMessage,
244
10
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@covibes/zeroshot",
3
- "version": "5.2.1",
4
- "description": "Multi-agent orchestration engine for Claude - cluster coordinator and CLI",
3
+ "version": "5.3.0",
4
+ "description": "Multi-agent orchestration engine for Claude, Codex, and Gemini",
5
5
  "main": "src/orchestrator.js",
6
6
  "bin": {
7
7
  "zeroshot": "./cli/index.js"
@@ -11,14 +11,18 @@
11
11
  "test": "tests"
12
12
  },
13
13
  "scripts": {
14
- "test": "mocha 'tests/**/*.test.js'",
15
- "test:coverage": "c8 npm test",
16
- "test:coverage:report": "c8 --reporter=html npm test && echo 'Coverage report generated at coverage/index.html'",
14
+ "test": "mocha 'tests/unit/**/*.test.js' 'tests/*.test.js' --parallel",
15
+ "test:unit": "mocha 'tests/unit/**/*.test.js' 'tests/*.test.js' --parallel",
16
+ "test:slow": "mocha 'tests/integration/**/*.test.js' --timeout 180000",
17
+ "test:all": "npm run test && npm run test:slow",
18
+ "test:coverage": "c8 npm run test:unit",
19
+ "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'",
17
20
  "postinstall": "node scripts/fix-node-pty-permissions.js",
18
21
  "start": "node cli/index.js",
19
22
  "typecheck": "tsc --noEmit",
20
23
  "lint": "eslint .",
21
24
  "lint:fix": "eslint . --fix",
25
+ "validate:templates": "node scripts/validate-templates.js",
22
26
  "format": "prettier --write .",
23
27
  "format:check": "prettier --check .",
24
28
  "deadcode": "ts-prune --skip node_modules",
@@ -55,6 +59,8 @@
55
59
  "multi-agent",
56
60
  "orchestration",
57
61
  "claude",
62
+ "codex",
63
+ "gemini",
58
64
  "automation"
59
65
  ],
60
66
  "author": "Covibes",
@@ -115,7 +121,9 @@
115
121
  "eslint-plugin-unused-imports": "^4.3.0",
116
122
  "husky": "^9.1.7",
117
123
  "jscpd": "^3.5.10",
124
+ "lint-staged": "^16.2.7",
118
125
  "mocha": "^11.7.5",
126
+ "prettier": "^3.7.4",
119
127
  "semantic-release": "^25.0.2",
120
128
  "sinon": "^21.0.0",
121
129
  "ts-prune": "^0.10.3",
@@ -124,5 +132,13 @@
124
132
  },
125
133
  "overrides": {
126
134
  "xml2js": "^0.5.0"
135
+ },
136
+ "lint-staged": {
137
+ "*.{js,mjs,cjs}": [
138
+ "eslint --fix"
139
+ ],
140
+ "*.{js,mjs,cjs,json,md}": [
141
+ "prettier --write"
142
+ ]
127
143
  }
128
144
  }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate all cluster templates for config errors
4
+ * Run in CI to prevent broken templates from being merged
5
+ *
6
+ * Usage: node scripts/validate-templates.js
7
+ * Exit codes: 0 = all valid, 1 = validation errors found
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { validateConfig } = require('../src/config-validator');
13
+
14
+ const TEMPLATES_DIR = path.join(__dirname, '../cluster-templates');
15
+
16
+ function findJsonFiles(dir) {
17
+ const files = [];
18
+ if (!fs.existsSync(dir)) return files;
19
+
20
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
21
+ const fullPath = path.join(dir, entry.name);
22
+ if (entry.isDirectory()) {
23
+ files.push(...findJsonFiles(fullPath));
24
+ } else if (entry.name.endsWith('.json')) {
25
+ files.push(fullPath);
26
+ }
27
+ }
28
+ return files;
29
+ }
30
+
31
+ function validateTemplate(filePath) {
32
+ const relativePath = path.relative(process.cwd(), filePath);
33
+
34
+ try {
35
+ const content = fs.readFileSync(filePath, 'utf-8');
36
+ const config = JSON.parse(content);
37
+
38
+ // Skip non-cluster configs (like package.json)
39
+ if (!config.agents && !config.name) {
40
+ return { valid: true, skipped: true };
41
+ }
42
+
43
+ const result = validateConfig(config);
44
+
45
+ if (!result.valid) {
46
+ console.error(`\n❌ ${relativePath}`);
47
+ for (const error of result.errors) {
48
+ console.error(` ERROR: ${error}`);
49
+ }
50
+ } else if (result.warnings.length > 0) {
51
+ console.warn(`\n⚠️ ${relativePath}`);
52
+ for (const warning of result.warnings) {
53
+ console.warn(` WARN: ${warning}`);
54
+ }
55
+ } else {
56
+ console.log(`✓ ${relativePath}`);
57
+ }
58
+
59
+ return result;
60
+ } catch (err) {
61
+ console.error(`\n❌ ${relativePath}`);
62
+ console.error(` PARSE ERROR: ${err.message}`);
63
+ return { valid: false, errors: [err.message], warnings: [] };
64
+ }
65
+ }
66
+
67
+ function main() {
68
+ console.log('Validating cluster templates...\n');
69
+
70
+ const templateFiles = [...findJsonFiles(TEMPLATES_DIR)];
71
+
72
+ let hasErrors = false;
73
+ let validated = 0;
74
+ let skipped = 0;
75
+
76
+ for (const file of templateFiles) {
77
+ const result = validateTemplate(file);
78
+ if (result.skipped) {
79
+ skipped++;
80
+ } else {
81
+ validated++;
82
+ if (!result.valid) {
83
+ hasErrors = true;
84
+ }
85
+ }
86
+ }
87
+
88
+ console.log(`\n${'='.repeat(60)}`);
89
+ console.log(`Validated: ${validated} templates, Skipped: ${skipped} files`);
90
+
91
+ if (hasErrors) {
92
+ console.error('\n❌ VALIDATION FAILED - Fix errors above before merging\n');
93
+ process.exit(1);
94
+ } else {
95
+ console.log('\n✓ All templates valid\n');
96
+ process.exit(0);
97
+ }
98
+ }
99
+
100
+ main();