infinity-harness 2.6.0 → 2.6.2

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 CHANGED
@@ -4,6 +4,30 @@ 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.2] — 2026-08-25
8
+
9
+ Publish 2.6.1 left the tarball staged on the registry awaiting an attestation that never arrived; npm
10
+ republish is blocked on a staged version. Bumping to 2.6.2 to clear the staging queue — same code as 2.6.1.
11
+
12
+ ## [2.6.1] — 2026-08-25
13
+
14
+ Every active level now blinks together, subtasks show up where they always should have, and the TUI
15
+ fits the whole chain on one line per parallel lane.
16
+
17
+ ### Fixed
18
+
19
+ - **Dashboard actually blinks the active branch.** Phase rail pulsing was faint and only the rail. Now
20
+ every active box (`goal`, `sprint`, `feature` currently being developed, plus all parallel `task`/`subtask`
21
+ 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`.
22
+
23
+ - **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.
24
+
25
+ - **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.
26
+
27
+ ### Added
28
+
29
+ - `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.
30
+
7
31
  ## [2.6.0] — 2026-08-25
8
32
 
9
33
  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.0",
3
+ "version": "2.6.2",
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": [
@@ -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
- if (mode === "none" || (mode === "active" && !active)) return "";
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
- s.status === "complete" ? GLYPHS.subDone : s.status === "in_progress" ? GLYPHS.subActive : GLYPHS.subPending;
510
- const cls = s.status === "complete" ? "complete" : s.status === "in_progress" ? "active" : "pending";
511
- return `<li class="sub sub-${cls}"><span class="sub-glyph" aria-hidden="true">${esc(glyph)}</span>${esc(s.title ?? "")}</li>`;
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
- const current = isCurrent && !complete;
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
- const activeGoal = activeFeatureId ? group.sprints.some((sg) => sg.features.some((f) => f.id === activeFeatureId)) : false;
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 = activeFeatureId ? group.features.some((f) => f.id === activeFeatureId) : false;
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:.42}
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 blinking for the whole current branch */
981
- .tier.is-current,.feature.is-current{animation:cardPulse 1.4s ease-in-out infinite}
982
- .tier.is-current .tier-name,.feature.is-current .feature-name{animation:textPulse 1.2s ease-in-out infinite}
983
- @keyframes cardPulse{0%,100%{box-shadow:var(--shadow)}50%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.22),var(--shadow)}}
984
- @keyframes textPulse{0%,100%{opacity:1}50%{opacity:.65}}
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 goals = state.list.goals ?? [];
416
- const headline = !display.levels.goal
415
+ const _goals = state.list.goals ?? [];
416
+ const _headline = !display.levels.goal
417
417
  ? null
418
- : goals.length === 1
419
- ? (goals[0]?.title ?? null)
420
- : goals.length === 0
418
+ : _goals.length === 1
419
+ ? (_goals[0]?.title ?? null)
420
+ : _goals.length === 0
421
421
  ? (state.intake ?? null)
422
422
  : null;
423
- if (headline) {
424
- const wrapped = wrap(headline, inner - 2);
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
- // -- the plan -------------------------------------------------------------
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