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,721 @@
1
+ /**
2
+ * infinity-harness — the plan widget.
3
+ *
4
+ * This is what the human watches while the loop runs unattended for hours. It
5
+ * has to answer three questions in one glance, without scrolling:
6
+ *
7
+ * Where are we? the phase rail
8
+ * How far along? the progress meter
9
+ * What is happening? the task window, centred on the active task
10
+ *
11
+ * Everything is derived from state on disk, so the widget is always truthful
12
+ * even if the agent's own narration has drifted.
13
+ */
14
+ import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.js";
15
+ import { getPhaseOrder } from "../core/phases.js";
16
+ import { focusRowIndex } from "./planTree.js";
17
+ import { defaultDisplay, normalizeDisplay } from "./display.js";
18
+ import { createStyler, detectGlyphs, padEnd, truncate, width, wrap, } from "./theme.js";
19
+ export const DEFAULT_WIDTH = 76;
20
+ /** Plan rows shown at once. Enough for context, short enough to stay glanceable. */
21
+ export const TASK_WINDOW = 9;
22
+ /** Completed rows kept above the active task so progress stays visible. */
23
+ export const COMPLETED_CONTEXT = 3;
24
+ /** How many rows one scroll step moves. */
25
+ export const SCROLL_STEP = 3;
26
+ /** Rows shown when the human expands the widget. */
27
+ export const EXPANDED_WINDOW = 28;
28
+ export function defaultView() {
29
+ return { scroll: null, expanded: false };
30
+ }
31
+ /**
32
+ * Move the view. Returns a new view; the caller stores it.
33
+ *
34
+ * Scrolling to the very top is how the human gets back to "following the run":
35
+ * a widget that has to be reset from a menu is a widget that stays stuck.
36
+ */
37
+ export function scrollView(view, delta, rowCount, windowRows) {
38
+ const max = Math.max(0, rowCount - windowRows);
39
+ const current = view.scroll ?? 0;
40
+ const next = Math.max(0, Math.min(max, current + delta));
41
+ return { ...view, scroll: next };
42
+ }
43
+ const STATUS_ROLE = {
44
+ pending: "pending",
45
+ in_progress: "active",
46
+ complete: "success",
47
+ blocked: "blocked",
48
+ rework: "rework",
49
+ };
50
+ export function statusGlyph(status, g = detectGlyphs()) {
51
+ switch (status) {
52
+ case "complete":
53
+ case "done":
54
+ case "closed":
55
+ case "passed":
56
+ return g.complete;
57
+ case "in_progress":
58
+ case "in-progress":
59
+ case "active":
60
+ return g.inProgress;
61
+ case "blocked":
62
+ return g.blocked;
63
+ case "rework":
64
+ case "waiting":
65
+ return g.rework;
66
+ default:
67
+ return g.pending;
68
+ }
69
+ }
70
+ function statusRole(status) {
71
+ return STATUS_ROLE[status] ?? "pending";
72
+ }
73
+ /**
74
+ * Pick the slice of tasks to display: centred on the active task, biased so a
75
+ * few completed rows stay visible above it. Clamps at both ends so the window
76
+ * is always exactly `limit` rows when there are enough tasks.
77
+ */
78
+ export function taskWindowBounds(tasks, limit = TASK_WINDOW, context = COMPLETED_CONTEXT) {
79
+ const total = tasks.length;
80
+ if (total <= limit)
81
+ return { start: 0, end: total };
82
+ let active = tasks.findIndex((t) => t.status === "in_progress");
83
+ if (active === -1)
84
+ active = tasks.findIndex((t) => t.status === "rework");
85
+ if (active === -1)
86
+ active = tasks.findIndex((t) => t.status === "blocked");
87
+ if (active === -1)
88
+ active = tasks.findIndex((t) => t.status === "pending");
89
+ if (active === -1)
90
+ active = total - 1; // everything done: show the tail
91
+ if (active < limit - context)
92
+ return { start: 0, end: limit };
93
+ if (active >= total - (limit - context))
94
+ return { start: total - limit, end: total };
95
+ const start = Math.max(0, Math.min(active - context, total - limit));
96
+ return { start, end: start + limit };
97
+ }
98
+ /** `▰▰▰▰▱▱▱▱ 62%` — a fixed-width meter that never reflows the line. */
99
+ export function progressBar(percent, cells, g, s) {
100
+ const clamped = Math.max(0, Math.min(100, percent));
101
+ const filled = Math.round((clamped / 100) * cells);
102
+ const bar = s.fg("success", g.barFull.repeat(filled)) + s.fg("rule", g.barEmpty.repeat(Math.max(0, cells - filled)));
103
+ return bar + " " + s.bold(String(clamped).padStart(3) + "%");
104
+ }
105
+ /**
106
+ * `define ─ plan ─ ◉ BUILD ─ verify ─ review ─ ship`
107
+ *
108
+ * Collapses to just the current phase plus its neighbours when the terminal is
109
+ * too narrow to hold the whole pipeline.
110
+ */
111
+ export function phaseRail(current, enabled, max, g, s, gateHistory) {
112
+ const order = getPhaseOrder(enabled);
113
+ const idx = current ? order.indexOf(current) : -1;
114
+ // Last verdict per phase — failed phases show as blocked (red).
115
+ const lastByPhase = new Map();
116
+ if (gateHistory) {
117
+ for (let i = gateHistory.length - 1; i >= 0; i--) {
118
+ const e = gateHistory[i];
119
+ if (!e?.phase || !e?.result)
120
+ continue;
121
+ if (!lastByPhase.has(e.phase))
122
+ lastByPhase.set(e.phase, e.result);
123
+ }
124
+ }
125
+ const render = (phases, elideLeft, elideRight) => {
126
+ const parts = phases.map((p) => {
127
+ const i = order.indexOf(p);
128
+ const last = lastByPhase.get(p);
129
+ if (p === current)
130
+ return s.bold(s.fg("accent", g.phaseCurrent + " " + p.toUpperCase()));
131
+ if (last === "fail")
132
+ return s.bold(s.fg("blocked", g.blocked + " " + p));
133
+ if (idx >= 0 && i < idx)
134
+ return s.fg("success", g.phaseDone + " " + p);
135
+ return s.fg("muted", g.phaseTodo + " " + p);
136
+ });
137
+ const joiner = s.fg("rule", " " + g.rail + " ");
138
+ const body = parts.join(joiner);
139
+ const lead = elideLeft ? s.fg("rule", g.more + " ") : "";
140
+ const tail = elideRight ? s.fg("rule", " " + g.more) : "";
141
+ return lead + body + tail;
142
+ };
143
+ const full = render(order, false, false);
144
+ if (width(full) <= max || idx === -1)
145
+ return truncate(full, max);
146
+ // Narrow: keep the current phase and one neighbour each side.
147
+ const lo = Math.max(0, idx - 1);
148
+ const hi = Math.min(order.length, idx + 2);
149
+ const windowed = render(order.slice(lo, hi), lo > 0, hi < order.length);
150
+ return truncate(windowed, max);
151
+ }
152
+ function depLabel(deps, indexByKey, g, s) {
153
+ if (!deps || deps.length === 0)
154
+ return "";
155
+ const nums = deps.map((d) => {
156
+ const i = indexByKey.get(d);
157
+ return i === undefined ? d : "#" + i;
158
+ });
159
+ return s.fg("muted", g.arrow + " " + nums.join(", "));
160
+ }
161
+ /** `2/5` — how much of a grouping row's branch is finished. */
162
+ function countTag(row, s) {
163
+ if (row.total === 0)
164
+ return s.fg("rule", "empty");
165
+ const role = row.done === row.total ? "success" : row.done > 0 ? "active" : "muted";
166
+ return s.fg(role, row.done + "/" + row.total);
167
+ }
168
+ const LEVEL_ROLE = {
169
+ goal: "brand",
170
+ sprint: "accent",
171
+ feature: "muted",
172
+ task: "text",
173
+ subtask: "muted",
174
+ };
175
+ /**
176
+ * One plan row, drawn.
177
+ *
178
+ * Indentation is by level, so the five levels of the plan are five columns on
179
+ * screen and the shape of the plan is readable without counting ids. The
180
+ * grouping rows carry a `done/total` tag on the right, which is what turns a
181
+ * long plan into something you can judge at a glance instead of reading.
182
+ */
183
+ function renderRow(row, inner, indexByKey, g, s, display) {
184
+ const indent = " ".repeat(row.depth);
185
+ const tagFor = (r) => (display.counts ? countTag(r, s) : "");
186
+ if (row.level === "goal" || row.level === "sprint") {
187
+ const icon = s.fg(LEVEL_ROLE[row.level], row.level === "goal" ? g.goal : g.sprint);
188
+ const tag = tagFor(row);
189
+ const prefix = indent + icon + " ";
190
+ const head = s.bold(s.fg(LEVEL_ROLE[row.level], row.title)) +
191
+ (row.label && row.label !== row.title ? s.fg("rule", " " + row.label) : "");
192
+ const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
193
+ if (!tag)
194
+ return [body];
195
+ const gap = Math.max(1, inner - width(body) - width(tag));
196
+ return [body + " ".repeat(gap) + tag];
197
+ }
198
+ if (row.level === "feature") {
199
+ const tag = tagFor(row);
200
+ const prefix = indent + s.fg("muted", g.branch + " ");
201
+ const head = s.fg("muted", row.label) + s.fg("rule", " · ") + s.fg("text", row.title);
202
+ const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
203
+ if (!tag)
204
+ return [body];
205
+ const gap = Math.max(1, inner - width(body) - width(tag));
206
+ return [body + " ".repeat(gap) + tag];
207
+ }
208
+ if (row.level === "subtask") {
209
+ const icon = row.status === "complete"
210
+ ? s.fg("success", g.subDone)
211
+ : row.status === "in_progress"
212
+ ? s.fg("active", g.subActive)
213
+ : s.fg("rule", g.subPending);
214
+ const prefix = indent + icon + " ";
215
+ return [truncate(prefix + s.fg("muted", row.title), inner)];
216
+ }
217
+ // -- a task ---------------------------------------------------------------
218
+ const role = statusRole(row.status ?? "pending");
219
+ const icon = s.fg(role, statusGlyph(row.status ?? "pending", g));
220
+ const num = s.fg("rule", row.label);
221
+ const dep = display.dependencies ? depLabel(row.dependsOn, indexByKey, g, s) : "";
222
+ const prefix = indent + icon + " " + num + " ";
223
+ const prefixW = width(prefix);
224
+ const depW = dep ? width(dep) + 1 : 0;
225
+ const titleMax = Math.max(8, inner - prefixW - depW);
226
+ const titleLines = wrap(row.title || row.id, titleMax);
227
+ const first = titleLines[0] ?? row.id;
228
+ const head = row.active
229
+ ? s.bold(s.fg("text", first))
230
+ : s.fg(role === "success" ? "muted" : "text", first);
231
+ let line = prefix + head;
232
+ if (dep) {
233
+ const spacer = Math.max(1, inner - width(line) - width(dep));
234
+ line += " ".repeat(spacer) + dep;
235
+ }
236
+ const out = [line];
237
+ for (const extra of titleLines.slice(1))
238
+ out.push(" ".repeat(prefixW) + s.fg("muted", extra));
239
+ return out;
240
+ }
241
+ /**
242
+ * Which slice of rows to show.
243
+ *
244
+ * With no explicit scroll the window follows the run: centred on the active
245
+ * task, biased so a few finished rows stay above it, because "what just got
246
+ * done" is most of what makes progress legible.
247
+ */
248
+ export function rowWindow(rows, limit, scroll, context = COMPLETED_CONTEXT) {
249
+ const total = rows.length;
250
+ if (total <= limit)
251
+ return { start: 0, end: total };
252
+ if (scroll !== null) {
253
+ const start = Math.max(0, Math.min(scroll, total - limit));
254
+ return { start, end: start + limit };
255
+ }
256
+ const focus = focusRowIndex(rows);
257
+ if (focus < limit - context)
258
+ return { start: 0, end: limit };
259
+ if (focus >= total - (limit - context))
260
+ return { start: total - limit, end: total };
261
+ const start = Math.max(0, Math.min(focus - context, total - limit));
262
+ return { start, end: start + limit };
263
+ }
264
+ /**
265
+ * Render the widget as terminal lines.
266
+ *
267
+ * Returns plain strings so the host can hand them straight to
268
+ * `ctx.ui.setWidget`. Colour is embedded as ANSI when the styler is colouring.
269
+ */
270
+ /** Rows of background log drawn by default. Enough to see the shape of a minute. */
271
+ export const ACTIVITY_ROWS = 5;
272
+ const ACTIVITY_ROLE = {
273
+ info: "muted",
274
+ work: "text",
275
+ warn: "rework",
276
+ error: "blocked",
277
+ good: "success",
278
+ };
279
+ const WORKER_ROLE = {
280
+ starting: "active",
281
+ working: "active",
282
+ idle: "muted",
283
+ closed: "muted",
284
+ failed: "blocked",
285
+ };
286
+ /** `HH:MM` in local time — a full ISO stamp is six columns of nothing. */
287
+ function clock(iso) {
288
+ const d = new Date(iso);
289
+ if (Number.isNaN(d.getTime()))
290
+ return "--:--";
291
+ return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
292
+ }
293
+ /**
294
+ * The background sessions, one line each.
295
+ *
296
+ * The model is on the line on purpose. The whole point of routing work out of
297
+ * the human's session is that a different model does it, and a claim you
298
+ * cannot see is a claim nobody believes — least of all after the version
299
+ * where it silently did not happen.
300
+ */
301
+ export function renderWorkers(workers, inner, g, s) {
302
+ const out = [];
303
+ for (const w of workers) {
304
+ const role = WORKER_ROLE[w.state] ?? "muted";
305
+ const dot = s.fg(role, w.state === "working" || w.state === "starting" ? g.inProgress : g.pending);
306
+ const head = dot +
307
+ " " +
308
+ s.bold(s.fg(role, w.name)) +
309
+ s.fg("rule", " · ") +
310
+ s.fg("text", w.unit) +
311
+ (w.difficulty ? s.fg("muted", " (" + w.difficulty + ")") : "");
312
+ const model = s.fg("accent", w.model || "pi default");
313
+ const ctx = typeof w.contextRatio === "number" ? s.fg(w.contextRatio > 0.75 ? "rework" : "muted", " " + Math.round(w.contextRatio * 100) + "%") : "";
314
+ const right = model + ctx;
315
+ const gap = Math.max(1, inner - width(head) - width(right));
316
+ out.push(truncate(head + " ".repeat(gap) + right, inner));
317
+ if (w.doing)
318
+ out.push(truncate(" " + s.fg("muted", g.arrow + " " + w.doing), inner));
319
+ }
320
+ return out;
321
+ }
322
+ /** The background log: the only place a human sees what the workers did. */
323
+ export function renderActivity(lines, rows, inner, s) {
324
+ const tail = lines.slice(-Math.max(0, rows));
325
+ return tail.map((l) => truncate(s.fg("rule", clock(l.at)) +
326
+ " " +
327
+ (l.worker ? s.fg("accent", l.worker) + " " : "") +
328
+ s.fg(ACTIVITY_ROLE[l.level] ?? "muted", l.text), inner));
329
+ }
330
+ export function renderWidget(state, options = {}) {
331
+ const total = options.width ?? DEFAULT_WIDTH;
332
+ const s = options.styler ?? createStyler();
333
+ const g = options.glyphs ?? detectGlyphs();
334
+ const boxed = options.boxed ?? false;
335
+ const pad = boxed ? 2 : 0;
336
+ const inner = Math.max(24, total - pad * 2);
337
+ const view = state.view ?? defaultView();
338
+ const display = normalizeDisplay(state.display ?? defaultDisplay());
339
+ const limit = options.taskWindow ?? (view.expanded ? Math.max(EXPANDED_WINDOW, display.taskWindow * 2) : display.taskWindow);
340
+ const out = [];
341
+ const push = (line = "") => {
342
+ out.push(line);
343
+ };
344
+ const progress = computeProgress(state.list);
345
+ const tasks = flattenTasks(state.list);
346
+ const viewSnap = state.viewState ?? null;
347
+ // -- viewState banner -----------------------------------------------------
348
+ // Reads daemon.json first. Widget never renders dead run as live.
349
+ if (viewSnap && viewSnap.state !== "running" && viewSnap.state !== "awaiting-approval") {
350
+ const hb = viewSnap.daemon?.heartbeatAt ?? null;
351
+ const ts = hb ? s.fg("muted", ` (as of ${String(hb).slice(11, 16)})`) : "";
352
+ const msg = viewSnap.state === "stale"
353
+ ? s.fg("rework", `\u26A0 Daemon unresponsive since ${hb ? String(hb).slice(11, 16) : "?"}`) + ts
354
+ : viewSnap.state === "not-running"
355
+ ? s.fg("blocked", "Daemon not running \u2014 /infinity:run to start.") + ts
356
+ : viewSnap.state === "never-armed"
357
+ ? s.fg("muted", viewSnap.reason ?? "Never armed \u2014 run the wizard.")
358
+ : viewSnap.state === "stopped"
359
+ ? s.fg("blocked", `STOPPED: ${viewSnap.reason ?? viewSnap.run?.stopReason ?? "stopped"}`) + ts
360
+ : s.fg("muted", viewSnap.reason ?? "");
361
+ push(truncate(msg, inner));
362
+ }
363
+ if (viewSnap && viewSnap.state === "awaiting-approval") {
364
+ const phase2 = (viewSnap.supervisor?.phase ?? state.phase ?? "?");
365
+ push(truncate(s.fg("active", `\u23F8 Awaiting approval: ${String(phase2).toUpperCase()} \u2192 /infinity:approve`) + s.fg("muted", " (run is healthy, not stuck)"), inner));
366
+ }
367
+ // -- pilot badge + per-tier spend + rework queue ---------------------------
368
+ if (state.pilot) {
369
+ const pilot = state.pilot;
370
+ const tag = s.bold(s.fg(pilot === "full" ? "rework" : pilot === "autopilot" ? "success" : "accent", pilot.toUpperCase()));
371
+ const pModes = state.phaseModes ?? null;
372
+ const modesStr = pModes ? Object.entries(pModes).map(([ph, m]) => s.fg(m === "autopilot" ? "success" : "active", `${ph}:${m}`)).join(s.fg("rule", " \u00b7 ")) : "";
373
+ push(truncate(s.fg("muted", "Pilot ") + tag + (modesStr ? s.fg("rule", " ") + modesStr : ""), inner));
374
+ }
375
+ {
376
+ const ts2 = state.tierSpend ?? null;
377
+ if (ts2) {
378
+ const parts = [];
379
+ for (const tier of ["A", "B", "C", "D", "X"]) {
380
+ const t = ts2[tier];
381
+ if (!t)
382
+ continue;
383
+ const label = `${tier}:${t.input + t.output}`;
384
+ parts.push(tier === "X" && (t.cost > 0 || t.input + t.output > 0) ? s.bold(s.fg("blocked", label)) : s.fg("muted", label));
385
+ }
386
+ if (parts.length)
387
+ push(truncate(s.fg("muted", "Spend ") + parts.join(s.fg("rule", " \u00b7 ")), inner));
388
+ }
389
+ }
390
+ if (typeof state.reworkDepth === "number" && state.reworkDepth > 0) {
391
+ push(truncate(s.fg("rework", `\u21BB Rework queue: ${state.reworkDepth} item(s) holding gate open`), inner));
392
+ }
393
+ if (state.replanDiff)
394
+ push(truncate(s.fg("muted", "Replan: ") + s.fg("text", String(state.replanDiff).slice(0, 80)), inner));
395
+ // -- header + dashboard --------------------------------------------
396
+ // Every band has its own colour so the top scans as sections, not one grey block.
397
+ // Muted separators make it legible even with NO_COLOR.
398
+ const sep = () => { push(s.fg("rule", g.rail.repeat(Math.max(1, inner)))); };
399
+ const brand = s.bold(s.fg("brand", "\u221e INFINITY"));
400
+ const phaseTag = state.paused
401
+ ? s.fg("blocked", "PAUSED")
402
+ : state.phase
403
+ ? s.fg("accent", state.phase.toUpperCase())
404
+ : s.fg("muted", "NOT STARTED");
405
+ const revTag = state.revision === undefined ? "" : s.fg("muted", " rev " + state.revision);
406
+ const headLeft = brand;
407
+ const headRight = phaseTag + revTag;
408
+ const gapW = inner - width(headLeft) - width(headRight);
409
+ push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
410
+ // Dashboard URL with a real port — :PORT is never correct for an ephemeral port.
411
+ {
412
+ const url = state.dashboardUrl ?? null;
413
+ if (url) {
414
+ const label = s.fg("accent", url);
415
+ const link = "\u001b]8;;" + url + "\u0007" + label + "\u001b]8;;\u0007";
416
+ push(truncate(s.fg("accent", "Dashboard \u2192 ") + link, inner));
417
+ }
418
+ else {
419
+ push(truncate(s.fg("muted", "Dashboard \u2192 ") + s.fg("muted", "/infinity:dashboard"), inner));
420
+ }
421
+ }
422
+ if (state.handoffModelNote) {
423
+ push(truncate(s.fg("muted", state.handoffModelNote), inner));
424
+ }
425
+ // Goal count sits right under the header; the old widget hid goals entirely.
426
+ {
427
+ const goals = state.list.goals ?? [];
428
+ if (goals.length === 0 && state.intake) {
429
+ push(truncate(s.fg("rule", "Goal \u00b7 ") + s.fg("text", state.intake.slice(0, 90)), inner));
430
+ }
431
+ else if (goals.length > 0) {
432
+ const n = goals.length;
433
+ const countLine = s.fg("brand", "\u25C8 Goals: " + String(n) + " " + (n === 1 ? "goal" : "goals"));
434
+ push(truncate(countLine, inner));
435
+ }
436
+ }
437
+ sep();
438
+ // -- goal + phase rail ------------------------------------------------
439
+ // One goal section per your spec, each with its own colour:
440
+ // [brand] Goals: N
441
+ // [text] Active goal headline
442
+ // [accent/muted/success/blocked] phase rail with active/passed/failed
443
+ // [muted] Active-phase breakdown: features/tasks
444
+ // [rule] dotted separator
445
+ // [accent/text] progress bar (whole project)
446
+ // [rule] dotted separator
447
+ {
448
+ const goals = state.list.goals ?? [];
449
+ // Single-goal headline (the old headline logic) — now sits directly under
450
+ // the Goals count, inside the same band, before the rail. Multi-goal plans
451
+ // show the active goal instead (not buried in the lane tree).
452
+ let headline = null;
453
+ if (display.levels.goal) {
454
+ if (goals.length === 1)
455
+ headline = goals[0]?.title ?? null;
456
+ else if (goals.length === 0)
457
+ headline = state.intake ?? null;
458
+ else {
459
+ // Multi-goal: pick the goal owning the next actionable task, else the first.
460
+ const next = nextActionableTask(state.list);
461
+ if (next) {
462
+ const feat = state.list.features.find((f) => f.id === next.featureId) ?? null;
463
+ const spr = feat?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === feat.sprintId) ?? null : null;
464
+ const gid = feat?.goalId ?? spr?.goalId ?? null;
465
+ const g2 = gid ? goals.find((gg) => gg.id === gid) ?? goals[0] ?? null : goals[0] ?? null;
466
+ headline = g2?.title ?? null;
467
+ }
468
+ else {
469
+ headline = goals[0]?.title ?? null;
470
+ }
471
+ }
472
+ }
473
+ if (headline) {
474
+ const wrapped = wrap(headline, inner - 2);
475
+ wrapped.forEach((line, i) => {
476
+ push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
477
+ });
478
+ }
479
+ if (display.rail) {
480
+ push(phaseRail(state.phase, state.enabledPhases, inner, g, s, state.gateHistory ?? null));
481
+ }
482
+ // Active-phase breakdown: features + tasks in the current phase (or whole project when no phase yet).
483
+ // Respects display.counts; when counts off we skip the line entirely.
484
+ if (display.counts) {
485
+ const phaseName = state.phase;
486
+ const allFeats = state.list.features ?? [];
487
+ const inPhase = (phaseName
488
+ ? allFeats.filter((f) => f.phase === phaseName)
489
+ : allFeats);
490
+ const phaseTasks = phaseName
491
+ ? flattenTasks(state.list).filter((tt) => tt.effectivePhase === phaseName)
492
+ : flattenTasks(state.list);
493
+ const done = phaseTasks.filter((tt) => tt.status === "complete").length;
494
+ const total = phaseTasks.length;
495
+ const featCount = inPhase.length;
496
+ const label = phaseName ? phaseName.toUpperCase() : "PROJECT";
497
+ const featPart = s.fg("accent", String(featCount) + " " + (featCount === 1 ? "feature" : "features"));
498
+ const taskPart = s.fg("text", String(done) + "/" + String(total) + " tasks");
499
+ push(truncate(s.fg("muted", label + " \u00b7 ") + featPart + s.fg("rule", " \u00b7 ") + taskPart, inner));
500
+ }
501
+ sep();
502
+ if (display.progress) {
503
+ const full = s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal) + " tasks") +
504
+ s.fg("rule", " \u00b7 ") +
505
+ s.fg("muted", String(progress.featuresDone) + "/" + String(progress.featuresTotal) + " features") +
506
+ s.fg("rule", " \u00b7 ") +
507
+ s.fg("accent", String((state.list.goals ?? []).length) + " " + ((state.list.goals ?? []).length === 1 ? "goal" : "goals"));
508
+ const compact = s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal));
509
+ const METER_MIN = 8 + 6;
510
+ let stats = full;
511
+ if (inner - width(full) < METER_MIN)
512
+ stats = compact;
513
+ if (inner - width(stats) < METER_MIN)
514
+ stats = "";
515
+ const statsW = width(stats);
516
+ const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
517
+ const bar = progressBar(progress.percent, barCells, g, s);
518
+ const gap2 = inner - width(bar) - statsW;
519
+ push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
520
+ sep();
521
+ }
522
+ }
523
+ // -- task lanes -------------------------------------------------------
524
+ // Aggregate task-window size respected; lane count capped; elision markers show hidden work.
525
+ const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
526
+ if (!display.levels.task) {
527
+ const f = state.list.features[0];
528
+ if (f) {
529
+ const sname = f.sprintId ? (state.list.sprints ?? []).find((s) => s.id === f.sprintId)?.name : null;
530
+ const gname = f.goalId ? (state.list.goals ?? []).find((g) => g.id === f.goalId)?.title : null;
531
+ const shape = [];
532
+ if (display.levels.goal && gname)
533
+ shape.push(s.fg("muted", "goal " + gname.slice(0, 28)));
534
+ if (display.levels.sprint && sname)
535
+ shape.push(s.fg("muted", "sprint " + sname.slice(0, 22)));
536
+ if (display.levels.feature)
537
+ shape.push(s.fg("success", f.name.slice(0, 28)));
538
+ if (shape.length) {
539
+ push();
540
+ push(truncate(shape.join(s.fg("rule", " \u00b7 ")), inner));
541
+ }
542
+ }
543
+ }
544
+ if (display.levels.task) {
545
+ const allTasks = flattenTasks(state.list);
546
+ let lanes = [];
547
+ if (view.scroll !== null) {
548
+ const start = Math.max(0, Math.min(view.scroll, Math.max(0, allTasks.length - taskWindow)));
549
+ lanes = allTasks.slice(start, start + taskWindow).map((t) => t);
550
+ }
551
+ else {
552
+ const activeTasks = allTasks.filter((t) => t.status === "in_progress" || t.status === "rework");
553
+ const focus = nextActionableTask(state.list) ?? activeTasks[0] ?? null;
554
+ if (focus)
555
+ lanes.push(focus);
556
+ for (const t of activeTasks)
557
+ if (focus && t.compositeKey !== focus.compositeKey && lanes.length < taskWindow)
558
+ lanes.push(t);
559
+ if (!lanes.length) {
560
+ const pending = nextActionableTask(state.list);
561
+ if (pending)
562
+ lanes.push(pending);
563
+ }
564
+ if (lanes.length < taskWindow) {
565
+ const pendingQ = allTasks.filter((t) => t.status === "pending" && !lanes.some((l) => l.compositeKey === t.compositeKey));
566
+ for (const t of pendingQ) {
567
+ if (lanes.length >= taskWindow)
568
+ break;
569
+ lanes.push(t);
570
+ }
571
+ }
572
+ }
573
+ const formatChain = (t) => {
574
+ const curFeature = state.list.features.find((f) => f.id === t.featureId) ?? null;
575
+ const curSprint = curFeature?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === curFeature.sprintId) ?? null : null;
576
+ const parts = [];
577
+ if (state.phase)
578
+ parts.push(s.bold(s.fg("accent", state.phase.toUpperCase())));
579
+ if (curSprint && display.levels.sprint)
580
+ parts.push(s.fg("muted", "sprint " + (curSprint.name ?? curSprint.id).slice(0, 18)));
581
+ if (curFeature && display.levels.feature)
582
+ parts.push(s.fg("success", curFeature.name.slice(0, 24)));
583
+ const descEarly = t.description ? t.description.slice(0, 44) : "";
584
+ parts.push(s.fg("text", t.compositeKey + (descEarly ? " " + descEarly.slice(0, 36) : "")));
585
+ return parts.join(s.fg("rule", " \u00b7 "));
586
+ };
587
+ if (lanes.length) {
588
+ const totalPendable = allTasks.length;
589
+ const above = allTasks.findIndex((t) => t.compositeKey === lanes[0].compositeKey);
590
+ const lastIdx = allTasks.findIndex((t) => t.compositeKey === lanes[lanes.length - 1].compositeKey);
591
+ const below = Math.max(0, totalPendable - lastIdx - 1);
592
+ const shownAbove = above > 0 ? above : 0;
593
+ const shownBelow = below > 0 ? below : 0;
594
+ if (shownAbove > 0)
595
+ push(s.fg("rule", " " + g.more + " " + shownAbove + " above"));
596
+ for (const task of lanes.slice(0, taskWindow)) {
597
+ push(truncate(formatChain(task), inner));
598
+ if (display.levels.subtask !== "none") {
599
+ const cur = (task.subtasks ?? []).find((ss) => ss.status !== "complete") ?? null;
600
+ if (cur)
601
+ push(truncate(" " + s.fg("active", "> " + cur.title.slice(0, 56)), inner));
602
+ }
603
+ }
604
+ if (shownBelow > 0)
605
+ push(s.fg("rule", " " + g.more + " " + shownBelow + " below"));
606
+ }
607
+ else if (state.list.features.length === 0) {
608
+ push(s.fg("muted", " no plan yet"));
609
+ }
610
+ }
611
+ // -- background sessions --------------------------------------------------
612
+ //
613
+ // Placed above the rail because, on a run driven by workers, this is the
614
+ // part of the widget that changes second by second. A human glancing at the
615
+ // terminal is checking that something is alive.
616
+ const workers = state.workers ?? [];
617
+ if (workers.length) {
618
+ push();
619
+ push(s.fg("rule", "background") + s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 12))));
620
+ for (const line of renderWorkers(workers, inner, g, s))
621
+ push(line);
622
+ }
623
+ else if (state.engine === "background" && state.list.features.length > 0) {
624
+ push();
625
+ push(truncate(s.fg("muted", "background · no worker running — /infinity:run starts one"), inner));
626
+ }
627
+ const activityRows = state.activityRows ?? (state.activity?.length ? ACTIVITY_ROWS : 0);
628
+ if (activityRows > 0 && state.activity?.length) {
629
+ push();
630
+ for (const line of renderActivity(state.activity, view.expanded ? activityRows * 3 : activityRows, inner, s))
631
+ push(line);
632
+ }
633
+ // phase rail + whole-project progress already rendered in the header band above.
634
+ // -- alerts ---------------------------------------------------------------
635
+ const alerts = [];
636
+ if (progress.blocked > 0)
637
+ alerts.push(s.fg("blocked", g.blocked + " " + progress.blocked + " blocked"));
638
+ if (progress.rework > 0)
639
+ alerts.push(s.fg("rework", g.rework + " " + progress.rework + " rework"));
640
+ if (state.retries && state.retries.task > 0) {
641
+ const role = state.retries.task >= state.retries.max ? "blocked" : "active";
642
+ alerts.push(s.fg(role, "retry " + state.retries.task + "/" + state.retries.max));
643
+ }
644
+ if (state.goalPass && state.goalPass.max > 1) {
645
+ const role = state.goalPass.current >= state.goalPass.max ? "blocked" : "active";
646
+ alerts.push(s.fg(role, "pass " + state.goalPass.current + "/" + state.goalPass.max));
647
+ }
648
+ if (state.escalation?.strategy) {
649
+ alerts.push(s.fg("rework", g.rework + " " + state.escalation.strategy));
650
+ }
651
+ if (state.awaitingApproval) {
652
+ alerts.unshift(s.bold(s.fg("active", g.rework + " " + state.awaitingApproval.toUpperCase() + " needs your OK")));
653
+ }
654
+ if (typeof state.sessions === "number" && state.sessions > 1) {
655
+ // Proof the handoff is working. Without it a run that quietly stopped
656
+ // starting fresh sessions looks exactly like one that never did.
657
+ alerts.push(s.fg("muted", "session " + state.sessions));
658
+ }
659
+ if (state.gate && !state.gate.overall) {
660
+ alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
661
+ }
662
+ if (display.alerts && alerts.length)
663
+ push(truncate(alerts.join(s.fg("rule", " · ")), inner));
664
+ // keyboard hint — always visible so scroll is discoverable without reading the README
665
+ {
666
+ const scrollable = flattenTasks(state.list).length > taskWindow;
667
+ const baseHint = hintKeys(g);
668
+ const followPart = view.scroll !== null ? s.fg("accent", " · following: /infinity:scroll follow") : "";
669
+ const hasWorker = (state.workers?.length ?? 0) > 0 || state.engine === "background";
670
+ // Keep hint one line in narrow terminals; otherwise add a second affordance line.
671
+ const hintLine = baseHint + followPart;
672
+ push(truncate(scrollable || view.scroll !== null ? s.fg("muted", hintLine) : s.fg("rule", hintLine), inner));
673
+ if (hasWorker) {
674
+ push(truncate(s.fg("muted", ` ${g.more} tip: ${s.fg("text", "/infinity:workers")} and ${s.fg("text", "/infinity:dashboard")} show live detail`), inner));
675
+ }
676
+ }
677
+ // footer only — scroll tree removed to keep TUI readable on narrow term
678
+ push(s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 2))));
679
+ return frame(out, total, boxed, s, g);
680
+ }
681
+ /**
682
+ * The keys that move the window, said once, where the window runs out.
683
+ *
684
+ * `alt+` rather than `ctrl+`: pi binds ctrl+j, ctrl+k and ctrl+o in the editor
685
+ * already, and a widget is not worth shadowing an editor key for.
686
+ */
687
+ export function hintKeys(_g) {
688
+ return "↕ alt+j/k scroll · alt+o expand · /infinity:scroll";
689
+ }
690
+ function frame(lines, total, boxed, s, g) {
691
+ // Unboxed still has to honour the requested width: the caller sized the
692
+ // widget to a terminal, and a row that overruns wraps and breaks the layout.
693
+ if (!boxed)
694
+ return lines.map((l) => truncate(l, total));
695
+ const inner = total - 4;
696
+ const top = s.fg("rule", "╭" + "─".repeat(total - 2) + "╮");
697
+ const bottom = s.fg("rule", "╰" + "─".repeat(total - 2) + "╯");
698
+ const side = s.fg("rule", "│");
699
+ const body = lines.map((l) => side + " " + padEnd(truncate(l, inner), inner) + " " + side);
700
+ return [top, ...body, bottom];
701
+ }
702
+ /** Compact one-liner for the status bar: `BUILD 13/21 ◐`. */
703
+ export function renderStatusLine(state, g = detectGlyphs()) {
704
+ const p = computeProgress(state.list);
705
+ if (state.paused)
706
+ return "paused";
707
+ if (state.awaitingApproval)
708
+ return `${state.awaitingApproval} · needs your OK`;
709
+ if (p.tasksTotal === 0)
710
+ return state.phase ?? "idle";
711
+ const mark = p.blocked > 0
712
+ ? g.blocked
713
+ : p.inProgress > 0
714
+ ? g.inProgress
715
+ : p.tasksDone === p.tasksTotal
716
+ ? g.complete
717
+ : g.pending;
718
+ const phase = state.phase ? state.phase + " " : "";
719
+ return phase + p.tasksDone + "/" + p.tasksTotal + " " + mark;
720
+ }
721
+ //# sourceMappingURL=widget.js.map