infinity-harness 2.3.1 → 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.
package/src/intake.ts CHANGED
@@ -7,100 +7,98 @@
7
7
  * "you decide everything, including what I want", which is not a mode anybody
8
8
  * asked for.
9
9
  *
10
- * The fix is to separate two questions that were tangled together:
10
+ * The fix was to separate two questions that were tangled together:
11
11
  *
12
- * 1. What are we building? — always asked, in both modes
13
- * 2. Who signs off on what? — the mode's actual meaning
12
+ * 1. What are we building? — always asked
13
+ * 2. Who signs off on what? — the workflow
14
14
  *
15
- * copilot the human is in the loop. DEFINE and PLAN are theirs to approve
16
- * (and RESEARCH too, when it is on). Not negotiable that is what
17
- * the word means.
18
- * autopilot the human is *optionally* in the loop. They pick which of
19
- * RESEARCH / DEFINE / PLAN they want to sign and which they hand
20
- * to the model. Forfeiting all three is the "give it a goal and
21
- * walk away" mode; keeping DEFINE is the common middle.
22
- *
23
- * RESEARCH is a separate, optional phase that runs before DEFINE: the human
24
- * gives an idea, the model goes and finds out what it actually has to be.
15
+ * And the second of those turned out to be tangled too. "copilot" and
16
+ * "autopilot" are one switch, and one switch cannot say "let it define and
17
+ * plan on its own but show me the review". So the answer is a **workflow**: a
18
+ * mode per phase, with the two familiar words as two named points in that
19
+ * space rather than the only two points in it. `src/workflow.ts` owns what a
20
+ * workflow is and where saved ones live.
25
21
  *
26
22
  * Nothing here talks to pi. It takes answers and returns a plan of record, so
27
23
  * the flow can be unit-tested without a terminal and driven from the adapter,
28
24
  * from a test, or from a config file.
29
25
  */
30
26
 
31
- import type { ApprovalPolicy, Phase, SessionPolicy } from "./core/types.ts";
27
+ import type { ApprovalPolicy, DisplayPolicy, Phase, SessionPolicy } from "./core/types.ts";
32
28
  import { DEFAULT_ENABLED_PHASES, PHASE_ORDER } from "./core/types.ts";
29
+ import {
30
+ BUILTIN_WORKFLOWS,
31
+ describeModes,
32
+ normalizeModes,
33
+ normalizePhases,
34
+ signedPhases as signedIn,
35
+ type PhaseMode,
36
+ type PhaseModes,
37
+ type Workflow,
38
+ } from "./workflow.ts";
39
+ import { defaultDisplay, normalizeDisplay } from "./ui/display.ts";
33
40
 
34
41
  export type Mode = "copilot" | "autopilot";
35
42
 
36
43
  /** The wizard's questions, in the order they are asked. */
37
- export const INTAKE_STEPS = ["mode", "brief", "research", "approvals", "handoff"] as const;
44
+ export const INTAKE_STEPS = ["workflow", "brief", "handoff", "display"] as const;
38
45
  export type IntakeStep = (typeof INTAKE_STEPS)[number];
39
46
 
40
47
  export type IntakeAnswers = {
41
- mode: Mode;
48
+ /** The chosen workflow: a built-in, one they saved, or one they just built. */
49
+ workflow: Workflow;
42
50
  /** What the human wants built, in their words. Empty is allowed but warned about. */
43
51
  brief: string;
44
- /** Run the optional RESEARCH phase before DEFINE. */
45
- research: boolean;
46
- /**
47
- * Which phases the human wants to sign, for autopilot only.
48
- * Ignored in copilot, where all three are always signed.
49
- */
50
- approvals?: Partial<ApprovalPolicy>;
51
52
  /** Session handoff policy. Defaults to a fresh session per phase. */
52
53
  handoff?: SessionPolicy["handoff"];
53
- /** Phases the human explicitly chose. Overrides the research toggle. */
54
- phases?: Phase[];
54
+ /** What the surfaces should draw. Defaults to the `focus` template. */
55
+ display?: DisplayPolicy;
55
56
  };
56
57
 
57
58
  export type IntakePlan = {
59
+ /** Derived: "copilot" when the run stops for the human anywhere, else "autopilot". */
58
60
  mode: Mode;
61
+ workflow: { id: string; name: string };
59
62
  brief: string | null;
60
63
  phases: Phase[];
64
+ phaseModes: PhaseModes;
65
+ /** Kept in step with `phaseModes` so a 2.3 config read by a 2.3 tool still works. */
61
66
  approvals: ApprovalPolicy;
62
67
  session: SessionPolicy;
68
+ display: DisplayPolicy;
63
69
  /** What the human should be told about what they just chose. */
64
70
  summary: string;
65
71
  /** Things that will bite later if left as they are. */
66
72
  warnings: string[];
67
73
  };
68
74
 
69
- /**
70
- * In copilot the human is in the loop by definition, so every approvable
71
- * phase that is enabled is theirs. Making this configurable would make
72
- * "copilot" mean nothing.
73
- */
74
- export function copilotApprovals(phases: Phase[]): ApprovalPolicy {
75
- return {
76
- research: phases.includes("research"),
77
- define: true,
78
- plan: true,
79
- };
75
+ export function builtInByName(name: string): Workflow | null {
76
+ return BUILTIN_WORKFLOWS.find((w) => w.id === name || w.name === name) ?? null;
80
77
  }
81
78
 
82
- function normalizePhases(base: Phase[], research: boolean): Phase[] {
83
- const wanted = new Set<Phase>(base.filter((p) => (PHASE_ORDER as readonly string[]).includes(p)));
84
- wanted.delete("init");
85
- if (research) wanted.add("research");
86
- else wanted.delete("research");
87
- const ordered = PHASE_ORDER.filter((p) => wanted.has(p));
88
- return ordered.length ? [...ordered] : [...DEFAULT_ENABLED_PHASES];
79
+ /** Build an ad-hoc workflow from a phase list and a mode per phase. */
80
+ export function customWorkflow(
81
+ phases: Phase[],
82
+ modes: PhaseModes,
83
+ name = "custom",
84
+ ): Workflow {
85
+ const ordered = normalizePhases(phases);
86
+ const normalized = normalizeModes(modes, ordered);
87
+ return {
88
+ id: "custom",
89
+ name,
90
+ description: describeModes(ordered, normalized),
91
+ builtIn: false,
92
+ phases: ordered,
93
+ modes: normalized,
94
+ };
89
95
  }
90
96
 
91
97
  /** Turn the wizard's answers into everything `initHarness` needs. */
92
98
  export function planIntake(answers: IntakeAnswers): IntakePlan {
93
- const research = answers.research === true;
94
- const phases = normalizePhases(answers.phases ?? [...DEFAULT_ENABLED_PHASES], research);
95
-
96
- const approvals: ApprovalPolicy =
97
- answers.mode === "copilot"
98
- ? copilotApprovals(phases)
99
- : {
100
- research: phases.includes("research") && answers.approvals?.research === true,
101
- define: answers.approvals?.define === true,
102
- plan: answers.approvals?.plan === true,
103
- };
99
+ const workflow = answers.workflow;
100
+ const phases = normalizePhases(workflow.phases);
101
+ const phaseModes = normalizeModes(workflow.modes, phases);
104
102
 
105
103
  const handoff = answers.handoff ?? "phase";
106
104
  const session: SessionPolicy = {
@@ -109,7 +107,10 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
109
107
  carryNotes: true,
110
108
  };
111
109
 
110
+ const display = normalizeDisplay(answers.display ?? defaultDisplay());
112
111
  const brief = answers.brief?.trim() ? answers.brief.trim() : null;
112
+ const signed = PHASE_ORDER.filter((p) => phaseModes[p] === "copilot");
113
+ const mode: Mode = signed.length > 0 ? "copilot" : "autopilot";
113
114
 
114
115
  const warnings: string[] = [];
115
116
  if (!brief) {
@@ -118,13 +119,19 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
118
119
  "It will not guess a project.",
119
120
  );
120
121
  }
121
- if (answers.mode === "autopilot" && !approvals.define && !approvals.plan && !approvals.research) {
122
+ if (signed.length === 0) {
122
123
  warnings.push(
123
124
  "Nothing is being approved by you. The model decides what to build and how, " +
124
125
  "and you see it when it is done. This is the right setting for a run you " +
125
126
  "want to walk away from — and the wrong one if the goal is vague.",
126
127
  );
127
128
  }
129
+ if (signed.length === phases.length) {
130
+ warnings.push(
131
+ "Every phase stops for you. Nothing moves while you are away, which is the " +
132
+ "point — just do not expect to start this and go to bed.",
133
+ );
134
+ }
128
135
  if (handoff === "off") {
129
136
  warnings.push(
130
137
  "Session handoff is off, so the whole run shares one context window. " +
@@ -132,20 +139,36 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
132
139
  );
133
140
  }
134
141
 
135
- return { mode: answers.mode, brief, phases, approvals, session, summary: summarize(answers.mode, phases, approvals, session, brief), warnings };
142
+ return {
143
+ mode,
144
+ workflow: { id: workflow.id, name: workflow.name },
145
+ brief,
146
+ phases,
147
+ phaseModes,
148
+ approvals: {
149
+ research: phaseModes.research === "copilot",
150
+ define: phaseModes.define === "copilot",
151
+ plan: phaseModes.plan === "copilot",
152
+ },
153
+ session,
154
+ display,
155
+ summary: summarize(workflow, phases, phaseModes, session, display, brief),
156
+ warnings,
157
+ };
136
158
  }
137
159
 
138
160
  function summarize(
139
- mode: Mode,
161
+ workflow: Workflow,
140
162
  phases: Phase[],
141
- approvals: ApprovalPolicy,
163
+ modes: PhaseModes,
142
164
  session: SessionPolicy,
165
+ display: DisplayPolicy,
143
166
  brief: string | null,
144
167
  ): string {
145
- const signed = (["research", "define", "plan"] as const).filter((p) => approvals[p]);
168
+ const signed = phases.filter((p) => modes[p] === "copilot");
146
169
  const L: string[] = [];
147
- L.push(`Mode ${mode}`);
148
- L.push(`Pipeline ${phases.join(" → ")}`);
170
+ L.push(`Workflow ${workflow.name}`);
171
+ L.push(`Pipeline ${phases.map((p) => (modes[p] === "copilot" ? `[${p}]` : p)).join(" → ")}`);
149
172
  L.push(
150
173
  `You sign ${signed.length ? signed.map((s) => s.toUpperCase()).join(", ") : "nothing — the model decides and runs"}`,
151
174
  );
@@ -158,6 +181,7 @@ function summarize(
158
181
  : "fresh session per phase"
159
182
  }`,
160
183
  );
184
+ L.push(`Display ${display.preset}`);
161
185
  L.push(`Goal ${brief ?? "(none yet — you will be asked first thing)"}`);
162
186
  return L.join("\n");
163
187
  }
@@ -177,21 +201,9 @@ export type Question = {
177
201
  placeholder?: string;
178
202
  };
179
203
 
180
- export const MODE_QUESTION: Question = {
181
- id: "mode",
182
- title: "How much do you want to be involved?",
183
- options: [
184
- {
185
- value: "copilot",
186
- label: "copilot — I approve the definition and the plan",
187
- help: "The run stops and shows you its work before it starts building. You can send any phase back with a note.",
188
- },
189
- {
190
- value: "autopilot",
191
- label: "autopilot — I choose what to approve, if anything",
192
- help: "You pick which of research, definition and plan you sign. Approve none of them and the run is yours to walk away from.",
193
- },
194
- ],
204
+ export const WORKFLOW_QUESTION: Question = {
205
+ id: "workflow",
206
+ title: "How should this run which phases, and which of them stop for you?",
195
207
  };
196
208
 
197
209
  export const BRIEF_QUESTION: Question = {
@@ -200,23 +212,6 @@ export const BRIEF_QUESTION: Question = {
200
212
  placeholder: "e.g. a CLI that reconciles Stripe payouts against our ledger",
201
213
  };
202
214
 
203
- export const RESEARCH_QUESTION: Question = {
204
- id: "research",
205
- title: "Research the idea first?",
206
- options: [
207
- {
208
- value: "no",
209
- label: "no — go straight to defining it",
210
- help: "Right when you already know what has to be built.",
211
- },
212
- {
213
- value: "yes",
214
- label: "yes — find out what it has to be first",
215
- help: "Adds a RESEARCH phase before DEFINE: prior art, constraints, options with costs, a recommendation, and the questions only you can answer.",
216
- },
217
- ],
218
- };
219
-
220
215
  export const HANDOFF_QUESTION: Question = {
221
216
  id: "handoff",
222
217
  title: "When should the run start a fresh session?",
@@ -239,25 +234,39 @@ export const HANDOFF_QUESTION: Question = {
239
234
  ],
240
235
  };
241
236
 
242
- /** The approval checklist, offered only in autopilot. */
243
- export function approvalOptions(research: boolean): { value: keyof ApprovalPolicy; label: string; help: string }[] {
244
- const out: { value: keyof ApprovalPolicy; label: string; help: string }[] = [];
245
- if (research) {
246
- out.push({
247
- value: "research",
248
- label: "RESEARCH what it found before anything is specified",
249
- help: "You read harness/docs/RESEARCH.md and say whether it is looking at the right problem.",
250
- });
251
- }
252
- out.push({
253
- value: "define",
254
- label: "DEFINE — the scope and the acceptance criteria",
255
- help: "The single highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
256
- });
257
- out.push({
258
- value: "plan",
259
- label: "PLAN — the task list before any code is written",
260
- help: "You see the whole decomposition and can send it back before it is built.",
261
- });
262
- return out;
263
- }
237
+ export const DISPLAY_QUESTION: Question = {
238
+ id: "display",
239
+ title: "How much of the plan do you want on screen?",
240
+ };
241
+
242
+ /** The two modes, as a question about one phase. */
243
+ export const PHASE_MODE_OPTIONS: { value: PhaseMode; label: string; help: string }[] = [
244
+ {
245
+ value: "autopilot",
246
+ label: "autopilot — it passes the gate and moves on",
247
+ help: "The deterministic gate is the only referee for this phase.",
248
+ },
249
+ {
250
+ value: "copilot",
251
+ label: "copilot — it stops and waits for you",
252
+ help: "When the gate passes, the run parks and asks you to sign it off before advancing.",
253
+ },
254
+ ];
255
+
256
+ /** What each phase is for, one line, shown while picking modes. */
257
+ export const PHASE_PURPOSE: Record<Phase, string> = {
258
+ init: "set the project up",
259
+ research: "find out what it actually has to be",
260
+ define: "write down what is being built, and the criteria",
261
+ plan: "break it into ordered, dependency-aware tasks",
262
+ build: "implement it, one task at a time",
263
+ verify: "prove it behaves; hunt what the tests miss",
264
+ simplify: "delete more than you add",
265
+ review: "judge it as if someone else wrote it",
266
+ ship: "tag, changelog, leave the tree clean",
267
+ };
268
+
269
+ /** Phases someone can put in a pipeline. INIT is not one of them. */
270
+ export const SELECTABLE_PHASES: Phase[] = PHASE_ORDER.filter((p) => p !== "init");
271
+
272
+ export { DEFAULT_ENABLED_PHASES, signedIn as signedPhases };
package/src/remote.ts CHANGED
@@ -18,12 +18,13 @@ 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, Sprint } from "./core/types.ts";
21
+ import type { DisplayPolicy, 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
26
  import { loadRunState } from "./runState.ts";
27
+ import { normalizeDisplay } from "./ui/display.ts";
27
28
  import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
28
29
 
29
30
  export { escapeHtml };
@@ -54,6 +55,8 @@ export interface RemoteState {
54
55
  /** pi sessions this run has spent — proof the handoff is doing its job. */
55
56
  sessions: number | null;
56
57
  goalPass: { current: number; max: number } | null;
58
+ /** What the reader has asked the dashboard to draw. */
59
+ display: DisplayPolicy;
57
60
  }
58
61
 
59
62
  export interface RemoteServer {
@@ -118,6 +121,7 @@ export function buildRemoteState(projectDir?: string): RemoteState {
118
121
  ? { current: config.goalPass, max: config.goalMaxPasses }
119
122
  : null,
120
123
  sprints: list.sprints ?? [],
124
+ display: normalizeDisplay(config.display),
121
125
  };
122
126
  }
123
127
 
@@ -136,6 +140,7 @@ function toDashboardState(s: RemoteState): DashboardState {
136
140
  awaitingApproval: s.awaitingApproval,
137
141
  sessions: s.sessions,
138
142
  goalPass: s.goalPass,
143
+ display: s.display,
139
144
  };
140
145
  }
141
146
 
@@ -159,6 +164,7 @@ export function buildApiPayload(state: RemoteState): Record<string, unknown> {
159
164
  sessions: state.sessions,
160
165
  goalPass: state.goalPass,
161
166
  sprints: state.sprints,
167
+ display: state.display,
162
168
  features: state.features.map((f) => ({
163
169
  id: f.id,
164
170
  name: f.name,
package/src/ui/config.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import type { Setting, SettingsGroup } from "../core/settings.ts";
18
+ import { summarizeWorkflow } from "../workflow.ts";
19
+ import { normalizeDisplay, summarizeDisplay } from "./display.ts";
18
20
  import {
19
21
  SETTINGS,
20
22
  coerce,
@@ -92,6 +94,10 @@ function summarize(group: SettingsGroup, io: ReturnType<typeof readAll>): string
92
94
  }
93
95
  case "pipeline":
94
96
  return (io.config.phases?.enabled ?? []).join(" → ") || "(none)";
97
+ case "workflow":
98
+ return summarizeWorkflow(io.config);
99
+ case "display":
100
+ return summarizeDisplay(normalizeDisplay(io.config.display));
95
101
  case "commands": {
96
102
  const set = Object.entries(io.config.commands ?? {}).filter(([, v]) => Boolean(v));
97
103
  return set.length ? set.map(([k]) => k).join(", ") : "none set";
@@ -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">