infinity-harness 2.8.9 → 2.8.11

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 (192) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/extensions/infinity-harness/index.js +3614 -0
  3. package/dist/extensions/infinity-harness/index.js.map +1 -0
  4. package/dist/src/approval.js +154 -0
  5. package/dist/src/approval.js.map +1 -0
  6. package/dist/src/core/brief.js +309 -0
  7. package/dist/src/core/brief.js.map +1 -0
  8. package/dist/src/core/config.js +539 -0
  9. package/dist/src/core/config.js.map +1 -0
  10. package/dist/src/core/exec.js +106 -0
  11. package/dist/src/core/exec.js.map +1 -0
  12. package/dist/src/core/featureList.js +365 -0
  13. package/dist/src/core/featureList.js.map +1 -0
  14. package/dist/src/core/fsx.js +123 -0
  15. package/dist/src/core/fsx.js.map +1 -0
  16. package/dist/src/core/gates.js +490 -0
  17. package/dist/src/core/gates.js.map +1 -0
  18. package/dist/src/core/init.js +480 -0
  19. package/dist/src/core/init.js.map +1 -0
  20. package/dist/src/core/lock.js +183 -0
  21. package/dist/src/core/lock.js.map +1 -0
  22. package/dist/src/core/modelRouter.js +185 -0
  23. package/dist/src/core/modelRouter.js.map +1 -0
  24. package/dist/src/core/paths.js +129 -0
  25. package/dist/src/core/paths.js.map +1 -0
  26. package/dist/src/core/phases.js +254 -0
  27. package/dist/src/core/phases.js.map +1 -0
  28. package/dist/src/core/plan.js +15 -0
  29. package/dist/src/core/plan.js.map +1 -0
  30. package/dist/src/core/runState.js +126 -0
  31. package/dist/src/core/runState.js.map +1 -0
  32. package/dist/src/core/settings.js +877 -0
  33. package/dist/src/core/settings.js.map +1 -0
  34. package/dist/src/core/skills.js +319 -0
  35. package/dist/src/core/skills.js.map +1 -0
  36. package/dist/src/core/skillsAudit.js +194 -0
  37. package/dist/src/core/skillsAudit.js.map +1 -0
  38. package/dist/src/core/types.js +77 -0
  39. package/dist/src/core/types.js.map +1 -0
  40. package/dist/src/daemon/budget.js +85 -0
  41. package/dist/src/daemon/budget.js.map +1 -0
  42. package/dist/src/daemon/guard.js +122 -0
  43. package/dist/src/daemon/guard.js.map +1 -0
  44. package/dist/src/daemon/index.js +461 -0
  45. package/dist/src/daemon/index.js.map +1 -0
  46. package/dist/src/daemon/isolation.js +84 -0
  47. package/dist/src/daemon/isolation.js.map +1 -0
  48. package/dist/src/daemon/preflight.js +131 -0
  49. package/dist/src/daemon/preflight.js.map +1 -0
  50. package/dist/src/daemon/server.js +174 -0
  51. package/dist/src/daemon/server.js.map +1 -0
  52. package/dist/src/daemon/supervisorState.js +42 -0
  53. package/dist/src/daemon/supervisorState.js.map +1 -0
  54. package/dist/src/daemon/worker.js +239 -0
  55. package/dist/src/daemon/worker.js.map +1 -0
  56. package/dist/src/daemon/worktree.js +102 -0
  57. package/dist/src/daemon/worktree.js.map +1 -0
  58. package/dist/src/escalate.js +312 -0
  59. package/dist/src/escalate.js.map +1 -0
  60. package/dist/src/exec/piWorker.js +630 -0
  61. package/dist/src/exec/piWorker.js.map +1 -0
  62. package/dist/src/goal.js +317 -0
  63. package/dist/src/goal.js.map +1 -0
  64. package/dist/src/goalLoop.js +374 -0
  65. package/dist/src/goalLoop.js.map +1 -0
  66. package/dist/src/goalSpec.js +397 -0
  67. package/dist/src/goalSpec.js.map +1 -0
  68. package/dist/src/goalState.js +255 -0
  69. package/dist/src/goalState.js.map +1 -0
  70. package/dist/src/handoff.js +205 -0
  71. package/dist/src/handoff.js.map +1 -0
  72. package/dist/src/intake.js +203 -0
  73. package/dist/src/intake.js.map +1 -0
  74. package/dist/src/loop.js +592 -0
  75. package/dist/src/loop.js.map +1 -0
  76. package/dist/src/modelRouter.js +216 -0
  77. package/dist/src/modelRouter.js.map +1 -0
  78. package/dist/src/remote.js +246 -0
  79. package/dist/src/remote.js.map +1 -0
  80. package/dist/src/replan.js +262 -0
  81. package/dist/src/replan.js.map +1 -0
  82. package/dist/src/review.js +54 -0
  83. package/dist/src/review.js.map +1 -0
  84. package/dist/src/rework.js +241 -0
  85. package/dist/src/rework.js.map +1 -0
  86. package/dist/src/runState.js +4 -0
  87. package/dist/src/runState.js.map +1 -0
  88. package/dist/src/scheduler.js +270 -0
  89. package/dist/src/scheduler.js.map +1 -0
  90. package/dist/src/supervisor.js +787 -0
  91. package/dist/src/supervisor.js.map +1 -0
  92. package/dist/src/taskList.js +430 -0
  93. package/dist/src/taskList.js.map +1 -0
  94. package/dist/src/ui/config.js +268 -0
  95. package/dist/src/ui/config.js.map +1 -0
  96. package/dist/src/ui/dashboard.js +1288 -0
  97. package/dist/src/ui/dashboard.js.map +1 -0
  98. package/dist/src/ui/display.js +229 -0
  99. package/dist/src/ui/display.js.map +1 -0
  100. package/dist/src/ui/planTree.js +219 -0
  101. package/dist/src/ui/planTree.js.map +1 -0
  102. package/dist/src/ui/theme.js +298 -0
  103. package/dist/src/ui/theme.js.map +1 -0
  104. package/dist/src/ui/viewState.js +65 -0
  105. package/dist/src/ui/viewState.js.map +1 -0
  106. package/dist/src/ui/widget.js +721 -0
  107. package/dist/src/ui/widget.js.map +1 -0
  108. package/dist/src/ui/wizard.js +540 -0
  109. package/dist/src/ui/wizard.js.map +1 -0
  110. package/dist/src/unstuck.js +210 -0
  111. package/dist/src/unstuck.js.map +1 -0
  112. package/dist/src/worker.js +267 -0
  113. package/dist/src/worker.js.map +1 -0
  114. package/dist/src/workflow.js +250 -0
  115. package/dist/src/workflow.js.map +1 -0
  116. package/dist/tests/approval.test.js +144 -0
  117. package/dist/tests/approval.test.js.map +1 -0
  118. package/dist/tests/brief.test.js +271 -0
  119. package/dist/tests/brief.test.js.map +1 -0
  120. package/dist/tests/config.test.js +320 -0
  121. package/dist/tests/config.test.js.map +1 -0
  122. package/dist/tests/dashboard.test.js +277 -0
  123. package/dist/tests/dashboard.test.js.map +1 -0
  124. package/dist/tests/display.test.js +229 -0
  125. package/dist/tests/display.test.js.map +1 -0
  126. package/dist/tests/escalate.test.js +272 -0
  127. package/dist/tests/escalate.test.js.map +1 -0
  128. package/dist/tests/featureList.test.js +429 -0
  129. package/dist/tests/featureList.test.js.map +1 -0
  130. package/dist/tests/gates.test.js +333 -0
  131. package/dist/tests/gates.test.js.map +1 -0
  132. package/dist/tests/goal.test.js +229 -0
  133. package/dist/tests/goal.test.js.map +1 -0
  134. package/dist/tests/goalLoop.test.js +170 -0
  135. package/dist/tests/goalLoop.test.js.map +1 -0
  136. package/dist/tests/goalSpec.test.js +146 -0
  137. package/dist/tests/goalSpec.test.js.map +1 -0
  138. package/dist/tests/goalState.test.js +202 -0
  139. package/dist/tests/goalState.test.js.map +1 -0
  140. package/dist/tests/handoff.test.js +127 -0
  141. package/dist/tests/handoff.test.js.map +1 -0
  142. package/dist/tests/init.test.js +264 -0
  143. package/dist/tests/init.test.js.map +1 -0
  144. package/dist/tests/intake.test.js +288 -0
  145. package/dist/tests/intake.test.js.map +1 -0
  146. package/dist/tests/loop.test.js +453 -0
  147. package/dist/tests/loop.test.js.map +1 -0
  148. package/dist/tests/modelRouter.test.js +131 -0
  149. package/dist/tests/modelRouter.test.js.map +1 -0
  150. package/dist/tests/phases.test.js +244 -0
  151. package/dist/tests/phases.test.js.map +1 -0
  152. package/dist/tests/planTree.test.js +190 -0
  153. package/dist/tests/planTree.test.js.map +1 -0
  154. package/dist/tests/remote.test.js +380 -0
  155. package/dist/tests/remote.test.js.map +1 -0
  156. package/dist/tests/replan.test.js +113 -0
  157. package/dist/tests/replan.test.js.map +1 -0
  158. package/dist/tests/review.test.js +77 -0
  159. package/dist/tests/review.test.js.map +1 -0
  160. package/dist/tests/rework.test.js +109 -0
  161. package/dist/tests/rework.test.js.map +1 -0
  162. package/dist/tests/routing-live.test.js +121 -0
  163. package/dist/tests/routing-live.test.js.map +1 -0
  164. package/dist/tests/runState.test.js +73 -0
  165. package/dist/tests/runState.test.js.map +1 -0
  166. package/dist/tests/settings.test.js +311 -0
  167. package/dist/tests/settings.test.js.map +1 -0
  168. package/dist/tests/skills.test.js +387 -0
  169. package/dist/tests/skills.test.js.map +1 -0
  170. package/dist/tests/supervisor.test.js +437 -0
  171. package/dist/tests/supervisor.test.js.map +1 -0
  172. package/dist/tests/surface.test.js +203 -0
  173. package/dist/tests/surface.test.js.map +1 -0
  174. package/dist/tests/taskList.test.js +376 -0
  175. package/dist/tests/taskList.test.js.map +1 -0
  176. package/dist/tests/theme.test.js +149 -0
  177. package/dist/tests/theme.test.js.map +1 -0
  178. package/dist/tests/unstuck.test.js +268 -0
  179. package/dist/tests/unstuck.test.js.map +1 -0
  180. package/dist/tests/widget.test.js +424 -0
  181. package/dist/tests/widget.test.js.map +1 -0
  182. package/dist/tests/worker.test.js +306 -0
  183. package/dist/tests/worker.test.js.map +1 -0
  184. package/dist/tests/workflow.test.js +187 -0
  185. package/dist/tests/workflow.test.js.map +1 -0
  186. package/extensions/infinity-harness/index.ts +39 -12
  187. package/package.json +5 -2
  188. package/src/core/modelRouter.ts +23 -1
  189. package/src/daemon/index.ts +3 -3
  190. package/src/daemon/worker.ts +28 -5
  191. package/src/ui/config.ts +25 -1
  192. package/src/ui/wizard.ts +10 -5
@@ -0,0 +1,1288 @@
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
+ import { computeProgress, flattenTasks, normalizeStatus, normalizeSubtaskStatus, } from "../core/featureList.js";
20
+ import { getPhaseOrder } from "../core/phases.js";
21
+ import { statusGlyph } from "./widget.js";
22
+ import { UNICODE_GLYPHS } from "./theme.js";
23
+ import { groupPlan } from "./planTree.js";
24
+ import { defaultDisplay, normalizeDisplay } from "./display.js";
25
+ // ── escaping ────────────────────────────────────────────────────────────────
26
+ const HTML_ESCAPES = {
27
+ "&": "&",
28
+ "<": "&lt;",
29
+ ">": "&gt;",
30
+ '"': "&quot;",
31
+ "'": "&#39;",
32
+ };
33
+ /**
34
+ * Escape for both element text and double/single-quoted attribute values.
35
+ *
36
+ * `String(s)` is not redundant: the type says `string`, but the values reaching
37
+ * this function come from `JSON.parse` of files the harness does not control,
38
+ * so a number or `null` can arrive at runtime past the type system.
39
+ */
40
+ export function escapeHtml(s) {
41
+ return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
42
+ }
43
+ /** Escape anything, including values the type system says cannot be here. */
44
+ function esc(v) {
45
+ if (v === null || v === undefined)
46
+ return "";
47
+ return escapeHtml(typeof v === "string" ? v : String(v));
48
+ }
49
+ // ── status normalisation ────────────────────────────────────────────────────
50
+ const STATUS_LABEL = {
51
+ pending: "pending",
52
+ in_progress: "in progress",
53
+ complete: "complete",
54
+ blocked: "blocked",
55
+ rework: "rework",
56
+ };
57
+ /** CSS class suffix per status; keeps the palette mapping in one place. */
58
+ const STATUS_CLASS = {
59
+ pending: "pending",
60
+ in_progress: "active",
61
+ complete: "complete",
62
+ blocked: "blocked",
63
+ rework: "rework",
64
+ };
65
+ /**
66
+ * `normalizeStatus` throws on junk, which is right for a writer and wrong for a
67
+ * renderer — a monitoring page must never 500 because one task has a typo'd
68
+ * status. Unknown values degrade to `pending`.
69
+ */
70
+ function safeStatus(v) {
71
+ try {
72
+ return normalizeStatus(v);
73
+ }
74
+ catch {
75
+ return "pending";
76
+ }
77
+ }
78
+ function safeSubtaskStatus(v) {
79
+ try {
80
+ return normalizeSubtaskStatus(v);
81
+ }
82
+ catch {
83
+ return "pending";
84
+ }
85
+ }
86
+ /**
87
+ * remote.ts reads feature-list.json raw, without the normalisation
88
+ * `loadFeatureList` applies, so legacy aliases ("done", "in-progress") reach us
89
+ * intact. `computeProgress` compares against the canonical spelling only, so a
90
+ * page rendered from raw data would show a task as complete in the task list and
91
+ * still count it as outstanding in the meter. Normalising once, up front, is
92
+ * what keeps every number on the page agreeing with every other one.
93
+ */
94
+ function normalizeForRender(list) {
95
+ const features = Array.isArray(list?.features) ? list.features : [];
96
+ return {
97
+ ...list,
98
+ features: features.map((f) => ({
99
+ ...f,
100
+ tasks: (Array.isArray(f?.tasks) ? f.tasks : []).map((t) => ({
101
+ ...t,
102
+ status: safeStatus(t?.status),
103
+ subtasks: (Array.isArray(t?.subtasks) ? t.subtasks : []).map((s) => ({
104
+ ...s,
105
+ status: safeSubtaskStatus(s?.status),
106
+ })),
107
+ })),
108
+ })),
109
+ };
110
+ }
111
+ function countByStatus(tasks) {
112
+ const counts = { pending: 0, in_progress: 0, complete: 0, blocked: 0, rework: 0 };
113
+ for (const t of tasks)
114
+ counts[t.status] += 1;
115
+ return counts;
116
+ }
117
+ // ── numeric helpers ─────────────────────────────────────────────────────────
118
+ /** Percentages are interpolated into `style="width:…"`, so they must be finite. */
119
+ function pct(part, whole) {
120
+ if (!Number.isFinite(part) || !Number.isFinite(whole) || whole <= 0)
121
+ return 0;
122
+ return Math.max(0, Math.min(100, (part / whole) * 100));
123
+ }
124
+ function widthStyle(value) {
125
+ return value <= 0 ? "" : ` style="width:${value.toFixed(3)}%"`;
126
+ }
127
+ function readString(rec, key) {
128
+ const v = rec[key];
129
+ return typeof v === "string" && v.trim() !== "" ? v : null;
130
+ }
131
+ /**
132
+ * `router` and `rework` are declared `unknown` on purpose — this module has no
133
+ * business knowing their schemas, and they change independently of the UI. But
134
+ * "truthy" alone is the wrong test: remote.ts emits `{ active: false }` and
135
+ * `{ enabled: false }` sentinels rather than null, so a naive check would leave
136
+ * both badges permanently lit. Honour an explicit off-switch, then show whatever
137
+ * one short detail we can recognise, and nothing more.
138
+ */
139
+ function readBadge(value, label) {
140
+ if (!value)
141
+ return null;
142
+ if (typeof value !== "object")
143
+ return { label, note: null };
144
+ const rec = value;
145
+ if (rec.enabled === false || rec.active === false)
146
+ return null;
147
+ const impacted = rec.impactedCount;
148
+ if (typeof impacted === "number" && impacted > 0) {
149
+ return { label, note: `${impacted} impacted` };
150
+ }
151
+ const note = readString(rec, "returnTask") ?? readString(rec, "default");
152
+ return { label, note };
153
+ }
154
+ // ── components ──────────────────────────────────────────────────────────────
155
+ const GLYPHS = UNICODE_GLYPHS;
156
+ /** Stacked meter: outstanding work is visible as colour, not just as absence. */
157
+ function meter(counts, total, extraClass = "") {
158
+ const order = ["complete", "in_progress", "rework", "blocked"];
159
+ const segments = order
160
+ .filter((s) => counts[s] > 0)
161
+ .map((s) => {
162
+ const w = pct(counts[s], total);
163
+ return `<span class="seg seg-${STATUS_CLASS[s]}"${widthStyle(w)}></span>`;
164
+ })
165
+ .join("");
166
+ const done = Math.round(pct(counts.complete, total));
167
+ const cls = extraClass ? ` ${extraClass}` : "";
168
+ return (`<div class="meter${cls}" role="img" aria-label="${esc(String(done))} percent complete">` +
169
+ segments +
170
+ `</div>`);
171
+ }
172
+ function renderMasthead(phase, paused, percent, baseRevision, badges) {
173
+ const phaseTag = paused
174
+ ? `<span class="tag tag-blocked">paused</span>`
175
+ : phase
176
+ ? `<span class="tag tag-accent">${esc(phase)}</span>`
177
+ : `<span class="tag tag-pending">not started</span>`;
178
+ const badgeHtml = badges
179
+ .map((b) => `<span class="chip chip-brand" title="${esc(b.label)}">${esc(b.label)}` +
180
+ (b.note ? `<span class="chip-note">${esc(b.note)}</span>` : "") +
181
+ `</span>`)
182
+ .join("");
183
+ return `<header class="masthead">
184
+ <div class="masthead-id">
185
+ <span class="brand" aria-hidden="true">&#8734;</span>
186
+ <span class="brand-name">infinity&#8209;harness</span>
187
+ ${phaseTag}
188
+ ${badgeHtml}
189
+ </div>
190
+ <div class="masthead-live">
191
+ <span class="pct-inline">${esc(String(percent))}%</span>
192
+ <span class="rev mono">rev ${esc(String(baseRevision))}</span>
193
+ <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>
194
+ </div>
195
+ </header>`;
196
+ }
197
+ function renderGoals(goals) {
198
+ if (goals.length === 0)
199
+ return "";
200
+ const [first, ...rest] = goals;
201
+ if (!first)
202
+ return "";
203
+ const extras = rest.length
204
+ ? `<ul class="goal-more">` +
205
+ rest.map((g) => `<li>${esc(g.title ?? g.id ?? "")}</li>`).join("") +
206
+ `</ul>`
207
+ : "";
208
+ return `<section class="card goal" aria-label="Goal">
209
+ <div class="label">Goal</div>
210
+ <h1 class="goal-title">${esc(first.title ?? first.id ?? "")}</h1>
211
+ ${first.description && first.description !== first.title ? `<p class="goal-desc">${esc(first.description)}</p>` : ""}
212
+ ${extras}
213
+ </section>`;
214
+ }
215
+ /**
216
+ * The pipeline as a rail. A phase that is not part of the enabled pipeline
217
+ * (`init`, or a phase disabled after the run began) is still where the harness
218
+ * actually is, so it is prepended rather than dropped — a rail that cannot show
219
+ * the current phase is worse than a rail with an extra node.
220
+ */
221
+ function renderRail(phase, enabled, paused) {
222
+ const order = getPhaseOrder(enabled);
223
+ const steps = phase && !order.includes(phase) ? [phase, ...order] : order;
224
+ const current = phase ? steps.indexOf(phase) : -1;
225
+ const items = steps
226
+ .map((p, i) => {
227
+ const state = current >= 0 && i < current
228
+ ? "is-done"
229
+ : i === current
230
+ ? paused
231
+ ? "is-current is-paused"
232
+ : "is-current"
233
+ : "is-todo";
234
+ const mark = current >= 0 && i < current
235
+ ? GLYPHS.phaseDone
236
+ : i === current
237
+ ? GLYPHS.phaseCurrent
238
+ : GLYPHS.phaseTodo;
239
+ return `<li class="step ${state}">
240
+ <span class="track"><span class="dot" aria-hidden="true"></span></span>
241
+ <span class="step-name">${esc(p)}</span>
242
+ <span class="sr-only">${esc(mark)}</span>
243
+ </li>`;
244
+ })
245
+ .join("");
246
+ return `<nav class="card rail-wrap" aria-label="Pipeline">
247
+ <ol class="rail">${items}</ol>
248
+ </nav>`;
249
+ }
250
+ function renderProgress(counts, total, featuresDone, featuresTotal) {
251
+ const percent = Math.round(pct(counts.complete, total));
252
+ const legendOrder = ["complete", "in_progress", "rework", "blocked", "pending"];
253
+ const legend = legendOrder
254
+ .map((s) => {
255
+ const zero = counts[s] === 0 ? " is-zero" : "";
256
+ return `<li class="legend-item legend-${STATUS_CLASS[s]}${zero}">
257
+ <span class="legend-dot" aria-hidden="true"></span>
258
+ <span class="legend-n">${esc(String(counts[s]))}</span>
259
+ <span class="legend-label">${esc(STATUS_LABEL[s])}</span>
260
+ </li>`;
261
+ })
262
+ .join("");
263
+ return `<section class="card progress" aria-label="Progress">
264
+ <div class="progress-head">
265
+ <div>
266
+ <div class="label">Overall</div>
267
+ <div class="pct">${esc(String(percent))}<span class="pct-sign">%</span></div>
268
+ </div>
269
+ <div class="progress-counts">
270
+ <div><span class="count">${esc(String(counts.complete))}</span><span class="count-of"> / ${esc(String(total))}</span> tasks</div>
271
+ <div><span class="count">${esc(String(featuresDone))}</span><span class="count-of"> / ${esc(String(featuresTotal))}</span> features</div>
272
+ </div>
273
+ </div>
274
+ ${meter(counts, total, "meter-lg")}
275
+ <ul class="legend">${legend}</ul>
276
+ </section>`;
277
+ }
278
+ /**
279
+ * The strip only exists when it has something to say. An always-present "0
280
+ * blocked" row trains the eye to skip the exact region that matters on the one
281
+ * day it is not zero.
282
+ */
283
+ function renderAlerts(counts, paused, retries, gate, extra = {}) {
284
+ const alerts = [];
285
+ if (paused)
286
+ alerts.push({ tone: "blocked", text: "run paused" });
287
+ // A run parked for a signature looks, from every other indicator, exactly
288
+ // like a run that has stopped. Saying so is the difference between someone
289
+ // coming back to a finished phase and someone coming back to a dead run.
290
+ if (extra.awaitingApproval) {
291
+ alerts.push({
292
+ tone: "active",
293
+ text: `${String(extra.awaitingApproval).toUpperCase()} is waiting for your approval`,
294
+ });
295
+ }
296
+ if (extra.goalPass && extra.goalPass.max > 1) {
297
+ alerts.push({
298
+ tone: extra.goalPass.current >= extra.goalPass.max ? "blocked" : "active",
299
+ text: `goal pass ${extra.goalPass.current} / ${extra.goalPass.max}`,
300
+ });
301
+ }
302
+ if (counts.blocked > 0) {
303
+ alerts.push({ tone: "blocked", text: `${counts.blocked} blocked ${counts.blocked === 1 ? "task" : "tasks"}` });
304
+ }
305
+ if (counts.rework > 0) {
306
+ alerts.push({ tone: "rework", text: `${counts.rework} in rework` });
307
+ }
308
+ if (retries && retries.task > 0) {
309
+ const spent = retries.max > 0 && retries.task >= retries.max;
310
+ alerts.push({
311
+ tone: spent ? "blocked" : "active",
312
+ text: `retry budget ${retries.task} / ${retries.max}${spent ? " — exhausted" : ""}`,
313
+ });
314
+ }
315
+ if (typeof extra.sessions === "number" && extra.sessions > 1) {
316
+ alerts.push({ tone: "quiet", text: `${extra.sessions} sessions this run` });
317
+ }
318
+ if (gate && gate.overall === false) {
319
+ const failures = Array.isArray(gate.failures) ? gate.failures : [];
320
+ const head = failures.slice(0, 3).join(", ");
321
+ const more = failures.length > 3 ? ` +${failures.length - 3} more` : "";
322
+ alerts.push({ tone: "blocked", text: `gate failing${head ? ": " + head : ""}${more}` });
323
+ }
324
+ if (alerts.length === 0)
325
+ return "";
326
+ const items = alerts
327
+ .map((a) => `<li class="alert alert-${esc(a.tone)}"><span class="alert-dot" aria-hidden="true"></span>${esc(a.text)}</li>`)
328
+ .join("");
329
+ return `<ul class="alerts" role="status" aria-label="Alerts">${items}</ul>`;
330
+ }
331
+ function checkState(c) {
332
+ if (c.pass)
333
+ return { key: "complete", label: "pass" };
334
+ if (c.advisory)
335
+ return { key: "active", label: "advisory" };
336
+ return { key: "blocked", label: "fail" };
337
+ }
338
+ function renderGate(gate) {
339
+ if (!gate)
340
+ return "";
341
+ const checks = Array.isArray(gate.checks) ? gate.checks : [];
342
+ const failures = Array.isArray(gate.failures) ? gate.failures : [];
343
+ const passed = gate.overall !== false;
344
+ const scope = [gate.feature, gate.task].filter((v) => typeof v === "string" && v !== "");
345
+ const rows = checks.length
346
+ ? checks
347
+ .map((c) => {
348
+ const st = checkState(c);
349
+ return `<li class="check check-${st.key}">
350
+ <span class="check-state">${esc(st.label)}</span>
351
+ <span class="check-name mono">${esc(c.name ?? "")}</span>
352
+ <span class="check-detail">${esc(c.detail ?? "")}</span>
353
+ </li>`;
354
+ })
355
+ .join("")
356
+ : `<li class="check check-empty">No checks ran for this gate.</li>`;
357
+ const summary = !passed && failures.length
358
+ ? `<p class="gate-summary">Blocking: ${esc(failures.join(", "))}</p>`
359
+ : "";
360
+ return `<section class="card gate ${passed ? "gate-pass" : "gate-fail"}" aria-label="Gate result">
361
+ <div class="gate-head">
362
+ <div>
363
+ <div class="label">Gate</div>
364
+ <div class="gate-phase">${esc(gate.phase ?? "")}${scope.length ? `<span class="gate-scope mono">${esc(scope.join(" · "))}</span>` : ""}</div>
365
+ </div>
366
+ <span class="verdict verdict-${passed ? "pass" : "fail"}">${passed ? "passed" : "failed"}</span>
367
+ </div>
368
+ ${summary}
369
+ <ul class="checks">${rows}</ul>
370
+ </section>`;
371
+ }
372
+ function depLabel(task, indexByKey) {
373
+ const deps = Array.isArray(task.dependsOn) ? task.dependsOn : [];
374
+ if (deps.length === 0)
375
+ return "";
376
+ // Unresolvable dependencies print verbatim — a dangling reference is a real
377
+ // planning bug and hiding it would make the plan look sounder than it is.
378
+ const refs = deps.map((d) => {
379
+ const i = indexByKey.get(d);
380
+ return i === undefined ? esc(d) : `#${esc(String(i))}`;
381
+ });
382
+ return `<span class="deps mono" title="depends on">${esc(GLYPHS.arrow)} ${refs.join(", ")}</span>`;
383
+ }
384
+ /**
385
+ * Every subtask, on every task.
386
+ *
387
+ * The terminal widget shows these only for the task being worked, because it
388
+ * has nine rows and a job to do with them. The dashboard has a whole page and
389
+ * a scrollbar: the reason to open it is precisely to see the detail the widget
390
+ * cannot fit, so hiding four of the five plan levels here made it a worse copy
391
+ * of the widget rather than the place you go for the full picture.
392
+ */
393
+ function renderSubtasks(task, mode, active) {
394
+ // Dashboard always shows subtasks when any exist — it's the full-detail view.
395
+ // The display.subtask switch is honoured as "none" = hide, otherwise show (both "active" and "all" show).
396
+ if (mode === "none")
397
+ return "";
398
+ const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
399
+ if (subs.length === 0)
400
+ return "";
401
+ const items = subs
402
+ .map((s) => {
403
+ const st = safeSubtaskStatus(s?.status);
404
+ const glyph = st === "complete" ? GLYPHS.subDone : st === "in_progress" ? GLYPHS.subActive : GLYPHS.subPending;
405
+ const cls = st === "complete" ? "complete" : st === "in_progress" ? "active" : "pending";
406
+ const isActive = st === "in_progress";
407
+ return `<li class="sub sub-${cls}${isActive ? " is-active" : ""}"><span class="sub-glyph" aria-hidden="true">${esc(glyph)}</span>${esc(s.title ?? "")}</li>`;
408
+ })
409
+ .join("");
410
+ return `<ul class="subs">${items}</ul>`;
411
+ }
412
+ function renderTaskRow(task, indexByKey, display) {
413
+ const status = task.status;
414
+ const cls = STATUS_CLASS[status];
415
+ const isActive = status === "in_progress" || status === "rework";
416
+ const difficulty = typeof task.difficulty === "string" ? `<span class="chip chip-quiet">${esc(task.difficulty)}</span>` : "";
417
+ return `<tr class="row row-${cls}${isActive ? " is-active" : ""}">
418
+ <td class="cell-n">
419
+ <span class="glyph glyph-${cls}" aria-hidden="true">${esc(statusGlyph(status, GLYPHS))}</span>
420
+ <span class="n mono">${esc(String(task.index))}</span>
421
+ </td>
422
+ <td class="cell-task">
423
+ <div class="task-line">
424
+ <span class="task-desc">${esc(task.description || task.compositeKey)}</span>
425
+ ${difficulty}
426
+ ${display.dependencies ? depLabel(task, indexByKey) : ""}
427
+ </div>
428
+ ${renderSubtasks(task, display.levels.subtask, isActive)}
429
+ </td>
430
+ <td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
431
+ </tr>`;
432
+ }
433
+ function renderFeature(feature, tasks, indexByKey, sprintName, goalName, display, isCurrent = false) {
434
+ const counts = countByStatus(tasks);
435
+ const total = tasks.length;
436
+ const complete = total > 0 && counts.complete === total;
437
+ // active anywhere in this branch: any in_progress/rework or unblocked pending.
438
+ const hasActiveTask = tasks.some((t) => t.status === "in_progress" || t.status === "rework");
439
+ const current = (isCurrent || hasActiveTask) && !complete;
440
+ const chips = [
441
+ sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
442
+ goalName ? `<span class="chip chip-quiet">${esc(goalName)}</span>` : "",
443
+ feature.passes === true ? `<span class="chip chip-complete">verified</span>` : "",
444
+ ].join("");
445
+ const body = !display.levels.task
446
+ ? ""
447
+ : total
448
+ ? `<div class="table-wrap">
449
+ <table class="tasks">
450
+ <thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
451
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
452
+ </table>
453
+ </div>`
454
+ : `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
455
+ const criteria = display.criteria && Array.isArray(feature.criteria) && feature.criteria.length
456
+ ? `<ul class="criteria">${feature.criteria
457
+ .map((c) => `<li>${esc(String(c))}</li>`)
458
+ .join("")}</ul>`
459
+ : "";
460
+ return `<section class="card feature${complete ? " is-complete" : ""}${current ? " is-current" : ""}">
461
+ <div class="feature-head">
462
+ <div class="feature-id">
463
+ <h2 class="feature-name">${esc(feature.name ?? feature.id ?? "")}</h2>
464
+ <span class="mono faint">${esc(feature.id ?? "")}</span>
465
+ ${chips}
466
+ </div>
467
+ <div class="feature-progress">
468
+ ${display.counts ? `<span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>` : ""}
469
+ ${meter(counts, total)}
470
+ </div>
471
+ </div>
472
+ ${feature.description ? `<p class="feature-desc">${esc(feature.description)}</p>` : ""}
473
+ ${criteria}
474
+ ${body}
475
+ </section>`;
476
+ }
477
+ /**
478
+ * A goal, its sprints, and their features — one collapsible section per level.
479
+ *
480
+ * `<details open>` is deliberate: everything is visible on load, and a human
481
+ * reading a 60-task plan can fold away the parts they are not looking at
482
+ * without the page needing a line of state management.
483
+ */
484
+ function renderGoalGroup(group, tasksByFeature, indexByKey, show, display, activeFeatureId) {
485
+ // active if any task anywhere in this goal is active.
486
+ const activeGoal = group.sprints.some((sg) => sg.features.some((f) => (tasksByFeature.get(f.id) ?? []).some((t) => t.status === "in_progress" || t.status === "rework")));
487
+ const sprints = group.sprints
488
+ .map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display, activeFeatureId))
489
+ .join("");
490
+ if (!show.showGoal || !group.goal)
491
+ return sprints;
492
+ return `<details class="tier tier-goal${activeGoal ? " is-current" : ""}" open>
493
+ <summary class="tier-head">
494
+ <span class="tier-kind">goal</span>
495
+ <span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
496
+ <span class="mono faint">${esc(group.goal.id ?? "")}</span>
497
+ ${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
498
+ </summary>
499
+ <div class="tier-body">${sprints}</div>
500
+ </details>`;
501
+ }
502
+ function renderSprintGroup(group, tasksByFeature, indexByKey, showSprints, display, activeFeatureId) {
503
+ const activeSprint = group.features.some((f) => (tasksByFeature.get(f.id) ?? []).some((t) => t.status === "in_progress" || t.status === "rework"));
504
+ const features = display.levels.feature
505
+ ? group.features
506
+ .map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display, f.id === activeFeatureId))
507
+ .join("")
508
+ : // Hiding the feature card must not hide its tasks: they move up into the
509
+ // sprint, which is what "hide features" has to mean on a page whose whole
510
+ // job is to show the plan.
511
+ renderLooseTasks(group.features.flatMap((f) => tasksByFeature.get(f.id) ?? []), indexByKey, display);
512
+ if (!showSprints || !group.sprint)
513
+ return features;
514
+ return `<details class="tier tier-sprint${activeSprint ? " is-current" : ""}" open>
515
+ <summary class="tier-head">
516
+ <span class="tier-kind">sprint</span>
517
+ <span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
518
+ <span class="mono faint">${esc(group.sprint.id ?? "")}</span>
519
+ ${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
520
+ </summary>
521
+ <div class="tier-body">${features}</div>
522
+ </details>`;
523
+ }
524
+ /** Tasks with no feature card above them, for the templates that hide features. */
525
+ function renderLooseTasks(tasks, indexByKey, display) {
526
+ if (!display.levels.task || tasks.length === 0)
527
+ return "";
528
+ return `<section class="card feature">
529
+ <div class="table-wrap">
530
+ <table class="tasks">
531
+ <thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
532
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
533
+ </table>
534
+ </div>
535
+ </section>`;
536
+ }
537
+ function renderEmptyPlan(phase) {
538
+ const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
539
+ return `<section class="card empty">
540
+ <div class="empty-mark" aria-hidden="true">${esc(GLYPHS.pending)}</div>
541
+ <h2>No plan yet</h2>
542
+ <p>${where} Features and tasks appear here once DEFINE and PLAN have produced a feature list.</p>
543
+ <p class="faint">This page refreshes itself every few seconds — leave it open.</p>
544
+ </section>`;
545
+ }
546
+ // ── styles ──────────────────────────────────────────────────────────────────
547
+ /**
548
+ * Palette note: `--c-*` are the canonical semantic hues, byte-identical to
549
+ * PALETTE in src/ui/theme.ts, and are used for fills, glyphs and rules. Small
550
+ * text needs a second set: #D98A00 on white is 2.8:1 and #D64545 on the dark
551
+ * ground is 4.1:1, both below AA. `--t-*` keep the same hue and shift luminance
552
+ * per theme so status text stays readable in both.
553
+ */
554
+ const STYLES = `
555
+ *,*::before,*::after{box-sizing:border-box}
556
+
557
+ :root{
558
+ --bg:#F6F7F9;
559
+ --surface:#FFFFFF;
560
+ --surface-2:#F1F3F6;
561
+ --border:#E3E6EB;
562
+ --border-strong:#CDD2DA;
563
+ --text:#13161B;
564
+ --muted:#59606B;
565
+ --faint:#8A909B;
566
+ --shadow:0 1px 2px rgba(17,22,32,.05),0 8px 20px rgba(17,22,32,.04);
567
+ --ring:rgba(0,184,212,.20);
568
+
569
+ --c-complete:#2E9E5B;
570
+ --c-active:#D98A00;
571
+ --c-pending:#8A8F98;
572
+ --c-blocked:#D64545;
573
+ --c-rework:#B36BD4;
574
+ --c-brand:#7C5CFF;
575
+ --c-accent:#00B8D4;
576
+
577
+ --rgb-complete:46,158,91;
578
+ --rgb-active:217,138,0;
579
+ --rgb-pending:138,143,152;
580
+ --rgb-blocked:214,69,69;
581
+ --rgb-rework:179,107,212;
582
+ --rgb-brand:124,92,255;
583
+ --rgb-accent:0,184,212;
584
+
585
+ --t-complete:#1E7A43;
586
+ --t-active:#8F5C11;
587
+ --t-pending:#616772;
588
+ --t-blocked:#B12F2F;
589
+ --t-rework:#8843AB;
590
+ --t-brand:#5A3EE0;
591
+ --t-accent:#0A7386;
592
+ }
593
+
594
+ @media (prefers-color-scheme:dark){
595
+ :root{
596
+ --bg:#0B0D11;
597
+ --surface:#14171C;
598
+ --surface-2:#191D24;
599
+ --border:#242A33;
600
+ --border-strong:#333A45;
601
+ --text:#E7EAEF;
602
+ --muted:#98A0AC;
603
+ --faint:#6C7480;
604
+ --shadow:0 1px 2px rgba(0,0,0,.36),0 10px 28px rgba(0,0,0,.30);
605
+ --ring:rgba(0,184,212,.26);
606
+
607
+ --t-complete:#5DC98A;
608
+ --t-active:#F0AE3C;
609
+ --t-pending:#9AA1AC;
610
+ --t-blocked:#F08281;
611
+ --t-rework:#CF9AE4;
612
+ --t-brand:#A492FF;
613
+ --t-accent:#4FD8ED;
614
+ }
615
+ }
616
+
617
+ html{-webkit-text-size-adjust:100%}
618
+ body{
619
+ margin:0;
620
+ background:var(--bg);
621
+ color:var(--text);
622
+ font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,Cantarell,"Noto Sans",sans-serif;
623
+ font-variant-numeric:tabular-nums;
624
+ -webkit-font-smoothing:antialiased;
625
+ }
626
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:.92em}
627
+ .faint{color:var(--faint)}
628
+ .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}
629
+
630
+ .page{max-width:1120px;margin:0 auto;padding:0 20px 72px}
631
+ .card{
632
+ background:var(--surface);
633
+ border:1px solid var(--border);
634
+ border-radius:12px;
635
+ padding:16px 18px;
636
+ margin:14px 0;
637
+ box-shadow:var(--shadow);
638
+ }
639
+ .label{
640
+ font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;
641
+ color:var(--faint);font-weight:600;margin-bottom:6px;
642
+ }
643
+
644
+
645
+ /* -- background sessions --------------------------------------------------- */
646
+ /*
647
+ * The run's work happens in other pi processes now, so this panel is the only
648
+ * place it is visible. It is deliberately the loudest thing under the meter:
649
+ * a live dot, the model each session is on, and a reverse-chronological log.
650
+ */
651
+ .card.background h2{margin:0 0 4px;font-size:14px;letter-spacing:.01em}
652
+ .bg-note{margin:0 0 12px;color:var(--muted);font-size:13px}
653
+ .bg-workers{list-style:none;margin:0;padding:0;display:grid;gap:8px}
654
+ .bg-worker{
655
+ display:grid;grid-template-columns:auto auto 1fr;gap:4px 10px;align-items:baseline;
656
+ padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);
657
+ }
658
+ .bg-dot{
659
+ width:8px;height:8px;border-radius:50%;background:var(--muted);
660
+ align-self:center;grid-row:1;
661
+ }
662
+ .bg-worker.is-working .bg-dot,.bg-worker.is-starting .bg-dot{
663
+ background:var(--c-accent);box-shadow:0 0 0 3px var(--ring);animation:bgpulse 1.6s ease-in-out infinite;
664
+ }
665
+ .bg-worker.is-failed .bg-dot{background:var(--t-blocked,#C0392B)}
666
+ @keyframes bgpulse{0%,100%{opacity:1}50%{opacity:.35}}
667
+ @media (prefers-reduced-motion:reduce){.bg-worker .bg-dot{animation:none}}
668
+ .bg-name{font-weight:650}
669
+ .bg-model{
670
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;
671
+ color:var(--c-accent);overflow-wrap:anywhere;
672
+ }
673
+ .bg-meta{grid-column:2/-1;color:var(--muted);font-size:12.5px}
674
+ .bg-doing{grid-column:2/-1;font-size:13px;overflow-wrap:anywhere}
675
+ .bg-log{
676
+ list-style:none;margin:12px 0 0;padding:10px 0 0;border-top:1px solid var(--border);
677
+ max-height:260px;overflow:auto;display:grid;gap:2px;
678
+ }
679
+ .bg-line{display:grid;grid-template-columns:44px 34px 1fr;gap:8px;font-size:12.5px;align-items:baseline}
680
+ .bg-time{color:var(--faint);font-variant-numeric:tabular-nums}
681
+ .bg-who{color:var(--c-accent);font-weight:600}
682
+ .bg-text{color:var(--muted);overflow-wrap:anywhere}
683
+ .bg-line.lvl-work .bg-text{color:var(--text)}
684
+ .bg-line.lvl-good .bg-text{color:var(--t-success,#177245)}
685
+ .bg-line.lvl-warn .bg-text{color:var(--t-rework,#8E44AD)}
686
+ .bg-line.lvl-error .bg-text{color:var(--t-blocked,#C0392B)}
687
+
688
+ /* -- masthead ------------------------------------------------------------- */
689
+ .masthead{
690
+ position:sticky;top:0;z-index:20;
691
+ display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center;justify-content:space-between;
692
+ padding:14px 0 12px;margin-bottom:2px;
693
+ background:var(--bg);
694
+ border-bottom:1px solid var(--border);
695
+ }
696
+ .masthead-id,.masthead-live{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}
697
+ .brand{color:var(--c-brand);font-size:19px;line-height:1}
698
+ .brand-name{font-weight:650;letter-spacing:-.01em}
699
+ .pct-inline{font-weight:650;font-variant-numeric:tabular-nums}
700
+ .rev{color:var(--faint)}
701
+
702
+ .tag{
703
+ font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;
704
+ padding:3px 8px;border-radius:999px;border:1px solid transparent;
705
+ }
706
+ .tag-accent{color:var(--t-accent);background:rgba(var(--rgb-accent),.12);border-color:rgba(var(--rgb-accent),.32)}
707
+ .tag-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.12);border-color:rgba(var(--rgb-blocked),.34)}
708
+ .tag-pending{color:var(--t-pending);background:rgba(var(--rgb-pending),.12);border-color:rgba(var(--rgb-pending),.30)}
709
+
710
+ /* Chips carry model-supplied names (sprints, router defaults). inline-block
711
+ rather than inline-flex so text-overflow can actually clip an absurd one
712
+ instead of pushing the masthead past the viewport. */
713
+ .chip{
714
+ display:inline-block;
715
+ font-size:11px;padding:2px 7px;border-radius:6px;
716
+ border:1px solid var(--border);background:var(--surface-2);color:var(--muted);
717
+ white-space:nowrap;max-width:min(100%,26ch);overflow:hidden;text-overflow:ellipsis;
718
+ vertical-align:baseline;
719
+ }
720
+ .chip-brand{color:var(--t-brand);background:rgba(var(--rgb-brand),.10);border-color:rgba(var(--rgb-brand),.28)}
721
+ .chip-complete{color:var(--t-complete);background:rgba(var(--rgb-complete),.12);border-color:rgba(var(--rgb-complete),.30)}
722
+ .chip-quiet{font-size:10.5px}
723
+ .chip-note{color:var(--faint);font-size:10.5px;margin-left:5px}
724
+
725
+ .conn{display:inline-flex;align-items:center;gap:6px;color:var(--faint);font-size:12px}
726
+ .conn-dot{width:7px;height:7px;border-radius:50%;background:var(--c-pending);flex:none}
727
+ .conn.is-live .conn-dot{background:var(--c-complete)}
728
+ .conn.is-live .conn-label{color:var(--t-complete)}
729
+ .conn.is-stale .conn-dot{background:var(--c-active)}
730
+ .conn.is-stale .conn-label{color:var(--t-active)}
731
+
732
+ /* -- goal ----------------------------------------------------------------- */
733
+ .goal-title{font-size:17px;font-weight:600;margin:0;line-height:1.35;overflow-wrap:anywhere}
734
+ .goal-desc{margin:8px 0 0;color:var(--muted);overflow-wrap:anywhere}
735
+ .goal-more{margin:10px 0 0;padding-left:18px;color:var(--muted);font-size:13px;overflow-wrap:anywhere}
736
+
737
+ /* -- phase rail ----------------------------------------------------------- */
738
+ .rail-wrap{overflow-x:auto;padding:18px 18px 14px}
739
+ .rail{display:flex;list-style:none;margin:0;padding:0;min-width:520px}
740
+ .step{flex:1 1 0;min-width:76px;text-align:center;position:relative}
741
+ .track{display:block;position:relative;height:14px}
742
+ .track::before,.track::after{content:"";position:absolute;top:50%;height:2px;background:var(--border-strong);transform:translateY(-50%)}
743
+ .track::before{left:0;right:50%}
744
+ .track::after{left:50%;right:0}
745
+ .step:first-child .track::before,.step:last-child .track::after{display:none}
746
+ .dot{
747
+ position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
748
+ width:11px;height:11px;border-radius:50%;
749
+ background:var(--surface);border:2px solid var(--border-strong);z-index:1;
750
+ }
751
+ .step-name{
752
+ display:block;margin-top:9px;font-size:12px;color:var(--faint);
753
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px;
754
+ }
755
+ .step.is-done .dot{background:var(--c-complete);border-color:var(--c-complete)}
756
+ .step.is-done .track::before,.step.is-done .track::after,.step.is-current .track::before{background:var(--c-complete)}
757
+ .step.is-done .step-name{color:var(--t-complete)}
758
+ .step.is-current .dot{
759
+ background:var(--c-accent);border-color:var(--c-accent);
760
+ width:13px;height:13px;box-shadow:0 0 0 4px var(--ring);
761
+ animation:pulse 2.6s ease-in-out infinite;
762
+ }
763
+ .step.is-current .step-name{color:var(--t-accent);font-weight:650;letter-spacing:.02em}
764
+ .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}
765
+ .step.is-current.is-paused .step-name{color:var(--t-blocked)}
766
+ @keyframes pulse{0%,100%{box-shadow:0 0 0 3px var(--ring)}50%{box-shadow:0 0 0 7px rgba(var(--rgb-accent),.06)}}
767
+
768
+ /* -- progress ------------------------------------------------------------- */
769
+ .progress-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
770
+ .pct{font-size:38px;line-height:1;font-weight:640;letter-spacing:-.02em}
771
+ .pct-sign{font-size:19px;font-weight:500;color:var(--faint);margin-left:2px}
772
+ .progress-counts{text-align:right;color:var(--muted);font-size:13px;line-height:1.7}
773
+ .count{font-weight:650;color:var(--text)}
774
+ .count-of{color:var(--faint)}
775
+
776
+ .meter{
777
+ display:flex;height:6px;border-radius:999px;overflow:hidden;
778
+ background:var(--surface-2);border:1px solid var(--border);
779
+ }
780
+ .meter-lg{height:10px;margin:14px 0 12px}
781
+ .seg{display:block;height:100%}
782
+ .seg-complete{background:var(--c-complete)}
783
+ .seg-active{background:var(--c-active)}
784
+ .seg-rework{background:var(--c-rework)}
785
+ .seg-blocked{background:var(--c-blocked)}
786
+
787
+ .legend{display:flex;flex-wrap:wrap;gap:6px 18px;list-style:none;margin:0;padding:0;font-size:12px}
788
+ .legend-item{display:flex;align-items:center;gap:6px;color:var(--muted);opacity:1}
789
+ .legend-item.is-zero{opacity:1}
790
+ .legend-dot{width:8px;height:8px;border-radius:2px;flex:none}
791
+ .legend-n{font-weight:650;color:var(--text);font-variant-numeric:tabular-nums}
792
+ .legend-complete .legend-dot{background:var(--c-complete)}
793
+ .legend-active .legend-dot{background:var(--c-active)}
794
+ .legend-rework .legend-dot{background:var(--c-rework)}
795
+ .legend-blocked .legend-dot{background:var(--c-blocked)}
796
+ .legend-pending .legend-dot{background:var(--c-pending)}
797
+
798
+ /* -- alerts --------------------------------------------------------------- */
799
+ .alerts{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:14px 0;padding:0}
800
+ .alert{
801
+ display:inline-flex;align-items:center;gap:7px;
802
+ font-size:12.5px;font-weight:550;padding:5px 11px;border-radius:8px;border:1px solid transparent;
803
+ max-width:100%;overflow-wrap:anywhere;
804
+ }
805
+ .alert-dot{width:7px;height:7px;border-radius:50%;flex:none;background:currentColor}
806
+ .alert-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.10);border-color:rgba(var(--rgb-blocked),.30)}
807
+ .alert-rework{color:var(--t-rework);background:rgba(var(--rgb-rework),.10);border-color:rgba(var(--rgb-rework),.28)}
808
+ .alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
809
+ .alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
810
+
811
+ /* -- acceptance criteria -------------------------------------------------- */
812
+ .criteria{margin:10px 0 0;padding:0 0 0 18px;color:var(--muted);font-size:13px}
813
+ .criteria li{margin:3px 0;overflow-wrap:anywhere}
814
+ .criteria li::marker{color:var(--t-accent)}
815
+
816
+ /* -- gate ----------------------------------------------------------------- */
817
+ .gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
818
+ .gate-phase{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;overflow-wrap:anywhere}
819
+ .gate-scope{margin-left:10px;color:var(--faint);font-weight:400;text-transform:none;letter-spacing:0}
820
+ .gate-fail{border-color:rgba(var(--rgb-blocked),.45);box-shadow:var(--shadow),inset 3px 0 0 var(--c-blocked)}
821
+ .verdict{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 10px;border-radius:999px}
822
+ .verdict-pass{color:var(--t-complete);background:rgba(var(--rgb-complete),.12)}
823
+ .verdict-fail{color:#fff;background:var(--c-blocked)}
824
+ .gate-summary{margin:10px 0 0;color:var(--t-blocked);font-weight:550;overflow-wrap:anywhere}
825
+ .checks{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:1px}
826
+ .check{
827
+ display:grid;grid-template-columns:74px minmax(110px,190px) 1fr;gap:12px;align-items:baseline;
828
+ padding:8px 10px;border-radius:7px;background:var(--surface-2);
829
+ }
830
+ .check-state{font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
831
+ .check-name{overflow-wrap:anywhere}
832
+ .check-detail{color:var(--muted);overflow-wrap:anywhere}
833
+ .check-complete .check-state{color:var(--t-complete)}
834
+ .check-active .check-state{color:var(--t-active)}
835
+ .check-blocked{background:rgba(var(--rgb-blocked),.10);box-shadow:inset 2px 0 0 var(--c-blocked)}
836
+ .check-blocked .check-state{color:var(--t-blocked)}
837
+ .check-blocked .check-name{font-weight:650}
838
+ .check-empty{display:block;color:var(--muted)}
839
+
840
+ /* -- features ------------------------------------------------------------- */
841
+ .feature-head{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap}
842
+ .feature-id{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;min-width:0;overflow-wrap:anywhere}
843
+ .feature-name{font-size:15px;font-weight:640;margin:0;overflow-wrap:anywhere}
844
+ .feature-progress{display:flex;align-items:center;gap:10px;flex:0 1 220px;min-width:150px}
845
+ .feature-count{color:var(--muted);white-space:nowrap}
846
+ .feature-progress .meter{flex:1}
847
+ .feature.is-complete .feature-name{color:var(--muted)}
848
+ .feature-desc{margin:8px 0 0;color:var(--muted);font-size:13px;overflow-wrap:anywhere}
849
+ .empty-inline{color:var(--faint);font-size:13px;margin:12px 0 2px}
850
+
851
+ .table-wrap{overflow-x:auto;margin:12px -18px -16px;padding:0 18px 4px}
852
+ table.tasks{width:100%;border-collapse:collapse;min-width:480px}
853
+ table.tasks th{
854
+ font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--faint);
855
+ font-weight:600;text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);
856
+ }
857
+ table.tasks td{padding:8px;border-bottom:1px solid var(--border);vertical-align:top}
858
+ table.tasks tr:last-child td{border-bottom:0}
859
+ .cell-n{width:1%;white-space:nowrap;padding-left:10px}
860
+ .cell-status{width:1%;white-space:nowrap;text-align:right}
861
+ .glyph{margin-right:6px}
862
+ .glyph-complete{color:var(--t-complete)}
863
+ .glyph-active{color:var(--t-active)}
864
+ .glyph-pending{color:var(--t-pending)}
865
+ .glyph-blocked{color:var(--t-blocked)}
866
+ .glyph-rework{color:var(--t-rework)}
867
+ .n{color:var(--faint)}
868
+ .task-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}
869
+ .task-desc{overflow-wrap:anywhere}
870
+ .row-complete .task-desc{color:var(--muted)}
871
+ .row.is-active{background:rgba(var(--rgb-active),.07);animation:taskBlink 1.2s ease-in-out infinite}
872
+ .row.is-active .task-desc{font-weight:600}
873
+ .row.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-active)}
874
+ .row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
875
+ .row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
876
+ @keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
877
+ /* While-developed: the whole active branch pulses — every active box, not just one feature. */
878
+ .dash-url{margin:8px 0 0;font-size:13px} .dash-url a{color:var(--t-accent);text-decoration:none;border-bottom:1px dashed rgba(var(--rgb-accent),.45)} .dash-url a:hover{border-bottom-style:solid}
879
+ .handoff-note{margin:4px 0 0;color:var(--muted);font-size:12px}
880
+ /* Active branching: make it scream, not whisper. Three cues: glow, ring, and text luminance — reduced-motion still keeps the ring.
881
+ Pulse is strong at the crest, not near-reduced-motion. */
882
+ .tier.is-current,.feature.is-current{animation:cardPulse 1s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 4px rgba(var(--rgb-accent),.60), 0 0 16px rgba(var(--rgb-accent),.45), var(--shadow)}
883
+ .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent);font-weight:800; animation:textPulse 1s ease-in-out infinite}
884
+ .row.is-active{animation:taskBlink 0.85s ease-in-out infinite; outline:2.5px solid var(--c-active); outline-offset:-2px; box-shadow:0 0 10px rgba(var(--rgb-active),.45)}
885
+ .step.is-current .dot{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.50), 0 0 14px rgba(var(--rgb-accent),.40); animation:phasePulse 1s ease-in-out infinite}
886
+ @keyframes cardPulse{0%,100%{box-shadow:0 0 0 4px rgba(var(--rgb-accent),.60),0 0 16px rgba(var(--rgb-accent),.45),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 8px rgba(var(--rgb-accent),.15),0 0 20px rgba(var(--rgb-accent),.55),var(--shadow); border-color:rgba(var(--rgb-accent),.40)}}
887
+ @keyframes phasePulse{0%,100%{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.50),0 0 14px rgba(var(--rgb-accent),.40)}50%{box-shadow:0 0 0 9px rgba(var(--rgb-accent),.10),0 0 18px rgba(var(--rgb-accent),.55)}}
888
+ @keyframes textPulse{0%,100%{opacity:1}50%{opacity:.78}}
889
+ .row-blocked{background:rgba(var(--rgb-blocked),.07)}
890
+ .row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
891
+ .deps{color:var(--faint);white-space:nowrap}
892
+ .pill{
893
+ font-size:10.5px;font-weight:600;letter-spacing:.04em;padding:2px 8px;border-radius:999px;
894
+ border:1px solid transparent;white-space:nowrap;
895
+ }
896
+ .pill-complete{color:var(--t-complete);background:rgba(var(--rgb-complete),.11);border-color:rgba(var(--rgb-complete),.26)}
897
+ .pill-active{color:var(--t-active);background:rgba(var(--rgb-active),.12);border-color:rgba(var(--rgb-active),.28)}
898
+ .pill-pending{color:var(--t-pending);background:rgba(var(--rgb-pending),.10);border-color:rgba(var(--rgb-pending),.24)}
899
+ .pill-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.12);border-color:rgba(var(--rgb-blocked),.30)}
900
+ .pill-rework{color:var(--t-rework);background:rgba(var(--rgb-rework),.12);border-color:rgba(var(--rgb-rework),.28)}
901
+
902
+ .subs{list-style:none;margin:7px 0 2px;padding:0 0 0 2px;display:flex;flex-direction:column;gap:3px}
903
+ .sub{font-size:12.5px;color:var(--muted);display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere}
904
+ .sub-glyph{flex:none;width:12px;text-align:center;color:var(--t-pending)}
905
+ .sub-complete .sub-glyph{color:var(--t-complete)}
906
+ .sub-active .sub-glyph{color:var(--t-active)}
907
+ .sub-active{color:var(--text)}
908
+
909
+ /* -- empty / footer ------------------------------------------------------- */
910
+ .empty{text-align:center;padding:44px 24px}
911
+ .empty-mark{font-size:26px;color:var(--faint);margin-bottom:10px}
912
+ .empty h2{margin:0 0 8px;font-size:16px;font-weight:600}
913
+ .empty p{margin:0 auto;max-width:46ch;color:var(--muted)}
914
+ .empty p.faint{margin-top:10px;font-size:12.5px}
915
+ .section-label{
916
+ margin:26px 2px 2px;font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;
917
+ color:var(--faint);font-weight:600;
918
+ }
919
+
920
+ /* -- plan tiers: goal > sprint > feature ---------------------------------- */
921
+ /* Depth is carried by a left rule rather than indentation, so a 60-task plan
922
+ does not walk off the right edge of a phone. */
923
+ .tier{margin:18px 0 0}
924
+ .tier-head{
925
+ display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;cursor:pointer;
926
+ padding:7px 2px;list-style:none;border-radius:8px;
927
+ }
928
+ .tier-head::-webkit-details-marker{display:none}
929
+ .tier-head:hover{background:var(--surface-2)}
930
+ .tier-head:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}
931
+ .tier-kind{
932
+ font-size:10px;text-transform:uppercase;letter-spacing:.1em;font-weight:700;
933
+ padding:2px 7px;border-radius:999px;border:1px solid var(--border);color:var(--faint);
934
+ }
935
+ .tier-goal>.tier-head .tier-kind{color:var(--t-brand);border-color:var(--c-brand)}
936
+ .tier-sprint>.tier-head .tier-kind{color:var(--t-accent);border-color:var(--c-accent)}
937
+ .tier-name{font-size:15px;font-weight:650;overflow-wrap:anywhere}
938
+ .tier-goal>.tier-head .tier-name{font-size:17px}
939
+ .tier-count{margin-left:auto;color:var(--muted);font-size:13px;flex:none}
940
+ .tier-body{
941
+ margin-left:5px;padding-left:14px;border-left:2px solid var(--border);
942
+ }
943
+ .tier-goal>.tier-body{border-left-color:var(--c-brand)}
944
+ .tier-sprint>.tier-body{border-left-color:var(--c-accent)}
945
+ .tier[open]>.tier-head .tier-kind{opacity:.85}
946
+ .tier:not([open])>.tier-head{opacity:.72}
947
+ .foot{
948
+ display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:space-between;
949
+ margin-top:22px;padding-top:14px;border-top:1px solid var(--border);
950
+ color:var(--faint);font-size:12px;
951
+ }
952
+ .foot a{color:var(--t-accent);text-decoration:none}
953
+ .foot a:hover{text-decoration:underline}
954
+
955
+ @media (max-width:640px){
956
+ .page{padding:0 14px 56px}
957
+ .card{padding:14px;border-radius:10px}
958
+ .pct{font-size:30px}
959
+ .progress-counts{text-align:left}
960
+ .feature-progress{flex:1 1 100%}
961
+ .table-wrap{margin:12px -14px -14px;padding:0 14px 4px}
962
+ .check{grid-template-columns:64px 1fr;row-gap:2px}
963
+ .check-detail{grid-column:1/-1;font-size:12.5px}
964
+ }
965
+
966
+ @media (prefers-reduced-motion:reduce){
967
+ *{animation:none!important;transition:none!important}
968
+ }
969
+ `;
970
+ // ── refresh script ──────────────────────────────────────────────────────────
971
+ /**
972
+ * Poll-and-swap rather than a websocket: the server is a plain read-only GET
973
+ * endpoint and the page must survive it restarting. Only the contents of #app
974
+ * are replaced, so this script (which lives outside it) keeps running across
975
+ * refreshes and never re-registers its timers.
976
+ *
977
+ * Written without template literals — this string is itself embedded in one.
978
+ */
979
+ const SCRIPT = `
980
+ (function () {
981
+ var BASE = 5000, MAX = 60000, TIMEOUT = 4000;
982
+ var delay = BASE, lastOk = Date.now(), timer = null;
983
+
984
+ function mark(state) {
985
+ var el = document.getElementById("conn");
986
+ if (!el) return;
987
+ el.className = "conn " + (state === "live" ? "is-live" : "is-stale");
988
+ var label = el.querySelector(".conn-label");
989
+ if (label) label.textContent = state === "live" ? "live" : "reconnecting";
990
+ }
991
+
992
+ function ago() {
993
+ var el = document.getElementById("age");
994
+ if (!el) return;
995
+ var s = Math.max(0, Math.round((Date.now() - lastOk) / 1000));
996
+ el.textContent = s < 3 ? "just now" : s < 90 ? s + "s ago" : Math.round(s / 60) + "m ago";
997
+ }
998
+
999
+ function schedule(ms) {
1000
+ if (timer) clearTimeout(timer);
1001
+ timer = setTimeout(refresh, ms);
1002
+ }
1003
+
1004
+ function opts() {
1005
+ var o = { cache: "no-store", credentials: "same-origin", headers: { "Accept": "text/html" } };
1006
+ // Without a deadline a half-open socket stalls the whole loop.
1007
+ try { if (window.AbortSignal && AbortSignal.timeout) o.signal = AbortSignal.timeout(TIMEOUT); } catch (e) {}
1008
+ return o;
1009
+ }
1010
+
1011
+ // Tabs: remember selection in localStorage; Goal hidden when only one goal; filter by phase/goal.
1012
+ function tabBind() {
1013
+ try {
1014
+ var GK='ih-goal', PK='ih-phase';
1015
+ function read(k){ try{ return localStorage.getItem(k); }catch(e){ return null; } }
1016
+ function write(k,v){ try{ localStorage.setItem(k,v); }catch(e){} }
1017
+ var selGoal = read(GK);
1018
+ var selPhase = read(PK);
1019
+ var goalEls = document.querySelectorAll('[data-goal]');
1020
+ var phaseEls = document.querySelectorAll('[data-phase]');
1021
+ // Seed from is-current when no stored choice.
1022
+ if(!selGoal){ var cur=document.querySelector('[data-goal].is-current'); if(cur) selGoal=cur.getAttribute('data-goal'); }
1023
+ if(!selPhase){ var curP=document.querySelector('[data-phase].is-current'); if(curP) selPhase=curP.getAttribute('data-phase'); }
1024
+ function apply(){
1025
+ var gTabs=document.querySelectorAll('.dash-tabs [data-goal]');
1026
+ var pTabs=document.querySelectorAll('.dash-tabs [data-phase]');
1027
+ gTabs.forEach(function(el){ el.classList.toggle('is-current', el.getAttribute('data-goal')===selGoal); });
1028
+ pTabs.forEach(function(el){ el.classList.toggle('is-current', el.getAttribute('data-phase')===selPhase); });
1029
+ // If user picked a goal that doesn't exist anymore, fall back to first.
1030
+ var tiers=document.querySelectorAll('.tier.tier-goal');
1031
+ if(tiers.length===0) tiers=document.querySelectorAll('.tier:not(.tier-goal)');
1032
+ // Hide tiers that don't match selected goal (when goal tabs visible, else show all).
1033
+ if(goalEls.length>0 && selGoal){
1034
+ document.querySelectorAll('.tier.tier-goal').forEach(function(t){ var id=(t.querySelector('[data-goal]')||t).getAttribute('data-goal'); var match = !id || id===selGoal; t.classList.toggle('tier-hidden', !match); });
1035
+ }
1036
+ // Phase filter: hide features/tasks not in selected phase. Server could filter but client keeps auto-refresh.
1037
+ if(selPhase){
1038
+ document.querySelectorAll('.feature').forEach(function(f){ var ph=f.getAttribute('data-phase')||''; if(ph && ph!==selPhase) f.classList.add('tier-hidden'); else f.classList.remove('tier-hidden'); });
1039
+ }
1040
+ // If only one goal, goal strip already hidden server-side — no tier-hidden by goal.
1041
+ }
1042
+ goalEls.forEach(function(el){ el.addEventListener('click', function(){ selGoal=el.getAttribute('data-goal'); write(GK, selGoal); apply(); }); });
1043
+ phaseEls.forEach(function(el){ el.addEventListener('click', function(){ selPhase=el.getAttribute('data-phase'); write(PK, selPhase); apply(); }); });
1044
+ apply();
1045
+ } catch(e){}
1046
+ }
1047
+ tabBind();
1048
+ function refresh() {
1049
+ // A hidden tab is not being read; skip the work but keep the loop alive.
1050
+ if (document.hidden) { schedule(BASE); return; }
1051
+ fetch(location.pathname + location.search, opts())
1052
+ .then(function (res) {
1053
+ if (!res.ok) throw new Error("HTTP " + res.status);
1054
+ return res.text();
1055
+ })
1056
+ .then(function (html) {
1057
+ var doc = new DOMParser().parseFromString(html, "text/html");
1058
+ var next = doc.getElementById("app");
1059
+ var cur = document.getElementById("app");
1060
+ if (next && cur) {
1061
+ var x = window.scrollX, y = window.scrollY;
1062
+ // innerHTML from a parsed document never executes scripts, and the
1063
+ // server already escaped every value it interpolated.
1064
+ cur.innerHTML = next.innerHTML;
1065
+ window.scrollTo(x, y);
1066
+ }
1067
+ if (doc.title) document.title = doc.title;
1068
+ lastOk = Date.now();
1069
+ delay = BASE;
1070
+ mark("live");
1071
+ ago();
1072
+ })
1073
+ .catch(function () {
1074
+ // Back off instead of hammering a server that is restarting mid-run.
1075
+ delay = Math.min(MAX, Math.round(delay * 1.7));
1076
+ mark("stale");
1077
+ })
1078
+ .then(function () { schedule(delay); });
1079
+ }
1080
+
1081
+ setInterval(ago, 1000);
1082
+ document.addEventListener("visibilitychange", function () {
1083
+ if (!document.hidden) { delay = BASE; schedule(0); }
1084
+ });
1085
+ schedule(BASE);
1086
+ })();
1087
+ `;
1088
+ // ── document ────────────────────────────────────────────────────────────────
1089
+ function formatTimestamp(iso) {
1090
+ const d = new Date(iso);
1091
+ if (Number.isNaN(d.getTime()))
1092
+ return iso;
1093
+ return d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
1094
+ }
1095
+ /**
1096
+ * The background sessions, and the log of what they have done.
1097
+ *
1098
+ * When the work moved out of the human's terminal this became the only place
1099
+ * a run is visible at all — so it says, per session, which unit it owns, what
1100
+ * model is answering for it, and the last thing it did. The model is on the
1101
+ * card because "routed to the difficult tier" is a claim, and a claim you
1102
+ * cannot see is one nobody believes.
1103
+ */
1104
+ export function renderBackground(engine, workers, activity) {
1105
+ if (!engine && workers.length === 0 && activity.length === 0)
1106
+ return "";
1107
+ const head = engine === "main-session"
1108
+ ? `<p class="bg-note">Work runs in the pi session you started the harness from.</p>`
1109
+ : `<p class="bg-note">Work runs in separate background pi sessions, each on the model its difficulty tier names.</p>`;
1110
+ const cards = workers.length
1111
+ ? workers
1112
+ .map((w) => {
1113
+ const tokens = w.tokens ? w.tokens.inputTokens + w.tokens.outputTokens : 0;
1114
+ const ctx = typeof w.contextRatio === "number" ? `${Math.round(w.contextRatio * 100)}% ctx` : "";
1115
+ const bits = [
1116
+ `${w.level} · ${w.unitLabel}`,
1117
+ w.difficulty ? `${w.difficulty} tier` : "",
1118
+ `${w.turns ?? 0} turn${(w.turns ?? 0) === 1 ? "" : "s"}`,
1119
+ tokens ? `${tokens.toLocaleString("en-US")} tokens` : "",
1120
+ ctx,
1121
+ ].filter(Boolean);
1122
+ return (`<li class="bg-worker is-${esc(w.state)}">` +
1123
+ `<span class="bg-dot" aria-hidden="true"></span>` +
1124
+ `<span class="bg-name">${esc(w.name)}</span>` +
1125
+ `<span class="bg-model">${esc(w.servedModel || w.model || "pi default")}</span>` +
1126
+ `<span class="bg-meta">${esc(bits.join(" · "))}</span>` +
1127
+ (w.doing ? `<span class="bg-doing">${esc(w.doing)}</span>` : "") +
1128
+ `</li>`);
1129
+ })
1130
+ .join("")
1131
+ : `<li class="bg-worker is-idle"><span class="bg-dot" aria-hidden="true"></span><span class="bg-name">idle</span><span class="bg-meta">no background session running</span></li>`;
1132
+ const log = activity.length
1133
+ ? `<ol class="bg-log">` +
1134
+ activity
1135
+ .slice(-40)
1136
+ .reverse()
1137
+ .map((l) => `<li class="bg-line lvl-${esc(l.level)}"><span class="bg-time">${esc(l.at.slice(11, 16))}</span>` +
1138
+ (l.worker ? `<span class="bg-who">${esc(l.worker)}</span>` : `<span class="bg-who"></span>`) +
1139
+ `<span class="bg-text">${esc(l.text)}</span></li>`)
1140
+ .join("") +
1141
+ `</ol>`
1142
+ : "";
1143
+ return `<section class="card background"><h2>Background</h2>${head}<ul class="bg-workers">${cards}</ul>${log}</section>`;
1144
+ }
1145
+ export function renderDashboard(state) {
1146
+ const list = normalizeForRender(state.list ?? { version: "0", baseRevision: 0, features: [] });
1147
+ const features = list.features ?? [];
1148
+ const goals = Array.isArray(list.goals) ? list.goals : [];
1149
+ const sprints = Array.isArray(list.sprints) ? list.sprints : [];
1150
+ const paused = state.paused === true;
1151
+ const gate = state.gate ?? null;
1152
+ const progress = computeProgress(list);
1153
+ const tasks = flattenTasks(list);
1154
+ const counts = countByStatus(tasks);
1155
+ // Dependency labels reference the flattened plan position (`← #3`), matching
1156
+ // the terminal widget, so the two views can be read side by side.
1157
+ const indexByKey = new Map();
1158
+ for (const t of tasks) {
1159
+ indexByKey.set(t.compositeKey, t.index);
1160
+ indexByKey.set(t.id, t.index);
1161
+ if (t.key)
1162
+ indexByKey.set(t.key, t.index);
1163
+ }
1164
+ const tasksByFeature = new Map();
1165
+ for (const t of tasks) {
1166
+ const bucket = tasksByFeature.get(t.featureId);
1167
+ if (bucket)
1168
+ bucket.push(t);
1169
+ else
1170
+ tasksByFeature.set(t.featureId, [t]);
1171
+ }
1172
+ const sprintNames = new Map();
1173
+ for (const s of sprints)
1174
+ if (s?.id)
1175
+ sprintNames.set(s.id, s.name ?? s.id);
1176
+ const goalNames = new Map();
1177
+ for (const g of goals)
1178
+ if (g?.id)
1179
+ goalNames.set(g.id, g.title ?? g.id);
1180
+ const badges = [readBadge(state.router, "router"), readBadge(state.rework, "rework")].filter((b) => b !== null);
1181
+ // The plan is five levels deep, and it is drawn five levels deep: goals hold
1182
+ // sprints hold features hold tasks hold subtasks. Rendering it flat — which
1183
+ // is what this page used to do, with the goal and sprint reduced to two
1184
+ // chips on a feature card — throws away the only structure that tells you
1185
+ // whether the run is nearly done with something or scattered across
1186
+ // everything.
1187
+ const display = normalizeDisplay(state.display ?? defaultDisplay());
1188
+ const groups = groupPlan(list);
1189
+ // Which feature/sprint/goal is currently being worked (for blinking).
1190
+ const activeTask = tasks.find((t) => t.status === "in_progress" || t.status === "rework") ?? tasks.find((t) => t.status === "pending") ?? null;
1191
+ const activeFeatureId = activeTask?.featureId ?? null;
1192
+ // — tabs: Goal(s) top row, Phase(s) second row. Default = active task's goal/phase, else current phase.
1193
+ // On a project that has exactly one goal, the goal tab strip hides (requirement §3). The phase tab strip
1194
+ // always shows so RESEARCH progress is isolated from BUILD progress — same plan-list, filtered view.
1195
+ const activeGoalId = (() => {
1196
+ if (!activeTask)
1197
+ return goals[0]?.id ?? null;
1198
+ const f = features.find((ff) => ff.id === activeTask.featureId) ?? null;
1199
+ const s = f?.sprintId ? sprints.find((ss) => ss.id === f.sprintId) ?? null : null;
1200
+ return (f?.goalId ?? s?.goalId ?? goals[0]?.id ?? null);
1201
+ })();
1202
+ const currentPhase = state.phase ?? null;
1203
+ const showGoalTabs = display.levels.goal && goals.length > 1;
1204
+ const phaseTabs = getPhaseOrder(state.enabledPhases);
1205
+ const goalTabsHtml = showGoalTabs
1206
+ ? `<nav class="dash-tabs" aria-label="Goals">` +
1207
+ goals.map((g) => {
1208
+ const cur = g.id === activeGoalId ? ' is-current' : '';
1209
+ return `<button type="button" class="dash-tab${cur}" data-goal="${esc(g.id)}">${esc(g.title ?? g.id)}</button>`;
1210
+ }).join("") + `</nav>`
1211
+ : "";
1212
+ const phaseTabsHtml = `<nav class="dash-tabs" aria-label="Phases">` +
1213
+ phaseTabs.map((p) => {
1214
+ const cur = p === currentPhase ? ' is-current' : '';
1215
+ return `<button type="button" class="dash-tab${cur}" data-phase="${esc(p)}">${esc(p.toUpperCase())}</button>`;
1216
+ }).join("") + `</nav>`;
1217
+ const tabStyle = `<style>.dash-tabs{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 6px}.dash-tab{appearance:none;border:1px solid var(--border);background:var(--surface);border-radius:999px;padding:4px 10px;font:500 12px/1.2 inherit;color:var(--muted);cursor:pointer}.dash-tab.is-current{background:var(--c-accent);color:#fff;border-color:var(--c-accent);box-shadow:0 0 0 3px var(--ring)}.dash-tab:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}.tier-hidden{display:none!important}</style>`;
1218
+ // Tag every feature row with its effective phase so tabs can filter client-side without a round-trip.
1219
+ const origBody = features.length
1220
+ ? groups
1221
+ .map((group) => renderGoalGroup(group, tasksByFeature, indexByKey, {
1222
+ showGoal: display.levels.goal && goals.length > 0,
1223
+ showSprints: display.levels.sprint && sprints.length > 0,
1224
+ }, display, activeFeatureId))
1225
+ .join("")
1226
+ : renderEmptyPlan(state.phase);
1227
+ const body = (() => {
1228
+ if (!origBody)
1229
+ return origBody;
1230
+ // Inject data-phase on each feature card using featurePhase list.
1231
+ let idx = 0;
1232
+ return origBody.replace(/<section class="card feature/g, () => {
1233
+ const f = features[idx++] ?? null;
1234
+ const ph = f?.phase ?? flattenTasks(list).find((t) => t.featureId === f?.id)?.effectivePhase ?? "build";
1235
+ return `<section data-phase="${esc(ph)}" class="card feature`;
1236
+ });
1237
+ })();
1238
+ const titleBits = [];
1239
+ if (paused)
1240
+ titleBits.push("PAUSED");
1241
+ titleBits.push(state.phase ? state.phase.toUpperCase() : "IDLE");
1242
+ if (progress.tasksTotal > 0)
1243
+ titleBits.push(`${progress.percent}%`);
1244
+ const title = `${titleBits.join(" · ")} · infinity-harness`;
1245
+ const dashboardUrlHtml = state.dashboardUrl ? `<div class="dash-url">Dashboard: <a href="${esc(state.dashboardUrl)}">${esc(state.dashboardUrl)}</a></div>` : "";
1246
+ const handoffNoteHtml = state.handoffModelNote ? `<div class="handoff-note">${esc(state.handoffModelNote)}</div>` : "";
1247
+ return `<!doctype html>
1248
+ <html lang="en">
1249
+ <head>
1250
+ <meta charset="utf-8">
1251
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1252
+ <meta name="color-scheme" content="light dark">
1253
+ <meta name="robots" content="noindex">
1254
+ <!-- Empty data URI: suppresses the /favicon.ico request the harness server would 404. -->
1255
+ <link rel="icon" href="data:,">
1256
+ <title>${esc(title)}</title>
1257
+ <style>${STYLES}</style>${tabStyle}
1258
+ </head>
1259
+ <body>
1260
+ <div id="app">
1261
+ <div class="page">
1262
+ ${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
1263
+ ${dashboardUrlHtml}${handoffNoteHtml}
1264
+ ${display.levels.goal ? renderGoals(goals) : ""}
1265
+ ${display.rail ? renderRail(state.phase, state.enabledPhases, paused) : ""}
1266
+ ${display.alerts
1267
+ ? renderAlerts(counts, paused, state.retries, gate, {
1268
+ awaitingApproval: state.awaitingApproval ?? null,
1269
+ sessions: state.sessions ?? null,
1270
+ goalPass: state.goalPass ?? null,
1271
+ })
1272
+ : ""}
1273
+ ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1274
+ ${renderBackground(state.engine ?? null, state.workers ?? [], state.activity ?? [])}
1275
+ ${renderGate(gate)}
1276
+ ${goalTabsHtml}${phaseTabsHtml}
1277
+ ${body}
1278
+ <footer class="foot">
1279
+ <span>state as of <span class="mono">${esc(formatTimestamp(state.timestamp))}</span></span>
1280
+ <span><a href="/api/harness">/api/harness</a> &middot; <a href="/api/health">/api/health</a></span>
1281
+ </footer>
1282
+ </div>
1283
+ </div>
1284
+ <script>${SCRIPT}</script>
1285
+ </body>
1286
+ </html>`;
1287
+ }
1288
+ //# sourceMappingURL=dashboard.js.map