@chllming/wave-orchestration 0.9.0 → 0.9.1

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 (54) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +119 -18
  3. package/docs/README.md +7 -3
  4. package/docs/architecture/README.md +1498 -0
  5. package/docs/concepts/operating-modes.md +2 -2
  6. package/docs/guides/author-and-run-waves.md +14 -4
  7. package/docs/guides/planner.md +2 -2
  8. package/docs/guides/{recommendations-0.9.0.md → recommendations-0.9.1.md} +8 -7
  9. package/docs/guides/sandboxed-environments.md +158 -0
  10. package/docs/guides/terminal-surfaces.md +14 -12
  11. package/docs/plans/current-state.md +5 -3
  12. package/docs/plans/end-state-architecture.md +3 -1
  13. package/docs/plans/examples/wave-example-design-handoff.md +1 -1
  14. package/docs/plans/examples/wave-example-live-proof.md +1 -1
  15. package/docs/plans/migration.md +46 -19
  16. package/docs/plans/sandbox-end-state-architecture.md +153 -0
  17. package/docs/reference/cli-reference.md +71 -7
  18. package/docs/reference/coordination-and-closure.md +1 -1
  19. package/docs/reference/github-packages-setup.md +1 -1
  20. package/docs/reference/migration-0.2-to-0.5.md +9 -7
  21. package/docs/reference/npmjs-token-publishing.md +53 -0
  22. package/docs/reference/npmjs-trusted-publishing.md +4 -50
  23. package/docs/reference/package-publishing-flow.md +272 -0
  24. package/docs/reference/runtime-config/README.md +2 -2
  25. package/docs/reference/sample-waves.md +5 -5
  26. package/docs/reference/skills.md +1 -1
  27. package/docs/roadmap.md +43 -201
  28. package/package.json +1 -1
  29. package/releases/manifest.json +19 -0
  30. package/scripts/wave-orchestrator/agent-process-runner.mjs +344 -0
  31. package/scripts/wave-orchestrator/agent-state.mjs +0 -1
  32. package/scripts/wave-orchestrator/artifact-schemas.mjs +7 -0
  33. package/scripts/wave-orchestrator/autonomous.mjs +47 -14
  34. package/scripts/wave-orchestrator/closure-engine.mjs +138 -17
  35. package/scripts/wave-orchestrator/control-cli.mjs +42 -5
  36. package/scripts/wave-orchestrator/dashboard-renderer.mjs +115 -43
  37. package/scripts/wave-orchestrator/derived-state-engine.mjs +6 -3
  38. package/scripts/wave-orchestrator/gate-engine.mjs +106 -38
  39. package/scripts/wave-orchestrator/install.mjs +13 -0
  40. package/scripts/wave-orchestrator/launcher-progress.mjs +91 -0
  41. package/scripts/wave-orchestrator/launcher-runtime.mjs +179 -68
  42. package/scripts/wave-orchestrator/launcher.mjs +201 -53
  43. package/scripts/wave-orchestrator/ledger.mjs +7 -2
  44. package/scripts/wave-orchestrator/projection-writer.mjs +13 -1
  45. package/scripts/wave-orchestrator/reducer-snapshot.mjs +6 -0
  46. package/scripts/wave-orchestrator/retry-control.mjs +3 -3
  47. package/scripts/wave-orchestrator/retry-engine.mjs +93 -6
  48. package/scripts/wave-orchestrator/role-helpers.mjs +30 -0
  49. package/scripts/wave-orchestrator/session-supervisor.mjs +94 -85
  50. package/scripts/wave-orchestrator/supervisor-cli.mjs +1306 -0
  51. package/scripts/wave-orchestrator/terminals.mjs +12 -32
  52. package/scripts/wave-orchestrator/tmux-adapter.mjs +300 -0
  53. package/scripts/wave-orchestrator/wave-files.mjs +38 -5
  54. 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,
@@ -42,11 +42,12 @@ import {
42
42
  import { readWaveRelaunchPlanSnapshot, readWaveRetryOverride, resolveRetryOverrideAgentIds, writeWaveRetryOverride, clearWaveRetryOverride } from "./retry-control.mjs";
43
43
  import { flushWaveControlQueue, readWaveControlQueueState } from "./wave-control-client.mjs";
44
44
  import { readAgentExecutionSummary, validateImplementationSummary } from "./agent-state.mjs";
45
- import { isContEvalReportOnlyAgent, isSecurityReviewAgent } from "./role-helpers.mjs";
45
+ import { isContEvalReportOnlyAgent, isSecurityReviewAgentForLane } from "./role-helpers.mjs";
46
46
  import {
47
47
  buildSignalStatusLine,
48
48
  syncWaveSignalProjections,
49
49
  } from "./signals.mjs";
50
+ import { summarizeSupervisorStateForWave } from "./supervisor-cli.mjs";
50
51
 
51
52
  function printUsage() {
52
53
  console.log(`Usage:
@@ -366,7 +367,8 @@ function buildLogicalAgents({
366
367
  proofRegistry || { entries: [] },
367
368
  );
368
369
  const proofValidation =
369
- !isSecurityReviewAgent(agent) && !isContEvalReportOnlyAgent(agent, { contEvalAgentId: lanePaths.contEvalAgentId })
370
+ !isSecurityReviewAgentForLane(agent, lanePaths) &&
371
+ !isContEvalReportOnlyAgent(agent, { contEvalAgentId: lanePaths.contEvalAgentId })
370
372
  ? validateImplementationSummary(agent, summary ? summary : null)
371
373
  : { ok: statusRecord?.code === 0, statusCode: statusRecord?.code === 0 ? "pass" : "pending" };
372
374
  const targetedTasks = tasks.filter(
@@ -385,7 +387,7 @@ function buildLogicalAgents({
385
387
  const satisfiedByStatus =
386
388
  statusRecord?.code === 0 &&
387
389
  (proofValidation.ok ||
388
- isSecurityReviewAgent(agent) ||
390
+ isSecurityReviewAgentForLane(agent, lanePaths) ||
389
391
  isContEvalReportOnlyAgent(agent, { contEvalAgentId: lanePaths.contEvalAgentId }));
390
392
  let state = "planned";
391
393
  let reason = "";
@@ -404,7 +406,7 @@ function buildLogicalAgents({
404
406
  lanePaths.integrationAgentId || "A8",
405
407
  lanePaths.documentationAgentId || "A9",
406
408
  lanePaths.contQaAgentId || "A0",
407
- ].includes(agent.agentId) || isSecurityReviewAgent(agent)
409
+ ].includes(agent.agentId) || isSecurityReviewAgentForLane(agent, lanePaths)
408
410
  ? "closed"
409
411
  : "satisfied";
410
412
  reason = "Completed wave preserves the latest satisfied agent state.";
@@ -425,7 +427,7 @@ function buildLogicalAgents({
425
427
  lanePaths.integrationAgentId || "A8",
426
428
  lanePaths.documentationAgentId || "A9",
427
429
  lanePaths.contQaAgentId || "A0",
428
- ].includes(agent.agentId) || isSecurityReviewAgent(agent)
430
+ ].includes(agent.agentId) || isSecurityReviewAgentForLane(agent, lanePaths)
429
431
  ? "closed"
430
432
  : "satisfied";
431
433
  reason = "Latest attempt satisfied current control-plane state.";
@@ -657,6 +659,12 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
657
659
  const controlState = readWaveControlPlaneState(lanePaths, wave.wave);
658
660
  const proofRegistry = readWaveProofRegistry(lanePaths, wave.wave) || { entries: [] };
659
661
  const relaunchPlan = readWaveRelaunchPlanSnapshot(lanePaths, wave.wave);
662
+ const supervisor = summarizeSupervisorStateForWave(lanePaths, wave.wave, {
663
+ agentId,
664
+ });
665
+ const forwardedClosureGaps = Array.isArray(relaunchPlan?.forwardedClosureGaps)
666
+ ? relaunchPlan.forwardedClosureGaps
667
+ : [];
660
668
  const rerunRequest = controlState.activeRerunRequest
661
669
  ? {
662
670
  ...controlState.activeRerunRequest,
@@ -718,6 +726,8 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
718
726
  selectionSource: selection.source,
719
727
  rerunRequest,
720
728
  relaunchPlan,
729
+ forwardedClosureGaps,
730
+ supervisor,
721
731
  nextTimer: isCompletedPhase(phase) ? null : nextTaskDeadline(tasks),
722
732
  activeAttempt: controlState.activeAttempt,
723
733
  };
@@ -758,6 +768,33 @@ function printStatus(payload) {
758
768
  console.log(buildSignalStatusLine(payload.signals.wave, payload));
759
769
  }
760
770
  console.log(`blocking=${blocking}`);
771
+ if (payload.supervisor) {
772
+ console.log(
773
+ `supervisor=${payload.supervisor.terminalDisposition || payload.supervisor.status} run_id=${payload.supervisor.runId} launcher_pid=${payload.supervisor.launcherPid || "none"}`,
774
+ );
775
+ if (payload.supervisor.sessionBackend || payload.supervisor.recoveryState || payload.supervisor.resumeAction) {
776
+ console.log(
777
+ `supervisor-backend=${payload.supervisor.sessionBackend || "unknown"} recovery=${payload.supervisor.recoveryState || "unknown"} resume=${payload.supervisor.resumeAction || "none"}`,
778
+ );
779
+ }
780
+ if ((payload.supervisor.agentRuntimeSummary || []).length > 0) {
781
+ console.log("supervisor-runtime:");
782
+ for (const record of payload.supervisor.agentRuntimeSummary) {
783
+ console.log(
784
+ `- ${record.agentId || "unknown"} ${record.terminalDisposition || "unknown"} pid=${record.pid || "none"} backend=${record.sessionBackend || "process"} attach=${record.attachMode || "log-tail"} heartbeat=${record.lastHeartbeatAt || "n/a"}`,
785
+ );
786
+ }
787
+ }
788
+ }
789
+ if ((payload.forwardedClosureGaps || []).length > 0) {
790
+ console.log("forwarded-closure-gaps:");
791
+ for (const gap of payload.forwardedClosureGaps) {
792
+ const targets = Array.isArray(gap.targets) && gap.targets.length > 0 ? gap.targets.join(",") : "none";
793
+ console.log(
794
+ `- ${gap.stageKey} agent=${gap.agentId || "unknown"} attempt=${gap.attempt ?? "n/a"} targets=${targets}${gap.detail ? ` detail=${gap.detail}` : ""}`,
795
+ );
796
+ }
797
+ }
761
798
  if (payload.nextTimer) {
762
799
  console.log(`next-timer=${payload.nextTimer.kind} ${payload.nextTimer.taskId} at ${payload.nextTimer.at}`);
763
800
  }
@@ -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,
@@ -214,7 +214,9 @@ export function buildWaveSecuritySummary({
214
214
  summariesByAgentId = {},
215
215
  }) {
216
216
  const createdAt = toIsoTimestamp();
217
- const securityAgents = (wave.agents || []).filter((agent) => isSecurityReviewAgent(agent));
217
+ const securityAgents = (wave.agents || []).filter((agent) =>
218
+ isSecurityReviewAgentForLane(agent, lanePaths),
219
+ );
218
220
  if (securityAgents.length === 0) {
219
221
  return {
220
222
  wave: wave.wave,
@@ -377,7 +379,7 @@ function buildIntegrationEvidence({
377
379
  isContEvalImplementationOwningAgent(agent, {
378
380
  contEvalAgentId: roleBindings.contEvalAgentId,
379
381
  });
380
- if (isSecurityReviewAgent(agent)) {
382
+ if (isSecurityReviewAgentForLane(agent, lanePaths)) {
381
383
  continue;
382
384
  }
383
385
  if (agent.agentId === roleBindings.contEvalAgentId) {
@@ -710,6 +712,7 @@ export function buildWaveDerivedState({
710
712
  benchmarkCatalogPath: lanePaths.laneProfile?.paths?.benchmarkCatalogPath,
711
713
  capabilityAssignments,
712
714
  dependencySnapshot,
715
+ securityRolePromptPath: lanePaths.securityRolePromptPath,
713
716
  });
714
717
  const inboxDir = waveInboxDir(lanePaths, wave.wave);
715
718
  const sharedSummary = compileSharedSummary({