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 +24 -0
- package/extensions/infinity-harness/index.ts +25 -32
- package/package.json +1 -1
- package/src/core/gates.ts +6 -5
- package/src/core/phases.ts +13 -6
- package/src/loop.ts +19 -4
- package/src/remote.ts +1 -1
- package/src/ui/dashboard.ts +84 -6
- package/src/ui/widget.ts +10 -7
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
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
}
|
|
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.
|
|
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
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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],
|
package/src/core/phases.ts
CHANGED
|
@@ -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
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
//
|
|
279
|
-
//
|
|
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
|
|
286
|
-
if (
|
|
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,
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
995
|
-
|
|
996
|
-
.
|
|
997
|
-
|
|
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
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
const url = state.dashboardUrl;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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));
|