@chllming/wave-orchestration 0.7.0 → 0.7.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.
@@ -2,6 +2,24 @@
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@chllming/wave-orchestration",
4
4
  "releases": [
5
+ {
6
+ "version": "0.7.1",
7
+ "date": "2026-03-23",
8
+ "summary": "Run-control hardening, completed-with-drift reconcile preservation, live Codex ceiling visibility, and 0.7.1 release-surface alignment.",
9
+ "features": [
10
+ "Fresh live launches now clear stale auto-generated relaunch plans by default, so explicit wave starts recompute the implementation fan-out unless `--resume-control-state` is passed.",
11
+ "`wave control status` now treats the active attempt as the authoritative live fan-out instead of replaying stale rerun intent or unrelated closure blockers.",
12
+ "Historical `reconcile-status` now preserves previously authoritative completed waves as `completed_with_drift` when the only mismatch is prompt-hash drift.",
13
+ "Live executor overlays now always write `launch-preview.json`, and Codex summaries record an observed turn ceiling when the runtime reports one.",
14
+ "Shipped package docs, migration guidance, sample-wave references, and npm publishing instructions now point at the `0.7.1` release surface."
15
+ ],
16
+ "manualSteps": [
17
+ "If you intentionally want to reuse a prior auto-generated relaunch selection on a fresh live start, pass `--resume-control-state` explicitly.",
18
+ "Use `pnpm exec wave dashboard --lane <lane> --attach current` or `--attach global` to reattach to live tmux-backed dashboards without resolving sockets or session names by hand.",
19
+ "If an adopted `0.6.x` repo fails `wave doctor` after the `0.7.x` upgrade, sync the repo-owned planner starter surface (`docs/agents/wave-planner-role.md`, `skills/role-planner/`, `docs/context7/planner-agent/`, `docs/reference/wave-planning-lessons.md`, and the `planner-agentic` bundle entry) before relying on planner-aware validation."
20
+ ],
21
+ "breaking": false
22
+ },
5
23
  {
6
24
  "version": "0.7.0",
7
25
  "date": "2026-03-23",
@@ -12,11 +30,15 @@
12
30
  "Wave Control telemetry: local-first event queueing with best-effort batch delivery, configurable report modes, selective artifact upload, and per-category capture toggles.",
13
31
  "Live-wave orchestration refresh that keeps coordination surfaces, clarification triage, and dashboard metrics current during active execution.",
14
32
  "Resident orchestrator support via `--resident-orchestrator` for long-running non-owning monitoring sessions.",
15
- "Native and external benchmark telemetry with failure-review validity classification and config attestation hashing."
33
+ "Native and external benchmark telemetry with failure-review validity classification and config attestation hashing.",
34
+ "Stable dashboard reattach via `wave dashboard --attach current|global`, plus live `launch-preview.json` artifacts that preserve observed Codex turn ceilings without pretending Wave set them.",
35
+ "Historical `reconcile-status` now preserves previously authoritative completed waves as completed-with-drift when the only mismatch is prompt-hash drift.",
36
+ "Fresh live launches now clear stale auto-generated relaunch plans by default, while `wave control status` treats the active attempt as the authoritative fan-out instead of replaying stale relaunch state."
16
37
  ],
17
38
  "manualSteps": [
18
39
  "Existing `wave coord`, `wave retry`, and `wave proof` commands remain available as compatibility surfaces. No migration required, but new operator docs prefer `wave control`.",
19
- "To enable Wave Control telemetry, add a `waveControl` section to `wave.config.json` with at minimum an `endpoint` and `workspaceId`. Pass `--no-telemetry` to disable for a single run."
40
+ "To enable Wave Control telemetry, add a `waveControl` section to `wave.config.json` with at minimum an `endpoint` and `workspaceId`. Pass `--no-telemetry` to disable for a single run.",
41
+ "If an adopted `0.6.x` repo fails `wave doctor` after the `0.7.x` upgrade, sync the repo-owned planner starter surface (`docs/agents/wave-planner-role.md`, `skills/role-planner/`, `docs/context7/planner-agent/`, `docs/reference/wave-planning-lessons.md`, and the `planner-agentic` bundle entry) before relying on planner-aware validation."
20
42
  ],
21
43
  "breaking": false
22
44
  },
@@ -185,23 +185,27 @@ function findLatestComponentMatches(text) {
185
185
 
186
186
  function detectTermination(agent, logText, statusRecord) {
187
187
  const patterns = [
188
- { reason: "max-turns", regex: /(Reached max turns \(\d+\))/i },
188
+ { reason: "max-turns", regex: /Reached max turns \((\d+)\)/i },
189
189
  { reason: "timeout", regex: /(timed out(?: after [^\n.]+)?)/i },
190
190
  { reason: "session-missing", regex: /(session [^\n]+ disappeared before [^\n]+ was written)/i },
191
191
  ];
192
192
  for (const pattern of patterns) {
193
193
  const match = String(logText || "").match(pattern.regex);
194
194
  if (match) {
195
- const baseHint = cleanText(match[1] || match[0]);
195
+ const baseHint = cleanText(match[0]);
196
+ const observedTurnLimit =
197
+ pattern.reason === "max-turns" && Number.isFinite(Number(match[1])) ? Number(match[1]) : null;
196
198
  if (pattern.reason === "max-turns" && agent?.executorResolved?.id === "codex") {
197
199
  return {
198
200
  reason: pattern.reason,
199
201
  hint: `${baseHint}. Wave does not set a Codex turn-limit flag; inspect launch-preview.json limits for any profile or upstream-runtime ceiling notes.`,
202
+ observedTurnLimit,
200
203
  };
201
204
  }
202
205
  return {
203
206
  reason: pattern.reason,
204
207
  hint: baseHint,
208
+ observedTurnLimit,
205
209
  };
206
210
  }
207
211
  }
@@ -212,6 +216,7 @@ function detectTermination(agent, logText, statusRecord) {
212
216
  return {
213
217
  reason: "status-detail",
214
218
  hint: statusHint,
219
+ observedTurnLimit: null,
215
220
  };
216
221
  }
217
222
  const exitCode = Number.isFinite(Number(statusRecord?.code)) ? Number(statusRecord.code) : null;
@@ -219,11 +224,13 @@ function detectTermination(agent, logText, statusRecord) {
219
224
  return {
220
225
  reason: "exit-code",
221
226
  hint: `Exit code ${exitCode}.`,
227
+ observedTurnLimit: null,
222
228
  };
223
229
  }
224
230
  return {
225
231
  reason: null,
226
232
  hint: "",
233
+ observedTurnLimit: null,
227
234
  };
228
235
  }
229
236
 
@@ -437,6 +444,8 @@ export function buildAgentExecutionSummary({ agent, statusRecord, logPath, repor
437
444
  : null,
438
445
  terminationReason: termination.reason,
439
446
  terminationHint: termination.hint,
447
+ terminationObservedTurnLimit:
448
+ Number.isFinite(Number(termination.observedTurnLimit)) ? Number(termination.observedTurnLimit) : null,
440
449
  logPath: path.relative(REPO_ROOT, logPath),
441
450
  reportPath: reportPath ? path.relative(REPO_ROOT, reportPath) : null,
442
451
  };
@@ -270,8 +270,46 @@ 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) => {
@@ -301,9 +339,15 @@ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabi
301
339
  const dependency = openInbound.find((record) => record.assignedAgentId === agent.agentId);
302
340
  let state = "planned";
303
341
  let reason = "";
304
- if (rerunSelected.has(agent.agentId)) {
342
+ if (selection?.source === "active-attempt" && selectedAgentIds.has(agent.agentId)) {
343
+ state = "working";
344
+ reason = selection?.detail || "Selected by the active launcher attempt.";
345
+ } else if (selectedAgentIds.has(agent.agentId)) {
305
346
  state = "needs-rerun";
306
- reason = "Selected by active rerun request.";
347
+ reason =
348
+ selection?.source === "relaunch-plan"
349
+ ? "Selected by the persisted relaunch plan."
350
+ : "Selected by active rerun request.";
307
351
  } else if (targetedBlockingTasks.some((task) => task.state === "working")) {
308
352
  state = "working";
309
353
  reason = targetedBlockingTasks.find((task) => task.state === "working")?.title || "";
@@ -334,7 +378,8 @@ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabi
334
378
  state,
335
379
  reason: reason || null,
336
380
  taskIds: targetedTasks.map((task) => task.taskId),
337
- selectedForRerun: rerunSelected.has(agent.agentId),
381
+ selectedForRerun: selectedAgentIds.has(agent.agentId) && selection?.source !== "active-attempt",
382
+ selectedForActiveAttempt: selection?.source === "active-attempt" && selectedAgentIds.has(agent.agentId),
338
383
  activeProofBundleIds: (proofRegistry?.entries || [])
339
384
  .filter(
340
385
  (entry) =>
@@ -346,10 +391,25 @@ function buildLogicalAgents({ lanePaths, wave, tasks, dependencySnapshot, capabi
346
391
  });
347
392
  }
348
393
 
349
- function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, rerunRequest, agentId = "" }) {
350
- const scopedTasks = agentId
394
+ function selectionTargetsAgent(agentId, selectionSet) {
395
+ return Boolean(agentId) && selectionSet.has(agentId);
396
+ }
397
+
398
+ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, activeAttempt, rerunRequest, relaunchPlan, agentId = "" }) {
399
+ const attemptSelection = new Set(activeAttempt?.selectedAgentIds || []);
400
+ const scopeToActiveAttempt = !agentId && attemptSelection.size > 0;
401
+ const scopedTasks = (agentId
351
402
  ? tasks.filter((task) => task.ownerAgentId === agentId || task.assigneeAgentId === agentId)
352
- : tasks;
403
+ : tasks
404
+ ).filter((task) => {
405
+ if (!scopeToActiveAttempt) {
406
+ return true;
407
+ }
408
+ return (
409
+ selectionTargetsAgent(task.ownerAgentId, attemptSelection) ||
410
+ selectionTargetsAgent(task.assigneeAgentId, attemptSelection)
411
+ );
412
+ });
353
413
  const pendingHuman = scopedTasks.find((task) => task.state === "input-required");
354
414
  if (pendingHuman) {
355
415
  return {
@@ -381,11 +441,19 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
381
441
  detail: clarification.title,
382
442
  };
383
443
  }
384
- const unresolvedAssignment = (capabilityAssignments || []).find(
444
+ const scopedAssignments = (capabilityAssignments || []).filter((assignment) => {
445
+ if (!scopeToActiveAttempt) {
446
+ return assignmentRelevantToAgent(assignment, agentId);
447
+ }
448
+ return (
449
+ selectionTargetsAgent(assignment.assignedAgentId, attemptSelection) ||
450
+ selectionTargetsAgent(assignment.sourceAgentId, attemptSelection)
451
+ );
452
+ });
453
+ const unresolvedAssignment = scopedAssignments.find(
385
454
  (assignment) =>
386
455
  assignment.blocking &&
387
- !assignment.assignedAgentId &&
388
- assignmentRelevantToAgent(assignment, agentId),
456
+ !assignment.assignedAgentId,
389
457
  );
390
458
  if (unresolvedAssignment) {
391
459
  return {
@@ -395,10 +463,7 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
395
463
  detail: unresolvedAssignment.assignmentDetail || unresolvedAssignment.summary || unresolvedAssignment.requestId,
396
464
  };
397
465
  }
398
- const blockingAssignment = (capabilityAssignments || []).find(
399
- (assignment) =>
400
- assignment.blocking && assignmentRelevantToAgent(assignment, agentId),
401
- );
466
+ const blockingAssignment = scopedAssignments.find((assignment) => assignment.blocking);
402
467
  if (blockingAssignment) {
403
468
  return {
404
469
  kind: "helper-assignment",
@@ -410,7 +475,18 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
410
475
  const dependency = [
411
476
  ...(dependencySnapshot?.openInbound || []),
412
477
  ...(dependencySnapshot?.openOutbound || []),
413
- ].find((record) => !agentId || record.assignedAgentId === agentId || record.agentId === agentId);
478
+ ].find((record) => {
479
+ if (agentId) {
480
+ return record.assignedAgentId === agentId || record.agentId === agentId;
481
+ }
482
+ if (!scopeToActiveAttempt) {
483
+ return true;
484
+ }
485
+ return (
486
+ selectionTargetsAgent(record.assignedAgentId, attemptSelection) ||
487
+ selectionTargetsAgent(record.agentId, attemptSelection)
488
+ );
489
+ });
414
490
  if (dependency) {
415
491
  return {
416
492
  kind: "dependency",
@@ -419,7 +495,7 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
419
495
  detail: dependency.summary || dependency.detail || dependency.id,
420
496
  };
421
497
  }
422
- if (rerunRequest) {
498
+ if (!scopeToActiveAttempt && rerunRequest) {
423
499
  return {
424
500
  kind: "rerun-request",
425
501
  id: rerunRequest.requestId || "active-rerun",
@@ -427,6 +503,14 @@ function buildBlockingEdge({ tasks, capabilityAssignments, dependencySnapshot, r
427
503
  detail: rerunRequest.reason || "Active rerun request controls next attempt selection.",
428
504
  };
429
505
  }
506
+ if (!scopeToActiveAttempt && relaunchPlan) {
507
+ return {
508
+ kind: "relaunch-plan",
509
+ id: `wave-${relaunchPlan.wave ?? "unknown"}-relaunch-plan`,
510
+ agentId: null,
511
+ detail: "Persisted relaunch plan controls the next safe launcher selection.",
512
+ };
513
+ }
430
514
  const blocker = scopedTasks.find(
431
515
  (task) => task.taskType === "blocker" && ["open", "working"].includes(task.state),
432
516
  );
@@ -485,6 +569,7 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
485
569
  }).filter((task) => !agentId || task.ownerAgentId === agentId || task.assigneeAgentId === agentId);
486
570
  const controlState = readWaveControlPlaneState(lanePaths, wave.wave);
487
571
  const proofRegistry = readWaveProofRegistry(lanePaths, wave.wave) || { entries: [] };
572
+ const relaunchPlan = readWaveRelaunchPlanSnapshot(lanePaths, wave.wave);
488
573
  const rerunRequest = controlState.activeRerunRequest
489
574
  ? {
490
575
  ...controlState.activeRerunRequest,
@@ -497,6 +582,11 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
497
582
  }),
498
583
  }
499
584
  : null;
585
+ const selection = buildEffectiveSelection(lanePaths, wave, {
586
+ activeAttempt: controlState.activeAttempt,
587
+ rerunRequest,
588
+ relaunchPlan,
589
+ });
500
590
  return {
501
591
  lane: lanePaths.lane,
502
592
  wave: wave.wave,
@@ -506,7 +596,9 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
506
596
  tasks,
507
597
  capabilityAssignments,
508
598
  dependencySnapshot,
599
+ activeAttempt: controlState.activeAttempt,
509
600
  rerunRequest,
601
+ relaunchPlan,
510
602
  agentId,
511
603
  }),
512
604
  logicalAgents: buildLogicalAgents({
@@ -515,7 +607,7 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
515
607
  tasks,
516
608
  dependencySnapshot,
517
609
  capabilityAssignments,
518
- rerunRequest,
610
+ selection,
519
611
  proofRegistry,
520
612
  }).filter((agent) => !agentId || agent.agentId === agentId),
521
613
  tasks,
@@ -533,8 +625,9 @@ export function buildControlStatusPayload({ lanePaths, wave, agentId = "" }) {
533
625
  proofBundles: (proofRegistry?.entries || []).filter(
534
626
  (entry) => !agentId || entry.agentId === agentId,
535
627
  ),
628
+ selectionSource: selection.source,
536
629
  rerunRequest,
537
- relaunchPlan: readWaveRelaunchPlanSnapshot(lanePaths, wave.wave),
630
+ relaunchPlan,
538
631
  nextTimer: nextTaskDeadline(tasks),
539
632
  activeAttempt: controlState.activeAttempt,
540
633
  };
@@ -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)
@@ -92,6 +92,33 @@ const REQUIRED_GITIGNORE_ENTRIES = [
92
92
  "docs/research/papers/",
93
93
  "docs/research/articles/",
94
94
  ];
95
+ const PLANNER_MIGRATION_REQUIRED_SURFACES = [
96
+ {
97
+ id: "planner-role",
98
+ label: "docs/agents/wave-planner-role.md",
99
+ path: "docs/agents/wave-planner-role.md",
100
+ kind: "file",
101
+ },
102
+ {
103
+ id: "planner-skill",
104
+ label: "skills/role-planner/",
105
+ path: "skills/role-planner",
106
+ kind: "dir",
107
+ },
108
+ {
109
+ id: "planner-context7",
110
+ label: "docs/context7/planner-agent/",
111
+ path: "docs/context7/planner-agent",
112
+ kind: "dir",
113
+ },
114
+ {
115
+ id: "planner-lessons",
116
+ label: "docs/reference/wave-planning-lessons.md",
117
+ path: "docs/reference/wave-planning-lessons.md",
118
+ kind: "file",
119
+ },
120
+ ];
121
+ const PLANNER_REQUIRED_BUNDLE_ID = "planner-agentic";
95
122
 
96
123
  function collectDeclaredDeployKinds(waves = []) {
97
124
  return Array.from(
@@ -222,6 +249,8 @@ function slugifyVersion(value) {
222
249
  }
223
250
 
224
251
  function formatUpgradeReport(report) {
252
+ const plannerMigrationErrors = report.doctor.errors.filter((issue) => isPlannerMigrationIssue(issue));
253
+ const otherDoctorErrors = report.doctor.errors.filter((issue) => !isPlannerMigrationIssue(issue));
225
254
  return [
226
255
  `# Wave Upgrade Report`,
227
256
  "",
@@ -238,12 +267,27 @@ function formatUpgradeReport(report) {
238
267
  "",
239
268
  "- No repo-owned plans, waves, role prompts, or config files were overwritten.",
240
269
  "- New runtime behavior comes from the installed package version.",
270
+ ...(report.initMode === "adopt-existing" && plannerMigrationErrors.length > 0
271
+ ? [
272
+ "",
273
+ "## Adopted Repo Follow-Up",
274
+ "",
275
+ "- This workspace was adopted from an existing repo-owned Wave surface.",
276
+ "- `wave upgrade` does not copy new planner starter docs, skills, or Context7 bundle entries into adopted repos.",
277
+ ...plannerMigrationErrors.map((issue) => `- Error: ${issue}`),
278
+ ]
279
+ : []),
280
+ ...(report.initMode === "adopt-existing" && plannerMigrationErrors.length > 0
281
+ ? [
282
+ "- After syncing that planner surface, rerun `pnpm exec wave doctor` before relying on `wave draft --agentic` or planner-aware validation.",
283
+ ]
284
+ : []),
241
285
  ...(report.doctor.errors.length > 0 || report.doctor.warnings.length > 0
242
286
  ? [
243
287
  "",
244
288
  "## Follow-Up",
245
289
  "",
246
- ...report.doctor.errors.map((issue) => `- Error: ${issue}`),
290
+ ...otherDoctorErrors.map((issue) => `- Error: ${issue}`),
247
291
  ...report.doctor.warnings.map((issue) => `- Warning: ${issue}`),
248
292
  ]
249
293
  : []),
@@ -275,6 +319,48 @@ function plannerRequiredPaths() {
275
319
  ).sort();
276
320
  }
277
321
 
322
+ function isPlannerMigrationIssue(issue) {
323
+ return String(issue || "").startsWith("Planner starter surface is incomplete");
324
+ }
325
+
326
+ function missingPlannerMigrationSurfaceLabels() {
327
+ const missing = [];
328
+ for (const surface of PLANNER_MIGRATION_REQUIRED_SURFACES) {
329
+ const targetPath = path.join(REPO_ROOT, surface.path);
330
+ if (!fs.existsSync(targetPath)) {
331
+ missing.push(surface.label);
332
+ continue;
333
+ }
334
+ if (surface.kind === "dir") {
335
+ try {
336
+ if (fs.readdirSync(targetPath).length === 0) {
337
+ missing.push(surface.label);
338
+ }
339
+ } catch {
340
+ missing.push(surface.label);
341
+ }
342
+ }
343
+ }
344
+ return missing;
345
+ }
346
+
347
+ function plannerMigrationIssue(config, context7BundleIndex) {
348
+ const missing = missingPlannerMigrationSurfaceLabels();
349
+ const bundleId = String(config?.planner?.agentic?.context7Bundle || "").trim();
350
+ const bundleEntryMissing =
351
+ bundleId === "" || bundleId === PLANNER_REQUIRED_BUNDLE_ID
352
+ ? !context7BundleIndex?.bundles?.[PLANNER_REQUIRED_BUNDLE_ID]
353
+ : false;
354
+ if (missing.length === 0 && !bundleEntryMissing) {
355
+ return null;
356
+ }
357
+ const remediationItems = missing.slice();
358
+ if (bundleEntryMissing) {
359
+ remediationItems.push(`docs/context7/bundles.json#${PLANNER_REQUIRED_BUNDLE_ID}`);
360
+ }
361
+ return `Planner starter surface is incomplete for 0.7.x workspaces. Sync ${remediationItems.join(", ")} from the packaged release, then rerun \`pnpm exec wave doctor\`.`;
362
+ }
363
+
278
364
  export function runDoctor() {
279
365
  const errors = [];
280
366
  const warnings = [];
@@ -320,14 +406,22 @@ export function runDoctor() {
320
406
  }
321
407
  }
322
408
  const context7BundleIndex = loadContext7BundleIndex(lanePaths.context7BundleIndexPath);
409
+ const plannerMigration = plannerMigrationIssue(config, context7BundleIndex);
410
+ if (plannerMigration) {
411
+ errors.push(plannerMigration);
412
+ }
323
413
  const plannerPaths = plannerRequiredPaths();
324
414
  for (const relPath of plannerPaths) {
325
- if (!fs.existsSync(path.join(REPO_ROOT, relPath))) {
415
+ if (!fs.existsSync(path.join(REPO_ROOT, relPath)) && !plannerMigration) {
326
416
  errors.push(`Missing planner file: ${relPath}`);
327
417
  }
328
418
  }
329
419
  const plannerBundleId = String(config.planner?.agentic?.context7Bundle || "").trim();
330
- if (plannerBundleId && !context7BundleIndex.bundles[plannerBundleId]) {
420
+ if (
421
+ plannerBundleId &&
422
+ !context7BundleIndex.bundles[plannerBundleId] &&
423
+ !(plannerMigration && plannerBundleId === PLANNER_REQUIRED_BUNDLE_ID)
424
+ ) {
331
425
  errors.push(
332
426
  `planner.agentic.context7Bundle references unknown bundle "${plannerBundleId}".`,
333
427
  );
@@ -484,6 +578,7 @@ export function upgradeWorkspace() {
484
578
  previousVersion,
485
579
  currentVersion: metadata.version,
486
580
  generatedAt,
581
+ initMode: existingState.initMode || null,
487
582
  releases,
488
583
  doctor,
489
584
  };
@@ -135,16 +135,16 @@ export async function launchAgentSession(lanePaths, params, { runTmuxFn }) {
135
135
  skillProjection: agent.skillsResolved,
136
136
  });
137
137
  const resolvedExecutorMode = launchSpec.executorId || agent.executorResolved?.id || "codex";
138
+ writeJsonAtomic(path.join(overlayDir, "launch-preview.json"), {
139
+ executorId: resolvedExecutorMode,
140
+ command: launchSpec.command,
141
+ env: launchSpec.env || {},
142
+ useRateLimitRetries: launchSpec.useRateLimitRetries === true,
143
+ invocationLines: launchSpec.invocationLines,
144
+ limits: launchSpec.limits || null,
145
+ skills: summarizeResolvedSkills(agent.skillsResolved),
146
+ });
138
147
  if (dryRun) {
139
- writeJsonAtomic(path.join(overlayDir, "launch-preview.json"), {
140
- executorId: resolvedExecutorMode,
141
- command: launchSpec.command,
142
- env: launchSpec.env || {},
143
- useRateLimitRetries: launchSpec.useRateLimitRetries === true,
144
- invocationLines: launchSpec.invocationLines,
145
- limits: launchSpec.limits || null,
146
- skills: summarizeResolvedSkills(agent.skillsResolved),
147
- });
148
148
  return {
149
149
  promptHash,
150
150
  context7,