@kennethsolomon/shipkit 3.4.0 → 3.5.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.
package/README.md CHANGED
@@ -243,6 +243,7 @@ Requirement changes → /sk:change → re-enter at correct step
243
243
  |---------|-------------|
244
244
  | `/sk:help` | Show all commands and workflow overview |
245
245
  | `/sk:status` | Show workflow and task status at a glance |
246
+ | `/sk:dashboard` | Read-only workflow Kanban board — localhost server, multi-worktree |
246
247
  | `/sk:skill-creator` | Create or improve ShipKit skills |
247
248
 
248
249
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennethsolomon/shipkit",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "A structured workflow toolkit for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: sk:dashboard
3
+ description: Read-only workflow Kanban board — localhost server showing workflow status across git worktrees
4
+ license: Complete terms in LICENSE.txt
5
+ ---
6
+
7
+ # /sk:dashboard
8
+
9
+ ## Purpose
10
+
11
+ Read-only Kanban board that visualizes workflow progress across all git worktrees in a project. Runs as a standalone localhost server — no workflow integration required. Use it anytime you want a visual overview of where each worktree stands in the workflow.
12
+
13
+ ## How to Start
14
+
15
+ ```bash
16
+ node skills/sk:dashboard/server.js
17
+ ```
18
+
19
+ Opens on `http://localhost:3333`. Stop with `Ctrl+C`.
20
+
21
+ Override the port:
22
+
23
+ ```bash
24
+ node skills/sk:dashboard/server.js --port 4000
25
+ # or
26
+ PORT=4000 node skills/sk:dashboard/server.js
27
+ ```
28
+
29
+ ## What It Shows
30
+
31
+ - **Swimlanes per worktree** — one row per worktree discovered via `git worktree list`
32
+ - **Phase timeline** — workflow steps laid out as columns (Read, Explore, Plan, Branch, Tests, Implement, Lint, Verify, Security, Review, E2E, Finalize)
33
+ - **Status indicators** — done, skipped, partial, in-progress, not yet
34
+ - **Progress bars** — percentage of steps completed per worktree
35
+ - **Current task** — the active task name from `tasks/todo.md`
36
+
37
+ ## Architecture
38
+
39
+ Zero-dependency Node.js server. Uses only built-in modules (`http`, `fs`, `path`, `child_process`).
40
+
41
+ - `server.js` serves the dashboard HTML and exposes `/api/status`
42
+ - `/api/status` reads `tasks/workflow-status.md` and `tasks/todo.md` from each worktree, parses step statuses, and returns JSON
43
+ - `dashboard.html` is a single-file UI (HTML + embedded CSS + JS) that polls `/api/status` every 3 seconds
44
+ - Worktree discovery via `git worktree list`
45
+
46
+ ## Key Details
47
+
48
+ - Read-only — does not modify any files
49
+ - Auto-discovers worktrees via `git worktree list`
50
+ - Graceful degradation: missing files show empty state, offline falls back to system fonts
51
+ - Default port 3333, configurable via `--port` flag or `PORT` env var
52
+ - Uses only Node.js built-in modules (http, fs, path, child_process)
53
+
54
+ ## Files
55
+
56
+ - `server.js` — Node.js HTTP server (~150 lines)
57
+ - `dashboard.html` — Single-file Kanban UI (HTML + embedded CSS + JS)
58
+
59
+ ## Model Routing
60
+
61
+ Read `.shipkit/config.json` from the project root if it exists.
62
+
63
+ - If `model_overrides["sk:dashboard"]` is set, use that model — it takes precedence.
64
+ - Otherwise use the `profile` field. Default: `balanced`.
65
+
66
+ | Profile | Model |
67
+ |---------|-------|
68
+ | `full-sail` | sonnet |
69
+ | `quality` | sonnet |
70
+ | `balanced` | sonnet |
71
+ | `budget` | haiku |
@@ -0,0 +1,939 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ShipKit Mission Control</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@600&display=swap" rel="stylesheet">
10
+ <style>
11
+ *,
12
+ *::before,
13
+ *::after {
14
+ margin: 0;
15
+ padding: 0;
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ :root {
20
+ --color-bg: #080C14;
21
+ --color-surface: #111827;
22
+ --color-surface-alt: #1A2234;
23
+ --color-border: #1E2D4A;
24
+ --color-text: #E2E8F0;
25
+ --color-text-muted: #64748B;
26
+ --color-done: #10B981;
27
+ --color-active: #3B82F6;
28
+ --color-pending: #334155;
29
+ --color-skipped: #F59E0B;
30
+ --color-gate: #EF4444;
31
+ --color-partial: #8B5CF6;
32
+ --font-display: 'Orbitron', sans-serif;
33
+ --font-mono: 'JetBrains Mono', monospace;
34
+ }
35
+
36
+ html, body {
37
+ height: 100%;
38
+ background: var(--color-bg);
39
+ color: var(--color-text);
40
+ font-family: var(--font-mono);
41
+ font-size: 13px;
42
+ font-weight: 400;
43
+ -webkit-font-smoothing: antialiased;
44
+ -moz-osx-font-smoothing: grayscale;
45
+ }
46
+
47
+ /* ── Header ── */
48
+
49
+ .header {
50
+ position: fixed;
51
+ top: 0;
52
+ left: 0;
53
+ right: 0;
54
+ height: 56px;
55
+ background: var(--color-surface);
56
+ border-bottom: 1px solid var(--color-border);
57
+ display: flex;
58
+ align-items: center;
59
+ justify-content: space-between;
60
+ padding: 0 20px;
61
+ z-index: 100;
62
+ }
63
+
64
+ .header-left {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 10px;
68
+ }
69
+
70
+ .header-left svg {
71
+ width: 18px;
72
+ height: 18px;
73
+ fill: var(--color-active);
74
+ }
75
+
76
+ .header-title {
77
+ font-family: var(--font-display);
78
+ font-size: 14px;
79
+ font-weight: 600;
80
+ letter-spacing: 3px;
81
+ color: var(--color-text);
82
+ }
83
+
84
+ .header-right {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 16px;
88
+ font-size: 12px;
89
+ font-weight: 500;
90
+ color: var(--color-text-muted);
91
+ }
92
+
93
+ .live-indicator {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 6px;
97
+ }
98
+
99
+ .live-dot {
100
+ width: 7px;
101
+ height: 7px;
102
+ border-radius: 50%;
103
+ background: var(--color-done);
104
+ animation: pulse-live 2s infinite ease-in-out;
105
+ }
106
+
107
+ @keyframes pulse-live {
108
+ 0%, 100% { opacity: 1; }
109
+ 50% { opacity: 0.3; }
110
+ }
111
+
112
+ .live-label {
113
+ font-weight: 600;
114
+ color: var(--color-done);
115
+ font-size: 11px;
116
+ letter-spacing: 1px;
117
+ }
118
+
119
+ /* ── Content ── */
120
+
121
+ .content {
122
+ padding: 80px 24px 60px;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 16px;
126
+ min-height: 100vh;
127
+ }
128
+
129
+ /* ── Empty State ── */
130
+
131
+ .empty-state {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ min-height: 60vh;
136
+ color: var(--color-text-muted);
137
+ font-size: 15px;
138
+ font-weight: 500;
139
+ }
140
+
141
+ /* ── Swimlane ── */
142
+
143
+ .swimlane {
144
+ background: var(--color-surface);
145
+ border: 1px solid var(--color-border);
146
+ border-radius: 8px;
147
+ overflow: hidden;
148
+ }
149
+
150
+ .swimlane-header {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ padding: 14px 16px;
155
+ cursor: pointer;
156
+ user-select: none;
157
+ transition: background 0.15s;
158
+ }
159
+
160
+ .swimlane-header:hover {
161
+ background: var(--color-surface-alt);
162
+ }
163
+
164
+ .swimlane-header-left {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 10px;
168
+ min-width: 0;
169
+ }
170
+
171
+ .swimlane-header-left svg {
172
+ width: 16px;
173
+ height: 16px;
174
+ flex-shrink: 0;
175
+ stroke: var(--color-text-muted);
176
+ fill: none;
177
+ stroke-width: 2;
178
+ stroke-linecap: round;
179
+ stroke-linejoin: round;
180
+ }
181
+
182
+ .branch-name {
183
+ font-size: 14px;
184
+ font-weight: 600;
185
+ color: var(--color-text);
186
+ white-space: nowrap;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
+ }
190
+
191
+ .header-dot {
192
+ color: var(--color-text-muted);
193
+ font-size: 10px;
194
+ flex-shrink: 0;
195
+ }
196
+
197
+ .task-name {
198
+ font-size: 13px;
199
+ font-weight: 400;
200
+ color: var(--color-text-muted);
201
+ white-space: nowrap;
202
+ overflow: hidden;
203
+ text-overflow: ellipsis;
204
+ }
205
+
206
+ .swimlane-header-right {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 12px;
210
+ flex-shrink: 0;
211
+ }
212
+
213
+ .progress-fraction {
214
+ font-size: 13px;
215
+ font-weight: 600;
216
+ color: var(--color-text);
217
+ }
218
+
219
+ .progress-pct {
220
+ font-size: 12px;
221
+ font-weight: 500;
222
+ color: var(--color-text-muted);
223
+ }
224
+
225
+ .chevron {
226
+ width: 16px;
227
+ height: 16px;
228
+ stroke: var(--color-text-muted);
229
+ fill: none;
230
+ stroke-width: 2;
231
+ stroke-linecap: round;
232
+ stroke-linejoin: round;
233
+ transition: transform 0.3s ease;
234
+ flex-shrink: 0;
235
+ }
236
+
237
+ .swimlane.expanded .chevron {
238
+ transform: rotate(90deg);
239
+ }
240
+
241
+ /* ── Progress Bar ── */
242
+
243
+ .progress-bar-track {
244
+ height: 4px;
245
+ background: var(--color-pending);
246
+ margin: 0 16px 14px;
247
+ border-radius: 2px;
248
+ overflow: hidden;
249
+ }
250
+
251
+ .progress-bar-fill {
252
+ height: 100%;
253
+ border-radius: 2px;
254
+ background: linear-gradient(90deg, var(--color-active), var(--color-done));
255
+ transition: width 0.5s ease;
256
+ }
257
+
258
+ /* ── Expanded Body ── */
259
+
260
+ .swimlane-body {
261
+ max-height: 0;
262
+ overflow: hidden;
263
+ transition: max-height 0.3s ease;
264
+ }
265
+
266
+ .swimlane.expanded .swimlane-body {
267
+ max-height: 4000px;
268
+ }
269
+
270
+ .swimlane-body-inner {
271
+ padding: 0 16px 16px;
272
+ display: flex;
273
+ flex-direction: column;
274
+ gap: 14px;
275
+ }
276
+
277
+ /* ── Phase Timeline ── */
278
+
279
+ .phase-timeline {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ gap: 3px;
283
+ }
284
+
285
+ .phase-cell {
286
+ width: 44px;
287
+ height: 36px;
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ border-radius: 4px;
292
+ font-size: 10px;
293
+ font-weight: 600;
294
+ font-family: var(--font-mono);
295
+ position: relative;
296
+ cursor: default;
297
+ transition: background 0.15s;
298
+ }
299
+
300
+ .phase-cell.status-done {
301
+ background: var(--color-done);
302
+ color: #064E3B;
303
+ }
304
+
305
+ .phase-cell.status-active {
306
+ background: var(--color-active);
307
+ color: #FFFFFF;
308
+ animation: glow-active 2s infinite ease-in-out;
309
+ }
310
+
311
+ @keyframes glow-active {
312
+ 0%, 100% { box-shadow: 0 0 6px rgba(59, 130, 246, 0.4); }
313
+ 50% { box-shadow: 0 0 16px rgba(59, 130, 246, 0.7); }
314
+ }
315
+
316
+ .phase-cell.status-pending {
317
+ background: var(--color-pending);
318
+ color: var(--color-text-muted);
319
+ }
320
+
321
+ .phase-cell.status-skipped {
322
+ background: transparent;
323
+ outline: 1.5px solid var(--color-skipped);
324
+ color: var(--color-skipped);
325
+ }
326
+
327
+ .phase-cell.status-partial {
328
+ background: var(--color-partial);
329
+ color: #FFFFFF;
330
+ }
331
+
332
+ .phase-cell.hard-gate {
333
+ border-bottom: 2px solid var(--color-gate);
334
+ }
335
+
336
+ /* ── Tooltip ── */
337
+
338
+ .phase-cell .tooltip {
339
+ display: none;
340
+ position: absolute;
341
+ bottom: calc(100% + 6px);
342
+ left: 50%;
343
+ transform: translateX(-50%);
344
+ background: var(--color-surface-alt);
345
+ border: 1px solid var(--color-border);
346
+ border-radius: 6px;
347
+ padding: 8px 10px;
348
+ white-space: nowrap;
349
+ font-size: 11px;
350
+ font-weight: 400;
351
+ color: var(--color-text);
352
+ z-index: 50;
353
+ pointer-events: none;
354
+ line-height: 1.5;
355
+ }
356
+
357
+ .phase-cell .tooltip .tt-name {
358
+ font-weight: 600;
359
+ }
360
+
361
+ .phase-cell .tooltip .tt-cmd {
362
+ color: var(--color-active);
363
+ }
364
+
365
+ .phase-cell .tooltip .tt-notes {
366
+ color: var(--color-text-muted);
367
+ font-style: italic;
368
+ }
369
+
370
+ .phase-cell:hover .tooltip {
371
+ display: block;
372
+ }
373
+
374
+ /* ── Legend ── */
375
+
376
+ .legend {
377
+ display: flex;
378
+ flex-wrap: wrap;
379
+ gap: 14px;
380
+ font-size: 11px;
381
+ color: var(--color-text-muted);
382
+ }
383
+
384
+ .legend-item {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 5px;
388
+ }
389
+
390
+ .legend-dot {
391
+ width: 8px;
392
+ height: 8px;
393
+ border-radius: 2px;
394
+ flex-shrink: 0;
395
+ }
396
+
397
+ .legend-dot.ld-done { background: var(--color-done); }
398
+ .legend-dot.ld-active { background: var(--color-active); }
399
+ .legend-dot.ld-gate { background: var(--color-gate); }
400
+ .legend-dot.ld-skipped { background: transparent; outline: 1.5px solid var(--color-skipped); }
401
+ .legend-dot.ld-pending { background: var(--color-pending); }
402
+ .legend-dot.ld-partial { background: var(--color-partial); }
403
+
404
+ /* ── Active Step Card ── */
405
+
406
+ .active-step-card {
407
+ background: var(--color-surface-alt);
408
+ border-left: 3px solid var(--color-active);
409
+ border-radius: 0 6px 6px 0;
410
+ padding: 12px 14px;
411
+ display: flex;
412
+ align-items: center;
413
+ gap: 10px;
414
+ }
415
+
416
+ .active-step-num {
417
+ font-size: 18px;
418
+ font-weight: 700;
419
+ color: var(--color-active);
420
+ min-width: 28px;
421
+ }
422
+
423
+ .active-step-info {
424
+ display: flex;
425
+ flex-direction: column;
426
+ gap: 2px;
427
+ }
428
+
429
+ .active-step-name {
430
+ font-size: 13px;
431
+ font-weight: 600;
432
+ color: var(--color-text);
433
+ }
434
+
435
+ .active-step-cmd {
436
+ font-size: 12px;
437
+ font-weight: 400;
438
+ color: var(--color-active);
439
+ }
440
+
441
+ /* ── Status Columns ── */
442
+
443
+ .status-columns {
444
+ display: grid;
445
+ grid-template-columns: repeat(3, 1fr);
446
+ gap: 12px;
447
+ }
448
+
449
+ .status-col {
450
+ display: flex;
451
+ flex-direction: column;
452
+ gap: 6px;
453
+ }
454
+
455
+ .status-col-header {
456
+ display: flex;
457
+ align-items: center;
458
+ gap: 6px;
459
+ font-size: 11px;
460
+ font-weight: 600;
461
+ letter-spacing: 0.5px;
462
+ text-transform: uppercase;
463
+ padding-bottom: 6px;
464
+ border-bottom: 1px solid var(--color-border);
465
+ }
466
+
467
+ .status-col-dot {
468
+ width: 7px;
469
+ height: 7px;
470
+ border-radius: 50%;
471
+ flex-shrink: 0;
472
+ }
473
+
474
+ .status-col-dot.sc-done { background: var(--color-done); }
475
+ .status-col-dot.sc-skipped { background: var(--color-skipped); }
476
+ .status-col-dot.sc-pending { background: var(--color-pending); }
477
+
478
+ .status-col-count {
479
+ color: var(--color-text-muted);
480
+ font-weight: 400;
481
+ }
482
+
483
+ .status-col-list {
484
+ list-style: none;
485
+ font-size: 11px;
486
+ font-weight: 400;
487
+ color: var(--color-text-muted);
488
+ line-height: 1.7;
489
+ }
490
+
491
+ /* ── Tasks Panel ── */
492
+
493
+ .tasks-panel {
494
+ border-top: 1px solid var(--color-border);
495
+ padding-top: 12px;
496
+ display: flex;
497
+ flex-direction: column;
498
+ gap: 10px;
499
+ }
500
+
501
+ .tasks-panel-header {
502
+ display: flex;
503
+ align-items: center;
504
+ gap: 6px;
505
+ font-size: 11px;
506
+ font-weight: 600;
507
+ letter-spacing: 0.5px;
508
+ text-transform: uppercase;
509
+ padding-bottom: 6px;
510
+ border-bottom: 1px solid var(--color-border);
511
+ }
512
+
513
+ .tasks-section-label {
514
+ font-size: 10px;
515
+ font-weight: 600;
516
+ color: var(--color-text-muted);
517
+ text-transform: uppercase;
518
+ letter-spacing: 0.5px;
519
+ margin-top: 4px;
520
+ margin-bottom: 2px;
521
+ }
522
+
523
+ .todo-item {
524
+ display: flex;
525
+ align-items: baseline;
526
+ gap: 6px;
527
+ font-size: 11px;
528
+ font-weight: 400;
529
+ line-height: 1.6;
530
+ padding: 1px 0;
531
+ }
532
+
533
+ .todo-item.todo-done {
534
+ color: rgba(16, 185, 129, 0.65);
535
+ }
536
+
537
+ .todo-item.todo-current {
538
+ color: #3B82F6;
539
+ background: rgba(59, 130, 246, 0.08);
540
+ border-left: 2px solid #3B82F6;
541
+ padding-left: 6px;
542
+ border-radius: 0 3px 3px 0;
543
+ }
544
+
545
+ .todo-item.todo-pending {
546
+ color: #6B7280;
547
+ }
548
+
549
+ .todo-item-icon {
550
+ flex-shrink: 0;
551
+ font-style: normal;
552
+ }
553
+
554
+ /* ── Footer ── */
555
+
556
+ .footer {
557
+ position: fixed;
558
+ bottom: 0;
559
+ left: 0;
560
+ right: 0;
561
+ height: 36px;
562
+ background: var(--color-surface);
563
+ border-top: 1px solid var(--color-border);
564
+ display: flex;
565
+ align-items: center;
566
+ justify-content: center;
567
+ font-size: 11px;
568
+ font-weight: 400;
569
+ color: var(--color-text-muted);
570
+ z-index: 100;
571
+ }
572
+
573
+ /* ── Responsive ── */
574
+
575
+ @media (max-width: 768px) {
576
+ .phase-timeline {
577
+ flex-wrap: wrap;
578
+ }
579
+
580
+ .status-columns {
581
+ grid-template-columns: 1fr;
582
+ }
583
+
584
+ .header-title {
585
+ font-size: 11px;
586
+ letter-spacing: 1.5px;
587
+ }
588
+
589
+ .task-name {
590
+ display: none;
591
+ }
592
+
593
+ .header-dot {
594
+ display: none;
595
+ }
596
+ }
597
+ </style>
598
+ </head>
599
+ <body>
600
+
601
+ <!-- ── Header ── -->
602
+ <header class="header">
603
+ <div class="header-left">
604
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
605
+ <path d="M12 2 L22 12 L12 22 L2 12 Z"/>
606
+ </svg>
607
+ <span class="header-title">SHIPKIT MISSION CONTROL</span>
608
+ </div>
609
+ <div class="header-right">
610
+ <div class="live-indicator">
611
+ <span class="live-dot"></span>
612
+ <span class="live-label">LIVE</span>
613
+ </div>
614
+ <span class="refresh-interval">&#8635; 3s</span>
615
+ <span class="clock" id="clock">--:--</span>
616
+ </div>
617
+ </header>
618
+
619
+ <!-- ── Content ── -->
620
+ <main class="content" id="content">
621
+ <div class="empty-state">No active workflows detected.</div>
622
+ </main>
623
+
624
+ <!-- ── Footer ── -->
625
+ <footer class="footer" id="footer">
626
+ 0 worktrees &middot; Last refresh: --s ago &middot; Port 3333
627
+ </footer>
628
+
629
+ <script>
630
+ (function () {
631
+ 'use strict';
632
+
633
+ var lastRefreshTime = Date.now();
634
+ var expandedWorktrees = {};
635
+ var initialized = false;
636
+ var lastWorktreeCount = 0;
637
+ var lastResponseJson = '';
638
+
639
+ var contentEl = document.getElementById('content');
640
+ var footerEl = document.getElementById('footer');
641
+ var clockEl = document.getElementById('clock');
642
+
643
+ // ── SVG helpers ──
644
+
645
+ function svgBranch() {
646
+ return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
647
+ '<line x1="6" y1="3" x2="6" y2="15"/>' +
648
+ '<circle cx="18" cy="6" r="3"/>' +
649
+ '<circle cx="6" cy="18" r="3"/>' +
650
+ '<path d="M18 9a9 9 0 0 1-9 9"/>' +
651
+ '</svg>';
652
+ }
653
+
654
+ function svgChevron() {
655
+ return '<svg class="chevron" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
656
+ '<polyline points="9 6 15 12 9 18"/>' +
657
+ '</svg>';
658
+ }
659
+
660
+ // ── Helpers ──
661
+
662
+ function esc(str) {
663
+ return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
664
+ }
665
+
666
+ function classifyStatus(raw) {
667
+ if (!raw) return 'pending';
668
+ var s = raw.toLowerCase().trim();
669
+ if (s === 'done') return 'done';
670
+ if (s === 'skipped') return 'skipped';
671
+ if (s === 'partial') return 'partial';
672
+ if (s.indexOf('>> next <<') !== -1) return 'active';
673
+ if (s === 'not yet' || s === '') return 'pending';
674
+ return 'pending';
675
+ }
676
+
677
+ function statusCol(label, dotCls, items) {
678
+ var h = '<div class="status-col">';
679
+ h += '<div class="status-col-header"><span class="status-col-dot ' + dotCls + '"></span>' + label + ' <span class="status-col-count">(' + items.length + ')</span></div>';
680
+ h += '<ul class="status-col-list">';
681
+ for (var i = 0; i < items.length; i++) h += '<li>' + esc(items[i]) + '</li>';
682
+ h += '</ul></div>';
683
+ return h;
684
+ }
685
+
686
+ function renderTodoItems(todoItems) {
687
+ if (!todoItems || todoItems.length === 0) return '';
688
+
689
+ // Find first undone item index
690
+ var currentIdx = -1;
691
+ for (var i = 0; i < todoItems.length; i++) {
692
+ if (!todoItems[i].done) { currentIdx = i; break; }
693
+ }
694
+
695
+ var h = '<div class="tasks-panel">';
696
+ h += '<div class="tasks-panel-header">TASKS</div>';
697
+
698
+ var lastSection = null;
699
+ for (var j = 0; j < todoItems.length; j++) {
700
+ var item = todoItems[j];
701
+ if (item.section && item.section !== lastSection) {
702
+ h += '<div class="tasks-section-label">' + esc(item.section) + '</div>';
703
+ lastSection = item.section;
704
+ }
705
+ var cls, icon;
706
+ if (item.done) {
707
+ cls = 'todo-done'; icon = '\u2713';
708
+ } else if (j === currentIdx) {
709
+ cls = 'todo-current'; icon = '\u2192';
710
+ } else {
711
+ cls = 'todo-pending'; icon = '\u25CB';
712
+ }
713
+ h += '<div class="todo-item ' + cls + '">';
714
+ h += '<span class="todo-item-icon">' + icon + '</span>';
715
+ h += '<span>' + esc(item.text) + '</span>';
716
+ h += '</div>';
717
+ }
718
+
719
+ h += '</div>';
720
+ return h;
721
+ }
722
+
723
+ // ── Render one worktree swimlane ──
724
+
725
+ function renderWorktree(wt, index, total) {
726
+ var steps = wt.steps || [];
727
+ var branch = wt.branch || 'unknown';
728
+ var taskName = wt.taskName || '';
729
+ var wtId = wt.path || branch;
730
+
731
+ // Set initial expand state
732
+ if (!initialized) {
733
+ expandedWorktrees[wtId] = (total === 1) || (index === 0);
734
+ }
735
+ var isExpanded = !!expandedWorktrees[wtId];
736
+
737
+ // Classify all 27 steps
738
+ var doneSteps = [];
739
+ var skippedSteps = [];
740
+ var pendingSteps = [];
741
+ var activeStep = null;
742
+
743
+ for (var i = 0; i < steps.length; i++) {
744
+ var step = steps[i];
745
+ var st = classifyStatus(step.status);
746
+ var label = step.name || ('Step ' + step.number);
747
+
748
+ if (st === 'done') {
749
+ doneSteps.push(label);
750
+ } else if (st === 'partial') {
751
+ doneSteps.push(label + ' (partial)');
752
+ } else if (st === 'skipped') {
753
+ skippedSteps.push(label);
754
+ } else if (st === 'active') {
755
+ activeStep = step;
756
+ pendingSteps.push(label);
757
+ } else {
758
+ pendingSteps.push(label);
759
+ }
760
+ }
761
+
762
+ var completed = doneSteps.length + skippedSteps.length;
763
+ var totalSteps = steps.length || 27;
764
+ var pct = Math.round((completed / totalSteps) * 100);
765
+
766
+ var h = '';
767
+ h += '<div class="swimlane' + (isExpanded ? ' expanded' : '') + '" data-wt="' + esc(wtId) + '">';
768
+
769
+ // ── Header row ──
770
+ h += '<div class="swimlane-header" data-wt-toggle="' + esc(wtId) + '">';
771
+ h += '<div class="swimlane-header-left">';
772
+ h += svgBranch();
773
+ h += '<span class="branch-name">' + esc(branch) + '</span>';
774
+ if (taskName) {
775
+ h += '<span class="header-dot">&bull;</span>';
776
+ h += '<span class="task-name">' + esc(taskName) + '</span>';
777
+ }
778
+ h += '</div>';
779
+ h += '<div class="swimlane-header-right">';
780
+ h += '<span class="progress-fraction">' + completed + '/' + totalSteps + '</span>';
781
+ h += '<span class="progress-pct">' + pct + '%</span>';
782
+ h += svgChevron();
783
+ h += '</div>';
784
+ h += '</div>';
785
+
786
+ // ── Progress bar ──
787
+ h += '<div class="progress-bar-track">';
788
+ h += '<div class="progress-bar-fill" style="width:' + pct + '%"></div>';
789
+ h += '</div>';
790
+
791
+ // ── Expanded body ──
792
+ h += '<div class="swimlane-body"><div class="swimlane-body-inner">';
793
+
794
+ // Phase timeline
795
+ h += '<div class="phase-timeline">';
796
+ for (var j = 0; j < steps.length; j++) {
797
+ var sd = steps[j];
798
+ var cls = classifyStatus(sd.status);
799
+ var cellCls = 'phase-cell status-' + cls + (sd.isHardGate ? ' hard-gate' : '');
800
+
801
+ h += '<div class="' + cellCls + '">';
802
+ h += sd.number;
803
+ h += '<div class="tooltip">';
804
+ h += '<div class="tt-name">' + esc(sd.name) + '</div>';
805
+ if (sd.command) h += '<div class="tt-cmd">' + esc(sd.command) + '</div>';
806
+ if (sd.notes) h += '<div class="tt-notes">' + esc(sd.notes) + '</div>';
807
+ h += '</div>';
808
+ h += '</div>';
809
+ }
810
+ h += '</div>';
811
+
812
+ // Legend
813
+ h += '<div class="legend">';
814
+ h += '<div class="legend-item"><div class="legend-dot ld-done"></div>Done</div>';
815
+ h += '<div class="legend-item"><div class="legend-dot ld-active"></div>Next</div>';
816
+ h += '<div class="legend-item"><div class="legend-dot ld-gate"></div>Hard Gate</div>';
817
+ h += '<div class="legend-item"><div class="legend-dot ld-skipped"></div>Skipped</div>';
818
+ h += '<div class="legend-item"><div class="legend-dot ld-partial"></div>Partial</div>';
819
+ h += '<div class="legend-item"><div class="legend-dot ld-pending"></div>Not Yet</div>';
820
+ h += '</div>';
821
+
822
+ // Active step card
823
+ if (activeStep) {
824
+ h += '<div class="active-step-card">';
825
+ h += '<span class="active-step-num">' + activeStep.number + '</span>';
826
+ h += '<div class="active-step-info">';
827
+ h += '<span class="active-step-name">' + esc(activeStep.name) + '</span>';
828
+ if (activeStep.command) {
829
+ h += '<span class="active-step-cmd">' + esc(activeStep.command) + '</span>';
830
+ }
831
+ h += '</div>';
832
+ h += '</div>';
833
+ }
834
+
835
+ // Status columns
836
+ h += '<div class="status-columns">';
837
+ h += statusCol('Done', 'sc-done', doneSteps);
838
+ h += statusCol('Skipped', 'sc-skipped', skippedSteps);
839
+ h += statusCol('Not Yet', 'sc-pending', pendingSteps);
840
+ h += '</div>'; // status-columns
841
+ h += renderTodoItems(wt.todoItems);
842
+ h += '</div></div>'; // swimlane-body-inner, swimlane-body
843
+ h += '</div>'; // swimlane
844
+
845
+ return h;
846
+ }
847
+
848
+ // ── Render all worktrees ──
849
+
850
+ function render(worktrees) {
851
+ if (!worktrees || worktrees.length === 0) {
852
+ contentEl.innerHTML = '<div class="empty-state">No active workflows detected.</div>';
853
+ document.title = 'ShipKit Mission Control';
854
+ return;
855
+ }
856
+
857
+ // Count active worktrees
858
+ var activeCount = 0;
859
+ for (var a = 0; a < worktrees.length; a++) {
860
+ if (worktrees[a].currentStep > 0) activeCount++;
861
+ }
862
+ document.title = 'ShipKit \u2014 ' + activeCount + ' active';
863
+
864
+ var html = '';
865
+ for (var i = 0; i < worktrees.length; i++) {
866
+ html += renderWorktree(worktrees[i], i, worktrees.length);
867
+ }
868
+ contentEl.innerHTML = html;
869
+ initialized = true;
870
+
871
+ // Bind toggle click handlers
872
+ var toggles = contentEl.querySelectorAll('[data-wt-toggle]');
873
+ for (var t = 0; t < toggles.length; t++) {
874
+ toggles[t].addEventListener('click', handleToggle);
875
+ }
876
+ }
877
+
878
+ function handleToggle(e) {
879
+ var el = e.currentTarget;
880
+ var wtId = el.getAttribute('data-wt-toggle');
881
+ expandedWorktrees[wtId] = !expandedWorktrees[wtId];
882
+ var swimlane = el.closest('.swimlane');
883
+ if (swimlane) swimlane.classList.toggle('expanded');
884
+ }
885
+
886
+ // ── Clock ──
887
+
888
+ function updateClock() {
889
+ var now = new Date();
890
+ var hh = String(now.getHours()).padStart(2, '0');
891
+ var mm = String(now.getMinutes()).padStart(2, '0');
892
+ clockEl.textContent = hh + ':' + mm;
893
+ }
894
+
895
+ // ── Footer ──
896
+
897
+ function updateFooter() {
898
+ var ago = Math.round((Date.now() - lastRefreshTime) / 1000);
899
+ footerEl.textContent = lastWorktreeCount + ' worktree' + (lastWorktreeCount !== 1 ? 's' : '') +
900
+ ' \u00B7 Last refresh: ' + ago + 's ago \u00B7 Port 3333';
901
+ }
902
+
903
+ // ── Polling ──
904
+
905
+ function fetchStatus() {
906
+ fetch('/api/status')
907
+ .then(function (res) {
908
+ if (!res.ok) throw new Error('HTTP ' + res.status);
909
+ return res.json();
910
+ })
911
+ .then(function (data) {
912
+ lastRefreshTime = Date.now();
913
+ var worktrees = Array.isArray(data) ? data : (data.worktrees || []);
914
+ lastWorktreeCount = worktrees.length;
915
+ var json = JSON.stringify(data);
916
+ if (json !== lastResponseJson) {
917
+ lastResponseJson = json;
918
+ render(worktrees);
919
+ }
920
+ updateFooter();
921
+ })
922
+ .catch(function (err) {
923
+ console.warn('Dashboard fetch failed:', err.message || err);
924
+ });
925
+ }
926
+
927
+ // ── Init ──
928
+
929
+ updateClock();
930
+ fetchStatus();
931
+
932
+ setInterval(fetchStatus, 3000);
933
+ setInterval(updateClock, 1000);
934
+ setInterval(updateFooter, 1000);
935
+ })();
936
+ </script>
937
+
938
+ </body>
939
+ </html>
@@ -0,0 +1,203 @@
1
+ // ShipKit Dashboard — zero-dependency Node.js server
2
+ const http = require("http");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { execSync } = require("child_process");
6
+
7
+ const PORT =
8
+ parseInt(process.argv.find((_, i, a) => a[i - 1] === "--port") || process.env.PORT, 10) || 3333;
9
+
10
+ const HARD_GATES = new Set([12, 14, 16, 20, 22]);
11
+ const OPTIONALS = new Set([4, 5, 8, 18, 27]);
12
+
13
+ function stripMd(s) {
14
+ return (s || "").replace(/\*\*/g, "").replace(/`/g, "").trim();
15
+ }
16
+
17
+ function discoverWorktrees() {
18
+ try {
19
+ const raw = execSync("git worktree list", { encoding: "utf8" });
20
+ return raw
21
+ .trim()
22
+ .split("\n")
23
+ .filter(Boolean)
24
+ .map((line) => {
25
+ const branchMatch = line.match(/^(.+?)\s+[0-9a-f]+\s+\[(.+?)\]$/);
26
+ if (branchMatch) return { path: branchMatch[1].trim(), branch: branchMatch[2].trim() };
27
+ const detachedMatch = line.match(/^(.+?)\s+[0-9a-f]+\s+\((.+?)\)$/);
28
+ if (detachedMatch) return { path: detachedMatch[1].trim(), branch: detachedMatch[2].trim() };
29
+ return null;
30
+ })
31
+ .filter(Boolean);
32
+ } catch {
33
+ return [{ path: process.cwd(), branch: "unknown" }];
34
+ }
35
+ }
36
+
37
+ function parseWorkflowStatus(worktreePath) {
38
+ const filePath = path.join(worktreePath, "tasks", "workflow-status.md");
39
+ try {
40
+ const lines = fs.readFileSync(filePath, "utf8").split("\n");
41
+
42
+ let headerFound = false;
43
+ let separatorSkipped = false;
44
+ const steps = [];
45
+
46
+ for (const line of lines) {
47
+ if (!headerFound) {
48
+ if (line.includes("| # |")) headerFound = true;
49
+ continue;
50
+ }
51
+ if (!separatorSkipped) {
52
+ separatorSkipped = true;
53
+ continue;
54
+ }
55
+ const cells = line.split("|").slice(1, -1).map((c) => c.trim());
56
+ if (cells.length < 3) continue;
57
+
58
+ const number = parseInt(cells[0], 10);
59
+ if (isNaN(number)) continue;
60
+
61
+ const rawStep = stripMd(cells[1]);
62
+ const cmdMatch = rawStep.match(/\((.+?)\)/);
63
+ const command = cmdMatch ? cmdMatch[1].trim() : "";
64
+ const name = rawStep.replace(/\s*\(.+?\)\s*/, "").trim();
65
+
66
+ steps.push({
67
+ number,
68
+ name,
69
+ command,
70
+ status: stripMd(cells[2]),
71
+ notes: stripMd(cells[3]),
72
+ isHardGate: HARD_GATES.has(number),
73
+ isOptional: OPTIONALS.has(number),
74
+ });
75
+ }
76
+ return steps;
77
+ } catch (err) {
78
+ if (err.code === "ENOENT") return [];
79
+ process.stderr.write(`Error parsing workflow-status.md: ${err.message}\n`);
80
+ return [];
81
+ }
82
+ }
83
+
84
+ const STOP_HEADERS = new Set(["Verification", "Acceptance Criteria", "Risks", "Change Log", "Summary"]);
85
+
86
+ function parseTodo(worktreePath) {
87
+ const filePath = path.join(worktreePath, "tasks", "todo.md");
88
+ try {
89
+ const lines = fs.readFileSync(filePath, "utf8").split("\n");
90
+
91
+ let taskName = "";
92
+ let done = 0;
93
+ let total = 0;
94
+ let section = "";
95
+ let inMilestones = false;
96
+ let pastMilestones = false;
97
+ const todoItems = [];
98
+
99
+ for (const line of lines) {
100
+ if (!taskName && line.startsWith("# TODO")) {
101
+ const dashIdx = line.indexOf("—");
102
+ if (dashIdx !== -1) taskName = line.slice(dashIdx + 1).trim();
103
+ else taskName = line.replace(/^#\s*TODO\s*/, "").trim();
104
+ }
105
+
106
+ if (line.startsWith("## ")) {
107
+ const header = line.slice(3).trim();
108
+ if (header.startsWith("Milestone")) {
109
+ inMilestones = true;
110
+ section = header;
111
+ } else if (inMilestones && STOP_HEADERS.has(header)) {
112
+ pastMilestones = true;
113
+ }
114
+ }
115
+
116
+ if (/^\s*-\s*\[x\]/i.test(line)) {
117
+ done++;
118
+ total++;
119
+ if (inMilestones && !pastMilestones) {
120
+ todoItems.push({ text: stripMd(line.replace(/^\s*-\s*\[x\]\s*/i, "")), done: true, section });
121
+ }
122
+ } else if (/^\s*-\s*\[\s\]/.test(line)) {
123
+ total++;
124
+ if (inMilestones && !pastMilestones) {
125
+ todoItems.push({ text: stripMd(line.replace(/^\s*-\s*\[\s\]\s*/, "")), done: false, section });
126
+ }
127
+ }
128
+ }
129
+ return { taskName, todosDone: done, todosTotal: total, todoItems };
130
+ } catch (err) {
131
+ if (err.code === "ENOENT") return { taskName: "", todosDone: 0, todosTotal: 0, todoItems: [] };
132
+ process.stderr.write(`Error parsing todo.md: ${err.message}\n`);
133
+ return { taskName: "", todosDone: 0, todosTotal: 0, todoItems: [] };
134
+ }
135
+ }
136
+
137
+ function buildStatus() {
138
+ const worktrees = discoverWorktrees();
139
+ return worktrees.map((wt) => {
140
+ const steps = parseWorkflowStatus(wt.path);
141
+ const todo = parseTodo(wt.path);
142
+
143
+ let currentStep = 0;
144
+ let totalDone = 0;
145
+ let totalSkipped = 0;
146
+ for (const s of steps) {
147
+ if (s.status === ">> next <<") currentStep = s.number;
148
+ if (s.status === "done") totalDone++;
149
+ if (s.status === "skipped") totalSkipped++;
150
+ }
151
+
152
+ return {
153
+ path: wt.path,
154
+ branch: wt.branch,
155
+ taskName: todo.taskName,
156
+ todosDone: todo.todosDone,
157
+ todosTotal: todo.todosTotal,
158
+ todoItems: todo.todoItems,
159
+ currentStep,
160
+ totalDone,
161
+ totalSkipped,
162
+ totalSteps: steps.length,
163
+ steps,
164
+ };
165
+ });
166
+ }
167
+
168
+ const server = http.createServer((req, res) => {
169
+ res.setHeader("Access-Control-Allow-Origin", "*");
170
+
171
+ if (req.method === "GET" && req.url === "/") {
172
+ const htmlPath = path.join(__dirname, "dashboard.html");
173
+ try {
174
+ const html = fs.readFileSync(htmlPath, "utf8");
175
+ res.writeHead(200, { "Content-Type": "text/html" });
176
+ res.end(html);
177
+ } catch {
178
+ res.writeHead(404, { "Content-Type": "text/plain" });
179
+ res.end("dashboard.html not found");
180
+ }
181
+ return;
182
+ }
183
+
184
+ if (req.method === "GET" && req.url === "/api/status") {
185
+ try {
186
+ const data = buildStatus();
187
+ res.writeHead(200, { "Content-Type": "application/json" });
188
+ res.end(JSON.stringify(data));
189
+ } catch (err) {
190
+ process.stderr.write(`Error building status: ${err.message}\n`);
191
+ res.writeHead(500, { "Content-Type": "application/json" });
192
+ res.end(JSON.stringify({ error: "Internal server error" }));
193
+ }
194
+ return;
195
+ }
196
+
197
+ res.writeHead(404, { "Content-Type": "text/plain" });
198
+ res.end("Not found");
199
+ });
200
+
201
+ server.listen(PORT, () => {
202
+ console.log(`ShipKit Dashboard running at http://localhost:${PORT}`);
203
+ });