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.
- package/CHANGELOG.md +114 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/extensions/infinity-harness/index.ts +870 -0
- package/harness/docs/ARCHITECTURE.md +159 -0
- package/harness/docs/CONSTRAINTS.md +19 -0
- package/harness/docs/DECISIONS.md +107 -0
- package/harness/docs/DOMAIN.md +13 -0
- package/harness/docs/agents/evaluator.md +14 -0
- package/harness/docs/agents/generator.md +13 -0
- package/harness/docs/agents/planner.md +13 -0
- package/harness/docs/agents/simplifier.md +13 -0
- package/harness/docs/api-patterns.md +23 -0
- package/harness/docs/phases/build.md +47 -0
- package/harness/docs/phases/define.md +58 -0
- package/harness/docs/phases/plan.md +50 -0
- package/harness/docs/phases/review.md +47 -0
- package/harness/docs/phases/ship.md +43 -0
- package/harness/docs/phases/simplify.md +45 -0
- package/harness/docs/phases/verify.md +46 -0
- package/harness/model-router.json +28 -0
- package/harness/skills/README.md +60 -0
- package/harness/skills/auth-security.md +56 -0
- package/harness/skills/building-mcp-servers.md +70 -0
- package/harness/skills/building-tools.md +60 -0
- package/harness/skills/capability-acquisition.md +72 -0
- package/harness/skills/cli-design.md +55 -0
- package/harness/skills/code-review.md +57 -0
- package/harness/skills/codebase-design.md +70 -0
- package/harness/skills/concurrency-async.md +61 -0
- package/harness/skills/config-and-secrets.md +52 -0
- package/harness/skills/context-hygiene.md +51 -0
- package/harness/skills/databases.md +63 -0
- package/harness/skills/diagnosing-bugs.md +84 -0
- package/harness/skills/domain-modeling.md +65 -0
- package/harness/skills/error-handling-logging.md +56 -0
- package/harness/skills/frontend-ui.md +56 -0
- package/harness/skills/grilling.md +48 -0
- package/harness/skills/http-apis.md +60 -0
- package/harness/skills/performance.md +53 -0
- package/harness/skills/pi-todo-adapted.md +41 -0
- package/harness/skills/planning-tasks.md +86 -0
- package/harness/skills/prototype.md +39 -0
- package/harness/skills/research.md +32 -0
- package/harness/skills/resolving-merge-conflicts.md +30 -0
- package/harness/skills/scope-discipline.md +49 -0
- package/harness/skills/self-review.md +45 -0
- package/harness/skills/stuck-protocol.md +51 -0
- package/harness/skills/tdd.md +80 -0
- package/harness/skills/testing-infra.md +57 -0
- package/harness/skills/writing-skills.md +60 -0
- package/package.json +61 -0
- package/src/core/brief.ts +242 -0
- package/src/core/config.ts +265 -0
- package/src/core/exec.ts +130 -0
- package/src/core/featureList.ts +286 -0
- package/src/core/fsx.ts +119 -0
- package/src/core/gates.ts +444 -0
- package/src/core/lock.ts +192 -0
- package/src/core/paths.ts +95 -0
- package/src/core/phases.ts +143 -0
- package/src/core/settings.ts +445 -0
- package/src/core/types.ts +245 -0
- package/src/goalLoop.ts +628 -0
- package/src/goalSpec.ts +679 -0
- package/src/goalState.ts +338 -0
- package/src/loop.ts +355 -0
- package/src/modelRouter.ts +184 -0
- package/src/remote.ts +244 -0
- package/src/replan.ts +300 -0
- package/src/review.ts +53 -0
- package/src/rework.ts +274 -0
- package/src/taskList.ts +355 -0
- package/src/ui/config.ts +286 -0
- package/src/ui/dashboard.ts +1066 -0
- package/src/ui/theme.ts +317 -0
- package/src/ui/widget.ts +370 -0
- package/src/unstuck.ts +214 -0
- package/src/worker.ts +351 -0
- package/types/proper-lockfile.d.ts +19 -0
package/src/ui/theme.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ui/widget.ts
ADDED
|
@@ -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
|
+
}
|