infinity-harness 2.3.1 → 2.5.0

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.
@@ -39,6 +39,8 @@ import { getPhaseOrder } from "../core/phases.ts";
39
39
  import { statusGlyph } from "./widget.ts";
40
40
  import { UNICODE_GLYPHS } from "./theme.ts";
41
41
  import { groupPlan, type PlanGoalGroup, type PlanSprintGroup } from "./planTree.ts";
42
+ import { defaultDisplay, normalizeDisplay } from "./display.ts";
43
+ import type { DisplayPolicy } from "../core/types.ts";
42
44
 
43
45
  export type DashboardState = {
44
46
  list: FeatureList;
@@ -59,6 +61,11 @@ export type DashboardState = {
59
61
  sessions?: number | null;
60
62
  /** Which goal pass this is, out of how many. */
61
63
  goalPass?: { current: number; max: number } | null;
64
+ /**
65
+ * What this reader has asked to see — the same policy the terminal widget
66
+ * reads, so a level turned off in one is off in the other.
67
+ */
68
+ display?: DisplayPolicy | null;
62
69
  };
63
70
 
64
71
  // ── escaping ────────────────────────────────────────────────────────────────
@@ -492,7 +499,8 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
492
499
  * cannot fit, so hiding four of the five plan levels here made it a worse copy
493
500
  * of the widget rather than the place you go for the full picture.
494
501
  */
495
- function renderSubtasks(task: FlatTask): string {
502
+ function renderSubtasks(task: FlatTask, mode: DisplayPolicy["levels"]["subtask"], active: boolean): string {
503
+ if (mode === "none" || (mode === "active" && !active)) return "";
496
504
  const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
497
505
  if (subs.length === 0) return "";
498
506
  const items = subs
@@ -506,7 +514,11 @@ function renderSubtasks(task: FlatTask): string {
506
514
  return `<ul class="subs">${items}</ul>`;
507
515
  }
508
516
 
509
- function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>): string {
517
+ function renderTaskRow(
518
+ task: FlatTask,
519
+ indexByKey: ReadonlyMap<string, number>,
520
+ display: DisplayPolicy,
521
+ ): string {
510
522
  const status = task.status;
511
523
  const cls = STATUS_CLASS[status];
512
524
  const isActive = status === "in_progress" || status === "rework";
@@ -522,9 +534,9 @@ function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>):
522
534
  <div class="task-line">
523
535
  <span class="task-desc">${esc(task.description || task.compositeKey)}</span>
524
536
  ${difficulty}
525
- ${depLabel(task, indexByKey)}
537
+ ${display.dependencies ? depLabel(task, indexByKey) : ""}
526
538
  </div>
527
- ${renderSubtasks(task)}
539
+ ${renderSubtasks(task, display.levels.subtask, isActive)}
528
540
  </td>
529
541
  <td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
530
542
  </tr>`;
@@ -536,10 +548,13 @@ function renderFeature(
536
548
  indexByKey: ReadonlyMap<string, number>,
537
549
  sprintName: string | null,
538
550
  goalName: string | null,
551
+ display: DisplayPolicy,
552
+ isCurrent = false,
539
553
  ): string {
540
554
  const counts = countByStatus(tasks);
541
555
  const total = tasks.length;
542
556
  const complete = total > 0 && counts.complete === total;
557
+ const current = isCurrent && !complete;
543
558
 
544
559
  const chips = [
545
560
  sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
@@ -547,16 +562,25 @@ function renderFeature(
547
562
  feature.passes === true ? `<span class="chip chip-complete">verified</span>` : "",
548
563
  ].join("");
549
564
 
550
- const body = total
551
- ? `<div class="table-wrap">
565
+ const body = !display.levels.task
566
+ ? ""
567
+ : total
568
+ ? `<div class="table-wrap">
552
569
  <table class="tasks">
553
570
  <thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
554
- <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey)).join("")}</tbody>
571
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
555
572
  </table>
556
573
  </div>`
557
- : `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
574
+ : `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
575
+
576
+ const criteria =
577
+ display.criteria && Array.isArray(feature.criteria) && feature.criteria.length
578
+ ? `<ul class="criteria">${feature.criteria
579
+ .map((c) => `<li>${esc(String(c))}</li>`)
580
+ .join("")}</ul>`
581
+ : "";
558
582
 
559
- return `<section class="card feature${complete ? " is-complete" : ""}">
583
+ return `<section class="card feature${complete ? " is-complete" : ""}${current ? " is-current" : ""}">
560
584
  <div class="feature-head">
561
585
  <div class="feature-id">
562
586
  <h2 class="feature-name">${esc(feature.name ?? feature.id ?? "")}</h2>
@@ -564,11 +588,12 @@ function renderFeature(
564
588
  ${chips}
565
589
  </div>
566
590
  <div class="feature-progress">
567
- <span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>
591
+ ${display.counts ? `<span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>` : ""}
568
592
  ${meter(counts, total)}
569
593
  </div>
570
594
  </div>
571
595
  ${feature.description ? `<p class="feature-desc">${esc(feature.description)}</p>` : ""}
596
+ ${criteria}
572
597
  ${body}
573
598
  </section>`;
574
599
  }
@@ -585,19 +610,22 @@ function renderGoalGroup(
585
610
  tasksByFeature: ReadonlyMap<string, FlatTask[]>,
586
611
  indexByKey: ReadonlyMap<string, number>,
587
612
  show: { showGoal: boolean; showSprints: boolean },
613
+ display: DisplayPolicy,
614
+ activeFeatureId?: string | null,
588
615
  ): string {
616
+ const activeGoal = activeFeatureId ? group.sprints.some((sg) => sg.features.some((f) => f.id === activeFeatureId)) : false;
589
617
  const sprints = group.sprints
590
- .map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints))
618
+ .map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display, activeFeatureId))
591
619
  .join("");
592
620
 
593
621
  if (!show.showGoal || !group.goal) return sprints;
594
622
 
595
- return `<details class="tier tier-goal" open>
623
+ return `<details class="tier tier-goal${activeGoal ? " is-current" : ""}" open>
596
624
  <summary class="tier-head">
597
625
  <span class="tier-kind">goal</span>
598
626
  <span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
599
627
  <span class="mono faint">${esc(group.goal.id ?? "")}</span>
600
- <span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
628
+ ${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
601
629
  </summary>
602
630
  <div class="tier-body">${sprints}</div>
603
631
  </details>`;
@@ -608,24 +636,49 @@ function renderSprintGroup(
608
636
  tasksByFeature: ReadonlyMap<string, FlatTask[]>,
609
637
  indexByKey: ReadonlyMap<string, number>,
610
638
  showSprints: boolean,
639
+ display: DisplayPolicy,
640
+ activeFeatureId?: string | null,
611
641
  ): string {
612
- const features = group.features
613
- .map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null))
614
- .join("");
642
+ const activeSprint = activeFeatureId ? group.features.some((f) => f.id === activeFeatureId) : false;
643
+ const features = display.levels.feature
644
+ ? group.features
645
+ .map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display, f.id === activeFeatureId))
646
+ .join("")
647
+ : // Hiding the feature card must not hide its tasks: they move up into the
648
+ // sprint, which is what "hide features" has to mean on a page whose whole
649
+ // job is to show the plan.
650
+ renderLooseTasks(group.features.flatMap((f) => tasksByFeature.get(f.id) ?? []), indexByKey, display);
615
651
 
616
652
  if (!showSprints || !group.sprint) return features;
617
653
 
618
- return `<details class="tier tier-sprint" open>
654
+ return `<details class="tier tier-sprint${activeSprint ? " is-current" : ""}" open>
619
655
  <summary class="tier-head">
620
656
  <span class="tier-kind">sprint</span>
621
657
  <span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
622
658
  <span class="mono faint">${esc(group.sprint.id ?? "")}</span>
623
- <span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
659
+ ${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
624
660
  </summary>
625
661
  <div class="tier-body">${features}</div>
626
662
  </details>`;
627
663
  }
628
664
 
665
+ /** Tasks with no feature card above them, for the templates that hide features. */
666
+ function renderLooseTasks(
667
+ tasks: readonly FlatTask[],
668
+ indexByKey: ReadonlyMap<string, number>,
669
+ display: DisplayPolicy,
670
+ ): string {
671
+ if (!display.levels.task || tasks.length === 0) return "";
672
+ return `<section class="card feature">
673
+ <div class="table-wrap">
674
+ <table class="tasks">
675
+ <thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
676
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
677
+ </table>
678
+ </div>
679
+ </section>`;
680
+ }
681
+
629
682
  function renderEmptyPlan(phase: Phase | null): string {
630
683
  const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
631
684
  return `<section class="card empty">
@@ -858,6 +911,11 @@ body{
858
911
  .alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
859
912
  .alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
860
913
 
914
+ /* -- acceptance criteria -------------------------------------------------- */
915
+ .criteria{margin:10px 0 0;padding:0 0 0 18px;color:var(--muted);font-size:13px}
916
+ .criteria li{margin:3px 0;overflow-wrap:anywhere}
917
+ .criteria li::marker{color:var(--t-accent)}
918
+
861
919
  /* -- gate ----------------------------------------------------------------- */
862
920
  .gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
863
921
  .gate-phase{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;overflow-wrap:anywhere}
@@ -913,11 +971,17 @@ table.tasks tr:last-child td{border-bottom:0}
913
971
  .task-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}
914
972
  .task-desc{overflow-wrap:anywhere}
915
973
  .row-complete .task-desc{color:var(--muted)}
916
- .row.is-active{background:rgba(var(--rgb-active),.07)}
974
+ .row.is-active{background:rgba(var(--rgb-active),.07);animation:taskBlink 1.2s ease-in-out infinite}
917
975
  .row.is-active .task-desc{font-weight:600}
918
976
  .row.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-active)}
919
977
  .row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
920
978
  .row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
979
+ @keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
980
+ /* While-developed blinking for the whole current branch */
981
+ .tier.is-current,.feature.is-current{animation:cardPulse 1.4s ease-in-out infinite}
982
+ .tier.is-current .tier-name,.feature.is-current .feature-name{animation:textPulse 1.2s ease-in-out infinite}
983
+ @keyframes cardPulse{0%,100%{box-shadow:var(--shadow)}50%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.22),var(--shadow)}}
984
+ @keyframes textPulse{0%,100%{opacity:1}50%{opacity:.65}}
921
985
  .row-blocked{background:rgba(var(--rgb-blocked),.07)}
922
986
  .row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
923
987
  .deps{color:var(--faint);white-space:nowrap}
@@ -1134,14 +1198,25 @@ export function renderDashboard(state: DashboardState): string {
1134
1198
  // chips on a feature card — throws away the only structure that tells you
1135
1199
  // whether the run is nearly done with something or scattered across
1136
1200
  // everything.
1201
+ const display = normalizeDisplay(state.display ?? defaultDisplay());
1137
1202
  const groups = groupPlan(list);
1203
+ // Which feature/sprint/goal is currently being worked (for blinking).
1204
+ const activeTask = tasks.find((t) => t.status === "in_progress" || t.status === "rework") ?? tasks.find((t) => t.status === "pending") ?? null;
1205
+ const activeFeatureId = activeTask?.featureId ?? null;
1138
1206
  const body = features.length
1139
1207
  ? groups
1140
1208
  .map((group) =>
1141
- renderGoalGroup(group, tasksByFeature, indexByKey, {
1142
- showGoal: goals.length > 0,
1143
- showSprints: sprints.length > 0,
1144
- }),
1209
+ renderGoalGroup(
1210
+ group,
1211
+ tasksByFeature,
1212
+ indexByKey,
1213
+ {
1214
+ showGoal: display.levels.goal && goals.length > 0,
1215
+ showSprints: display.levels.sprint && sprints.length > 0,
1216
+ },
1217
+ display,
1218
+ activeFeatureId,
1219
+ ),
1145
1220
  )
1146
1221
  .join("")
1147
1222
  : renderEmptyPlan(state.phase);
@@ -1168,14 +1243,18 @@ export function renderDashboard(state: DashboardState): string {
1168
1243
  <div id="app">
1169
1244
  <div class="page">
1170
1245
  ${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
1171
- ${renderGoals(goals)}
1172
- ${renderRail(state.phase, state.enabledPhases, paused)}
1173
- ${renderAlerts(counts, paused, state.retries, gate, {
1174
- awaitingApproval: state.awaitingApproval ?? null,
1175
- sessions: state.sessions ?? null,
1176
- goalPass: state.goalPass ?? null,
1177
- })}
1178
- ${renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal)}
1246
+ ${display.levels.goal ? renderGoals(goals) : ""}
1247
+ ${display.rail ? renderRail(state.phase, state.enabledPhases, paused) : ""}
1248
+ ${
1249
+ display.alerts
1250
+ ? renderAlerts(counts, paused, state.retries, gate, {
1251
+ awaitingApproval: state.awaitingApproval ?? null,
1252
+ sessions: state.sessions ?? null,
1253
+ goalPass: state.goalPass ?? null,
1254
+ })
1255
+ : ""
1256
+ }
1257
+ ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1179
1258
  ${renderGate(gate)}
1180
1259
  ${body}
1181
1260
  <footer class="foot">
@@ -0,0 +1,267 @@
1
+ /**
2
+ * infinity-harness — display templates: what the surfaces actually draw.
3
+ *
4
+ * The plan has five levels, and shipping all five to everyone was the wrong
5
+ * answer for the same reason shipping two was: two people watching the same
6
+ * run want different things on screen. One works in sprints and never opens a
7
+ * subtask. The next has no sprints at all and lives in the subtask list. A run
8
+ * on a forty-task plan wants the shape; a run on one feature wants the detail.
9
+ *
10
+ * So the levels are a setting, three templates cover the common shapes, and
11
+ * anything else is a template the person builds and names. The terminal widget
12
+ * and the web dashboard read the same one, so what you configure once is what
13
+ * you see in both.
14
+ *
15
+ * Templates live with the *person* — `~/.pi/agent/infinity-harness/` — because
16
+ * how you like to read a plan does not change when you change project.
17
+ */
18
+
19
+ import type { DisplayPolicy } from "../core/types.ts";
20
+ import { userDisplayPath } from "../core/paths.ts";
21
+ import { readJsonSafe, writeJsonAtomic, ensureDir } from "../core/fsx.ts";
22
+ import { dirname } from "node:path";
23
+
24
+ export type DisplayTemplate = {
25
+ id: string;
26
+ name: string;
27
+ description: string;
28
+ builtIn: boolean;
29
+ policy: DisplayPolicy;
30
+ savedAt?: string;
31
+ };
32
+
33
+ /** Everything on, at a size a terminal can hold. */
34
+ export function defaultDisplay(): DisplayPolicy {
35
+ return {
36
+ preset: "focus",
37
+ levels: { goal: true, sprint: true, feature: true, task: true, subtask: "active" },
38
+ counts: true,
39
+ dependencies: true,
40
+ rail: true,
41
+ progress: true,
42
+ alerts: true,
43
+ criteria: true,
44
+ taskWindow: 9,
45
+ };
46
+ }
47
+
48
+ const focus: DisplayPolicy = defaultDisplay();
49
+
50
+ const everything: DisplayPolicy = {
51
+ ...defaultDisplay(),
52
+ preset: "everything",
53
+ levels: { goal: true, sprint: true, feature: true, task: true, subtask: "all" },
54
+ taskWindow: 14,
55
+ };
56
+
57
+ /**
58
+ * The shape of the run, without the work.
59
+ *
60
+ * For a plan too big to read: goals and sprints and features with their
61
+ * counts, and no tasks at all. You come here to see which branch is stuck.
62
+ */
63
+ const overview: DisplayPolicy = {
64
+ ...defaultDisplay(),
65
+ preset: "overview",
66
+ levels: { goal: true, sprint: true, feature: true, task: false, subtask: "none" },
67
+ dependencies: false,
68
+ criteria: false,
69
+ taskWindow: 12,
70
+ };
71
+
72
+ /**
73
+ * The work, and nothing else.
74
+ *
75
+ * For someone who already knows the plan and wants the next thing to do.
76
+ * No grouping rows, no meters, no rail.
77
+ */
78
+ const worklist: DisplayPolicy = {
79
+ ...defaultDisplay(),
80
+ preset: "worklist",
81
+ levels: { goal: false, sprint: false, feature: false, task: true, subtask: "active" },
82
+ counts: false,
83
+ rail: false,
84
+ progress: true,
85
+ alerts: true,
86
+ criteria: false,
87
+ taskWindow: 12,
88
+ };
89
+
90
+ export const BUILTIN_DISPLAYS: DisplayTemplate[] = [
91
+ {
92
+ id: "focus",
93
+ name: "focus",
94
+ description: "The default: every level, with subtasks on the task being worked.",
95
+ builtIn: true,
96
+ policy: focus,
97
+ },
98
+ {
99
+ id: "everything",
100
+ name: "everything",
101
+ description: "All five levels, every subtask on every task, and a taller window.",
102
+ builtIn: true,
103
+ policy: everything,
104
+ },
105
+ {
106
+ id: "overview",
107
+ name: "overview",
108
+ description: "Goals, sprints and features with their counts. No tasks — the shape, not the work.",
109
+ builtIn: true,
110
+ policy: overview,
111
+ },
112
+ {
113
+ id: "worklist",
114
+ name: "worklist",
115
+ description: "Tasks only. No grouping rows, no rail — for when you already know the plan.",
116
+ builtIn: true,
117
+ policy: worklist,
118
+ },
119
+ ];
120
+
121
+ export function builtInDisplay(id: string): DisplayTemplate | null {
122
+ return BUILTIN_DISPLAYS.find((d) => d.id === id) ?? null;
123
+ }
124
+
125
+ // ── the saved store ─────────────────────────────────────────────────────────
126
+
127
+ type SavedStore = { version: string; displays: DisplayTemplate[] };
128
+
129
+ export function slugify(name: string): string {
130
+ return String(name ?? "")
131
+ .trim()
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9]+/g, "-")
134
+ .replace(/^-+|-+$/g, "")
135
+ .slice(0, 48);
136
+ }
137
+
138
+ export function loadSavedDisplays(env?: NodeJS.ProcessEnv): DisplayTemplate[] {
139
+ const store = readJsonSafe<SavedStore | null>(userDisplayPath(env), null);
140
+ const list = Array.isArray(store?.displays) ? store.displays : [];
141
+ return list
142
+ .filter((d): d is DisplayTemplate => typeof d?.id === "string" && typeof d?.name === "string")
143
+ .map((d) => ({ ...d, builtIn: false, policy: normalizeDisplay(d.policy) }));
144
+ }
145
+
146
+ export function listDisplays(env?: NodeJS.ProcessEnv): DisplayTemplate[] {
147
+ return [...BUILTIN_DISPLAYS, ...loadSavedDisplays(env)];
148
+ }
149
+
150
+ export function findDisplay(id: string, env?: NodeJS.ProcessEnv): DisplayTemplate | null {
151
+ return listDisplays(env).find((d) => d.id === id) ?? null;
152
+ }
153
+
154
+ export type SaveResult = { ok: boolean; error: string | null; template?: DisplayTemplate };
155
+
156
+ export function saveDisplay(
157
+ input: { name: string; description?: string; policy: DisplayPolicy },
158
+ env?: NodeJS.ProcessEnv,
159
+ ): SaveResult {
160
+ const name = String(input.name ?? "").trim();
161
+ if (!name) return { ok: false, error: "A template needs a name." };
162
+ const id = slugify(name);
163
+ if (!id) return { ok: false, error: `"${name}" has no letters or digits in it.` };
164
+ if (builtInDisplay(id)) {
165
+ return { ok: false, error: `"${name}" is a built-in template. Pick another name.` };
166
+ }
167
+
168
+ const policy = { ...normalizeDisplay(input.policy), preset: id };
169
+ const template: DisplayTemplate = {
170
+ id,
171
+ name,
172
+ description: (input.description ?? "").trim() || describeDisplay(policy),
173
+ builtIn: false,
174
+ policy,
175
+ savedAt: new Date().toISOString(),
176
+ };
177
+
178
+ const existing = loadSavedDisplays(env).filter((d) => d.id !== id);
179
+ const path = userDisplayPath(env);
180
+ try {
181
+ ensureDir(dirname(path));
182
+ writeJsonAtomic(path, { version: "1", displays: [...existing, template] } satisfies SavedStore);
183
+ } catch (e) {
184
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
185
+ }
186
+ return { ok: true, error: null, template };
187
+ }
188
+
189
+ export function deleteDisplay(id: string, env?: NodeJS.ProcessEnv): SaveResult {
190
+ if (builtInDisplay(id)) return { ok: false, error: "Built-in templates cannot be deleted." };
191
+ const remaining = loadSavedDisplays(env).filter((d) => d.id !== id);
192
+ const path = userDisplayPath(env);
193
+ try {
194
+ ensureDir(dirname(path));
195
+ writeJsonAtomic(path, { version: "1", displays: remaining } satisfies SavedStore);
196
+ } catch (e) {
197
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
198
+ }
199
+ return { ok: true, error: null };
200
+ }
201
+
202
+ // ── normalising ─────────────────────────────────────────────────────────────
203
+
204
+ const SUBTASK_MODES = new Set(["none", "active", "all"]);
205
+
206
+ /**
207
+ * Repair a policy read from disk.
208
+ *
209
+ * Every field is read on every render, in a lifecycle hook, so a hand-edited
210
+ * config with `levels: "yes"` in it must produce a widget rather than an
211
+ * exception that takes the session down.
212
+ */
213
+ export function normalizeDisplay(raw: unknown): DisplayPolicy {
214
+ const base = defaultDisplay();
215
+ if (typeof raw !== "object" || raw === null) return base;
216
+ const r = raw as Partial<DisplayPolicy> & { levels?: Partial<DisplayPolicy["levels"]> };
217
+ const bool = (v: unknown, fallback: boolean): boolean => (typeof v === "boolean" ? v : fallback);
218
+
219
+ const window = typeof r.taskWindow === "number" && Number.isFinite(r.taskWindow) ? r.taskWindow : base.taskWindow;
220
+
221
+ return {
222
+ preset: typeof r.preset === "string" && r.preset.trim() ? r.preset.trim() : base.preset,
223
+ levels: {
224
+ goal: bool(r.levels?.goal, base.levels.goal),
225
+ sprint: bool(r.levels?.sprint, base.levels.sprint),
226
+ feature: bool(r.levels?.feature, base.levels.feature),
227
+ task: bool(r.levels?.task, base.levels.task),
228
+ subtask: SUBTASK_MODES.has(String(r.levels?.subtask))
229
+ ? (r.levels?.subtask as DisplayPolicy["levels"]["subtask"])
230
+ : base.levels.subtask,
231
+ },
232
+ counts: bool(r.counts, base.counts),
233
+ dependencies: bool(r.dependencies, base.dependencies),
234
+ rail: bool(r.rail, base.rail),
235
+ progress: bool(r.progress, base.progress),
236
+ alerts: bool(r.alerts, base.alerts),
237
+ criteria: bool(r.criteria, base.criteria),
238
+ // A window of zero is a widget with no plan in it, which nobody wants and
239
+ // which a typo can produce.
240
+ taskWindow: Math.max(3, Math.min(60, Math.round(window))),
241
+ };
242
+ }
243
+
244
+ /** Which template a policy currently matches, if any. */
245
+ export function matchDisplay(policy: DisplayPolicy, env?: NodeJS.ProcessEnv): DisplayTemplate | null {
246
+ const same = (a: DisplayPolicy, b: DisplayPolicy): boolean =>
247
+ JSON.stringify({ ...normalizeDisplay(a), preset: "" }) ===
248
+ JSON.stringify({ ...normalizeDisplay(b), preset: "" });
249
+ return listDisplays(env).find((d) => same(d.policy, policy)) ?? null;
250
+ }
251
+
252
+ export function describeDisplay(policy: DisplayPolicy): string {
253
+ const p = normalizeDisplay(policy);
254
+ const on = (["goal", "sprint", "feature", "task"] as const).filter((k) => p.levels[k]);
255
+ const subtasks =
256
+ p.levels.subtask === "all" ? "all subtasks" : p.levels.subtask === "active" ? "active subtasks" : null;
257
+ const shown = [...on, ...(subtasks ? [subtasks] : [])];
258
+ return shown.length ? shown.join(" · ") : "nothing — every level is hidden";
259
+ }
260
+
261
+ /** One line for a menu row: `focus · goal · sprint · feature · task · active subtasks`. */
262
+ export function summarizeDisplay(policy: DisplayPolicy, env?: NodeJS.ProcessEnv): string {
263
+ const p = normalizeDisplay(policy);
264
+ const named = matchDisplay(p, env);
265
+ const label = named ? named.name : `${p.preset} (edited)`;
266
+ return `${label} · ${describeDisplay(p)}`;
267
+ }
@@ -55,6 +55,14 @@ export type PlanTreeOptions = {
55
55
  * goal and no sprints should not spend two rows saying so.
56
56
  */
57
57
  collapseTrivial?: boolean;
58
+ /**
59
+ * Which levels the reader has asked to see.
60
+ *
61
+ * A hidden level does not hide what is under it: turning off sprints on a
62
+ * plan organised into sprints must still show the features, one indent
63
+ * shallower, or the setting silently deletes half the plan from view.
64
+ */
65
+ levels?: Partial<Record<PlanLevel, boolean>>;
58
66
  };
59
67
 
60
68
  function taskCounts(tasks: Task[] | undefined): { done: number; total: number } {
@@ -185,35 +193,48 @@ export function buildPlanRows(
185
193
  ): PlanRow[] {
186
194
  const collapse = options.collapseTrivial !== false;
187
195
  const groups = groupPlan(list);
196
+ const wants = (level: PlanLevel): boolean => options.levels?.[level] !== false;
188
197
 
189
198
  // A single goal is the run's headline and the surface draws it separately.
190
- const showGoals = collapse ? groups.filter((g) => g.goal !== null).length > 1 : groups.some((g) => g.goal);
191
- const showSprints = groups.some((g) => g.sprints.some((sg) => sg.sprint !== null));
199
+ const showGoals =
200
+ wants("goal") &&
201
+ (collapse ? groups.filter((g) => g.goal !== null).length > 1 : groups.some((g) => g.goal));
202
+ const showSprints = wants("sprint") && groups.some((g) => g.sprints.some((sg) => sg.sprint !== null));
192
203
 
193
204
  const rows: PlanRow[] = [];
194
205
  let taskIndex = 0;
195
206
 
196
207
  const emitFeature = (feature: Feature, depth: number): void => {
197
208
  const counts = taskCounts(feature.tasks);
198
- rows.push({
199
- level: "feature",
200
- depth,
201
- id: feature.id,
202
- label: feature.id,
203
- title: feature.name ?? feature.id,
204
- status: null,
205
- done: counts.done,
206
- total: counts.total,
207
- criteria: Array.isArray(feature.criteria) ? feature.criteria : undefined,
208
- });
209
+ let taskDepth = depth;
210
+ if (wants("feature")) {
211
+ rows.push({
212
+ level: "feature",
213
+ depth,
214
+ id: feature.id,
215
+ label: feature.id,
216
+ title: feature.name ?? feature.id,
217
+ status: null,
218
+ done: counts.done,
219
+ total: counts.total,
220
+ criteria: Array.isArray(feature.criteria) ? feature.criteria : undefined,
221
+ });
222
+ } else {
223
+ // Hiding the feature row must not hide its tasks — nor indent them as
224
+ // though the row were still there.
225
+ taskDepth = Math.max(0, depth - 1);
226
+ }
209
227
 
210
228
  for (const task of feature.tasks ?? []) {
229
+ // Numbered even when hidden: `← #3` has to keep meaning the same task
230
+ // whatever the reader has chosen to look at.
211
231
  taskIndex += 1;
232
+ if (!wants("task")) continue;
212
233
  const id = task.key ?? `${feature.id}/${task.id}`;
213
234
  const active = activeTaskId !== null && (id === activeTaskId || task.id === activeTaskId);
214
235
  rows.push({
215
236
  level: "task",
216
- depth: depth + 1,
237
+ depth: taskDepth + 1,
217
238
  id,
218
239
  label: String(taskIndex),
219
240
  title: task.description ?? id,
@@ -233,8 +254,8 @@ export function buildPlanRows(
233
254
  // one, so a task that is plainly in progress counts too.
234
255
  const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
235
256
  const working = task.status === "in_progress" || task.status === "rework";
236
- const show = options.expandSubtasks || active || working;
237
- if (show && subs.length) rows.push(...subtaskRows(task, id, depth + 2, subs));
257
+ const show = wants("subtask") && (options.expandSubtasks || active || working);
258
+ if (show && subs.length) rows.push(...subtaskRows(task, id, taskDepth + 2, subs));
238
259
  }
239
260
  };
240
261
 
@@ -255,6 +276,7 @@ export function buildPlanRows(
255
276
  }
256
277
  for (const sg of group.sprints) {
257
278
  let featureDepth = depth;
279
+ // Every feature row hidden means the tasks sit where the features were.
258
280
  if (showSprints && sg.sprint) {
259
281
  rows.push({
260
282
  level: "sprint",