@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,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import fs from "node:fs";
3
2
  import path from "node:path";
4
3
  import {
@@ -7,10 +6,11 @@ import {
7
6
  REPO_ROOT,
8
7
  TERMINAL_COLOR,
9
8
  TERMINAL_ICON,
10
- TMUX_COMMAND_TIMEOUT_MS,
11
9
  ensureDirectory,
10
+ shellQuote,
12
11
  writeJsonAtomic,
13
12
  } from "./shared.mjs";
13
+ import { killSessionIfExists } from "./tmux-adapter.mjs";
14
14
 
15
15
  export const TERMINAL_SURFACES = ["vscode", "tmux", "none"];
16
16
 
@@ -95,13 +95,15 @@ export function createWaveAgentSessionName(lanePaths, wave, agentSlug) {
95
95
  export function createTemporaryTerminalEntries(
96
96
  lanePaths,
97
97
  wave,
98
- agents,
98
+ agentRuns,
99
99
  runTag,
100
100
  includeDashboard = false,
101
101
  ) {
102
- const agentEntries = agents.map((agent) => {
102
+ const agentEntries = (agentRuns || []).map((run) => {
103
+ const agent = run?.agent || run;
103
104
  const terminalName = `${lanePaths.terminalNamePrefix}${wave}-${agent.slug}`;
104
- const sessionName = createWaveAgentSessionName(lanePaths, wave, agent.slug);
105
+ const sessionName = run?.sessionName || createWaveAgentSessionName(lanePaths, wave, agent.slug);
106
+ const logPath = String(run?.logPath || "").trim();
105
107
  return {
106
108
  terminalName,
107
109
  sessionName,
@@ -109,7 +111,9 @@ export function createTemporaryTerminalEntries(
109
111
  name: terminalName,
110
112
  icon: TERMINAL_ICON,
111
113
  color: TERMINAL_COLOR,
112
- command: `TMUX= tmux -L ${lanePaths.tmuxSocketName} new -As ${sessionName}`,
114
+ command: logPath
115
+ ? `bash -lc "mkdir -p ${shellQuote(path.dirname(logPath))} && touch ${shellQuote(logPath)} && tail -n 200 -F ${shellQuote(logPath)}"`
116
+ : `TMUX= tmux -L ${lanePaths.tmuxSocketName} new -As ${sessionName}`,
113
117
  },
114
118
  };
115
119
  });
@@ -221,30 +225,6 @@ export function pruneOrphanLaneTemporaryTerminalEntries(
221
225
  };
222
226
  }
223
227
 
224
- export function killTmuxSessionIfExists(socketName, sessionName) {
225
- const result = spawnSync("tmux", ["-L", socketName, "kill-session", "-t", sessionName], {
226
- cwd: REPO_ROOT,
227
- encoding: "utf8",
228
- env: { ...process.env, TMUX: "" },
229
- timeout: TMUX_COMMAND_TIMEOUT_MS,
230
- });
231
- if (result.error) {
232
- if (result.error.code === "ETIMEDOUT") {
233
- throw new Error(`kill existing session ${sessionName} failed: tmux command timed out`);
234
- }
235
- throw new Error(`kill existing session ${sessionName} failed: ${result.error.message}`);
236
- }
237
- if (result.status === 0) {
238
- return;
239
- }
240
- const combined = `${String(result.stderr || "").toLowerCase()}\n${String(result.stdout || "").toLowerCase()}`;
241
- if (
242
- combined.includes("can't find session") ||
243
- combined.includes("no server running") ||
244
- combined.includes("no current target") ||
245
- combined.includes("error connecting")
246
- ) {
247
- return;
248
- }
249
- throw new Error(`kill existing session ${sessionName} failed: ${(result.stderr || "").trim()}`);
228
+ export async function killTmuxSessionIfExists(socketName, sessionName) {
229
+ await killSessionIfExists(socketName, sessionName);
250
230
  }
@@ -0,0 +1,300 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ REPO_ROOT,
4
+ TMUX_COMMAND_TIMEOUT_MS,
5
+ sleep,
6
+ } from "./shared.mjs";
7
+
8
+ const RETRYABLE_TMUX_ERROR_CODES = new Set(["EAGAIN", "EMFILE", "ENFILE"]);
9
+ const MISSING_SESSION_MARKERS = [
10
+ "can't find session",
11
+ "no current target",
12
+ ];
13
+ const NO_SERVER_MARKERS = [
14
+ "no server running",
15
+ "failed to connect",
16
+ "error connecting",
17
+ ];
18
+ const DEFAULT_TMUX_RETRY_ATTEMPTS = 4;
19
+
20
+ function tmuxCombinedOutput(stdout = "", stderr = "") {
21
+ return `${String(stderr || "").toLowerCase()}\n${String(stdout || "").toLowerCase()}`;
22
+ }
23
+
24
+ function classifyTmuxOutput(stdout = "", stderr = "") {
25
+ const combined = tmuxCombinedOutput(stdout, stderr);
26
+ return {
27
+ combined,
28
+ missingSession: MISSING_SESSION_MARKERS.some((marker) => combined.includes(marker)),
29
+ noServer: NO_SERVER_MARKERS.some((marker) => combined.includes(marker)),
30
+ };
31
+ }
32
+
33
+ function buildTmuxError(message, details = {}) {
34
+ const error = new Error(message);
35
+ Object.assign(error, details);
36
+ return error;
37
+ }
38
+
39
+ function retryDelayMs(attemptIndex) {
40
+ const baseDelay = Math.min(250, 25 * (2 ** attemptIndex));
41
+ return baseDelay + Math.floor(Math.random() * 25);
42
+ }
43
+
44
+ async function defaultSpawnTmux(socketName, args, { stdio = "pipe", timeoutMs = TMUX_COMMAND_TIMEOUT_MS } = {}) {
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn("tmux", ["-L", socketName, ...args], {
47
+ cwd: REPO_ROOT,
48
+ env: { ...process.env, TMUX: "" },
49
+ stdio,
50
+ });
51
+ let stdout = "";
52
+ let stderr = "";
53
+ let timedOut = false;
54
+ if (child.stdout) {
55
+ child.stdout.on("data", (chunk) => {
56
+ stdout += chunk.toString();
57
+ });
58
+ }
59
+ if (child.stderr) {
60
+ child.stderr.on("data", (chunk) => {
61
+ stderr += chunk.toString();
62
+ });
63
+ }
64
+ const timer = setTimeout(() => {
65
+ timedOut = true;
66
+ child.kill("SIGKILL");
67
+ }, timeoutMs);
68
+ child.once("error", (error) => {
69
+ clearTimeout(timer);
70
+ reject(
71
+ buildTmuxError(`tmux process failed: ${error.message}`, {
72
+ code: error?.code || null,
73
+ stdout,
74
+ stderr,
75
+ }),
76
+ );
77
+ });
78
+ child.once("close", (status) => {
79
+ clearTimeout(timer);
80
+ if (timedOut) {
81
+ reject(
82
+ buildTmuxError(`tmux command timed out after ${timeoutMs}ms`, {
83
+ code: "ETIMEDOUT",
84
+ status,
85
+ stdout,
86
+ stderr,
87
+ tmuxTimedOut: true,
88
+ }),
89
+ );
90
+ return;
91
+ }
92
+ resolve({
93
+ status: typeof status === "number" ? status : 1,
94
+ stdout,
95
+ stderr,
96
+ });
97
+ });
98
+ });
99
+ }
100
+
101
+ export function createTmuxAdapter({
102
+ spawnTmuxFn = defaultSpawnTmux,
103
+ sleepFn = sleep,
104
+ retryAttempts = DEFAULT_TMUX_RETRY_ATTEMPTS,
105
+ } = {}) {
106
+ let mutationQueue = Promise.resolve();
107
+
108
+ const enqueueMutation = (callback) => {
109
+ const run = mutationQueue.then(callback, callback);
110
+ mutationQueue = run.catch(() => {});
111
+ return run;
112
+ };
113
+
114
+ const runWithRetry = async (callback, { description = "tmux command" } = {}) => {
115
+ for (let attemptIndex = 0; attemptIndex < retryAttempts; attemptIndex += 1) {
116
+ try {
117
+ return await callback();
118
+ } catch (error) {
119
+ if (!RETRYABLE_TMUX_ERROR_CODES.has(String(error?.code || "")) || attemptIndex === retryAttempts - 1) {
120
+ throw error;
121
+ }
122
+ await sleepFn(retryDelayMs(attemptIndex));
123
+ }
124
+ }
125
+ throw new Error(`${description} failed after ${retryAttempts} attempts.`);
126
+ };
127
+
128
+ const runTmuxCommand = async (
129
+ socketName,
130
+ args,
131
+ { description = "tmux command", stdio = "pipe", mutate = false } = {},
132
+ ) => {
133
+ const invoke = async () => {
134
+ try {
135
+ const result = await spawnTmuxFn(socketName, args, { stdio, timeoutMs: TMUX_COMMAND_TIMEOUT_MS });
136
+ if (result.status === 0) {
137
+ return result;
138
+ }
139
+ const classification = classifyTmuxOutput(result.stdout, result.stderr);
140
+ throw buildTmuxError(
141
+ `${description} failed: ${(result.stderr || result.stdout || "tmux command failed").trim() || "tmux command failed"}`,
142
+ {
143
+ status: result.status,
144
+ stdout: result.stdout,
145
+ stderr: result.stderr,
146
+ tmuxMissingSession: classification.missingSession,
147
+ tmuxNoServer: classification.noServer,
148
+ },
149
+ );
150
+ } catch (error) {
151
+ if (error?.tmuxTimedOut || error?.tmuxMissingSession || error?.tmuxNoServer) {
152
+ throw error;
153
+ }
154
+ if (error?.code === "ENOENT") {
155
+ throw buildTmuxError(`${description} failed: tmux is not installed or not on PATH`, {
156
+ code: "ENOENT",
157
+ tmuxMissingBinary: true,
158
+ });
159
+ }
160
+ if (error?.code === "ETIMEDOUT") {
161
+ throw buildTmuxError(
162
+ `${description} failed: tmux command timed out after ${TMUX_COMMAND_TIMEOUT_MS}ms`,
163
+ {
164
+ code: "ETIMEDOUT",
165
+ tmuxTimedOut: true,
166
+ },
167
+ );
168
+ }
169
+ if (error instanceof Error) {
170
+ throw buildTmuxError(`${description} failed: ${error.message}`, {
171
+ code: error?.code || null,
172
+ stdout: error?.stdout || "",
173
+ stderr: error?.stderr || "",
174
+ });
175
+ }
176
+ throw error;
177
+ }
178
+ };
179
+ const runner = () => runWithRetry(invoke, { description });
180
+ return mutate ? enqueueMutation(runner) : runner();
181
+ };
182
+
183
+ const listSessions = async (socketName) => {
184
+ try {
185
+ const result = await runTmuxCommand(socketName, ["list-sessions", "-F", "#{session_name}"], {
186
+ description: "list tmux sessions",
187
+ });
188
+ return String(result.stdout || "")
189
+ .split(/\r?\n/)
190
+ .map((line) => line.trim())
191
+ .filter(Boolean);
192
+ } catch (error) {
193
+ if (error?.tmuxMissingBinary || error?.tmuxNoServer) {
194
+ return [];
195
+ }
196
+ throw error;
197
+ }
198
+ };
199
+
200
+ const hasSession = async (socketName, sessionName, { allowMissingBinary = true } = {}) => {
201
+ try {
202
+ await runTmuxCommand(socketName, ["has-session", "-t", sessionName], {
203
+ description: `lookup tmux session ${sessionName}`,
204
+ });
205
+ return true;
206
+ } catch (error) {
207
+ if (error?.tmuxMissingSession || error?.tmuxNoServer) {
208
+ return false;
209
+ }
210
+ if (error?.tmuxMissingBinary && allowMissingBinary) {
211
+ return false;
212
+ }
213
+ throw error;
214
+ }
215
+ };
216
+
217
+ const killSessionIfExists = async (socketName, sessionName) => {
218
+ try {
219
+ await runTmuxCommand(socketName, ["kill-session", "-t", sessionName], {
220
+ description: `kill existing session ${sessionName}`,
221
+ mutate: true,
222
+ });
223
+ return true;
224
+ } catch (error) {
225
+ if (error?.tmuxMissingBinary || error?.tmuxMissingSession || error?.tmuxNoServer) {
226
+ return false;
227
+ }
228
+ throw error;
229
+ }
230
+ };
231
+
232
+ const createSession = async (socketName, sessionName, command, { description = `launch session ${sessionName}` } = {}) => {
233
+ await runTmuxCommand(socketName, ["new-session", "-d", "-s", sessionName, command], {
234
+ description,
235
+ mutate: true,
236
+ });
237
+ return sessionName;
238
+ };
239
+
240
+ const attachSession = async (socketName, sessionName) => {
241
+ const exists = await hasSession(socketName, sessionName, { allowMissingBinary: false });
242
+ if (!exists) {
243
+ throw buildTmuxError(`No live tmux session named ${sessionName}.`, {
244
+ tmuxMissingSession: true,
245
+ });
246
+ }
247
+ try {
248
+ await runTmuxCommand(socketName, ["attach", "-t", sessionName], {
249
+ description: `attach tmux session ${sessionName}`,
250
+ stdio: "inherit",
251
+ });
252
+ } catch (error) {
253
+ if (error?.tmuxMissingBinary) {
254
+ throw error;
255
+ }
256
+ const stillExists = await hasSession(socketName, sessionName);
257
+ if (!stillExists || error?.tmuxMissingSession || error?.tmuxNoServer) {
258
+ throw buildTmuxError(`No live tmux session named ${sessionName}.`, {
259
+ tmuxMissingSession: true,
260
+ });
261
+ }
262
+ throw error;
263
+ }
264
+ };
265
+
266
+ return {
267
+ runTmuxCommand,
268
+ createSession,
269
+ killSessionIfExists,
270
+ listSessions,
271
+ hasSession,
272
+ attachSession,
273
+ };
274
+ }
275
+
276
+ const defaultTmuxAdapter = createTmuxAdapter();
277
+
278
+ export function runTmuxCommand(socketName, args, options = {}) {
279
+ return defaultTmuxAdapter.runTmuxCommand(socketName, args, options);
280
+ }
281
+
282
+ export function createSession(socketName, sessionName, command, options = {}) {
283
+ return defaultTmuxAdapter.createSession(socketName, sessionName, command, options);
284
+ }
285
+
286
+ export function killSessionIfExists(socketName, sessionName) {
287
+ return defaultTmuxAdapter.killSessionIfExists(socketName, sessionName);
288
+ }
289
+
290
+ export function listSessions(socketName) {
291
+ return defaultTmuxAdapter.listSessions(socketName);
292
+ }
293
+
294
+ export function hasSession(socketName, sessionName, options = {}) {
295
+ return defaultTmuxAdapter.hasSession(socketName, sessionName, options);
296
+ }
297
+
298
+ export function attachSession(socketName, sessionName) {
299
+ return defaultTmuxAdapter.attachSession(socketName, sessionName);
300
+ }
@@ -798,6 +798,8 @@ export function writeTraceBundle({
798
798
  capabilityAssignments = [],
799
799
  dependencySnapshot = null,
800
800
  securitySummary = null,
801
+ corridorSummary = null,
802
+ corridorSummaryPath = null,
801
803
  integrationSummary,
802
804
  integrationMarkdownPath,
803
805
  proofRegistryPath = null,
@@ -860,6 +862,28 @@ export function writeTraceBundle({
860
862
  "json",
861
863
  true,
862
864
  );
865
+ const corridorArtifact =
866
+ corridorSummaryPath || corridorSummary
867
+ ? corridorSummaryPath
868
+ ? copyArtifactDescriptor(
869
+ dir,
870
+ corridorSummaryPath,
871
+ path.join(dir, "corridor.json"),
872
+ false,
873
+ )
874
+ : writeArtifactDescriptor(
875
+ dir,
876
+ path.join(dir, "corridor.json"),
877
+ corridorSummary || {},
878
+ "json",
879
+ false,
880
+ )
881
+ : {
882
+ path: "corridor.json",
883
+ required: false,
884
+ present: false,
885
+ sha256: null,
886
+ };
863
887
  const integrationArtifact = writeArtifactDescriptor(
864
888
  dir,
865
889
  path.join(dir, "integration.json"),
@@ -1023,6 +1047,7 @@ export function writeTraceBundle({
1023
1047
  capabilityAssignments: capabilityAssignmentsArtifact,
1024
1048
  dependencySnapshot: dependencySnapshotArtifact,
1025
1049
  security: securityArtifact,
1050
+ corridor: corridorArtifact,
1026
1051
  integration: integrationArtifact,
1027
1052
  integrationMarkdown: integrationMarkdownArtifact,
1028
1053
  proofRegistry: proofRegistryArtifact,
@@ -154,6 +154,19 @@ function resolveWaveControlConfig(lanePaths, overrides = {}) {
154
154
  };
155
155
  }
156
156
 
157
+ function resolveWaveControlAuthToken(config) {
158
+ const authVars = Array.isArray(config.authTokenEnvVars)
159
+ ? config.authTokenEnvVars
160
+ : [config.authTokenEnvVar].filter(Boolean);
161
+ for (const envVar of authVars) {
162
+ const value = envVar ? String(process.env[envVar] || "").trim() : "";
163
+ if (value) {
164
+ return value;
165
+ }
166
+ }
167
+ return "";
168
+ }
169
+
157
170
  function buildWorkspaceId(lanePaths, config) {
158
171
  return normalizeText(config.workspaceId, buildWorkspaceTmuxToken(REPO_ROOT));
159
172
  }
@@ -505,7 +518,7 @@ export async function flushWaveControlQueue(lanePaths, options = {}) {
505
518
 
506
519
  try {
507
520
  const ingestUrl = resolveIngestUrl(config.endpoint);
508
- const authToken = config.authTokenEnvVar ? process.env[config.authTokenEnvVar] || "" : "";
521
+ const authToken = resolveWaveControlAuthToken(config);
509
522
  await postBatch(
510
523
  ingestUrl,
511
524
  authToken,
@@ -68,6 +68,8 @@ import {
68
68
  resolveDesignReportPath,
69
69
  isSecurityRolePromptPath,
70
70
  isSecurityReviewAgent,
71
+ resolveAgentClosureRoleKeys,
72
+ resolveWaveRoleBindings,
71
73
  resolveSecurityReviewReportPath,
72
74
  } from "./role-helpers.mjs";
73
75
  import {
@@ -107,6 +109,13 @@ const COMPONENT_MATURITY_ORDER = Object.fromEntries(
107
109
  );
108
110
  const PROOF_CENTRIC_COMPONENT_LEVEL = "pilot-live";
109
111
  const RETRY_POLICY_VALUES = new Set(["sticky", "fallback-allowed"]);
112
+ const CLOSURE_ROLE_LABELS = {
113
+ "cont-eval": "cont-EVAL",
114
+ "security-review": "security review",
115
+ integration: "integration steward",
116
+ documentation: "documentation steward",
117
+ "cont-qa": "cont-QA",
118
+ };
110
119
 
111
120
  function resolveLaneProfileForOptions(options = {}) {
112
121
  if (options.laneProfile) {
@@ -1209,7 +1218,11 @@ function isImplementationOwningWaveAgent(
1209
1218
  );
1210
1219
  }
1211
1220
 
1212
- function resolveAgentSummaryReportPath(wave, agentId, { contQaAgentId, contEvalAgentId } = {}) {
1221
+ function resolveAgentSummaryReportPath(
1222
+ wave,
1223
+ agentId,
1224
+ { contQaAgentId, contEvalAgentId, securityRolePromptPath } = {},
1225
+ ) {
1213
1226
  if (agentId === contQaAgentId && wave.contQaReportPath) {
1214
1227
  return path.resolve(REPO_ROOT, wave.contQaReportPath);
1215
1228
  }
@@ -1223,7 +1236,7 @@ function resolveAgentSummaryReportPath(wave, agentId, { contQaAgentId, contEvalA
1223
1236
  return path.resolve(REPO_ROOT, designReportPath);
1224
1237
  }
1225
1238
  }
1226
- if (isSecurityReviewAgent(agent)) {
1239
+ if (isSecurityReviewAgent(agent, { securityRolePromptPath })) {
1227
1240
  const securityReportPath = resolveSecurityReviewReportPath(agent);
1228
1241
  if (securityReportPath) {
1229
1242
  return path.resolve(REPO_ROOT, securityReportPath);
@@ -1240,6 +1253,7 @@ function materializeLiveExecutionSummaryIfMissing({
1240
1253
  logsDir,
1241
1254
  contQaAgentId,
1242
1255
  contEvalAgentId,
1256
+ securityRolePromptPath = null,
1243
1257
  }) {
1244
1258
  const logPath = logsDir ? path.join(logsDir, `wave-${wave.wave}-${agent.slug}.log`) : null;
1245
1259
  const existing = readAgentExecutionSummary(statusPath, {
@@ -1250,6 +1264,7 @@ function materializeLiveExecutionSummaryIfMissing({
1250
1264
  reportPath: resolveAgentSummaryReportPath(wave, agent.agentId, {
1251
1265
  contQaAgentId,
1252
1266
  contEvalAgentId,
1267
+ securityRolePromptPath,
1253
1268
  }),
1254
1269
  });
1255
1270
  if (existing) {
@@ -1265,6 +1280,7 @@ function materializeLiveExecutionSummaryIfMissing({
1265
1280
  reportPath: resolveAgentSummaryReportPath(wave, agent.agentId, {
1266
1281
  contQaAgentId,
1267
1282
  contEvalAgentId,
1283
+ securityRolePromptPath,
1268
1284
  }),
1269
1285
  });
1270
1286
  writeAgentExecutionSummary(statusPath, summary);
@@ -1746,6 +1762,19 @@ export function validateWaveDefinition(wave, options = {}) {
1746
1762
  errors.push(`Design agent ${designAgent.agentId} must stay docs/spec-only unless it explicitly owns implementation files`);
1747
1763
  }
1748
1764
  }
1765
+ const closureRoleBindings = resolveWaveRoleBindings(wave, laneProfile.roles, wave.agents);
1766
+ for (const agent of wave.agents) {
1767
+ const closureRoles = resolveAgentClosureRoleKeys(
1768
+ agent,
1769
+ closureRoleBindings,
1770
+ laneProfile.roles,
1771
+ );
1772
+ if (closureRoles.length > 1) {
1773
+ errors.push(
1774
+ `Agent ${agent.agentId} must not overlap closure roles (${closureRoles.map((role) => CLOSURE_ROLE_LABELS[role] || role).join(", ")})`,
1775
+ );
1776
+ }
1777
+ }
1749
1778
  if (integrationRuleActive) {
1750
1779
  const integrationStewards = wave.agents.filter((agent) =>
1751
1780
  agent.rolePromptPaths?.includes(laneProfile.roles.integrationRolePromptPath),
@@ -2006,7 +2035,9 @@ function inferAgentRuntimeRole(agent, laneProfile) {
2006
2035
  ) {
2007
2036
  return "design";
2008
2037
  }
2009
- if (isSecurityReviewAgent(agent)) {
2038
+ if (isSecurityReviewAgent(agent, {
2039
+ securityRolePromptPath: laneProfile?.roles?.securityRolePromptPath,
2040
+ })) {
2010
2041
  return "security";
2011
2042
  }
2012
2043
  const capabilities = Array.isArray(agent?.capabilities)
@@ -2203,7 +2234,7 @@ export function resolveAgentExecutor(agent, options = {}) {
2203
2234
  profile?.codex?.sandbox ||
2204
2235
  (executorId === "codex"
2205
2236
  ? normalizeCodexSandboxMode(
2206
- options.codexSandboxMode || laneProfile.executors.codex.sandbox,
2237
+ options.codexSandboxMode ?? laneProfile.executors.codex.sandbox ?? DEFAULT_CODEX_SANDBOX_MODE,
2207
2238
  "executor.codex.sandbox",
2208
2239
  )
2209
2240
  : laneProfile.executors.codex.sandbox || DEFAULT_CODEX_SANDBOX_MODE),
@@ -2924,6 +2955,7 @@ function analyzeWaveCompletionFromStatusFiles(wave, statusDir, options = {}) {
2924
2955
  const componentThreshold =
2925
2956
  options.requireComponentPromotionsFromWave ??
2926
2957
  laneProfile.validation.requireComponentPromotionsFromWave;
2958
+ const securityRolePromptPath = resolveSecurityRolePromptPath(laneProfile);
2927
2959
 
2928
2960
  const reasons = [];
2929
2961
  const summariesByAgentId = {};
@@ -2980,6 +3012,7 @@ function analyzeWaveCompletionFromStatusFiles(wave, statusDir, options = {}) {
2980
3012
  logsDir,
2981
3013
  contQaAgentId,
2982
3014
  contEvalAgentId,
3015
+ securityRolePromptPath,
2983
3016
  });
2984
3017
  summariesByAgentId[agent.agentId] = summary;
2985
3018
  if (agent.agentId === contQaAgentId) {
@@ -3021,7 +3054,7 @@ function analyzeWaveCompletionFromStatusFiles(wave, statusDir, options = {}) {
3021
3054
  }
3022
3055
  continue;
3023
3056
  }
3024
- if (isSecurityReviewAgent(agent)) {
3057
+ if (isSecurityReviewAgent(agent, { securityRolePromptPath })) {
3025
3058
  const validation = validateSecuritySummary(agent, summary);
3026
3059
  if (!validation.ok) {
3027
3060
  pushWaveCompletionReason(
package/scripts/wave.mjs CHANGED
@@ -20,6 +20,11 @@ function printHelp() {
20
20
  wave draft [draft options]
21
21
  wave adhoc [adhoc options]
22
22
  wave launch [launcher options]
23
+ wave submit [launcher options]
24
+ wave supervise [supervisor options]
25
+ wave status --run-id <id> [--json]
26
+ wave wait --run-id <id> [--timeout-seconds <n>] [--json]
27
+ wave attach --run-id <id> (--agent <id> | --dashboard)
23
28
  wave autonomous [autonomous options]
24
29
  wave feedback [feedback options]
25
30
  wave dashboard [dashboard options]
@@ -73,6 +78,14 @@ if (["init", "upgrade", "self-update", "changelog", "doctor"].includes(subcomman
73
78
  console.error(`[wave] ${error instanceof Error ? error.message : String(error)}`);
74
79
  process.exit(Number.isInteger(error?.exitCode) ? error.exitCode : 1);
75
80
  }
81
+ } else if (["submit", "supervise", "status", "wait", "attach"].includes(subcommand)) {
82
+ try {
83
+ const { runSupervisorCli } = await import("./wave-orchestrator/supervisor-cli.mjs");
84
+ await runSupervisorCli(subcommand, rest);
85
+ } catch (error) {
86
+ console.error(`[wave] ${error instanceof Error ? error.message : String(error)}`);
87
+ process.exit(Number.isInteger(error?.exitCode) ? error.exitCode : 1);
88
+ }
76
89
  } else if (subcommand === "autonomous") {
77
90
  const { runAutonomousCli } = await import("./wave-orchestrator/autonomous.mjs");
78
91
  try {