@chllming/wave-orchestration 0.7.0 → 0.7.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 (42) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +9 -8
  3. package/docs/guides/planner.md +19 -0
  4. package/docs/guides/terminal-surfaces.md +12 -0
  5. package/docs/plans/component-cutover-matrix.json +50 -3
  6. package/docs/plans/current-state.md +1 -1
  7. package/docs/plans/end-state-architecture.md +927 -0
  8. package/docs/plans/examples/wave-example-live-proof.md +1 -1
  9. package/docs/plans/migration.md +26 -0
  10. package/docs/plans/wave-orchestrator.md +4 -7
  11. package/docs/plans/waves/wave-1.md +376 -0
  12. package/docs/plans/waves/wave-2.md +292 -0
  13. package/docs/plans/waves/wave-3.md +342 -0
  14. package/docs/plans/waves/wave-4.md +391 -0
  15. package/docs/plans/waves/wave-5.md +382 -0
  16. package/docs/plans/waves/wave-6.md +321 -0
  17. package/docs/reference/cli-reference.md +547 -0
  18. package/docs/reference/coordination-and-closure.md +1 -1
  19. package/docs/reference/npmjs-trusted-publishing.md +2 -2
  20. package/docs/reference/runtime-config/README.md +2 -2
  21. package/docs/reference/runtime-config/codex.md +2 -1
  22. package/docs/reference/sample-waves.md +4 -4
  23. package/package.json +1 -1
  24. package/releases/manifest.json +43 -2
  25. package/scripts/wave-orchestrator/agent-state.mjs +458 -35
  26. package/scripts/wave-orchestrator/artifact-schemas.mjs +81 -0
  27. package/scripts/wave-orchestrator/control-cli.mjs +119 -20
  28. package/scripts/wave-orchestrator/coordination.mjs +11 -10
  29. package/scripts/wave-orchestrator/dashboard-renderer.mjs +82 -2
  30. package/scripts/wave-orchestrator/human-input-workflow.mjs +289 -0
  31. package/scripts/wave-orchestrator/install.mjs +120 -3
  32. package/scripts/wave-orchestrator/launcher-derived-state.mjs +915 -0
  33. package/scripts/wave-orchestrator/launcher-gates.mjs +1061 -0
  34. package/scripts/wave-orchestrator/launcher-retry.mjs +873 -0
  35. package/scripts/wave-orchestrator/launcher-runtime.mjs +9 -9
  36. package/scripts/wave-orchestrator/launcher-supervisor.mjs +704 -0
  37. package/scripts/wave-orchestrator/launcher.mjs +317 -2999
  38. package/scripts/wave-orchestrator/task-entity.mjs +557 -0
  39. package/scripts/wave-orchestrator/terminals.mjs +1 -1
  40. package/scripts/wave-orchestrator/wave-files.mjs +138 -20
  41. package/scripts/wave-orchestrator/wave-state-reducer.mjs +566 -0
  42. package/wave.config.json +1 -1
@@ -403,3 +403,84 @@ export function writeWaveControlDeliveryState(filePath, payload, defaults = {})
403
403
  export function cloneArtifactPayload(value) {
404
404
  return cloneJson(value);
405
405
  }
406
+
407
+ // ── Wave 4: Surface class metadata and additional schema normalizers ──
408
+
409
+ export const WAVE_STATE_SCHEMA_VERSION = 1;
410
+ export const TASK_ENTITY_SCHEMA_VERSION = 1;
411
+ export const AGENT_RESULT_ENVELOPE_SCHEMA_VERSION = 1;
412
+ export const RESUME_PLAN_SCHEMA_VERSION = 1;
413
+ export const HUMAN_INPUT_WORKFLOW_SCHEMA_VERSION = 1;
414
+
415
+ export const SURFACE_CLASS_CANONICAL_EVENT = "canonical-event";
416
+ export const SURFACE_CLASS_CANONICAL_SNAPSHOT = "canonical-snapshot";
417
+ export const SURFACE_CLASS_CACHED_DERIVED = "cached-derived";
418
+ export const SURFACE_CLASS_HUMAN_PROJECTION = "human-projection";
419
+ export const SURFACE_CLASSES = new Set([
420
+ SURFACE_CLASS_CANONICAL_EVENT,
421
+ SURFACE_CLASS_CANONICAL_SNAPSHOT,
422
+ SURFACE_CLASS_CACHED_DERIVED,
423
+ SURFACE_CLASS_HUMAN_PROJECTION,
424
+ ]);
425
+
426
+ export const WAVE_STATE_KIND = "wave-state-snapshot";
427
+ export const TASK_ENTITY_KIND = "wave-task-entity";
428
+ export const AGENT_RESULT_ENVELOPE_KIND = "agent-result-envelope";
429
+ export const RESUME_PLAN_KIND = "wave-resume-plan";
430
+ export const HUMAN_INPUT_WORKFLOW_KIND = "human-input-workflow-state";
431
+
432
+ export function normalizeWaveStateSnapshot(payload, defaults = {}) {
433
+ const source = isPlainObject(payload) ? payload : {};
434
+ return {
435
+ schemaVersion: WAVE_STATE_SCHEMA_VERSION,
436
+ kind: WAVE_STATE_KIND,
437
+ _meta: { surfaceClass: SURFACE_CLASS_CANONICAL_SNAPSHOT },
438
+ lane: normalizeText(source.lane, normalizeText(defaults.lane, null)),
439
+ wave: normalizeInteger(source.wave, normalizeInteger(defaults.wave, null)),
440
+ ...source,
441
+ schemaVersion: WAVE_STATE_SCHEMA_VERSION,
442
+ kind: WAVE_STATE_KIND,
443
+ _meta: { surfaceClass: SURFACE_CLASS_CANONICAL_SNAPSHOT },
444
+ generatedAt: normalizeText(source.generatedAt, toIsoTimestamp()),
445
+ };
446
+ }
447
+
448
+ export function readWaveStateSnapshot(filePath, defaults = {}) {
449
+ const payload = readJsonOrNull(filePath);
450
+ if (!payload) {
451
+ return null;
452
+ }
453
+ return normalizeWaveStateSnapshot(payload, defaults);
454
+ }
455
+
456
+ export function writeWaveStateSnapshot(filePath, payload, defaults = {}) {
457
+ const normalized = normalizeWaveStateSnapshot(payload, defaults);
458
+ writeJsonAtomic(filePath, normalized);
459
+ return normalized;
460
+ }
461
+
462
+ export function normalizeAgentResultEnvelope(payload) {
463
+ const source = isPlainObject(payload) ? payload : {};
464
+ return {
465
+ schemaVersion: AGENT_RESULT_ENVELOPE_SCHEMA_VERSION,
466
+ kind: AGENT_RESULT_ENVELOPE_KIND,
467
+ _meta: { surfaceClass: SURFACE_CLASS_CANONICAL_SNAPSHOT },
468
+ ...source,
469
+ schemaVersion: AGENT_RESULT_ENVELOPE_SCHEMA_VERSION,
470
+ kind: AGENT_RESULT_ENVELOPE_KIND,
471
+ _meta: { surfaceClass: SURFACE_CLASS_CANONICAL_SNAPSHOT },
472
+ };
473
+ }
474
+
475
+ export function normalizeResumePlan(payload) {
476
+ const source = isPlainObject(payload) ? payload : {};
477
+ return {
478
+ schemaVersion: RESUME_PLAN_SCHEMA_VERSION,
479
+ kind: RESUME_PLAN_KIND,
480
+ _meta: { surfaceClass: SURFACE_CLASS_CACHED_DERIVED },
481
+ ...source,
482
+ schemaVersion: RESUME_PLAN_SCHEMA_VERSION,
483
+ kind: RESUME_PLAN_KIND,
484
+ _meta: { surfaceClass: SURFACE_CLASS_CACHED_DERIVED },
485
+ };
486
+ }
@@ -270,16 +270,60 @@ function assignmentRelevantToAgent(assignment, agentId = "") {
270
270
  );
271
271
  }
272
272
 
273
- function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabilityAssignments, rerunRequest, proofRegistry }) {
274
- const rerunSelected = new Set(rerunRequest?.selectedAgentIds || resolveRetryOverrideAgentIds(wave, lanePaths, rerunRequest));
273
+ function buildEffectiveSelection(lanePaths, wave, { activeAttempt = null, rerunRequest = null, relaunchPlan = null } = {}) {
274
+ const activeAttemptSelected = Array.isArray(activeAttempt?.selectedAgentIds)
275
+ ? Array.from(new Set(activeAttempt.selectedAgentIds.filter(Boolean)))
276
+ : [];
277
+ if (activeAttemptSelected.length > 0) {
278
+ return {
279
+ source: "active-attempt",
280
+ selectedAgentIds: activeAttemptSelected,
281
+ detail: activeAttempt?.detail || null,
282
+ };
283
+ }
284
+ const rerunSelected = rerunRequest?.selectedAgentIds?.length
285
+ ? rerunRequest.selectedAgentIds
286
+ : resolveRetryOverrideAgentIds(wave, lanePaths, rerunRequest);
287
+ if (rerunSelected.length > 0) {
288
+ return {
289
+ source: "rerun-request",
290
+ selectedAgentIds: rerunSelected,
291
+ detail: rerunRequest?.reason || null,
292
+ };
293
+ }
294
+ const relaunchSelected = Array.isArray(relaunchPlan?.selectedAgentIds)
295
+ ? Array.from(new Set(relaunchPlan.selectedAgentIds.filter(Boolean)))
296
+ : [];
297
+ if (relaunchSelected.length > 0) {
298
+ return {
299
+ source: "relaunch-plan",
300
+ selectedAgentIds: relaunchSelected,
301
+ detail: null,
302
+ };
303
+ }
304
+ return {
305
+ source: "none",
306
+ selectedAgentIds: [],
307
+ detail: null,
308
+ };
309
+ }
310
+
311
+ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabilityAssignments, selection, proofRegistry }) {
312
+ const selectedAgentIds = new Set(selection?.selectedAgentIds || []);
275
313
  const helperAssignments = Array.isArray(capabilityAssignments) ? capabilityAssignments : [];
276
314
  const openInbound = dependencySnapshot?.openInbound || [];
277
315
  return wave.agents.map((agent) => {
278
316
  const statusPath = statusPathForAgent(lanePaths, wave, agent);
279
317
  const statusRecord = readStatusRecordIfPresent(statusPath);
318
+ const logPath = path.join(lanePaths.logsDir, `wave-${wave.wave}-${agent.slug}.log`);
280
319
  const summary = augmentSummaryWithProofRegistry(
281
320
  agent,
282
- readAgentExecutionSummary(statusPath),
321
+ readAgentExecutionSummary(statusPath, {
322
+ agent,
323
+ statusPath,
324
+ statusRecord,
325
+ logPath: fs.existsSync(logPath) ? logPath : null,
326
+ }),
283
327
  proofRegistry || { entries: [] },
284
328
  );
285
329
  const proofValidation =
@@ -301,9 +345,15 @@ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabi
301
345
  const dependency = openInbound.find((record) => record.assignedAgentId === agent.agentId);
302
346
  let state = "planned";
303
347
  let reason = "";
304
- if (rerunSelected.has(agent.agentId)) {
348
+ if (selection?.source === "active-attempt" && selectedAgentIds.has(agent.agentId)) {
349
+ state = "working";
350
+ reason = selection?.detail || "Selected by the active launcher attempt.";
351
+ } else if (selectedAgentIds.has(agent.agentId)) {
305
352
  state = "needs-rerun";
306
- reason = "Selected by active rerun request.";
353
+ reason =
354
+ selection?.source === "relaunch-plan"
355
+ ? "Selected by the persisted relaunch plan."
356
+ : "Selected by active rerun request.";
307
357
  } else if (targetedBlockingTasks.some((task) => task.state === "working")) {
308
358
  state = "working";
309
359
  reason = targetedBlockingTasks.find((task) => task.state === "working")?.title || "";
@@ -334,7 +384,8 @@ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabi
334
384
  state,
335
385
  reason: reason || null,
336
386
  taskIds: targetedTasks.map((task) => task.taskId),
337
- selectedForRerun: rerunSelected.has(agent.agentId),
387
+ selectedForRerun: selectedAgentIds.has(agent.agentId) && selection?.source !== "active-attempt",
388
+ selectedForActiveAttempt: selection?.source === "active-attempt" && selectedAgentIds.has(agent.agentId),
338
389
  activeProofBundleIds: (proofRegistry?.entries || [])
339
390
  .filter(
340
391
  (entry) =>
@@ -346,10 +397,25 @@ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabi
346
397
  });
347
398
  }
348
399
 
349
- function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, rerunRequest, agentId = "" }) {
350
- const scopedTasks = agentId
400
+ function selectionTargetsAgent(agentId, selectionSet) {
401
+ return Boolean(agentId) && selectionSet.has(agentId);
402
+ }
403
+
404
+ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, activeAttempt, rerunRequest, relaunchPlan, agentId = "" }) {
405
+ const attemptSelection = new Set(activeAttempt?.selectedAgentIds || []);
406
+ const scopeToActiveAttempt = !agentId && attemptSelection.size > 0;
407
+ const scopedTasks = (agentId
351
408
  ? tasks.filter((task) => task.ownerAgentId === agentId || task.assigneeAgentId === agentId)
352
- : tasks;
409
+ : tasks
410
+ ).filter((task) => {
411
+ if (!scopeToActiveAttempt) {
412
+ return true;
413
+ }
414
+ return (
415
+ selectionTargetsAgent(task.ownerAgentId, attemptSelection) ||
416
+ selectionTargetsAgent(task.assigneeAgentId, attemptSelection)
417
+ );
418
+ });
353
419
  const pendingHuman = scopedTasks.find((task) => task.state === "input-required");
354
420
  if (pendingHuman) {
355
421
  return {
@@ -381,11 +447,19 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
381
447
  detail: clarification.title,
382
448
  };
383
449
  }
384
- const unresolvedAssignment = (capabilityAssignments || []).find(
450
+ const scopedAssignments = (capabilityAssignments || []).filter((assignment) => {
451
+ if (!scopeToActiveAttempt) {
452
+ return assignmentRelevantToAgent(assignment, agentId);
453
+ }
454
+ return (
455
+ selectionTargetsAgent(assignment.assignedAgentId, attemptSelection) ||
456
+ selectionTargetsAgent(assignment.sourceAgentId, attemptSelection)
457
+ );
458
+ });
459
+ const unresolvedAssignment = scopedAssignments.find(
385
460
  (assignment) =>
386
461
  assignment.blocking &&
387
- !assignment.assignedAgentId &&
388
- assignmentRelevantToAgent(assignment, agentId),
462
+ !assignment.assignedAgentId,
389
463
  );
390
464
  if (unresolvedAssignment) {
391
465
  return {
@@ -395,10 +469,7 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
395
469
  detail: unresolvedAssignment.assignmentDetail || unresolvedAssignment.summary || unresolvedAssignment.requestId,
396
470
  };
397
471
  }
398
- const blockingAssignment = (capabilityAssignments || []).find(
399
- (assignment) =>
400
- assignment.blocking && assignmentRelevantToAgent(assignment, agentId),
401
- );
472
+ const blockingAssignment = scopedAssignments.find((assignment) => assignment.blocking);
402
473
  if (blockingAssignment) {
403
474
  return {
404
475
  kind: "helper-assignment",
@@ -410,7 +481,18 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
410
481
  const dependency = [
411
482
  ...(dependencySnapshot?.openInbound || []),
412
483
  ...(dependencySnapshot?.openOutbound || []),
413
- ].find((record) => !agentId || record.assignedAgentId === agentId || record.agentId === agentId);
484
+ ].find((record) => {
485
+ if (agentId) {
486
+ return record.assignedAgentId === agentId || record.agentId === agentId;
487
+ }
488
+ if (!scopeToActiveAttempt) {
489
+ return true;
490
+ }
491
+ return (
492
+ selectionTargetsAgent(record.assignedAgentId, attemptSelection) ||
493
+ selectionTargetsAgent(record.agentId, attemptSelection)
494
+ );
495
+ });
414
496
  if (dependency) {
415
497
  return {
416
498
  kind: "dependency",
@@ -419,7 +501,7 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
419
501
  detail: dependency.summary || dependency.detail || dependency.id,
420
502
  };
421
503
  }
422
- if (rerunRequest) {
504
+ if (!scopeToActiveAttempt && rerunRequest) {
423
505
  return {
424
506
  kind: "rerun-request",
425
507
  id: rerunRequest.requestId || "active-rerun",
@@ -427,6 +509,14 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
427
509
  detail: rerunRequest.reason || "Active rerun request controls next attempt selection.",
428
510
  };
429
511
  }
512
+ if (!scopeToActiveAttempt && relaunchPlan) {
513
+ return {
514
+ kind: "relaunch-plan",
515
+ id: `wave-${relaunchPlan.wave ?? "unknown"}-relaunch-plan`,
516
+ agentId: null,
517
+ detail: "Persisted relaunch plan controls the next safe launcher selection.",
518
+ };
519
+ }
430
520
  const blocker = scopedTasks.find(
431
521
  (task) => task.taskType === "blocker" && ["open", "working"].includes(task.state),
432
522
  );
@@ -485,6 +575,7 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
485
575
  }).filter((task) => !agentId || task.ownerAgentId === agentId || task.assigneeAgentId === agentId);
486
576
  const controlState = readWaveControlPlaneState(lanePaths, wave.wave);
487
577
  const proofRegistry = readWaveProofRegistry(lanePaths, wave.wave) || { entries: [] };
578
+ const relaunchPlan = readWaveRelaunchPlanSnapshot(lanePaths, wave.wave);
488
579
  const rerunRequest = controlState.activeRerunRequest
489
580
  ? {
490
581
  ...controlState.activeRerunRequest,
@@ -497,6 +588,11 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
497
588
  }),
498
589
  }
499
590
  : null;
591
+ const selection = buildEffectiveSelection(lanePaths, wave, {
592
+ activeAttempt: controlState.activeAttempt,
593
+ rerunRequest,
594
+ relaunchPlan,
595
+ });
500
596
  return {
501
597
  lane: lanePaths.lane,
502
598
  wave: wave.wave,
@@ -506,7 +602,9 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
506
602
  tasks,
507
603
  capabilityAssignments,
508
604
  dependencySnapshot,
605
+ activeAttempt: controlState.activeAttempt,
509
606
  rerunRequest,
607
+ relaunchPlan,
510
608
  agentId,
511
609
  }),
512
610
  logicalAgents: buildLogicalAgents({
@@ -515,7 +613,7 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
515
613
  tasks,
516
614
  dependencySnapshot,
517
615
  capabilityAssignments,
518
- rerunRequest,
616
+ selection,
519
617
  proofRegistry,
520
618
  }).filter((agent) => !agentId || agent.agentId === agentId),
521
619
  tasks,
@@ -533,8 +631,9 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
533
631
  proofBundles: (proofRegistry?.entries || []).filter(
534
632
  (entry) => !agentId || entry.agentId === agentId,
535
633
  ),
634
+ selectionSource: selection.source,
536
635
  rerunRequest,
537
- relaunchPlan: readWaveRelaunchPlanSnapshot(lanePaths, wave.wave),
636
+ relaunchPlan,
538
637
  nextTimer: nextTaskDeadline(tasks),
539
638
  activeAttempt: controlState.activeAttempt,
540
639
  };
@@ -269,6 +269,15 @@ export function buildExecutionPrompt({
269
269
  "- Use `clear` only when no unresolved findings or approvals remain. Use `blocked` only when the wave must stop before integration.",
270
270
  ]
271
271
  : [];
272
+ const coordinationCommand = [
273
+ "pnpm exec wave coord post",
274
+ `--lane ${lane}`,
275
+ `--wave ${wave}`,
276
+ `--agent ${agent.agentId}`,
277
+ '--kind "<request|ack|claim|evidence|decision|blocker|handoff|clarification-request|orchestrator-guidance|resolved-by-policy|human-escalation|human-feedback|integration-summary>"',
278
+ '--summary "<one-line summary>"',
279
+ '--detail "<short detail>"',
280
+ ].join(" ");
272
281
  const implementationRequirements =
273
282
  ![contQaAgentId, documentationAgentId].includes(agent.agentId) &&
274
283
  !isSecurityReviewAgent(agent) &&
@@ -281,7 +290,8 @@ export function buildExecutionPrompt({
281
290
  "- Emit one final structured component marker per owned component: `[wave-component] component=<id> level=<level> state=<met|gap> detail=<short-note>`.",
282
291
  ]
283
292
  : []),
284
- "- If you leave any material architecture, integration, durability, ops, or docs gap, emit `[wave-gap] kind=<architecture|integration|durability|ops|docs> detail=<short-note>` and make the gap explicit instead of implying completion.",
293
+ "- If the work is incomplete, keep the required proof/doc/component markers and set `state=gap` on the relevant final marker instead of narrating completion.",
294
+ `- Route unresolved architecture, integration, durability, ops, or docs issues through \`${coordinationCommand}\`. Do not append \`[wave-gap]\` lines after the final implementation markers.`,
285
295
  ]
286
296
  : [];
287
297
  const exitContractLines = agent.exitContract
@@ -305,15 +315,6 @@ export function buildExecutionPrompt({
305
315
  '--context "<what you tried, options, and impact>"',
306
316
  "--timeout-seconds 30",
307
317
  ].join(" ");
308
- const coordinationCommand = [
309
- "pnpm exec wave coord post",
310
- `--lane ${lane}`,
311
- `--wave ${wave}`,
312
- `--agent ${agent.agentId}`,
313
- '--kind "<request|ack|claim|evidence|decision|blocker|handoff|clarification-request|orchestrator-guidance|resolved-by-policy|human-escalation|human-feedback|integration-summary>"',
314
- '--summary "<one-line summary>"',
315
- '--detail "<short detail>"',
316
- ].join(" ");
317
318
  const context7Selection = context7?.selection || agent?.context7Resolved || null;
318
319
  const executorId = agent?.executorResolved?.id || "default";
319
320
  const context7LibrarySummary =
@@ -1,8 +1,11 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
4
+ import { loadWaveConfig } from "./config.mjs";
3
5
  import { analyzeMessageBoardCommunication } from "./coordination.mjs";
4
6
  import { commsAgeSummary, deploymentSummary } from "./dashboard-state.mjs";
5
7
  import {
8
+ buildLanePaths,
6
9
  DEFAULT_REFRESH_MS,
7
10
  DEFAULT_WAVE_LANE,
8
11
  FINAL_EXIT_DELAY_MS,
@@ -14,12 +17,29 @@ import {
14
17
  sleep,
15
18
  truncate,
16
19
  } from "./shared.mjs";
20
+ import {
21
+ createCurrentWaveDashboardTerminalEntry,
22
+ createGlobalDashboardTerminalEntry,
23
+ } from "./terminals.mjs";
24
+
25
+ const DASHBOARD_ATTACH_TARGETS = ["current", "global"];
26
+
27
+ function normalizeDashboardAttachTarget(value) {
28
+ const normalized = String(value || "")
29
+ .trim()
30
+ .toLowerCase();
31
+ if (!DASHBOARD_ATTACH_TARGETS.includes(normalized)) {
32
+ throw new Error(`--attach must be one of: ${DASHBOARD_ATTACH_TARGETS.join(", ")}`);
33
+ }
34
+ return normalized;
35
+ }
17
36
 
18
37
  export function parseDashboardArgs(argv) {
19
38
  const options = {
20
39
  lane: DEFAULT_WAVE_LANE,
21
40
  dashboardFile: null,
22
41
  messageBoard: null,
42
+ attach: null,
23
43
  watch: false,
24
44
  refreshMs: DEFAULT_REFRESH_MS,
25
45
  };
@@ -39,6 +59,8 @@ export function parseDashboardArgs(argv) {
39
59
  options.dashboardFile = path.resolve(REPO_ROOT, argv[++i] || "");
40
60
  } else if (arg === "--message-board") {
41
61
  options.messageBoard = path.resolve(REPO_ROOT, argv[++i] || "");
62
+ } else if (arg === "--attach") {
63
+ options.attach = normalizeDashboardAttachTarget(argv[++i] || "");
42
64
  } else if (arg === "--refresh-ms") {
43
65
  options.refreshMs = Number.parseInt(String(argv[++i] || ""), 10);
44
66
  } else if (arg === "--help" || arg === "-h") {
@@ -47,12 +69,63 @@ export function parseDashboardArgs(argv) {
47
69
  throw new Error(`Unknown argument: ${arg}`);
48
70
  }
49
71
  }
50
- if (!options.dashboardFile) {
51
- throw new Error("--dashboard-file is required");
72
+ if (!options.dashboardFile && !options.attach) {
73
+ throw new Error("--dashboard-file is required unless --attach is used");
52
74
  }
53
75
  return { help: false, options };
54
76
  }
55
77
 
78
+ function tmuxSessionExists(socketName, sessionName) {
79
+ const result = spawnSync("tmux", ["-L", socketName, "has-session", "-t", sessionName], {
80
+ cwd: REPO_ROOT,
81
+ encoding: "utf8",
82
+ env: { ...process.env, TMUX: "" },
83
+ });
84
+ if (result.error) {
85
+ throw new Error(`tmux session lookup failed: ${result.error.message}`);
86
+ }
87
+ if (result.status === 0) {
88
+ return true;
89
+ }
90
+ const combined = `${String(result.stderr || "").toLowerCase()}\n${String(result.stdout || "").toLowerCase()}`;
91
+ if (
92
+ combined.includes("can't find session") ||
93
+ combined.includes("no server running") ||
94
+ combined.includes("error connecting")
95
+ ) {
96
+ return false;
97
+ }
98
+ throw new Error((result.stderr || result.stdout || "tmux has-session failed").trim());
99
+ }
100
+
101
+ function attachDashboardSession(lane, target) {
102
+ const config = loadWaveConfig();
103
+ const lanePaths = buildLanePaths(lane, { config });
104
+ const entry =
105
+ target === "global"
106
+ ? createGlobalDashboardTerminalEntry(lanePaths, "current")
107
+ : createCurrentWaveDashboardTerminalEntry(lanePaths);
108
+ if (!tmuxSessionExists(lanePaths.tmuxSocketName, entry.sessionName)) {
109
+ const dashboardsRel = path.relative(REPO_ROOT, path.dirname(lanePaths.globalDashboardPath));
110
+ throw new Error(
111
+ `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.`,
112
+ );
113
+ }
114
+ const result = spawnSync("tmux", ["-L", lanePaths.tmuxSocketName, "attach", "-t", entry.sessionName], {
115
+ cwd: REPO_ROOT,
116
+ stdio: "inherit",
117
+ env: { ...process.env, TMUX: "" },
118
+ });
119
+ if (result.error) {
120
+ throw new Error(`tmux attach failed: ${result.error.message}`);
121
+ }
122
+ if (result.status !== 0) {
123
+ throw new Error(
124
+ `tmux attach exited ${result.status} for lane ${lanePaths.lane} ${target} dashboard session ${entry.sessionName}.`,
125
+ );
126
+ }
127
+ }
128
+
56
129
  function readMessageBoardTail(messageBoardPath, maxLines = 24) {
57
130
  if (!messageBoardPath) {
58
131
  return ["(message board path unavailable)"];
@@ -379,12 +452,19 @@ Options:
379
452
  --lane <name> Wave lane name (default: ${DEFAULT_WAVE_LANE})
380
453
  --dashboard-file <path> Path to wave/global dashboard JSON
381
454
  --message-board <path> Optional message board path override
455
+ --attach <current|global>
456
+ Attach to the stable tmux-backed dashboard session for the lane
382
457
  --watch Refresh continuously
383
458
  --refresh-ms <n> Refresh interval in ms (default: ${DEFAULT_REFRESH_MS})
384
459
  `);
385
460
  return;
386
461
  }
387
462
 
463
+ if (options.attach) {
464
+ attachDashboardSession(options.lane, options.attach);
465
+ return;
466
+ }
467
+
388
468
  let terminalStateReachedAt = null;
389
469
  while (true) {
390
470
  const raw = fs.existsSync(options.dashboardFile)