@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
@@ -0,0 +1,363 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ DEFAULT_CORRIDOR_BASE_URL,
5
+ DEFAULT_CORRIDOR_SEVERITY_THRESHOLD,
6
+ DEFAULT_WAVE_CONTROL_ENDPOINT,
7
+ } from "./config.mjs";
8
+ import { writeJsonAtomic, readJsonOrNull } from "./shared.mjs";
9
+ import {
10
+ isDefaultWaveControlEndpoint,
11
+ readJsonResponse,
12
+ resolveWaveControlAuthToken,
13
+ } from "./provider-runtime.mjs";
14
+ import {
15
+ isContEvalImplementationOwningAgent,
16
+ isDesignAgent,
17
+ isSecurityReviewAgent,
18
+ } from "./role-helpers.mjs";
19
+
20
+ const SEVERITY_RANK = {
21
+ low: 1,
22
+ medium: 2,
23
+ high: 3,
24
+ critical: 4,
25
+ };
26
+
27
+ function normalizeOwnedPath(value) {
28
+ return String(value || "").trim().replaceAll("\\", "/").replace(/\/+$/, "");
29
+ }
30
+
31
+ function isRelevantOwnedPath(value) {
32
+ const normalized = normalizeOwnedPath(value);
33
+ if (!normalized || normalized.startsWith(".tmp/")) {
34
+ return false;
35
+ }
36
+ if (normalized.startsWith("docs/")) {
37
+ return false;
38
+ }
39
+ return !/\.(?:md|txt)$/i.test(normalized);
40
+ }
41
+
42
+ function matchesOwnedPath(findingPath, ownedPath) {
43
+ const normalizedFinding = normalizeOwnedPath(findingPath);
44
+ const normalizedOwned = normalizeOwnedPath(ownedPath);
45
+ if (!normalizedFinding || !normalizedOwned) {
46
+ return false;
47
+ }
48
+ return normalizedFinding === normalizedOwned || normalizedFinding.startsWith(`${normalizedOwned}/`);
49
+ }
50
+
51
+ function shouldIncludeImplementationOwnedPaths(agent, lanePaths = {}) {
52
+ if (!agent || isSecurityReviewAgent(agent) || isDesignAgent(agent)) {
53
+ return false;
54
+ }
55
+ if (agent.agentId === lanePaths.contQaAgentId || agent.agentId === lanePaths.documentationAgentId) {
56
+ return false;
57
+ }
58
+ if (agent.agentId === lanePaths.integrationAgentId) {
59
+ return false;
60
+ }
61
+ if (agent.agentId === lanePaths.contEvalAgentId) {
62
+ return isContEvalImplementationOwningAgent(agent, {
63
+ contEvalAgentId: lanePaths.contEvalAgentId,
64
+ });
65
+ }
66
+ return true;
67
+ }
68
+
69
+ export function waveCorridorContextPath(lanePaths, waveNumber) {
70
+ return path.join(lanePaths.securityDir, `wave-${waveNumber}-corridor.json`);
71
+ }
72
+
73
+ export function readWaveCorridorContext(lanePaths, waveNumber) {
74
+ return readJsonOrNull(waveCorridorContextPath(lanePaths, waveNumber));
75
+ }
76
+
77
+ function corridorArtifactBase({ lanePaths, wave, ownedPaths, providerMode, source }) {
78
+ return {
79
+ schemaVersion: 1,
80
+ wave,
81
+ lane: lanePaths.lane,
82
+ projectId: lanePaths.project,
83
+ providerMode,
84
+ source,
85
+ requiredAtClosure: lanePaths.externalProviders?.corridor?.requiredAtClosure !== false,
86
+ severityThreshold:
87
+ lanePaths.externalProviders?.corridor?.severityThreshold || DEFAULT_CORRIDOR_SEVERITY_THRESHOLD,
88
+ fetchedAt: new Date().toISOString(),
89
+ relevantOwnedPaths: ownedPaths,
90
+ };
91
+ }
92
+
93
+ function summarizeCorridorPayload(base, guardrails, findings) {
94
+ const thresholdRank = SEVERITY_RANK[String(base.severityThreshold || "critical").toLowerCase()] || 4;
95
+ const matchedFindings = (Array.isArray(findings) ? findings : [])
96
+ .map((finding) => {
97
+ const matchedOwnedPaths = base.relevantOwnedPaths.filter((ownedPath) =>
98
+ matchesOwnedPath(finding.affectedFile, ownedPath),
99
+ );
100
+ if (matchedOwnedPaths.length === 0) {
101
+ return null;
102
+ }
103
+ return {
104
+ id: finding.id || null,
105
+ title: finding.title || null,
106
+ affectedFile: finding.affectedFile || null,
107
+ cwe: finding.cwe || null,
108
+ severity: finding.severity || null,
109
+ state: finding.state || null,
110
+ createdAt: finding.createdAt || null,
111
+ matchedOwnedPaths,
112
+ };
113
+ })
114
+ .filter(Boolean);
115
+ const blockingFindings = matchedFindings.filter((finding) => {
116
+ const rank = SEVERITY_RANK[String(finding.severity || "").toLowerCase()] || 0;
117
+ return rank >= thresholdRank;
118
+ });
119
+ return {
120
+ ...base,
121
+ ok: true,
122
+ guardrails: Array.isArray(guardrails?.reports) ? guardrails.reports : [],
123
+ matchedFindings,
124
+ blockingFindings,
125
+ blocking: blockingFindings.length > 0,
126
+ error: null,
127
+ };
128
+ }
129
+
130
+ function failureCorridorPayload(base, error) {
131
+ return {
132
+ ...base,
133
+ ok: false,
134
+ guardrails: [],
135
+ matchedFindings: [],
136
+ blockingFindings: [],
137
+ blocking: base.requiredAtClosure === true,
138
+ error: error instanceof Error ? error.message : String(error),
139
+ };
140
+ }
141
+
142
+ async function requestCorridorJson(fetchImpl, url, token) {
143
+ const response = await fetchImpl(url, {
144
+ method: "GET",
145
+ headers: {
146
+ authorization: `Bearer ${token}`,
147
+ accept: "application/json",
148
+ },
149
+ signal: AbortSignal.timeout(10000),
150
+ });
151
+ if (!response.ok) {
152
+ const payload = await readJsonResponse(response, null);
153
+ throw new Error(
154
+ `Corridor request failed (${response.status}): ${payload?.error || payload?.message || response.statusText || "unknown error"}`,
155
+ );
156
+ }
157
+ return response.json();
158
+ }
159
+
160
+ async function listCorridorFindings(fetchImpl, baseUrl, projectId, token, findingStates) {
161
+ const findings = [];
162
+ const states = findingStates.size > 0 ? [...findingStates] : [null];
163
+ for (const state of states) {
164
+ let nextUrl = new URL(`${baseUrl}/projects/${projectId}/findings`);
165
+ if (state) {
166
+ nextUrl.searchParams.set("state", state);
167
+ }
168
+ let pages = 0;
169
+ while (nextUrl && pages < 10) {
170
+ const payload = await requestCorridorJson(fetchImpl, nextUrl, token);
171
+ if (Array.isArray(payload)) {
172
+ findings.push(...payload);
173
+ break;
174
+ }
175
+ const items = Array.isArray(payload?.items)
176
+ ? payload.items
177
+ : Array.isArray(payload?.findings)
178
+ ? payload.findings
179
+ : Array.isArray(payload?.data)
180
+ ? payload.data
181
+ : [];
182
+ findings.push(...items);
183
+ if (payload?.nextPageUrl) {
184
+ nextUrl = new URL(payload.nextPageUrl);
185
+ } else if (payload?.nextCursor) {
186
+ nextUrl = new URL(`${baseUrl}/projects/${projectId}/findings`);
187
+ if (state) {
188
+ nextUrl.searchParams.set("state", state);
189
+ }
190
+ nextUrl.searchParams.set("cursor", String(payload.nextCursor));
191
+ } else if (payload?.page && payload?.totalPages && Number(payload.page) < Number(payload.totalPages)) {
192
+ nextUrl = new URL(`${baseUrl}/projects/${projectId}/findings`);
193
+ if (state) {
194
+ nextUrl.searchParams.set("state", state);
195
+ }
196
+ nextUrl.searchParams.set("page", String(Number(payload.page) + 1));
197
+ } else {
198
+ nextUrl = null;
199
+ }
200
+ pages += 1;
201
+ }
202
+ }
203
+ return findings;
204
+ }
205
+
206
+ async function fetchCorridorDirect(fetchImpl, lanePaths, ownedPaths) {
207
+ const corridor = lanePaths.externalProviders?.corridor || {};
208
+ const token =
209
+ process.env[corridor.apiTokenEnvVar || "CORRIDOR_API_TOKEN"] ||
210
+ process.env[corridor.apiKeyFallbackEnvVar || "CORRIDOR_API_KEY"] ||
211
+ "";
212
+ if (!token) {
213
+ throw new Error(
214
+ `Corridor token is missing; set ${corridor.apiTokenEnvVar || "CORRIDOR_API_TOKEN"} or ${corridor.apiKeyFallbackEnvVar || "CORRIDOR_API_KEY"}.`,
215
+ );
216
+ }
217
+ const baseUrl = String(corridor.baseUrl || DEFAULT_CORRIDOR_BASE_URL).replace(/\/$/, "");
218
+ const findingStates = new Set((corridor.findingStates || []).map((state) => String(state).trim().toLowerCase()));
219
+ const [guardrails, findings] = await Promise.all([
220
+ requestCorridorJson(fetchImpl, `${baseUrl}/projects/${corridor.projectId}/reports`, token),
221
+ listCorridorFindings(fetchImpl, baseUrl, corridor.projectId, token, findingStates),
222
+ ]);
223
+ const filteredFindings = (Array.isArray(findings) ? findings : []).filter((finding) =>
224
+ findingStates.size === 0 || findingStates.has(String(finding.state || "").trim().toLowerCase()),
225
+ );
226
+ return summarizeCorridorPayload(
227
+ corridorArtifactBase({
228
+ lanePaths,
229
+ wave: null,
230
+ ownedPaths,
231
+ providerMode: "direct",
232
+ source: "corridor-api",
233
+ }),
234
+ guardrails,
235
+ filteredFindings,
236
+ );
237
+ }
238
+
239
+ async function fetchCorridorBroker(fetchImpl, lanePaths, waveNumber, ownedPaths) {
240
+ const waveControl = lanePaths.waveControl || {};
241
+ const endpoint = String(waveControl.endpoint || DEFAULT_WAVE_CONTROL_ENDPOINT).trim();
242
+ if (!endpoint || isDefaultWaveControlEndpoint(endpoint)) {
243
+ throw new Error("Corridor broker mode requires an owned Wave Control endpoint.");
244
+ }
245
+ const authToken = resolveWaveControlAuthToken(waveControl);
246
+ if (!authToken) {
247
+ throw new Error("WAVE_API_TOKEN is not set; Corridor broker mode is unavailable.");
248
+ }
249
+ const response = await fetchImpl(`${endpoint.replace(/\/$/, "")}/providers/corridor/context`, {
250
+ method: "POST",
251
+ headers: {
252
+ authorization: `Bearer ${authToken}`,
253
+ "content-type": "application/json",
254
+ accept: "application/json",
255
+ },
256
+ body: JSON.stringify({
257
+ projectId: lanePaths.project,
258
+ wave: waveNumber,
259
+ ownedPaths,
260
+ severityThreshold:
261
+ lanePaths.externalProviders?.corridor?.severityThreshold || DEFAULT_CORRIDOR_SEVERITY_THRESHOLD,
262
+ findingStates: lanePaths.externalProviders?.corridor?.findingStates || [],
263
+ }),
264
+ });
265
+ if (!response.ok) {
266
+ const payload = await readJsonResponse(response, null);
267
+ throw new Error(
268
+ `Corridor broker request failed (${response.status}): ${payload?.error || payload?.message || response.statusText || "unknown error"}`,
269
+ );
270
+ }
271
+ return response.json();
272
+ }
273
+
274
+ export async function materializeWaveCorridorContext(
275
+ lanePaths,
276
+ waveDefinition,
277
+ {
278
+ fetchImpl = globalThis.fetch,
279
+ } = {},
280
+ ) {
281
+ const corridor = lanePaths.externalProviders?.corridor || {};
282
+ const waveNumber = waveDefinition?.wave ?? 0;
283
+ const artifactPath = waveCorridorContextPath(lanePaths, waveNumber);
284
+ if (!corridor.enabled) {
285
+ return null;
286
+ }
287
+ const ownedPaths = (Array.isArray(waveDefinition?.agents) ? waveDefinition.agents : [])
288
+ .filter((agent) => shouldIncludeImplementationOwnedPaths(agent, lanePaths))
289
+ .flatMap((agent) => (Array.isArray(agent.ownedPaths) ? agent.ownedPaths : []))
290
+ .map(normalizeOwnedPath)
291
+ .filter(isRelevantOwnedPath);
292
+ const base = corridorArtifactBase({
293
+ lanePaths,
294
+ wave: waveNumber,
295
+ ownedPaths,
296
+ providerMode: corridor.mode || "direct",
297
+ source: null,
298
+ });
299
+ if (ownedPaths.length === 0) {
300
+ const payload = {
301
+ ...base,
302
+ ok: true,
303
+ guardrails: [],
304
+ matchedFindings: [],
305
+ blockingFindings: [],
306
+ blocking: false,
307
+ error: null,
308
+ detail: "No implementation-owned paths were eligible for Corridor matching in this wave.",
309
+ };
310
+ writeJsonAtomic(artifactPath, payload);
311
+ return payload;
312
+ }
313
+ try {
314
+ let payload;
315
+ if (corridor.mode === "broker") {
316
+ payload = await fetchCorridorBroker(fetchImpl, lanePaths, waveNumber, ownedPaths);
317
+ } else if (corridor.mode === "hybrid") {
318
+ try {
319
+ payload = await fetchCorridorBroker(fetchImpl, lanePaths, waveNumber, ownedPaths);
320
+ } catch {
321
+ payload = await fetchCorridorDirect(fetchImpl, lanePaths, ownedPaths);
322
+ }
323
+ } else {
324
+ payload = await fetchCorridorDirect(fetchImpl, lanePaths, ownedPaths);
325
+ }
326
+ const mergedPayload = {
327
+ ...base,
328
+ ...payload,
329
+ wave: waveNumber,
330
+ lane: lanePaths.lane,
331
+ projectId: lanePaths.project,
332
+ relevantOwnedPaths: ownedPaths,
333
+ requiredAtClosure: corridor.requiredAtClosure !== false,
334
+ };
335
+ writeJsonAtomic(artifactPath, mergedPayload);
336
+ return mergedPayload;
337
+ } catch (error) {
338
+ const payload = failureCorridorPayload(base, error);
339
+ writeJsonAtomic(artifactPath, payload);
340
+ return payload;
341
+ }
342
+ }
343
+
344
+ export function renderCorridorPromptContext(corridorContext) {
345
+ if (!corridorContext || corridorContext.ok !== true) {
346
+ if (corridorContext?.error) {
347
+ return `Corridor provider fetch failed: ${corridorContext.error}`;
348
+ }
349
+ return "";
350
+ }
351
+ const lines = [
352
+ `Corridor source: ${corridorContext.source || corridorContext.providerMode || "unknown"}`,
353
+ `Corridor blocking: ${corridorContext.blocking ? "yes" : "no"}`,
354
+ `Corridor threshold: ${corridorContext.severityThreshold || DEFAULT_CORRIDOR_SEVERITY_THRESHOLD}`,
355
+ `Corridor matched findings: ${(corridorContext.matchedFindings || []).length}`,
356
+ ];
357
+ for (const finding of (corridorContext.blockingFindings || []).slice(0, 5)) {
358
+ lines.push(
359
+ `- ${finding.severity || "unknown"} ${finding.affectedFile || "unknown-file"}: ${finding.title || "finding"}`
360
+ );
361
+ }
362
+ return lines.join("\n");
363
+ }
@@ -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 { loadWaveConfig } from "./config.mjs";
@@ -14,6 +13,7 @@ import {
14
13
  formatAgeFromTimestamp,
15
14
  formatElapsed,
16
15
  pad,
16
+ readJsonOrNull,
17
17
  sleep,
18
18
  truncate,
19
19
  } from "./shared.mjs";
@@ -21,6 +21,10 @@ import {
21
21
  createCurrentWaveDashboardTerminalEntry,
22
22
  createGlobalDashboardTerminalEntry,
23
23
  } from "./terminals.mjs";
24
+ import {
25
+ attachSession as attachTmuxSession,
26
+ hasSession as hasTmuxSession,
27
+ } from "./tmux-adapter.mjs";
24
28
 
25
29
  const DASHBOARD_ATTACH_TARGETS = ["current", "global"];
26
30
 
@@ -78,30 +82,7 @@ export function parseDashboardArgs(argv) {
78
82
  return { help: false, options };
79
83
  }
80
84
 
81
- function tmuxSessionExists(socketName, sessionName) {
82
- const result = spawnSync("tmux", ["-L", socketName, "has-session", "-t", sessionName], {
83
- cwd: REPO_ROOT,
84
- encoding: "utf8",
85
- env: { ...process.env, TMUX: "" },
86
- });
87
- if (result.error) {
88
- throw new Error(`tmux session lookup failed: ${result.error.message}`);
89
- }
90
- if (result.status === 0) {
91
- return true;
92
- }
93
- const combined = `${String(result.stderr || "").toLowerCase()}\n${String(result.stdout || "").toLowerCase()}`;
94
- if (
95
- combined.includes("can't find session") ||
96
- combined.includes("no server running") ||
97
- combined.includes("error connecting")
98
- ) {
99
- return false;
100
- }
101
- throw new Error((result.stderr || result.stdout || "tmux has-session failed").trim());
102
- }
103
-
104
- function attachDashboardSession(project, lane, target) {
85
+ async function attachDashboardSession(project, lane, target) {
105
86
  const config = loadWaveConfig();
106
87
  const lanePaths = buildLanePaths(lane, {
107
88
  config,
@@ -111,25 +92,112 @@ function attachDashboardSession(project, lane, target) {
111
92
  target === "global"
112
93
  ? createGlobalDashboardTerminalEntry(lanePaths, "current")
113
94
  : createCurrentWaveDashboardTerminalEntry(lanePaths);
114
- if (!tmuxSessionExists(lanePaths.tmuxSocketName, entry.sessionName)) {
115
- const dashboardsRel = path.relative(REPO_ROOT, path.dirname(lanePaths.globalDashboardPath));
116
- throw new Error(
117
- `No ${target} dashboard session is live for lane ${lanePaths.lane}. Launch a dashboarded run on that lane, then inspect ${dashboardsRel} if you need the last written dashboard state.`,
118
- );
95
+ if (!await hasTmuxSession(lanePaths.tmuxSocketName, entry.sessionName, { allowMissingBinary: false })) {
96
+ const fallback = resolveDashboardAttachFallback(lanePaths, target);
97
+ if (fallback) {
98
+ return fallback;
99
+ }
100
+ throw new Error(buildMissingDashboardAttachError(lanePaths, target));
101
+ }
102
+ try {
103
+ await attachTmuxSession(lanePaths.tmuxSocketName, entry.sessionName);
104
+ return null;
105
+ } catch (error) {
106
+ if (error?.tmuxMissingSession) {
107
+ const fallback = resolveDashboardAttachFallback(lanePaths, target);
108
+ if (fallback) {
109
+ return fallback;
110
+ }
111
+ throw new Error(buildMissingDashboardAttachError(lanePaths, target));
112
+ }
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ function buildMissingDashboardAttachError(lanePaths, target) {
118
+ const dashboardsRel = path.relative(REPO_ROOT, path.dirname(lanePaths.globalDashboardPath));
119
+ return `No ${target} dashboard session is live for lane ${lanePaths.lane}. Launch a dashboarded run on that lane, then inspect ${dashboardsRel} if you need the last written dashboard state.`;
120
+ }
121
+
122
+ function waveDashboardPathForNumber(lanePaths, waveNumber) {
123
+ if (!Number.isFinite(Number(waveNumber))) {
124
+ return null;
119
125
  }
120
- const result = spawnSync("tmux", ["-L", lanePaths.tmuxSocketName, "attach", "-t", entry.sessionName], {
121
- cwd: REPO_ROOT,
122
- stdio: "inherit",
123
- env: { ...process.env, TMUX: "" },
126
+ const candidate = path.join(lanePaths.dashboardsDir, `wave-${Number(waveNumber)}.json`);
127
+ return fs.existsSync(candidate) ? candidate : null;
128
+ }
129
+
130
+ function selectCurrentWaveFromGlobalDashboard(globalState) {
131
+ const waves = Array.isArray(globalState?.waves) ? globalState.waves : [];
132
+ const candidates = waves
133
+ .map((wave) => ({
134
+ waveNumber: Number.parseInt(String(wave?.wave ?? ""), 10),
135
+ status: String(wave?.status || "").trim().toLowerCase(),
136
+ updatedAt: Date.parse(
137
+ String(wave?.updatedAt || wave?.completedAt || wave?.startedAt || ""),
138
+ ),
139
+ }))
140
+ .filter((entry) => Number.isFinite(entry.waveNumber));
141
+ if (candidates.length === 0) {
142
+ return null;
143
+ }
144
+ candidates.sort((left, right) => {
145
+ const leftTerminal = TERMINAL_STATES.has(left.status);
146
+ const rightTerminal = TERMINAL_STATES.has(right.status);
147
+ if (leftTerminal !== rightTerminal) {
148
+ return leftTerminal ? 1 : -1;
149
+ }
150
+ const leftUpdatedAt = Number.isFinite(left.updatedAt) ? left.updatedAt : 0;
151
+ const rightUpdatedAt = Number.isFinite(right.updatedAt) ? right.updatedAt : 0;
152
+ if (leftUpdatedAt !== rightUpdatedAt) {
153
+ return rightUpdatedAt - leftUpdatedAt;
154
+ }
155
+ return right.waveNumber - left.waveNumber;
124
156
  });
125
- if (result.error) {
126
- throw new Error(`tmux attach failed: ${result.error.message}`);
157
+ return candidates[0].waveNumber;
158
+ }
159
+
160
+ export function resolveDashboardAttachFallback(lanePaths, target) {
161
+ if (target === "global") {
162
+ return fs.existsSync(lanePaths.globalDashboardPath)
163
+ ? { dashboardFile: lanePaths.globalDashboardPath }
164
+ : null;
127
165
  }
128
- if (result.status !== 0) {
129
- throw new Error(
130
- `tmux attach exited ${result.status} for lane ${lanePaths.lane} ${target} dashboard session ${entry.sessionName}.`,
131
- );
166
+ const globalState = readJsonOrNull(lanePaths.globalDashboardPath);
167
+ const preferredWaveNumber = selectCurrentWaveFromGlobalDashboard(globalState);
168
+ const preferredWavePath = waveDashboardPathForNumber(lanePaths, preferredWaveNumber);
169
+ if (preferredWavePath) {
170
+ return { dashboardFile: preferredWavePath };
132
171
  }
172
+ if (!fs.existsSync(lanePaths.dashboardsDir)) {
173
+ return fs.existsSync(lanePaths.globalDashboardPath)
174
+ ? { dashboardFile: lanePaths.globalDashboardPath }
175
+ : null;
176
+ }
177
+ const candidates = fs.readdirSync(lanePaths.dashboardsDir, { withFileTypes: true })
178
+ .filter((entry) => entry.isFile())
179
+ .map((entry) => ({
180
+ filePath: path.join(lanePaths.dashboardsDir, entry.name),
181
+ match: entry.name.match(/^wave-(\d+)\.json$/),
182
+ }))
183
+ .filter((entry) => entry.match)
184
+ .map((entry) => ({
185
+ dashboardFile: entry.filePath,
186
+ waveNumber: Number.parseInt(entry.match[1], 10),
187
+ mtimeMs: fs.statSync(entry.filePath).mtimeMs,
188
+ }))
189
+ .sort((left, right) => {
190
+ if (left.mtimeMs !== right.mtimeMs) {
191
+ return right.mtimeMs - left.mtimeMs;
192
+ }
193
+ return right.waveNumber - left.waveNumber;
194
+ });
195
+ if (candidates.length > 0) {
196
+ return { dashboardFile: candidates[0].dashboardFile };
197
+ }
198
+ return fs.existsSync(lanePaths.globalDashboardPath)
199
+ ? { dashboardFile: lanePaths.globalDashboardPath }
200
+ : null;
133
201
  }
134
202
 
135
203
  function readMessageBoardTail(messageBoardPath, maxLines = 24) {
@@ -460,7 +528,7 @@ Options:
460
528
  --dashboard-file <path> Path to wave/global dashboard JSON
461
529
  --message-board <path> Optional message board path override
462
530
  --attach <current|global>
463
- Attach to the stable tmux-backed dashboard session for the lane
531
+ Attach to the stable dashboard session for the lane, or follow the last written dashboard file when no live session exists
464
532
  --watch Refresh continuously
465
533
  --refresh-ms <n> Refresh interval in ms (default: ${DEFAULT_REFRESH_MS})
466
534
  `);
@@ -468,8 +536,12 @@ Options:
468
536
  }
469
537
 
470
538
  if (options.attach) {
471
- attachDashboardSession(options.project, options.lane, options.attach);
472
- return;
539
+ const fallback = await attachDashboardSession(options.project, options.lane, options.attach);
540
+ if (!fallback) {
541
+ return;
542
+ }
543
+ options.dashboardFile = fallback.dashboardFile;
544
+ options.watch = true;
473
545
  }
474
546
 
475
547
  let terminalStateReachedAt = null;
@@ -26,7 +26,7 @@ import { deriveWaveLedger, readWaveLedger } from "./ledger.mjs";
26
26
  import { buildDocsQueue, readDocsQueue } from "./docs-queue.mjs";
27
27
  import { parseStructuredSignalsFromLog } from "./dashboard-state.mjs";
28
28
  import {
29
- isSecurityReviewAgent,
29
+ isSecurityReviewAgentForLane,
30
30
  resolveSecurityReviewReportPath,
31
31
  isContEvalImplementationOwningAgent,
32
32
  resolveWaveRoleBindings,
@@ -47,6 +47,10 @@ import {
47
47
  describeContext7Libraries,
48
48
  loadContext7BundleIndex,
49
49
  } from "./context7.mjs";
50
+ import {
51
+ readWaveCorridorContext,
52
+ waveCorridorContextPath,
53
+ } from "./corridor.mjs";
50
54
 
51
55
  export function waveCoordinationLogPath(lanePaths, waveNumber) {
52
56
  return path.join(lanePaths.coordinationDir, `wave-${waveNumber}.jsonl`);
@@ -212,9 +216,12 @@ export function buildWaveSecuritySummary({
212
216
  wave,
213
217
  attempt,
214
218
  summariesByAgentId = {},
219
+ corridorSummary = null,
215
220
  }) {
216
221
  const createdAt = toIsoTimestamp();
217
- const securityAgents = (wave.agents || []).filter((agent) => isSecurityReviewAgent(agent));
222
+ const securityAgents = (wave.agents || []).filter((agent) =>
223
+ isSecurityReviewAgentForLane(agent, lanePaths),
224
+ );
218
225
  if (securityAgents.length === 0) {
219
226
  return {
220
227
  wave: wave.wave,
@@ -273,7 +280,9 @@ export function buildWaveSecuritySummary({
273
280
  const totalFindings = agents.reduce((sum, entry) => sum + (entry.findings || 0), 0);
274
281
  const totalApprovals = agents.reduce((sum, entry) => sum + (entry.approvals || 0), 0);
275
282
  const detail =
276
- overallState === "blocked"
283
+ corridorSummary?.blocking
284
+ ? `Corridor matched blocking findings for implementation-owned paths.`
285
+ : overallState === "blocked"
277
286
  ? `Security review blocked by ${blockedAgentIds.join(", ")}.`
278
287
  : overallState === "pending"
279
288
  ? `Security review output is incomplete for ${pendingAgentIds.join(", ")}.`
@@ -290,6 +299,17 @@ export function buildWaveSecuritySummary({
290
299
  concernAgentIds,
291
300
  blockedAgentIds,
292
301
  detail,
302
+ corridor: corridorSummary
303
+ ? {
304
+ ok: corridorSummary.ok === true,
305
+ providerMode: corridorSummary.providerMode || null,
306
+ source: corridorSummary.source || null,
307
+ blocking: corridorSummary.blocking === true,
308
+ blockingFindings: corridorSummary.blockingFindings || [],
309
+ matchedFindings: corridorSummary.matchedFindings || [],
310
+ error: corridorSummary.error || null,
311
+ }
312
+ : null,
293
313
  agents,
294
314
  createdAt,
295
315
  updatedAt: createdAt,
@@ -377,7 +397,7 @@ function buildIntegrationEvidence({
377
397
  isContEvalImplementationOwningAgent(agent, {
378
398
  contEvalAgentId: roleBindings.contEvalAgentId,
379
399
  });
380
- if (isSecurityReviewAgent(agent)) {
400
+ if (isSecurityReviewAgentForLane(agent, lanePaths)) {
381
401
  continue;
382
402
  }
383
403
  if (agent.agentId === roleBindings.contEvalAgentId) {
@@ -490,6 +510,15 @@ function buildIntegrationEvidence({
490
510
  );
491
511
  }
492
512
  }
513
+ for (const finding of securitySummary?.corridor?.matchedFindings || []) {
514
+ securityFindingEntries.push(
515
+ summarizeGap(
516
+ "corridor",
517
+ `${finding.severity || "unknown"} ${finding.affectedFile || "unknown-file"}: ${finding.title || "finding"}`,
518
+ "Corridor matched a relevant finding.",
519
+ ),
520
+ );
521
+ }
493
522
 
494
523
  return {
495
524
  openClaims: uniqueStringEntries(openClaims),
@@ -506,6 +535,13 @@ function buildIntegrationEvidence({
506
535
  securityState: securitySummary?.overallState || "not-applicable",
507
536
  securityFindings: uniqueStringEntries(securityFindingEntries),
508
537
  securityApprovals: uniqueStringEntries(securityApprovalEntries),
538
+ corridorState: securitySummary?.corridor?.blocking
539
+ ? "blocked"
540
+ : securitySummary?.corridor?.ok === false
541
+ ? "error"
542
+ : securitySummary?.corridor
543
+ ? "clear"
544
+ : "not-configured",
509
545
  };
510
546
  }
511
547
 
@@ -681,6 +717,7 @@ export function buildWaveDerivedState({
681
717
  wave,
682
718
  attempt,
683
719
  summariesByAgentId,
720
+ corridorSummary: readWaveCorridorContext(lanePaths, wave.wave),
684
721
  });
685
722
  const integrationSummary = buildWaveIntegrationSummary({
686
723
  lanePaths,
@@ -710,6 +747,7 @@ export function buildWaveDerivedState({
710
747
  benchmarkCatalogPath: lanePaths.laneProfile?.paths?.benchmarkCatalogPath,
711
748
  capabilityAssignments,
712
749
  dependencySnapshot,
750
+ securityRolePromptPath: lanePaths.securityRolePromptPath,
713
751
  });
714
752
  const inboxDir = waveInboxDir(lanePaths, wave.wave);
715
753
  const sharedSummary = compileSharedSummary({
@@ -759,6 +797,8 @@ export function buildWaveDerivedState({
759
797
  dependencySnapshotMarkdownPath: waveDependencySnapshotMarkdownPath(lanePaths, wave.wave),
760
798
  securitySummary,
761
799
  securitySummaryPath: waveSecurityPath(lanePaths, wave.wave),
800
+ corridorSummary: readWaveCorridorContext(lanePaths, wave.wave),
801
+ corridorSummaryPath: waveCorridorContextPath(lanePaths, wave.wave),
762
802
  integrationSummary,
763
803
  integrationSummaryPath: waveIntegrationPath(lanePaths, wave.wave),
764
804
  integrationMarkdownPath: waveIntegrationMarkdownPath(lanePaths, wave.wave),