infinity-harness 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/extensions/infinity-harness/index.ts +870 -0
  5. package/harness/docs/ARCHITECTURE.md +159 -0
  6. package/harness/docs/CONSTRAINTS.md +19 -0
  7. package/harness/docs/DECISIONS.md +107 -0
  8. package/harness/docs/DOMAIN.md +13 -0
  9. package/harness/docs/agents/evaluator.md +14 -0
  10. package/harness/docs/agents/generator.md +13 -0
  11. package/harness/docs/agents/planner.md +13 -0
  12. package/harness/docs/agents/simplifier.md +13 -0
  13. package/harness/docs/api-patterns.md +23 -0
  14. package/harness/docs/phases/build.md +47 -0
  15. package/harness/docs/phases/define.md +58 -0
  16. package/harness/docs/phases/plan.md +50 -0
  17. package/harness/docs/phases/review.md +47 -0
  18. package/harness/docs/phases/ship.md +43 -0
  19. package/harness/docs/phases/simplify.md +45 -0
  20. package/harness/docs/phases/verify.md +46 -0
  21. package/harness/model-router.json +28 -0
  22. package/harness/skills/README.md +60 -0
  23. package/harness/skills/auth-security.md +56 -0
  24. package/harness/skills/building-mcp-servers.md +70 -0
  25. package/harness/skills/building-tools.md +60 -0
  26. package/harness/skills/capability-acquisition.md +72 -0
  27. package/harness/skills/cli-design.md +55 -0
  28. package/harness/skills/code-review.md +57 -0
  29. package/harness/skills/codebase-design.md +70 -0
  30. package/harness/skills/concurrency-async.md +61 -0
  31. package/harness/skills/config-and-secrets.md +52 -0
  32. package/harness/skills/context-hygiene.md +51 -0
  33. package/harness/skills/databases.md +63 -0
  34. package/harness/skills/diagnosing-bugs.md +84 -0
  35. package/harness/skills/domain-modeling.md +65 -0
  36. package/harness/skills/error-handling-logging.md +56 -0
  37. package/harness/skills/frontend-ui.md +56 -0
  38. package/harness/skills/grilling.md +48 -0
  39. package/harness/skills/http-apis.md +60 -0
  40. package/harness/skills/performance.md +53 -0
  41. package/harness/skills/pi-todo-adapted.md +41 -0
  42. package/harness/skills/planning-tasks.md +86 -0
  43. package/harness/skills/prototype.md +39 -0
  44. package/harness/skills/research.md +32 -0
  45. package/harness/skills/resolving-merge-conflicts.md +30 -0
  46. package/harness/skills/scope-discipline.md +49 -0
  47. package/harness/skills/self-review.md +45 -0
  48. package/harness/skills/stuck-protocol.md +51 -0
  49. package/harness/skills/tdd.md +80 -0
  50. package/harness/skills/testing-infra.md +57 -0
  51. package/harness/skills/writing-skills.md +60 -0
  52. package/package.json +61 -0
  53. package/src/core/brief.ts +242 -0
  54. package/src/core/config.ts +265 -0
  55. package/src/core/exec.ts +130 -0
  56. package/src/core/featureList.ts +286 -0
  57. package/src/core/fsx.ts +119 -0
  58. package/src/core/gates.ts +444 -0
  59. package/src/core/lock.ts +192 -0
  60. package/src/core/paths.ts +95 -0
  61. package/src/core/phases.ts +143 -0
  62. package/src/core/settings.ts +445 -0
  63. package/src/core/types.ts +245 -0
  64. package/src/goalLoop.ts +628 -0
  65. package/src/goalSpec.ts +679 -0
  66. package/src/goalState.ts +338 -0
  67. package/src/loop.ts +355 -0
  68. package/src/modelRouter.ts +184 -0
  69. package/src/remote.ts +244 -0
  70. package/src/replan.ts +300 -0
  71. package/src/review.ts +53 -0
  72. package/src/rework.ts +274 -0
  73. package/src/taskList.ts +355 -0
  74. package/src/ui/config.ts +286 -0
  75. package/src/ui/dashboard.ts +1066 -0
  76. package/src/ui/theme.ts +317 -0
  77. package/src/ui/widget.ts +370 -0
  78. package/src/unstuck.ts +214 -0
  79. package/src/worker.ts +351 -0
  80. package/types/proper-lockfile.d.ts +19 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * infinity-harness — terminal styling primitives.
3
+ *
4
+ * Two things every renderer here needs and neither the host nor Node gives us:
5
+ *
6
+ * 1. Width that matches what the terminal actually draws. An ANSI escape
7
+ * occupies zero columns and a CJK glyph occupies two, so every layout
8
+ * decision measures with `width()` rather than `String.length`.
9
+ * 2. Colour that degrades. A pipe, a CI log, and NO_COLOR all want plain
10
+ * text; a modern terminal wants truecolor. `createStyler` picks once.
11
+ */
12
+
13
+ import stringWidth from "string-width";
14
+
15
+ const ESC = "\u001b";
16
+ const RESET = ESC + "[0m";
17
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
18
+
19
+ export function stripAnsi(s: string): string {
20
+ return s.replace(ANSI_RE, "");
21
+ }
22
+
23
+ /** Display width in terminal columns, ignoring ANSI and honouring wide chars. */
24
+ export function width(s: string): number {
25
+ const plain = stripAnsi(s);
26
+ let w = 0;
27
+ for (const ch of plain) w += cellWidth(ch);
28
+ return w;
29
+ }
30
+
31
+ /**
32
+ * Truncate to `max` columns, appending an ellipsis when it does not fit.
33
+ *
34
+ * ANSI-aware: escape sequences are copied through without consuming columns,
35
+ * and a reset is appended when the cut lands inside a styled run. Truncating a
36
+ * styled string with a naive slice would either bleed colour into the rest of
37
+ * the line or strip it entirely.
38
+ */
39
+ export function truncate(s: string, max: number, ellipsis = "…"): string {
40
+ if (max <= 0) return "";
41
+ if (width(s) <= max) return s;
42
+
43
+ const eW = width(ellipsis);
44
+ const budget = max - eW;
45
+ let out = "";
46
+ let w = 0;
47
+ let styled = false;
48
+ let i = 0;
49
+
50
+ while (i < s.length) {
51
+ if (s[i] === ESC) {
52
+ const end = s.indexOf("m", i);
53
+ if (end === -1) break;
54
+ const seq = s.slice(i, end + 1);
55
+ out += seq;
56
+ styled = seq !== RESET;
57
+ i = end + 1;
58
+ continue;
59
+ }
60
+ const cp = String.fromCodePoint(s.codePointAt(i)!);
61
+ const cw = cellWidth(cp);
62
+ if (w + cw > budget) break;
63
+ out += cp;
64
+ w += cw;
65
+ i += cp.length;
66
+ }
67
+
68
+ return out + (styled ? RESET : "") + ellipsis;
69
+ }
70
+
71
+ /**
72
+ * Column width of a single grapheme.
73
+ *
74
+ * `string-width` reports East Asian Ambiguous characters as 2 columns, but
75
+ * every terminal we target draws these particular symbols in 1. Trusting the
76
+ * library here shifts whole columns of the widget out of alignment, so the
77
+ * handful of glyphs the UI actually uses are pinned to 1.
78
+ */
79
+ const AMBIGUOUS_SINGLE = new Set([
80
+ "\u26a0", // warning sign
81
+ "\u21b7", // clockwise top semicircle arrow
82
+ "\u25b8", // small right-pointing triangle
83
+ "\u2713", // check mark
84
+ "\u2261", // identical to
85
+ "\u2022", // bullet
86
+ "\u00b7", // middle dot
87
+ "\u2500", // box drawing light horizontal
88
+ ]);
89
+
90
+ export function cellWidth(ch: string): number {
91
+ if (AMBIGUOUS_SINGLE.has(ch)) return 1;
92
+ try {
93
+ return stringWidth(ch);
94
+ } catch {
95
+ return 1;
96
+ }
97
+ }
98
+
99
+ /** Pad on the right to exactly `n` columns (no-op if already wider). */
100
+ export function padEnd(s: string, n: number): string {
101
+ const w = width(s);
102
+ return w >= n ? s : s + " ".repeat(n - w);
103
+ }
104
+
105
+ export function padStart(s: string, n: number): string {
106
+ const w = width(s);
107
+ return w >= n ? s : " ".repeat(n - w) + s;
108
+ }
109
+
110
+ /**
111
+ * Wrap to `max` columns on word boundaries, hard-splitting words that cannot
112
+ * fit. Never truncates — plan text the human needs to read stays readable.
113
+ */
114
+ export function wrap(text: string, max: number): string[] {
115
+ if (max <= 0) return [text];
116
+ if (text === "") return [""];
117
+ if (width(text) <= max) return [text];
118
+
119
+ const lines: string[] = [];
120
+ let cur = "";
121
+
122
+ for (const word of text.split(" ")) {
123
+ if (width(word) > max) {
124
+ if (cur) {
125
+ lines.push(cur);
126
+ cur = "";
127
+ }
128
+ let chunk = "";
129
+ for (const ch of word) {
130
+ if (width(chunk) + width(ch) > max) {
131
+ lines.push(chunk);
132
+ chunk = ch;
133
+ } else {
134
+ chunk += ch;
135
+ }
136
+ }
137
+ if (chunk) cur = chunk;
138
+ continue;
139
+ }
140
+ if (!cur) cur = word;
141
+ else if (width(cur) + 1 + width(word) <= max) cur += " " + word;
142
+ else {
143
+ lines.push(cur);
144
+ cur = word;
145
+ }
146
+ }
147
+ if (cur) lines.push(cur);
148
+ return lines.length ? lines : [""];
149
+ }
150
+
151
+ // -- Colour ------------------------------------------------------------------
152
+
153
+ export type ColorMode = "none" | "ansi256" | "truecolor";
154
+
155
+ export type Role =
156
+ | "brand"
157
+ | "accent"
158
+ | "success"
159
+ | "active"
160
+ | "pending"
161
+ | "blocked"
162
+ | "rework"
163
+ | "muted"
164
+ | "text"
165
+ | "rule";
166
+
167
+ export type Styler = {
168
+ mode: ColorMode;
169
+ fg(role: Role, s: string): string;
170
+ bold(s: string): string;
171
+ dim(s: string): string;
172
+ hex(hex: string, s: string): string;
173
+ };
174
+
175
+ /**
176
+ * Palette. Chosen to stay legible on both light and dark terminals: every
177
+ * colour sits in the mid-luminance band rather than at either extreme, so
178
+ * nothing disappears into the background on one theme or the other.
179
+ */
180
+ export const PALETTE: Record<Role, string> = {
181
+ brand: "#7C5CFF",
182
+ accent: "#00B8D4",
183
+ success: "#2E9E5B",
184
+ active: "#D98A00",
185
+ pending: "#8A8F98",
186
+ blocked: "#D64545",
187
+ rework: "#B36BD4",
188
+ muted: "#6E7481",
189
+ text: "#C9CDD4",
190
+ rule: "#4A5058",
191
+ };
192
+
193
+ function hexToRgb(hex: string): [number, number, number] {
194
+ const h = hex.replace("#", "");
195
+ return [
196
+ Number.parseInt(h.slice(0, 2), 16),
197
+ Number.parseInt(h.slice(2, 4), 16),
198
+ Number.parseInt(h.slice(4, 6), 16),
199
+ ];
200
+ }
201
+
202
+ /** Nearest xterm-256 index for an RGB triple (6x6x6 cube + grey ramp). */
203
+ function rgbTo256(r: number, g: number, b: number): number {
204
+ if (r === g && g === b) {
205
+ if (r < 8) return 16;
206
+ if (r > 248) return 231;
207
+ return Math.round(((r - 8) / 247) * 24) + 232;
208
+ }
209
+ const q = (v: number): number => Math.round((v / 255) * 5);
210
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
211
+ }
212
+
213
+ export function detectColorMode(env: NodeJS.ProcessEnv = process.env): ColorMode {
214
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return "none";
215
+ if (env.INFINITY_HARNESS_COLOR === "0" || env.INFINITY_HARNESS_COLOR === "none") return "none";
216
+ if (env.INFINITY_HARNESS_COLOR === "truecolor") return "truecolor";
217
+ if (env.INFINITY_HARNESS_COLOR === "256") return "ansi256";
218
+ if (env.FORCE_COLOR === "0") return "none";
219
+ const ct = env.COLORTERM ?? "";
220
+ if (ct.includes("truecolor") || ct.includes("24bit")) return "truecolor";
221
+ const term = env.TERM ?? "";
222
+ if (term === "dumb") return "none";
223
+ if (term.includes("256")) return "ansi256";
224
+ if (env.FORCE_COLOR) return "ansi256";
225
+ if (term) return "ansi256";
226
+ return "none";
227
+ }
228
+
229
+ export function createStyler(mode: ColorMode = detectColorMode()): Styler {
230
+ if (mode === "none") {
231
+ const id = (s: string): string => s;
232
+ return { mode, fg: (_r, s) => s, bold: id, dim: id, hex: (_h, s) => s };
233
+ }
234
+ const sgr = (open: string, s: string): string => ESC + "[" + open + "m" + s + ESC + "[0m";
235
+ const hex = (h: string, s: string): string => {
236
+ const [r, g, b] = hexToRgb(h);
237
+ return mode === "truecolor"
238
+ ? sgr("38;2;" + r + ";" + g + ";" + b, s)
239
+ : sgr("38;5;" + rgbTo256(r, g, b), s);
240
+ };
241
+ return {
242
+ mode,
243
+ hex,
244
+ fg: (role, s) => hex(PALETTE[role], s),
245
+ bold: (s) => sgr("1", s),
246
+ dim: (s) => sgr("2", s),
247
+ };
248
+ }
249
+
250
+ // -- Glyphs ------------------------------------------------------------------
251
+
252
+ export type GlyphSet = {
253
+ pending: string;
254
+ inProgress: string;
255
+ complete: string;
256
+ blocked: string;
257
+ rework: string;
258
+ subPending: string;
259
+ subActive: string;
260
+ subDone: string;
261
+ barFull: string;
262
+ barEmpty: string;
263
+ arrow: string;
264
+ more: string;
265
+ branch: string;
266
+ phaseDone: string;
267
+ phaseCurrent: string;
268
+ phaseTodo: string;
269
+ rail: string;
270
+ };
271
+
272
+ export const UNICODE_GLYPHS: GlyphSet = {
273
+ pending: "○",
274
+ inProgress: "◐",
275
+ complete: "●",
276
+ blocked: "⚠",
277
+ rework: "↷",
278
+ subPending: "·",
279
+ subActive: "▸",
280
+ subDone: "✓",
281
+ barFull: "▰",
282
+ barEmpty: "▱",
283
+ arrow: "←",
284
+ more: "⋯",
285
+ branch: "▸",
286
+ phaseDone: "●",
287
+ phaseCurrent: "◉",
288
+ phaseTodo: "○",
289
+ rail: "─",
290
+ };
291
+
292
+ export const ASCII_GLYPHS: GlyphSet = {
293
+ pending: "o",
294
+ inProgress: "*",
295
+ complete: "x",
296
+ blocked: "!",
297
+ rework: "~",
298
+ subPending: ".",
299
+ subActive: ">",
300
+ subDone: "+",
301
+ barFull: "#",
302
+ barEmpty: "-",
303
+ arrow: "<-",
304
+ more: "...",
305
+ branch: ">",
306
+ phaseDone: "x",
307
+ phaseCurrent: "O",
308
+ phaseTodo: "o",
309
+ rail: "-",
310
+ };
311
+
312
+ export function detectGlyphs(env: NodeJS.ProcessEnv = process.env): GlyphSet {
313
+ if (env.INFINITY_HARNESS_ASCII === "1") return ASCII_GLYPHS;
314
+ const enc = (env.LC_ALL ?? env.LC_CTYPE ?? env.LANG ?? "").toLowerCase();
315
+ if (enc && !enc.includes("utf")) return ASCII_GLYPHS;
316
+ return UNICODE_GLYPHS;
317
+ }
@@ -0,0 +1,370 @@
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
+
15
+ import type { FeatureList, Phase, TaskStatus } from "../core/types.ts";
16
+ import { computeProgress, flattenTasks, type FlatTask } from "../core/featureList.ts";
17
+ import { getPhaseOrder } from "../core/phases.ts";
18
+ import {
19
+ createStyler,
20
+ detectGlyphs,
21
+ padEnd,
22
+ truncate,
23
+ width,
24
+ wrap,
25
+ type GlyphSet,
26
+ type Role,
27
+ type Styler,
28
+ } from "./theme.ts";
29
+
30
+ export const DEFAULT_WIDTH = 76;
31
+ /** Task rows shown at once. Enough for context, short enough to stay glanceable. */
32
+ export const TASK_WINDOW = 9;
33
+ /** Completed rows kept above the active task so progress stays visible. */
34
+ export const COMPLETED_CONTEXT = 3;
35
+
36
+ export type WidgetState = {
37
+ list: FeatureList;
38
+ phase: Phase | null;
39
+ enabledPhases?: readonly string[] | null;
40
+ paused?: boolean;
41
+ gate?: { overall: boolean; failures: string[] } | null;
42
+ /** Shown in the header rule, e.g. "rev 42". */
43
+ revision?: number;
44
+ retries?: { task: number; max: number };
45
+ };
46
+
47
+ export type WidgetOptions = {
48
+ width?: number;
49
+ styler?: Styler;
50
+ glyphs?: GlyphSet;
51
+ /** Frame the widget in a box. Off when the host already draws a frame. */
52
+ boxed?: boolean;
53
+ /** Cap on task rows. Defaults to TASK_WINDOW. */
54
+ taskWindow?: number;
55
+ };
56
+
57
+ const STATUS_ROLE: Record<TaskStatus, Role> = {
58
+ pending: "pending",
59
+ in_progress: "active",
60
+ complete: "success",
61
+ blocked: "blocked",
62
+ rework: "rework",
63
+ };
64
+
65
+ export function statusGlyph(status: string, g: GlyphSet = detectGlyphs()): string {
66
+ switch (status) {
67
+ case "complete":
68
+ case "done":
69
+ case "closed":
70
+ case "passed":
71
+ return g.complete;
72
+ case "in_progress":
73
+ case "in-progress":
74
+ case "active":
75
+ return g.inProgress;
76
+ case "blocked":
77
+ return g.blocked;
78
+ case "rework":
79
+ case "waiting":
80
+ return g.rework;
81
+ default:
82
+ return g.pending;
83
+ }
84
+ }
85
+
86
+ function statusRole(status: string): Role {
87
+ return STATUS_ROLE[status as TaskStatus] ?? "pending";
88
+ }
89
+
90
+ /**
91
+ * Pick the slice of tasks to display: centred on the active task, biased so a
92
+ * few completed rows stay visible above it. Clamps at both ends so the window
93
+ * is always exactly `limit` rows when there are enough tasks.
94
+ */
95
+ export function taskWindowBounds(
96
+ tasks: Array<{ status: string }>,
97
+ limit = TASK_WINDOW,
98
+ context = COMPLETED_CONTEXT,
99
+ ): { start: number; end: number } {
100
+ const total = tasks.length;
101
+ if (total <= limit) return { start: 0, end: total };
102
+
103
+ let active = tasks.findIndex((t) => t.status === "in_progress");
104
+ if (active === -1) active = tasks.findIndex((t) => t.status === "rework");
105
+ if (active === -1) active = tasks.findIndex((t) => t.status === "blocked");
106
+ if (active === -1) active = tasks.findIndex((t) => t.status === "pending");
107
+ if (active === -1) active = total - 1; // everything done: show the tail
108
+
109
+ if (active < limit - context) return { start: 0, end: limit };
110
+ if (active >= total - (limit - context)) return { start: total - limit, end: total };
111
+
112
+ const start = Math.max(0, Math.min(active - context, total - limit));
113
+ return { start, end: start + limit };
114
+ }
115
+
116
+ /** `▰▰▰▰▱▱▱▱ 62%` — a fixed-width meter that never reflows the line. */
117
+ export function progressBar(percent: number, cells: number, g: GlyphSet, s: Styler): string {
118
+ const clamped = Math.max(0, Math.min(100, percent));
119
+ const filled = Math.round((clamped / 100) * cells);
120
+ const bar =
121
+ s.fg("success", g.barFull.repeat(filled)) + s.fg("rule", g.barEmpty.repeat(Math.max(0, cells - filled)));
122
+ return bar + " " + s.bold(String(clamped).padStart(3) + "%");
123
+ }
124
+
125
+ /**
126
+ * `define ─ plan ─ ◉ BUILD ─ verify ─ review ─ ship`
127
+ *
128
+ * Collapses to just the current phase plus its neighbours when the terminal is
129
+ * too narrow to hold the whole pipeline.
130
+ */
131
+ export function phaseRail(
132
+ current: Phase | null,
133
+ enabled: readonly string[] | null | undefined,
134
+ max: number,
135
+ g: GlyphSet,
136
+ s: Styler,
137
+ ): string {
138
+ const order = getPhaseOrder(enabled);
139
+ const idx = current ? order.indexOf(current) : -1;
140
+
141
+ const render = (phases: Phase[], elideLeft: boolean, elideRight: boolean): string => {
142
+ const parts = phases.map((p) => {
143
+ const i = order.indexOf(p);
144
+ if (p === current) return s.bold(s.fg("accent", g.phaseCurrent + " " + p.toUpperCase()));
145
+ if (idx >= 0 && i < idx) return s.fg("success", g.phaseDone + " " + p);
146
+ return s.fg("muted", g.phaseTodo + " " + p);
147
+ });
148
+ const joiner = s.fg("rule", " " + g.rail + " ");
149
+ const body = parts.join(joiner);
150
+ const lead = elideLeft ? s.fg("rule", g.more + " ") : "";
151
+ const tail = elideRight ? s.fg("rule", " " + g.more) : "";
152
+ return lead + body + tail;
153
+ };
154
+
155
+ const full = render(order, false, false);
156
+ if (width(full) <= max || idx === -1) return truncate(full, max);
157
+
158
+ // Narrow: keep the current phase and one neighbour each side.
159
+ const lo = Math.max(0, idx - 1);
160
+ const hi = Math.min(order.length, idx + 2);
161
+ const windowed = render(order.slice(lo, hi), lo > 0, hi < order.length);
162
+ return truncate(windowed, max);
163
+ }
164
+
165
+ function depLabel(
166
+ task: FlatTask,
167
+ indexByKey: Map<string, number>,
168
+ g: GlyphSet,
169
+ s: Styler,
170
+ ): string {
171
+ const deps = task.dependsOn ?? [];
172
+ if (deps.length === 0) return "";
173
+ const nums = deps.map((d) => {
174
+ const i = indexByKey.get(d);
175
+ return i === undefined ? d : "#" + i;
176
+ });
177
+ return s.fg("muted", g.arrow + " " + nums.join(", "));
178
+ }
179
+
180
+ /**
181
+ * Render the widget as terminal lines.
182
+ *
183
+ * Returns plain strings so the host can hand them straight to
184
+ * `ctx.ui.setWidget`. Colour is embedded as ANSI when the styler is colouring.
185
+ */
186
+ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): string[] {
187
+ const total = options.width ?? DEFAULT_WIDTH;
188
+ const s = options.styler ?? createStyler();
189
+ const g = options.glyphs ?? detectGlyphs();
190
+ const boxed = options.boxed ?? false;
191
+ const pad = boxed ? 2 : 0;
192
+ const inner = Math.max(24, total - pad * 2);
193
+ const limit = options.taskWindow ?? TASK_WINDOW;
194
+
195
+ const out: string[] = [];
196
+ const push = (line = ""): void => {
197
+ out.push(line);
198
+ };
199
+
200
+ const progress = computeProgress(state.list);
201
+ const tasks = flattenTasks(state.list);
202
+
203
+ // -- header ---------------------------------------------------------------
204
+ const brand = s.bold(s.fg("brand", "∞ INFINITY"));
205
+ const phaseTag = state.paused
206
+ ? s.fg("blocked", "PAUSED")
207
+ : state.phase
208
+ ? s.fg("accent", state.phase.toUpperCase())
209
+ : s.fg("muted", "NOT STARTED");
210
+ const revTag = state.revision === undefined ? "" : s.fg("muted", " rev " + state.revision);
211
+ const headLeft = brand;
212
+ const headRight = phaseTag + revTag;
213
+ const gapW = inner - width(headLeft) - width(headRight);
214
+ push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
215
+
216
+ // -- goal -----------------------------------------------------------------
217
+ const goal = (state.list.goals ?? [])[0];
218
+ if (goal?.title) {
219
+ for (const line of wrap(goal.title, inner - 2)) {
220
+ push(s.fg("muted", g.branch + " ") + s.fg("text", line));
221
+ }
222
+ }
223
+
224
+ // -- phase rail -----------------------------------------------------------
225
+ push();
226
+ push(phaseRail(state.phase, state.enabledPhases, inner, g, s));
227
+
228
+ // -- progress -------------------------------------------------------------
229
+ const full =
230
+ s.fg("muted", progress.tasksDone + "/" + progress.tasksTotal + " tasks") +
231
+ s.fg("rule", " · ") +
232
+ s.fg("muted", progress.featuresDone + "/" + progress.featuresTotal + " features");
233
+ const compact = s.fg("muted", progress.tasksDone + "/" + progress.tasksTotal);
234
+
235
+ // The meter has a floor of 8 cells plus " NNN%" (6 columns). Below that the
236
+ // long stat cannot fit, and padding it out would push the row past the
237
+ // frame — so the row degrades to the task count alone, then to no stat.
238
+ const METER_MIN = 8 + 6;
239
+ let stats = full;
240
+ if (inner - width(full) < METER_MIN) stats = compact;
241
+ if (inner - width(stats) < METER_MIN) stats = "";
242
+
243
+ const statsW = width(stats);
244
+ const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
245
+ const bar = progressBar(progress.percent, barCells, g, s);
246
+ push();
247
+ const gap2 = inner - width(bar) - statsW;
248
+ push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
249
+
250
+ // -- alerts ---------------------------------------------------------------
251
+ const alerts: string[] = [];
252
+ if (progress.blocked > 0) alerts.push(s.fg("blocked", g.blocked + " " + progress.blocked + " blocked"));
253
+ if (progress.rework > 0) alerts.push(s.fg("rework", g.rework + " " + progress.rework + " rework"));
254
+ if (state.retries && state.retries.task > 0) {
255
+ const role: Role = state.retries.task >= state.retries.max ? "blocked" : "active";
256
+ alerts.push(s.fg(role, "retry " + state.retries.task + "/" + state.retries.max));
257
+ }
258
+ if (state.gate && !state.gate.overall) {
259
+ alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
260
+ }
261
+ if (alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
262
+
263
+ // -- tasks ----------------------------------------------------------------
264
+ push();
265
+ if (tasks.length === 0) {
266
+ push(s.fg("muted", " no tasks planned yet"));
267
+ return frame(out, total, boxed, s, g);
268
+ }
269
+
270
+ const indexByKey = new Map<string, number>();
271
+ for (const t of tasks) {
272
+ indexByKey.set(t.compositeKey, t.index);
273
+ indexByKey.set(t.id, t.index);
274
+ if (t.key) indexByKey.set(t.key, t.index);
275
+ }
276
+
277
+ const bounds = taskWindowBounds(tasks, limit);
278
+ const visible = tasks.slice(bounds.start, bounds.end);
279
+ const hiddenBefore = bounds.start;
280
+ const hiddenAfter = tasks.length - bounds.end;
281
+
282
+ if (hiddenBefore > 0) {
283
+ push(s.fg("rule", " " + g.more + " " + hiddenBefore + " earlier"));
284
+ }
285
+
286
+ let lastFeature: string | null = null;
287
+ const numW = String(tasks.length).length;
288
+
289
+ for (const t of visible) {
290
+ if (t.featureId !== lastFeature) {
291
+ lastFeature = t.featureId;
292
+ const label = t.featureId + s.fg("rule", " · ") + t.featureName;
293
+ push(s.fg("muted", g.branch + " ") + truncate(label, inner - 2));
294
+ }
295
+
296
+ const role = statusRole(t.status);
297
+ const icon = s.fg(role, statusGlyph(t.status, g));
298
+ const num = s.fg("rule", String(t.index).padStart(numW));
299
+ const dep = depLabel(t, indexByKey, g, s);
300
+ const prefix = " " + icon + " " + num + " ";
301
+ const prefixW = width(prefix);
302
+ const depW = dep ? width(dep) + 1 : 0;
303
+ const titleMax = Math.max(8, inner - prefixW - depW);
304
+
305
+ const isActive = t.status === "in_progress" || t.status === "rework";
306
+ const titleLines = wrap(t.description || t.compositeKey, titleMax);
307
+ const head = isActive ? s.bold(s.fg("text", titleLines[0]!)) : s.fg(role === "success" ? "muted" : "text", titleLines[0]!);
308
+
309
+ let row = prefix + head;
310
+ if (dep) {
311
+ const rowW = width(row);
312
+ const spacer = Math.max(1, inner - rowW - width(dep));
313
+ row += " ".repeat(spacer) + dep;
314
+ }
315
+ push(row);
316
+ for (const extra of titleLines.slice(1)) {
317
+ push(" ".repeat(prefixW) + s.fg("muted", extra));
318
+ }
319
+
320
+ // Subtasks only for the task actually being worked — otherwise the window
321
+ // fills with detail nobody is acting on.
322
+ if (isActive) {
323
+ for (const sub of t.subtasks ?? []) {
324
+ const sIcon =
325
+ sub.status === "complete"
326
+ ? s.fg("success", g.subDone)
327
+ : sub.status === "in_progress"
328
+ ? s.fg("active", g.subActive)
329
+ : s.fg("rule", g.subPending);
330
+ const sTitle = truncate(sub.title, inner - prefixW - 4);
331
+ push(" ".repeat(prefixW) + sIcon + " " + s.fg("muted", sTitle));
332
+ }
333
+ }
334
+ }
335
+
336
+ if (hiddenAfter > 0) {
337
+ push(s.fg("rule", " " + g.more + " " + hiddenAfter + " more"));
338
+ }
339
+
340
+ return frame(out, total, boxed, s, g);
341
+ }
342
+
343
+ function frame(lines: string[], total: number, boxed: boolean, s: Styler, g: GlyphSet): string[] {
344
+ // Unboxed still has to honour the requested width: the caller sized the
345
+ // widget to a terminal, and a row that overruns wraps and breaks the layout.
346
+ if (!boxed) return lines.map((l) => truncate(l, total));
347
+ const inner = total - 4;
348
+ const top = s.fg("rule", "╭" + "─".repeat(total - 2) + "╮");
349
+ const bottom = s.fg("rule", "╰" + "─".repeat(total - 2) + "╯");
350
+ const side = s.fg("rule", "│");
351
+ const body = lines.map((l) => side + " " + padEnd(truncate(l, inner), inner) + " " + side);
352
+ return [top, ...body, bottom];
353
+ }
354
+
355
+ /** Compact one-liner for the status bar: `BUILD 13/21 ◐`. */
356
+ export function renderStatusLine(state: WidgetState, g: GlyphSet = detectGlyphs()): string {
357
+ const p = computeProgress(state.list);
358
+ if (state.paused) return "paused";
359
+ if (p.tasksTotal === 0) return state.phase ?? "idle";
360
+ const mark =
361
+ p.blocked > 0
362
+ ? g.blocked
363
+ : p.inProgress > 0
364
+ ? g.inProgress
365
+ : p.tasksDone === p.tasksTotal
366
+ ? g.complete
367
+ : g.pending;
368
+ const phase = state.phase ? state.phase + " " : "";
369
+ return phase + p.tasksDone + "/" + p.tasksTotal + " " + mark;
370
+ }