infinity-harness 2.8.4 → 2.8.6

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,34 @@ 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.8.6] — 2026-08-31
8
+
9
+ Parked stays parked.
10
+
11
+ ### Fixed
12
+
13
+ - **Main session started harness work without a widget after declining the final wizard prompt.** The post-wizard "parked" note was sent as `sendUserMessage(..., { deliverAs: "followUp" })`, which intentionally wakes the agent on the main-session model. Changed to `sendMessage(..., { triggerTurn: false })` (visible `infinity:brief` with `parked: true` but no turn) and hardened the control-panel system prompt to explicitly say "PARKED — do NOT start building/researching/validating; only answer questions; only `/infinity:run` starts the harness; background uses default/tiered models, not the main session model" when not armed. Session now simply does nothing until `/infinity:run`.
14
+
15
+ ## [2.8.5] — 2026-08-31
16
+
17
+ Richer top widget, one panel.
18
+
19
+ ### Changed
20
+
21
+ - **Widget header is now coloured and sectioned.** ─── separators between bands; each band has its own colour (brand/accent/muted/rule) so the header reads as distinct sections even with NO\_COLOR.
22
+
23
+ ### Fixed
24
+
25
+ - **Dashboard URL now shows the real port from the start.** Pulls the live `daemon.json:port` when no fallback remote is running, so ":PORT" never appears. The link is a clickable OSC 8 hyperlink and width/ANSI handling now accounts for it (no truncation at 76 cols).
26
+
27
+ - **Widget now shows goal counts and the active goal on its own line.** "◈ Goals: N" sits right under the Dashboard line (brand colour); the active goal headline (the single goal or, for multi-goal plans, the goal owning the next task) appears before the phase rail rather than buried inside the task lanes.
28
+
29
+ - **Phase rail sits with the active goal and highlights failures.** "● define ─ ⚠ plan ─ ◉ BUILD ─ ○ verify" — the current phase is bold accent, passed phases are success/green, failed phases (from `gateHistory`) are blocked/red with "⚠" so a gate failure is obvious from the rail. Narrow terminals still elide correctly.
30
+
31
+ - **Active-phase breakdown under the rail.** One line per your spec: "BUILD · 2 features · 1/4 tasks" (phase name · accent feature count · text task progress). Shown when `display.counts` is on; respects the display preset.
32
+
33
+ - **Whole-project progress bar with goal count.** "1/4 tasks · 0/2 features · 2 goals" alongside the "■30%" bar. Displayed when `display.progress` is on; the old duplicate rail+progress block that lived after the task lanes has been removed so there is no second copy.
34
+
7
35
  ## [2.8.4] — 2026-08-31
8
36
 
9
37
  Single scrollable widget.
@@ -288,7 +288,15 @@ export default function (pi: ExtensionAPI): void {
288
288
  worker: l.worker,
289
289
  text: l.text,
290
290
  })),
291
- dashboardUrl: remoteServer?.url ?? null,
291
+ dashboardUrl: (() => {
292
+ if (remoteServer?.url) return remoteServer.url;
293
+ try {
294
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
295
+ const port = d?.port;
296
+ if (typeof port === "number" && port > 0) return `http://127.0.0.1:${port}/dashboard`;
297
+ } catch {}
298
+ return null;
299
+ })(),
292
300
  handoffModelNote,
293
301
  sessions: run?.sessions ?? null,
294
302
  intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
@@ -296,6 +304,7 @@ export default function (pi: ExtensionAPI): void {
296
304
  display: normalizeDisplay(config.display),
297
305
  phase: config.currentPhase,
298
306
  enabledPhases: config.phases?.enabled,
307
+ gateHistory: Array.isArray((config as { gateHistory?: unknown }).gateHistory) ? (config as { gateHistory: import("../../src/core/types.ts").GateHistoryEntry[] }).gateHistory : null,
299
308
  paused: Boolean(config.paused),
300
309
  revision: list.baseRevision,
301
310
  retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
@@ -1924,22 +1933,23 @@ export default function (pi: ExtensionAPI): void {
1924
1933
  if (!dr.spawned) await startEngine(ctx, dir);
1925
1934
  notify(ctx, "infinity-harness: run armed — background run started. /infinity:halt stops it.", "info");
1926
1935
  } else {
1936
+ // The harness is parked — do NOT trigger an agent turn. The
1937
+ // control-panel contract (before_agent_start) already stops
1938
+ // autonomous work, but a `followUp` brief starts one anyway.
1939
+ // `triggerTurn: false` keeps it visible without waking the model.
1927
1940
  const brief = await briefText(dir);
1928
- if (!plan.brief) {
1929
- pi.sendUserMessage(
1930
- `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`,
1931
- { deliverAs: "followUp" },
1932
- );
1933
- } else if (plan.phases[0] === "research") {
1934
- pi.sendUserMessage(
1935
- `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes start the run now" next time you init.`,
1936
- { deliverAs: "followUp" },
1937
- );
1938
- } else {
1939
- pi.sendUserMessage(
1940
- `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`,
1941
- { deliverAs: "followUp" },
1941
+ const parkedNote = !plan.brief
1942
+ ? `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`
1943
+ : plan.phases[0] === "research"
1944
+ ? `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes — start the run now" next time you init.`
1945
+ : `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`;
1946
+ try {
1947
+ pi.sendMessage(
1948
+ { customType: "infinity:brief", content: parkedNote, display: true, details: { parked: true, phase: plan.phases[0] ?? null } },
1949
+ { triggerTurn: false },
1942
1950
  );
1951
+ } catch (e) {
1952
+ notify(ctx, `infinity-harness: ${errMsg(e as Error)}`, "warning");
1943
1953
  }
1944
1954
  }
1945
1955
  },
@@ -3385,19 +3395,47 @@ function controlPanelContract(dir: string): string | null {
3385
3395
  if (!ok || !config.currentPhase) return null;
3386
3396
  const { list } = loadFeatureList(dir);
3387
3397
  const p = computeProgress(list);
3398
+ let armed = false;
3399
+ try {
3400
+ const r = readJsonSafe<{ armed?: boolean } | null>(runStatePathSync(dir), null);
3401
+ armed = r?.armed === true;
3402
+ } catch {}
3403
+ // When parked, the harness does NO work anywhere — not in this session and
3404
+ // not in background sessions. When armed, background sessions do the work
3405
+ // and this session is idle by design (it never spends the human's model).
3406
+ const workLine = armed
3407
+ ? "The work is being done by separate background pi sessions on their own models, not by you."
3408
+ : "The harness is NOT running — nothing is happening in background. Only `/infinity:run` starts it.";
3409
+ const rule1 = armed
3410
+ ? "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the"
3411
+ : "1. Do NOT start building, researching, or validating. The harness is parked. If the human";
3412
+ const rule1b = armed
3413
+ ? " human's questions about the run, and use `/infinity:workers` and `infinity_status`"
3414
+ : " asks you to do harness work anyway, tell them it is parked and needs `/infinity:run`,";
3415
+ const rule1c = armed
3416
+ ? " to see what the background sessions are doing."
3417
+ : " then stop. Do not touch `harness/` files.";
3418
+ const rule2 = armed
3419
+ ? "2. If the human asks you to build something, say that the harness is driving it and offer"
3420
+ : "2. While parked, you may ONLY answer questions about the project — its stack, commands,";
3421
+ const rule2b = armed
3422
+ ? " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead."
3423
+ : " files, and how the harness would run. Do not write code, docs, plans, or validation.";
3424
+ const rule3 = "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.";
3425
+ const extra = armed ? [] : ["", "Parked means parked. No tool calls that mutate the project while parked."];
3388
3426
  return [
3389
- "## infinity-harness — you are the control panel",
3427
+ "## infinity-harness — you are the control panel" + (armed ? "" : " (PARKED)"),
3390
3428
  "",
3391
3429
  `This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
3392
- `${p.tasksDone}/${p.tasksTotal} tasks done. The work is being done by separate background ` +
3393
- `pi sessions on their own models, not by you.`,
3430
+ `${p.tasksDone}/${p.tasksTotal} tasks done. ` + workLine,
3394
3431
  "",
3395
- "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the",
3396
- " human's questions about the run, and use `/infinity:workers` and `infinity_status`",
3397
- " to see what the background sessions are doing.",
3398
- "2. If the human asks you to build something, say that the harness is driving it and offer",
3399
- " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead.",
3400
- "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.",
3432
+ rule1,
3433
+ rule1b,
3434
+ rule1c,
3435
+ rule2,
3436
+ rule2b,
3437
+ rule3,
3438
+ ...extra,
3401
3439
  ].join("\n");
3402
3440
  }
3403
3441
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.8.4",
3
+ "version": "2.8.6",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended — 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/theme.ts CHANGED
@@ -15,9 +15,10 @@ import stringWidth from "string-width";
15
15
  const ESC = "\u001b";
16
16
  const RESET = ESC + "[0m";
17
17
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
18
+ const OSC8_RE = /\u001b\]8;;[^\u0007]*\u0007/g;
18
19
 
19
20
  export function stripAnsi(s: string): string {
20
- return s.replace(ANSI_RE, "");
21
+ return s.replace(OSC8_RE, "").replace(ANSI_RE, "");
21
22
  }
22
23
 
23
24
  /** Display width in terminal columns, ignoring ANSI and honouring wide chars. */
@@ -49,6 +50,14 @@ export function truncate(s: string, max: number, ellipsis = "…"): string {
49
50
 
50
51
  while (i < s.length) {
51
52
  if (s[i] === ESC) {
53
+ // OSC 8 hyperlink: ESC ] 8 ;; URL BEL — zero-width wrapper
54
+ if (s[i + 1] === "]") {
55
+ const bel = s.indexOf("\u0007", i);
56
+ if (bel === -1) break;
57
+ out += s.slice(i, bel + 1);
58
+ i = bel + 1;
59
+ continue;
60
+ }
52
61
  const end = s.indexOf("m", i);
53
62
  if (end === -1) break;
54
63
  const seq = s.slice(i, end + 1);
package/src/ui/widget.ts CHANGED
@@ -54,6 +54,8 @@ export type WidgetState = {
54
54
  replanDiff?: string | null;
55
55
  /** Dashboard URL to show near the top, clickable. Meaningful host:port, not just numbers. */
56
56
  dashboardUrl?: string | null;
57
+ /** Gate history for phase rail failure highlighting. */
58
+ gateHistory?: import("../core/types.ts").GateHistoryEntry[] | null;
57
59
  /** Model routing note: e.g. "Model per task — subtasks share parent". Shown once. */
58
60
  handoffModelNote?: string | null;
59
61
  /** Shown in the header rule, e.g. "rev 42". */
@@ -257,14 +259,26 @@ export function phaseRail(
257
259
  max: number,
258
260
  g: GlyphSet,
259
261
  s: Styler,
262
+ gateHistory?: readonly import("../core/types.ts").GateHistoryEntry[] | null,
260
263
  ): string {
261
264
  const order = getPhaseOrder(enabled);
262
265
  const idx = current ? order.indexOf(current) : -1;
266
+ // Last verdict per phase — failed phases show as blocked (red).
267
+ const lastByPhase = new Map<string, "pass" | "fail">();
268
+ if (gateHistory) {
269
+ for (let i = gateHistory.length - 1; i >= 0; i--) {
270
+ const e = gateHistory[i] as { phase?: string; result?: string } | null;
271
+ if (!e?.phase || !e?.result) continue;
272
+ if (!lastByPhase.has(e.phase)) lastByPhase.set(e.phase, e.result as "pass" | "fail");
273
+ }
274
+ }
263
275
 
264
276
  const render = (phases: Phase[], elideLeft: boolean, elideRight: boolean): string => {
265
277
  const parts = phases.map((p) => {
266
278
  const i = order.indexOf(p);
279
+ const last = lastByPhase.get(p);
267
280
  if (p === current) return s.bold(s.fg("accent", g.phaseCurrent + " " + p.toUpperCase()));
281
+ if (last === "fail") return s.bold(s.fg("blocked", g.blocked + " " + p));
268
282
  if (idx >= 0 && i < idx) return s.fg("success", g.phaseDone + " " + p);
269
283
  return s.fg("muted", g.phaseTodo + " " + p);
270
284
  });
@@ -566,8 +580,11 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
566
580
  }
567
581
  if ((state as WidgetState).replanDiff) push(truncate(s.fg("muted", "Replan: ") + s.fg("text", String((state as WidgetState).replanDiff).slice(0, 80)), inner));
568
582
 
569
- // -- header ---------------------------------------------------------------
570
- const brand = s.bold(s.fg("brand", "∞ INFINITY"));
583
+ // -- header + dashboard --------------------------------------------
584
+ // Every band has its own colour so the top scans as sections, not one grey block.
585
+ // Muted separators make it legible even with NO_COLOR.
586
+ const sep = (): void => { push(s.fg("rule", g.rail.repeat(Math.max(1, inner)))); };
587
+ const brand = s.bold(s.fg("brand", "\u221e INFINITY"));
571
588
  const phaseTag = state.paused
572
589
  ? s.fg("blocked", "PAUSED")
573
590
  : state.phase
@@ -578,48 +595,119 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
578
595
  const headRight = phaseTag + revTag;
579
596
  const gapW = inner - width(headLeft) - width(headRight);
580
597
  push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
581
- // Dashboard URL: always visible (user never has to type /infinity:dashboard to discover it).
598
+ // Dashboard URL with a real port :PORT is never correct for an ephemeral port.
582
599
  {
583
- const url = state.dashboardUrl as string | null | undefined;
600
+ const url = (state.dashboardUrl as string | null | undefined) ?? null;
584
601
  if (url) {
585
602
  const label = s.fg("accent", url);
586
- const link = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
587
- push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
603
+ const link = "\u001b]8;;" + url + "\u0007" + label + "\u001b]8;;\u0007";
604
+ push(truncate(s.fg("accent", "Dashboard \u2192 ") + link, inner));
588
605
  } else {
589
- push(truncate(s.fg("muted", "Dashboard: ") + s.fg("muted", "/infinity:dashboard → http://127.0.0.1:PORT"), inner));
606
+ push(truncate(s.fg("muted", "Dashboard \u2192 ") + s.fg("muted", "/infinity:dashboard"), inner));
590
607
  }
591
608
  }
592
609
  if (state.handoffModelNote) {
593
610
  push(truncate(s.fg("muted", state.handoffModelNote), inner));
594
611
  }
595
-
596
- // -- goal -----------------------------------------------------------------
597
- //
598
- // A single goal is the run's headline and belongs at the top, not buried in
599
- // the tree `buildPlanRows` collapses it there for exactly this reason.
600
- // Several goals are structure, and structure belongs in the tree.
601
- const _goals = state.list.goals ?? [];
602
- const _headline = !display.levels.goal
603
- ? null
604
- : _goals.length === 1
605
- ? (_goals[0]?.title ?? null)
606
- : _goals.length === 0
607
- ? (state.intake ?? null)
608
- : null;
609
- if (_headline) {
610
- const wrapped = wrap(_headline, inner - 2);
611
- wrapped.forEach((line, i) => {
612
- push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
613
- });
612
+ // Goal count sits right under the header; the old widget hid goals entirely.
613
+ {
614
+ const goals = state.list.goals ?? [];
615
+ if (goals.length === 0 && state.intake) {
616
+ push(truncate(s.fg("rule", "Goal \u00b7 ") + s.fg("text", state.intake.slice(0, 90)), inner));
617
+ } else if (goals.length > 0) {
618
+ const n = goals.length;
619
+ const countLine = s.fg("brand", "\u25C8 Goals: " + String(n) + " " + (n === 1 ? "goal" : "goals"));
620
+ push(truncate(countLine, inner));
621
+ }
622
+ }
623
+ sep();
624
+
625
+ // -- goal + phase rail ------------------------------------------------
626
+ // One goal section per your spec, each with its own colour:
627
+ // [brand] Goals: N
628
+ // [text] Active goal headline
629
+ // [accent/muted/success/blocked] phase rail with active/passed/failed
630
+ // [muted] Active-phase breakdown: features/tasks
631
+ // [rule] dotted separator
632
+ // [accent/text] progress bar (whole project)
633
+ // [rule] dotted separator
634
+ {
635
+ const goals = state.list.goals ?? [];
636
+ // Single-goal headline (the old headline logic) — now sits directly under
637
+ // the Goals count, inside the same band, before the rail. Multi-goal plans
638
+ // show the active goal instead (not buried in the lane tree).
639
+ let headline: string | null = null;
640
+ if (display.levels.goal) {
641
+ if (goals.length === 1) headline = goals[0]?.title ?? null;
642
+ else if (goals.length === 0) headline = state.intake ?? null;
643
+ else {
644
+ // Multi-goal: pick the goal owning the next actionable task, else the first.
645
+ const next = nextActionableTask(state.list);
646
+ if (next) {
647
+ const feat = state.list.features.find((f) => f.id === next.featureId) ?? null;
648
+ const spr = feat?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === feat.sprintId) ?? null : null;
649
+ const gid = (feat as { goalId?: string } | null)?.goalId ?? spr?.goalId ?? null;
650
+ const g2 = gid ? goals.find((gg) => gg.id === gid) ?? goals[0] ?? null : goals[0] ?? null;
651
+ headline = g2?.title ?? null;
652
+ } else {
653
+ headline = goals[0]?.title ?? null;
654
+ }
655
+ }
656
+ }
657
+ if (headline) {
658
+ const wrapped = wrap(headline, inner - 2);
659
+ wrapped.forEach((line, i) => {
660
+ push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
661
+ });
662
+ }
663
+ if (display.rail) {
664
+ push(phaseRail(state.phase, state.enabledPhases, inner, g, s, state.gateHistory ?? null));
665
+ }
666
+ // Active-phase breakdown: features + tasks in the current phase (or whole project when no phase yet).
667
+ // Respects display.counts; when counts off we skip the line entirely.
668
+ if (display.counts) {
669
+ const phaseName = state.phase;
670
+ const allFeats = state.list.features ?? [];
671
+ const inPhase = (phaseName
672
+ ? allFeats.filter((f) => (f as { phase?: string }).phase === phaseName)
673
+ : allFeats);
674
+ const phaseTasks = phaseName
675
+ ? flattenTasks(state.list).filter((tt) => tt.effectivePhase === phaseName)
676
+ : flattenTasks(state.list);
677
+ const done = phaseTasks.filter((tt) => tt.status === "complete").length;
678
+ const total = phaseTasks.length;
679
+ const featCount = inPhase.length;
680
+ const label = phaseName ? phaseName.toUpperCase() : "PROJECT";
681
+ const featPart = s.fg("accent", String(featCount) + " " + (featCount === 1 ? "feature" : "features"));
682
+ const taskPart = s.fg("text", String(done) + "/" + String(total) + " tasks");
683
+ push(truncate(s.fg("muted", label + " \u00b7 ") + featPart + s.fg("rule", " \u00b7 ") + taskPart, inner));
684
+ }
685
+ sep();
686
+ if (display.progress) {
687
+ const full =
688
+ s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal) + " tasks") +
689
+ s.fg("rule", " \u00b7 ") +
690
+ s.fg("muted", String(progress.featuresDone) + "/" + String(progress.featuresTotal) + " features") +
691
+ s.fg("rule", " \u00b7 ") +
692
+ s.fg("accent", String((state.list.goals ?? []).length) + " " + ((state.list.goals ?? []).length === 1 ? "goal" : "goals"));
693
+ const compact = s.fg("brand", String(progress.tasksDone) + "/" + String(progress.tasksTotal));
694
+ const METER_MIN = 8 + 6;
695
+ let stats = full;
696
+ if (inner - width(full) < METER_MIN) stats = compact;
697
+ if (inner - width(stats) < METER_MIN) stats = "";
698
+ const statsW = width(stats);
699
+ const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
700
+ const bar = progressBar(progress.percent, barCells, g, s);
701
+ const gap2 = inner - width(bar) - statsW;
702
+ push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
703
+ sep();
704
+ }
614
705
  }
615
706
 
616
- // -- current chain: one line per lane: phase · task · feature (+ sprint) · subtask
617
- // On an empty plan with no task yet, just show phase/goal.
618
-
619
- // Task lane disabled -> no lane at all (overview template wants shape not work).
620
- // Otherwise show active + pending first lane.
707
+ // -- task lanes -------------------------------------------------------
708
+ // Aggregate task-window size respected; lane count capped; elision markers show hidden work.
709
+ const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
621
710
  if (!display.levels.task) {
622
- // Overview: keep the shape (goal/sprint/feature tier names) even without lanes.
623
711
  const f = state.list.features[0];
624
712
  if (f) {
625
713
  const sname = (f as { sprintId?: string }).sprintId ? (state.list.sprints ?? []).find((s) => s.id === (f as { sprintId?: string }).sprintId)?.name : null;
@@ -631,15 +719,8 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
631
719
  if (shape.length) { push(); push(truncate(shape.join(s.fg("rule", " \u00b7 ")), inner)); }
632
720
  }
633
721
  }
634
- // Aggregate task-window size respected: lane count capped; elision markers show hidden work.
635
- // On a huge plan (120 tasks) the widget previously rendered ~TASK_WINDOW rows; now show up to display.taskWindow lanes.
636
- // On a long plan the lane is compact — goal handled via headline above; phase first inside lane so
637
- // narrow TUI still shows active work before sprint/feature tail gets cut.
638
- const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
639
722
  if (display.levels.task) {
640
723
  const allTasks = flattenTasks(state.list);
641
- // When user scrolled explicitly, honour the scroll window (huge plan test uses scroll: 0/1e6).
642
- // Otherwise show focus-centred window (active task plus pending tail).
643
724
  let lanes: Array<FlatTask & { subtasks?: { status: string; title: string }[] }> = [];
644
725
  if (view.scroll !== null) {
645
726
  const start = Math.max(0, Math.min(view.scroll as number, Math.max(0, allTasks.length - taskWindow)));
@@ -664,33 +745,24 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
664
745
  const formatChain = (t: FlatTask & { subtasks?: { status: string; title: string }[] }): string => {
665
746
  const curFeature = state.list.features.find((f) => f.id === t.featureId) ?? null;
666
747
  const curSprint = curFeature?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === curFeature!.sprintId) ?? null : null;
667
- 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;
668
- // goal + sprint + phase + feature + task + subtask: names/titles
669
- // Always show the phase and the task; goal/sprint/feature/subtask honour display.levels.
670
748
  const parts: string[] = [];
671
- // One-line chain: phase · sprint · feature · task · subtask on one line.
672
- // Sprint before task/feature so even narrow realpi rasterizer keeps Foundations visible.
673
749
  if (state.phase) parts.push(s.bold(s.fg("accent", state.phase.toUpperCase())));
674
750
  if (curSprint && display.levels.sprint) parts.push(s.fg("muted", "sprint " + (curSprint.name ?? curSprint.id).slice(0, 18)));
675
751
  if (curFeature && display.levels.feature) parts.push(s.fg("success", curFeature.name.slice(0, 24)));
676
752
  const descEarly = t.description ? t.description.slice(0, 44) : "";
677
753
  parts.push(s.fg("text", t.compositeKey + (descEarly ? " " + descEarly.slice(0, 36) : "")));
678
- // subtask handled as separate line so width budget doesn't cut it off; drop from chain
679
- return parts.join(s.fg("rule", " · "));
754
+ return parts.join(s.fg("rule", " \u00b7 "));
680
755
  };
681
756
  if (lanes.length) {
682
- // Elision: lanes window + markers so huge plan still shows ... N above / ... N below
683
757
  const totalPendable = allTasks.length;
684
758
  const above = allTasks.findIndex((t) => t.compositeKey === lanes[0]!.compositeKey);
685
759
  const lastIdx = allTasks.findIndex((t) => t.compositeKey === lanes[lanes.length - 1]!.compositeKey);
686
760
  const below = Math.max(0, totalPendable - lastIdx - 1);
687
761
  const shownAbove = above > 0 ? above : 0;
688
762
  const shownBelow = below > 0 ? below : 0;
689
- push();
690
763
  if (shownAbove > 0) push(s.fg("rule", " " + g.more + " " + shownAbove + " above"));
691
764
  for (const task of lanes.slice(0, taskWindow)) {
692
765
  push(truncate(formatChain(task), inner));
693
- // If task has an active subtask, show it on its own line so narrow TUI doesn't truncate it away.
694
766
  if (display.levels.subtask !== "none") {
695
767
  const cur = ((task as unknown as FlatTask & { subtasks?: Subtask[] }).subtasks ?? []).find((ss) => ss.status !== "complete") ?? null;
696
768
  if (cur) push(truncate(" " + s.fg("active", "> " + cur.title.slice(0, 56)), inner));
@@ -698,11 +770,9 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
698
770
  }
699
771
  if (shownBelow > 0) push(s.fg("rule", " " + g.more + " " + shownBelow + " below"));
700
772
  } else if (state.list.features.length === 0) {
701
- push();
702
773
  push(s.fg("muted", " no plan yet"));
703
774
  }
704
775
  }
705
-
706
776
  // -- background sessions --------------------------------------------------
707
777
  //
708
778
  // Placed above the rail because, on a run driven by workers, this is the
@@ -724,36 +794,7 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
724
794
  for (const line of renderActivity(state.activity, view.expanded ? activityRows * 3 : activityRows, inner, s)) push(line);
725
795
  }
726
796
 
727
- // -- phase rail -----------------------------------------------------------
728
- if (display.rail) {
729
- push();
730
- push(phaseRail(state.phase, state.enabledPhases, inner, g, s));
731
- }
732
-
733
- // -- progress -------------------------------------------------------------
734
- const full =
735
- s.fg("muted", progress.tasksDone + "/" + progress.tasksTotal + " tasks") +
736
- s.fg("rule", " · ") +
737
- s.fg("muted", progress.featuresDone + "/" + progress.featuresTotal + " features");
738
- const compact = s.fg("muted", progress.tasksDone + "/" + progress.tasksTotal);
739
-
740
- // The meter has a floor of 8 cells plus " NNN%" (6 columns). Below that the
741
- // long stat cannot fit, and padding it out would push the row past the
742
- // frame — so the row degrades to the task count alone, then to no stat.
743
- const METER_MIN = 8 + 6;
744
- let stats = full;
745
- if (inner - width(full) < METER_MIN) stats = compact;
746
- if (inner - width(stats) < METER_MIN) stats = "";
747
-
748
- if (display.progress) {
749
- const statsW = width(stats);
750
- const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
751
- const bar = progressBar(progress.percent, barCells, g, s);
752
- push();
753
- const gap2 = inner - width(bar) - statsW;
754
- push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
755
- }
756
-
797
+ // phase rail + whole-project progress already rendered in the header band above.
757
798
  // -- alerts ---------------------------------------------------------------
758
799
  const alerts: string[] = [];
759
800
  if (progress.blocked > 0) alerts.push(s.fg("blocked", g.blocked + " " + progress.blocked + " blocked"));