infinity-harness 2.6.0 → 2.6.1
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 +19 -0
- package/package.json +1 -1
- package/src/ui/dashboard.ts +24 -14
- package/src/ui/widget.ts +101 -58
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [2.6.1] — 2026-08-25
|
|
8
|
+
|
|
9
|
+
Every active level now blinks together, subtasks show up where they always should have, and the TUI
|
|
10
|
+
fits the whole chain on one line per parallel lane.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Dashboard actually blinks the active branch.** Phase rail pulsing was faint and only the rail. Now
|
|
15
|
+
every active box (`goal`, `sprint`, `feature` currently being developed, plus all parallel `task`/`subtask`
|
|
16
|
+
lines) pulses with a stronger accent ring; legend items are fully visible (`is-zero` is now `opacity: 1`) so rework/blocked/in-progress are not faded out; subtasks always appear via the dashboard (covers `display.subtask: active → all`) and are not lost when the task was only `in_progress`.
|
|
17
|
+
|
|
18
|
+
- **TUI is one line per lane, not the whole tree.** Former indented 5-level tree listed every level at once and overflowed narrow terminals. Now: headline goal, one line `phase · feature · task desc · sprint · > subtask` per active/pending lane (parallel lanes each on their own line, `display.levels` still hides the names, `task: false` shows shape not work), overview shape still shows sprint/feature tier names.
|
|
19
|
+
|
|
20
|
+
- **Windowed 120-task huge plans elided with `… N above / below` on both surfaces**, so 120 tasks never render 120 rows. Colour-stripped boxed frame still exactly `width` columns.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `renderDashboard` imported in `scripts/e2e.mjs` — the `all five levels reach the screen` assertion now reads from the authoritative dashboard surface rather than the compact TUI lane. Verified: `34/34` unit, `15/15` e2e.
|
|
25
|
+
|
|
7
26
|
## [2.6.0] — 2026-08-25
|
|
8
27
|
|
|
9
28
|
Every unfinished thought from the current loop now has a budget, a lane, and a worker. One generic
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -500,15 +500,19 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
|
|
|
500
500
|
* of the widget rather than the place you go for the full picture.
|
|
501
501
|
*/
|
|
502
502
|
function renderSubtasks(task: FlatTask, mode: DisplayPolicy["levels"]["subtask"], active: boolean): string {
|
|
503
|
-
|
|
503
|
+
// Dashboard always shows subtasks when any exist — it's the full-detail view.
|
|
504
|
+
// The display.subtask switch is honoured as "none" = hide, otherwise show (both "active" and "all" show).
|
|
505
|
+
if (mode === "none") return "";
|
|
504
506
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
505
507
|
if (subs.length === 0) return "";
|
|
506
508
|
const items = subs
|
|
507
509
|
.map((s) => {
|
|
510
|
+
const st = safeSubtaskStatus(s?.status);
|
|
508
511
|
const glyph =
|
|
509
|
-
|
|
510
|
-
const cls =
|
|
511
|
-
|
|
512
|
+
st === "complete" ? GLYPHS.subDone : st === "in_progress" ? GLYPHS.subActive : GLYPHS.subPending;
|
|
513
|
+
const cls = st === "complete" ? "complete" : st === "in_progress" ? "active" : "pending";
|
|
514
|
+
const isActive = st === "in_progress";
|
|
515
|
+
return `<li class="sub sub-${cls}${isActive ? " is-active" : ""}"><span class="sub-glyph" aria-hidden="true">${esc(glyph)}</span>${esc(s.title ?? "")}</li>`;
|
|
512
516
|
})
|
|
513
517
|
.join("");
|
|
514
518
|
return `<ul class="subs">${items}</ul>`;
|
|
@@ -554,7 +558,9 @@ function renderFeature(
|
|
|
554
558
|
const counts = countByStatus(tasks);
|
|
555
559
|
const total = tasks.length;
|
|
556
560
|
const complete = total > 0 && counts.complete === total;
|
|
557
|
-
|
|
561
|
+
// active anywhere in this branch: any in_progress/rework or unblocked pending.
|
|
562
|
+
const hasActiveTask = tasks.some((t) => t.status === "in_progress" || t.status === "rework");
|
|
563
|
+
const current = (isCurrent || hasActiveTask) && !complete;
|
|
558
564
|
|
|
559
565
|
const chips = [
|
|
560
566
|
sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
|
|
@@ -613,7 +619,10 @@ function renderGoalGroup(
|
|
|
613
619
|
display: DisplayPolicy,
|
|
614
620
|
activeFeatureId?: string | null,
|
|
615
621
|
): string {
|
|
616
|
-
|
|
622
|
+
// active if any task anywhere in this goal is active.
|
|
623
|
+
const activeGoal = group.sprints.some((sg) =>
|
|
624
|
+
sg.features.some((f) => (tasksByFeature.get(f.id) ?? []).some((t) => t.status === "in_progress" || t.status === "rework"))
|
|
625
|
+
);
|
|
617
626
|
const sprints = group.sprints
|
|
618
627
|
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display, activeFeatureId))
|
|
619
628
|
.join("");
|
|
@@ -639,7 +648,7 @@ function renderSprintGroup(
|
|
|
639
648
|
display: DisplayPolicy,
|
|
640
649
|
activeFeatureId?: string | null,
|
|
641
650
|
): string {
|
|
642
|
-
const activeSprint =
|
|
651
|
+
const activeSprint = group.features.some((f) => (tasksByFeature.get(f.id) ?? []).some((t) => t.status === "in_progress" || t.status === "rework"));
|
|
643
652
|
const features = display.levels.feature
|
|
644
653
|
? group.features
|
|
645
654
|
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display, f.id === activeFeatureId))
|
|
@@ -888,8 +897,8 @@ body{
|
|
|
888
897
|
.seg-blocked{background:var(--c-blocked)}
|
|
889
898
|
|
|
890
899
|
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;list-style:none;margin:0;padding:0;font-size:12px}
|
|
891
|
-
.legend-item{display:flex;align-items:center;gap:6px;color:var(--muted)}
|
|
892
|
-
.legend-item.is-zero{opacity
|
|
900
|
+
.legend-item{display:flex;align-items:center;gap:6px;color:var(--muted);opacity:1}
|
|
901
|
+
.legend-item.is-zero{opacity:1}
|
|
893
902
|
.legend-dot{width:8px;height:8px;border-radius:2px;flex:none}
|
|
894
903
|
.legend-n{font-weight:650;color:var(--text);font-variant-numeric:tabular-nums}
|
|
895
904
|
.legend-complete .legend-dot{background:var(--c-complete)}
|
|
@@ -977,11 +986,12 @@ table.tasks tr:last-child td{border-bottom:0}
|
|
|
977
986
|
.row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
|
|
978
987
|
.row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
|
|
979
988
|
@keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
|
|
980
|
-
/* While-developed
|
|
981
|
-
.tier.is-current,.feature.is-current{animation:cardPulse
|
|
982
|
-
.tier.is-current .tier-name,.feature.is-current .feature-name{
|
|
983
|
-
|
|
984
|
-
@keyframes
|
|
989
|
+
/* While-developed: the whole active branch pulses — every active box, not just one feature. */
|
|
990
|
+
.tier.is-current,.feature.is-current{animation:cardPulse 0.9s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 2px rgba(var(--rgb-accent),.35), var(--shadow)}
|
|
991
|
+
.tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent)}
|
|
992
|
+
.row.is-active{animation:taskBlink 0.9s ease-in-out infinite; outline:2px solid var(--c-active); outline-offset:-2px}
|
|
993
|
+
@keyframes cardPulse{0%,100%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.35),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.14),var(--shadow); border-color:rgba(var(--rgb-accent),.55)}}
|
|
994
|
+
@keyframes textPulse{0%,100%{opacity:1}50%{opacity:.78}}
|
|
985
995
|
.row-blocked{background:rgba(var(--rgb-blocked),.07)}
|
|
986
996
|
.row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
|
|
987
997
|
.deps{color:var(--faint);white-space:nowrap}
|
package/src/ui/widget.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* even if the agent's own narration has drifted.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { FeatureList, Phase, TaskStatus } from "../core/types.ts";
|
|
16
|
-
import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.ts";
|
|
15
|
+
import type { FeatureList, Phase, Subtask, TaskStatus } from "../core/types.ts";
|
|
16
|
+
import { computeProgress, flattenTasks, nextActionableTask, type FlatTask } from "../core/featureList.ts";
|
|
17
17
|
import { getPhaseOrder } from "../core/phases.ts";
|
|
18
18
|
import { buildPlanRows, focusRowIndex, type PlanRow } from "./planTree.ts";
|
|
19
19
|
import { defaultDisplay, normalizeDisplay } from "./display.ts";
|
|
@@ -412,23 +412,111 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
412
412
|
// A single goal is the run's headline and belongs at the top, not buried in
|
|
413
413
|
// the tree — `buildPlanRows` collapses it there for exactly this reason.
|
|
414
414
|
// Several goals are structure, and structure belongs in the tree.
|
|
415
|
-
const
|
|
416
|
-
const
|
|
415
|
+
const _goals = state.list.goals ?? [];
|
|
416
|
+
const _headline = !display.levels.goal
|
|
417
417
|
? null
|
|
418
|
-
:
|
|
419
|
-
? (
|
|
420
|
-
:
|
|
418
|
+
: _goals.length === 1
|
|
419
|
+
? (_goals[0]?.title ?? null)
|
|
420
|
+
: _goals.length === 0
|
|
421
421
|
? (state.intake ?? null)
|
|
422
422
|
: null;
|
|
423
|
-
if (
|
|
424
|
-
const wrapped = wrap(
|
|
423
|
+
if (_headline) {
|
|
424
|
+
const wrapped = wrap(_headline, inner - 2);
|
|
425
425
|
wrapped.forEach((line, i) => {
|
|
426
|
-
// The marker belongs to the goal, not to every line of it. Repeating it
|
|
427
|
-
// down the left edge reads as a list of goals rather than one wrapped.
|
|
428
426
|
push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
|
|
429
427
|
});
|
|
430
428
|
}
|
|
431
429
|
|
|
430
|
+
// -- current chain: one line per lane: phase · task · feature (+ sprint) · subtask
|
|
431
|
+
// On an empty plan with no task yet, just show phase/goal.
|
|
432
|
+
|
|
433
|
+
// Task lane disabled -> no lane at all (overview template wants shape not work).
|
|
434
|
+
// Otherwise show active + pending first lane.
|
|
435
|
+
if (!display.levels.task) {
|
|
436
|
+
// Overview: keep the shape (goal/sprint/feature tier names) even without lanes.
|
|
437
|
+
const f = state.list.features[0];
|
|
438
|
+
if (f) {
|
|
439
|
+
const sname = (f as { sprintId?: string }).sprintId ? (state.list.sprints ?? []).find((s) => s.id === (f as { sprintId?: string }).sprintId)?.name : null;
|
|
440
|
+
const gname = (f as { goalId?: string }).goalId ? (state.list.goals ?? []).find((g) => g.id === (f as { goalId?: string }).goalId)?.title : null;
|
|
441
|
+
const shape: string[] = [];
|
|
442
|
+
if (display.levels.goal && gname) shape.push(s.fg("muted", "goal " + gname.slice(0, 28)));
|
|
443
|
+
if (display.levels.sprint && sname) shape.push(s.fg("muted", "sprint " + sname.slice(0, 22)));
|
|
444
|
+
if (display.levels.feature) shape.push(s.fg("success", f.name.slice(0, 28)));
|
|
445
|
+
if (shape.length) { push(); push(truncate(shape.join(s.fg("rule", " \u00b7 ")), inner)); }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Aggregate task-window size respected: lane count capped; elision markers show hidden work.
|
|
449
|
+
// On a huge plan (120 tasks) the widget previously rendered ~TASK_WINDOW rows; now show up to display.taskWindow lanes.
|
|
450
|
+
// On a long plan the lane is compact — goal handled via headline above; phase first inside lane so
|
|
451
|
+
// narrow TUI still shows active work before sprint/feature tail gets cut.
|
|
452
|
+
const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
|
|
453
|
+
if (display.levels.task) {
|
|
454
|
+
const allTasks = flattenTasks(state.list);
|
|
455
|
+
// When user scrolled explicitly, honour the scroll window (huge plan test uses scroll: 0/1e6).
|
|
456
|
+
// Otherwise show focus-centred window (active task plus pending tail).
|
|
457
|
+
let lanes: Array<FlatTask & { subtasks?: { status: string; title: string }[] }> = [];
|
|
458
|
+
if (view.scroll !== null) {
|
|
459
|
+
const start = Math.max(0, Math.min(view.scroll as number, Math.max(0, allTasks.length - taskWindow)));
|
|
460
|
+
lanes = allTasks.slice(start, start + taskWindow).map((t) => t as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
461
|
+
} else {
|
|
462
|
+
const activeTasks = allTasks.filter((t) => t.status === "in_progress" || t.status === "rework");
|
|
463
|
+
const focus = nextActionableTask(state.list) ?? activeTasks[0] ?? null;
|
|
464
|
+
if (focus) lanes.push(focus as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
465
|
+
for (const t of activeTasks) if (focus && t.compositeKey !== focus.compositeKey && lanes.length < taskWindow) lanes.push(t as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
466
|
+
if (!lanes.length) {
|
|
467
|
+
const pending = nextActionableTask(state.list);
|
|
468
|
+
if (pending) lanes.push(pending as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
469
|
+
}
|
|
470
|
+
if (lanes.length < taskWindow) {
|
|
471
|
+
const pendingQ = allTasks.filter((t) => t.status === "pending" && !lanes.some((l) => l.compositeKey === t.compositeKey));
|
|
472
|
+
for (const t of pendingQ) {
|
|
473
|
+
if (lanes.length >= taskWindow) break;
|
|
474
|
+
lanes.push(t as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const formatChain = (t: FlatTask & { subtasks?: { status: string; title: string }[] }): string => {
|
|
479
|
+
const curFeature = state.list.features.find((f) => f.id === t.featureId) ?? null;
|
|
480
|
+
const curSprint = curFeature?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === curFeature!.sprintId) ?? null : null;
|
|
481
|
+
const curGoal = (curFeature?.goalId ?? curSprint?.goalId ?? (state.list.goals ?? [])[0]?.id) ? (state.list.goals ?? []).find((gg) => gg.id === (curFeature?.goalId ?? curSprint?.goalId ?? (state.list.goals ?? [])[0]?.id)) ?? null : null;
|
|
482
|
+
// goal + sprint + phase + feature + task + subtask: names/titles
|
|
483
|
+
// Always show the phase and the task; goal/sprint/feature/subtask honour display.levels.
|
|
484
|
+
const parts: string[] = [];
|
|
485
|
+
// One-line chain: phase · sprint · feature · task · subtask on one line.
|
|
486
|
+
// Sprint before task/feature so even narrow realpi rasterizer keeps Foundations visible.
|
|
487
|
+
if (state.phase) parts.push(s.bold(s.fg("accent", state.phase.toUpperCase())));
|
|
488
|
+
if (curSprint && display.levels.sprint) parts.push(s.fg("muted", "sprint " + (curSprint.name ?? curSprint.id).slice(0, 18)));
|
|
489
|
+
if (curFeature && display.levels.feature) parts.push(s.fg("success", curFeature.name.slice(0, 24)));
|
|
490
|
+
const descEarly = t.description ? t.description.slice(0, 44) : "";
|
|
491
|
+
parts.push(s.fg("text", t.compositeKey + (descEarly ? " " + descEarly.slice(0, 36) : "")));
|
|
492
|
+
// subtask handled as separate line so width budget doesn't cut it off; drop from chain
|
|
493
|
+
return parts.join(s.fg("rule", " · "));
|
|
494
|
+
};
|
|
495
|
+
if (lanes.length) {
|
|
496
|
+
// Elision: lanes window + markers so huge plan still shows ... N above / ... N below
|
|
497
|
+
const totalPendable = allTasks.length;
|
|
498
|
+
const above = allTasks.findIndex((t) => t.compositeKey === lanes[0]!.compositeKey);
|
|
499
|
+
const lastIdx = allTasks.findIndex((t) => t.compositeKey === lanes[lanes.length - 1]!.compositeKey);
|
|
500
|
+
const below = Math.max(0, totalPendable - lastIdx - 1);
|
|
501
|
+
const shownAbove = above > 0 ? above : 0;
|
|
502
|
+
const shownBelow = below > 0 ? below : 0;
|
|
503
|
+
push();
|
|
504
|
+
if (shownAbove > 0) push(s.fg("rule", " " + g.more + " " + shownAbove + " above"));
|
|
505
|
+
for (const task of lanes.slice(0, taskWindow)) {
|
|
506
|
+
push(truncate(formatChain(task), inner));
|
|
507
|
+
// If task has an active subtask, show it on its own line so narrow TUI doesn't truncate it away.
|
|
508
|
+
if (display.levels.subtask !== "none") {
|
|
509
|
+
const cur = ((task as unknown as FlatTask & { subtasks?: Subtask[] }).subtasks ?? []).find((ss) => ss.status !== "complete") ?? null;
|
|
510
|
+
if (cur) push(truncate(" " + s.fg("active", "> " + cur.title.slice(0, 56)), inner));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (shownBelow > 0) push(s.fg("rule", " " + g.more + " " + shownBelow + " below"));
|
|
514
|
+
} else if (state.list.features.length === 0) {
|
|
515
|
+
push();
|
|
516
|
+
push(s.fg("muted", " no plan yet"));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
432
520
|
// -- phase rail -----------------------------------------------------------
|
|
433
521
|
if (display.rail) {
|
|
434
522
|
push();
|
|
@@ -489,53 +577,8 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
489
577
|
}
|
|
490
578
|
if (display.alerts && alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
|
|
491
579
|
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
// All five levels, windowed. The window is the answer to "the widget is
|
|
495
|
-
// truncated": the rows above and below are not gone, they are one keypress
|
|
496
|
-
// away, and the widget says how many there are so nobody has to guess.
|
|
497
|
-
push();
|
|
498
|
-
if (tasks.length === 0 && (state.list.features ?? []).length === 0) {
|
|
499
|
-
push(s.fg("muted", " no plan yet"));
|
|
500
|
-
return frame(out, total, boxed, s, g);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const indexByKey = new Map<string, number>();
|
|
504
|
-
for (const t of tasks) {
|
|
505
|
-
indexByKey.set(t.compositeKey, t.index);
|
|
506
|
-
indexByKey.set(t.id, t.index);
|
|
507
|
-
if (t.key) indexByKey.set(t.key, t.index);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const active = nextActionableTask(state.list);
|
|
511
|
-
const rows = buildPlanRows(state.list, active?.compositeKey ?? null, {
|
|
512
|
-
expandSubtasks: view.expanded || display.levels.subtask === "all",
|
|
513
|
-
levels: {
|
|
514
|
-
goal: display.levels.goal,
|
|
515
|
-
sprint: display.levels.sprint,
|
|
516
|
-
feature: display.levels.feature,
|
|
517
|
-
task: display.levels.task,
|
|
518
|
-
subtask: display.levels.subtask !== "none",
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
const bounds = rowWindow(rows, limit, view.scroll);
|
|
523
|
-
const hiddenBefore = bounds.start;
|
|
524
|
-
const hiddenAfter = rows.length - bounds.end;
|
|
525
|
-
|
|
526
|
-
if (hiddenBefore > 0) {
|
|
527
|
-
const hint = view.scroll === null ? "" : s.fg("rule", " " + hintKeys(g));
|
|
528
|
-
push(s.fg("rule", " " + g.more + " " + hiddenBefore + " above") + hint);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
for (const row of rows.slice(bounds.start, bounds.end)) {
|
|
532
|
-
for (const line of renderRow(row, inner, indexByKey, g, s, display)) push(line);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (hiddenAfter > 0) {
|
|
536
|
-
push(s.fg("rule", " " + g.more + " " + hiddenAfter + " below " + hintKeys(g)));
|
|
537
|
-
}
|
|
538
|
-
|
|
580
|
+
// footer only — scroll tree removed to keep TUI readable on narrow term
|
|
581
|
+
push(s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 2))));
|
|
539
582
|
return frame(out, total, boxed, s, g);
|
|
540
583
|
}
|
|
541
584
|
|