infinity-harness 2.2.0 → 2.3.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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * infinity-harness — the plan, flattened into displayable rows.
3
+ *
4
+ * The plan on disk has five levels: goal → sprint → feature → task → subtask.
5
+ * Only two of them used to reach the human. The widget drew features and
6
+ * tasks; goals appeared as a single title line; sprints appeared nowhere at
7
+ * all, so a plan organised into sprints looked, on screen, exactly like a plan
8
+ * that was not. Subtasks showed up only under the one task being worked.
9
+ *
10
+ * The fix is one shared model of what the plan looks like, consumed by every
11
+ * surface that draws it. The TUI windows these rows; the dashboard renders all
12
+ * of them. Neither one walks the plan itself, so the two cannot drift — which
13
+ * is the same rule the rest of this package follows for the same reason.
14
+ *
15
+ * Rows are pure data: no colour, no glyphs, no width. Presentation belongs to
16
+ * the surface doing the drawing.
17
+ */
18
+
19
+ import type { Feature, FeatureList, Goal, Sprint, Subtask, Task } from "../core/types.ts";
20
+ import { isDone } from "../core/featureList.ts";
21
+
22
+ export type PlanLevel = "goal" | "sprint" | "feature" | "task" | "subtask";
23
+
24
+ export type PlanRow = {
25
+ level: PlanLevel;
26
+ /** 0 for the outermost level actually present, so a plan with no goals is not indented. */
27
+ depth: number;
28
+ /** Stable identifier within the plan — `goal-001`, `feature-001/task-004`, … */
29
+ id: string;
30
+ /** Short label shown before the title: an id, or a task's plan number. */
31
+ label: string;
32
+ title: string;
33
+ /** Task and subtask statuses; null for the grouping levels. */
34
+ status: string | null;
35
+ /** `3/7` for grouping levels — how much of this branch is finished. */
36
+ done: number;
37
+ total: number;
38
+ /** Task-level extras. */
39
+ dependsOn?: string[];
40
+ criteria?: string[];
41
+ difficulty?: string | null;
42
+ /** 1-based position of a task in the flattened plan, for `← #3` labels. */
43
+ index?: number;
44
+ /** True when this row is the task the pipeline is working on right now. */
45
+ active?: boolean;
46
+ /** Which task row a subtask belongs to. */
47
+ parentId?: string;
48
+ };
49
+
50
+ export type PlanTreeOptions = {
51
+ /** Show subtasks for every task, not only the active one. */
52
+ expandSubtasks?: boolean;
53
+ /**
54
+ * Skip grouping levels that carry no information. A plan with one unnamed
55
+ * goal and no sprints should not spend two rows saying so.
56
+ */
57
+ collapseTrivial?: boolean;
58
+ };
59
+
60
+ function taskCounts(tasks: Task[] | undefined): { done: number; total: number } {
61
+ const list = tasks ?? [];
62
+ return { done: list.filter((t) => isDone(t.status as never)).length, total: list.length };
63
+ }
64
+
65
+ function subtaskRows(
66
+ task: Task,
67
+ taskId: string,
68
+ depth: number,
69
+ subtasks: Subtask[],
70
+ ): PlanRow[] {
71
+ return subtasks.map((s, i) => ({
72
+ level: "subtask" as const,
73
+ depth,
74
+ id: `${taskId}#${s.id ?? i + 1}`,
75
+ label: "",
76
+ title: s.title ?? "",
77
+ status: s.status ?? "pending",
78
+ done: s.status === "complete" ? 1 : 0,
79
+ total: 1,
80
+ parentId: taskId,
81
+ }));
82
+ }
83
+
84
+ export type PlanSprintGroup = {
85
+ sprint: Sprint | null;
86
+ features: Feature[];
87
+ done: number;
88
+ total: number;
89
+ };
90
+
91
+ export type PlanGoalGroup = {
92
+ goal: Goal | null;
93
+ sprints: PlanSprintGroup[];
94
+ done: number;
95
+ total: number;
96
+ };
97
+
98
+ function sumTasks(features: Feature[]): { done: number; total: number } {
99
+ return features.reduce(
100
+ (acc, f) => {
101
+ const c = taskCounts(f.tasks);
102
+ return { done: acc.done + c.done, total: acc.total + c.total };
103
+ },
104
+ { done: 0, total: 0 },
105
+ );
106
+ }
107
+
108
+ /**
109
+ * The plan's real shape: goals holding sprints holding features.
110
+ *
111
+ * Both the widget and the dashboard group by this, so the tree in the terminal
112
+ * and the tree in the browser cannot disagree about where a feature belongs.
113
+ * A feature with no sprint sits in a nameless sprint group; a feature with no
114
+ * goal sits in a nameless goal group. Nothing is dropped for lacking a parent,
115
+ * including a feature pointing at a goal or sprint that has been deleted —
116
+ * silently hiding it is how a task nobody can see gets stuck forever.
117
+ */
118
+ export function groupPlan(list: FeatureList): PlanGoalGroup[] {
119
+ const goals = list.goals ?? [];
120
+ const sprints = list.sprints ?? [];
121
+ const features = list.features ?? [];
122
+
123
+ const goalIds = new Set(goals.map((g) => g.id));
124
+ const sprintIds = new Set(sprints.map((s) => s.id));
125
+ const claimed = new Set<string>();
126
+
127
+ const groupFor = (goal: Goal | null, ownSprints: Sprint[], direct: Feature[]): PlanGoalGroup => {
128
+ const sprintGroups: PlanSprintGroup[] = ownSprints.map((sprint) => {
129
+ const owned = features.filter((f) => f.sprintId === sprint.id);
130
+ for (const f of owned) claimed.add(f.id);
131
+ const c = sumTasks(owned);
132
+ return { sprint, features: owned, done: c.done, total: c.total };
133
+ });
134
+ if (direct.length) {
135
+ for (const f of direct) claimed.add(f.id);
136
+ const c = sumTasks(direct);
137
+ sprintGroups.push({ sprint: null, features: direct, done: c.done, total: c.total });
138
+ }
139
+ const c = sumTasks(sprintGroups.flatMap((sg) => sg.features));
140
+ return { goal, sprints: sprintGroups, done: c.done, total: c.total };
141
+ };
142
+
143
+ const out: PlanGoalGroup[] = goals.map((goal) =>
144
+ groupFor(
145
+ goal,
146
+ sprints.filter((s) => s.goalId === goal.id),
147
+ features.filter((f) => f.goalId === goal.id && (!f.sprintId || !sprintIds.has(f.sprintId))),
148
+ ),
149
+ );
150
+
151
+ // Sprints with no goal, and features with neither.
152
+ const orphanSprints = sprints.filter((s) => !s.goalId || !goalIds.has(s.goalId));
153
+ const orphanFeatures = features.filter((f) => !claimed.has(f.id) && !orphanSprints.some((s) => s.id === f.sprintId));
154
+ if (orphanSprints.length || orphanFeatures.length) {
155
+ out.push(groupFor(null, orphanSprints, orphanFeatures));
156
+ }
157
+
158
+ // A last sweep for anything the passes above still missed.
159
+ const missed = features.filter((f) => !claimed.has(f.id));
160
+ if (missed.length) {
161
+ const c = sumTasks(missed);
162
+ out.push({
163
+ goal: null,
164
+ sprints: [{ sprint: null, features: missed, done: c.done, total: c.total }],
165
+ done: c.done,
166
+ total: c.total,
167
+ });
168
+ for (const f of missed) claimed.add(f.id);
169
+ }
170
+
171
+ return out.filter((g) => g.goal !== null || g.sprints.some((sg) => sg.features.length > 0 || sg.sprint !== null));
172
+ }
173
+
174
+ /**
175
+ * Flatten a plan into rows, in reading order.
176
+ *
177
+ * A grouping level with nothing to say is skipped: one unnamed goal and no
178
+ * sprints should not cost two rows to communicate nothing. Everything else is
179
+ * drawn, at the depth it belongs to.
180
+ */
181
+ export function buildPlanRows(
182
+ list: FeatureList,
183
+ activeTaskId: string | null = null,
184
+ options: PlanTreeOptions = {},
185
+ ): PlanRow[] {
186
+ const collapse = options.collapseTrivial !== false;
187
+ const groups = groupPlan(list);
188
+
189
+ // A single goal is the run's headline and the surface draws it separately.
190
+ const showGoals = collapse ? groups.filter((g) => g.goal !== null).length > 1 : groups.some((g) => g.goal);
191
+ const showSprints = groups.some((g) => g.sprints.some((sg) => sg.sprint !== null));
192
+
193
+ const rows: PlanRow[] = [];
194
+ let taskIndex = 0;
195
+
196
+ const emitFeature = (feature: Feature, depth: number): void => {
197
+ const counts = taskCounts(feature.tasks);
198
+ rows.push({
199
+ level: "feature",
200
+ depth,
201
+ id: feature.id,
202
+ label: feature.id,
203
+ title: feature.name ?? feature.id,
204
+ status: null,
205
+ done: counts.done,
206
+ total: counts.total,
207
+ criteria: Array.isArray(feature.criteria) ? feature.criteria : undefined,
208
+ });
209
+
210
+ for (const task of feature.tasks ?? []) {
211
+ taskIndex += 1;
212
+ const id = task.key ?? `${feature.id}/${task.id}`;
213
+ const active = activeTaskId !== null && (id === activeTaskId || task.id === activeTaskId);
214
+ rows.push({
215
+ level: "task",
216
+ depth: depth + 1,
217
+ id,
218
+ label: String(taskIndex),
219
+ title: task.description ?? id,
220
+ status: task.status,
221
+ done: isDone(task.status) ? 1 : 0,
222
+ total: 1,
223
+ dependsOn: Array.isArray(task.dependsOn) && task.dependsOn.length ? task.dependsOn : undefined,
224
+ criteria: Array.isArray(task.criteria) && task.criteria.length ? task.criteria : undefined,
225
+ difficulty: typeof task.difficulty === "string" ? task.difficulty : null,
226
+ index: taskIndex,
227
+ active,
228
+ });
229
+
230
+ // Subtasks follow the work. Showing them for every task fills the
231
+ // window with detail nobody is acting on; showing them only for the
232
+ // caller's "active" key hides them from any surface that does not track
233
+ // one, so a task that is plainly in progress counts too.
234
+ const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
235
+ const working = task.status === "in_progress" || task.status === "rework";
236
+ const show = options.expandSubtasks || active || working;
237
+ if (show && subs.length) rows.push(...subtaskRows(task, id, depth + 2, subs));
238
+ }
239
+ };
240
+
241
+ for (const group of groups) {
242
+ let depth = 0;
243
+ if (showGoals && group.goal) {
244
+ rows.push({
245
+ level: "goal",
246
+ depth: 0,
247
+ id: group.goal.id,
248
+ label: group.goal.id,
249
+ title: group.goal.title ?? group.goal.id,
250
+ status: null,
251
+ done: group.done,
252
+ total: group.total,
253
+ });
254
+ depth = 1;
255
+ }
256
+ for (const sg of group.sprints) {
257
+ let featureDepth = depth;
258
+ if (showSprints && sg.sprint) {
259
+ rows.push({
260
+ level: "sprint",
261
+ depth,
262
+ id: sg.sprint.id,
263
+ label: sg.sprint.id,
264
+ title: sg.sprint.name ?? sg.sprint.id,
265
+ status: null,
266
+ done: sg.done,
267
+ total: sg.total,
268
+ });
269
+ featureDepth = depth + 1;
270
+ }
271
+ for (const f of sg.features) emitFeature(f, featureDepth);
272
+ }
273
+ }
274
+
275
+ return rows;
276
+ }
277
+
278
+ /** Rows the human is most likely to want in view: the active task, or the first open one. */
279
+ export function focusRowIndex(rows: PlanRow[]): number {
280
+ const active = rows.findIndex((r) => r.level === "task" && r.active);
281
+ if (active !== -1) return active;
282
+ for (const status of ["in_progress", "rework", "blocked", "pending"]) {
283
+ const i = rows.findIndex((r) => r.level === "task" && r.status === status);
284
+ if (i !== -1) return i;
285
+ }
286
+ return Math.max(0, rows.length - 1);
287
+ }
package/src/ui/theme.ts CHANGED
@@ -263,6 +263,11 @@ export type GlyphSet = {
263
263
  arrow: string;
264
264
  more: string;
265
265
  branch: string;
266
+ goal: string;
267
+ sprint: string;
268
+ treeMid: string;
269
+ treeEnd: string;
270
+ treeBar: string;
266
271
  phaseDone: string;
267
272
  phaseCurrent: string;
268
273
  phaseTodo: string;
@@ -283,6 +288,11 @@ export const UNICODE_GLYPHS: GlyphSet = {
283
288
  arrow: "←",
284
289
  more: "⋯",
285
290
  branch: "▸",
291
+ goal: "◈",
292
+ sprint: "▤",
293
+ treeMid: "├─",
294
+ treeEnd: "└─",
295
+ treeBar: "│ ",
286
296
  phaseDone: "●",
287
297
  phaseCurrent: "◉",
288
298
  phaseTodo: "○",
@@ -303,6 +313,11 @@ export const ASCII_GLYPHS: GlyphSet = {
303
313
  arrow: "<-",
304
314
  more: "...",
305
315
  branch: ">",
316
+ goal: "#",
317
+ sprint: "=",
318
+ treeMid: "|-",
319
+ treeEnd: "`-",
320
+ treeBar: "| ",
306
321
  phaseDone: "x",
307
322
  phaseCurrent: "O",
308
323
  phaseTodo: "o",