infinity-harness 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/loop.ts CHANGED
@@ -39,6 +39,13 @@ import {
39
39
  type EscalationState,
40
40
  } from "./escalate.ts";
41
41
  import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
42
+ import {
43
+ needsApproval,
44
+ requestApproval,
45
+ describeApproval,
46
+ renderApprovalRequest,
47
+ rejectionStandsFor,
48
+ } from "./approval.ts";
42
49
 
43
50
  export const LOOP_STATE_FILE = "loop-state.json";
44
51
  export const STOP_FILE = "STOP";
@@ -89,7 +96,13 @@ export type LoopDecision =
89
96
  | { action: "continue"; message: string; reason: string }
90
97
  | { action: "advanced"; toPhase: Phase; message: string; reason: string }
91
98
  | { action: "stop"; reason: string; detail: string }
92
- | { action: "wait"; reason: string; detail: string };
99
+ | { action: "wait"; reason: string; detail: string }
100
+ /**
101
+ * The gate passed, but this phase decides *what gets built* and the human
102
+ * asked to sign it. The run parks here until they answer; it does not
103
+ * advance, and it does not count as being stuck.
104
+ */
105
+ | { action: "approve"; phase: Phase; message: string; detail: string; reason: string };
93
106
 
94
107
  export function loopStatePath(targetDir: string): string {
95
108
  return resolve(harnessDir(targetDir), LOOP_STATE_FILE);
@@ -232,6 +245,15 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
232
245
  detail: "The pipeline is paused. Unpause to continue.",
233
246
  });
234
247
  }
248
+ if (config.awaitingApproval) {
249
+ return finish({
250
+ action: "wait",
251
+ reason: "awaiting-approval",
252
+ detail:
253
+ `${config.awaitingApproval.toUpperCase()} is waiting for your approval. ` +
254
+ "`/infinity:approve` continues; `/infinity:approve <what is wrong>` sends it back.",
255
+ });
256
+ }
235
257
 
236
258
  state.lastPhase = config.currentPhase;
237
259
 
@@ -294,10 +316,40 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
294
316
 
295
317
  const gate = options.skipGate ? null : await runChecks(targetDir, phase, { record: true });
296
318
 
297
- if (gate && gate.overall) {
319
+ // A phase the human already sent back, on a project that has not moved
320
+ // since, is not ready to be asked about again. The gate is deterministic:
321
+ // without this it passes on the very next tick and the human is asked the
322
+ // identical question about the identical artefact, forever.
323
+ //
324
+ // Crucially this is treated as a *failure*, not as its own early return. An
325
+ // early return skipped the no-progress detector entirely — so an agent that
326
+ // ignored the rejection re-briefed itself without limit, which is the one
327
+ // thing an unattended loop must never do. Falling through means the same
328
+ // budgets, the same strikes and the same escalation ladder apply.
329
+ const standingRejection =
330
+ gate && gate.overall && needsApproval(config, phase) && config.awaitingApproval !== phase
331
+ ? rejectionStandsFor(config, phase, await fingerprint(targetDir))
332
+ : null;
333
+
334
+ if (gate && gate.overall && !standingRejection) {
298
335
  state.noProgressStreak = 0;
299
336
  state.lastFingerprint = await fingerprint(targetDir);
300
337
 
338
+ // A passed gate on an approvable phase is not permission to continue — it
339
+ // is permission to *ask*. The gate proves the phase produced what it was
340
+ // supposed to produce; only the human can say it produced the right thing.
341
+ if (needsApproval(config, phase) && config.awaitingApproval !== phase) {
342
+ requestApproval(targetDir, phase);
343
+ const request = describeApproval(phase);
344
+ return finish({
345
+ action: "approve",
346
+ reason: "awaiting-approval",
347
+ phase,
348
+ message: renderApprovalRequest(request),
349
+ detail: `${phase.toUpperCase()} is waiting for your approval.`,
350
+ });
351
+ }
352
+
301
353
  const upcoming = nextPhase(phase, config.phases?.enabled);
302
354
  if (upcoming === null) {
303
355
  return finish({
@@ -316,6 +368,19 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
316
368
  });
317
369
  }
318
370
 
371
+ // A new phase starts with no baseline.
372
+ //
373
+ // Without this the first failure of the new phase compares the tree
374
+ // against the fingerprint taken when the *previous* phase passed — which
375
+ // is of course identical, because nothing has happened yet — and is
376
+ // counted as a stall. The run then escalated on the first turn of every
377
+ // phase, spending `retry` and `reframe` on an agent that had not yet been
378
+ // given a chance to do anything. A stall means the agent produced nothing
379
+ // when asked; a fresh brief has not asked yet.
380
+ state.lastFingerprint = null;
381
+ state.noProgressStreak = 0;
382
+ state.escalation = { ...state.escalation, tried: [] };
383
+
319
384
  const brief = await buildBrief(targetDir);
320
385
  return finish({
321
386
  action: "advanced",
@@ -399,9 +464,12 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
399
464
  if (state.noProgressStreak >= budget.noProgressLimit) {
400
465
  return finish({
401
466
  action: "stop",
402
- reason: "no-progress",
403
- detail:
404
- `The gate has failed ${state.noProgressStreak} times in a row with no change to the working tree ` +
467
+ reason: standingRejection ? "rejection-unaddressed" : "no-progress",
468
+ detail: standingRejection
469
+ ? `You sent ${phase.toUpperCase()} back ${state.noProgressStreak} turns ago and nothing has ` +
470
+ `changed since. The agent is not acting on it: "${standingRejection.note}". ` +
471
+ `Stopping so you can take over.`
472
+ : `The gate has failed ${state.noProgressStreak} times in a row with no change to the working tree ` +
405
473
  `or the plan. The agent is looping without making progress` +
406
474
  (gate ? `: ${gate.failures.join(", ")}` : "") +
407
475
  `.` +
@@ -435,14 +503,22 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
435
503
 
436
504
  // An escalation replaces the standard "fix these" nudge, because repeating
437
505
  // that nudge is exactly what the ladder exists to interrupt.
438
- const head = escalation?.instruction
439
- ? `${escalation.instruction}\n`
440
- : `The ${phase.toUpperCase()} gate did not pass. Fix exactly these, then stop talking — ` +
441
- `the harness will re-validate automatically.\n\n${failures}${focus}\n`;
506
+ const head = standingRejection
507
+ ? `A human reviewed ${phase.toUpperCase()} and sent it back. Nothing in the project has changed ` +
508
+ `since, so the run is still waiting on this and nothing else:\n\n ${standingRejection.note}\n\n` +
509
+ `Fix that, then validate again.${focus}\n`
510
+ : escalation?.instruction
511
+ ? `${escalation.instruction}\n`
512
+ : `The ${phase.toUpperCase()} gate did not pass. Fix exactly these, then stop talking — ` +
513
+ `the harness will re-validate automatically.\n\n${failures}${focus}\n`;
442
514
 
443
515
  return finish({
444
516
  action: "continue",
445
- reason: escalation?.strategy ? `escalated: ${describeEscalation(escalation)}` : "gate failed",
517
+ reason: standingRejection
518
+ ? "rejected, not yet addressed"
519
+ : escalation?.strategy
520
+ ? `escalated: ${describeEscalation(escalation)}`
521
+ : "gate failed",
446
522
  message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
447
523
  });
448
524
  }
@@ -495,6 +571,8 @@ export function describeDecision(d: LoopDecision): string {
495
571
  return `advanced to ${d.toPhase}`;
496
572
  case "wait":
497
573
  return `waiting — ${d.detail}`;
574
+ case "approve":
575
+ return `waiting for your approval of ${d.phase}`;
498
576
  case "stop":
499
577
  return `stopped — ${d.detail}`;
500
578
  }
package/src/remote.ts CHANGED
@@ -18,11 +18,12 @@ import { createServer, type Server } from "node:http";
18
18
  import type { AddressInfo } from "node:net";
19
19
  import { resolve } from "node:path";
20
20
 
21
- import type { FeatureList, Feature, Goal, GateResult, Phase } from "./core/types.ts";
21
+ import type { FeatureList, Feature, Goal, GateResult, Phase, Sprint } from "./core/types.ts";
22
22
  import { loadFeatureList, computeProgress } from "./core/featureList.ts";
23
23
  import { loadConfig } from "./core/config.ts";
24
24
  import { modelRouterPath, reworkPath } from "./core/paths.ts";
25
25
  import { readJsonSafe } from "./core/fsx.ts";
26
+ import { loadRunState } from "./runState.ts";
26
27
  import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
27
28
 
28
29
  export { escapeHtml };
@@ -47,6 +48,12 @@ export interface RemoteState {
47
48
  timestamp: string;
48
49
  router: unknown;
49
50
  rework: unknown;
51
+ sprints: Sprint[];
52
+ /** A phase whose gate passed and which is waiting for a human signature. */
53
+ awaitingApproval: string | null;
54
+ /** pi sessions this run has spent — proof the handoff is doing its job. */
55
+ sessions: number | null;
56
+ goalPass: { current: number; max: number } | null;
50
57
  }
51
58
 
52
59
  export interface RemoteServer {
@@ -104,6 +111,13 @@ export function buildRemoteState(projectDir?: string): RemoteState {
104
111
  timestamp: new Date().toISOString(),
105
112
  router: readJsonSafe<unknown>(modelRouterPath(dir), null),
106
113
  rework: readJsonSafe<unknown>(reworkPath(dir), null),
114
+ awaitingApproval: config.awaitingApproval ?? null,
115
+ sessions: loadRunState(dir)?.sessions ?? null,
116
+ goalPass:
117
+ typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
118
+ ? { current: config.goalPass, max: config.goalMaxPasses }
119
+ : null,
120
+ sprints: list.sprints ?? [],
107
121
  };
108
122
  }
109
123
 
@@ -119,6 +133,9 @@ function toDashboardState(s: RemoteState): DashboardState {
119
133
  retries: s.retries,
120
134
  router: s.router,
121
135
  rework: s.rework,
136
+ awaitingApproval: s.awaitingApproval,
137
+ sessions: s.sessions,
138
+ goalPass: s.goalPass,
122
139
  };
123
140
  }
124
141
 
@@ -138,6 +155,10 @@ export function buildApiPayload(state: RemoteState): Record<string, unknown> {
138
155
  gate: state.gate,
139
156
  router: state.router,
140
157
  rework: state.rework,
158
+ awaitingApproval: state.awaitingApproval,
159
+ sessions: state.sessions,
160
+ goalPass: state.goalPass,
161
+ sprints: state.sprints,
141
162
  features: state.features.map((f) => ({
142
163
  id: f.id,
143
164
  name: f.name,
@@ -0,0 +1,121 @@
1
+ /**
2
+ * infinity-harness — is a continuous run armed, and which run is it?
3
+ *
4
+ * This used to be a `let loopEnabled = false` inside the extension closure,
5
+ * which meant the answer died with the pi session that held it. That was fine
6
+ * while the harness lived in exactly one session forever, and wrong the moment
7
+ * it did not:
8
+ *
9
+ * - a fresh session per handoff (the whole point of `src/handoff.ts`) starts
10
+ * a new extension instance, and the run it was continuing was over
11
+ * - `/reload`, `/new`, `/resume` and a crash all did the same thing
12
+ * - the run id was a `randomUUID()` per session, so `loadLoopState` saw a
13
+ * different run each time and reset the iteration count, the wall-clock
14
+ * budget, the no-progress streak and the escalation ladder — every budget
15
+ * that exists to stop a runaway run
16
+ *
17
+ * A run is a property of the project, not of the terminal window that started
18
+ * it. It lives on disk.
19
+ */
20
+
21
+ import { runStatePath } from "./core/paths.ts";
22
+ import { readJsonSafe, writeJsonAtomic, removeFile } from "./core/fsx.ts";
23
+
24
+ export type RunState = {
25
+ /** Whether the loop should keep driving. Read on every session start. */
26
+ armed: boolean;
27
+ /** Stable across every session this run spans. */
28
+ runId: string;
29
+ startedAt: string;
30
+ /** How many pi sessions this run has used. Shown in the widget. */
31
+ sessions: number;
32
+ /** Why the run last stopped, so a returning human is not left guessing. */
33
+ stoppedAt: string | null;
34
+ stopReason: string | null;
35
+ };
36
+
37
+ export function newRunState(runId: string, now = new Date()): RunState {
38
+ return {
39
+ armed: true,
40
+ runId,
41
+ startedAt: now.toISOString(),
42
+ sessions: 1,
43
+ stoppedAt: null,
44
+ stopReason: null,
45
+ };
46
+ }
47
+
48
+ export function loadRunState(targetDir: string): RunState | null {
49
+ const raw = readJsonSafe<Partial<RunState> | null>(runStatePath(targetDir), null);
50
+ if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
51
+ return {
52
+ armed: raw.armed === true,
53
+ runId: raw.runId,
54
+ startedAt: typeof raw.startedAt === "string" ? raw.startedAt : new Date(0).toISOString(),
55
+ sessions: typeof raw.sessions === "number" && raw.sessions > 0 ? raw.sessions : 1,
56
+ stoppedAt: typeof raw.stoppedAt === "string" ? raw.stoppedAt : null,
57
+ stopReason: typeof raw.stopReason === "string" ? raw.stopReason : null,
58
+ };
59
+ }
60
+
61
+ export function saveRunState(targetDir: string, state: RunState): void {
62
+ try {
63
+ writeJsonAtomic(runStatePath(targetDir), state);
64
+ } catch {
65
+ // Losing the file costs the run its cross-session budgets, which is bad,
66
+ // but throwing here would kill the session, which is worse.
67
+ }
68
+ }
69
+
70
+ /** Arm a run. Reuses the existing run id when one is already armed. */
71
+ export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
72
+ const existing = loadRunState(targetDir);
73
+ const state =
74
+ existing && existing.armed
75
+ ? { ...existing, stoppedAt: null, stopReason: null }
76
+ : newRunState(runId, now);
77
+ saveRunState(targetDir, state);
78
+ return state;
79
+ }
80
+
81
+ export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
82
+ const existing = loadRunState(targetDir);
83
+ if (!existing) return null;
84
+ const state: RunState = {
85
+ ...existing,
86
+ armed: false,
87
+ stoppedAt: now.toISOString(),
88
+ stopReason: reason,
89
+ };
90
+ saveRunState(targetDir, state);
91
+ return state;
92
+ }
93
+
94
+ /** Count one more pi session against this run. Called from `session_start`. */
95
+ export function countSession(targetDir: string): RunState | null {
96
+ const existing = loadRunState(targetDir);
97
+ if (!existing) return null;
98
+ const state = { ...existing, sessions: existing.sessions + 1 };
99
+ saveRunState(targetDir, state);
100
+ return state;
101
+ }
102
+
103
+ export function clearRunState(targetDir: string): void {
104
+ try {
105
+ removeFile(runStatePath(targetDir));
106
+ } catch {
107
+ /* nothing to clear */
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The run id the loop should use.
113
+ *
114
+ * An armed run keeps its id so `loadLoopState` finds the same budgets after a
115
+ * handoff. With nothing armed, the caller's session id is the run id — an
116
+ * ad-hoc `/infinity:validate` should not inherit a finished run's strikes.
117
+ */
118
+ export function runIdFor(targetDir: string, fallback: string): string {
119
+ const state = loadRunState(targetDir);
120
+ return state && state.armed ? state.runId : fallback;
121
+ }
@@ -38,6 +38,7 @@ import {
38
38
  import { getPhaseOrder } from "../core/phases.ts";
39
39
  import { statusGlyph } from "./widget.ts";
40
40
  import { UNICODE_GLYPHS } from "./theme.ts";
41
+ import { groupPlan, type PlanGoalGroup, type PlanSprintGroup } from "./planTree.ts";
41
42
 
42
43
  export type DashboardState = {
43
44
  list: FeatureList;
@@ -52,6 +53,12 @@ export type DashboardState = {
52
53
  router?: unknown;
53
54
  /** Rework record. Opaque here — rendered as a badge, never interpreted. */
54
55
  rework?: unknown;
56
+ /** A phase whose gate passed and which is waiting for a human signature. */
57
+ awaitingApproval?: string | null;
58
+ /** How many pi sessions this run has spent, when handoff is on. */
59
+ sessions?: number | null;
60
+ /** Which goal pass this is, out of how many. */
61
+ goalPass?: { current: number; max: number } | null;
55
62
  };
56
63
 
57
64
  // ── escaping ────────────────────────────────────────────────────────────────
@@ -361,10 +368,30 @@ function renderAlerts(
361
368
  paused: boolean,
362
369
  retries: DashboardState["retries"],
363
370
  gate: GateResult | null,
371
+ extra: {
372
+ awaitingApproval?: string | null;
373
+ sessions?: number | null;
374
+ goalPass?: { current: number; max: number } | null;
375
+ } = {},
364
376
  ): string {
365
377
  const alerts: Alert[] = [];
366
378
 
367
379
  if (paused) alerts.push({ tone: "blocked", text: "run paused" });
380
+ // A run parked for a signature looks, from every other indicator, exactly
381
+ // like a run that has stopped. Saying so is the difference between someone
382
+ // coming back to a finished phase and someone coming back to a dead run.
383
+ if (extra.awaitingApproval) {
384
+ alerts.push({
385
+ tone: "active",
386
+ text: `${String(extra.awaitingApproval).toUpperCase()} is waiting for your approval`,
387
+ });
388
+ }
389
+ if (extra.goalPass && extra.goalPass.max > 1) {
390
+ alerts.push({
391
+ tone: extra.goalPass.current >= extra.goalPass.max ? "blocked" : "active",
392
+ text: `goal pass ${extra.goalPass.current} / ${extra.goalPass.max}`,
393
+ });
394
+ }
368
395
  if (counts.blocked > 0) {
369
396
  alerts.push({ tone: "blocked", text: `${counts.blocked} blocked ${counts.blocked === 1 ? "task" : "tasks"}` });
370
397
  }
@@ -378,6 +405,9 @@ function renderAlerts(
378
405
  text: `retry budget ${retries.task} / ${retries.max}${spent ? " — exhausted" : ""}`,
379
406
  });
380
407
  }
408
+ if (typeof extra.sessions === "number" && extra.sessions > 1) {
409
+ alerts.push({ tone: "quiet", text: `${extra.sessions} sessions this run` });
410
+ }
381
411
  if (gate && gate.overall === false) {
382
412
  const failures = Array.isArray(gate.failures) ? gate.failures : [];
383
413
  const head = failures.slice(0, 3).join(", ");
@@ -453,6 +483,15 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
453
483
  return `<span class="deps mono" title="depends on">${esc(GLYPHS.arrow)} ${refs.join(", ")}</span>`;
454
484
  }
455
485
 
486
+ /**
487
+ * Every subtask, on every task.
488
+ *
489
+ * The terminal widget shows these only for the task being worked, because it
490
+ * has nine rows and a job to do with them. The dashboard has a whole page and
491
+ * a scrollbar: the reason to open it is precisely to see the detail the widget
492
+ * cannot fit, so hiding four of the five plan levels here made it a worse copy
493
+ * of the widget rather than the place you go for the full picture.
494
+ */
456
495
  function renderSubtasks(task: FlatTask): string {
457
496
  const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
458
497
  if (subs.length === 0) return "";
@@ -470,8 +509,6 @@ function renderSubtasks(task: FlatTask): string {
470
509
  function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>): string {
471
510
  const status = task.status;
472
511
  const cls = STATUS_CLASS[status];
473
- // Subtasks are shown only for the task actually being worked on. Everywhere
474
- // else they are detail nobody can act on, and they bury the active row.
475
512
  const isActive = status === "in_progress" || status === "rework";
476
513
  const difficulty =
477
514
  typeof task.difficulty === "string" ? `<span class="chip chip-quiet">${esc(task.difficulty)}</span>` : "";
@@ -487,7 +524,7 @@ function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>):
487
524
  ${difficulty}
488
525
  ${depLabel(task, indexByKey)}
489
526
  </div>
490
- ${isActive ? renderSubtasks(task) : ""}
527
+ ${renderSubtasks(task)}
491
528
  </td>
492
529
  <td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
493
530
  </tr>`;
@@ -536,6 +573,59 @@ function renderFeature(
536
573
  </section>`;
537
574
  }
538
575
 
576
+ /**
577
+ * A goal, its sprints, and their features — one collapsible section per level.
578
+ *
579
+ * `<details open>` is deliberate: everything is visible on load, and a human
580
+ * reading a 60-task plan can fold away the parts they are not looking at
581
+ * without the page needing a line of state management.
582
+ */
583
+ function renderGoalGroup(
584
+ group: PlanGoalGroup,
585
+ tasksByFeature: ReadonlyMap<string, FlatTask[]>,
586
+ indexByKey: ReadonlyMap<string, number>,
587
+ show: { showGoal: boolean; showSprints: boolean },
588
+ ): string {
589
+ const sprints = group.sprints
590
+ .map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints))
591
+ .join("");
592
+
593
+ if (!show.showGoal || !group.goal) return sprints;
594
+
595
+ return `<details class="tier tier-goal" open>
596
+ <summary class="tier-head">
597
+ <span class="tier-kind">goal</span>
598
+ <span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
599
+ <span class="mono faint">${esc(group.goal.id ?? "")}</span>
600
+ <span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
601
+ </summary>
602
+ <div class="tier-body">${sprints}</div>
603
+ </details>`;
604
+ }
605
+
606
+ function renderSprintGroup(
607
+ group: PlanSprintGroup,
608
+ tasksByFeature: ReadonlyMap<string, FlatTask[]>,
609
+ indexByKey: ReadonlyMap<string, number>,
610
+ showSprints: boolean,
611
+ ): string {
612
+ const features = group.features
613
+ .map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null))
614
+ .join("");
615
+
616
+ if (!showSprints || !group.sprint) return features;
617
+
618
+ return `<details class="tier tier-sprint" open>
619
+ <summary class="tier-head">
620
+ <span class="tier-kind">sprint</span>
621
+ <span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
622
+ <span class="mono faint">${esc(group.sprint.id ?? "")}</span>
623
+ <span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
624
+ </summary>
625
+ <div class="tier-body">${features}</div>
626
+ </details>`;
627
+ }
628
+
539
629
  function renderEmptyPlan(phase: Phase | null): string {
540
630
  const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
541
631
  return `<section class="card empty">
@@ -766,6 +856,7 @@ body{
766
856
  .alert-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.10);border-color:rgba(var(--rgb-blocked),.30)}
767
857
  .alert-rework{color:var(--t-rework);background:rgba(var(--rgb-rework),.10);border-color:rgba(var(--rgb-rework),.28)}
768
858
  .alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
859
+ .alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
769
860
 
770
861
  /* -- gate ----------------------------------------------------------------- */
771
862
  .gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
@@ -857,6 +948,34 @@ table.tasks tr:last-child td{border-bottom:0}
857
948
  margin:26px 2px 2px;font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;
858
949
  color:var(--faint);font-weight:600;
859
950
  }
951
+
952
+ /* -- plan tiers: goal > sprint > feature ---------------------------------- */
953
+ /* Depth is carried by a left rule rather than indentation, so a 60-task plan
954
+ does not walk off the right edge of a phone. */
955
+ .tier{margin:18px 0 0}
956
+ .tier-head{
957
+ display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;cursor:pointer;
958
+ padding:7px 2px;list-style:none;border-radius:8px;
959
+ }
960
+ .tier-head::-webkit-details-marker{display:none}
961
+ .tier-head:hover{background:var(--surface-2)}
962
+ .tier-head:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}
963
+ .tier-kind{
964
+ font-size:10px;text-transform:uppercase;letter-spacing:.1em;font-weight:700;
965
+ padding:2px 7px;border-radius:999px;border:1px solid var(--border);color:var(--faint);
966
+ }
967
+ .tier-goal>.tier-head .tier-kind{color:var(--t-brand);border-color:var(--c-brand)}
968
+ .tier-sprint>.tier-head .tier-kind{color:var(--t-accent);border-color:var(--c-accent)}
969
+ .tier-name{font-size:15px;font-weight:650;overflow-wrap:anywhere}
970
+ .tier-goal>.tier-head .tier-name{font-size:17px}
971
+ .tier-count{margin-left:auto;color:var(--muted);font-size:13px;flex:none}
972
+ .tier-body{
973
+ margin-left:5px;padding-left:14px;border-left:2px solid var(--border);
974
+ }
975
+ .tier-goal>.tier-body{border-left-color:var(--c-brand)}
976
+ .tier-sprint>.tier-body{border-left-color:var(--c-accent)}
977
+ .tier[open]>.tier-head .tier-kind{opacity:.85}
978
+ .tier:not([open])>.tier-head{opacity:.72}
860
979
  .foot{
861
980
  display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:space-between;
862
981
  margin-top:22px;padding-top:14px;border-top:1px solid var(--border);
@@ -1009,19 +1128,20 @@ export function renderDashboard(state: DashboardState): string {
1009
1128
  (b): b is Badge => b !== null,
1010
1129
  );
1011
1130
 
1131
+ // The plan is five levels deep, and it is drawn five levels deep: goals hold
1132
+ // sprints hold features hold tasks hold subtasks. Rendering it flat — which
1133
+ // is what this page used to do, with the goal and sprint reduced to two
1134
+ // chips on a feature card — throws away the only structure that tells you
1135
+ // whether the run is nearly done with something or scattered across
1136
+ // everything.
1137
+ const groups = groupPlan(list);
1012
1138
  const body = features.length
1013
- ? `<div class="section-label">Features (${esc(String(features.length))})</div>` +
1014
- features
1015
- .map((f: Feature) =>
1016
- renderFeature(
1017
- f,
1018
- tasksByFeature.get(f.id) ?? [],
1019
- indexByKey,
1020
- f.sprintId ? (sprintNames.get(f.sprintId) ?? null) : null,
1021
- // A goal chip only earns its place when there is more than one goal
1022
- // to tell apart.
1023
- goals.length > 1 && f.goalId ? (goalNames.get(f.goalId) ?? null) : null,
1024
- ),
1139
+ ? groups
1140
+ .map((group) =>
1141
+ renderGoalGroup(group, tasksByFeature, indexByKey, {
1142
+ showGoal: goals.length > 0,
1143
+ showSprints: sprints.length > 0,
1144
+ }),
1025
1145
  )
1026
1146
  .join("")
1027
1147
  : renderEmptyPlan(state.phase);
@@ -1050,7 +1170,11 @@ export function renderDashboard(state: DashboardState): string {
1050
1170
  ${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
1051
1171
  ${renderGoals(goals)}
1052
1172
  ${renderRail(state.phase, state.enabledPhases, paused)}
1053
- ${renderAlerts(counts, paused, state.retries, gate)}
1173
+ ${renderAlerts(counts, paused, state.retries, gate, {
1174
+ awaitingApproval: state.awaitingApproval ?? null,
1175
+ sessions: state.sessions ?? null,
1176
+ goalPass: state.goalPass ?? null,
1177
+ })}
1054
1178
  ${renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal)}
1055
1179
  ${renderGate(gate)}
1056
1180
  ${body}