taskplane 0.0.1 → 0.1.0

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -20
  3. package/bin/taskplane.mjs +706 -0
  4. package/dashboard/public/app.js +900 -0
  5. package/dashboard/public/index.html +92 -0
  6. package/dashboard/public/style.css +924 -0
  7. package/dashboard/server.cjs +531 -0
  8. package/extensions/task-orchestrator.ts +28 -0
  9. package/extensions/task-runner.ts +1923 -0
  10. package/extensions/taskplane/abort.ts +466 -0
  11. package/extensions/taskplane/config.ts +102 -0
  12. package/extensions/taskplane/discovery.ts +988 -0
  13. package/extensions/taskplane/engine.ts +758 -0
  14. package/extensions/taskplane/execution.ts +1752 -0
  15. package/extensions/taskplane/extension.ts +577 -0
  16. package/extensions/taskplane/formatting.ts +718 -0
  17. package/extensions/taskplane/git.ts +38 -0
  18. package/extensions/taskplane/index.ts +22 -0
  19. package/extensions/taskplane/merge.ts +795 -0
  20. package/extensions/taskplane/messages.ts +134 -0
  21. package/extensions/taskplane/persistence.ts +1121 -0
  22. package/extensions/taskplane/resume.ts +1092 -0
  23. package/extensions/taskplane/sessions.ts +92 -0
  24. package/extensions/taskplane/types.ts +1514 -0
  25. package/extensions/taskplane/waves.ts +900 -0
  26. package/extensions/taskplane/worktree.ts +1624 -0
  27. package/package.json +48 -3
  28. package/skills/create-taskplane-task/SKILL.md +326 -0
  29. package/skills/create-taskplane-task/references/context-template.md +78 -0
  30. package/skills/create-taskplane-task/references/prompt-template.md +246 -0
  31. package/templates/agents/task-merger.md +256 -0
  32. package/templates/agents/task-reviewer.md +81 -0
  33. package/templates/agents/task-worker.md +140 -0
  34. package/templates/config/task-orchestrator.yaml +89 -0
  35. package/templates/config/task-runner.yaml +99 -0
  36. package/templates/tasks/CONTEXT.md +31 -0
  37. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
  38. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Output formatting, dashboard widget, wave plan display
3
+ * @module orch/formatting
4
+ */
5
+ import { join } from "path";
6
+ import { truncateToWidth } from "@mariozechner/pi-tui";
7
+
8
+ import { parseDependencyReference } from "./discovery.ts";
9
+ import type { LaneAssignment, MonitorState, OrchBatchRuntimeState, OrchDashboardViewModel, OrchLaneCardData, OrchSummaryCounts, ParsedTask, WaveComputationResult } from "./types.ts";
10
+ import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts";
11
+
12
+ // ── Wave Output Formatting ───────────────────────────────────────────
13
+
14
+ // ── Dependency Graph Formatting ──────────────────────────────────────
15
+
16
+ /**
17
+ * Format a dependency graph for display.
18
+ *
19
+ * Shows both upstream (what each task depends on) and downstream
20
+ * (what depends on each task) views. Output is deterministic:
21
+ * tasks sorted by ID, edges sorted by target ID.
22
+ *
23
+ * If `filterTaskId` is provided, only shows edges involving that task.
24
+ */
25
+ export function formatDependencyGraph(
26
+ pending: Map<string, ParsedTask>,
27
+ completed: Set<string>,
28
+ filterTaskId?: string,
29
+ ): string {
30
+ const lines: string[] = [];
31
+
32
+ // Sort tasks deterministically by ID
33
+ const sortedTasks = [...pending.values()].sort((a, b) =>
34
+ a.taskId.localeCompare(b.taskId),
35
+ );
36
+
37
+ // Build downstream index: taskID → tasks that depend on it
38
+ const downstream = new Map<string, string[]>();
39
+ for (const task of sortedTasks) {
40
+ for (const depRaw of task.dependencies) {
41
+ const depId = parseDependencyReference(depRaw).taskId;
42
+ const existing = downstream.get(depId) || [];
43
+ existing.push(task.taskId);
44
+ downstream.set(depId, existing);
45
+ }
46
+ }
47
+
48
+ // If filtering to a single task
49
+ if (filterTaskId) {
50
+ const task = pending.get(filterTaskId);
51
+ if (!task) {
52
+ lines.push(`❌ Task "${filterTaskId}" not found in pending tasks.`);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ lines.push(`🔗 Dependencies for ${filterTaskId} (${task.taskName}):`);
57
+ lines.push("");
58
+
59
+ // Upstream: what this task depends on
60
+ lines.push(" ⬆ Upstream (depends on):");
61
+ if (task.dependencies.length === 0) {
62
+ lines.push(" (none — no dependencies)");
63
+ } else {
64
+ const sortedDeps = [...task.dependencies].sort();
65
+ for (const depRaw of sortedDeps) {
66
+ const depId = parseDependencyReference(depRaw).taskId;
67
+ const status = completed.has(depId)
68
+ ? "✅ complete"
69
+ : pending.has(depId)
70
+ ? "⏳ pending"
71
+ : "❓ unknown";
72
+ lines.push(` ${filterTaskId} → ${depRaw} (${status})`);
73
+ }
74
+ }
75
+
76
+ // Downstream: what depends on this task
77
+ lines.push("");
78
+ lines.push(" ⬇ Downstream (depended on by):");
79
+ const downstreamTasks = (downstream.get(filterTaskId) || []).sort();
80
+ if (downstreamTasks.length === 0) {
81
+ lines.push(" (none — no tasks depend on this)");
82
+ } else {
83
+ for (const dep of downstreamTasks) {
84
+ lines.push(` ${dep} → ${filterTaskId}`);
85
+ }
86
+ }
87
+
88
+ return lines.join("\n");
89
+ }
90
+
91
+ // Full graph view
92
+ lines.push("🔗 Dependency Graph:");
93
+ lines.push("");
94
+
95
+ let hasDeps = false;
96
+
97
+ // Section 1: Upstream view (what each task depends on)
98
+ lines.push(" ⬆ Upstream (task → depends on):");
99
+ for (const task of sortedTasks) {
100
+ if (task.dependencies.length > 0) {
101
+ hasDeps = true;
102
+ const sortedDeps = [...task.dependencies].sort();
103
+ for (const depRaw of sortedDeps) {
104
+ const depId = parseDependencyReference(depRaw).taskId;
105
+ const status = completed.has(depId)
106
+ ? "✅ complete"
107
+ : pending.has(depId)
108
+ ? "⏳ pending"
109
+ : "❓ unknown";
110
+ lines.push(` ${task.taskId} → ${depRaw} (${status})`);
111
+ }
112
+ }
113
+ }
114
+ if (!hasDeps) {
115
+ lines.push(" (none — all tasks are independent)");
116
+ }
117
+
118
+ // Section 2: Downstream view (what depends on each task)
119
+ lines.push("");
120
+ lines.push(" ⬇ Downstream (task ← depended on by):");
121
+ let hasDownstream = false;
122
+ const allTargets = new Set<string>();
123
+ for (const task of sortedTasks) {
124
+ for (const depRaw of task.dependencies) {
125
+ allTargets.add(parseDependencyReference(depRaw).taskId);
126
+ }
127
+ }
128
+ const sortedTargets = [...allTargets].sort();
129
+ for (const target of sortedTargets) {
130
+ const dependents = (downstream.get(target) || []).sort();
131
+ if (dependents.length > 0) {
132
+ hasDownstream = true;
133
+ const status = completed.has(target)
134
+ ? "✅"
135
+ : pending.has(target)
136
+ ? "⏳"
137
+ : "❓";
138
+ lines.push(
139
+ ` ${target} ${status} ← ${dependents.join(", ")}`,
140
+ );
141
+ }
142
+ }
143
+ if (!hasDownstream) {
144
+ lines.push(" (none — no downstream dependencies)");
145
+ }
146
+
147
+ // Section 3: Independent tasks (no deps, nothing depends on them)
148
+ const independentTasks = sortedTasks.filter(
149
+ (t) =>
150
+ t.dependencies.length === 0 &&
151
+ !(downstream.get(t.taskId)?.length),
152
+ );
153
+ if (independentTasks.length > 0) {
154
+ lines.push("");
155
+ lines.push(" ○ Independent (no dependencies, nothing depends on them):");
156
+ for (const task of independentTasks) {
157
+ lines.push(` ${task.taskId} [${task.size}] ${task.taskName}`);
158
+ }
159
+ }
160
+
161
+ return lines.join("\n");
162
+ }
163
+
164
+ /**
165
+ * Format wave computation results as a readable execution plan.
166
+ *
167
+ * Output sections (fixed order):
168
+ * 1. Wave overview header
169
+ * 2. Per-wave: task count, lane count, parallel/serial indicator
170
+ * 3. Per-lane within wave: tasks with sizes, serial notes, lane weight
171
+ * 4. Per-wave: estimated duration (critical path = max lane duration)
172
+ * 5. Summary: total estimated duration, size-to-duration table
173
+ *
174
+ * Duration calculation:
175
+ * - Per lane: sum of task durations for tasks in that lane
176
+ * - Per wave: max lane duration (parallel bottleneck / critical path)
177
+ * - Total: sum of wave durations (waves run sequentially)
178
+ */
179
+ export function formatWavePlan(
180
+ result: WaveComputationResult,
181
+ sizeWeights: Record<string, number>,
182
+ ): string {
183
+ const lines: string[] = [];
184
+
185
+ if (result.errors.length > 0) {
186
+ lines.push("❌ Wave Computation Errors:");
187
+ for (const err of result.errors) {
188
+ lines.push(` [${err.code}] ${err.message}`);
189
+ }
190
+ return lines.join("\n");
191
+ }
192
+
193
+ if (result.waves.length === 0) {
194
+ lines.push("No waves to schedule.");
195
+ return lines.join("\n");
196
+ }
197
+
198
+ // Count total tasks
199
+ const totalTasks = result.waves.reduce((sum, w) => sum + w.tasks.length, 0);
200
+ const maxLanesUsed = Math.max(
201
+ ...result.waves.map((w) => {
202
+ const lanes = new Set(w.tasks.map((t) => t.lane));
203
+ return lanes.size;
204
+ }),
205
+ );
206
+
207
+ lines.push(
208
+ `🌊 Execution Plan: ${result.waves.length} wave(s), ` +
209
+ `${totalTasks} task(s), up to ${maxLanesUsed} lane(s)`,
210
+ );
211
+ lines.push("");
212
+
213
+ let totalEstimate = 0;
214
+ for (const wave of result.waves) {
215
+ // Group tasks by lane (deterministic: Map preserves insertion order)
216
+ const laneGroups = new Map<number, LaneAssignment[]>();
217
+ for (const assignment of wave.tasks) {
218
+ const existing = laneGroups.get(assignment.lane) || [];
219
+ existing.push(assignment);
220
+ laneGroups.set(assignment.lane, existing);
221
+ }
222
+
223
+ const laneCount = laneGroups.size;
224
+ const taskCount = wave.tasks.length;
225
+ const parallel = laneCount > 1 ? "parallel" : "serial";
226
+
227
+ lines.push(
228
+ ` Wave ${wave.waveNumber}: ${taskCount} task(s) across ` +
229
+ `${laneCount} lane(s) [${parallel}]`,
230
+ );
231
+
232
+ // Calculate wave duration: critical path = max lane duration
233
+ let maxLaneDuration = 0;
234
+
235
+ // Sort lanes deterministically by lane number
236
+ const sortedLanes = [...laneGroups.entries()].sort(
237
+ (a, b) => a[0] - b[0],
238
+ );
239
+
240
+ for (const [lane, assignments] of sortedLanes) {
241
+ // Sort tasks within lane by task ID for deterministic output
242
+ const sortedAssignments = [...assignments].sort((a, b) =>
243
+ a.taskId.localeCompare(b.taskId),
244
+ );
245
+ const taskList = sortedAssignments
246
+ .map((a) => `${a.taskId} [${a.task.size}]`)
247
+ .join(", ");
248
+ const laneDuration = sortedAssignments.reduce(
249
+ (sum, a) =>
250
+ sum + getTaskDurationMinutes(a.task.size, sizeWeights),
251
+ 0,
252
+ );
253
+ if (laneDuration > maxLaneDuration) maxLaneDuration = laneDuration;
254
+ const serialNote =
255
+ sortedAssignments.length > 1 ? " (serial)" : "";
256
+ lines.push(
257
+ ` Lane ${lane}: ${taskList}${serialNote} ` +
258
+ `[est. ${laneDuration} min]`,
259
+ );
260
+ }
261
+
262
+ // Critical path for this wave
263
+ totalEstimate += maxLaneDuration;
264
+ lines.push(
265
+ ` ⏱ Wave duration: ${maxLaneDuration} min ` +
266
+ `(critical path: longest lane)`,
267
+ );
268
+ lines.push("");
269
+ }
270
+
271
+ // Summary with size-to-duration table
272
+ const totalHours = (totalEstimate / 60).toFixed(1);
273
+ lines.push(`📊 Total estimated duration: ${totalEstimate} min (~${totalHours} hours)`);
274
+ lines.push(
275
+ ` Duration model: S=${SIZE_DURATION_MINUTES["S"]}m, ` +
276
+ `M=${SIZE_DURATION_MINUTES["M"]}m, L=${SIZE_DURATION_MINUTES["L"]}m`,
277
+ );
278
+ lines.push(
279
+ " Critical path: sum of per-wave bottleneck lanes " +
280
+ "(waves sequential, lanes parallel)",
281
+ );
282
+
283
+ return lines.join("\n");
284
+ }
285
+
286
+
287
+ // ── Summary Helpers ──────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Compute summary counts from batch state + optional monitor state.
291
+ *
292
+ * Pure function — no side effects, deterministic output.
293
+ */
294
+ export function computeOrchSummaryCounts(
295
+ batchState: OrchBatchRuntimeState,
296
+ monitorState?: MonitorState | null,
297
+ ): OrchSummaryCounts {
298
+ let running = 0;
299
+ let stalled = 0;
300
+
301
+ // If we have live monitor data, count running/stalled from it
302
+ if (monitorState) {
303
+ for (const lane of monitorState.lanes) {
304
+ if (lane.currentTaskSnapshot) {
305
+ if (lane.currentTaskSnapshot.status === "stalled") {
306
+ stalled++;
307
+ } else if (lane.currentTaskSnapshot.status === "running") {
308
+ running++;
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ const completed = batchState.succeededTasks;
315
+ const failed = batchState.failedTasks;
316
+ const blocked = batchState.blockedTasks;
317
+ const total = batchState.totalTasks;
318
+ const queued = Math.max(0, total - completed - failed - blocked - stalled - running - batchState.skippedTasks);
319
+
320
+ return { completed, running, queued, failed, blocked, stalled, total };
321
+ }
322
+
323
+ /**
324
+ * Format elapsed time from start/end timestamps.
325
+ *
326
+ * @param startMs - Start epoch ms
327
+ * @param endMs - End epoch ms (null = use current time)
328
+ * @returns Human-readable string, e.g., "2m 14s" or "1h 5m 30s"
329
+ */
330
+ export function formatElapsedTime(startMs: number, endMs?: number | null): string {
331
+ if (startMs <= 0) return "0s";
332
+ const elapsed = (endMs ?? Date.now()) - startMs;
333
+ if (elapsed < 0) return "0s";
334
+
335
+ const totalSec = Math.floor(elapsed / 1000);
336
+ const hours = Math.floor(totalSec / 3600);
337
+ const minutes = Math.floor((totalSec % 3600) / 60);
338
+ const seconds = totalSec % 60;
339
+
340
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
341
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
342
+ return `${seconds}s`;
343
+ }
344
+
345
+ /**
346
+ * Build the dashboard view-model from runtime state.
347
+ *
348
+ * Pure function — deterministic mapping from OrchBatchRuntimeState +
349
+ * optional MonitorState to render-ready OrchDashboardViewModel.
350
+ *
351
+ * Fallback behavior:
352
+ * - No batch → idle view with zeroed counts
353
+ * - No monitor data → empty lane cards, counts from batch state only
354
+ * - Missing STATUS.md → "no data" in lane card
355
+ */
356
+ export function buildDashboardViewModel(
357
+ batchState: OrchBatchRuntimeState,
358
+ monitorState?: MonitorState | null,
359
+ ): OrchDashboardViewModel {
360
+ const summary = computeOrchSummaryCounts(batchState, monitorState);
361
+ const elapsed = formatElapsedTime(batchState.startedAt, batchState.endedAt);
362
+
363
+ const waveProgress = batchState.totalWaves > 0
364
+ ? `${Math.max(0, batchState.currentWaveIndex + 1)}/${batchState.totalWaves}`
365
+ : "0/0";
366
+
367
+ // Build lane cards from monitor state (if available) or current lanes
368
+ const laneCards: OrchLaneCardData[] = [];
369
+
370
+ if (monitorState && monitorState.lanes.length > 0) {
371
+ // Sort lanes by laneNumber (deterministic)
372
+ const sortedLanes = [...monitorState.lanes].sort((a, b) => a.laneNumber - b.laneNumber);
373
+
374
+ for (const lane of sortedLanes) {
375
+ const snap = lane.currentTaskSnapshot;
376
+ let status: OrchLaneCardData["status"] = "idle";
377
+ if (lane.failedTasks.length > 0) status = "failed";
378
+ else if (snap?.status === "stalled") status = "stalled";
379
+ else if (snap?.status === "running") status = "running";
380
+ else if (lane.completedTasks.length > 0 && lane.remainingTasks.length === 0 && !lane.currentTaskId) status = "succeeded";
381
+
382
+ laneCards.push({
383
+ laneNumber: lane.laneNumber,
384
+ laneId: lane.laneId,
385
+ sessionName: lane.sessionName,
386
+ sessionAlive: lane.sessionAlive,
387
+ currentTaskId: lane.currentTaskId,
388
+ currentStepName: snap?.currentStepName || null,
389
+ totalChecked: snap?.totalChecked || 0,
390
+ totalItems: snap?.totalItems || 0,
391
+ completedTasks: lane.completedTasks.length,
392
+ totalLaneTasks: lane.completedTasks.length + lane.failedTasks.length + lane.remainingTasks.length + (lane.currentTaskId ? 1 : 0),
393
+ status,
394
+ stallReason: snap?.stallReason || null,
395
+ });
396
+ }
397
+ } else if (batchState.currentLanes.length > 0) {
398
+ // No monitor data yet — show lanes from allocation
399
+ const sortedLanes = [...batchState.currentLanes].sort((a, b) => a.laneNumber - b.laneNumber);
400
+ for (const lane of sortedLanes) {
401
+ laneCards.push({
402
+ laneNumber: lane.laneNumber,
403
+ laneId: lane.laneId,
404
+ sessionName: lane.tmuxSessionName,
405
+ sessionAlive: true, // assumed alive during allocation
406
+ currentTaskId: lane.tasks.length > 0 ? lane.tasks[0].taskId : null,
407
+ currentStepName: null,
408
+ totalChecked: 0,
409
+ totalItems: 0,
410
+ completedTasks: 0,
411
+ totalLaneTasks: lane.tasks.length,
412
+ status: "running",
413
+ stallReason: null,
414
+ });
415
+ }
416
+ }
417
+
418
+ // Determine attach hint
419
+ let attachHint = "";
420
+ const aliveLane = laneCards.find(l => l.sessionAlive && l.status === "running");
421
+ if (aliveLane) {
422
+ attachHint = `tmux attach -t ${aliveLane.sessionName}`;
423
+ } else if (laneCards.length > 0) {
424
+ attachHint = "/orch-sessions for session list";
425
+ }
426
+
427
+ // Determine failure policy if batch was stopped
428
+ let failurePolicy: string | null = null;
429
+ if (batchState.phase === "stopped" && batchState.waveResults.length > 0) {
430
+ const lastWave = batchState.waveResults[batchState.waveResults.length - 1];
431
+ if (lastWave.stoppedEarly && lastWave.policyApplied) {
432
+ failurePolicy = lastWave.policyApplied;
433
+ }
434
+ }
435
+
436
+ return {
437
+ phase: batchState.phase,
438
+ batchId: batchState.batchId,
439
+ waveProgress,
440
+ elapsed,
441
+ summary,
442
+ laneCards,
443
+ attachHint,
444
+ errors: batchState.errors,
445
+ failurePolicy,
446
+ };
447
+ }
448
+
449
+ // ── Lane Card Rendering ──────────────────────────────────────────────
450
+
451
+ /**
452
+ * Render a single lane card for the dashboard.
453
+ *
454
+ * Follows the task-runner `renderStepCard` pattern:
455
+ * bordered box with lane info, status icon, task progress.
456
+ *
457
+ * @param card - Lane card data from view-model
458
+ * @param colWidth - Available width for the card (including borders)
459
+ * @param theme - Pi theme object for color styling
460
+ * @returns Array of styled string lines (one per card row)
461
+ */
462
+ export function renderLaneCard(card: OrchLaneCardData, colWidth: number, theme: any): string[] {
463
+ const w = colWidth - 2; // inner width (excluding │ borders)
464
+ const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
465
+
466
+ // Status icon and color
467
+ const statusIcon = card.status === "succeeded" ? "✓"
468
+ : card.status === "running" ? "●"
469
+ : card.status === "failed" ? "✗"
470
+ : card.status === "stalled" ? "⚠"
471
+ : "○";
472
+ const statusColor = card.status === "succeeded" ? "success"
473
+ : card.status === "running" ? "accent"
474
+ : card.status === "failed" ? "error"
475
+ : card.status === "stalled" ? "warning"
476
+ : "dim";
477
+
478
+ // Line 1: Session name (e.g., "⎡orch-lane-1⎤")
479
+ const sessionLabel = `⎡${card.sessionName}⎤`;
480
+ const sessionStr = theme.fg("accent", theme.bold(trunc(sessionLabel, w)));
481
+ const sessionVis = Math.min(sessionLabel.length, w);
482
+
483
+ // Line 2: Status + current task
484
+ const taskInfo = card.currentTaskId
485
+ ? `${statusIcon} ${card.currentTaskId}`
486
+ : card.status === "succeeded" ? `${statusIcon} done`
487
+ : card.status === "failed" ? `${statusIcon} failed`
488
+ : `${statusIcon} idle`;
489
+ const taskStr = theme.fg(statusColor, trunc(taskInfo, w));
490
+ const taskVis = Math.min(taskInfo.length, w);
491
+
492
+ // Line 3: Step progress
493
+ let stepInfo = "";
494
+ if (card.currentStepName) {
495
+ stepInfo = trunc(card.currentStepName, w - 2);
496
+ } else if (card.currentTaskId && card.totalItems === 0) {
497
+ stepInfo = "waiting for data...";
498
+ } else if (!card.currentTaskId && card.status !== "idle") {
499
+ stepInfo = `${card.completedTasks}/${card.totalLaneTasks} tasks`;
500
+ }
501
+ const stepStr = theme.fg("muted", trunc(stepInfo, w));
502
+ const stepVis = Math.min(stepInfo.length, w);
503
+
504
+ // Line 4: Checkbox progress or stall reason
505
+ let extraInfo = "";
506
+ let extraColor = "dim";
507
+ if (card.stallReason) {
508
+ extraInfo = `⚠ ${trunc(card.stallReason, w - 4)}`;
509
+ extraColor = "warning";
510
+ } else if (card.totalItems > 0) {
511
+ extraInfo = `${card.totalChecked}/${card.totalItems} ✓`;
512
+ extraColor = card.totalChecked === card.totalItems ? "success" : "muted";
513
+ } else if (!card.sessionAlive && card.status === "running") {
514
+ extraInfo = "session dead";
515
+ extraColor = "error";
516
+ }
517
+ const extraStr = theme.fg(extraColor, trunc(extraInfo, w));
518
+ const extraVis = Math.min(extraInfo.length, w);
519
+
520
+ // Build bordered card
521
+ const top = "┌" + "─".repeat(w) + "┐";
522
+ const bot = "└" + "─".repeat(w) + "┘";
523
+ const border = (content: string, vis: number) =>
524
+ theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - vis)) + theme.fg("dim", "│");
525
+
526
+ return [
527
+ theme.fg("dim", top),
528
+ border(" " + sessionStr, 1 + sessionVis),
529
+ border(" " + taskStr, 1 + taskVis),
530
+ border(" " + stepStr, 1 + stepVis),
531
+ border(extraInfo ? " " + extraStr : "", extraVis ? 1 + extraVis : 0),
532
+ theme.fg("dim", bot),
533
+ ];
534
+ }
535
+
536
+ // ── Core Widget ──────────────────────────────────────────────────────
537
+
538
+ /**
539
+ * Create the widget registration callback for the orchestrator dashboard.
540
+ *
541
+ * This is the main entry point for the dashboard widget. It captures
542
+ * batchState and monitorState references and returns a widget that
543
+ * re-renders on each paint cycle using the latest state.
544
+ *
545
+ * @param getBatchState - Getter for current batch state
546
+ * @param getMonitorState - Getter for current monitor state (may be null)
547
+ * @param tmuxPrefix - TMUX session prefix for attach hints
548
+ */
549
+ export function createOrchWidget(
550
+ getBatchState: () => OrchBatchRuntimeState,
551
+ getMonitorState: () => MonitorState | null,
552
+ tmuxPrefix: string,
553
+ ): (_tui: any, theme: any) => { render(width: number): string[]; invalidate(): void } {
554
+ return (_tui: any, theme: any) => {
555
+ return {
556
+ render(width: number): string[] {
557
+ const batchState = getBatchState();
558
+ const monitorState = getMonitorState();
559
+ const vm = buildDashboardViewModel(batchState, monitorState);
560
+
561
+ // ── Idle state ─────────────────────────────────
562
+ if (vm.phase === "idle") {
563
+ return [
564
+ "",
565
+ truncateToWidth(
566
+ theme.fg("dim", " No active batch. Use /orch <areas|all> to start."),
567
+ width,
568
+ ),
569
+ ];
570
+ }
571
+
572
+ const lines: string[] = [""];
573
+
574
+ // ── Phase-specific rendering ──────────────────
575
+ const phaseIcon =
576
+ vm.phase === "planning" ? "◌"
577
+ : vm.phase === "executing" ? "●"
578
+ : vm.phase === "merging" ? "🔀"
579
+ : vm.phase === "paused" ? "⏸"
580
+ : vm.phase === "stopped" ? "⛔"
581
+ : vm.phase === "completed" ? "✓"
582
+ : vm.phase === "failed" ? "✗"
583
+ : "○";
584
+ const phaseColor =
585
+ vm.phase === "executing" ? "accent"
586
+ : vm.phase === "merging" ? "accent"
587
+ : vm.phase === "completed" ? "success"
588
+ : vm.phase === "failed" || vm.phase === "stopped" ? "error"
589
+ : vm.phase === "paused" ? "warning"
590
+ : "dim";
591
+
592
+ // Header: phase icon + batch ID + wave + elapsed
593
+ const header =
594
+ theme.fg(phaseColor, ` ${phaseIcon} `) +
595
+ theme.fg("accent", theme.bold(vm.batchId || "—")) +
596
+ theme.fg("dim", " ") +
597
+ theme.fg("warning", `W${vm.waveProgress}`) +
598
+ theme.fg("dim", " · ") +
599
+ theme.fg("muted", vm.elapsed);
600
+ lines.push(truncateToWidth(header, width));
601
+
602
+ // ── Planning state ────────────────────────────
603
+ if (vm.phase === "planning") {
604
+ lines.push(truncateToWidth(
605
+ theme.fg("dim", " ◌ Planning batch..."),
606
+ width,
607
+ ));
608
+ return lines;
609
+ }
610
+
611
+ // ── Progress bar ──────────────────────────────
612
+ const { completed, failed, total } = vm.summary;
613
+ const done = completed + failed;
614
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
615
+ const barWidth = Math.min(30, width - 20);
616
+ const filled = Math.round((pct / 100) * barWidth);
617
+ const progressBar =
618
+ theme.fg("dim", " ") +
619
+ theme.fg("warning", "[") +
620
+ theme.fg("success", "█".repeat(filled)) +
621
+ theme.fg("dim", "░".repeat(Math.max(0, barWidth - filled))) +
622
+ theme.fg("warning", "]") +
623
+ theme.fg("dim", " ") +
624
+ theme.fg("accent", `${done}/${total}`) +
625
+ theme.fg("dim", ` (${pct}%)`);
626
+ lines.push(truncateToWidth(progressBar, width));
627
+
628
+ // ── Summary counts line ───────────────────────
629
+ const countParts: string[] = [];
630
+ if (vm.summary.completed > 0) countParts.push(theme.fg("success", `${vm.summary.completed} ✓`));
631
+ if (vm.summary.running > 0) countParts.push(theme.fg("accent", `${vm.summary.running} running`));
632
+ if (vm.summary.queued > 0) countParts.push(theme.fg("dim", `${vm.summary.queued} queued`));
633
+ if (vm.summary.failed > 0) countParts.push(theme.fg("error", `${vm.summary.failed} ✗`));
634
+ if (vm.summary.blocked > 0) countParts.push(theme.fg("warning", `${vm.summary.blocked} blocked`));
635
+ if (vm.summary.stalled > 0) countParts.push(theme.fg("warning", `${vm.summary.stalled} stalled`));
636
+ if (countParts.length > 0) {
637
+ lines.push(truncateToWidth(" " + countParts.join(theme.fg("dim", " · ")), width));
638
+ }
639
+ lines.push("");
640
+
641
+ // ── Lane cards ─────────────────────────────────
642
+ if (vm.laneCards.length > 0 && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
643
+ const arrowWidth = 3;
644
+ const minCardWidth = 18;
645
+ const maxCols = Math.max(1, Math.floor((width + arrowWidth) / (minCardWidth + arrowWidth)));
646
+ const cols = Math.min(vm.laneCards.length, maxCols);
647
+ const colWidth = Math.max(minCardWidth, Math.floor((width - arrowWidth * (cols - 1)) / cols));
648
+
649
+ for (let rowStart = 0; rowStart < vm.laneCards.length; rowStart += cols) {
650
+ const rowCards = vm.laneCards.slice(rowStart, rowStart + cols);
651
+ const rendered = rowCards.map(c => renderLaneCard(c, colWidth, theme));
652
+
653
+ if (rendered.length > 0) {
654
+ const cardHeight = rendered[0].length;
655
+ for (let line = 0; line < cardHeight; line++) {
656
+ let row = rendered[0][line];
657
+ for (let c = 1; c < rendered.length; c++) {
658
+ row += " "; // spacer between cards
659
+ row += rendered[c][line];
660
+ }
661
+ lines.push(truncateToWidth(row, width));
662
+ }
663
+ }
664
+ }
665
+ }
666
+
667
+ // ── Terminal states (completed/failed/stopped) ──
668
+ if (vm.phase === "completed") {
669
+ lines.push(truncateToWidth(
670
+ theme.fg("success", " ✅ Batch complete"),
671
+ width,
672
+ ));
673
+ } else if (vm.phase === "failed") {
674
+ lines.push(truncateToWidth(
675
+ theme.fg("error", " ❌ Batch failed"),
676
+ width,
677
+ ));
678
+ for (const err of vm.errors.slice(0, 3)) {
679
+ lines.push(truncateToWidth(
680
+ theme.fg("error", ` ${err.slice(0, 80)}`),
681
+ width,
682
+ ));
683
+ }
684
+ } else if (vm.phase === "stopped") {
685
+ lines.push(truncateToWidth(
686
+ theme.fg("error", ` ⛔ Stopped by ${vm.failurePolicy || "policy"}`),
687
+ width,
688
+ ));
689
+ } else if (vm.phase === "merging") {
690
+ lines.push("");
691
+ lines.push(truncateToWidth(
692
+ theme.fg("accent", " 🔀 Merging lane branches into develop..."),
693
+ width,
694
+ ));
695
+ } else if (vm.phase === "paused") {
696
+ lines.push("");
697
+ lines.push(truncateToWidth(
698
+ theme.fg("warning", " ⏸ Batch paused — lanes will stop after current tasks"),
699
+ width,
700
+ ));
701
+ }
702
+
703
+ // ── Footer: attach hint ───────────────────────
704
+ if (vm.attachHint && (vm.phase === "executing" || vm.phase === "merging" || vm.phase === "paused")) {
705
+ lines.push("");
706
+ lines.push(truncateToWidth(
707
+ theme.fg("dim", ` 💡 ${vm.attachHint}`),
708
+ width,
709
+ ));
710
+ }
711
+
712
+ return lines;
713
+ },
714
+ invalidate() {},
715
+ };
716
+ };
717
+ }
718
+