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.
- package/CHANGELOG.md +130 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +742 -51
- package/harness/docs/agents/researcher.md +14 -0
- package/harness/docs/phases/research.md +64 -0
- package/harness/skills/grilling.md +1 -1
- package/harness/skills/research.md +1 -1
- package/package.json +2 -2
- package/src/approval.ts +194 -0
- package/src/core/brief.ts +51 -2
- package/src/core/config.ts +32 -13
- package/src/core/fsx.ts +10 -0
- package/src/core/gates.ts +15 -0
- package/src/core/init.ts +43 -2
- package/src/core/paths.ts +14 -0
- package/src/core/settings.ts +56 -0
- package/src/core/types.ts +65 -2
- package/src/goal.ts +11 -0
- package/src/handoff.ts +197 -0
- package/src/intake.ts +263 -0
- package/src/loop.ts +88 -10
- package/src/remote.ts +22 -1
- package/src/runState.ts +121 -0
- package/src/ui/dashboard.ts +140 -16
- package/src/ui/planTree.ts +287 -0
- package/src/ui/theme.ts +15 -0
- package/src/ui/widget.ts +230 -67
- package/src/ui/wizard.ts +195 -0
|
@@ -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",
|