infinity-harness 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/extensions/infinity-harness/index.ts +870 -0
  5. package/harness/docs/ARCHITECTURE.md +159 -0
  6. package/harness/docs/CONSTRAINTS.md +19 -0
  7. package/harness/docs/DECISIONS.md +107 -0
  8. package/harness/docs/DOMAIN.md +13 -0
  9. package/harness/docs/agents/evaluator.md +14 -0
  10. package/harness/docs/agents/generator.md +13 -0
  11. package/harness/docs/agents/planner.md +13 -0
  12. package/harness/docs/agents/simplifier.md +13 -0
  13. package/harness/docs/api-patterns.md +23 -0
  14. package/harness/docs/phases/build.md +47 -0
  15. package/harness/docs/phases/define.md +58 -0
  16. package/harness/docs/phases/plan.md +50 -0
  17. package/harness/docs/phases/review.md +47 -0
  18. package/harness/docs/phases/ship.md +43 -0
  19. package/harness/docs/phases/simplify.md +45 -0
  20. package/harness/docs/phases/verify.md +46 -0
  21. package/harness/model-router.json +28 -0
  22. package/harness/skills/README.md +60 -0
  23. package/harness/skills/auth-security.md +56 -0
  24. package/harness/skills/building-mcp-servers.md +70 -0
  25. package/harness/skills/building-tools.md +60 -0
  26. package/harness/skills/capability-acquisition.md +72 -0
  27. package/harness/skills/cli-design.md +55 -0
  28. package/harness/skills/code-review.md +57 -0
  29. package/harness/skills/codebase-design.md +70 -0
  30. package/harness/skills/concurrency-async.md +61 -0
  31. package/harness/skills/config-and-secrets.md +52 -0
  32. package/harness/skills/context-hygiene.md +51 -0
  33. package/harness/skills/databases.md +63 -0
  34. package/harness/skills/diagnosing-bugs.md +84 -0
  35. package/harness/skills/domain-modeling.md +65 -0
  36. package/harness/skills/error-handling-logging.md +56 -0
  37. package/harness/skills/frontend-ui.md +56 -0
  38. package/harness/skills/grilling.md +48 -0
  39. package/harness/skills/http-apis.md +60 -0
  40. package/harness/skills/performance.md +53 -0
  41. package/harness/skills/pi-todo-adapted.md +41 -0
  42. package/harness/skills/planning-tasks.md +86 -0
  43. package/harness/skills/prototype.md +39 -0
  44. package/harness/skills/research.md +32 -0
  45. package/harness/skills/resolving-merge-conflicts.md +30 -0
  46. package/harness/skills/scope-discipline.md +49 -0
  47. package/harness/skills/self-review.md +45 -0
  48. package/harness/skills/stuck-protocol.md +51 -0
  49. package/harness/skills/tdd.md +80 -0
  50. package/harness/skills/testing-infra.md +57 -0
  51. package/harness/skills/writing-skills.md +60 -0
  52. package/package.json +61 -0
  53. package/src/core/brief.ts +242 -0
  54. package/src/core/config.ts +265 -0
  55. package/src/core/exec.ts +130 -0
  56. package/src/core/featureList.ts +286 -0
  57. package/src/core/fsx.ts +119 -0
  58. package/src/core/gates.ts +444 -0
  59. package/src/core/lock.ts +192 -0
  60. package/src/core/paths.ts +95 -0
  61. package/src/core/phases.ts +143 -0
  62. package/src/core/settings.ts +445 -0
  63. package/src/core/types.ts +245 -0
  64. package/src/goalLoop.ts +628 -0
  65. package/src/goalSpec.ts +679 -0
  66. package/src/goalState.ts +338 -0
  67. package/src/loop.ts +355 -0
  68. package/src/modelRouter.ts +184 -0
  69. package/src/remote.ts +244 -0
  70. package/src/replan.ts +300 -0
  71. package/src/review.ts +53 -0
  72. package/src/rework.ts +274 -0
  73. package/src/taskList.ts +355 -0
  74. package/src/ui/config.ts +286 -0
  75. package/src/ui/dashboard.ts +1066 -0
  76. package/src/ui/theme.ts +317 -0
  77. package/src/ui/widget.ts +370 -0
  78. package/src/unstuck.ts +214 -0
  79. package/src/worker.ts +351 -0
  80. package/types/proper-lockfile.d.ts +19 -0
@@ -0,0 +1,1066 @@
1
+ /**
2
+ * infinity-harness — the web dashboard.
3
+ *
4
+ * Same information design as src/ui/widget.ts, rendered for a browser. The
5
+ * harness runs unattended for hours; this page is what a human leaves open on a
6
+ * second monitor and glances at. It must answer four questions without
7
+ * interaction: where in the pipeline are we, how far along, what is being worked
8
+ * on right now, and is anything stuck.
9
+ *
10
+ * Two constraints shape everything below.
11
+ *
12
+ * 1. Every value on this page originates in model output or project files, so
13
+ * every interpolation goes through `escapeHtml`. There is no templating
14
+ * engine to catch a miss — the discipline is the mitigation.
15
+ * 2. The document is served from a localhost HTTP server with no asset routes,
16
+ * so it is entirely self-contained: no CDN, no web fonts, no remote images,
17
+ * no network at all beyond re-fetching itself.
18
+ */
19
+
20
+ import type {
21
+ CheckResult,
22
+ Feature,
23
+ FeatureList,
24
+ GateResult,
25
+ Goal,
26
+ Phase,
27
+ Subtask,
28
+ Task,
29
+ TaskStatus,
30
+ } from "../core/types.ts";
31
+ import {
32
+ computeProgress,
33
+ flattenTasks,
34
+ normalizeStatus,
35
+ normalizeSubtaskStatus,
36
+ type FlatTask,
37
+ } from "../core/featureList.ts";
38
+ import { getPhaseOrder } from "../core/phases.ts";
39
+ import { statusGlyph } from "./widget.ts";
40
+ import { UNICODE_GLYPHS } from "./theme.ts";
41
+
42
+ export type DashboardState = {
43
+ list: FeatureList;
44
+ phase: Phase | null;
45
+ enabledPhases?: readonly string[] | null;
46
+ paused?: boolean;
47
+ gate?: GateResult | null;
48
+ baseRevision: number;
49
+ timestamp: string;
50
+ retries?: { task: number; max: number };
51
+ /** Model-router config. Opaque here — rendered as a badge, never interpreted. */
52
+ router?: unknown;
53
+ /** Rework record. Opaque here — rendered as a badge, never interpreted. */
54
+ rework?: unknown;
55
+ };
56
+
57
+ // ── escaping ────────────────────────────────────────────────────────────────
58
+
59
+ const HTML_ESCAPES: Record<string, string> = {
60
+ "&": "&amp;",
61
+ "<": "&lt;",
62
+ ">": "&gt;",
63
+ '"': "&quot;",
64
+ "'": "&#39;",
65
+ };
66
+
67
+ /**
68
+ * Escape for both element text and double/single-quoted attribute values.
69
+ *
70
+ * `String(s)` is not redundant: the type says `string`, but the values reaching
71
+ * this function come from `JSON.parse` of files the harness does not control,
72
+ * so a number or `null` can arrive at runtime past the type system.
73
+ */
74
+ export function escapeHtml(s: string): string {
75
+ return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
76
+ }
77
+
78
+ /** Escape anything, including values the type system says cannot be here. */
79
+ function esc(v: unknown): string {
80
+ if (v === null || v === undefined) return "";
81
+ return escapeHtml(typeof v === "string" ? v : String(v));
82
+ }
83
+
84
+ // ── status normalisation ────────────────────────────────────────────────────
85
+
86
+ const STATUS_LABEL: Record<TaskStatus, string> = {
87
+ pending: "pending",
88
+ in_progress: "in progress",
89
+ complete: "complete",
90
+ blocked: "blocked",
91
+ rework: "rework",
92
+ };
93
+
94
+ /** CSS class suffix per status; keeps the palette mapping in one place. */
95
+ const STATUS_CLASS: Record<TaskStatus, string> = {
96
+ pending: "pending",
97
+ in_progress: "active",
98
+ complete: "complete",
99
+ blocked: "blocked",
100
+ rework: "rework",
101
+ };
102
+
103
+ /**
104
+ * `normalizeStatus` throws on junk, which is right for a writer and wrong for a
105
+ * renderer — a monitoring page must never 500 because one task has a typo'd
106
+ * status. Unknown values degrade to `pending`.
107
+ */
108
+ function safeStatus(v: unknown): TaskStatus {
109
+ try {
110
+ return normalizeStatus(v);
111
+ } catch {
112
+ return "pending";
113
+ }
114
+ }
115
+
116
+ function safeSubtaskStatus(v: unknown): Subtask["status"] {
117
+ try {
118
+ return normalizeSubtaskStatus(v);
119
+ } catch {
120
+ return "pending";
121
+ }
122
+ }
123
+
124
+ /**
125
+ * remote.ts reads feature-list.json raw, without the normalisation
126
+ * `loadFeatureList` applies, so legacy aliases ("done", "in-progress") reach us
127
+ * intact. `computeProgress` compares against the canonical spelling only, so a
128
+ * page rendered from raw data would show a task as complete in the task list and
129
+ * still count it as outstanding in the meter. Normalising once, up front, is
130
+ * what keeps every number on the page agreeing with every other one.
131
+ */
132
+ function normalizeForRender(list: FeatureList): FeatureList {
133
+ const features = Array.isArray(list?.features) ? list.features : [];
134
+ return {
135
+ ...list,
136
+ features: features.map((f) => ({
137
+ ...f,
138
+ tasks: (Array.isArray(f?.tasks) ? f.tasks : []).map((t) => ({
139
+ ...t,
140
+ status: safeStatus(t?.status),
141
+ subtasks: (Array.isArray(t?.subtasks) ? t.subtasks : []).map((s) => ({
142
+ ...s,
143
+ status: safeSubtaskStatus(s?.status),
144
+ })),
145
+ })),
146
+ })),
147
+ };
148
+ }
149
+
150
+ type StatusCounts = Record<TaskStatus, number>;
151
+
152
+ function countByStatus(tasks: readonly Task[]): StatusCounts {
153
+ const counts: StatusCounts = { pending: 0, in_progress: 0, complete: 0, blocked: 0, rework: 0 };
154
+ for (const t of tasks) counts[t.status] += 1;
155
+ return counts;
156
+ }
157
+
158
+ // ── numeric helpers ─────────────────────────────────────────────────────────
159
+
160
+ /** Percentages are interpolated into `style="width:…"`, so they must be finite. */
161
+ function pct(part: number, whole: number): number {
162
+ if (!Number.isFinite(part) || !Number.isFinite(whole) || whole <= 0) return 0;
163
+ return Math.max(0, Math.min(100, (part / whole) * 100));
164
+ }
165
+
166
+ function widthStyle(value: number): string {
167
+ return value <= 0 ? "" : ` style="width:${value.toFixed(3)}%"`;
168
+ }
169
+
170
+ // ── opaque badges (router / rework) ─────────────────────────────────────────
171
+
172
+ type Badge = { label: string; note: string | null };
173
+
174
+ function readString(rec: Record<string, unknown>, key: string): string | null {
175
+ const v = rec[key];
176
+ return typeof v === "string" && v.trim() !== "" ? v : null;
177
+ }
178
+
179
+ /**
180
+ * `router` and `rework` are declared `unknown` on purpose — this module has no
181
+ * business knowing their schemas, and they change independently of the UI. But
182
+ * "truthy" alone is the wrong test: remote.ts emits `{ active: false }` and
183
+ * `{ enabled: false }` sentinels rather than null, so a naive check would leave
184
+ * both badges permanently lit. Honour an explicit off-switch, then show whatever
185
+ * one short detail we can recognise, and nothing more.
186
+ */
187
+ function readBadge(value: unknown, label: string): Badge | null {
188
+ if (!value) return null;
189
+ if (typeof value !== "object") return { label, note: null };
190
+
191
+ const rec = value as Record<string, unknown>;
192
+ if (rec.enabled === false || rec.active === false) return null;
193
+
194
+ const impacted = rec.impactedCount;
195
+ if (typeof impacted === "number" && impacted > 0) {
196
+ return { label, note: `${impacted} impacted` };
197
+ }
198
+ const note = readString(rec, "returnTask") ?? readString(rec, "default");
199
+ return { label, note };
200
+ }
201
+
202
+ // ── components ──────────────────────────────────────────────────────────────
203
+
204
+ const GLYPHS = UNICODE_GLYPHS;
205
+
206
+ /** Stacked meter: outstanding work is visible as colour, not just as absence. */
207
+ function meter(counts: StatusCounts, total: number, extraClass = ""): string {
208
+ const order: TaskStatus[] = ["complete", "in_progress", "rework", "blocked"];
209
+ const segments = order
210
+ .filter((s) => counts[s] > 0)
211
+ .map((s) => {
212
+ const w = pct(counts[s], total);
213
+ return `<span class="seg seg-${STATUS_CLASS[s]}"${widthStyle(w)}></span>`;
214
+ })
215
+ .join("");
216
+ const done = Math.round(pct(counts.complete, total));
217
+ const cls = extraClass ? ` ${extraClass}` : "";
218
+ return (
219
+ `<div class="meter${cls}" role="img" aria-label="${esc(String(done))} percent complete">` +
220
+ segments +
221
+ `</div>`
222
+ );
223
+ }
224
+
225
+ function renderMasthead(
226
+ phase: Phase | null,
227
+ paused: boolean,
228
+ percent: number,
229
+ baseRevision: number,
230
+ badges: readonly Badge[],
231
+ ): string {
232
+ const phaseTag = paused
233
+ ? `<span class="tag tag-blocked">paused</span>`
234
+ : phase
235
+ ? `<span class="tag tag-accent">${esc(phase)}</span>`
236
+ : `<span class="tag tag-pending">not started</span>`;
237
+
238
+ const badgeHtml = badges
239
+ .map(
240
+ (b) =>
241
+ `<span class="chip chip-brand" title="${esc(b.label)}">${esc(b.label)}` +
242
+ (b.note ? `<span class="chip-note">${esc(b.note)}</span>` : "") +
243
+ `</span>`,
244
+ )
245
+ .join("");
246
+
247
+ return `<header class="masthead">
248
+ <div class="masthead-id">
249
+ <span class="brand" aria-hidden="true">&#8734;</span>
250
+ <span class="brand-name">infinity&#8209;harness</span>
251
+ ${phaseTag}
252
+ ${badgeHtml}
253
+ </div>
254
+ <div class="masthead-live">
255
+ <span class="pct-inline">${esc(String(percent))}%</span>
256
+ <span class="rev mono">rev ${esc(String(baseRevision))}</span>
257
+ <span id="conn" class="conn is-live"><span class="conn-dot"></span><span class="conn-label">live</span> &middot; <span id="age">just now</span></span>
258
+ </div>
259
+ </header>`;
260
+ }
261
+
262
+ function renderGoals(goals: readonly Goal[]): string {
263
+ if (goals.length === 0) return "";
264
+ const [first, ...rest] = goals;
265
+ if (!first) return "";
266
+
267
+ const extras = rest.length
268
+ ? `<ul class="goal-more">` +
269
+ rest.map((g) => `<li>${esc(g.title ?? g.id ?? "")}</li>`).join("") +
270
+ `</ul>`
271
+ : "";
272
+
273
+ return `<section class="card goal" aria-label="Goal">
274
+ <div class="label">Goal</div>
275
+ <h1 class="goal-title">${esc(first.title ?? first.id ?? "")}</h1>
276
+ ${first.description && first.description !== first.title ? `<p class="goal-desc">${esc(first.description)}</p>` : ""}
277
+ ${extras}
278
+ </section>`;
279
+ }
280
+
281
+ /**
282
+ * The pipeline as a rail. A phase that is not part of the enabled pipeline
283
+ * (`init`, or a phase disabled after the run began) is still where the harness
284
+ * actually is, so it is prepended rather than dropped — a rail that cannot show
285
+ * the current phase is worse than a rail with an extra node.
286
+ */
287
+ function renderRail(phase: Phase | null, enabled: readonly string[] | null | undefined, paused: boolean): string {
288
+ const order = getPhaseOrder(enabled);
289
+ const steps: Phase[] = phase && !order.includes(phase) ? [phase, ...order] : order;
290
+ const current = phase ? steps.indexOf(phase) : -1;
291
+
292
+ const items = steps
293
+ .map((p, i) => {
294
+ const state =
295
+ current >= 0 && i < current
296
+ ? "is-done"
297
+ : i === current
298
+ ? paused
299
+ ? "is-current is-paused"
300
+ : "is-current"
301
+ : "is-todo";
302
+ const mark =
303
+ current >= 0 && i < current
304
+ ? GLYPHS.phaseDone
305
+ : i === current
306
+ ? GLYPHS.phaseCurrent
307
+ : GLYPHS.phaseTodo;
308
+ return `<li class="step ${state}">
309
+ <span class="track"><span class="dot" aria-hidden="true"></span></span>
310
+ <span class="step-name">${esc(p)}</span>
311
+ <span class="sr-only">${esc(mark)}</span>
312
+ </li>`;
313
+ })
314
+ .join("");
315
+
316
+ return `<nav class="card rail-wrap" aria-label="Pipeline">
317
+ <ol class="rail">${items}</ol>
318
+ </nav>`;
319
+ }
320
+
321
+ function renderProgress(counts: StatusCounts, total: number, featuresDone: number, featuresTotal: number): string {
322
+ const percent = Math.round(pct(counts.complete, total));
323
+
324
+ const legendOrder: TaskStatus[] = ["complete", "in_progress", "rework", "blocked", "pending"];
325
+ const legend = legendOrder
326
+ .map((s) => {
327
+ const zero = counts[s] === 0 ? " is-zero" : "";
328
+ return `<li class="legend-item legend-${STATUS_CLASS[s]}${zero}">
329
+ <span class="legend-dot" aria-hidden="true"></span>
330
+ <span class="legend-n">${esc(String(counts[s]))}</span>
331
+ <span class="legend-label">${esc(STATUS_LABEL[s])}</span>
332
+ </li>`;
333
+ })
334
+ .join("");
335
+
336
+ return `<section class="card progress" aria-label="Progress">
337
+ <div class="progress-head">
338
+ <div>
339
+ <div class="label">Overall</div>
340
+ <div class="pct">${esc(String(percent))}<span class="pct-sign">%</span></div>
341
+ </div>
342
+ <div class="progress-counts">
343
+ <div><span class="count">${esc(String(counts.complete))}</span><span class="count-of"> / ${esc(String(total))}</span> tasks</div>
344
+ <div><span class="count">${esc(String(featuresDone))}</span><span class="count-of"> / ${esc(String(featuresTotal))}</span> features</div>
345
+ </div>
346
+ </div>
347
+ ${meter(counts, total, "meter-lg")}
348
+ <ul class="legend">${legend}</ul>
349
+ </section>`;
350
+ }
351
+
352
+ type Alert = { tone: string; text: string };
353
+
354
+ /**
355
+ * The strip only exists when it has something to say. An always-present "0
356
+ * blocked" row trains the eye to skip the exact region that matters on the one
357
+ * day it is not zero.
358
+ */
359
+ function renderAlerts(
360
+ counts: StatusCounts,
361
+ paused: boolean,
362
+ retries: DashboardState["retries"],
363
+ gate: GateResult | null,
364
+ ): string {
365
+ const alerts: Alert[] = [];
366
+
367
+ if (paused) alerts.push({ tone: "blocked", text: "run paused" });
368
+ if (counts.blocked > 0) {
369
+ alerts.push({ tone: "blocked", text: `${counts.blocked} blocked ${counts.blocked === 1 ? "task" : "tasks"}` });
370
+ }
371
+ if (counts.rework > 0) {
372
+ alerts.push({ tone: "rework", text: `${counts.rework} in rework` });
373
+ }
374
+ if (retries && retries.task > 0) {
375
+ const spent = retries.max > 0 && retries.task >= retries.max;
376
+ alerts.push({
377
+ tone: spent ? "blocked" : "active",
378
+ text: `retry budget ${retries.task} / ${retries.max}${spent ? " — exhausted" : ""}`,
379
+ });
380
+ }
381
+ if (gate && gate.overall === false) {
382
+ const failures = Array.isArray(gate.failures) ? gate.failures : [];
383
+ const head = failures.slice(0, 3).join(", ");
384
+ const more = failures.length > 3 ? ` +${failures.length - 3} more` : "";
385
+ alerts.push({ tone: "blocked", text: `gate failing${head ? ": " + head : ""}${more}` });
386
+ }
387
+
388
+ if (alerts.length === 0) return "";
389
+
390
+ const items = alerts
391
+ .map(
392
+ (a) =>
393
+ `<li class="alert alert-${esc(a.tone)}"><span class="alert-dot" aria-hidden="true"></span>${esc(a.text)}</li>`,
394
+ )
395
+ .join("");
396
+ return `<ul class="alerts" role="status" aria-label="Alerts">${items}</ul>`;
397
+ }
398
+
399
+ function checkState(c: CheckResult): { key: string; label: string } {
400
+ if (c.pass) return { key: "complete", label: "pass" };
401
+ if (c.advisory) return { key: "active", label: "advisory" };
402
+ return { key: "blocked", label: "fail" };
403
+ }
404
+
405
+ function renderGate(gate: GateResult | null): string {
406
+ if (!gate) return "";
407
+ const checks = Array.isArray(gate.checks) ? gate.checks : [];
408
+ const failures = Array.isArray(gate.failures) ? gate.failures : [];
409
+ const passed = gate.overall !== false;
410
+
411
+ const scope = [gate.feature, gate.task].filter((v): v is string => typeof v === "string" && v !== "");
412
+
413
+ const rows = checks.length
414
+ ? checks
415
+ .map((c) => {
416
+ const st = checkState(c);
417
+ return `<li class="check check-${st.key}">
418
+ <span class="check-state">${esc(st.label)}</span>
419
+ <span class="check-name mono">${esc(c.name ?? "")}</span>
420
+ <span class="check-detail">${esc(c.detail ?? "")}</span>
421
+ </li>`;
422
+ })
423
+ .join("")
424
+ : `<li class="check check-empty">No checks ran for this gate.</li>`;
425
+
426
+ const summary =
427
+ !passed && failures.length
428
+ ? `<p class="gate-summary">Blocking: ${esc(failures.join(", "))}</p>`
429
+ : "";
430
+
431
+ return `<section class="card gate ${passed ? "gate-pass" : "gate-fail"}" aria-label="Gate result">
432
+ <div class="gate-head">
433
+ <div>
434
+ <div class="label">Gate</div>
435
+ <div class="gate-phase">${esc(gate.phase ?? "")}${scope.length ? `<span class="gate-scope mono">${esc(scope.join(" · "))}</span>` : ""}</div>
436
+ </div>
437
+ <span class="verdict verdict-${passed ? "pass" : "fail"}">${passed ? "passed" : "failed"}</span>
438
+ </div>
439
+ ${summary}
440
+ <ul class="checks">${rows}</ul>
441
+ </section>`;
442
+ }
443
+
444
+ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): string {
445
+ const deps = Array.isArray(task.dependsOn) ? task.dependsOn : [];
446
+ if (deps.length === 0) return "";
447
+ // Unresolvable dependencies print verbatim — a dangling reference is a real
448
+ // planning bug and hiding it would make the plan look sounder than it is.
449
+ const refs = deps.map((d) => {
450
+ const i = indexByKey.get(d);
451
+ return i === undefined ? esc(d) : `#${esc(String(i))}`;
452
+ });
453
+ return `<span class="deps mono" title="depends on">${esc(GLYPHS.arrow)} ${refs.join(", ")}</span>`;
454
+ }
455
+
456
+ function renderSubtasks(task: FlatTask): string {
457
+ const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
458
+ if (subs.length === 0) return "";
459
+ const items = subs
460
+ .map((s) => {
461
+ const glyph =
462
+ s.status === "complete" ? GLYPHS.subDone : s.status === "in_progress" ? GLYPHS.subActive : GLYPHS.subPending;
463
+ const cls = s.status === "complete" ? "complete" : s.status === "in_progress" ? "active" : "pending";
464
+ return `<li class="sub sub-${cls}"><span class="sub-glyph" aria-hidden="true">${esc(glyph)}</span>${esc(s.title ?? "")}</li>`;
465
+ })
466
+ .join("");
467
+ return `<ul class="subs">${items}</ul>`;
468
+ }
469
+
470
+ function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>): string {
471
+ const status = task.status;
472
+ const cls = STATUS_CLASS[status];
473
+ // Subtasks are shown only for the task actually being worked on. Everywhere
474
+ // else they are detail nobody can act on, and they bury the active row.
475
+ const isActive = status === "in_progress" || status === "rework";
476
+ const difficulty =
477
+ typeof task.difficulty === "string" ? `<span class="chip chip-quiet">${esc(task.difficulty)}</span>` : "";
478
+
479
+ return `<tr class="row row-${cls}${isActive ? " is-active" : ""}">
480
+ <td class="cell-n">
481
+ <span class="glyph glyph-${cls}" aria-hidden="true">${esc(statusGlyph(status, GLYPHS))}</span>
482
+ <span class="n mono">${esc(String(task.index))}</span>
483
+ </td>
484
+ <td class="cell-task">
485
+ <div class="task-line">
486
+ <span class="task-desc">${esc(task.description || task.compositeKey)}</span>
487
+ ${difficulty}
488
+ ${depLabel(task, indexByKey)}
489
+ </div>
490
+ ${isActive ? renderSubtasks(task) : ""}
491
+ </td>
492
+ <td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
493
+ </tr>`;
494
+ }
495
+
496
+ function renderFeature(
497
+ feature: Feature,
498
+ tasks: readonly FlatTask[],
499
+ indexByKey: ReadonlyMap<string, number>,
500
+ sprintName: string | null,
501
+ goalName: string | null,
502
+ ): string {
503
+ const counts = countByStatus(tasks);
504
+ const total = tasks.length;
505
+ const complete = total > 0 && counts.complete === total;
506
+
507
+ const chips = [
508
+ sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
509
+ goalName ? `<span class="chip chip-quiet">${esc(goalName)}</span>` : "",
510
+ feature.passes === true ? `<span class="chip chip-complete">verified</span>` : "",
511
+ ].join("");
512
+
513
+ const body = total
514
+ ? `<div class="table-wrap">
515
+ <table class="tasks">
516
+ <thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
517
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey)).join("")}</tbody>
518
+ </table>
519
+ </div>`
520
+ : `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
521
+
522
+ return `<section class="card feature${complete ? " is-complete" : ""}">
523
+ <div class="feature-head">
524
+ <div class="feature-id">
525
+ <h2 class="feature-name">${esc(feature.name ?? feature.id ?? "")}</h2>
526
+ <span class="mono faint">${esc(feature.id ?? "")}</span>
527
+ ${chips}
528
+ </div>
529
+ <div class="feature-progress">
530
+ <span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>
531
+ ${meter(counts, total)}
532
+ </div>
533
+ </div>
534
+ ${feature.description ? `<p class="feature-desc">${esc(feature.description)}</p>` : ""}
535
+ ${body}
536
+ </section>`;
537
+ }
538
+
539
+ function renderEmptyPlan(phase: Phase | null): string {
540
+ const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
541
+ return `<section class="card empty">
542
+ <div class="empty-mark" aria-hidden="true">${esc(GLYPHS.pending)}</div>
543
+ <h2>No plan yet</h2>
544
+ <p>${where} Features and tasks appear here once DEFINE and PLAN have produced a feature list.</p>
545
+ <p class="faint">This page refreshes itself every few seconds — leave it open.</p>
546
+ </section>`;
547
+ }
548
+
549
+ // ── styles ──────────────────────────────────────────────────────────────────
550
+
551
+ /**
552
+ * Palette note: `--c-*` are the canonical semantic hues, byte-identical to
553
+ * PALETTE in src/ui/theme.ts, and are used for fills, glyphs and rules. Small
554
+ * text needs a second set: #D98A00 on white is 2.8:1 and #D64545 on the dark
555
+ * ground is 4.1:1, both below AA. `--t-*` keep the same hue and shift luminance
556
+ * per theme so status text stays readable in both.
557
+ */
558
+ const STYLES = `
559
+ *,*::before,*::after{box-sizing:border-box}
560
+
561
+ :root{
562
+ --bg:#F6F7F9;
563
+ --surface:#FFFFFF;
564
+ --surface-2:#F1F3F6;
565
+ --border:#E3E6EB;
566
+ --border-strong:#CDD2DA;
567
+ --text:#13161B;
568
+ --muted:#59606B;
569
+ --faint:#8A909B;
570
+ --shadow:0 1px 2px rgba(17,22,32,.05),0 8px 20px rgba(17,22,32,.04);
571
+ --ring:rgba(0,184,212,.20);
572
+
573
+ --c-complete:#2E9E5B;
574
+ --c-active:#D98A00;
575
+ --c-pending:#8A8F98;
576
+ --c-blocked:#D64545;
577
+ --c-rework:#B36BD4;
578
+ --c-brand:#7C5CFF;
579
+ --c-accent:#00B8D4;
580
+
581
+ --rgb-complete:46,158,91;
582
+ --rgb-active:217,138,0;
583
+ --rgb-pending:138,143,152;
584
+ --rgb-blocked:214,69,69;
585
+ --rgb-rework:179,107,212;
586
+ --rgb-brand:124,92,255;
587
+ --rgb-accent:0,184,212;
588
+
589
+ --t-complete:#1E7A43;
590
+ --t-active:#8F5C11;
591
+ --t-pending:#616772;
592
+ --t-blocked:#B12F2F;
593
+ --t-rework:#8843AB;
594
+ --t-brand:#5A3EE0;
595
+ --t-accent:#0A7386;
596
+ }
597
+
598
+ @media (prefers-color-scheme:dark){
599
+ :root{
600
+ --bg:#0B0D11;
601
+ --surface:#14171C;
602
+ --surface-2:#191D24;
603
+ --border:#242A33;
604
+ --border-strong:#333A45;
605
+ --text:#E7EAEF;
606
+ --muted:#98A0AC;
607
+ --faint:#6C7480;
608
+ --shadow:0 1px 2px rgba(0,0,0,.36),0 10px 28px rgba(0,0,0,.30);
609
+ --ring:rgba(0,184,212,.26);
610
+
611
+ --t-complete:#5DC98A;
612
+ --t-active:#F0AE3C;
613
+ --t-pending:#9AA1AC;
614
+ --t-blocked:#F08281;
615
+ --t-rework:#CF9AE4;
616
+ --t-brand:#A492FF;
617
+ --t-accent:#4FD8ED;
618
+ }
619
+ }
620
+
621
+ html{-webkit-text-size-adjust:100%}
622
+ body{
623
+ margin:0;
624
+ background:var(--bg);
625
+ color:var(--text);
626
+ font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,Cantarell,"Noto Sans",sans-serif;
627
+ font-variant-numeric:tabular-nums;
628
+ -webkit-font-smoothing:antialiased;
629
+ }
630
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:.92em}
631
+ .faint{color:var(--faint)}
632
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
633
+
634
+ .page{max-width:1120px;margin:0 auto;padding:0 20px 72px}
635
+ .card{
636
+ background:var(--surface);
637
+ border:1px solid var(--border);
638
+ border-radius:12px;
639
+ padding:16px 18px;
640
+ margin:14px 0;
641
+ box-shadow:var(--shadow);
642
+ }
643
+ .label{
644
+ font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;
645
+ color:var(--faint);font-weight:600;margin-bottom:6px;
646
+ }
647
+
648
+ /* -- masthead ------------------------------------------------------------- */
649
+ .masthead{
650
+ position:sticky;top:0;z-index:20;
651
+ display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center;justify-content:space-between;
652
+ padding:14px 0 12px;margin-bottom:2px;
653
+ background:var(--bg);
654
+ border-bottom:1px solid var(--border);
655
+ }
656
+ .masthead-id,.masthead-live{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}
657
+ .brand{color:var(--c-brand);font-size:19px;line-height:1}
658
+ .brand-name{font-weight:650;letter-spacing:-.01em}
659
+ .pct-inline{font-weight:650;font-variant-numeric:tabular-nums}
660
+ .rev{color:var(--faint)}
661
+
662
+ .tag{
663
+ font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;
664
+ padding:3px 8px;border-radius:999px;border:1px solid transparent;
665
+ }
666
+ .tag-accent{color:var(--t-accent);background:rgba(var(--rgb-accent),.12);border-color:rgba(var(--rgb-accent),.32)}
667
+ .tag-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.12);border-color:rgba(var(--rgb-blocked),.34)}
668
+ .tag-pending{color:var(--t-pending);background:rgba(var(--rgb-pending),.12);border-color:rgba(var(--rgb-pending),.30)}
669
+
670
+ /* Chips carry model-supplied names (sprints, router defaults). inline-block
671
+ rather than inline-flex so text-overflow can actually clip an absurd one
672
+ instead of pushing the masthead past the viewport. */
673
+ .chip{
674
+ display:inline-block;
675
+ font-size:11px;padding:2px 7px;border-radius:6px;
676
+ border:1px solid var(--border);background:var(--surface-2);color:var(--muted);
677
+ white-space:nowrap;max-width:min(100%,26ch);overflow:hidden;text-overflow:ellipsis;
678
+ vertical-align:baseline;
679
+ }
680
+ .chip-brand{color:var(--t-brand);background:rgba(var(--rgb-brand),.10);border-color:rgba(var(--rgb-brand),.28)}
681
+ .chip-complete{color:var(--t-complete);background:rgba(var(--rgb-complete),.12);border-color:rgba(var(--rgb-complete),.30)}
682
+ .chip-quiet{font-size:10.5px}
683
+ .chip-note{color:var(--faint);font-size:10.5px;margin-left:5px}
684
+
685
+ .conn{display:inline-flex;align-items:center;gap:6px;color:var(--faint);font-size:12px}
686
+ .conn-dot{width:7px;height:7px;border-radius:50%;background:var(--c-pending);flex:none}
687
+ .conn.is-live .conn-dot{background:var(--c-complete)}
688
+ .conn.is-live .conn-label{color:var(--t-complete)}
689
+ .conn.is-stale .conn-dot{background:var(--c-active)}
690
+ .conn.is-stale .conn-label{color:var(--t-active)}
691
+
692
+ /* -- goal ----------------------------------------------------------------- */
693
+ .goal-title{font-size:17px;font-weight:600;margin:0;line-height:1.35;overflow-wrap:anywhere}
694
+ .goal-desc{margin:8px 0 0;color:var(--muted);overflow-wrap:anywhere}
695
+ .goal-more{margin:10px 0 0;padding-left:18px;color:var(--muted);font-size:13px;overflow-wrap:anywhere}
696
+
697
+ /* -- phase rail ----------------------------------------------------------- */
698
+ .rail-wrap{overflow-x:auto;padding:18px 18px 14px}
699
+ .rail{display:flex;list-style:none;margin:0;padding:0;min-width:520px}
700
+ .step{flex:1 1 0;min-width:76px;text-align:center;position:relative}
701
+ .track{display:block;position:relative;height:14px}
702
+ .track::before,.track::after{content:"";position:absolute;top:50%;height:2px;background:var(--border-strong);transform:translateY(-50%)}
703
+ .track::before{left:0;right:50%}
704
+ .track::after{left:50%;right:0}
705
+ .step:first-child .track::before,.step:last-child .track::after{display:none}
706
+ .dot{
707
+ position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
708
+ width:11px;height:11px;border-radius:50%;
709
+ background:var(--surface);border:2px solid var(--border-strong);z-index:1;
710
+ }
711
+ .step-name{
712
+ display:block;margin-top:9px;font-size:12px;color:var(--faint);
713
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px;
714
+ }
715
+ .step.is-done .dot{background:var(--c-complete);border-color:var(--c-complete)}
716
+ .step.is-done .track::before,.step.is-done .track::after,.step.is-current .track::before{background:var(--c-complete)}
717
+ .step.is-done .step-name{color:var(--t-complete)}
718
+ .step.is-current .dot{
719
+ background:var(--c-accent);border-color:var(--c-accent);
720
+ width:13px;height:13px;box-shadow:0 0 0 4px var(--ring);
721
+ animation:pulse 2.6s ease-in-out infinite;
722
+ }
723
+ .step.is-current .step-name{color:var(--t-accent);font-weight:650;letter-spacing:.02em}
724
+ .step.is-current.is-paused .dot{background:var(--c-blocked);border-color:var(--c-blocked);box-shadow:0 0 0 4px rgba(var(--rgb-blocked),.20);animation:none}
725
+ .step.is-current.is-paused .step-name{color:var(--t-blocked)}
726
+ @keyframes pulse{0%,100%{box-shadow:0 0 0 3px var(--ring)}50%{box-shadow:0 0 0 7px rgba(var(--rgb-accent),.06)}}
727
+
728
+ /* -- progress ------------------------------------------------------------- */
729
+ .progress-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
730
+ .pct{font-size:38px;line-height:1;font-weight:640;letter-spacing:-.02em}
731
+ .pct-sign{font-size:19px;font-weight:500;color:var(--faint);margin-left:2px}
732
+ .progress-counts{text-align:right;color:var(--muted);font-size:13px;line-height:1.7}
733
+ .count{font-weight:650;color:var(--text)}
734
+ .count-of{color:var(--faint)}
735
+
736
+ .meter{
737
+ display:flex;height:6px;border-radius:999px;overflow:hidden;
738
+ background:var(--surface-2);border:1px solid var(--border);
739
+ }
740
+ .meter-lg{height:10px;margin:14px 0 12px}
741
+ .seg{display:block;height:100%}
742
+ .seg-complete{background:var(--c-complete)}
743
+ .seg-active{background:var(--c-active)}
744
+ .seg-rework{background:var(--c-rework)}
745
+ .seg-blocked{background:var(--c-blocked)}
746
+
747
+ .legend{display:flex;flex-wrap:wrap;gap:6px 18px;list-style:none;margin:0;padding:0;font-size:12px}
748
+ .legend-item{display:flex;align-items:center;gap:6px;color:var(--muted)}
749
+ .legend-item.is-zero{opacity:.42}
750
+ .legend-dot{width:8px;height:8px;border-radius:2px;flex:none}
751
+ .legend-n{font-weight:650;color:var(--text);font-variant-numeric:tabular-nums}
752
+ .legend-complete .legend-dot{background:var(--c-complete)}
753
+ .legend-active .legend-dot{background:var(--c-active)}
754
+ .legend-rework .legend-dot{background:var(--c-rework)}
755
+ .legend-blocked .legend-dot{background:var(--c-blocked)}
756
+ .legend-pending .legend-dot{background:var(--c-pending)}
757
+
758
+ /* -- alerts --------------------------------------------------------------- */
759
+ .alerts{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:14px 0;padding:0}
760
+ .alert{
761
+ display:inline-flex;align-items:center;gap:7px;
762
+ font-size:12.5px;font-weight:550;padding:5px 11px;border-radius:8px;border:1px solid transparent;
763
+ max-width:100%;overflow-wrap:anywhere;
764
+ }
765
+ .alert-dot{width:7px;height:7px;border-radius:50%;flex:none;background:currentColor}
766
+ .alert-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.10);border-color:rgba(var(--rgb-blocked),.30)}
767
+ .alert-rework{color:var(--t-rework);background:rgba(var(--rgb-rework),.10);border-color:rgba(var(--rgb-rework),.28)}
768
+ .alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
769
+
770
+ /* -- gate ----------------------------------------------------------------- */
771
+ .gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
772
+ .gate-phase{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;overflow-wrap:anywhere}
773
+ .gate-scope{margin-left:10px;color:var(--faint);font-weight:400;text-transform:none;letter-spacing:0}
774
+ .gate-fail{border-color:rgba(var(--rgb-blocked),.45);box-shadow:var(--shadow),inset 3px 0 0 var(--c-blocked)}
775
+ .verdict{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 10px;border-radius:999px}
776
+ .verdict-pass{color:var(--t-complete);background:rgba(var(--rgb-complete),.12)}
777
+ .verdict-fail{color:#fff;background:var(--c-blocked)}
778
+ .gate-summary{margin:10px 0 0;color:var(--t-blocked);font-weight:550;overflow-wrap:anywhere}
779
+ .checks{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:1px}
780
+ .check{
781
+ display:grid;grid-template-columns:74px minmax(110px,190px) 1fr;gap:12px;align-items:baseline;
782
+ padding:8px 10px;border-radius:7px;background:var(--surface-2);
783
+ }
784
+ .check-state{font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
785
+ .check-name{overflow-wrap:anywhere}
786
+ .check-detail{color:var(--muted);overflow-wrap:anywhere}
787
+ .check-complete .check-state{color:var(--t-complete)}
788
+ .check-active .check-state{color:var(--t-active)}
789
+ .check-blocked{background:rgba(var(--rgb-blocked),.10);box-shadow:inset 2px 0 0 var(--c-blocked)}
790
+ .check-blocked .check-state{color:var(--t-blocked)}
791
+ .check-blocked .check-name{font-weight:650}
792
+ .check-empty{display:block;color:var(--muted)}
793
+
794
+ /* -- features ------------------------------------------------------------- */
795
+ .feature-head{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap}
796
+ .feature-id{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;min-width:0;overflow-wrap:anywhere}
797
+ .feature-name{font-size:15px;font-weight:640;margin:0;overflow-wrap:anywhere}
798
+ .feature-progress{display:flex;align-items:center;gap:10px;flex:0 1 220px;min-width:150px}
799
+ .feature-count{color:var(--muted);white-space:nowrap}
800
+ .feature-progress .meter{flex:1}
801
+ .feature.is-complete .feature-name{color:var(--muted)}
802
+ .feature-desc{margin:8px 0 0;color:var(--muted);font-size:13px;overflow-wrap:anywhere}
803
+ .empty-inline{color:var(--faint);font-size:13px;margin:12px 0 2px}
804
+
805
+ .table-wrap{overflow-x:auto;margin:12px -18px -16px;padding:0 18px 4px}
806
+ table.tasks{width:100%;border-collapse:collapse;min-width:480px}
807
+ table.tasks th{
808
+ font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--faint);
809
+ font-weight:600;text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);
810
+ }
811
+ table.tasks td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}
812
+ table.tasks tr:last-child td{border-bottom:0}
813
+ .cell-n{width:1%;white-space:nowrap;padding-left:10px}
814
+ .cell-status{width:1%;white-space:nowrap;text-align:right}
815
+ .glyph{margin-right:6px}
816
+ .glyph-complete{color:var(--t-complete)}
817
+ .glyph-active{color:var(--t-active)}
818
+ .glyph-pending{color:var(--t-pending)}
819
+ .glyph-blocked{color:var(--t-blocked)}
820
+ .glyph-rework{color:var(--t-rework)}
821
+ .n{color:var(--faint)}
822
+ .task-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}
823
+ .task-desc{overflow-wrap:anywhere}
824
+ .row-complete .task-desc{color:var(--muted)}
825
+ .row.is-active{background:rgba(var(--rgb-active),.07)}
826
+ .row.is-active .task-desc{font-weight:600}
827
+ .row.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-active)}
828
+ .row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
829
+ .row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
830
+ .row-blocked{background:rgba(var(--rgb-blocked),.07)}
831
+ .row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
832
+ .deps{color:var(--faint);white-space:nowrap}
833
+ .pill{
834
+ font-size:10.5px;font-weight:600;letter-spacing:.04em;padding:2px 8px;border-radius:999px;
835
+ border:1px solid transparent;white-space:nowrap;
836
+ }
837
+ .pill-complete{color:var(--t-complete);background:rgba(var(--rgb-complete),.11);border-color:rgba(var(--rgb-complete),.26)}
838
+ .pill-active{color:var(--t-active);background:rgba(var(--rgb-active),.12);border-color:rgba(var(--rgb-active),.28)}
839
+ .pill-pending{color:var(--t-pending);background:rgba(var(--rgb-pending),.10);border-color:rgba(var(--rgb-pending),.24)}
840
+ .pill-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.12);border-color:rgba(var(--rgb-blocked),.30)}
841
+ .pill-rework{color:var(--t-rework);background:rgba(var(--rgb-rework),.12);border-color:rgba(var(--rgb-rework),.28)}
842
+
843
+ .subs{list-style:none;margin:7px 0 2px;padding:0 0 0 2px;display:flex;flex-direction:column;gap:3px}
844
+ .sub{font-size:12.5px;color:var(--muted);display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere}
845
+ .sub-glyph{flex:none;width:12px;text-align:center;color:var(--t-pending)}
846
+ .sub-complete .sub-glyph{color:var(--t-complete)}
847
+ .sub-active .sub-glyph{color:var(--t-active)}
848
+ .sub-active{color:var(--text)}
849
+
850
+ /* -- empty / footer ------------------------------------------------------- */
851
+ .empty{text-align:center;padding:44px 24px}
852
+ .empty-mark{font-size:26px;color:var(--faint);margin-bottom:10px}
853
+ .empty h2{margin:0 0 8px;font-size:16px;font-weight:600}
854
+ .empty p{margin:0 auto;max-width:46ch;color:var(--muted)}
855
+ .empty p.faint{margin-top:10px;font-size:12.5px}
856
+ .section-label{
857
+ margin:26px 2px 2px;font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;
858
+ color:var(--faint);font-weight:600;
859
+ }
860
+ .foot{
861
+ display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:space-between;
862
+ margin-top:22px;padding-top:14px;border-top:1px solid var(--border);
863
+ color:var(--faint);font-size:12px;
864
+ }
865
+ .foot a{color:var(--t-accent);text-decoration:none}
866
+ .foot a:hover{text-decoration:underline}
867
+
868
+ @media (max-width:640px){
869
+ .page{padding:0 14px 56px}
870
+ .card{padding:14px;border-radius:10px}
871
+ .pct{font-size:30px}
872
+ .progress-counts{text-align:left}
873
+ .feature-progress{flex:1 1 100%}
874
+ .table-wrap{margin:12px -14px -14px;padding:0 14px 4px}
875
+ .check{grid-template-columns:64px 1fr;row-gap:2px}
876
+ .check-detail{grid-column:1/-1;font-size:12.5px}
877
+ }
878
+
879
+ @media (prefers-reduced-motion:reduce){
880
+ *{animation:none!important;transition:none!important}
881
+ }
882
+ `;
883
+
884
+ // ── refresh script ──────────────────────────────────────────────────────────
885
+
886
+ /**
887
+ * Poll-and-swap rather than a websocket: the server is a plain read-only GET
888
+ * endpoint and the page must survive it restarting. Only the contents of #app
889
+ * are replaced, so this script (which lives outside it) keeps running across
890
+ * refreshes and never re-registers its timers.
891
+ *
892
+ * Written without template literals — this string is itself embedded in one.
893
+ */
894
+ const SCRIPT = `
895
+ (function () {
896
+ var BASE = 5000, MAX = 60000, TIMEOUT = 4000;
897
+ var delay = BASE, lastOk = Date.now(), timer = null;
898
+
899
+ function mark(state) {
900
+ var el = document.getElementById("conn");
901
+ if (!el) return;
902
+ el.className = "conn " + (state === "live" ? "is-live" : "is-stale");
903
+ var label = el.querySelector(".conn-label");
904
+ if (label) label.textContent = state === "live" ? "live" : "reconnecting";
905
+ }
906
+
907
+ function ago() {
908
+ var el = document.getElementById("age");
909
+ if (!el) return;
910
+ var s = Math.max(0, Math.round((Date.now() - lastOk) / 1000));
911
+ el.textContent = s < 3 ? "just now" : s < 90 ? s + "s ago" : Math.round(s / 60) + "m ago";
912
+ }
913
+
914
+ function schedule(ms) {
915
+ if (timer) clearTimeout(timer);
916
+ timer = setTimeout(refresh, ms);
917
+ }
918
+
919
+ function opts() {
920
+ var o = { cache: "no-store", credentials: "same-origin", headers: { "Accept": "text/html" } };
921
+ // Without a deadline a half-open socket stalls the whole loop.
922
+ try { if (window.AbortSignal && AbortSignal.timeout) o.signal = AbortSignal.timeout(TIMEOUT); } catch (e) {}
923
+ return o;
924
+ }
925
+
926
+ function refresh() {
927
+ // A hidden tab is not being read; skip the work but keep the loop alive.
928
+ if (document.hidden) { schedule(BASE); return; }
929
+ fetch(location.pathname + location.search, opts())
930
+ .then(function (res) {
931
+ if (!res.ok) throw new Error("HTTP " + res.status);
932
+ return res.text();
933
+ })
934
+ .then(function (html) {
935
+ var doc = new DOMParser().parseFromString(html, "text/html");
936
+ var next = doc.getElementById("app");
937
+ var cur = document.getElementById("app");
938
+ if (next && cur) {
939
+ var x = window.scrollX, y = window.scrollY;
940
+ // innerHTML from a parsed document never executes scripts, and the
941
+ // server already escaped every value it interpolated.
942
+ cur.innerHTML = next.innerHTML;
943
+ window.scrollTo(x, y);
944
+ }
945
+ if (doc.title) document.title = doc.title;
946
+ lastOk = Date.now();
947
+ delay = BASE;
948
+ mark("live");
949
+ ago();
950
+ })
951
+ .catch(function () {
952
+ // Back off instead of hammering a server that is restarting mid-run.
953
+ delay = Math.min(MAX, Math.round(delay * 1.7));
954
+ mark("stale");
955
+ })
956
+ .then(function () { schedule(delay); });
957
+ }
958
+
959
+ setInterval(ago, 1000);
960
+ document.addEventListener("visibilitychange", function () {
961
+ if (!document.hidden) { delay = BASE; schedule(0); }
962
+ });
963
+ schedule(BASE);
964
+ })();
965
+ `;
966
+
967
+ // ── document ────────────────────────────────────────────────────────────────
968
+
969
+ function formatTimestamp(iso: string): string {
970
+ const d = new Date(iso);
971
+ if (Number.isNaN(d.getTime())) return iso;
972
+ return d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
973
+ }
974
+
975
+ export function renderDashboard(state: DashboardState): string {
976
+ const list = normalizeForRender(state.list ?? { version: "0", baseRevision: 0, features: [] });
977
+ const features = list.features ?? [];
978
+ const goals = Array.isArray(list.goals) ? list.goals : [];
979
+ const sprints = Array.isArray(list.sprints) ? list.sprints : [];
980
+ const paused = state.paused === true;
981
+ const gate = state.gate ?? null;
982
+
983
+ const progress = computeProgress(list);
984
+ const tasks = flattenTasks(list);
985
+ const counts = countByStatus(tasks);
986
+
987
+ // Dependency labels reference the flattened plan position (`← #3`), matching
988
+ // the terminal widget, so the two views can be read side by side.
989
+ const indexByKey = new Map<string, number>();
990
+ for (const t of tasks) {
991
+ indexByKey.set(t.compositeKey, t.index);
992
+ indexByKey.set(t.id, t.index);
993
+ if (t.key) indexByKey.set(t.key, t.index);
994
+ }
995
+
996
+ const tasksByFeature = new Map<string, FlatTask[]>();
997
+ for (const t of tasks) {
998
+ const bucket = tasksByFeature.get(t.featureId);
999
+ if (bucket) bucket.push(t);
1000
+ else tasksByFeature.set(t.featureId, [t]);
1001
+ }
1002
+
1003
+ const sprintNames = new Map<string, string>();
1004
+ for (const s of sprints) if (s?.id) sprintNames.set(s.id, s.name ?? s.id);
1005
+ const goalNames = new Map<string, string>();
1006
+ for (const g of goals) if (g?.id) goalNames.set(g.id, g.title ?? g.id);
1007
+
1008
+ const badges = [readBadge(state.router, "router"), readBadge(state.rework, "rework")].filter(
1009
+ (b): b is Badge => b !== null,
1010
+ );
1011
+
1012
+ const body = features.length
1013
+ ? `<div class="section-label">Features (${esc(String(features.length))})</div>` +
1014
+ features
1015
+ .map((f: Feature) =>
1016
+ renderFeature(
1017
+ f,
1018
+ tasksByFeature.get(f.id) ?? [],
1019
+ indexByKey,
1020
+ f.sprintId ? (sprintNames.get(f.sprintId) ?? null) : null,
1021
+ // A goal chip only earns its place when there is more than one goal
1022
+ // to tell apart.
1023
+ goals.length > 1 && f.goalId ? (goalNames.get(f.goalId) ?? null) : null,
1024
+ ),
1025
+ )
1026
+ .join("")
1027
+ : renderEmptyPlan(state.phase);
1028
+
1029
+ const titleBits: string[] = [];
1030
+ if (paused) titleBits.push("PAUSED");
1031
+ titleBits.push(state.phase ? state.phase.toUpperCase() : "IDLE");
1032
+ if (progress.tasksTotal > 0) titleBits.push(`${progress.percent}%`);
1033
+ const title = `${titleBits.join(" · ")} · infinity-harness`;
1034
+
1035
+ return `<!doctype html>
1036
+ <html lang="en">
1037
+ <head>
1038
+ <meta charset="utf-8">
1039
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1040
+ <meta name="color-scheme" content="light dark">
1041
+ <meta name="robots" content="noindex">
1042
+ <!-- Empty data URI: suppresses the /favicon.ico request the harness server would 404. -->
1043
+ <link rel="icon" href="data:,">
1044
+ <title>${esc(title)}</title>
1045
+ <style>${STYLES}</style>
1046
+ </head>
1047
+ <body>
1048
+ <div id="app">
1049
+ <div class="page">
1050
+ ${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
1051
+ ${renderGoals(goals)}
1052
+ ${renderRail(state.phase, state.enabledPhases, paused)}
1053
+ ${renderAlerts(counts, paused, state.retries, gate)}
1054
+ ${renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal)}
1055
+ ${renderGate(gate)}
1056
+ ${body}
1057
+ <footer class="foot">
1058
+ <span>state as of <span class="mono">${esc(formatTimestamp(state.timestamp))}</span></span>
1059
+ <span><a href="/api/harness">/api/harness</a> &middot; <a href="/api/health">/api/health</a></span>
1060
+ </footer>
1061
+ </div>
1062
+ </div>
1063
+ <script>${SCRIPT}</script>
1064
+ </body>
1065
+ </html>`;
1066
+ }