@chllming/wave-orchestration 0.9.0 → 0.9.2

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 (68) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +133 -20
  4. package/docs/README.md +12 -4
  5. package/docs/agents/wave-security-role.md +1 -0
  6. package/docs/architecture/README.md +1498 -0
  7. package/docs/concepts/operating-modes.md +2 -2
  8. package/docs/guides/author-and-run-waves.md +14 -4
  9. package/docs/guides/planner.md +2 -2
  10. package/docs/guides/{recommendations-0.9.0.md → recommendations-0.9.2.md} +8 -7
  11. package/docs/guides/sandboxed-environments.md +158 -0
  12. package/docs/guides/terminal-surfaces.md +14 -12
  13. package/docs/plans/current-state.md +11 -3
  14. package/docs/plans/end-state-architecture.md +3 -1
  15. package/docs/plans/examples/wave-example-design-handoff.md +1 -1
  16. package/docs/plans/examples/wave-example-live-proof.md +1 -1
  17. package/docs/plans/migration.md +70 -19
  18. package/docs/plans/sandbox-end-state-architecture.md +153 -0
  19. package/docs/reference/cli-reference.md +71 -7
  20. package/docs/reference/coordination-and-closure.md +18 -1
  21. package/docs/reference/corridor.md +225 -0
  22. package/docs/reference/github-packages-setup.md +1 -1
  23. package/docs/reference/migration-0.2-to-0.5.md +9 -7
  24. package/docs/reference/npmjs-token-publishing.md +53 -0
  25. package/docs/reference/npmjs-trusted-publishing.md +4 -50
  26. package/docs/reference/package-publishing-flow.md +272 -0
  27. package/docs/reference/runtime-config/README.md +61 -3
  28. package/docs/reference/sample-waves.md +5 -5
  29. package/docs/reference/skills.md +1 -1
  30. package/docs/reference/wave-control.md +358 -27
  31. package/docs/roadmap.md +39 -204
  32. package/package.json +1 -1
  33. package/releases/manifest.json +38 -0
  34. package/scripts/wave-cli-bootstrap.mjs +52 -1
  35. package/scripts/wave-orchestrator/agent-process-runner.mjs +344 -0
  36. package/scripts/wave-orchestrator/agent-state.mjs +0 -1
  37. package/scripts/wave-orchestrator/artifact-schemas.mjs +7 -0
  38. package/scripts/wave-orchestrator/autonomous.mjs +47 -14
  39. package/scripts/wave-orchestrator/closure-engine.mjs +138 -17
  40. package/scripts/wave-orchestrator/config.mjs +199 -3
  41. package/scripts/wave-orchestrator/context7.mjs +231 -29
  42. package/scripts/wave-orchestrator/control-cli.mjs +42 -5
  43. package/scripts/wave-orchestrator/coordination.mjs +14 -0
  44. package/scripts/wave-orchestrator/corridor.mjs +363 -0
  45. package/scripts/wave-orchestrator/dashboard-renderer.mjs +115 -43
  46. package/scripts/wave-orchestrator/derived-state-engine.mjs +44 -4
  47. package/scripts/wave-orchestrator/gate-engine.mjs +126 -38
  48. package/scripts/wave-orchestrator/install.mjs +46 -0
  49. package/scripts/wave-orchestrator/launcher-progress.mjs +91 -0
  50. package/scripts/wave-orchestrator/launcher-runtime.mjs +290 -75
  51. package/scripts/wave-orchestrator/launcher.mjs +201 -53
  52. package/scripts/wave-orchestrator/ledger.mjs +7 -2
  53. package/scripts/wave-orchestrator/planner.mjs +1 -0
  54. package/scripts/wave-orchestrator/projection-writer.mjs +36 -1
  55. package/scripts/wave-orchestrator/provider-runtime.mjs +104 -0
  56. package/scripts/wave-orchestrator/reducer-snapshot.mjs +6 -0
  57. package/scripts/wave-orchestrator/retry-control.mjs +3 -3
  58. package/scripts/wave-orchestrator/retry-engine.mjs +93 -6
  59. package/scripts/wave-orchestrator/role-helpers.mjs +30 -0
  60. package/scripts/wave-orchestrator/session-supervisor.mjs +94 -85
  61. package/scripts/wave-orchestrator/shared.mjs +1 -0
  62. package/scripts/wave-orchestrator/supervisor-cli.mjs +1306 -0
  63. package/scripts/wave-orchestrator/terminals.mjs +12 -32
  64. package/scripts/wave-orchestrator/tmux-adapter.mjs +300 -0
  65. package/scripts/wave-orchestrator/traces.mjs +25 -0
  66. package/scripts/wave-orchestrator/wave-control-client.mjs +14 -1
  67. package/scripts/wave-orchestrator/wave-files.mjs +38 -5
  68. package/scripts/wave.mjs +13 -0
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { appendCoordinationRecord } from "./coordination-store.mjs";
2
3
  import {
3
4
  parseStructuredSignalsFromLog,
4
5
  refreshWaveDashboardAgentStates,
@@ -14,8 +15,13 @@ import {
14
15
  readWaveIntegrationBarrier as readWaveIntegrationBarrierDefault,
15
16
  readWaveSecurityGate as readWaveSecurityGateDefault,
16
17
  } from "./gate-engine.mjs";
18
+ import { applyLaunchResultToRun } from "./launcher-runtime.mjs";
17
19
  import { REPO_ROOT, toIsoTimestamp } from "./shared.mjs";
18
- import { isSecurityReviewAgent, resolveWaveRoleBindings } from "./role-helpers.mjs";
20
+ import {
21
+ isSecurityReviewAgentForLane,
22
+ resolveAgentClosureRoleKeys,
23
+ resolveWaveRoleBindings,
24
+ } from "./role-helpers.mjs";
19
25
  import { summarizeResolvedSkills } from "./skills.mjs";
20
26
 
21
27
  function failureResultFromGate(gate, fallbackLogPath) {
@@ -57,6 +63,64 @@ function recordClosureGateFailure({
57
63
  });
58
64
  }
59
65
 
66
+ function isForwardableClosureGap(gate) {
67
+ return gate?.statusCode === "wave-proof-gap";
68
+ }
69
+
70
+ function forwardedClosureGapRecord({
71
+ stage,
72
+ wave,
73
+ lanePaths,
74
+ gate,
75
+ attempt,
76
+ targetAgentIds = [],
77
+ }) {
78
+ return {
79
+ id: `wave-${wave.wave}-closure-gap-${stage.key}-${gate.agentId}-attempt-${attempt || 1}`,
80
+ kind: "blocker",
81
+ lane: lanePaths.lane,
82
+ wave: wave.wave,
83
+ agentId: gate.agentId,
84
+ status: "open",
85
+ priority: "high",
86
+ blocking: true,
87
+ blockerSeverity: "closure-critical",
88
+ summary: `${stage.label} reported a proof gap and was forwarded to later closure stages.`,
89
+ detail: gate.detail,
90
+ artifactRefs: gate.logPath ? [gate.logPath] : [],
91
+ targets: targetAgentIds.map((agentId) => `agent:${agentId}`),
92
+ attempt: attempt || 1,
93
+ };
94
+ }
95
+
96
+ function stageRequiresRun(stage, wave, lanePaths) {
97
+ switch (stage.key) {
98
+ case "integration":
99
+ case "documentation":
100
+ case "cont-qa":
101
+ return true;
102
+ case "cont-eval":
103
+ return Array.isArray(wave?.agents) && wave.agents.some((agent) => agent?.agentId === stage.agentId);
104
+ case "security-review":
105
+ return (
106
+ Array.isArray(wave?.agents) &&
107
+ wave.agents.some((agent) => isSecurityReviewAgentForLane(agent, lanePaths))
108
+ );
109
+ default:
110
+ return false;
111
+ }
112
+ }
113
+
114
+ function missingClosureRunGate(stage) {
115
+ return {
116
+ ok: false,
117
+ agentId: stage.agentId,
118
+ statusCode: "missing-closure-run",
119
+ detail: `${stage.label} is required for this wave but no matching closure run was provided.`,
120
+ logPath: null,
121
+ };
122
+ }
123
+
60
124
  export async function runClosureSweepPhase({
61
125
  lanePaths,
62
126
  wave,
@@ -113,10 +177,24 @@ export async function runClosureSweepPhase({
113
177
  ? readWaveContQaGateFn
114
178
  : readWaveContQaGateDefault;
115
179
  const stagedRuns = planClosureStages({ lanePaths, wave, closureRuns });
180
+ const forwardedFailures = [];
116
181
  const { contQaAgentId, contEvalAgentId, integrationAgentId, documentationAgentId } =
117
182
  resolveWaveRoleBindings(wave, lanePaths);
118
- for (const stage of stagedRuns) {
183
+ for (const [stageIndex, stage] of stagedRuns.entries()) {
119
184
  if (stage.runs.length === 0) {
185
+ if (stageRequiresRun(stage, wave, lanePaths)) {
186
+ const gate = missingClosureRunGate(stage);
187
+ recordClosureGateFailure({
188
+ wave,
189
+ lanePaths,
190
+ gate,
191
+ label: stage.label,
192
+ recordCombinedEvent,
193
+ appendCoordination,
194
+ actionRequested: stage.actionRequested,
195
+ });
196
+ return failureResultFromGate(gate, null);
197
+ }
120
198
  continue;
121
199
  }
122
200
  for (const runInfo of stage.runs) {
@@ -138,6 +216,7 @@ export async function runClosureSweepPhase({
138
216
  promptPath: runInfo.promptPath,
139
217
  logPath: runInfo.logPath,
140
218
  statusPath: runInfo.statusPath,
219
+ runtimePath: runInfo.runtimePath,
141
220
  messageBoardPath: runInfo.messageBoardPath,
142
221
  messageBoardSnapshot: runInfo.messageBoardSnapshot || "",
143
222
  sharedSummaryPath: runInfo.sharedSummaryPath,
@@ -157,19 +236,18 @@ export async function runClosureSweepPhase({
157
236
  attempt: dashboardState?.attempt || 1,
158
237
  },
159
238
  });
160
- runInfo.lastLaunchAttempt = dashboardState?.attempt || null;
161
- runInfo.lastPromptHash = launchResult?.promptHash || null;
162
- runInfo.lastContext7 = launchResult?.context7 || null;
163
- runInfo.lastExecutorId = launchResult?.executorId || runInfo.agent.executorResolved?.id || null;
164
- runInfo.lastSkillProjection =
165
- launchResult?.skills || summarizeResolvedSkills(runInfo.agent.skillsResolved);
239
+ applyLaunchResultToRun(runInfo, launchResult, {
240
+ attempt: dashboardState?.attempt || null,
241
+ fallbackExecutorId: runInfo.agent.executorResolved?.id || null,
242
+ fallbackSkills: summarizeResolvedSkills(runInfo.agent.skillsResolved),
243
+ });
166
244
  setWaveDashboardAgent(dashboardState, runInfo.agent.agentId, {
167
245
  state: "running",
168
246
  detail: `Closure sweep launched${launchResult?.context7?.mode ? ` (${launchResult.context7.mode})` : ""}`,
169
247
  });
170
248
  recordCombinedEvent({
171
249
  agentId: runInfo.agent.agentId,
172
- message: `Closure sweep launched in tmux session ${runInfo.sessionName}`,
250
+ message: `Closure sweep launched via ${launchResult?.sessionBackend || "process"} backend`,
173
251
  });
174
252
  flushDashboards();
175
253
  const result = await waitForWaveCompletionFn(
@@ -228,6 +306,43 @@ export async function runClosureSweepPhase({
228
306
  contQaAgentId,
229
307
  });
230
308
  if (!gate.ok) {
309
+ if (isForwardableClosureGap(gate)) {
310
+ const targetAgentIds = stagedRuns
311
+ .slice(stageIndex + 1)
312
+ .flatMap((candidate) => candidate.runs.map((run) => run.agent.agentId))
313
+ .filter(Boolean);
314
+ forwardedFailures.push({
315
+ agentId: gate.agentId,
316
+ statusCode: gate.statusCode,
317
+ logPath: gate.logPath || (stage.runs[0]?.logPath ? path.relative(REPO_ROOT, stage.runs[0].logPath) : null),
318
+ detail: gate.detail,
319
+ });
320
+ appendCoordinationRecord(
321
+ coordinationLogPath,
322
+ forwardedClosureGapRecord({
323
+ stage,
324
+ wave,
325
+ lanePaths,
326
+ gate,
327
+ attempt: dashboardState?.attempt || 1,
328
+ targetAgentIds,
329
+ }),
330
+ );
331
+ recordCombinedEvent({
332
+ level: "warn",
333
+ agentId: gate.agentId,
334
+ message: `${stage.label} reported a proof gap; continuing later closure stages with the gap as input.`,
335
+ });
336
+ appendCoordination({
337
+ event: "closure_gap_forwarded",
338
+ waves: [wave.wave],
339
+ status: "blocked",
340
+ details: `agent=${gate.agentId}; reason=${gate.statusCode}; ${gate.detail}`,
341
+ actionRequested: `Lane ${lanePaths.lane} owners should resolve the forwarded closure proof gap after downstream closure evidence is collected.`,
342
+ });
343
+ refreshDerivedState?.(dashboardState?.attempt || 0);
344
+ continue;
345
+ }
231
346
  recordClosureGateFailure({
232
347
  wave,
233
348
  lanePaths,
@@ -243,18 +358,21 @@ export async function runClosureSweepPhase({
243
358
  );
244
359
  }
245
360
  }
246
- return { failures: [], timedOut: false };
361
+ return { failures: forwardedFailures, timedOut: false };
247
362
  }
248
363
 
249
364
  export function planClosureStages({ lanePaths, wave, closureRuns }) {
365
+ const roleBindings = resolveWaveRoleBindings(wave, lanePaths);
250
366
  const { contQaAgentId, contEvalAgentId, integrationAgentId, documentationAgentId } =
251
- resolveWaveRoleBindings(wave, lanePaths);
367
+ roleBindings;
368
+ const runHasRole = (run, roleKey) =>
369
+ resolveAgentClosureRoleKeys(run.agent, roleBindings, lanePaths).includes(roleKey);
252
370
  return [
253
371
  {
254
372
  key: "cont-eval",
255
373
  agentId: contEvalAgentId,
256
374
  label: "cont-EVAL gate",
257
- runs: closureRuns.filter((run) => run.agent.agentId === contEvalAgentId),
375
+ runs: closureRuns.filter((run) => runHasRole(run, "cont-eval")),
258
376
  actionRequested:
259
377
  `Lane ${lanePaths.lane} owners should resolve cont-EVAL tuning gaps before integration closure.`,
260
378
  },
@@ -262,7 +380,7 @@ export function planClosureStages({ lanePaths, wave, closureRuns }) {
262
380
  key: "security-review",
263
381
  agentId: "security",
264
382
  label: "Security review",
265
- runs: closureRuns.filter((run) => isSecurityReviewAgent(run.agent)),
383
+ runs: closureRuns.filter((run) => runHasRole(run, "security-review")),
266
384
  actionRequested:
267
385
  `Lane ${lanePaths.lane} owners should resolve blocked security findings or missing approvals before integration closure.`,
268
386
  },
@@ -270,7 +388,7 @@ export function planClosureStages({ lanePaths, wave, closureRuns }) {
270
388
  key: "integration",
271
389
  agentId: integrationAgentId,
272
390
  label: "Integration gate",
273
- runs: closureRuns.filter((run) => run.agent.agentId === integrationAgentId),
391
+ runs: closureRuns.filter((run) => runHasRole(run, "integration")),
274
392
  actionRequested:
275
393
  `Lane ${lanePaths.lane} owners should resolve integration contradictions or blockers before documentation and cont-QA closure.`,
276
394
  },
@@ -278,7 +396,7 @@ export function planClosureStages({ lanePaths, wave, closureRuns }) {
278
396
  key: "documentation",
279
397
  agentId: documentationAgentId,
280
398
  label: "Documentation closure",
281
- runs: closureRuns.filter((run) => run.agent.agentId === documentationAgentId),
399
+ runs: closureRuns.filter((run) => runHasRole(run, "documentation")),
282
400
  actionRequested:
283
401
  `Lane ${lanePaths.lane} owners should resolve the shared-plan or component-matrix closure state before cont-QA progression.`,
284
402
  },
@@ -286,7 +404,7 @@ export function planClosureStages({ lanePaths, wave, closureRuns }) {
286
404
  key: "cont-qa",
287
405
  agentId: contQaAgentId,
288
406
  label: "cont-QA gate",
289
- runs: closureRuns.filter((run) => run.agent.agentId === contQaAgentId),
407
+ runs: closureRuns.filter((run) => runHasRole(run, "cont-qa")),
290
408
  actionRequested:
291
409
  `Lane ${lanePaths.lane} owners should resolve the cont-QA gate before wave progression.`,
292
410
  },
@@ -320,7 +438,10 @@ function evaluateClosureStage({
320
438
  benchmarkCatalogPath: lanePaths.laneProfile?.paths?.benchmarkCatalogPath,
321
439
  });
322
440
  case "security-review":
323
- return readWaveSecurityGateFn(wave, closureRuns, { mode: "live" });
441
+ return readWaveSecurityGateFn(wave, closureRuns, {
442
+ mode: "live",
443
+ securityRolePromptPath: lanePaths?.securityRolePromptPath,
444
+ });
324
445
  case "integration":
325
446
  return readWaveIntegrationBarrierFn(
326
447
  wave,
@@ -73,12 +73,22 @@ export const DEFAULT_CODEX_SANDBOX_MODE = "danger-full-access";
73
73
  export const CODEX_SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"];
74
74
  export const DEFAULT_CLAUDE_COMMAND = "claude";
75
75
  export const DEFAULT_OPENCODE_COMMAND = "opencode";
76
- export const DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR = "WAVE_CONTROL_AUTH_TOKEN";
76
+ export const DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR = "WAVE_API_TOKEN";
77
+ export const LEGACY_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR = "WAVE_CONTROL_AUTH_TOKEN";
77
78
  export const DEFAULT_WAVE_CONTROL_ENDPOINT = "https://wave-control.up.railway.app/api/v1";
78
79
  export const DEFAULT_WAVE_CONTROL_REPORT_MODE = "metadata-only";
79
80
  export const DEFAULT_WAVE_CONTROL_REQUEST_TIMEOUT_MS = 5000;
80
81
  export const DEFAULT_WAVE_CONTROL_FLUSH_BATCH_SIZE = 25;
81
82
  export const DEFAULT_WAVE_CONTROL_MAX_PENDING_EVENTS = 1000;
83
+ export const WAVE_CONTROL_RUNTIME_CREDENTIAL_PROVIDERS = ["anthropic", "openai"];
84
+ export const EXTERNAL_PROVIDER_MODES = ["direct", "broker", "hybrid"];
85
+ export const DEFAULT_CONTEXT7_API_KEY_ENV_VAR = "CONTEXT7_API_KEY";
86
+ export const DEFAULT_CORRIDOR_API_TOKEN_ENV_VAR = "CORRIDOR_API_TOKEN";
87
+ export const DEFAULT_CORRIDOR_API_KEY_FALLBACK_ENV_VAR = "CORRIDOR_API_KEY";
88
+ export const DEFAULT_CORRIDOR_BASE_URL = "https://app.corridor.dev/api";
89
+ export const DEFAULT_CORRIDOR_SEVERITY_THRESHOLD = "critical";
90
+ export const DEFAULT_CORRIDOR_FINDING_STATES = ["open", "potential"];
91
+ export const CORRIDOR_SEVERITY_LEVELS = ["low", "medium", "high", "critical"];
82
92
  export const DEFAULT_WAVE_CONTROL_SELECTED_ARTIFACT_KINDS = [
83
93
  "trace-run-metadata",
84
94
  "trace-quality",
@@ -295,6 +305,103 @@ function normalizeOptionalJsonObject(value, label) {
295
305
  throw new Error(`${label} must be a JSON object`);
296
306
  }
297
307
 
308
+ function normalizeExternalProviderMode(value, label, fallback = "direct") {
309
+ const normalized = String(value || fallback)
310
+ .trim()
311
+ .toLowerCase();
312
+ if (!EXTERNAL_PROVIDER_MODES.includes(normalized)) {
313
+ throw new Error(`${label} must be one of: ${EXTERNAL_PROVIDER_MODES.join(", ")}`);
314
+ }
315
+ return normalized;
316
+ }
317
+
318
+ function normalizeCorridorSeverity(value, label, fallback = DEFAULT_CORRIDOR_SEVERITY_THRESHOLD) {
319
+ const normalized = String(value || fallback)
320
+ .trim()
321
+ .toLowerCase();
322
+ if (!CORRIDOR_SEVERITY_LEVELS.includes(normalized)) {
323
+ throw new Error(`${label} must be one of: ${CORRIDOR_SEVERITY_LEVELS.join(", ")}`);
324
+ }
325
+ return normalized;
326
+ }
327
+
328
+ function normalizeExternalProviders(rawExternalProviders = {}, label = "externalProviders") {
329
+ const externalProviders =
330
+ rawExternalProviders &&
331
+ typeof rawExternalProviders === "object" &&
332
+ !Array.isArray(rawExternalProviders)
333
+ ? rawExternalProviders
334
+ : {};
335
+ const context7 =
336
+ externalProviders.context7 &&
337
+ typeof externalProviders.context7 === "object" &&
338
+ !Array.isArray(externalProviders.context7)
339
+ ? externalProviders.context7
340
+ : {};
341
+ const corridor =
342
+ externalProviders.corridor &&
343
+ typeof externalProviders.corridor === "object" &&
344
+ !Array.isArray(externalProviders.corridor)
345
+ ? externalProviders.corridor
346
+ : {};
347
+ const context7Mode = normalizeExternalProviderMode(
348
+ context7.mode,
349
+ `${label}.context7.mode`,
350
+ "direct",
351
+ );
352
+ const corridorMode = normalizeExternalProviderMode(
353
+ corridor.mode,
354
+ `${label}.corridor.mode`,
355
+ "direct",
356
+ );
357
+ const normalized = {
358
+ context7: {
359
+ mode: context7Mode,
360
+ apiKeyEnvVar:
361
+ normalizeOptionalString(
362
+ context7.apiKeyEnvVar,
363
+ DEFAULT_CONTEXT7_API_KEY_ENV_VAR,
364
+ ) || DEFAULT_CONTEXT7_API_KEY_ENV_VAR,
365
+ },
366
+ corridor: {
367
+ enabled: normalizeOptionalBoolean(corridor.enabled, false),
368
+ mode: corridorMode,
369
+ baseUrl: normalizeOptionalString(corridor.baseUrl, DEFAULT_CORRIDOR_BASE_URL),
370
+ apiTokenEnvVar:
371
+ normalizeOptionalString(
372
+ corridor.apiTokenEnvVar,
373
+ DEFAULT_CORRIDOR_API_TOKEN_ENV_VAR,
374
+ ) || DEFAULT_CORRIDOR_API_TOKEN_ENV_VAR,
375
+ apiKeyFallbackEnvVar:
376
+ normalizeOptionalString(
377
+ corridor.apiKeyFallbackEnvVar,
378
+ DEFAULT_CORRIDOR_API_KEY_FALLBACK_ENV_VAR,
379
+ ) || DEFAULT_CORRIDOR_API_KEY_FALLBACK_ENV_VAR,
380
+ teamId: normalizeOptionalString(corridor.teamId, null),
381
+ projectId: normalizeOptionalString(corridor.projectId, null),
382
+ severityThreshold: normalizeCorridorSeverity(
383
+ corridor.severityThreshold,
384
+ `${label}.corridor.severityThreshold`,
385
+ ),
386
+ findingStates: normalizeOptionalStringArray(
387
+ corridor.findingStates,
388
+ DEFAULT_CORRIDOR_FINDING_STATES,
389
+ ),
390
+ requiredAtClosure: normalizeOptionalBoolean(corridor.requiredAtClosure, true),
391
+ },
392
+ };
393
+ if (
394
+ normalized.corridor.enabled &&
395
+ normalized.corridor.mode === "direct" &&
396
+ (!normalized.corridor.teamId || !normalized.corridor.projectId)
397
+ ) {
398
+ throw new Error(
399
+ `${label}.corridor.teamId and ${label}.corridor.projectId are required when corridor is enabled in direct mode`,
400
+ );
401
+ }
402
+ return normalized;
403
+ }
404
+
298
405
  function normalizeExecutorBudget(rawBudget = {}, label = "budget") {
299
406
  const budget =
300
407
  rawBudget && typeof rawBudget === "object" && !Array.isArray(rawBudget) ? rawBudget : {};
@@ -578,6 +685,31 @@ function normalizeWaveControl(rawWaveControl = {}, label = "waveControl") {
578
685
  rawWaveControl && typeof rawWaveControl === "object" && !Array.isArray(rawWaveControl)
579
686
  ? rawWaveControl
580
687
  : {};
688
+ const credentials = Array.isArray(waveControl.credentials) ? waveControl.credentials : [];
689
+ const normalizedCredentials = [];
690
+ const seenCredentialEnvVars = new Set();
691
+ for (const [index, entry] of credentials.entries()) {
692
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
693
+ throw new Error(`${label}.credentials[${index}] must be an object with id and envVar.`);
694
+ }
695
+ const id = String(entry.id || "").trim().toLowerCase();
696
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) {
697
+ throw new Error(
698
+ `${label}.credentials[${index}].id must match /^[a-z0-9][a-z0-9._-]*$/.`,
699
+ );
700
+ }
701
+ const envVar = String(entry.envVar || "").trim().toUpperCase();
702
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(envVar)) {
703
+ throw new Error(
704
+ `${label}.credentials[${index}].envVar must match /^[A-Z_][A-Z0-9_]*$/.`,
705
+ );
706
+ }
707
+ if (seenCredentialEnvVars.has(envVar)) {
708
+ throw new Error(`${label}.credentials contains duplicate envVar mappings for ${envVar}.`);
709
+ }
710
+ seenCredentialEnvVars.add(envVar);
711
+ normalizedCredentials.push({ id, envVar });
712
+ }
581
713
  const reportMode = normalizeWaveControlReportMode(
582
714
  waveControl.reportMode,
583
715
  `${label}.reportMode`,
@@ -591,8 +723,36 @@ function normalizeWaveControl(rawWaveControl = {}, label = "waveControl") {
591
723
  workspaceId: normalizeOptionalString(waveControl.workspaceId, null),
592
724
  projectId: normalizeOptionalString(waveControl.projectId, null),
593
725
  authTokenEnvVar:
594
- normalizeOptionalString(waveControl.authTokenEnvVar, DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR) ||
595
- DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR,
726
+ normalizeOptionalString(
727
+ waveControl.authTokenEnvVar,
728
+ DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR,
729
+ ) || DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR,
730
+ authTokenEnvVars: Array.from(
731
+ new Set(
732
+ normalizeOptionalStringArray(
733
+ waveControl.authTokenEnvVars,
734
+ [
735
+ normalizeOptionalString(
736
+ waveControl.authTokenEnvVar,
737
+ DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR,
738
+ ) || DEFAULT_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR,
739
+ LEGACY_WAVE_CONTROL_AUTH_TOKEN_ENV_VAR,
740
+ ],
741
+ ),
742
+ ),
743
+ ),
744
+ credentialProviders: normalizeOptionalStringArray(waveControl.credentialProviders, []).map(
745
+ (providerId, index) => {
746
+ const normalized = String(providerId || "").trim().toLowerCase();
747
+ if (!WAVE_CONTROL_RUNTIME_CREDENTIAL_PROVIDERS.includes(normalized)) {
748
+ throw new Error(
749
+ `${label}.credentialProviders[${index}] must be one of: ${WAVE_CONTROL_RUNTIME_CREDENTIAL_PROVIDERS.join(", ")}`,
750
+ );
751
+ }
752
+ return normalized;
753
+ },
754
+ ),
755
+ credentials: normalizedCredentials,
596
756
  reportMode,
597
757
  uploadArtifactKinds: normalizeOptionalStringArray(
598
758
  waveControl.uploadArtifactKinds,
@@ -1109,6 +1269,7 @@ export function loadWaveConfig(configPath = DEFAULT_WAVE_CONFIG_PATH) {
1109
1269
  capabilityRouting: rawProject.capabilityRouting || {},
1110
1270
  runtimePolicy: rawProject.runtimePolicy || {},
1111
1271
  waveControl: rawProject.waveControl || {},
1272
+ externalProviders: rawProject.externalProviders || {},
1112
1273
  lanes: projectLanes,
1113
1274
  explicit: Boolean(rawProjects),
1114
1275
  },
@@ -1130,6 +1291,10 @@ export function loadWaveConfig(configPath = DEFAULT_WAVE_CONFIG_PATH) {
1130
1291
  capabilityRouting: normalizeCapabilityRouting(rawConfig.capabilityRouting),
1131
1292
  runtimePolicy: normalizeRuntimePolicy(rawConfig.runtimePolicy),
1132
1293
  waveControl: normalizeWaveControl(rawConfig.waveControl, "waveControl"),
1294
+ externalProviders: normalizeExternalProviders(
1295
+ rawConfig.externalProviders,
1296
+ "externalProviders",
1297
+ ),
1133
1298
  sharedPlanDocs,
1134
1299
  lanes: legacyLanes,
1135
1300
  projects,
@@ -1182,6 +1347,21 @@ export function resolveProjectProfile(config, projectInput = config.defaultProje
1182
1347
  ...config.runtimePolicy,
1183
1348
  ...(projectConfig.runtimePolicy || {}),
1184
1349
  }),
1350
+ externalProviders: normalizeExternalProviders(
1351
+ {
1352
+ ...config.externalProviders,
1353
+ ...(projectConfig.externalProviders || {}),
1354
+ context7: {
1355
+ ...(config.externalProviders?.context7 || {}),
1356
+ ...(projectConfig.externalProviders?.context7 || {}),
1357
+ },
1358
+ corridor: {
1359
+ ...(config.externalProviders?.corridor || {}),
1360
+ ...(projectConfig.externalProviders?.corridor || {}),
1361
+ },
1362
+ },
1363
+ `projects.${projectId}.externalProviders`,
1364
+ ),
1185
1365
  waveControl: normalizeWaveControl(
1186
1366
  {
1187
1367
  ...config.waveControl,
@@ -1256,6 +1436,21 @@ export function resolveLaneProfile(config, laneInput = config.defaultLane, proje
1256
1436
  },
1257
1437
  `${lane}.waveControl`,
1258
1438
  );
1439
+ const externalProviders = normalizeExternalProviders(
1440
+ {
1441
+ ...projectProfile.externalProviders,
1442
+ ...(laneConfig.externalProviders || {}),
1443
+ context7: {
1444
+ ...(projectProfile.externalProviders?.context7 || {}),
1445
+ ...(laneConfig.externalProviders?.context7 || {}),
1446
+ },
1447
+ corridor: {
1448
+ ...(projectProfile.externalProviders?.corridor || {}),
1449
+ ...(laneConfig.externalProviders?.corridor || {}),
1450
+ },
1451
+ },
1452
+ `${lane}.externalProviders`,
1453
+ );
1259
1454
  return {
1260
1455
  projectId: projectProfile.projectId,
1261
1456
  projectName: projectProfile.projectName,
@@ -1276,6 +1471,7 @@ export function resolveLaneProfile(config, laneInput = config.defaultLane, proje
1276
1471
  skills,
1277
1472
  capabilityRouting,
1278
1473
  runtimePolicy,
1474
+ externalProviders,
1279
1475
  waveControl,
1280
1476
  paths: {
1281
1477
  terminalsPath: normalizeRepoRelativePath(