infinity-harness 2.6.5 → 2.6.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,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.6] — 2026-08-28
8
+
9
+ Every phase shows its tracked work, tabs isolate it, and autopilot actually drives.
10
+
11
+ ### Fixed
12
+
13
+ - **All phases now show the damn breakdown you asked for (`wdisplay` = truth).** Every enabled phase owns tracked work: `RESEARCH` gets its 3/5/7 tasks+subtasks (by depth) even when its doc gate already passes, `DEFINE/PLAN` get starters when their gate `feature-criteria` fails, visibility is the wizard's `display` policy (`goal/sprint/feature/task/subtask` levels) respected everywhere — the widget lane and the dashboard still render `... N above/below` windowed.
14
+
15
+ - **Dashboard tabs: Goal(s) ▸ Phase(s).** Top nav filters the live plan without losing state: Goal tabs appear only when `goals.length >1` (if single goal, only Phase tabs), default = `activeTask`'s goal/phase otherwise `currentPhase`, persisted via `localStorage`, survives poll-refresh. Each feature card gets `data-phase` so phase tabs hide `phase-research` under `BUILD`, etc. `display.levels.goal/sprint/feature` still hide the tier chrome.
16
+
17
+ - **Autopilot actually auto-pilots (any phase).** `infinity_validate` was allowlisted to `research/define/plan`; now any `phaseModes[phase]==autopilot` PASS auto-advances, seeds the next phase (`ensurePhaseSeeded`), arms `spawnWorkers` scoped to `currentPhase`, and the brief still drives the main session. Copilot still parks on `needsApproval==true`. Fixes: `RESEARCH complete → advanced research → DEFINE` now lands on a `DEFINE 2/2` seeded plan, not `0/0`.
18
+
19
+ - **Seeding is per-phase, not per-first-feature.** `seedPhaseIfEmpty` previously appended to `list.features[0]` or first `phase==` match; with mixed phases it coalesced `research`+`define` into one `phase-research` feature. Now it reuses only `phase==cur` or creates `phase-<name>`, so `phase-research(5)` and `phase-define(2)` are separate cards and per-phase `computeProgress(phase)` tallies correctly. `src/loop.ts` special-cased `research` to always show (FAIL gated would leave it doc-only) and respects `approvalRejection` fingerprint (no seeding while a rejection stands, so `no-progress → give up` still fires).
20
+
21
+ - **Seeding never dirties a converged walk or an approval rejection.** `decideNext` skips seeding when `approvalRejection` present (lets `rejection-unaddressed` escalate), and only seeds `define/plan/...` when their gate probes `overall==false`. Research exception: `feature-criteria` `no real features planned yet` (seeded scaffolding excluded) so define triggers correctly. The existing `convergence` walk `define→ship` still passes because its synthetic satisfiable gates probe PASS and seeded scaffolds are skipped.
22
+
23
+ - **Widget dashboard URL: always visible.** Previously `renderWidget` only emitted `Dashboard: http://…` when `remoteServer` was live; now it always renders a hint `Dashboard: /infinity:dashboard → http://127.0.0.1:PORT` even without a server (OSC 8 hyperlink when live). You asked this twice last time and it was only half-wired.
24
+
25
+ - **Blink that blinks.** `dashboard.css` `cardPulse` 1.2s whisper → 1s `cardPulse + phasePulse + textPulse` with `4px` ring + `16px` glow, active goal/sprint/feature boxes now use `font-weight:800` + stronger `taskBlink` (`2.5px` outline, `0.85s`). `prefers-reduced-motion` still disables.
26
+
27
+ ### Verified
28
+
29
+ - `tsc --noEmit` clean, `34/34` unit, `pipeline/extension/convergence/dashboard/widget/stops/coldstart/escalation/goal/realpi` all pass in isolation and `npm run e2e` (live skipped) green; `bakr_test_2.6.5 define` retro-seeded to visible `RESEARCH(5)+DEFINE(2)` on dashboard (goal tabs hidden when one goal, phase tabs live).
30
+
7
31
  ## [2.6.5] — 2026-08-27
8
32
 
9
33
  Routing is honest about handoff, research knows how deep to go, and the human reads the same wiring the runner does.
@@ -1225,40 +1225,33 @@ export default function (pi: ExtensionAPI): void {
1225
1225
  const lines = gate.checks
1226
1226
  .map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
1227
1227
  .join("\n");
1228
- // Research (and any other phase whose mode is autopilot) used to stall
1229
- // forever until someone typed "continue" because the brief said
1230
- // PASS→advance but no component actually advanced without the continuous
1231
- // loop armed. Fix that, but do NOT auto-advance BUILD verify-style
1232
- // phases that require real work (tests, coverage, clean tree) to have
1233
- // genuinely passed on the *next* phase's gate as well — otherwise a
1234
- // single infinity_validate hops build→verify→review.
1235
- // Only auto-advance doc/process phases whose gate is purely content (
1236
- // research, define, plan). BUILD and later require explicit validation.
1228
+ // Autopilot means auto-pilot: when the gate passes and the current
1229
+ // phase's phaseMode is autopilot, advance immediately (any phase). The
1230
+ // old allowlist stalled real autopilot builds after RESEARCH → DEFINE.
1231
+ // Copilot still parks via needsApproval check below.
1237
1232
  if (gate.overall && !params?.feature && !params?.task) {
1238
- const autoPhases: ReadonlySet<string> = new Set(["research", "define", "plan"]);
1239
- if (autoPhases.has(String(gate.phase))) {
1240
- try {
1241
- const { needsApproval } = await import("../../src/approval.ts");
1242
- const fresh = loadConfig(dir).config;
1243
- if (!needsApproval(fresh, fresh.currentPhase)) {
1244
- const { advancePhase } = await import("../../src/core/phases.ts");
1245
- const moved = await advancePhase(dir);
1246
- if (moved.ok && moved.to) {
1247
- refreshWidget(ctx as ExtensionContext);
1248
- const brief = await briefText(dir);
1249
- return {
1250
- content: [
1251
- {
1252
- type: "text",
1253
- text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1254
- },
1255
- ],
1256
- details: { ...gate, advanced: moved } as unknown as typeof gate,
1257
- };
1258
- }
1233
+ try {
1234
+ const { needsApproval } = await import("../../src/approval.ts");
1235
+ const fresh = loadConfig(dir).config;
1236
+ if (!needsApproval(fresh, fresh.currentPhase)) {
1237
+ const { advancePhase, ensurePhaseSeeded } = await import("../../src/core/phases.ts");
1238
+ const moved = await advancePhase(dir);
1239
+ if (moved.ok && moved.to) {
1240
+ try { ensurePhaseSeeded(dir, moved.to); } catch {}
1241
+ refreshWidget(ctx as ExtensionContext);
1242
+ const brief = await briefText(dir);
1243
+ return {
1244
+ content: [
1245
+ {
1246
+ type: "text",
1247
+ text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1248
+ },
1249
+ ],
1250
+ details: { ...gate, advanced: moved } as unknown as typeof gate,
1251
+ };
1259
1252
  }
1260
- } catch {}
1261
- }
1253
+ }
1254
+ } catch {}
1262
1255
  }
1263
1256
  return {
1264
1257
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.6.5",
3
+ "version": "2.6.6",
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/core/gates.ts CHANGED
@@ -336,8 +336,10 @@ async function checkNoEmptyDirs({ targetDir }: Ctx): Promise<CheckResult> {
336
336
  async function checkFeatureCriteria({ targetDir }: Ctx): Promise<CheckResult> {
337
337
  const { list } = loadFeatureList(targetDir);
338
338
  // Seeded starter features (phase-*) are scaffolding, not real features — ignore for criteria gate.
339
+ // Also: if no non-scaffold features exist yet, DEFINE has not been planned and must not PASS on a
340
+ // single phase-* dummy. Treat absent as FAIL with a distinct detail so callers seeding define know to act.
339
341
  const features = (list.features ?? []).filter((f) => !(f as { phase?: string }).phase);
340
- if (features.length === 0) return fail("feature-criteria", "no features planned yet");
342
+ if (features.length === 0) return fail("feature-criteria", "no real features planned yet — run DEFINE");
341
343
  const missing = features.filter((f) => !(f.criteria ?? []).length).map((f) => f.id);
342
344
  return missing.length === 0
343
345
  ? pass("feature-criteria", `${features.length} feature(s) have criteria`)
@@ -381,10 +383,9 @@ type Check = (ctx: Ctx) => Promise<CheckResult>;
381
383
 
382
384
  const PHASE_CHECKS: Record<Phase, Check[]> = {
383
385
  init: [checkGitRepo, checkConfigExists],
384
- // Generic: tasks + doc on every phase RESEARCH no longer doc-only, DEFINE/PLAN no longer doc-only.
385
- // tasksComplete is advisory on define/plan/research so seeded tasks stay visible without freezing
386
- // the synthetic mkSatisfiableProject walk that never completes seeded tasks. The dashboard widget
387
- // still shows ... subtasks and progress as the real signal; BUILD keeps tasksComplete blocking.
386
+ // tasksComplete is advisory on seeded-phase gates only when seeded work exists otherwise the
387
+ // converge walk would freeze on the seeded define/plan tasks that test never completes. Once BUILD
388
+ // has tasksComplete remains blocking.
388
389
  research: [checkResearchDoc],
389
390
  define: [checkFeatureCriteria, checkSkillsLoad],
390
391
  plan: [checkFeatureCriteria, checkTasksPlanned],
@@ -217,6 +217,15 @@ function startersForPhase(dir: string, phase: string): StarterTask[] {
217
217
  return STARTER_TASKS_BY_DEPTH[DEFAULT_RESEARCH_DEPTH] ?? [];
218
218
  }
219
219
 
220
+ export function ensurePhaseSeeded(dir: string, phase: import("./types.ts").Phase): boolean {
221
+ try {
222
+ const { list } = loadFeatureList(dir);
223
+ if (tasksForPhase(list, phase).length > 0) return false;
224
+ const r = seedPhaseIfEmpty(dir, phase);
225
+ return r.seeded;
226
+ } catch { return false; }
227
+ }
228
+
220
229
  export function seedPhaseIfEmpty(dir: string, phase: import("./types.ts").Phase): { seeded: boolean; error: string | null } {
221
230
  const seeded = startersForPhase(dir, phase);
222
231
  if (seeded.length === 0) return { seeded: false, error: null };
@@ -224,12 +233,10 @@ export function seedPhaseIfEmpty(dir: string, phase: import("./types.ts").Phase)
224
233
  const { list } = loadFeatureList(dir);
225
234
  const existing = tasksForPhase(list, phase);
226
235
  if (existing.length > 0) return { seeded: false, error: null };
227
- // Append to first feature matching phase, or create a phase feature.
228
- const feature = (
229
- list.features.find((f) => (f as { phase?: string }).phase === phase) ??
230
- list.features[0] ??
231
- ({ id: `phase-${phase}`, name: phase.toUpperCase(), tasks: [] } as unknown as typeof list.features[number])
232
- );
236
+ // Each phase gets its own feature (phase-<name>) so per-phase tabs/groups isolate correctly.
237
+ // Never append a "define" task to a "research" feature the feature.phase is the grouping key.
238
+ const match = list.features.find((f) => (f as { phase?: string }).phase === phase);
239
+ const feature: typeof list.features[number] = match ?? ({ id: `phase-${phase}`, name: phase.toUpperCase(), tasks: [] } as unknown as typeof list.features[number]);
233
240
  if (!list.features.includes(feature as any)) {
234
241
  (feature as { phase?: string }).phase = phase;
235
242
  list.features.push(feature as any);
package/src/loop.ts CHANGED
@@ -275,17 +275,32 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
275
275
 
276
276
  state.lastPhase = config.currentPhase;
277
277
 
278
- // Generic: current phase with 0 tasks gets starters (research deep, define/plan → fix rev 0).
279
- // Same code for every phase, same trigger: 0 tasks && gate fails. Never dirties a converged walk.
278
+ // Every enabled phase owns tracked work. Visible breakdown (requirement 1) says RESEARCH must
279
+ // show its tasks even when its doc gate already PASSed progress is the real signal.
280
+ // On a copilot approval project with real features, never inject scaffolding on a PASSing DEFINE.
281
+ // On synthetic mkSatisfiableProject (define→ship already complete, first tick PASS) also skip.
282
+ // All other empty phases (bakr_test define/plan after autopilot research) seed on FAIL.
280
283
  if (config.currentPhase && (config.phases?.enabled ?? []).includes(config.currentPhase)) {
284
+ const maybeRejected = Boolean((config as { approvalRejection?: unknown }).approvalRejection);
285
+ if (!maybeRejected) {
281
286
  try {
282
287
  const { list: _list } = loadFeatureList(targetDir);
283
288
  const hasPhaseTasks = (await import("./core/featureList.ts")).tasksForPhase(_list, config.currentPhase).length > 0;
284
289
  if (!hasPhaseTasks) {
285
- const gateTrial = await runChecks(targetDir, config.currentPhase, { record: false });
286
- if (!gateTrial.overall) seedPhaseIfEmpty(targetDir, config.currentPhase);
290
+ const curPhase = config.currentPhase;
291
+ if (curPhase === "research") {
292
+ // Research is doc + tracked tasks: always show the breakdown (5 tasks for deep).
293
+ seedPhaseIfEmpty(targetDir, curPhase);
294
+ } else {
295
+ const curState = state;
296
+ const probe = await runChecks(targetDir, curPhase, { record: false });
297
+ if (!probe.overall) {
298
+ seedPhaseIfEmpty(targetDir, curPhase);
299
+ }
300
+ }
287
301
  }
288
302
  } catch {}
303
+ }
289
304
  }
290
305
 
291
306
  // -- terminal conditions --------------------------------------------------
package/src/remote.ts CHANGED
@@ -160,7 +160,7 @@ export function buildHtml(state: RemoteState): string {
160
160
  }
161
161
 
162
162
  /** JSON payload for `/api/harness`. Excludes the full list to stay compact. */
163
- export function buildApiPayload(state: RemoteState): Record<string, unknown> {
163
+ export function buildApiPayload(state: RemoteState & { dashboardUrl?: string | null; handoffModelNote?: string | null }): Record<string, unknown> {
164
164
  return {
165
165
  baseRevision: state.baseRevision,
166
166
  phase: state.phase,
@@ -991,10 +991,14 @@ table.tasks tr:last-child td{border-bottom:0}
991
991
  /* While-developed: the whole active branch pulses — every active box, not just one feature. */
992
992
  .dash-url{margin:8px 0 0;font-size:13px} .dash-url a{color:var(--t-accent);text-decoration:none;border-bottom:1px dashed rgba(var(--rgb-accent),.45)} .dash-url a:hover{border-bottom-style:solid}
993
993
  .handoff-note{margin:4px 0 0;color:var(--muted);font-size:12px}
994
- .tier.is-current,.feature.is-current{animation:cardPulse 1.2s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 3px rgba(var(--rgb-accent),.45), 0 0 12px rgba(var(--rgb-accent),.25), var(--shadow)}
995
- .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent);font-weight:650}
996
- .row.is-active{animation:taskBlink 0.9s ease-in-out infinite; outline:2px solid var(--c-active); outline-offset:-2px; box-shadow:0 0 8px rgba(var(--rgb-active),.35)}
997
- @keyframes cardPulse{0%,100%{box-shadow:0 0 0 3px rgba(var(--rgb-accent),.45),0 0 12px rgba(var(--rgb-accent),.25),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 7px rgba(var(--rgb-accent),.22),0 0 16px rgba(var(--rgb-accent),.32),var(--shadow); border-color:rgba(var(--rgb-accent),.70)}}
994
+ /* Active branching: make it scream, not whisper. Three cues: glow, ring, and text luminance — reduced-motion still keeps the ring.
995
+ Pulse is strong at the crest, not near-reduced-motion. */
996
+ .tier.is-current,.feature.is-current{animation:cardPulse 1s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 4px rgba(var(--rgb-accent),.60), 0 0 16px rgba(var(--rgb-accent),.45), var(--shadow)}
997
+ .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent);font-weight:800; animation:textPulse 1s ease-in-out infinite}
998
+ .row.is-active{animation:taskBlink 0.85s ease-in-out infinite; outline:2.5px solid var(--c-active); outline-offset:-2px; box-shadow:0 0 10px rgba(var(--rgb-active),.45)}
999
+ .step.is-current .dot{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.50), 0 0 14px rgba(var(--rgb-accent),.40); animation:phasePulse 1s ease-in-out infinite}
1000
+ @keyframes cardPulse{0%,100%{box-shadow:0 0 0 4px rgba(var(--rgb-accent),.60),0 0 16px rgba(var(--rgb-accent),.45),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 8px rgba(var(--rgb-accent),.15),0 0 20px rgba(var(--rgb-accent),.55),var(--shadow); border-color:rgba(var(--rgb-accent),.40)}}
1001
+ @keyframes phasePulse{0%,100%{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.50),0 0 14px rgba(var(--rgb-accent),.40)}50%{box-shadow:0 0 0 9px rgba(var(--rgb-accent),.10),0 0 18px rgba(var(--rgb-accent),.55)}}
998
1002
  @keyframes textPulse{0%,100%{opacity:1}50%{opacity:.78}}
999
1003
  .row-blocked{background:rgba(var(--rgb-blocked),.07)}
1000
1004
  .row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
@@ -1120,6 +1124,43 @@ const SCRIPT = `
1120
1124
  return o;
1121
1125
  }
1122
1126
 
1127
+ // Tabs: remember selection in localStorage; Goal hidden when only one goal; filter by phase/goal.
1128
+ function tabBind() {
1129
+ try {
1130
+ var GK='ih-goal', PK='ih-phase';
1131
+ function read(k){ try{ return localStorage.getItem(k); }catch(e){ return null; } }
1132
+ function write(k,v){ try{ localStorage.setItem(k,v); }catch(e){} }
1133
+ var selGoal = read(GK);
1134
+ var selPhase = read(PK);
1135
+ var goalEls = document.querySelectorAll('[data-goal]');
1136
+ var phaseEls = document.querySelectorAll('[data-phase]');
1137
+ // Seed from is-current when no stored choice.
1138
+ if(!selGoal){ var cur=document.querySelector('[data-goal].is-current'); if(cur) selGoal=cur.getAttribute('data-goal'); }
1139
+ if(!selPhase){ var curP=document.querySelector('[data-phase].is-current'); if(curP) selPhase=curP.getAttribute('data-phase'); }
1140
+ function apply(){
1141
+ var gTabs=document.querySelectorAll('.dash-tabs [data-goal]');
1142
+ var pTabs=document.querySelectorAll('.dash-tabs [data-phase]');
1143
+ gTabs.forEach(function(el){ el.classList.toggle('is-current', el.getAttribute('data-goal')===selGoal); });
1144
+ pTabs.forEach(function(el){ el.classList.toggle('is-current', el.getAttribute('data-phase')===selPhase); });
1145
+ // If user picked a goal that doesn't exist anymore, fall back to first.
1146
+ var tiers=document.querySelectorAll('.tier.tier-goal');
1147
+ if(tiers.length===0) tiers=document.querySelectorAll('.tier:not(.tier-goal)');
1148
+ // Hide tiers that don't match selected goal (when goal tabs visible, else show all).
1149
+ if(goalEls.length>0 && selGoal){
1150
+ document.querySelectorAll('.tier.tier-goal').forEach(function(t){ var id=(t.querySelector('[data-goal]')||t).getAttribute('data-goal'); var match = !id || id===selGoal; t.classList.toggle('tier-hidden', !match); });
1151
+ }
1152
+ // Phase filter: hide features/tasks not in selected phase. Server could filter but client keeps auto-refresh.
1153
+ if(selPhase){
1154
+ document.querySelectorAll('.feature').forEach(function(f){ var ph=f.getAttribute('data-phase')||''; if(ph && ph!==selPhase) f.classList.add('tier-hidden'); else f.classList.remove('tier-hidden'); });
1155
+ }
1156
+ // If only one goal, goal strip already hidden server-side — no tier-hidden by goal.
1157
+ }
1158
+ goalEls.forEach(function(el){ el.addEventListener('click', function(){ selGoal=el.getAttribute('data-goal'); write(GK, selGoal); apply(); }); });
1159
+ phaseEls.forEach(function(el){ el.addEventListener('click', function(){ selPhase=el.getAttribute('data-phase'); write(PK, selPhase); apply(); }); });
1160
+ apply();
1161
+ } catch(e){}
1162
+ }
1163
+ tabBind();
1123
1164
  function refresh() {
1124
1165
  // A hidden tab is not being read; skip the work but keep the loop alive.
1125
1166
  if (document.hidden) { schedule(BASE); return; }
@@ -1217,7 +1258,33 @@ export function renderDashboard(state: DashboardState): string {
1217
1258
  // Which feature/sprint/goal is currently being worked (for blinking).
1218
1259
  const activeTask = tasks.find((t) => t.status === "in_progress" || t.status === "rework") ?? tasks.find((t) => t.status === "pending") ?? null;
1219
1260
  const activeFeatureId = activeTask?.featureId ?? null;
1220
- const body = features.length
1261
+ // tabs: Goal(s) top row, Phase(s) second row. Default = active task's goal/phase, else current phase.
1262
+ // On a project that has exactly one goal, the goal tab strip hides (requirement §3). The phase tab strip
1263
+ // always shows so RESEARCH progress is isolated from BUILD progress — same plan-list, filtered view.
1264
+ const activeGoalId = (() => {
1265
+ if (!activeTask) return goals[0]?.id ?? null;
1266
+ const f = features.find((ff) => ff.id === activeTask.featureId) ?? null;
1267
+ const s = f?.sprintId ? sprints.find((ss) => ss.id === f!.sprintId) ?? null : null;
1268
+ return (f?.goalId ?? s?.goalId ?? goals[0]?.id ?? null) as string | null;
1269
+ })();
1270
+ const currentPhase = state.phase ?? null;
1271
+ const showGoalTabs = display.levels.goal && goals.length > 1;
1272
+ const phaseTabs = getPhaseOrder(state.enabledPhases);
1273
+ const goalTabsHtml = showGoalTabs
1274
+ ? `<nav class="dash-tabs" aria-label="Goals">` +
1275
+ goals.map((g) => {
1276
+ const cur = g.id === activeGoalId ? ' is-current' : '';
1277
+ return `<button type="button" class="dash-tab${cur}" data-goal="${esc(g.id)}">${esc(g.title ?? g.id)}</button>`;
1278
+ }).join("") + `</nav>`
1279
+ : "";
1280
+ const phaseTabsHtml = `<nav class="dash-tabs" aria-label="Phases">` +
1281
+ phaseTabs.map((p) => {
1282
+ const cur = p === currentPhase ? ' is-current' : '';
1283
+ return `<button type="button" class="dash-tab${cur}" data-phase="${esc(p)}">${esc(p.toUpperCase())}</button>`;
1284
+ }).join("") + `</nav>`;
1285
+ const tabStyle = `<style>.dash-tabs{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 6px}.dash-tab{appearance:none;border:1px solid var(--border);background:var(--surface);border-radius:999px;padding:4px 10px;font:500 12px/1.2 inherit;color:var(--muted);cursor:pointer}.dash-tab.is-current{background:var(--c-accent);color:#fff;border-color:var(--c-accent);box-shadow:0 0 0 3px var(--ring)}.dash-tab:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}.tier-hidden{display:none!important}</style>`;
1286
+ // Tag every feature row with its effective phase so tabs can filter client-side without a round-trip.
1287
+ const origBody = features.length
1221
1288
  ? groups
1222
1289
  .map((group) =>
1223
1290
  renderGoalGroup(
@@ -1234,6 +1301,16 @@ export function renderDashboard(state: DashboardState): string {
1234
1301
  )
1235
1302
  .join("")
1236
1303
  : renderEmptyPlan(state.phase);
1304
+ const body = (() => {
1305
+ if (!origBody) return origBody;
1306
+ // Inject data-phase on each feature card using featurePhase list.
1307
+ let idx = 0;
1308
+ return origBody.replace(/<section class="card feature/g, () => {
1309
+ const f = features[idx++] ?? null;
1310
+ const ph = (f as { phase?: string } | null)?.phase ?? (flattenTasks(list).find((t) => t.featureId === f?.id)?.effectivePhase as string | undefined) ?? "build";
1311
+ return `<section data-phase="${esc(ph)}" class="card feature`;
1312
+ });
1313
+ })();
1237
1314
 
1238
1315
  const titleBits: string[] = [];
1239
1316
  if (paused) titleBits.push("PAUSED");
@@ -1253,7 +1330,7 @@ export function renderDashboard(state: DashboardState): string {
1253
1330
  <!-- Empty data URI: suppresses the /favicon.ico request the harness server would 404. -->
1254
1331
  <link rel="icon" href="data:,">
1255
1332
  <title>${esc(title)}</title>
1256
- <style>${STYLES}</style>
1333
+ <style>${STYLES}</style>${tabStyle}
1257
1334
  </head>
1258
1335
  <body>
1259
1336
  <div id="app">
@@ -1273,6 +1350,7 @@ ${
1273
1350
  }
1274
1351
  ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1275
1352
  ${renderGate(gate)}
1353
+ ${goalTabsHtml}${phaseTabsHtml}
1276
1354
  ${body}
1277
1355
  <footer class="foot">
1278
1356
  <span>state as of <span class="mono">${esc(formatTimestamp(state.timestamp))}</span></span>
package/src/ui/widget.ts CHANGED
@@ -410,13 +410,16 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
410
410
  const headRight = phaseTag + revTag;
411
411
  const gapW = inner - width(headLeft) - width(headRight);
412
412
  push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
413
- // Dashboard URL near top, clickable (OSC 8) with meaningful host:port, not just numbers.
414
- if (state.dashboardUrl) {
415
- const url = state.dashboardUrl;
416
- const label = s.fg("accent", url);
417
- // OSC 8 hyperlink: terminals that support it make it clickable; others show the URL.
418
- const link = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
419
- push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
413
+ // Dashboard URL: always visible (user never has to type /infinity:dashboard to discover it).
414
+ {
415
+ const url = state.dashboardUrl as string | null | undefined;
416
+ if (url) {
417
+ const label = s.fg("accent", url);
418
+ const link = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
419
+ push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
420
+ } else {
421
+ push(truncate(s.fg("muted", "Dashboard: ") + s.fg("muted", "/infinity:dashboard → http://127.0.0.1:PORT"), inner));
422
+ }
420
423
  }
421
424
  if (state.handoffModelNote) {
422
425
  push(truncate(s.fg("muted", state.handoffModelNote), inner));