infinity-harness 2.3.0 → 2.4.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,6 +548,7 @@ function renderFeature(
536
548
  indexByKey: ReadonlyMap<string, number>,
537
549
  sprintName: string | null,
538
550
  goalName: string | null,
551
+ display: DisplayPolicy,
539
552
  ): string {
540
553
  const counts = countByStatus(tasks);
541
554
  const total = tasks.length;
@@ -547,14 +560,23 @@ function renderFeature(
547
560
  feature.passes === true ? `<span class="chip chip-complete">verified</span>` : "",
548
561
  ].join("");
549
562
 
550
- const body = total
551
- ? `<div class="table-wrap">
563
+ const body = !display.levels.task
564
+ ? ""
565
+ : total
566
+ ? `<div class="table-wrap">
552
567
  <table class="tasks">
553
568
  <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>
569
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
555
570
  </table>
556
571
  </div>`
557
- : `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
572
+ : `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
573
+
574
+ const criteria =
575
+ display.criteria && Array.isArray(feature.criteria) && feature.criteria.length
576
+ ? `<ul class="criteria">${feature.criteria
577
+ .map((c) => `<li>${esc(String(c))}</li>`)
578
+ .join("")}</ul>`
579
+ : "";
558
580
 
559
581
  return `<section class="card feature${complete ? " is-complete" : ""}">
560
582
  <div class="feature-head">
@@ -564,11 +586,12 @@ function renderFeature(
564
586
  ${chips}
565
587
  </div>
566
588
  <div class="feature-progress">
567
- <span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>
589
+ ${display.counts ? `<span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>` : ""}
568
590
  ${meter(counts, total)}
569
591
  </div>
570
592
  </div>
571
593
  ${feature.description ? `<p class="feature-desc">${esc(feature.description)}</p>` : ""}
594
+ ${criteria}
572
595
  ${body}
573
596
  </section>`;
574
597
  }
@@ -585,9 +608,10 @@ function renderGoalGroup(
585
608
  tasksByFeature: ReadonlyMap<string, FlatTask[]>,
586
609
  indexByKey: ReadonlyMap<string, number>,
587
610
  show: { showGoal: boolean; showSprints: boolean },
611
+ display: DisplayPolicy,
588
612
  ): string {
589
613
  const sprints = group.sprints
590
- .map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints))
614
+ .map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display))
591
615
  .join("");
592
616
 
593
617
  if (!show.showGoal || !group.goal) return sprints;
@@ -597,7 +621,7 @@ function renderGoalGroup(
597
621
  <span class="tier-kind">goal</span>
598
622
  <span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
599
623
  <span class="mono faint">${esc(group.goal.id ?? "")}</span>
600
- <span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
624
+ ${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
601
625
  </summary>
602
626
  <div class="tier-body">${sprints}</div>
603
627
  </details>`;
@@ -608,10 +632,16 @@ function renderSprintGroup(
608
632
  tasksByFeature: ReadonlyMap<string, FlatTask[]>,
609
633
  indexByKey: ReadonlyMap<string, number>,
610
634
  showSprints: boolean,
635
+ display: DisplayPolicy,
611
636
  ): string {
612
- const features = group.features
613
- .map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null))
614
- .join("");
637
+ const features = display.levels.feature
638
+ ? group.features
639
+ .map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display))
640
+ .join("")
641
+ : // Hiding the feature card must not hide its tasks: they move up into the
642
+ // sprint, which is what "hide features" has to mean on a page whose whole
643
+ // job is to show the plan.
644
+ renderLooseTasks(group.features.flatMap((f) => tasksByFeature.get(f.id) ?? []), indexByKey, display);
615
645
 
616
646
  if (!showSprints || !group.sprint) return features;
617
647
 
@@ -620,12 +650,29 @@ function renderSprintGroup(
620
650
  <span class="tier-kind">sprint</span>
621
651
  <span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
622
652
  <span class="mono faint">${esc(group.sprint.id ?? "")}</span>
623
- <span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
653
+ ${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
624
654
  </summary>
625
655
  <div class="tier-body">${features}</div>
626
656
  </details>`;
627
657
  }
628
658
 
659
+ /** Tasks with no feature card above them, for the templates that hide features. */
660
+ function renderLooseTasks(
661
+ tasks: readonly FlatTask[],
662
+ indexByKey: ReadonlyMap<string, number>,
663
+ display: DisplayPolicy,
664
+ ): string {
665
+ if (!display.levels.task || tasks.length === 0) return "";
666
+ return `<section class="card feature">
667
+ <div class="table-wrap">
668
+ <table class="tasks">
669
+ <thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
670
+ <tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
671
+ </table>
672
+ </div>
673
+ </section>`;
674
+ }
675
+
629
676
  function renderEmptyPlan(phase: Phase | null): string {
630
677
  const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
631
678
  return `<section class="card empty">
@@ -858,6 +905,11 @@ body{
858
905
  .alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
859
906
  .alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
860
907
 
908
+ /* -- acceptance criteria -------------------------------------------------- */
909
+ .criteria{margin:10px 0 0;padding:0 0 0 18px;color:var(--muted);font-size:13px}
910
+ .criteria li{margin:3px 0;overflow-wrap:anywhere}
911
+ .criteria li::marker{color:var(--t-accent)}
912
+
861
913
  /* -- gate ----------------------------------------------------------------- */
862
914
  .gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
863
915
  .gate-phase{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;overflow-wrap:anywhere}
@@ -1134,14 +1186,21 @@ export function renderDashboard(state: DashboardState): string {
1134
1186
  // chips on a feature card — throws away the only structure that tells you
1135
1187
  // whether the run is nearly done with something or scattered across
1136
1188
  // everything.
1189
+ const display = normalizeDisplay(state.display ?? defaultDisplay());
1137
1190
  const groups = groupPlan(list);
1138
1191
  const body = features.length
1139
1192
  ? groups
1140
1193
  .map((group) =>
1141
- renderGoalGroup(group, tasksByFeature, indexByKey, {
1142
- showGoal: goals.length > 0,
1143
- showSprints: sprints.length > 0,
1144
- }),
1194
+ renderGoalGroup(
1195
+ group,
1196
+ tasksByFeature,
1197
+ indexByKey,
1198
+ {
1199
+ showGoal: display.levels.goal && goals.length > 0,
1200
+ showSprints: display.levels.sprint && sprints.length > 0,
1201
+ },
1202
+ display,
1203
+ ),
1145
1204
  )
1146
1205
  .join("")
1147
1206
  : renderEmptyPlan(state.phase);
@@ -1168,14 +1227,18 @@ export function renderDashboard(state: DashboardState): string {
1168
1227
  <div id="app">
1169
1228
  <div class="page">
1170
1229
  ${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)}
1230
+ ${display.levels.goal ? renderGoals(goals) : ""}
1231
+ ${display.rail ? renderRail(state.phase, state.enabledPhases, paused) : ""}
1232
+ ${
1233
+ display.alerts
1234
+ ? renderAlerts(counts, paused, state.retries, gate, {
1235
+ awaitingApproval: state.awaitingApproval ?? null,
1236
+ sessions: state.sessions ?? null,
1237
+ goalPass: state.goalPass ?? null,
1238
+ })
1239
+ : ""
1240
+ }
1241
+ ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1179
1242
  ${renderGate(gate)}
1180
1243
  ${body}
1181
1244
  <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",