taskledger 0.1.0__py3-none-any.whl

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 (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. taskledger-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1742 @@
1
+ from __future__ import annotations
2
+
3
+ # ruff: noqa: E501
4
+ import hashlib
5
+ import json
6
+ import socket
7
+ import webbrowser
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from pathlib import Path
12
+ from textwrap import dedent
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from taskledger.errors import LaunchError
16
+ from taskledger.services.serve_read_model import (
17
+ serve_dashboard_snapshot,
18
+ serve_project_summary,
19
+ serve_task_events,
20
+ serve_task_summaries,
21
+ )
22
+ from taskledger.storage.task_store import (
23
+ resolve_task_or_active,
24
+ resolve_v2_paths,
25
+ task_dir,
26
+ )
27
+
28
+
29
+ @dataclass(slots=True, frozen=True)
30
+ class DashboardServerConfig:
31
+ workspace_root: Path
32
+ host: str = "127.0.0.1"
33
+ port: int = 8765
34
+ task_ref: str | None = None
35
+ refresh_ms: int = 1000
36
+ open_browser: bool = False
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class CachedResponse:
41
+ revision: str
42
+ body: bytes
43
+ content_type: str
44
+
45
+
46
+ class _DashboardHTTPServer(ThreadingHTTPServer):
47
+ daemon_threads = True
48
+ allow_reuse_address = True
49
+ workspace_root: Path
50
+ default_task_ref: str | None
51
+ refresh_ms: int
52
+ cache: dict[str, CachedResponse]
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class DashboardServerHandle:
57
+ server: _DashboardHTTPServer
58
+ host: str
59
+ port: int
60
+ url: str
61
+ _serving: bool = False
62
+
63
+ def serve_forever(self) -> None:
64
+ self._serving = True
65
+ try:
66
+ self.server.serve_forever()
67
+ finally:
68
+ self._serving = False
69
+
70
+ def close(self) -> None:
71
+ if self._serving:
72
+ self.server.shutdown()
73
+ self.server.server_close()
74
+
75
+
76
+ def render_index_html(*, refresh_ms: int, task_ref: str | None) -> str:
77
+ return "\n".join(
78
+ [
79
+ "<!doctype html>",
80
+ '<html lang="en">',
81
+ "<head>",
82
+ ' <meta charset="utf-8">',
83
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">',
84
+ " <title>Taskledger dashboard</title>",
85
+ _render_dashboard_css(),
86
+ "</head>",
87
+ _render_dashboard_body(),
88
+ _render_dashboard_script(refresh_ms, task_ref),
89
+ "</html>",
90
+ ]
91
+ )
92
+
93
+
94
+ def _render_dashboard_css() -> str:
95
+ return dedent(
96
+ """\
97
+ <style>
98
+ :root {
99
+ color-scheme: light dark;
100
+ --bg: #f6f7f9;
101
+ --panel: #ffffff;
102
+ --panel-muted: #f7f9fc;
103
+ --panel-strong: #e8eef8;
104
+ --text: #0f172a;
105
+ --muted: #64748b;
106
+ --border: #dde4ee;
107
+ --accent: #2563eb;
108
+ --accent-soft: rgba(37, 99, 235, 0.12);
109
+ --success: #15803d;
110
+ --warning: #b45309;
111
+ --danger: #b91c1c;
112
+ --radius: 14px;
113
+ --shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
114
+ }
115
+
116
+ @media (prefers-color-scheme: dark) {
117
+ :root {
118
+ --bg: #0b1020;
119
+ --panel: #111827;
120
+ --panel-muted: #182235;
121
+ --panel-strong: #1f2d44;
122
+ --text: #e5e7eb;
123
+ --muted: #9ca3af;
124
+ --border: #263244;
125
+ --accent-soft: rgba(96, 165, 250, 0.18);
126
+ --shadow: none;
127
+ }
128
+ }
129
+
130
+ * { box-sizing: border-box; }
131
+ html, body { min-height: 100%; }
132
+ body {
133
+ margin: 0;
134
+ background: var(--bg);
135
+ color: var(--text);
136
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
137
+ line-height: 1.5;
138
+ }
139
+ button, input {
140
+ font: inherit;
141
+ color: inherit;
142
+ }
143
+ code, pre, .mono {
144
+ font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
145
+ }
146
+ pre {
147
+ margin: 0;
148
+ white-space: pre-wrap;
149
+ overflow-wrap: anywhere;
150
+ }
151
+ button {
152
+ border: none;
153
+ background: none;
154
+ padding: 0;
155
+ }
156
+ button:focus-visible,
157
+ input:focus-visible,
158
+ summary:focus-visible {
159
+ outline: 2px solid var(--accent);
160
+ outline-offset: 2px;
161
+ }
162
+ .app-header {
163
+ position: sticky;
164
+ top: 0;
165
+ z-index: 10;
166
+ display: flex;
167
+ justify-content: space-between;
168
+ gap: 1rem;
169
+ align-items: flex-start;
170
+ padding: 1rem 1.25rem;
171
+ border-bottom: 1px solid var(--border);
172
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
173
+ backdrop-filter: blur(8px);
174
+ }
175
+ .eyebrow {
176
+ margin: 0 0 0.35rem 0;
177
+ color: var(--muted);
178
+ font-size: 0.8rem;
179
+ letter-spacing: 0.08em;
180
+ text-transform: uppercase;
181
+ }
182
+ .app-header h1 {
183
+ margin: 0;
184
+ font-size: 1.6rem;
185
+ }
186
+ .app-subtitle,
187
+ .status-detail {
188
+ margin: 0.35rem 0 0 0;
189
+ color: var(--muted);
190
+ max-width: 52rem;
191
+ }
192
+ .header-tools,
193
+ .header-meta {
194
+ display: grid;
195
+ gap: 0.75rem;
196
+ }
197
+ .header-tools {
198
+ min-width: min(100%, 28rem);
199
+ }
200
+ .meta-card,
201
+ .card {
202
+ border: 1px solid var(--border);
203
+ border-radius: var(--radius);
204
+ background: var(--panel);
205
+ box-shadow: var(--shadow);
206
+ }
207
+ .meta-card {
208
+ padding: 0.75rem 0.9rem;
209
+ }
210
+ .meta-card strong {
211
+ display: block;
212
+ margin-top: 0.15rem;
213
+ }
214
+ .meta-label {
215
+ display: block;
216
+ color: var(--muted);
217
+ font-size: 0.8rem;
218
+ margin-bottom: 0.25rem;
219
+ }
220
+ .header-controls {
221
+ display: flex;
222
+ gap: 0.65rem;
223
+ flex-wrap: wrap;
224
+ }
225
+ .action-button,
226
+ .copy-button {
227
+ border-radius: 10px;
228
+ border: 1px solid var(--border);
229
+ background: var(--panel);
230
+ padding: 0.55rem 0.8rem;
231
+ cursor: pointer;
232
+ }
233
+ .action-button-primary {
234
+ background: var(--accent);
235
+ border-color: var(--accent);
236
+ color: white;
237
+ }
238
+ .status-live {
239
+ color: var(--success);
240
+ }
241
+ .status-paused {
242
+ color: var(--warning);
243
+ }
244
+ .status-refreshing {
245
+ color: var(--accent);
246
+ }
247
+ .dashboard-layout {
248
+ display: grid;
249
+ grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr) minmax(18rem, 22rem);
250
+ gap: 1.1rem;
251
+ padding: 1rem 1.25rem 1.5rem;
252
+ align-items: start;
253
+ }
254
+ .sidebar-sticky,
255
+ .rail-sticky {
256
+ position: sticky;
257
+ top: 6.75rem;
258
+ display: grid;
259
+ gap: 1rem;
260
+ }
261
+ .sidebar-sticky {
262
+ max-height: calc(100vh - 7.75rem);
263
+ }
264
+ .main-column,
265
+ .hero-grid,
266
+ .metric-grid,
267
+ .section-stack,
268
+ .rail-sticky {
269
+ display: grid;
270
+ gap: 1rem;
271
+ }
272
+ .main-column {
273
+ min-width: 0;
274
+ }
275
+ .metric-grid {
276
+ grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
277
+ }
278
+ .card {
279
+ padding: 1.05rem;
280
+ min-width: 0;
281
+ }
282
+ .muted-card {
283
+ background: var(--panel-muted);
284
+ }
285
+ .card-header {
286
+ display: flex;
287
+ justify-content: space-between;
288
+ gap: 0.75rem;
289
+ align-items: flex-start;
290
+ margin-bottom: 0.9rem;
291
+ }
292
+ .card-header h2,
293
+ .card-header h3 {
294
+ margin: 0;
295
+ font-size: 1rem;
296
+ }
297
+ .hero-card {
298
+ padding: 1.15rem;
299
+ }
300
+ .next-action-card {
301
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
302
+ box-shadow: 0 14px 30px rgba(37, 99, 235, 0.12);
303
+ }
304
+ .hero-title {
305
+ display: flex;
306
+ flex-wrap: wrap;
307
+ gap: 0.5rem;
308
+ align-items: center;
309
+ margin-bottom: 0.6rem;
310
+ }
311
+ .hero-title h2 {
312
+ margin: 0;
313
+ font-size: 1.35rem;
314
+ }
315
+ .hero-meta,
316
+ .mini-meta,
317
+ .metric-value,
318
+ .list-grid,
319
+ .timeline {
320
+ display: grid;
321
+ gap: 0.65rem;
322
+ }
323
+ .hero-meta {
324
+ grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
325
+ }
326
+ .meta-row {
327
+ padding: 0.75rem;
328
+ border-radius: 12px;
329
+ border: 1px solid var(--border);
330
+ background: var(--panel-muted);
331
+ }
332
+ .meta-row strong,
333
+ .metric-number {
334
+ display: block;
335
+ font-size: 1.1rem;
336
+ margin-top: 0.2rem;
337
+ }
338
+ .muted {
339
+ color: var(--muted);
340
+ }
341
+ .badge-row,
342
+ .filter-row,
343
+ .pill-row {
344
+ display: flex;
345
+ flex-wrap: wrap;
346
+ gap: 0.5rem;
347
+ }
348
+ .badge,
349
+ .chip {
350
+ display: inline-flex;
351
+ align-items: center;
352
+ gap: 0.35rem;
353
+ border-radius: 999px;
354
+ font-size: 0.8rem;
355
+ line-height: 1;
356
+ padding: 0.38rem 0.65rem;
357
+ border: 1px solid var(--border);
358
+ background: color-mix(in srgb, var(--panel-muted) 75%, transparent);
359
+ color: var(--text);
360
+ }
361
+ .chip {
362
+ cursor: pointer;
363
+ }
364
+ .chip[data-active="true"],
365
+ .task-card[data-active="true"] {
366
+ border-color: var(--accent);
367
+ background: var(--accent-soft);
368
+ }
369
+ .badge-success { color: var(--success); }
370
+ .badge-warning { color: var(--warning); }
371
+ .badge-danger { color: var(--danger); }
372
+ .badge-info { color: var(--accent); }
373
+ .badge-muted { color: var(--muted); }
374
+ .search-stack {
375
+ display: grid;
376
+ gap: 0.65rem;
377
+ }
378
+ .search-stack label {
379
+ font-size: 0.85rem;
380
+ color: var(--muted);
381
+ }
382
+ .task-search {
383
+ width: 100%;
384
+ border: 1px solid var(--border);
385
+ border-radius: 12px;
386
+ background: var(--panel);
387
+ padding: 0.8rem 0.9rem;
388
+ }
389
+ .task-list {
390
+ display: grid;
391
+ gap: 0.7rem;
392
+ overflow: auto;
393
+ padding-right: 0.15rem;
394
+ }
395
+ .task-card {
396
+ width: 100%;
397
+ text-align: left;
398
+ border: 1px solid var(--border);
399
+ border-radius: 14px;
400
+ background: var(--panel);
401
+ box-shadow: var(--shadow);
402
+ padding: 0.9rem;
403
+ cursor: pointer;
404
+ }
405
+ .task-card:hover {
406
+ border-color: var(--accent);
407
+ }
408
+ .task-title {
409
+ margin: 0.4rem 0 0.3rem 0;
410
+ font-size: 0.96rem;
411
+ font-weight: 650;
412
+ }
413
+ .meta-line {
414
+ color: var(--muted);
415
+ font-size: 0.82rem;
416
+ }
417
+ .summary-line {
418
+ margin-top: 0.55rem;
419
+ color: var(--muted);
420
+ font-size: 0.85rem;
421
+ }
422
+ .empty-state {
423
+ border: 1px dashed var(--border);
424
+ border-radius: 14px;
425
+ padding: 0.9rem;
426
+ color: var(--muted);
427
+ background: var(--panel-muted);
428
+ }
429
+ .command-row {
430
+ display: flex;
431
+ gap: 0.65rem;
432
+ align-items: center;
433
+ flex-wrap: wrap;
434
+ border: 1px solid var(--border);
435
+ border-radius: 12px;
436
+ background: var(--panel-muted);
437
+ padding: 0.7rem 0.8rem;
438
+ }
439
+ .progress-block {
440
+ display: grid;
441
+ gap: 0.4rem;
442
+ }
443
+ .progress-track {
444
+ position: relative;
445
+ height: 0.7rem;
446
+ border-radius: 999px;
447
+ border: 1px solid var(--border);
448
+ background: var(--panel-strong);
449
+ overflow: hidden;
450
+ }
451
+ .progress-fill {
452
+ position: absolute;
453
+ inset: 0 auto 0 0;
454
+ height: 100%;
455
+ background: linear-gradient(90deg, var(--accent), #60a5fa);
456
+ border-radius: inherit;
457
+ }
458
+ .criteria-grid,
459
+ .change-grid {
460
+ display: grid;
461
+ gap: 0.75rem;
462
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
463
+ }
464
+ .item-card,
465
+ .timeline-item {
466
+ border: 1px solid var(--border);
467
+ border-radius: 12px;
468
+ background: var(--panel-muted);
469
+ padding: 0.85rem;
470
+ }
471
+ .todo-next {
472
+ border-color: var(--accent);
473
+ background: var(--accent-soft);
474
+ }
475
+ .item-title {
476
+ display: flex;
477
+ justify-content: space-between;
478
+ gap: 0.65rem;
479
+ align-items: flex-start;
480
+ margin-bottom: 0.45rem;
481
+ }
482
+ .item-title strong {
483
+ display: block;
484
+ }
485
+ ul.clean-list {
486
+ margin: 0;
487
+ padding-left: 1.1rem;
488
+ display: grid;
489
+ gap: 0.35rem;
490
+ }
491
+ details {
492
+ border-top: 1px solid var(--border);
493
+ margin-top: 0.75rem;
494
+ padding-top: 0.75rem;
495
+ }
496
+ .section-details {
497
+ border-top: none;
498
+ margin-top: 0;
499
+ padding-top: 0;
500
+ }
501
+ .section-details summary {
502
+ font-weight: 600;
503
+ }
504
+ .collapsible-card .section-subtitle:first-of-type {
505
+ margin-top: 0.7rem;
506
+ }
507
+ summary {
508
+ cursor: pointer;
509
+ color: var(--muted);
510
+ }
511
+ .section-subtitle {
512
+ margin: -0.3rem 0 0.8rem 0;
513
+ color: var(--muted);
514
+ }
515
+ .stack-gap {
516
+ display: grid;
517
+ gap: 0.9rem;
518
+ }
519
+ .debug-json {
520
+ max-height: 20rem;
521
+ overflow: auto;
522
+ }
523
+ .raw-payload details + details {
524
+ margin-top: 0.75rem;
525
+ }
526
+ @media (max-width: 1200px) {
527
+ .dashboard-layout {
528
+ grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
529
+ }
530
+ .right-rail {
531
+ grid-column: 1 / -1;
532
+ }
533
+ .rail-sticky {
534
+ position: static;
535
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
536
+ }
537
+ }
538
+ @media (max-width: 900px) {
539
+ .app-header {
540
+ position: static;
541
+ flex-direction: column;
542
+ }
543
+ .header-tools,
544
+ .header-meta {
545
+ min-width: 0;
546
+ width: 100%;
547
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
548
+ }
549
+ .dashboard-layout {
550
+ grid-template-columns: 1fr;
551
+ }
552
+ .sidebar-sticky,
553
+ .rail-sticky {
554
+ position: static;
555
+ max-height: none;
556
+ }
557
+ .task-list {
558
+ max-height: none;
559
+ }
560
+ }
561
+ @media (prefers-reduced-motion: reduce) {
562
+ * {
563
+ scroll-behavior: auto;
564
+ }
565
+ }
566
+ </style>
567
+ """
568
+ ).strip()
569
+
570
+
571
+ def _render_dashboard_body() -> str:
572
+ return dedent(
573
+ """\
574
+ <body>
575
+ <header class="app-header">
576
+ <div>
577
+ <p class="eyebrow">Taskledger</p>
578
+ <h1>Taskledger dashboard</h1>
579
+ <p id="status-headline" class="app-subtitle">Waiting for first refresh.</p>
580
+ <p id="status-detail" class="status-detail">The dashboard will load the selected task automatically.</p>
581
+ </div>
582
+ <div class="header-tools">
583
+ <div class="header-meta">
584
+ <div class="meta-card">
585
+ <span class="meta-label">Selected task</span>
586
+ <strong id="selected-task-label">active</strong>
587
+ </div>
588
+ <div class="meta-card">
589
+ <span class="meta-label">Last refresh</span>
590
+ <strong id="last-updated-label">never</strong>
591
+ </div>
592
+ <div class="meta-card">
593
+ <span class="meta-label">Review status</span>
594
+ <strong id="live-status-label" class="status-live">Live</strong>
595
+ </div>
596
+ </div>
597
+ <div class="header-controls">
598
+ <button id="toggle-polling-button" class="action-button" type="button">Pause updates</button>
599
+ <button id="refresh-now-button" class="action-button action-button-primary" type="button">Refresh now</button>
600
+ </div>
601
+ </div>
602
+ </header>
603
+ <div class="dashboard-layout">
604
+ <aside aria-label="Tasks">
605
+ <div class="sidebar-sticky">
606
+ <section class="card muted-card">
607
+ <div class="search-stack">
608
+ <label for="task-search">Search tasks</label>
609
+ <input
610
+ id="task-search"
611
+ class="task-search"
612
+ type="search"
613
+ placeholder="Filter by title, task id, or slug"
614
+ >
615
+ <nav id="task-filters" class="filter-row" aria-label="Task filters"></nav>
616
+ </div>
617
+ </section>
618
+ <div id="tasks" class="task-list"></div>
619
+ </div>
620
+ </aside>
621
+ <main class="main-column">
622
+ <div id="hero-slot" class="hero-grid"></div>
623
+ <section id="metric-grid" class="metric-grid" aria-label="Progress overview"></section>
624
+ <div id="sections" class="section-stack"></div>
625
+ </main>
626
+ <aside class="right-rail" aria-label="Current work">
627
+ <div id="rail-content" class="rail-sticky"></div>
628
+ </aside>
629
+ </div>
630
+ """
631
+ ).strip()
632
+
633
+
634
+ def _render_dashboard_script(refresh_ms: int, task_ref: str | None) -> str:
635
+ script = dedent(
636
+ """\
637
+ <script>
638
+ const refreshMs = __REFRESH_MS__;
639
+ const defaultTaskRef = __DEFAULT_TASK_REF__;
640
+ let selectedTaskRef = defaultTaskRef ?? "active";
641
+ let refreshTimer = null;
642
+ let refreshInFlight = false;
643
+ let pollingPaused = false;
644
+ let lastUpdatedText = "never";
645
+ let taskSearchQuery = "";
646
+ let taskStageFilter = "all";
647
+ const openDetailsKeys = new Set();
648
+
649
+ const STAGE_FILTERS = [
650
+ { value: "all", label: "All" },
651
+ { value: "active", label: "Active" },
652
+ { value: "draft", label: "Draft" },
653
+ { value: "review", label: "Review" },
654
+ { value: "approved", label: "Approved" },
655
+ { value: "implementation", label: "Implementing" },
656
+ { value: "validation", label: "Validating" },
657
+ { value: "failed", label: "Failed" },
658
+ { value: "done", label: "Done" },
659
+ { value: "cancelled", label: "Cancelled" },
660
+ ];
661
+
662
+ const endpointState = {
663
+ tasks: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
664
+ project: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
665
+ dashboard: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
666
+ events: { key: null, etag: null, payload: null, error: null, lastRequestedAt: 0 },
667
+ };
668
+
669
+ function apiTaskRef() {
670
+ return selectedTaskRef === "active" ? "active" : selectedTaskRef;
671
+ }
672
+
673
+ function endpointPath(name) {
674
+ if (name === "tasks") return "/api/tasks";
675
+ if (name === "project") return "/api/project";
676
+ const taskRef = encodeURIComponent(apiTaskRef());
677
+ if (name === "dashboard") return "/api/dashboard?task=" + taskRef;
678
+ return "/api/events?task=" + taskRef + "&limit=50";
679
+ }
680
+
681
+ function endpointCadence(name) {
682
+ if (name === "tasks") return Math.max(refreshMs * 5, 5000);
683
+ if (name === "project") return Math.max(refreshMs * 15, 15000);
684
+ return refreshMs;
685
+ }
686
+
687
+ async function getJson(name, path) {
688
+ const state = endpointState[name];
689
+ if (state.key !== path) {
690
+ state.key = path;
691
+ state.etag = null;
692
+ state.payload = null;
693
+ state.error = null;
694
+ }
695
+ const headers = {};
696
+ if (state.etag) {
697
+ headers["If-None-Match"] = state.etag;
698
+ }
699
+ const response = await fetch(path, { headers });
700
+ if (response.status === 304 && state.payload) {
701
+ return { payload: state.payload, changed: false };
702
+ }
703
+ const payload = await response.json();
704
+ if (!response.ok) {
705
+ throw new Error(payload?.error?.message || ("HTTP " + response.status));
706
+ }
707
+ const previousPayload = state.payload;
708
+ const previousRevision = previousPayload?.revision;
709
+ state.etag = response.headers.get("ETag");
710
+ state.payload = payload;
711
+ return {
712
+ payload,
713
+ changed: previousPayload === null || payload?.revision !== previousRevision,
714
+ };
715
+ }
716
+
717
+ function h(tag, attrs = {}, children = []) {
718
+ const node = document.createElement(tag);
719
+ for (const [key, value] of Object.entries(attrs || {})) {
720
+ if (value === null || value === undefined || value === false) continue;
721
+ if (key === "class") node.className = String(value);
722
+ else if (key === "text") node.textContent = String(value);
723
+ else if (key === "style") node.setAttribute("style", String(value));
724
+ else if (key === "htmlFor") node.htmlFor = String(value);
725
+ else if (key === "value") node.value = String(value);
726
+ else if (key.startsWith("aria-") || key.startsWith("data-") || key === "role" || key === "type" || key === "placeholder" || key === "id") {
727
+ node.setAttribute(key, String(value));
728
+ } else {
729
+ node[key] = value;
730
+ }
731
+ }
732
+ const list = (Array.isArray(children) ? children : [children]).flat(Infinity);
733
+ for (const child of list) {
734
+ if (child === null || child === undefined) continue;
735
+ node.append(child && child.nodeType ? child : document.createTextNode(String(child)));
736
+ }
737
+ return node;
738
+ }
739
+
740
+ function clearNode(node) {
741
+ if (node) node.replaceChildren();
742
+ }
743
+
744
+ function emptyState(text) {
745
+ return h("div", { class: "empty-state", text });
746
+ }
747
+
748
+ function rememberDetailsState(root = document) {
749
+ for (const node of root.querySelectorAll("details[data-detail-key]")) {
750
+ const key = node.getAttribute("data-detail-key");
751
+ if (!key) continue;
752
+ if (node.open) openDetailsKeys.add(key);
753
+ else openDetailsKeys.delete(key);
754
+ }
755
+ }
756
+
757
+ function bindDetailsState(details, key, openByDefault = false) {
758
+ details.setAttribute("data-detail-key", key);
759
+ details.open = openDetailsKeys.has(key) ? true : openByDefault;
760
+ details.addEventListener("toggle", () => {
761
+ if (details.open) openDetailsKeys.add(key);
762
+ else openDetailsKeys.delete(key);
763
+ });
764
+ return details;
765
+ }
766
+
767
+ function replaceContentPreservingDetails(container, children) {
768
+ if (!container) return;
769
+ rememberDetailsState(container);
770
+ container.replaceChildren(...children.filter(Boolean));
771
+ }
772
+
773
+ function titleCase(value) {
774
+ return String(value || "").replace(/[_-]+/g, " ").replace(/\\b\\w/g, (letter) => letter.toUpperCase());
775
+ }
776
+
777
+ function formatTimestamp(value) {
778
+ if (!value) return "Unknown";
779
+ const parsed = new Date(value);
780
+ if (Number.isNaN(parsed.getTime())) return String(value);
781
+ return parsed.toLocaleString();
782
+ }
783
+
784
+ function toneForStage(task) {
785
+ const stage = task?.status_stage;
786
+ const activeStage = task?.active_stage;
787
+ if (activeStage === "validation" || stage === "plan_review") return "warning";
788
+ if (stage === "failed_validation") return "danger";
789
+ if (stage === "done") return "success";
790
+ if (stage === "cancelled") return "muted";
791
+ if (activeStage || stage === "approved" || stage === "implemented") return "info";
792
+ return "muted";
793
+ }
794
+
795
+ function toneForStatus(status) {
796
+ if (status === "pass" || status === "done" || status === "finished" || status === "accepted") return "success";
797
+ if (status === "fail" || status === "failed" || status === "failed_validation") return "danger";
798
+ if (status === "warn" || status === "plan_review" || status === "running") return "warning";
799
+ if (status === "not_run" || status === "open" || status === "draft" || status === "superseded") return "muted";
800
+ return "info";
801
+ }
802
+
803
+ function badge(label, tone = "muted") {
804
+ return h("span", { class: "badge badge-" + tone, text: label || "-" });
805
+ }
806
+
807
+ function dashboardTaskKey() {
808
+ return endpointState.dashboard.payload?.task?.id || apiTaskRef();
809
+ }
810
+
811
+ function lazyJsonDetails(summary, getPayload, key) {
812
+ const details = bindDetailsState(h("details"), key);
813
+ const pre = h("pre", { class: "debug-json", text: "Open to render payload." });
814
+ const renderPayload = () => {
815
+ if (!details.open) return;
816
+ pre.textContent = JSON.stringify(getPayload() ?? {}, null, 2);
817
+ details.dataset.rendered = "true";
818
+ };
819
+ details.append(h("summary", { text: summary }), pre);
820
+ details.addEventListener("toggle", renderPayload);
821
+ queueMicrotask(renderPayload);
822
+ return details;
823
+ }
824
+
825
+ function jsonDetails(summary, payload, key) {
826
+ return lazyJsonDetails(summary, () => payload ?? {}, key || summary);
827
+ }
828
+
829
+ function copyCommand(command) {
830
+ if (!command) return;
831
+ navigator.clipboard?.writeText(command).catch(() => undefined);
832
+ }
833
+
834
+ function commandRow(command) {
835
+ if (!command) return emptyState("No command available.");
836
+ const button = h("button", { class: "copy-button", type: "button", text: "Copy" });
837
+ button.addEventListener("click", () => copyCommand(command));
838
+ return h("div", { class: "command-row" }, [h("code", { text: command }), button]);
839
+ }
840
+
841
+ function progressBar(done, total) {
842
+ const safeDone = Number(done || 0);
843
+ const safeTotal = Number(total || 0);
844
+ const pct = safeTotal > 0 ? Math.round((safeDone / safeTotal) * 100) : 0;
845
+ const track = h(
846
+ "div",
847
+ {
848
+ class: "progress-track",
849
+ role: "progressbar",
850
+ "aria-valuenow": pct,
851
+ "aria-valuemin": 0,
852
+ "aria-valuemax": 100,
853
+ "aria-label": pct + "% complete",
854
+ },
855
+ [h("div", { class: "progress-fill", style: "width:" + pct + "%" })]
856
+ );
857
+ return h("div", { class: "progress-block" }, [
858
+ h("strong", { text: safeDone + " / " + safeTotal }),
859
+ track,
860
+ h("span", { class: "muted", text: pct + "% complete" }),
861
+ ]);
862
+ }
863
+
864
+ function endpointMessage(name, emptyText) {
865
+ const state = endpointState[name];
866
+ if (state.payload) return null;
867
+ if (state.error) return emptyText + " Error: " + state.error;
868
+ return emptyText;
869
+ }
870
+
871
+ function endpointOrFallback(name, emptyText) {
872
+ return endpointMessage(name, emptyText) || emptyText;
873
+ }
874
+
875
+ function cardSection(title, children, className = "") {
876
+ const section = h("section", { class: ("card " + className).trim() });
877
+ section.append(h("div", { class: "card-header" }, [h("h2", { text: title })]));
878
+ const list = (Array.isArray(children) ? children : [children]).flat(Infinity);
879
+ for (const child of list) {
880
+ if (child) section.append(child);
881
+ }
882
+ return section;
883
+ }
884
+
885
+ function collapsibleCard(title, children, key, className = "") {
886
+ const section = h("section", { class: ("card collapsible-card " + className).trim() });
887
+ const details = bindDetailsState(h("details", { class: "section-details" }), key);
888
+ details.append(h("summary", { text: title }));
889
+ const list = (Array.isArray(children) ? children : [children]).flat(Infinity);
890
+ for (const child of list) {
891
+ if (child) details.append(child);
892
+ }
893
+ section.append(details);
894
+ return section;
895
+ }
896
+
897
+ function renderTaskFilters() {
898
+ const container = document.getElementById("task-filters");
899
+ clearNode(container);
900
+ for (const filter of STAGE_FILTERS) {
901
+ const button = h("button", {
902
+ class: "chip",
903
+ type: "button",
904
+ text: filter.label,
905
+ "data-active": String(taskStageFilter === filter.value),
906
+ });
907
+ button.addEventListener("click", () => {
908
+ taskStageFilter = filter.value;
909
+ renderTasks();
910
+ });
911
+ container.append(button);
912
+ }
913
+ }
914
+
915
+ function taskMatchesFilter(task) {
916
+ if (taskStageFilter === "all") return true;
917
+ if (taskStageFilter === "active") return Boolean(task.active_stage);
918
+ if (taskStageFilter === "review") return task.status_stage === "plan_review";
919
+ if (taskStageFilter === "implementation") return task.active_stage === "implementation" || task.status_stage === "implemented";
920
+ if (taskStageFilter === "validation") return task.active_stage === "validation" || task.status_stage === "validating";
921
+ if (taskStageFilter === "failed") return task.status_stage === "failed_validation";
922
+ return task.status_stage === taskStageFilter;
923
+ }
924
+
925
+ function sortedTasks(tasks) {
926
+ return [...(tasks || [])].sort((left, right) => {
927
+ const leftStamp = left.updated_at || left.created_at || "";
928
+ const rightStamp = right.updated_at || right.created_at || "";
929
+ if (leftStamp || rightStamp) {
930
+ return String(rightStamp).localeCompare(String(leftStamp));
931
+ }
932
+ return String(right.id || "").localeCompare(String(left.id || ""));
933
+ });
934
+ }
935
+
936
+ function renderTaskCard(task, currentTaskId) {
937
+ const selected = Boolean(task.id === selectedTaskRef || task.slug === selectedTaskRef || task.id === currentTaskId);
938
+ const button = h("button", { class: "task-card", type: "button", "data-active": String(selected) });
939
+ button.setAttribute("aria-current", selected ? "true" : "false");
940
+ button.addEventListener("click", () => {
941
+ selectedTaskRef = task.id;
942
+ refreshSelection().catch(renderError);
943
+ });
944
+ const idLabel = [task.id, task.slug].filter(Boolean).join(" · ");
945
+ const planLabel = task.accepted_plan_version
946
+ ? "plan v" + task.accepted_plan_version + " accepted"
947
+ : task.latest_plan_version
948
+ ? "plan v" + task.latest_plan_version + " proposed"
949
+ : "no plan";
950
+ const activeLabel = task.active_stage ? "active: " + task.active_stage : "active: none";
951
+ button.append(
952
+ h("div", { class: "badge-row" }, [
953
+ badge(task.active_stage ? titleCase(task.active_stage) : titleCase(task.status_stage || "draft"), toneForStage(task)),
954
+ task.priority ? badge("priority " + task.priority, "info") : null,
955
+ ]),
956
+ h("p", { class: "task-title", text: task.title || task.slug || task.id }),
957
+ h("div", { class: "meta-line mono", text: idLabel || "-" }),
958
+ h("div", { class: "meta-line", text: activeLabel }),
959
+ h("div", { class: "meta-line", text: planLabel }),
960
+ task.description_summary ? h("div", { class: "summary-line", text: task.description_summary }) : null
961
+ );
962
+ return button;
963
+ }
964
+
965
+ function renderTasks() {
966
+ renderTaskFilters();
967
+ const tasksNode = document.getElementById("tasks");
968
+ clearNode(tasksNode);
969
+ const tasksPayload = endpointState.tasks.payload;
970
+ if (!tasksPayload) {
971
+ tasksNode.append(emptyState(endpointOrFallback("tasks", "Loading tasks...")));
972
+ return;
973
+ }
974
+ const currentTaskId = endpointState.dashboard.payload?.task?.id || null;
975
+ const query = taskSearchQuery.trim().toLowerCase();
976
+ const tasks = sortedTasks(tasksPayload.tasks).filter((task) => {
977
+ const haystack = [task.title, task.slug, task.id].join(" ").toLowerCase();
978
+ return (!query || haystack.includes(query)) && taskMatchesFilter(task);
979
+ });
980
+ if (tasks.length === 0) {
981
+ tasksNode.append(emptyState("No tasks match the current search or filter."));
982
+ return;
983
+ }
984
+ for (const task of tasks) {
985
+ tasksNode.append(renderTaskCard(task, currentTaskId));
986
+ }
987
+ }
988
+
989
+ function renderHero(project, dashboard) {
990
+ if (!dashboard) {
991
+ return emptyState(endpointOrFallback("dashboard", "Loading dashboard summary..."));
992
+ }
993
+ const task = dashboard.task || {};
994
+ const lock = dashboard.lock;
995
+ const activeTask = project?.active_task || {};
996
+ const hero = h("section", { class: "card hero-card active-task-hero" });
997
+ hero.append(
998
+ h("div", { class: "hero-title" }, [
999
+ h("h2", { text: task.title || task.slug || task.id || "Active task" }),
1000
+ badge(task.status_stage || "unknown", toneForStage(task)),
1001
+ task.active_stage ? badge("active " + task.active_stage, "info") : badge("no active lock", "muted"),
1002
+ ]),
1003
+ h("p", { class: "section-subtitle", text: task.description_summary || "Human-focused read-only review of the selected task." }),
1004
+ h("div", { class: "hero-meta" }, [
1005
+ h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Task reference" }), h("strong", { class: "mono", text: [task.id, task.slug].filter(Boolean).join(" · ") || "-" })]),
1006
+ h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Lock state" }), h("strong", { text: lock ? lock.stage + " · " + lock.run_id : "No active lock" })]),
1007
+ h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Plan status" }), h("strong", { text: dashboard.plan ? "v" + dashboard.plan.version + " · " + dashboard.plan.status : "No plan proposed" })]),
1008
+ h("div", { class: "meta-row" }, [h("span", { class: "muted", text: "Project focus" }), h("strong", { text: activeTask.task_id ? (activeTask.slug || activeTask.task_id) + " · " + (project?.health || "not_checked") : project?.health || "not_checked" })]),
1009
+ ]),
1010
+ h("div", { class: "pill-row" }, [
1011
+ task.owner ? badge("owner " + task.owner, "info") : null,
1012
+ ...(task.labels || []).map((label) => badge(label, "muted")),
1013
+ task.created_at ? badge("created " + formatTimestamp(task.created_at), "muted") : null,
1014
+ task.updated_at ? badge("updated " + formatTimestamp(task.updated_at), "muted") : null,
1015
+ ])
1016
+ );
1017
+ return hero;
1018
+ }
1019
+
1020
+ function renderMetrics(dashboard, events) {
1021
+ if (!dashboard) {
1022
+ return [emptyState(endpointOrFallback("dashboard", "Loading progress overview..."))];
1023
+ }
1024
+ const validationCriteria = dashboard.validation?.criteria || [];
1025
+ const passedValidation = validationCriteria.filter((item) => item.satisfied).length;
1026
+ const cards = [
1027
+ { title: "Todos", detail: (dashboard.todos?.done || 0) + " complete of " + (dashboard.todos?.total || 0), body: progressBar(dashboard.todos?.done || 0, dashboard.todos?.total || 0) },
1028
+ { title: "Questions", detail: (dashboard.questions?.open || 0) + " open of " + (dashboard.questions?.total || 0), body: progressBar((dashboard.questions?.total || 0) - (dashboard.questions?.open || 0), dashboard.questions?.total || 0) },
1029
+ { title: "Validation", detail: passedValidation + " satisfied of " + validationCriteria.length, body: progressBar(passedValidation, validationCriteria.length) },
1030
+ { title: "Recent activity", detail: (events?.items || []).length + " recent entries", body: h("div", { class: "metric-value" }, [h("strong", { class: "metric-number", text: String((events?.items || []).length) }), h("span", { class: "muted", text: "Recent event tail" })]) },
1031
+ ];
1032
+ return cards.map((metric) => h("section", { class: "card" }, [h("div", { class: "card-header" }, [h("h2", { text: metric.title }), h("span", { class: "muted", text: metric.detail })]), metric.body]));
1033
+ }
1034
+
1035
+ function renderOverview(project, dashboard) {
1036
+ if (!project && !dashboard) return emptyState(endpointOrFallback("project", "Loading workspace summary..."));
1037
+ return h("div", { class: "list-grid" }, [
1038
+ h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Workspace" })]), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Workspace root" }), h("code", { text: project?.workspace_root || "-" }), h("span", { class: "muted", text: "Project dir" }), h("code", { text: project?.project_dir || "-" })])]),
1039
+ h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Selected task state" })]), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Stage" }), h("span", { text: dashboard?.task?.status_stage || "-" }), h("span", { class: "muted", text: "Active stage" }), h("span", { text: dashboard?.task?.active_stage || "none" }), h("span", { class: "muted", text: "Health" }), h("span", { text: project?.health || "unknown" })])]),
1040
+ ]);
1041
+ }
1042
+
1043
+ function renderNextAction(dashboard) {
1044
+ const nextAction = dashboard?.next_action;
1045
+ if (!nextAction) return emptyState(endpointOrFallback("dashboard", "Loading next action..."));
1046
+ const task = dashboard?.task || {};
1047
+ const blockers = nextAction.blocking || [];
1048
+ const nextItem = nextAction.next_item;
1049
+ const todoProgress = nextAction.progress?.todos || {};
1050
+ const card = h("section", { class: "card next-action-card" });
1051
+ card.append(h("div", { class: "card-header" }, [h("h2", { text: "Do next" }), badge(nextAction.action || "none", toneForStatus(nextAction.action || "none"))]), h("p", { class: "section-subtitle", text: nextAction.reason || "No next action available." }));
1052
+ card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: task.title || task.slug || "Selected task" }), task.id ? h("code", { text: task.id }) : null]), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Stage" }), h("span", { text: task.status_stage || "-" }), h("span", { class: "muted", text: "Active" }), h("span", { text: task.active_stage || "none" })])]));
1053
+ if (nextAction.next_command) {
1054
+ card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Inspect" })]), commandRow(nextAction.next_command)]));
1055
+ }
1056
+ if (nextItem) {
1057
+ card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: nextItem.id || "Next item" }), nextItem.kind ? badge(nextItem.kind, "info") : null]), nextItem.text ? h("p", { text: nextItem.text }) : null, nextItem.validation_hint ? h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Validation" })]) : null, nextItem.validation_hint ? commandRow(nextItem.validation_hint) : null, nextItem.done_command_hint ? h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "When done" })]) : null, nextItem.done_command_hint ? commandRow(nextItem.done_command_hint) : null]));
1058
+ }
1059
+ if (Object.keys(todoProgress).length > 0) {
1060
+ card.append(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Todo progress" })]), h("p", { text: String(todoProgress.done || 0) + "/" + String(todoProgress.total || 0) + " done" })]));
1061
+ }
1062
+ if (blockers.length > 0) {
1063
+ card.append(h("div", { class: "list-grid" }, [h("h3", { text: "Blockers" }), h("ul", { class: "clean-list" }, blockers.map((blocker) => h("li", { text: blocker.message || blocker.kind || "Blocking issue" }))) ]));
1064
+ }
1065
+ return card;
1066
+ }
1067
+
1068
+ function renderQuestionsSection(questions) {
1069
+ if (!questions) return emptyState(endpointOrFallback("dashboard", "Loading questions..."));
1070
+ if (!questions.items || questions.items.length === 0) return h("p", { class: "section-subtitle", text: "No planning questions are recorded." });
1071
+ return h("div", { class: "list-grid" }, questions.items.map((item) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: item.question || item.text || item.id }), badge(item.status || "open", toneForStatus(item.status || "open"))]), h("code", { text: item.id || "-" }), item.answer ? h("p", { text: item.answer }) : null])));
1072
+ }
1073
+
1074
+ function renderPlanSection(plans) {
1075
+ if (!plans || plans.length === 0) return emptyState("No plans have been proposed yet.");
1076
+ const taskKey = dashboardTaskKey();
1077
+ const latest = plans[plans.length - 1];
1078
+ const body = h("div", { class: "list-grid" }, [h("p", { class: "section-subtitle", text: "Latest plan v" + latest.plan_version + " · " + (latest.status || "unknown") })]);
1079
+ if (latest.goal) body.append(h("p", { text: latest.goal }));
1080
+ if (latest.criteria?.length) {
1081
+ body.append(h("div", { class: "criteria-grid" }, latest.criteria.map((criterion) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: criterion.text || criterion.id }), criterion.id ? h("code", { text: criterion.id }) : null]), badge(criterion.mandatory === false ? "optional" : "mandatory", criterion.mandatory === false ? "muted" : "info")]))));
1082
+ }
1083
+ if (latest.todos?.length) {
1084
+ body.append(h("div", { class: "list-grid" }, latest.todos.map((todo) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: todo.text || todo.id }), todo.id ? h("code", { text: todo.id }) : null]), todo.validation_hint ? commandRow(todo.validation_hint) : null]))));
1085
+ }
1086
+ if (latest.test_commands?.length) {
1087
+ body.append(h("div", { class: "list-grid" }, [h("h3", { text: "Test commands" }), ...latest.test_commands.map((command) => commandRow(command))]));
1088
+ }
1089
+ if (latest.expected_outputs?.length) {
1090
+ body.append(h("ul", { class: "clean-list" }, latest.expected_outputs.map((item) => h("li", { text: item }))));
1091
+ }
1092
+ if (latest.body) body.append(jsonDetails("Expanded plan body", latest.body, "plan.body." + taskKey + ".v" + latest.plan_version));
1093
+ if (plans.length > 1) {
1094
+ const details = bindDetailsState(h("details"), "plan.previous_versions." + taskKey);
1095
+ details.append(h("summary", { text: "Previous versions" }), ...plans.slice(0, -1).reverse().map((plan) => h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "v" + plan.plan_version }), badge(plan.status || "unknown", toneForStatus(plan.status || "unknown"))]), plan.goal ? h("p", { text: plan.goal }) : null, plan.body ? h("pre", { text: plan.body }) : null])));
1096
+ body.append(details);
1097
+ }
1098
+ return body;
1099
+ }
1100
+
1101
+ function renderTodosSection(todos, nextAction) {
1102
+ if (!todos) return emptyState(endpointOrFallback("dashboard", "Loading todos..."));
1103
+ const highlightedTodoId = nextAction?.next_item?.kind === "todo" ? nextAction.next_item.id : null;
1104
+ const items = [...(todos.items || [])].sort((left, right) => Number(Boolean(left.done)) - Number(Boolean(right.done)));
1105
+ const taskKey = dashboardTaskKey();
1106
+ const todoCards = items.map((todo) => {
1107
+ const done = Boolean(todo.done || todo.status === "done");
1108
+ const card = h("div", { class: "item-card" + (todo.id === highlightedTodoId ? " todo-next" : "") }, [h("div", { class: "item-title" }, [h("strong", { text: todo.text || todo.id }), badge(done ? "done" : todo.status || "open", toneForStatus(done ? "done" : todo.status || "open"))]), h("code", { text: todo.id || "-" })]);
1109
+ const lines = [];
1110
+ if (todo.evidence) lines.push("Evidence: " + todo.evidence);
1111
+ if (todo.source) lines.push("Source: " + todo.source);
1112
+ if (todo.active_at) lines.push("Active at: " + todo.active_at);
1113
+ if (lines.length > 0) {
1114
+ const details = bindDetailsState(h("details"), "todo." + taskKey + "." + (todo.id || todo.text || "todo") + ".details");
1115
+ details.append(h("summary", { text: "Details" }), h("ul", { class: "clean-list" }, lines.map((line) => h("li", { text: line }))));
1116
+ card.append(details);
1117
+ }
1118
+ return card;
1119
+ });
1120
+ return h("div", { class: "list-grid" }, [
1121
+ h("p", { class: "section-subtitle", text: (todos.done || 0) + " done of " + (todos.total || 0) + " total" }),
1122
+ progressBar(todos.done || 0, todos.total || 0),
1123
+ items.length === 0 ? emptyState("No todos are recorded.") : h("div", { class: "list-grid" }, todoCards),
1124
+ ]);
1125
+ }
1126
+
1127
+ function renderValidationSection(validation) {
1128
+ if (!validation) return emptyState(endpointOrFallback("dashboard", "Loading validation..."));
1129
+ const taskKey = dashboardTaskKey();
1130
+ const parts = [h("p", { class: "section-subtitle", text: validation.run_id ? "Validation run " + validation.run_id + " · " + (validation.can_finish_passed ? "ready to finish" : "checks remain") : "No validation run recorded" })];
1131
+ if ((validation.blockers || []).length > 0) {
1132
+ parts.push(h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: "Blockers" })]), h("ul", { class: "clean-list" }, validation.blockers.map((blocker) => h("li", { text: blocker.message || blocker.ref || blocker.kind || "Blocking issue" }))) ]));
1133
+ }
1134
+ parts.push((validation.criteria || []).length === 0 ? emptyState("No validation criteria were found.") : h("div", { class: "criteria-grid" }, validation.criteria.map((criterion) => {
1135
+ const card = h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: criterion.text || criterion.id }), badge(criterion.latest_status || "not_run", toneForStatus(criterion.latest_status || "not_run"))]), h("code", { text: criterion.id || "-" }), criterion.has_waiver ? badge("waived", "warning") : null, criterion.evidence?.length ? h("ul", { class: "clean-list" }, criterion.evidence.map((item) => h("li", { text: item }))) : h("p", { class: "muted", text: "No evidence recorded." })]);
1136
+ if (criterion.history?.length || criterion.blockers?.length) {
1137
+ const details = bindDetailsState(h("details"), "validation." + taskKey + "." + (criterion.id || "criterion") + ".history");
1138
+ details.append(h("summary", { text: "History and blockers" }), criterion.history?.length ? h("ul", { class: "clean-list" }, criterion.history.map((item) => h("li", { text: (item.check_id || "check") + " · " + (item.status || "unknown") }))) : null, criterion.blockers?.length ? h("ul", { class: "clean-list" }, criterion.blockers.map((item) => h("li", { text: item.message || item.kind || "blocker" }))) : null);
1139
+ card.append(details);
1140
+ }
1141
+ return card;
1142
+ })));
1143
+ return h("div", { class: "list-grid" }, parts);
1144
+ }
1145
+
1146
+ function renderRunsSection(runs) {
1147
+ if (!runs) return emptyState(endpointOrFallback("dashboard", "Loading runs..."));
1148
+ if (runs.length === 0) return emptyState("No implementation or validation runs are recorded.");
1149
+ const taskKey = dashboardTaskKey();
1150
+ return h("div", { class: "timeline" }, runs.map((run) => {
1151
+ const card = h("div", { class: "timeline-item" }, [h("div", { class: "item-title" }, [h("strong", { text: run.run_id + " · " + titleCase(run.run_type || "run") }), badge(run.result || run.status || "unknown", toneForStatus(run.result || run.status || "unknown"))]), h("p", { text: run.summary || "No summary recorded." }), h("div", { class: "mini-meta" }, [h("span", { class: "muted", text: "Started" }), h("span", { text: formatTimestamp(run.started_at) }), h("span", { class: "muted", text: "Finished" }), h("span", { text: run.finished_at ? formatTimestamp(run.finished_at) : "In progress" })])]);
1152
+ card.append(jsonDetails("Run details", run, "run." + taskKey + "." + (run.run_id || "run") + ".details"));
1153
+ return card;
1154
+ }));
1155
+ }
1156
+
1157
+ function renderChangesSection(changes) {
1158
+ if (!changes) return emptyState(endpointOrFallback("dashboard", "Loading changes..."));
1159
+ if (changes.length === 0) return emptyState("No implementation changes are recorded.");
1160
+ const taskKey = dashboardTaskKey();
1161
+ return h("div", { class: "change-grid" }, changes.map((change) => {
1162
+ const card = h("div", { class: "item-card" }, [h("div", { class: "item-title" }, [h("strong", { text: change.summary || change.path || change.change_id }), badge(change.kind || "change", "info")]), h("code", { text: change.path || change.change_id || "-" })]);
1163
+ card.append(jsonDetails("Change metadata", change, "change." + taskKey + "." + (change.change_id || change.path || "change") + ".metadata"));
1164
+ return card;
1165
+ }));
1166
+ }
1167
+
1168
+ function renderEventsSection(events) {
1169
+ if (!events) return emptyState(endpointOrFallback("events", "Loading events..."));
1170
+ if (!events.items || events.items.length === 0) return emptyState("No recent events are available.");
1171
+ const taskKey = dashboardTaskKey();
1172
+ return h("div", { class: "timeline" }, events.items.map((event, index) => {
1173
+ const actor = event.actor?.actor_name || event.actor?.actor_type || "unknown";
1174
+ const card = h("div", { class: "timeline-item" }, [h("div", { class: "item-title" }, [h("strong", { text: event.event || "event" }), h("span", { class: "muted mono", text: formatTimestamp(event.ts) })]), h("p", { text: actor })]);
1175
+ card.append(jsonDetails("Event payload", event, "event." + taskKey + "." + (event.event_id || event.ts || String(index)) + ".payload"));
1176
+ return card;
1177
+ }));
1178
+ }
1179
+
1180
+ function renderRecentEventsCard(events) {
1181
+ return cardSection("Recent events", [
1182
+ h("p", { class: "section-subtitle", text: "Recent activity stays visible while secondary detail stays collapsed." }),
1183
+ renderEventsSection(events),
1184
+ ]);
1185
+ }
1186
+
1187
+ function renderRawSection(project, dashboard, events) {
1188
+ const taskKey = dashboardTaskKey();
1189
+ return collapsibleCard(
1190
+ "Debug / raw payload",
1191
+ [
1192
+ h("p", { class: "section-subtitle", text: "Debug payloads stay available without dominating the main dashboard." }),
1193
+ h("div", { class: "raw-payload stack-gap" }, [
1194
+ lazyJsonDetails("Project payload", () => project || {}, "raw.project"),
1195
+ lazyJsonDetails("Dashboard payload", () => dashboard || {}, "raw.dashboard." + taskKey),
1196
+ lazyJsonDetails("Events payload", () => events || {}, "raw.events." + taskKey),
1197
+ ]),
1198
+ ],
1199
+ "section.debug." + taskKey,
1200
+ "raw-payload",
1201
+ );
1202
+ }
1203
+
1204
+ function renderSections() {
1205
+ const project = endpointState.project.payload;
1206
+ const dashboard = endpointState.dashboard.payload;
1207
+ const events = endpointState.events.payload;
1208
+ const taskKey = dashboardTaskKey();
1209
+ const heroSlot = document.getElementById("hero-slot");
1210
+ heroSlot?.replaceChildren(renderHero(project, dashboard));
1211
+ const metricGrid = document.getElementById("metric-grid");
1212
+ metricGrid?.replaceChildren(...renderMetrics(dashboard, events));
1213
+ const rail = document.getElementById("rail-content");
1214
+ replaceContentPreservingDetails(rail, [
1215
+ renderNextAction(dashboard),
1216
+ renderRecentEventsCard(events),
1217
+ ]);
1218
+ const sections = document.getElementById("sections");
1219
+ replaceContentPreservingDetails(sections, [
1220
+ cardSection("Current summary", renderOverview(project, dashboard)),
1221
+ cardSection("Todos", renderTodosSection(dashboard?.todos, dashboard?.next_action)),
1222
+ collapsibleCard("Plan", renderPlanSection(dashboard?.plans), "section.plan." + taskKey),
1223
+ collapsibleCard("Questions", renderQuestionsSection(dashboard?.questions), "section.questions." + taskKey),
1224
+ collapsibleCard("Validation", renderValidationSection(dashboard?.validation), "section.validation-card." + taskKey),
1225
+ collapsibleCard("Runs", renderRunsSection(dashboard?.runs), "section.runs." + taskKey),
1226
+ collapsibleCard("Changes", renderChangesSection(dashboard?.changes), "section.changes." + taskKey),
1227
+ renderRawSection(project, dashboard, events),
1228
+ ]);
1229
+ }
1230
+
1231
+ function setStatus() {
1232
+ const errors = Object.entries(endpointState).filter(([, state]) => Boolean(state.error)).map(([name, state]) => name + " error: " + state.error);
1233
+ const headline = document.getElementById("status-headline");
1234
+ const detail = document.getElementById("status-detail");
1235
+ const selected = document.getElementById("selected-task-label");
1236
+ const updated = document.getElementById("last-updated-label");
1237
+ const liveStatus = document.getElementById("live-status-label");
1238
+ const toggleButton = document.getElementById("toggle-polling-button");
1239
+ const refreshButton = document.getElementById("refresh-now-button");
1240
+ const reviewState = refreshInFlight ? "Refreshing" : (pollingPaused ? "Paused" : "Live");
1241
+ if (headline) {
1242
+ headline.textContent = reviewState === "Paused"
1243
+ ? "Dashboard updates are paused for focused review."
1244
+ : reviewState === "Refreshing"
1245
+ ? "Refreshing dashboard data..."
1246
+ : "Showing a read-only review of the selected task.";
1247
+ }
1248
+ if (detail) {
1249
+ detail.textContent = errors.length > 0
1250
+ ? errors.join(" · ")
1251
+ : pollingPaused
1252
+ ? "Automatic polling is paused. Use Refresh now to fetch current task state on demand."
1253
+ : "Polling dashboard and events every " + refreshMs + "ms with slower project and task refresh cadences.";
1254
+ }
1255
+ if (selected) {
1256
+ selected.textContent = endpointState.dashboard.payload?.task?.slug || apiTaskRef();
1257
+ }
1258
+ if (updated) {
1259
+ updated.textContent = lastUpdatedText;
1260
+ }
1261
+ if (liveStatus) {
1262
+ liveStatus.textContent = reviewState;
1263
+ liveStatus.className = reviewState === "Paused"
1264
+ ? "status-paused"
1265
+ : reviewState === "Refreshing"
1266
+ ? "status-refreshing"
1267
+ : "status-live";
1268
+ }
1269
+ if (toggleButton) {
1270
+ toggleButton.textContent = pollingPaused ? "Resume updates" : "Pause updates";
1271
+ }
1272
+ if (refreshButton) {
1273
+ refreshButton.disabled = refreshInFlight;
1274
+ }
1275
+ }
1276
+
1277
+ function togglePolling() {
1278
+ pollingPaused = !pollingPaused;
1279
+ setStatus();
1280
+ if (!pollingPaused) {
1281
+ refresh().catch(renderError);
1282
+ }
1283
+ }
1284
+
1285
+ function shouldRefresh(name, now) {
1286
+ const state = endpointState[name];
1287
+ const path = endpointPath(name);
1288
+ if (state.key !== path) return true;
1289
+ if (state.payload === null) return true;
1290
+ return (now - state.lastRequestedAt) >= endpointCadence(name);
1291
+ }
1292
+
1293
+ async function refreshEndpoint(name, now) {
1294
+ const state = endpointState[name];
1295
+ state.lastRequestedAt = now;
1296
+ try {
1297
+ const result = await getJson(name, endpointPath(name));
1298
+ state.error = null;
1299
+ return result.changed;
1300
+ } catch (error) {
1301
+ const previousError = state.error;
1302
+ state.error = String(error);
1303
+ return state.payload === null || state.error !== previousError;
1304
+ }
1305
+ }
1306
+
1307
+ async function refresh() {
1308
+ if (refreshInFlight) return;
1309
+ refreshInFlight = true;
1310
+ const changed = new Set();
1311
+ setStatus();
1312
+ try {
1313
+ const now = Date.now();
1314
+ const work = [];
1315
+ for (const name of ["project", "tasks", "dashboard", "events"]) {
1316
+ if (shouldRefresh(name, now)) {
1317
+ work.push(
1318
+ refreshEndpoint(name, now).then((didChange) => {
1319
+ if (didChange) changed.add(name);
1320
+ })
1321
+ );
1322
+ }
1323
+ }
1324
+ await Promise.allSettled(work);
1325
+ lastUpdatedText = new Date().toLocaleTimeString();
1326
+ } finally {
1327
+ refreshInFlight = false;
1328
+ if (changed.has("tasks")) renderTasks();
1329
+ if (changed.has("project") || changed.has("dashboard") || changed.has("events")) {
1330
+ renderSections();
1331
+ }
1332
+ setStatus();
1333
+ }
1334
+ }
1335
+
1336
+ async function refreshSelection() {
1337
+ endpointState.dashboard.key = null;
1338
+ endpointState.dashboard.etag = null;
1339
+ endpointState.dashboard.payload = null;
1340
+ endpointState.dashboard.error = null;
1341
+ endpointState.dashboard.lastRequestedAt = 0;
1342
+ endpointState.events.key = null;
1343
+ endpointState.events.payload = null;
1344
+ endpointState.events.lastRequestedAt = 0;
1345
+ endpointState.events.etag = null;
1346
+ endpointState.events.error = null;
1347
+ renderTasks();
1348
+ renderSections();
1349
+ setStatus();
1350
+ await refresh();
1351
+ }
1352
+
1353
+ function scheduleRefresh(delay = refreshMs) {
1354
+ clearTimeout(refreshTimer);
1355
+ refreshTimer = setTimeout(() => {
1356
+ if (pollingPaused) {
1357
+ setStatus();
1358
+ scheduleRefresh();
1359
+ return;
1360
+ }
1361
+ refresh().catch(renderError).finally(() => scheduleRefresh());
1362
+ }, delay);
1363
+ }
1364
+
1365
+ function renderError(error) {
1366
+ endpointState.dashboard.error = String(error);
1367
+ setStatus();
1368
+ renderSections();
1369
+ }
1370
+
1371
+ function setupControls() {
1372
+ const search = document.getElementById("task-search");
1373
+ search?.addEventListener("input", (event) => {
1374
+ taskSearchQuery = event.target?.value || "";
1375
+ renderTasks();
1376
+ });
1377
+ document.getElementById("toggle-polling-button")?.addEventListener("click", () => {
1378
+ togglePolling();
1379
+ });
1380
+ document.getElementById("refresh-now-button")?.addEventListener("click", () => {
1381
+ refresh().catch(renderError);
1382
+ });
1383
+ }
1384
+
1385
+ setupControls();
1386
+ renderTasks();
1387
+ renderSections();
1388
+ setStatus();
1389
+ refresh().catch(renderError).finally(() => scheduleRefresh());
1390
+ </script>
1391
+ """
1392
+ )
1393
+ return (
1394
+ script.replace("__REFRESH_MS__", json.dumps(refresh_ms))
1395
+ .replace("__DEFAULT_TASK_REF__", _safe_script_literal(task_ref))
1396
+ .strip()
1397
+ )
1398
+
1399
+
1400
+ def launch_dashboard_server(config: DashboardServerConfig) -> DashboardServerHandle:
1401
+ _validate_host(config.host)
1402
+ if config.port < 0 or config.port > 65535:
1403
+ raise LaunchError("taskledger serve requires --port between 0 and 65535.")
1404
+ if config.refresh_ms <= 0:
1405
+ raise LaunchError("taskledger serve requires --refresh-ms greater than 0.")
1406
+ server = _create_server(config)
1407
+ host = config.host
1408
+ port = int(server.server_address[1])
1409
+ url = _server_url(host, port)
1410
+ handle = DashboardServerHandle(server=server, host=host, port=port, url=url)
1411
+ if config.open_browser:
1412
+ webbrowser.open(url)
1413
+ return handle
1414
+
1415
+
1416
+ def serve_dashboard(config: DashboardServerConfig) -> None:
1417
+ handle = launch_dashboard_server(config)
1418
+ try:
1419
+ handle.serve_forever()
1420
+ finally:
1421
+ handle.close()
1422
+
1423
+
1424
+ def _validate_host(host: str) -> None:
1425
+ if host not in {"127.0.0.1", "localhost", "::1"}:
1426
+ raise LaunchError("taskledger serve only binds to localhost in the MVP.")
1427
+
1428
+
1429
+ def _create_server(config: DashboardServerConfig) -> _DashboardHTTPServer:
1430
+ address_family = socket.AF_INET6 if ":" in config.host else socket.AF_INET
1431
+
1432
+ class DashboardHTTPServer(_DashboardHTTPServer):
1433
+ pass
1434
+
1435
+ DashboardHTTPServer.address_family = address_family
1436
+ server = DashboardHTTPServer((config.host, config.port), _DashboardRequestHandler)
1437
+ server.workspace_root = config.workspace_root
1438
+ server.default_task_ref = config.task_ref
1439
+ server.refresh_ms = config.refresh_ms
1440
+ server.cache = {}
1441
+ return server
1442
+
1443
+
1444
+ class _DashboardRequestHandler(BaseHTTPRequestHandler):
1445
+ server: _DashboardHTTPServer
1446
+
1447
+ def do_GET(self) -> None: # noqa: N802
1448
+ parsed = urlparse(self.path)
1449
+ query = parse_qs(parsed.query)
1450
+ try:
1451
+ if parsed.path == "/":
1452
+ self._send_text(
1453
+ 200,
1454
+ render_index_html(
1455
+ refresh_ms=self.server.refresh_ms,
1456
+ task_ref=self.server.default_task_ref,
1457
+ ),
1458
+ content_type="text/html; charset=utf-8",
1459
+ )
1460
+ return
1461
+ if parsed.path == "/api/project":
1462
+ revision = _storage_revision_for_project(self.server.workspace_root)
1463
+ self._send_cached_json(
1464
+ 200,
1465
+ revision,
1466
+ lambda: serve_project_summary(self.server.workspace_root),
1467
+ )
1468
+ return
1469
+ if parsed.path == "/api/tasks":
1470
+ revision = _storage_revision_for_tasks(self.server.workspace_root)
1471
+ self._send_cached_json(
1472
+ 200,
1473
+ revision,
1474
+ lambda: serve_task_summaries(self.server.workspace_root),
1475
+ )
1476
+ return
1477
+ if parsed.path == "/api/dashboard":
1478
+ task_ref = _task_ref_from_query(query, self.server.default_task_ref)
1479
+ revision = _storage_revision_for_dashboard(
1480
+ self.server.workspace_root, task_ref
1481
+ )
1482
+ self._send_cached_json(
1483
+ 200,
1484
+ revision,
1485
+ lambda: serve_dashboard_snapshot(
1486
+ self.server.workspace_root,
1487
+ ref=task_ref,
1488
+ ),
1489
+ )
1490
+ return
1491
+ if parsed.path == "/api/events":
1492
+ task_ref = _task_ref_from_query(query, self.server.default_task_ref)
1493
+ revision = _storage_revision_for_events(
1494
+ self.server.workspace_root, task_ref
1495
+ )
1496
+ self._send_cached_json(
1497
+ 200,
1498
+ revision,
1499
+ lambda: serve_task_events(
1500
+ self.server.workspace_root,
1501
+ ref=task_ref,
1502
+ limit=_limit_from_query(query),
1503
+ ),
1504
+ )
1505
+ return
1506
+ self._send_api_error(404, "NotFound", f"Unknown path: {parsed.path}")
1507
+ except LaunchError as exc:
1508
+ error_status, error_type = _status_for_launch_error(exc)
1509
+ self._send_api_error(error_status, error_type, str(exc))
1510
+ except ValueError as exc:
1511
+ self._send_api_error(400, "BadRequest", str(exc))
1512
+ except Exception as exc: # noqa: BLE001
1513
+ self._send_api_error(500, "InternalError", str(exc))
1514
+
1515
+ def do_POST(self) -> None: # noqa: N802
1516
+ self._method_not_allowed()
1517
+
1518
+ def do_PUT(self) -> None: # noqa: N802
1519
+ self._method_not_allowed()
1520
+
1521
+ def do_PATCH(self) -> None: # noqa: N802
1522
+ self._method_not_allowed()
1523
+
1524
+ def do_DELETE(self) -> None: # noqa: N802
1525
+ self._method_not_allowed()
1526
+
1527
+ def do_HEAD(self) -> None: # noqa: N802
1528
+ self._method_not_allowed()
1529
+
1530
+ def do_OPTIONS(self) -> None: # noqa: N802
1531
+ self._method_not_allowed()
1532
+
1533
+ def log_message(self, format: str, *args: object) -> None:
1534
+ return
1535
+
1536
+ def _method_not_allowed(self) -> None:
1537
+ self._send_api_error(405, "MethodNotAllowed", "Only GET requests are allowed.")
1538
+
1539
+ def _send_text(self, status: int, text: str, *, content_type: str) -> None:
1540
+ body = text.encode("utf-8")
1541
+ try:
1542
+ self.send_response(status)
1543
+ self.send_header("Content-Type", content_type)
1544
+ self.send_header("Content-Length", str(len(body)))
1545
+ self.end_headers()
1546
+ self.wfile.write(body)
1547
+ except _CLIENT_DISCONNECT_ERRORS:
1548
+ return
1549
+
1550
+ def _send_json(self, status: int, payload: dict[str, object]) -> None:
1551
+ self._send_text(
1552
+ status,
1553
+ json.dumps(payload, sort_keys=True) + "\n",
1554
+ content_type="application/json",
1555
+ )
1556
+
1557
+ def _send_cached_json(
1558
+ self,
1559
+ status: int,
1560
+ revision: str,
1561
+ payload_factory: Callable[[], dict[str, object]],
1562
+ ) -> None:
1563
+ if self.headers.get("If-None-Match") == revision:
1564
+ try:
1565
+ self.send_response(304)
1566
+ self.send_header("ETag", revision)
1567
+ self.send_header("Cache-Control", "no-cache")
1568
+ self.end_headers()
1569
+ except _CLIENT_DISCONNECT_ERRORS:
1570
+ return
1571
+ return
1572
+
1573
+ cache_key = f"{self.path}:{revision}"
1574
+ cached = self.server.cache.get(cache_key)
1575
+ if cached is None:
1576
+ payload = payload_factory()
1577
+ payload["revision"] = revision
1578
+ cached = CachedResponse(
1579
+ revision=revision,
1580
+ body=(json.dumps(payload, sort_keys=True).encode("utf-8") + b"\n"),
1581
+ content_type="application/json",
1582
+ )
1583
+ if len(self.server.cache) > 128:
1584
+ self.server.cache.clear()
1585
+ self.server.cache[cache_key] = cached
1586
+
1587
+ try:
1588
+ self.send_response(status)
1589
+ self.send_header("Content-Type", cached.content_type)
1590
+ self.send_header("Content-Length", str(len(cached.body)))
1591
+ self.send_header("ETag", revision)
1592
+ self.send_header("Cache-Control", "no-cache")
1593
+ self.end_headers()
1594
+ self.wfile.write(cached.body)
1595
+ except _CLIENT_DISCONNECT_ERRORS:
1596
+ return
1597
+
1598
+ def _send_api_error(self, status: int, error_type: str, message: str) -> None:
1599
+ self._send_json(
1600
+ status,
1601
+ {
1602
+ "ok": False,
1603
+ "error": {
1604
+ "type": error_type,
1605
+ "message": message,
1606
+ },
1607
+ },
1608
+ )
1609
+
1610
+
1611
+ def _task_ref_from_query(
1612
+ query: dict[str, list[str]],
1613
+ default_task_ref: str | None,
1614
+ ) -> str | None:
1615
+ raw = _first_query_value(query, "task")
1616
+ if raw is None or raw == "active":
1617
+ return default_task_ref
1618
+ return raw
1619
+
1620
+
1621
+ def _limit_from_query(query: dict[str, list[str]]) -> int:
1622
+ raw = _first_query_value(query, "limit")
1623
+ if raw is None:
1624
+ return 50
1625
+ try:
1626
+ limit = int(raw)
1627
+ except ValueError as exc:
1628
+ raise ValueError("Invalid limit value.") from exc
1629
+ if limit <= 0:
1630
+ raise ValueError("limit must be greater than 0.")
1631
+ return limit
1632
+
1633
+
1634
+ def _first_query_value(query: dict[str, list[str]], name: str) -> str | None:
1635
+ values = query.get(name, [])
1636
+ if not values:
1637
+ return None
1638
+ return values[0].strip() or None
1639
+
1640
+
1641
+ def _storage_revision_for_project(workspace_root: Path) -> str:
1642
+ paths = resolve_v2_paths(workspace_root)
1643
+ return _revision_for_paths(
1644
+ paths.project_dir,
1645
+ [paths.active_task_path, *sorted(paths.tasks_dir.glob("task-*/task.md"))],
1646
+ )
1647
+
1648
+
1649
+ def _storage_revision_for_tasks(workspace_root: Path) -> str:
1650
+ paths = resolve_v2_paths(workspace_root)
1651
+ return _revision_for_paths(
1652
+ paths.project_dir,
1653
+ [
1654
+ *sorted(paths.tasks_dir.glob("task-*/task.md")),
1655
+ *sorted(paths.tasks_dir.glob("task-*/lock.yaml")),
1656
+ ],
1657
+ )
1658
+
1659
+
1660
+ def _storage_revision_for_dashboard(
1661
+ workspace_root: Path,
1662
+ task_ref: str | None,
1663
+ ) -> str:
1664
+ task = resolve_task_or_active(workspace_root, task_ref)
1665
+ paths = resolve_v2_paths(workspace_root)
1666
+ bundle = task_dir(paths, task.id)
1667
+ return _revision_for_paths(
1668
+ paths.project_dir,
1669
+ [bundle / "lock.yaml", *sorted(bundle.rglob("*.md"))],
1670
+ )
1671
+
1672
+
1673
+ def _storage_revision_for_events(
1674
+ workspace_root: Path,
1675
+ task_ref: str | None,
1676
+ ) -> str:
1677
+ resolve_task_or_active(workspace_root, task_ref)
1678
+ paths = resolve_v2_paths(workspace_root)
1679
+ return _revision_for_paths(
1680
+ paths.project_dir,
1681
+ sorted(paths.events_dir.glob("*.ndjson")),
1682
+ )
1683
+
1684
+
1685
+ def _revision_for_paths(project_dir: Path, paths: list[Path]) -> str:
1686
+ parts: list[str] = []
1687
+ seen: set[Path] = set()
1688
+ for path in sorted(paths, key=lambda item: str(item)):
1689
+ if path in seen:
1690
+ continue
1691
+ seen.add(path)
1692
+ if path.exists():
1693
+ stat = path.stat()
1694
+ parts.append(
1695
+ f"{_relative_path(project_dir, path)}:{stat.st_mtime_ns}:{stat.st_size}"
1696
+ )
1697
+ else:
1698
+ parts.append(f"{_relative_path(project_dir, path)}:missing")
1699
+ return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
1700
+
1701
+
1702
+ def _relative_path(project_dir: Path, path: Path) -> str:
1703
+ try:
1704
+ return str(path.relative_to(project_dir))
1705
+ except ValueError:
1706
+ return str(path)
1707
+
1708
+
1709
+ def _safe_script_literal(value: str | None) -> str:
1710
+ encoded = json.dumps(value)
1711
+ return (
1712
+ encoded.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
1713
+ )
1714
+
1715
+
1716
+ def _server_url(host: str, port: int) -> str:
1717
+ if ":" in host and not host.startswith("["):
1718
+ host = f"[{host}]"
1719
+ return f"http://{host}:{port}/"
1720
+
1721
+
1722
+ def _status_for_launch_error(exc: LaunchError) -> tuple[int, str]:
1723
+ message = str(exc)
1724
+ if "Task not found:" in message or "Active task" in message:
1725
+ return 404, "NotFound"
1726
+ return 400, "BadRequest"
1727
+
1728
+
1729
+ _CLIENT_DISCONNECT_ERRORS = (
1730
+ BrokenPipeError,
1731
+ ConnectionAbortedError,
1732
+ ConnectionResetError,
1733
+ )
1734
+
1735
+
1736
+ __all__ = [
1737
+ "DashboardServerConfig",
1738
+ "DashboardServerHandle",
1739
+ "launch_dashboard_server",
1740
+ "render_index_html",
1741
+ "serve_dashboard",
1742
+ ]