@wundam/orchex 1.0.0-rc.2 → 1.0.0-rc.21

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 (98) hide show
  1. package/README.md +59 -18
  2. package/dist/cloud-executor.d.ts +71 -0
  3. package/dist/cloud-executor.js +335 -0
  4. package/dist/cloud-sync.d.ts +8 -0
  5. package/dist/cloud-sync.js +52 -0
  6. package/dist/config.d.ts +30 -4
  7. package/dist/config.js +61 -2
  8. package/dist/context-builder.d.ts +2 -0
  9. package/dist/context-builder.js +11 -3
  10. package/dist/cost.js +1 -1
  11. package/dist/entitlements/jwt.d.ts +7 -0
  12. package/dist/entitlements/jwt.js +78 -0
  13. package/dist/entitlements/resolve.d.ts +17 -0
  14. package/dist/entitlements/resolve.js +49 -0
  15. package/dist/entitlements/types.d.ts +21 -0
  16. package/dist/entitlements/types.js +4 -0
  17. package/dist/executors/base.d.ts +1 -1
  18. package/dist/executors/bedrock-executor.d.ts +39 -0
  19. package/dist/executors/bedrock-executor.js +197 -0
  20. package/dist/executors/index.d.ts +1 -0
  21. package/dist/executors/index.js +24 -1
  22. package/dist/index.js +468 -23
  23. package/dist/intelligence/index.d.ts +44 -0
  24. package/dist/intelligence/index.js +160 -0
  25. package/dist/key-cache.d.ts +31 -0
  26. package/dist/key-cache.js +84 -0
  27. package/dist/login-helpers.d.ts +25 -0
  28. package/dist/login-helpers.js +54 -0
  29. package/dist/manifest.js +18 -1
  30. package/dist/mcp-instructions.d.ts +1 -0
  31. package/dist/mcp-instructions.js +84 -0
  32. package/dist/mcp-resources.d.ts +8 -0
  33. package/dist/mcp-resources.js +420 -0
  34. package/dist/model-cache.d.ts +18 -0
  35. package/dist/model-cache.js +62 -0
  36. package/dist/model-validator.d.ts +20 -0
  37. package/dist/model-validator.js +125 -0
  38. package/dist/orchestrator.d.ts +14 -0
  39. package/dist/orchestrator.js +191 -32
  40. package/dist/setup/ide-registry.d.ts +13 -0
  41. package/dist/setup/ide-registry.js +51 -0
  42. package/dist/setup/index.d.ts +1 -0
  43. package/dist/setup/index.js +111 -0
  44. package/dist/tier-gating.js +0 -16
  45. package/dist/tiers.d.ts +35 -5
  46. package/dist/tiers.js +39 -3
  47. package/dist/tools.d.ts +6 -1
  48. package/dist/tools.js +852 -95
  49. package/dist/types.d.ts +71 -60
  50. package/dist/types.js +3 -0
  51. package/dist/waves.d.ts +1 -1
  52. package/dist/waves.js +29 -2
  53. package/package.json +41 -5
  54. package/src/entitlements/public-key.pem +9 -0
  55. package/dist/intelligence/anti-pattern-detector.d.ts +0 -117
  56. package/dist/intelligence/anti-pattern-detector.js +0 -327
  57. package/dist/intelligence/budget-enforcer.d.ts +0 -119
  58. package/dist/intelligence/budget-enforcer.js +0 -226
  59. package/dist/intelligence/context-optimizer.d.ts +0 -111
  60. package/dist/intelligence/context-optimizer.js +0 -282
  61. package/dist/intelligence/cost-tracker.d.ts +0 -114
  62. package/dist/intelligence/cost-tracker.js +0 -183
  63. package/dist/intelligence/deliverable-extractor.d.ts +0 -134
  64. package/dist/intelligence/deliverable-extractor.js +0 -909
  65. package/dist/intelligence/dependency-inferrer.d.ts +0 -87
  66. package/dist/intelligence/dependency-inferrer.js +0 -403
  67. package/dist/intelligence/diagnostics.d.ts +0 -33
  68. package/dist/intelligence/diagnostics.js +0 -64
  69. package/dist/intelligence/error-analyzer.d.ts +0 -7
  70. package/dist/intelligence/error-analyzer.js +0 -76
  71. package/dist/intelligence/file-chunker.d.ts +0 -15
  72. package/dist/intelligence/file-chunker.js +0 -64
  73. package/dist/intelligence/fix-stream-manager.d.ts +0 -59
  74. package/dist/intelligence/fix-stream-manager.js +0 -212
  75. package/dist/intelligence/heuristics.d.ts +0 -23
  76. package/dist/intelligence/heuristics.js +0 -124
  77. package/dist/intelligence/learning-engine.d.ts +0 -157
  78. package/dist/intelligence/learning-engine.js +0 -433
  79. package/dist/intelligence/learning-feedback.d.ts +0 -96
  80. package/dist/intelligence/learning-feedback.js +0 -202
  81. package/dist/intelligence/pattern-analyzer.d.ts +0 -35
  82. package/dist/intelligence/pattern-analyzer.js +0 -189
  83. package/dist/intelligence/plan-parser.d.ts +0 -124
  84. package/dist/intelligence/plan-parser.js +0 -498
  85. package/dist/intelligence/planner.d.ts +0 -29
  86. package/dist/intelligence/planner.js +0 -86
  87. package/dist/intelligence/self-healer.d.ts +0 -16
  88. package/dist/intelligence/self-healer.js +0 -84
  89. package/dist/intelligence/slicing-metrics.d.ts +0 -62
  90. package/dist/intelligence/slicing-metrics.js +0 -202
  91. package/dist/intelligence/slicing-templates.d.ts +0 -81
  92. package/dist/intelligence/slicing-templates.js +0 -420
  93. package/dist/intelligence/split-suggester.d.ts +0 -69
  94. package/dist/intelligence/split-suggester.js +0 -176
  95. package/dist/intelligence/stream-generator.d.ts +0 -90
  96. package/dist/intelligence/stream-generator.js +0 -452
  97. package/dist/telemetry/telemetry-types.d.ts +0 -85
  98. package/dist/telemetry/telemetry-types.js +0 -1
@@ -0,0 +1,125 @@
1
+ import { readModelCache, fetchAndCacheModels } from './model-cache.js';
2
+ import { DEFAULT_MODELS, getProviderApiKey } from './config.js';
3
+ import { createLogger } from './logging.js';
4
+ const log = createLogger('model-validator');
5
+ /**
6
+ * Static fallback chains — used only when registry + cache are both unavailable.
7
+ */
8
+ export const MODEL_FALLBACKS = {
9
+ anthropic: ['claude-sonnet-4-5-20250929', 'claude-3-5-sonnet-20241022', 'claude-3-haiku-20240307'],
10
+ openai: ['gpt-4.1', 'gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
11
+ gemini: ['gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-flash'],
12
+ deepseek: ['deepseek-chat', 'deepseek-coder'],
13
+ bedrock: ['claude-3.5-sonnet', 'claude-3-haiku'],
14
+ ollama: ['llama3.3:70b', 'llama3.2:latest'],
15
+ };
16
+ /**
17
+ * Query Ollama for installed models (local-only).
18
+ */
19
+ async function queryOllamaModels() {
20
+ const baseUrl = process.env.OLLAMA_BASE_URL ?? process.env.OLLAMA_HOST ?? 'http://localhost:11434';
21
+ try {
22
+ const resp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
23
+ if (!resp.ok)
24
+ return [];
25
+ const data = await resp.json();
26
+ return data.models.map(m => m.name);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ /**
33
+ * Resolve a model to an available one using the registry/cache.
34
+ * Resolution chain: cache → server fetch → static fallback
35
+ */
36
+ export async function resolveModel(provider, preferredModel, apiUrl) {
37
+ // Special case: Ollama models are local-only, query directly
38
+ if (provider === 'ollama') {
39
+ const ollamaModels = await queryOllamaModels();
40
+ if (ollamaModels.length > 0) {
41
+ if (ollamaModels.includes(preferredModel)) {
42
+ return { originalModel: preferredModel, resolvedModel: preferredModel, wasFallback: false };
43
+ }
44
+ // Find partial match (e.g., "llama3.3" matches "llama3.3:70b")
45
+ const partial = ollamaModels.find(m => m.startsWith(preferredModel) || preferredModel.startsWith(m.split(':')[0]));
46
+ if (partial) {
47
+ return {
48
+ originalModel: preferredModel,
49
+ resolvedModel: partial,
50
+ wasFallback: true,
51
+ reason: `Partial match: ${partial}`,
52
+ };
53
+ }
54
+ return {
55
+ originalModel: preferredModel,
56
+ resolvedModel: ollamaModels[0],
57
+ wasFallback: true,
58
+ reason: `${preferredModel} not installed, using ${ollamaModels[0]}`,
59
+ };
60
+ }
61
+ // Ollama not running — fall through to static fallback
62
+ }
63
+ // 1. Try cache
64
+ let cache = await readModelCache();
65
+ // 2. If cache miss/expired and we have a server URL, fetch fresh
66
+ if (!cache && apiUrl) {
67
+ cache = await fetchAndCacheModels(apiUrl);
68
+ }
69
+ // 3. If we have cached data, check if model exists
70
+ if (cache?.providers[provider]) {
71
+ const providerModels = cache.providers[provider];
72
+ const found = providerModels.some(m => m.modelId === preferredModel);
73
+ if (found) {
74
+ return { originalModel: preferredModel, resolvedModel: preferredModel, wasFallback: false };
75
+ }
76
+ // Model not in registry — find first available from fallback chain
77
+ const fallbacks = MODEL_FALLBACKS[provider] ?? [];
78
+ for (const fb of fallbacks) {
79
+ if (providerModels.some(m => m.modelId === fb)) {
80
+ log.warn({ provider, original: preferredModel, fallback: fb }, 'model_fallback');
81
+ return {
82
+ originalModel: preferredModel,
83
+ resolvedModel: fb,
84
+ wasFallback: true,
85
+ reason: `${preferredModel} not found in ${provider} model registry, using ${fb}`,
86
+ };
87
+ }
88
+ }
89
+ // None of the fallbacks exist in registry either — use first available from registry
90
+ if (providerModels.length > 0) {
91
+ const first = providerModels[0].modelId;
92
+ log.warn({ provider, original: preferredModel, fallback: first }, 'model_fallback_registry_first');
93
+ return {
94
+ originalModel: preferredModel,
95
+ resolvedModel: first,
96
+ wasFallback: true,
97
+ reason: `${preferredModel} not found, using first available: ${first}`,
98
+ };
99
+ }
100
+ }
101
+ // 4. No cache, no server — use static fallback
102
+ const fallbacks = MODEL_FALLBACKS[provider] ?? [];
103
+ if (fallbacks.includes(preferredModel)) {
104
+ return { originalModel: preferredModel, resolvedModel: preferredModel, wasFallback: false };
105
+ }
106
+ const fallback = fallbacks[0] ?? DEFAULT_MODELS[provider] ?? preferredModel;
107
+ if (fallback !== preferredModel) {
108
+ log.warn({ provider, original: preferredModel, fallback }, 'model_fallback_static');
109
+ return {
110
+ originalModel: preferredModel,
111
+ resolvedModel: fallback,
112
+ wasFallback: true,
113
+ reason: `Offline — ${preferredModel} unverified, using safe default ${fallback}`,
114
+ };
115
+ }
116
+ return { originalModel: preferredModel, resolvedModel: preferredModel, wasFallback: false };
117
+ }
118
+ /**
119
+ * Check if a provider has a valid API key configured.
120
+ */
121
+ export function isProviderAvailable(provider) {
122
+ if (provider === 'ollama' || provider === 'bedrock')
123
+ return true; // No key needed / AWS creds
124
+ return !!getProviderApiKey(provider);
125
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExecutorStrategy, ExecuteResponse, StreamResult, BudgetViolationType, FileContext } from './types.js';
2
+ import { type ErrorCategory } from './intelligence/index.js';
2
3
  /**
3
4
  * Extended stream result with context budget metrics (Orchex Learn).
4
5
  */
@@ -6,6 +7,8 @@ export interface StreamResultWithBudget extends StreamResult {
6
7
  /** Context budget metrics */
7
8
  contextMetrics?: {
8
9
  estimatedTokens: number;
10
+ /** Actual input tokens from LLM API response */
11
+ actualTokens?: number;
9
12
  budgetUtilization: number;
10
13
  violationType: BudgetViolationType;
11
14
  budgetLimit: number;
@@ -28,6 +31,17 @@ export declare function gatherStreamFileContext(projectDir: string, stream: {
28
31
  owns?: string[];
29
32
  reads?: string[];
30
33
  }): Promise<FileContext[]>;
34
+ export interface CircuitBreakerResult {
35
+ tripped: boolean;
36
+ category?: ErrorCategory;
37
+ /** True if the tripped category is selfHealable — fix streams should still be generated. */
38
+ selfHealable?: boolean;
39
+ failedCount: number;
40
+ totalCount: number;
41
+ message?: string;
42
+ suggestion?: string;
43
+ }
44
+ export declare function checkWaveCircuitBreaker(waveResults: StreamResult[], threshold?: number): CircuitBreakerResult;
31
45
  /**
32
46
  * Execute a single wave of the orchestration.
33
47
  * Steps: resolve → setup → context → execute → validate → apply → verify → report
@@ -1,20 +1,17 @@
1
- import { loadManifest, updateStreamStatus, addStream, recoverStuckStreams, cleanStaleLock } from './manifest.js';
1
+ import { loadManifest, saveManifest, updateStreamStatus, addStream, recoverStuckStreams, cleanStaleLock } from './manifest.js';
2
2
  import { createLogger, clearExecutionLog } from './logger.js';
3
3
  import { createLogger as createPinoLogger } from './logging.js';
4
4
  const log = createPinoLogger('orchestrator');
5
- import { generateFixStream } from './intelligence/self-healer.js';
6
- import { analyzeError } from './intelligence/error-analyzer.js';
7
- import { cleanupOrphanFixStreams } from './intelligence/fix-stream-manager.js';
5
+ import { generateFixStream, analyzeError, cleanupOrphanFixStreams, streamResultToTelemetryEvent, appendLocalEvents, runLearningCycle, routeStream, loadRoutingRules, } from './intelligence/index.js';
8
6
  import { calculateWaves } from './waves.js';
9
7
  import { buildFullPrompt, buildFullPromptOptimized } from './context-builder.js';
10
- import { applyArtifact, writeArtifact, validateArtifactSanity, createStreamBackup, withStreamIsolation, writeBackup, readAllBackups, deleteBackup, recoverFromIsolationCrash } from './artifacts.js';
8
+ import { applyArtifact, writeArtifact, validateArtifactSanity, createStreamBackup, revertStreamBackup, withStreamIsolation, writeBackup, readAllBackups, deleteBackup, recoverFromIsolationCrash } from './artifacts.js';
11
9
  import { runCommands } from './commands.js';
12
10
  import * as fs from 'fs/promises';
13
11
  import * as path from 'path';
14
- import { loadConfig, getConfiguredModel } from './config.js';
15
- import { getTier } from './tiers.js';
12
+ import { loadConfig, getConfiguredModel, DEFAULT_MODELS } from './config.js';
13
+ import { getEffectiveTier, getEffectiveLimits } from './tiers.js';
16
14
  import { broadcaster } from './execution-broadcaster.js';
17
- import { streamResultToTelemetryEvent, appendLocalEvents, runLearningCycle, } from './intelligence/learning-engine.js';
18
15
  const DEFAULT_MAX_TOKENS = 16384;
19
16
  const LOCK_FILE = '.orchex/active/.lock';
20
17
  /**
@@ -44,6 +41,35 @@ export async function gatherStreamFileContext(projectDir, stream) {
44
41
  }
45
42
  return context;
46
43
  }
44
+ export function checkWaveCircuitBreaker(waveResults, threshold = 0.75) {
45
+ const totalCount = waveResults.length;
46
+ const failed = waveResults.filter(r => r.status === 'failed' && r.errorDetail);
47
+ const failedCount = failed.length;
48
+ if (failedCount === 0 || totalCount === 0) {
49
+ return { tripped: false, failedCount: 0, totalCount };
50
+ }
51
+ const ratio = failedCount / totalCount;
52
+ if (ratio < threshold) {
53
+ return { tripped: false, failedCount, totalCount };
54
+ }
55
+ const categories = new Set(failed.map(r => r.errorDetail.category));
56
+ if (categories.size !== 1) {
57
+ return { tripped: false, failedCount, totalCount };
58
+ }
59
+ const category = [...categories][0];
60
+ const suggestion = failed[0].errorDetail.suggestion;
61
+ // selfHealable is true only when ALL failed streams agree (any false → false)
62
+ const selfHealable = failed.every(r => r.errorDetail.selfHealable);
63
+ return {
64
+ tripped: true,
65
+ category,
66
+ selfHealable,
67
+ failedCount,
68
+ totalCount,
69
+ message: `${failedCount}/${totalCount} streams failed with ${category}`,
70
+ suggestion,
71
+ };
72
+ }
47
73
  /**
48
74
  * Execute a single wave of the orchestration.
49
75
  * Steps: resolve → setup → context → execute → validate → apply → verify → report
@@ -103,7 +129,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
103
129
  }
104
130
  // 1. RESOLVE — load manifest, calculate waves, find target
105
131
  const manifest = await loadManifest(projectDir);
106
- const { waves, errors, blocked } = calculateWaves(manifest);
132
+ const { waves, errors, blocked } = calculateWaves(manifest, projectDir);
107
133
  // Only fail when there are truly no runnable waves.
108
134
  // Blocked stream messages are informational — other streams may still be runnable
109
135
  // (e.g., fix streams whose deps are satisfied while the original's dependents are blocked).
@@ -145,7 +171,16 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
145
171
  };
146
172
  }
147
173
  const totalWaves = waves.length;
148
- const model = options.model ?? getConfiguredModel(config);
174
+ const baseModel = options.model ?? DEFAULT_MODELS[executor.provider] ?? getConfiguredModel(config);
175
+ // Validate base model before execution
176
+ const { resolveModel: resolveBaseModel } = await import('./model-validator.js');
177
+ const { resolveApiUrl: resolveBaseApiUrl } = await import('./config.js');
178
+ const baseApiUrl = config.mode === 'cloud' && config.apiKey ? await resolveBaseApiUrl() : undefined;
179
+ const baseValidation = await resolveBaseModel(executor.provider, baseModel, baseApiUrl);
180
+ const model = baseValidation.resolvedModel;
181
+ if (baseValidation.wasFallback) {
182
+ log.warn({ original: baseValidation.originalModel, resolved: model, reason: baseValidation.reason }, 'wave_model_fallback');
183
+ }
149
184
  const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
150
185
  const waveLog = log.child({ orchestrationId: manifest.feature, waveNumber: targetWave.number });
151
186
  const streamResults = [];
@@ -174,14 +209,14 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
174
209
  const failed = results.find((r) => !r.success);
175
210
  if (failed) {
176
211
  const setupError = `Setup command failed: ${failed.command}`;
177
- const setupAnalysis = analyzeError(failed.error ?? failed.stderr ?? setupError);
212
+ const setupAnalysis = analyzeError(failed.error ?? failed.stderr ?? setupError, 'setup');
178
213
  await updateStreamStatus(projectDir, stream.id, 'failed', `Setup failed: ${failed.error ?? failed.stderr}`);
179
214
  streamResults.push({
180
215
  id: stream.id,
181
216
  name: stream.name,
182
217
  status: 'failed',
183
218
  error: setupError,
184
- errorDetail: { category: setupAnalysis.category, retryable: setupAnalysis.retryable, suggestion: setupAnalysis.suggestion },
219
+ errorDetail: { category: setupAnalysis.category, retryable: setupAnalysis.retryable, selfHealable: setupAnalysis.selfHealable, suggestion: setupAnalysis.suggestion },
185
220
  });
186
221
  broadcaster.broadcast(manifest.feature, {
187
222
  type: 'stream_failed',
@@ -237,6 +272,38 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
237
272
  // Track wave execution timing
238
273
  const waveStartTime = Date.now();
239
274
  const waveStartedAt = new Date().toISOString();
275
+ // Smart routing — opt-in via ~/.orchex/config.json routing rules
276
+ const routingRules = loadRoutingRules();
277
+ const routeDecisions = new Map();
278
+ if (routingRules.defaultProvider || routingRules.overrides) {
279
+ for (const stream of activeStreams) {
280
+ const streamDef = updatedManifest.streams[stream.id];
281
+ const decision = routeStream({ id: stream.id, name: stream.name, owns: stream.owns ?? [], reads: stream.reads ?? [], plan: streamDef?.plan }, routingRules);
282
+ routeDecisions.set(stream.id, decision);
283
+ if (decision.provider !== executor.provider) {
284
+ log.info({ streamId: stream.id, provider: decision.provider, model: decision.model, reasoning: decision.reasoning }, 'smart_route_applied');
285
+ }
286
+ }
287
+ }
288
+ // Validate and resolve models before execution
289
+ const { resolveModel } = await import('./model-validator.js');
290
+ const { resolveApiUrl } = await import('./config.js');
291
+ const apiUrl = config.mode === 'cloud' && config.apiKey ? await resolveApiUrl() : undefined;
292
+ const modelFallbacks = [];
293
+ for (const [streamId, decision] of routeDecisions) {
294
+ const validation = await resolveModel(decision.provider, decision.model, apiUrl);
295
+ if (validation.wasFallback) {
296
+ routeDecisions.set(streamId, {
297
+ ...decision,
298
+ model: validation.resolvedModel,
299
+ reasoning: `${decision.reasoning} [fallback: ${validation.reason}]`,
300
+ });
301
+ modelFallbacks.push(`${streamId}: ${validation.originalModel} → ${validation.resolvedModel}`);
302
+ }
303
+ }
304
+ if (modelFallbacks.length > 0) {
305
+ log.warn({ fallbacks: modelFallbacks }, 'model_fallbacks_applied');
306
+ }
240
307
  const executePromises = activeStreams.map(async (stream) => {
241
308
  const streamLog = waveLog.child({ streamId: stream.id });
242
309
  const streamStartTime = Date.now();
@@ -247,6 +314,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
247
314
  const useOptimization = options.optimize !== false; // Default to true
248
315
  let prompt;
249
316
  let structuredPrompt;
317
+ const streamDef = updatedManifest.streams[stream.id];
250
318
  if (useOptimization) {
251
319
  // Use optimized builder with caching hints and budget checking
252
320
  const promptOptions = {
@@ -254,7 +322,12 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
254
322
  model,
255
323
  // Don't enforce hard limit here - just warn and continue
256
324
  enforceHardLimit: false,
325
+ isFixStream: !!streamDef?.parentStreamId,
257
326
  };
327
+ // Log model routing suggestion if present
328
+ if (streamDef?.suggestedProvider && !streamDef.provider) {
329
+ streamLog.info({ streamId: stream.id, suggestedProvider: streamDef.suggestedProvider }, 'model_routing_suggestion');
330
+ }
258
331
  const optimized = await buildFullPromptOptimized(projectDir, stream.id, updatedManifest, promptOptions);
259
332
  prompt = optimized.fullPrompt;
260
333
  structuredPrompt = optimized;
@@ -291,11 +364,14 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
291
364
  };
292
365
  }
293
366
  // 4. EXECUTE — call LLM API
294
- const streamDef = updatedManifest.streams[stream.id];
295
- const timeoutMs = streamDef.timeoutMs ?? 120000;
367
+ const routeDecision = routeDecisions.get(stream.id);
368
+ const streamModel = routeDecision?.model ?? model;
369
+ const isCloud = (config?.mode === 'cloud') || executor.provider === 'orchex-cloud';
370
+ const defaultTimeoutMs = isCloud ? 600_000 : 120_000; // 10min cloud, 2min local
371
+ const timeoutMs = streamDef.timeoutMs ?? defaultTimeoutMs;
296
372
  const request = {
297
373
  prompt,
298
- model,
374
+ model: streamModel,
299
375
  maxTokens,
300
376
  streamId: stream.id,
301
377
  structuredPrompt,
@@ -327,6 +403,10 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
327
403
  stopHeartbeat();
328
404
  }
329
405
  const executionTimeMs = Date.now() - streamStartTime;
406
+ // Enrich budgetCheck with actual token count from API response
407
+ if (budgetCheck && result.tokensUsed?.input) {
408
+ budgetCheck.actualTokens = result.tokensUsed.input;
409
+ }
330
410
  if (!result.success || !result.artifact) {
331
411
  // Log truncated rawResponse for debugging artifact extraction failures
332
412
  const responsePreview = result.rawResponse
@@ -339,7 +419,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
339
419
  responsePreview,
340
420
  });
341
421
  const apiError = result.error ?? 'No artifact produced';
342
- const apiAnalysis = analyzeError(apiError);
422
+ const apiAnalysis = analyzeError(apiError, 'transport');
343
423
  broadcaster.broadcast(manifest.feature, {
344
424
  type: 'stream_failed',
345
425
  orchestrationId: manifest.feature,
@@ -351,7 +431,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
351
431
  name: stream.name,
352
432
  status: 'failed',
353
433
  error: apiError,
354
- errorDetail: { category: apiAnalysis.category, retryable: apiAnalysis.retryable, suggestion: apiAnalysis.suggestion },
434
+ errorDetail: { category: apiAnalysis.category, retryable: apiAnalysis.retryable, selfHealable: apiAnalysis.selfHealable, suggestion: apiAnalysis.suggestion },
355
435
  tokensUsed: result.tokensUsed,
356
436
  provider: executor.provider,
357
437
  model,
@@ -366,11 +446,23 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
366
446
  });
367
447
  // 5. VALIDATE — artifact is already parsed by executor
368
448
  const artifact = result.artifact;
449
+ // Check if stream was skipped by sibling fix completion while we were executing
450
+ const freshManifest = await loadManifest(projectDir);
451
+ const freshStatus = freshManifest.streams[stream.id]?.status;
452
+ if (freshStatus === 'skipped') {
453
+ log.info({ id: stream.id }, 'stream_skipped_by_sibling — discarding result');
454
+ return {
455
+ id: stream.id,
456
+ name: stream.name,
457
+ status: 'skipped',
458
+ summary: 'Skipped — sibling fix already resolved this stream',
459
+ };
460
+ }
369
461
  // 5a. SANITY CHECK — reject degenerate outputs before applying
370
462
  const sanity = validateArtifactSanity(artifact);
371
463
  if (!sanity.passed) {
372
464
  const sanityError = `Sanity check failed: ${sanity.violations.join('; ')}`;
373
- const sanityAnalysis = analyzeError(sanityError);
465
+ const sanityAnalysis = analyzeError(sanityError, 'artifact');
374
466
  await logger.error('sanity_check_failed', {
375
467
  id: stream.id,
376
468
  violations: sanity.violations,
@@ -380,7 +472,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
380
472
  name: stream.name,
381
473
  status: 'failed',
382
474
  error: sanityError,
383
- errorDetail: { category: sanityAnalysis.category, retryable: sanityAnalysis.retryable, suggestion: sanityAnalysis.suggestion },
475
+ errorDetail: { category: sanityAnalysis.category, retryable: sanityAnalysis.retryable, selfHealable: sanityAnalysis.selfHealable, suggestion: sanityAnalysis.suggestion },
384
476
  tokensUsed: result.tokensUsed,
385
477
  provider: executor.provider,
386
478
  model,
@@ -395,7 +487,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
395
487
  const applyResult = await applyArtifact(projectDir, stream.id, { force: false });
396
488
  if (!applyResult.success) {
397
489
  const applyError = applyResult.error ?? 'Apply failed';
398
- const applyAnalysis = analyzeError(applyError);
490
+ const applyAnalysis = analyzeError(applyError, 'artifact');
399
491
  await logger.error('stream_failed', {
400
492
  id: stream.id,
401
493
  phase: 'apply',
@@ -412,7 +504,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
412
504
  name: stream.name,
413
505
  status: 'failed',
414
506
  error: applyResult.error,
415
- errorDetail: { category: applyAnalysis.category, retryable: applyAnalysis.retryable, suggestion: applyAnalysis.suggestion },
507
+ errorDetail: { category: applyAnalysis.category, retryable: applyAnalysis.retryable, selfHealable: applyAnalysis.selfHealable, suggestion: applyAnalysis.suggestion },
416
508
  tokensUsed: result.tokensUsed,
417
509
  provider: executor.provider,
418
510
  model,
@@ -449,7 +541,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
449
541
  }
450
542
  catch (error) {
451
543
  const catchError = error.message;
452
- const catchAnalysis = analyzeError(catchError);
544
+ const catchAnalysis = analyzeError(catchError, 'transport');
453
545
  await logger.error('stream_failed', {
454
546
  id: stream.id,
455
547
  phase: 'execution',
@@ -466,7 +558,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
466
558
  name: stream.name,
467
559
  status: 'failed',
468
560
  error: catchError,
469
- errorDetail: { category: catchAnalysis.category, retryable: catchAnalysis.retryable, suggestion: catchAnalysis.suggestion },
561
+ errorDetail: { category: catchAnalysis.category, retryable: catchAnalysis.retryable, selfHealable: catchAnalysis.selfHealable, suggestion: catchAnalysis.suggestion },
470
562
  provider: executor.provider,
471
563
  model,
472
564
  executionTimeMs: Date.now() - streamStartTime,
@@ -481,7 +573,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
481
573
  name: activeStreams[i]?.name ?? 'unknown',
482
574
  status: 'failed',
483
575
  error: err.message ?? String(err),
484
- errorDetail: { category: 'unknown', retryable: true, suggestion: 'Check orchestrator logs' },
576
+ errorDetail: { category: 'unknown', retryable: true, selfHealable: false, suggestion: 'Check orchestrator logs' },
485
577
  tokensUsed: { input: 0, output: 0 },
486
578
  })));
487
579
  // Promise.allSettled — one failure doesn't kill the wave
@@ -496,13 +588,13 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
496
588
  }
497
589
  else {
498
590
  const rejectedError = result.reason?.message ?? 'Unknown error';
499
- const rejectedAnalysis = analyzeError(rejectedError);
591
+ const rejectedAnalysis = analyzeError(rejectedError, 'transport');
500
592
  streamResults.push({
501
593
  id: 'unknown',
502
594
  name: 'unknown',
503
595
  status: 'failed',
504
596
  error: rejectedError,
505
- errorDetail: { category: rejectedAnalysis.category, retryable: rejectedAnalysis.retryable, suggestion: rejectedAnalysis.suggestion },
597
+ errorDetail: { category: rejectedAnalysis.category, retryable: rejectedAnalysis.retryable, selfHealable: rejectedAnalysis.selfHealable, suggestion: rejectedAnalysis.suggestion },
506
598
  });
507
599
  }
508
600
  }
@@ -578,7 +670,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
578
670
  .join('\n')
579
671
  || 'unknown error';
580
672
  const errorSummary = `Verify failed: ${failed.command}: ${verifyError.slice(0, 500)}`;
581
- const verifyAnalysis = analyzeError(verifyError);
673
+ const verifyAnalysis = analyzeError(verifyError, 'verify');
582
674
  verifyDetails.push(`${sr.id}: ${errorSummary}`);
583
675
  await updateStreamStatus(projectDir, sr.id, 'failed', errorSummary);
584
676
  await logger.error('verify_failed', {
@@ -588,9 +680,20 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
588
680
  error: verifyError.slice(0, 1000),
589
681
  isolated: otherBackups.length > 0,
590
682
  });
683
+ // Rollback file changes for failed verify — prevents leaving orphan files on disk
684
+ const streamBackup = successBackups.find(b => b.streamId === sr.id);
685
+ if (streamBackup) {
686
+ try {
687
+ await revertStreamBackup(projectDir, streamBackup);
688
+ await logger.info('verify_rollback', { stream: sr.id, files: streamBackup.entries.length + streamBackup.createdFiles.length });
689
+ }
690
+ catch (rollbackErr) {
691
+ await logger.warn('verify_rollback_failed', { stream: sr.id, error: rollbackErr.message });
692
+ }
693
+ }
591
694
  sr.status = 'failed';
592
695
  sr.error = errorSummary;
593
- sr.errorDetail = { category: verifyAnalysis.category, retryable: verifyAnalysis.retryable, suggestion: verifyAnalysis.suggestion };
696
+ sr.errorDetail = { category: verifyAnalysis.category, retryable: verifyAnalysis.retryable, selfHealable: verifyAnalysis.selfHealable, suggestion: verifyAnalysis.suggestion };
594
697
  }
595
698
  else {
596
699
  verifyPassed++;
@@ -621,15 +724,41 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
621
724
  });
622
725
  }
623
726
  }
727
+ // 7a. CIRCUIT BREAKER — detect systemic wave-level failures
728
+ const circuitBreaker = checkWaveCircuitBreaker(streamResults);
729
+ if (circuitBreaker.tripped) {
730
+ const firstFailedError = streamResults.find(sr => sr.status === 'failed')?.error;
731
+ log.warn({
732
+ category: circuitBreaker.category,
733
+ failedCount: circuitBreaker.failedCount,
734
+ totalCount: circuitBreaker.totalCount,
735
+ firstError: firstFailedError?.slice(0, 500),
736
+ }, 'circuit_breaker_tripped');
737
+ const cbManifest = await loadManifest(projectDir);
738
+ cbManifest.status = 'circuit_breaker';
739
+ await saveManifest(projectDir, cbManifest);
740
+ }
624
741
  // 7a. SELF-HEAL — generate fix streams for failed streams (tier-gated)
625
742
  const fixStreamsGenerated = [];
626
743
  const currentManifest = await loadManifest(projectDir);
627
- const tier = getTier(config.tier ?? 'free');
628
- if (tier.selfHealing === 'full') {
744
+ const effectiveTierId = config.mode === 'cloud' && config.apiKey
745
+ ? getEffectiveTier({
746
+ tier: config.tier ?? 'free',
747
+ createdAt: config.accountCreatedAt ?? new Date().toISOString(),
748
+ trialRunsRemaining: config.trialRunsRemaining ?? 0,
749
+ })
750
+ : (config.tier ?? 'free');
751
+ const tier = getEffectiveLimits(effectiveTierId, null);
752
+ // Circuit breaker blocks self-healing only for non-selfHealable categories (infra failures).
753
+ // When all streams fail with a selfHealable category (code errors), fix streams are still useful.
754
+ const cbBlocksHealing = circuitBreaker.tripped && !(circuitBreaker.selfHealable ?? false);
755
+ if (tier.selfHealing === 'full' && !cbBlocksHealing) {
629
756
  for (const sr of streamResults) {
630
757
  if (sr.status !== 'failed' || sr.id === 'unknown')
631
758
  continue;
632
- const fixResult = generateFixStream(currentManifest, sr.id);
759
+ // Pass the in-memory errorDetail (computed with origin hint) so generateFixStream
760
+ // can use the correct selfHealable flag rather than re-analyzing without origin.
761
+ const fixResult = generateFixStream(currentManifest, sr.id, sr.errorDetail);
633
762
  if (fixResult) {
634
763
  await addStream(projectDir, fixResult.fixStreamId, fixResult.fixStream);
635
764
  fixStreamsGenerated.push(fixResult.fixStreamId);
@@ -662,7 +791,7 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
662
791
  }
663
792
  // 8. REPORT — check for remaining waves
664
793
  const finalManifest = await loadManifest(projectDir);
665
- const { waves: remainingWaves } = calculateWaves(finalManifest);
794
+ const { waves: remainingWaves } = calculateWaves(finalManifest, projectDir);
666
795
  const done = remainingWaves.length === 0;
667
796
  const hasCompleted = streamResults.some((s) => s.status === 'complete');
668
797
  const completedCount = streamResults.filter((s) => s.status === 'complete').length;
@@ -694,6 +823,36 @@ async function executeWaveInternal(projectDir, executor, options, logger) {
694
823
  // Learning is best-effort
695
824
  log.warn({ err }, 'learning_cycle_failed');
696
825
  }
826
+ // 8c. PATTERN DETECTION (L2) — analyze reports every 5 runs
827
+ try {
828
+ const { detectPatterns, savePatterns, adaptReport } = await import('./intelligence/index.js');
829
+ const reportsDir = path.join(projectDir, '.orchex', 'reports');
830
+ const reportFiles = await fs.readdir(reportsDir).catch(() => []);
831
+ if (reportFiles.length > 0 && reportFiles.length % 5 === 0) {
832
+ const reports = [];
833
+ for (const file of reportFiles) {
834
+ if (!file.endsWith('.json'))
835
+ continue;
836
+ try {
837
+ const content = await fs.readFile(path.join(reportsDir, file), 'utf-8');
838
+ const canonical = JSON.parse(content);
839
+ reports.push(adaptReport(canonical));
840
+ }
841
+ catch { /* skip invalid */ }
842
+ }
843
+ if (reports.length >= 5) {
844
+ const patterns = detectPatterns(reports);
845
+ if (patterns.length > 0) {
846
+ await savePatterns(projectDir, patterns);
847
+ log.info({ patternCount: patterns.length }, 'patterns_detected');
848
+ }
849
+ }
850
+ }
851
+ }
852
+ catch (err) {
853
+ // Non-fatal — don't fail execution for pattern detection errors
854
+ log.warn({ err }, 'pattern_detection_error');
855
+ }
697
856
  }
698
857
  // Clean up disk backups after wave completion (all verify/isolation done)
699
858
  for (const backup of successBackups) {
@@ -0,0 +1,13 @@
1
+ export interface IdeConfig {
2
+ name: string;
3
+ slug: string;
4
+ detect: (projectDir: string) => boolean;
5
+ configPath: (projectDir: string) => string;
6
+ scope: 'project' | 'global';
7
+ }
8
+ export declare const IDE_REGISTRY: IdeConfig[];
9
+ export declare function detectIdes(projectDir: string): IdeConfig[];
10
+ export declare function findBySlug(slug: string): IdeConfig | undefined;
11
+ export declare function FALLBACK_CONFIG_PATH(projectDir: string): string;
12
+ export declare function generateMcpConfig(): Record<string, unknown>;
13
+ export declare function mergeConfig(existing: Record<string, unknown>, orchexEntry: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ export const IDE_REGISTRY = [
5
+ {
6
+ name: 'Cursor',
7
+ slug: 'cursor',
8
+ detect: (projectDir) => {
9
+ return Boolean(process.env.CURSOR_TRACE_DIR) || fs.existsSync(path.join(projectDir, '.cursor'));
10
+ },
11
+ configPath: (projectDir) => path.join(projectDir, '.cursor/mcp.json'),
12
+ scope: 'project'
13
+ },
14
+ {
15
+ name: 'Windsurf',
16
+ slug: 'windsurf',
17
+ detect: (projectDir) => {
18
+ return Boolean(process.env.WINDSURF_SESSION_ID) || fs.existsSync(path.join(os.homedir(), '.codeium'));
19
+ },
20
+ configPath: (projectDir) => path.join(os.homedir(), '.codeium/windsurf/mcp_config.json'),
21
+ scope: 'global'
22
+ }
23
+ ];
24
+ export function detectIdes(projectDir) {
25
+ return IDE_REGISTRY.filter(ide => ide.detect(projectDir));
26
+ }
27
+ export function findBySlug(slug) {
28
+ return IDE_REGISTRY.find(ide => ide.slug === slug);
29
+ }
30
+ export function FALLBACK_CONFIG_PATH(projectDir) {
31
+ return path.join(projectDir, '.mcp.json');
32
+ }
33
+ export function generateMcpConfig() {
34
+ return {
35
+ mcpServers: {
36
+ orchex: {
37
+ command: 'npx',
38
+ args: ['-y', '@wundam/orchex']
39
+ }
40
+ }
41
+ };
42
+ }
43
+ export function mergeConfig(existing, orchexEntry) {
44
+ return {
45
+ ...existing,
46
+ mcpServers: {
47
+ ...(existing.mcpServers || {}),
48
+ orchex: orchexEntry
49
+ }
50
+ };
51
+ }
@@ -0,0 +1 @@
1
+ export declare function handleSetupCommand(args: string[], projectDir?: string): Promise<void>;