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.
package/src/ui/widget.ts CHANGED
@@ -16,6 +16,8 @@ import type { FeatureList, Phase, TaskStatus } from "../core/types.ts";
16
16
  import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.ts";
17
17
  import { getPhaseOrder } from "../core/phases.ts";
18
18
  import { buildPlanRows, focusRowIndex, type PlanRow } from "./planTree.ts";
19
+ import { defaultDisplay, normalizeDisplay } from "./display.ts";
20
+ import type { DisplayPolicy } from "../core/types.ts";
19
21
  import {
20
22
  createStyler,
21
23
  detectGlyphs,
@@ -73,6 +75,13 @@ export type WidgetState = {
73
75
  view?: WidgetView | null;
74
76
  /** Sessions this run has spent. Only meaningful once handoff is on. */
75
77
  sessions?: number | null;
78
+ /**
79
+ * What this reader has asked to see.
80
+ *
81
+ * The same policy drives the dashboard, so a level turned off here is off
82
+ * there too — configuring how you read a plan once, rather than twice.
83
+ */
84
+ display?: DisplayPolicy | null;
76
85
  /**
77
86
  * What the human asked for, before a plan exists to hold a goal.
78
87
  *
@@ -268,26 +277,30 @@ function renderRow(
268
277
  indexByKey: Map<string, number>,
269
278
  g: GlyphSet,
270
279
  s: Styler,
280
+ display: DisplayPolicy,
271
281
  ): string[] {
272
282
  const indent = " ".repeat(row.depth);
283
+ const tagFor = (r: PlanRow): string => (display.counts ? countTag(r, s) : "");
273
284
 
274
285
  if (row.level === "goal" || row.level === "sprint") {
275
286
  const icon = s.fg(LEVEL_ROLE[row.level], row.level === "goal" ? g.goal : g.sprint);
276
- const tag = countTag(row, s);
287
+ const tag = tagFor(row);
277
288
  const prefix = indent + icon + " ";
278
289
  const head =
279
290
  s.bold(s.fg(LEVEL_ROLE[row.level], row.title)) +
280
291
  (row.label && row.label !== row.title ? s.fg("rule", " " + row.label) : "");
281
- const body = truncate(prefix + head, Math.max(8, inner - width(tag) - 1));
292
+ const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
293
+ if (!tag) return [body];
282
294
  const gap = Math.max(1, inner - width(body) - width(tag));
283
295
  return [body + " ".repeat(gap) + tag];
284
296
  }
285
297
 
286
298
  if (row.level === "feature") {
287
- const tag = countTag(row, s);
299
+ const tag = tagFor(row);
288
300
  const prefix = indent + s.fg("muted", g.branch + " ");
289
301
  const head = s.fg("muted", row.label) + s.fg("rule", " · ") + s.fg("text", row.title);
290
- const body = truncate(prefix + head, Math.max(8, inner - width(tag) - 1));
302
+ const body = truncate(prefix + head, tag ? Math.max(8, inner - width(tag) - 1) : inner);
303
+ if (!tag) return [body];
291
304
  const gap = Math.max(1, inner - width(body) - width(tag));
292
305
  return [body + " ".repeat(gap) + tag];
293
306
  }
@@ -307,7 +320,7 @@ function renderRow(
307
320
  const role = statusRole(row.status ?? "pending");
308
321
  const icon = s.fg(role, statusGlyph(row.status ?? "pending", g));
309
322
  const num = s.fg("rule", row.label);
310
- const dep = depLabel(row.dependsOn, indexByKey, g, s);
323
+ const dep = display.dependencies ? depLabel(row.dependsOn, indexByKey, g, s) : "";
311
324
  const prefix = indent + icon + " " + num + " ";
312
325
  const prefixW = width(prefix);
313
326
  const depW = dep ? width(dep) + 1 : 0;
@@ -369,7 +382,9 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
369
382
  const pad = boxed ? 2 : 0;
370
383
  const inner = Math.max(24, total - pad * 2);
371
384
  const view = state.view ?? defaultView();
372
- const limit = options.taskWindow ?? (view.expanded ? EXPANDED_WINDOW : TASK_WINDOW);
385
+ const display = normalizeDisplay(state.display ?? defaultDisplay());
386
+ const limit =
387
+ options.taskWindow ?? (view.expanded ? Math.max(EXPANDED_WINDOW, display.taskWindow * 2) : display.taskWindow);
373
388
 
374
389
  const out: string[] = [];
375
390
  const push = (line = ""): void => {
@@ -398,7 +413,13 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
398
413
  // the tree — `buildPlanRows` collapses it there for exactly this reason.
399
414
  // Several goals are structure, and structure belongs in the tree.
400
415
  const goals = state.list.goals ?? [];
401
- const headline = goals.length === 1 ? (goals[0]?.title ?? null) : goals.length === 0 ? (state.intake ?? null) : null;
416
+ const headline = !display.levels.goal
417
+ ? null
418
+ : goals.length === 1
419
+ ? (goals[0]?.title ?? null)
420
+ : goals.length === 0
421
+ ? (state.intake ?? null)
422
+ : null;
402
423
  if (headline) {
403
424
  const wrapped = wrap(headline, inner - 2);
404
425
  wrapped.forEach((line, i) => {
@@ -409,8 +430,10 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
409
430
  }
410
431
 
411
432
  // -- phase rail -----------------------------------------------------------
412
- push();
413
- push(phaseRail(state.phase, state.enabledPhases, inner, g, s));
433
+ if (display.rail) {
434
+ push();
435
+ push(phaseRail(state.phase, state.enabledPhases, inner, g, s));
436
+ }
414
437
 
415
438
  // -- progress -------------------------------------------------------------
416
439
  const full =
@@ -427,12 +450,14 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
427
450
  if (inner - width(full) < METER_MIN) stats = compact;
428
451
  if (inner - width(stats) < METER_MIN) stats = "";
429
452
 
430
- const statsW = width(stats);
431
- const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
432
- const bar = progressBar(progress.percent, barCells, g, s);
433
- push();
434
- const gap2 = inner - width(bar) - statsW;
435
- push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
453
+ if (display.progress) {
454
+ const statsW = width(stats);
455
+ const barCells = Math.max(8, Math.min(24, inner - statsW - 8));
456
+ const bar = progressBar(progress.percent, barCells, g, s);
457
+ push();
458
+ const gap2 = inner - width(bar) - statsW;
459
+ push(truncate(bar + (gap2 > 0 ? " ".repeat(gap2) : " ") + stats, inner));
460
+ }
436
461
 
437
462
  // -- alerts ---------------------------------------------------------------
438
463
  const alerts: string[] = [];
@@ -462,7 +487,7 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
462
487
  if (state.gate && !state.gate.overall) {
463
488
  alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
464
489
  }
465
- if (alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
490
+ if (display.alerts && alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
466
491
 
467
492
  // -- the plan -------------------------------------------------------------
468
493
  //
@@ -484,7 +509,14 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
484
509
 
485
510
  const active = nextActionableTask(state.list);
486
511
  const rows = buildPlanRows(state.list, active?.compositeKey ?? null, {
487
- expandSubtasks: view.expanded,
512
+ expandSubtasks: view.expanded || display.levels.subtask === "all",
513
+ levels: {
514
+ goal: display.levels.goal,
515
+ sprint: display.levels.sprint,
516
+ feature: display.levels.feature,
517
+ task: display.levels.task,
518
+ subtask: display.levels.subtask !== "none",
519
+ },
488
520
  });
489
521
 
490
522
  const bounds = rowWindow(rows, limit, view.scroll);
@@ -497,7 +529,7 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
497
529
  }
498
530
 
499
531
  for (const row of rows.slice(bounds.start, bounds.end)) {
500
- for (const line of renderRow(row, inner, indexByKey, g, s)) push(line);
532
+ for (const line of renderRow(row, inner, indexByKey, g, s, display)) push(line);
501
533
  }
502
534
 
503
535
  if (hiddenAfter > 0) {
package/src/ui/wizard.ts CHANGED
@@ -7,41 +7,61 @@
7
7
  * suite, by a script answering over pi's RPC extension-UI protocol, which is
8
8
  * as close to watching a human use it as this gets.
9
9
  *
10
- * The flow is short on purpose. Five questions, three of them one keypress:
10
+ * The flow is short on purpose. Four questions, three of them one keypress:
11
11
  *
12
- * 1. copilot or autopilot?
12
+ * 1. which workflow? — a built-in, one you saved, or "build one"
13
13
  * 2. what are you building?
14
- * 3. research it first?
15
- * 4. (autopilot only) which phases do you want to sign?
16
- * 5. when should the run start a fresh session?
14
+ * 3. when should it start a fresh session?
15
+ * 4. how much of the plan do you want on screen?
16
+ *
17
+ * Building a workflow is its own small flow: pick the phases, then say for
18
+ * each one whether it stops for you, then optionally give it a name and keep
19
+ * it. A kept workflow is offered first thing on the next project.
17
20
  *
18
21
  * Cancelling any question cancels the wizard. Nothing is written until the
19
22
  * human has seen the summary and said yes, because a wizard that half-commits
20
23
  * leaves a project in a state nobody chose.
21
24
  */
22
25
 
23
- import type { Prompter } from "./config.ts";
24
- import type { ApprovalPolicy, Phase } from "../core/types.ts";
26
+ import type { ModelChoice, Prompter } from "./config.ts";
27
+ import type { DisplayPolicy, Phase } from "../core/types.ts";
25
28
  import {
26
29
  BRIEF_QUESTION,
30
+ DISPLAY_QUESTION,
27
31
  HANDOFF_QUESTION,
28
- MODE_QUESTION,
29
- RESEARCH_QUESTION,
30
- approvalOptions,
32
+ PHASE_MODE_OPTIONS,
33
+ PHASE_PURPOSE,
34
+ SELECTABLE_PHASES,
35
+ WORKFLOW_QUESTION,
36
+ customWorkflow,
31
37
  planIntake,
32
38
  type IntakeAnswers,
33
39
  type IntakePlan,
34
- type Mode,
35
40
  } from "../intake.ts";
41
+ import {
42
+ listWorkflows,
43
+ normalizeModes,
44
+ normalizePhases,
45
+ renderWorkflow,
46
+ saveWorkflow,
47
+ type PhaseMode,
48
+ type PhaseModes,
49
+ type Workflow,
50
+ } from "../workflow.ts";
51
+ import { DEFAULT_ENABLED_PHASES } from "../core/types.ts";
52
+ import { defaultDisplay, listDisplays, normalizeDisplay, saveDisplay } from "./display.ts";
53
+ import type { ThinkingLevel } from "../modelRouter.ts";
36
54
 
37
55
  export type WizardOptions = {
38
56
  prompt: Prompter;
39
- /** Phases already chosen elsewhere (e.g. the phase picker). */
40
- phases?: Phase[];
41
- /** Pre-fill the goal, e.g. from `/infinity:start <goal>`. */
57
+ /** Pre-fill the goal, e.g. from `/infinity:init <goal>`. */
42
58
  brief?: string | null;
43
59
  /** Skip the final confirmation. Used when the caller does its own. */
44
60
  skipConfirm?: boolean;
61
+ /** Where saved workflows and templates live. Tests point this elsewhere. */
62
+ env?: NodeJS.ProcessEnv;
63
+ /** Models pi can use — offered for each tier and for consulting. */
64
+ models?: () => ModelChoice[] | Promise<ModelChoice[]>;
45
65
  };
46
66
 
47
67
  export type WizardResult =
@@ -51,51 +71,109 @@ export type WizardResult =
51
71
  const CONFIRM = "start with these settings";
52
72
  const RESTART = "change something";
53
73
  const CANCEL = "cancel";
74
+ const BUILD_ONE = "build one — I choose the phases and which of them stop for me";
75
+ const DONE = "✓ done";
54
76
 
55
77
  /** Render a choice as one selectable line: the label, then why you would pick it. */
56
78
  function line(label: string, help: string): string {
57
79
  return `${label} — ${help}`;
58
80
  }
59
81
 
82
+ const MODEL_STEP_TITLE = "Which models for the difficulty tiers, and the consulting master?";
83
+ const INHERIT = "(use pi's current model)";
84
+ const CUSTOM_MODEL = "type a model id…";
85
+ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
86
+ const THINK_INHERIT = "(inherit)";
87
+
88
+ async function pickModelChoice(prompt: Prompter, title: string, models: ModelChoice[], current: string): Promise<string | undefined> {
89
+ if (models.length === 0) {
90
+ const typed = await prompt.input(title, current || "provider/model-id");
91
+ return typed;
92
+ }
93
+ const rows = models.map((m) => (m.ref === current ? `${m.label} ← current` : m.label));
94
+ const picked = await prompt.select(title, [INHERIT, ...rows, CUSTOM_MODEL]);
95
+ if (picked === undefined) return undefined;
96
+ if (picked === INHERIT) return "";
97
+ if (picked === CUSTOM_MODEL) {
98
+ const typed = await prompt.input(`${title} — model id`, current || "provider/model-id");
99
+ return typed;
100
+ }
101
+ const model = models[rows.indexOf(picked)];
102
+ return model?.ref;
103
+ }
104
+
105
+ async function pickThinkingLevel(prompt: Prompter, title: string): Promise<ThinkingLevel | "" | undefined> {
106
+ const picked = await prompt.select(title, [THINK_INHERIT, ...THINKING_LEVELS]);
107
+ if (picked === undefined) return undefined;
108
+ if (picked === THINK_INHERIT) return "";
109
+ return picked as ThinkingLevel;
110
+ }
111
+
112
+ async function pickModelsStep(prompt: Prompter, modelsFn?: WizardOptions["models"]): Promise<{ router: NonNullable<IntakeAnswers["router"]> } | undefined> {
113
+ const models = modelsFn ? (await modelsFn()) ?? [] : [];
114
+ // First ask whether routing is even wanted — most runs don't need it, and
115
+ // skipping the 8 follow-up questions keeps the wizard short. Old tests that
116
+ // don't know about this step get routing off by default so they keep passing.
117
+ const ROUTE_ON = "yes — pick models per tier";
118
+ const ROUTE_OFF = "no — use pi's current model for everything";
119
+ const enablePick = await prompt.select("Route work by difficulty to different models?", [ROUTE_ON, ROUTE_OFF]);
120
+ if (enablePick === undefined) {
121
+ // No answer scripted (e.g. an older test) → treat as "off" so the
122
+ // wizard doesn't look cancelled to callers that only scripted four steps.
123
+ return { router: { enabled: false, byDifficulty: { easy: "", moderate: "", difficult: "" }, thinkingByDifficulty: { easy: "", moderate: "", difficult: "" }, master: "", thinkingMaster: "", default: "", thinkingDefault: "" } };
124
+ }
125
+ if (enablePick === ROUTE_OFF) {
126
+ return { router: { enabled: false, byDifficulty: { easy: "", moderate: "", difficult: "" }, thinkingByDifficulty: { easy: "", moderate: "", difficult: "" }, master: "", thinkingMaster: "", default: "", thinkingDefault: "" } };
127
+ }
128
+ const tiers = ["easy", "moderate", "difficult"] as const;
129
+ const byDifficulty: Record<string, string> = {};
130
+ const thinkingByDifficulty: Partial<Record<string, ThinkingLevel | "">> = {};
131
+ for (const tier of tiers) {
132
+ const model = await pickModelChoice(prompt, `${tier.toUpperCase()} tier — model`, models, "");
133
+ if (model === undefined) return undefined;
134
+ byDifficulty[tier] = model;
135
+ const thinking = await pickThinkingLevel(prompt, `${tier.toUpperCase()} tier — thinking level`);
136
+ if (thinking === undefined) return undefined;
137
+ thinkingByDifficulty[tier] = thinking;
138
+ }
139
+ const masterModel = await pickModelChoice(prompt, "Consulting master — model (used only when the ladder is exhausted)", models, "");
140
+ if (masterModel === undefined) return undefined;
141
+ const masterThinking = await pickThinkingLevel(prompt, "Consulting master — thinking level");
142
+ if (masterThinking === undefined) return undefined;
143
+ const defaultModel = await pickModelChoice(prompt, "Default — fallback when nothing more specific matches", models, "");
144
+ if (defaultModel === undefined) return undefined;
145
+ const defaultThinking = await pickThinkingLevel(prompt, "Default — thinking level fallback");
146
+ if (defaultThinking === undefined) return undefined;
147
+ return {
148
+ router: {
149
+ enabled: true,
150
+ byDifficulty,
151
+ thinkingByDifficulty,
152
+ master: masterModel ?? "",
153
+ thinkingMaster: masterThinking ?? "",
154
+ default: defaultModel ?? "",
155
+ thinkingDefault: defaultThinking ?? "",
156
+ },
157
+ };
158
+ }
159
+
60
160
  export async function runIntakeWizard(options: WizardOptions): Promise<WizardResult> {
61
- const { prompt } = options;
161
+ const { prompt, env } = options;
62
162
 
63
163
  for (;;) {
64
- // -- 1. mode ------------------------------------------------------------
65
- const modeOptions = MODE_QUESTION.options ?? [];
66
- const modeLabels = modeOptions.map((o) => line(o.label, o.help));
67
- const modePick = await prompt.select(MODE_QUESTION.title, modeLabels);
68
- if (modePick === undefined) return { cancelled: true };
69
- const mode = (modeOptions[modeLabels.indexOf(modePick)]?.value ?? "copilot") as Mode;
164
+ // -- 1. the workflow ----------------------------------------------------
165
+ const workflow = await pickWorkflow(prompt, env);
166
+ if (workflow === undefined) return { cancelled: true };
70
167
 
71
168
  // -- 2. what are we building -------------------------------------------
72
169
  //
73
- // Asked in *both* modes. This is the question the old flow skipped, and
74
- // skipping it is why autopilot used to invent a project and start
75
- // building it.
170
+ // Asked whatever the workflow is. This is the question the old flow
171
+ // skipped, and skipping it is why autopilot used to invent a project and
172
+ // start building it.
76
173
  const brief =
77
174
  (await prompt.input(BRIEF_QUESTION.title, BRIEF_QUESTION.placeholder)) ?? options.brief ?? "";
78
175
 
79
- // -- 3. research --------------------------------------------------------
80
- const researchOptions = RESEARCH_QUESTION.options ?? [];
81
- const researchLabels = researchOptions.map((o) => line(o.label, o.help));
82
- const researchPick = await prompt.select(RESEARCH_QUESTION.title, researchLabels);
83
- if (researchPick === undefined) return { cancelled: true };
84
- const research = researchOptions[researchLabels.indexOf(researchPick)]?.value === "yes";
85
-
86
- // -- 4. approvals -------------------------------------------------------
87
- //
88
- // Only autopilot gets a choice. In copilot the human is in the loop by
89
- // definition, and offering them a way out of it would make the word mean
90
- // nothing.
91
- let approvals: Partial<ApprovalPolicy> | undefined;
92
- if (mode === "autopilot") {
93
- const picked = await pickApprovals(prompt, research);
94
- if (picked === undefined) return { cancelled: true };
95
- approvals = picked;
96
- }
97
-
98
- // -- 5. sessions --------------------------------------------------------
176
+ // -- 3. sessions --------------------------------------------------------
99
177
  const handoffOptions = HANDOFF_QUESTION.options ?? [];
100
178
  const handoffLabels = handoffOptions.map((o) => line(o.label, o.help));
101
179
  const handoffPick = await prompt.select(HANDOFF_QUESTION.title, handoffLabels);
@@ -105,14 +183,15 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
105
183
  | "phase"
106
184
  | "task";
107
185
 
108
- const answers: IntakeAnswers = {
109
- mode,
110
- brief,
111
- research,
112
- approvals,
113
- handoff,
114
- phases: options.phases,
115
- };
186
+ // -- 4. models ----------------------------------------------------------
187
+ const modelsAnswer = await pickModelsStep(prompt, options.models);
188
+ if (modelsAnswer === undefined) return { cancelled: true };
189
+
190
+ // -- 5. display ---------------------------------------------------------
191
+ const display = await pickDisplay(prompt, env);
192
+ if (display === undefined) return { cancelled: true };
193
+
194
+ const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router };
116
195
  const plan = planIntake(answers);
117
196
 
118
197
  if (options.skipConfirm) return { cancelled: false, plan, answers };
@@ -129,62 +208,235 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
129
208
  }
130
209
  }
131
210
 
132
- const APPROVALS_DONE = "✓ done";
211
+ // ── choosing a workflow ─────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Pick a workflow, or build one.
215
+ *
216
+ * Built-ins first, then whatever this person has saved, then "build one".
217
+ * Undefined means they cancelled.
218
+ */
219
+ export async function pickWorkflow(
220
+ prompt: Prompter,
221
+ env?: NodeJS.ProcessEnv,
222
+ ): Promise<Workflow | undefined> {
223
+ const available = listWorkflows(env);
224
+ const rows = available.map((w) => line(w.builtIn ? w.name : `${w.name} (yours)`, w.description));
225
+ const picked = await prompt.select(WORKFLOW_QUESTION.title, [...rows, BUILD_ONE]);
226
+ if (picked === undefined) return undefined;
227
+ if (picked === BUILD_ONE) return buildWorkflow(prompt, env);
228
+ return available[rows.indexOf(picked)];
229
+ }
230
+
231
+ /**
232
+ * The custom flow: phases, then a mode for each, then keep it or don't.
233
+ *
234
+ * Saving is offered rather than required. Someone trying a one-off shape for
235
+ * one project should not have to name it, and someone who has found the shape
236
+ * they always want should not have to rebuild it every time.
237
+ */
238
+ export async function buildWorkflow(
239
+ prompt: Prompter,
240
+ env?: NodeJS.ProcessEnv,
241
+ ): Promise<Workflow | undefined> {
242
+ // -- which phases run ---------------------------------------------------
243
+ const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
244
+ for (;;) {
245
+ const rows = SELECTABLE_PHASES.map(
246
+ (p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p} — ${PHASE_PURPOSE[p]}`,
247
+ );
248
+ const hit = await prompt.select(
249
+ `Which phases should run? (${chosen.size ? [...SELECTABLE_PHASES].filter((p) => chosen.has(p)).join(" → ") : "none yet"})`,
250
+ [...rows, DONE],
251
+ );
252
+ if (hit === undefined) return undefined;
253
+ if (hit === DONE) break;
254
+ const key = SELECTABLE_PHASES[rows.indexOf(hit)];
255
+ if (!key) break;
256
+ if (chosen.has(key)) chosen.delete(key);
257
+ else chosen.add(key);
258
+ }
259
+
260
+ const phases = normalizePhases([...chosen]);
261
+
262
+ // -- a mode for each ----------------------------------------------------
263
+ //
264
+ // Asked one phase at a time rather than as a checklist, because "does this
265
+ // one stop for me" is a different question for RESEARCH than for SHIP and
266
+ // the help text is the useful part.
267
+ const modes: PhaseModes = {};
268
+ const modeLabels = PHASE_MODE_OPTIONS.map((o) => line(o.label, o.help));
269
+ for (const phase of phases) {
270
+ const answer = await prompt.select(
271
+ `${phase.toUpperCase()} — ${PHASE_PURPOSE[phase]}`,
272
+ modeLabels,
273
+ );
274
+ if (answer === undefined) return undefined;
275
+ modes[phase] = (PHASE_MODE_OPTIONS[modeLabels.indexOf(answer)]?.value ?? "autopilot") as PhaseMode;
276
+ }
277
+
278
+ const draft = customWorkflow(phases, modes);
279
+ prompt.notify(renderWorkflow(draft), "info");
280
+
281
+ // -- keep it? -----------------------------------------------------------
282
+ const KEEP = "save it under a name I can reuse";
283
+ const ONCE = "just use it here";
284
+ const keep = await prompt.select("Keep this workflow?", [KEEP, ONCE]);
285
+ if (keep === undefined) return undefined;
286
+ if (keep !== KEEP) return draft;
287
+
288
+ for (;;) {
289
+ const name = await prompt.input("Call it what?", "e.g. spec-heavy, weekend-run, client-work");
290
+ if (name === undefined || !name.trim()) return draft;
291
+ const saved = saveWorkflow(
292
+ { name: name.trim(), phases, modes },
293
+ env,
294
+ );
295
+ if (saved.ok && saved.workflow) {
296
+ prompt.notify(`Saved "${saved.workflow.name}". It will be offered on every project.`, "info");
297
+ return saved.workflow;
298
+ }
299
+ prompt.notify(saved.error ?? "Could not save that.", "warning");
300
+ }
301
+ }
302
+
303
+ // ── choosing a display template ─────────────────────────────────────────────
304
+
305
+ const CUSTOMISE = "choose level by level";
306
+
307
+ export async function pickDisplay(
308
+ prompt: Prompter,
309
+ env?: NodeJS.ProcessEnv,
310
+ ): Promise<DisplayPolicy | undefined> {
311
+ const available = listDisplays(env);
312
+ const rows = available.map((d) => line(d.builtIn ? d.name : `${d.name} (yours)`, d.description));
313
+ const picked = await prompt.select(DISPLAY_QUESTION.title, [...rows, CUSTOMISE]);
314
+ if (picked === undefined) return undefined;
315
+ if (picked !== CUSTOMISE) return available[rows.indexOf(picked)]?.policy ?? defaultDisplay();
316
+ return buildDisplay(prompt, defaultDisplay(), env);
317
+ }
318
+
319
+ const SUBTASK_CYCLE: DisplayPolicy["levels"]["subtask"][] = ["none", "active", "all"];
133
320
 
134
321
  /**
135
- * A checklist, built out of `select` because that is the only list widget pi
136
- * gives an extension. Each pick toggles a row and redraws; `done` commits.
322
+ * The level-by-level editor, shared by the wizard and `/infinity:display`.
323
+ *
324
+ * Every row toggles; subtasks cycle through none → active → all, because
325
+ * "only on the task being worked" is the answer most people want and a plain
326
+ * on/off cannot express it.
137
327
  */
138
- async function pickApprovals(
328
+ export async function buildDisplay(
139
329
  prompt: Prompter,
140
- research: boolean,
141
- ): Promise<Partial<ApprovalPolicy> | undefined> {
142
- const options = approvalOptions(research);
143
- // DEFINE is pre-ticked: it is the signature that pays for itself, and a
144
- // human who genuinely wants to forfeit everything unticks one box.
145
- const chosen = new Set<keyof ApprovalPolicy>(["define"]);
330
+ starting: DisplayPolicy,
331
+ env?: NodeJS.ProcessEnv,
332
+ ): Promise<DisplayPolicy | undefined> {
333
+ let policy = normalizeDisplay(starting);
146
334
 
147
335
  for (;;) {
148
- const rows = options.map((o) => `${chosen.has(o.value) ? "[x]" : "[ ]"} ${line(o.label, o.help)}`);
149
- const summary = chosen.size
150
- ? `you will sign ${[...chosen].map((c) => c.toUpperCase()).join(", ")}`
151
- : "you will sign nothing — the model decides and runs";
152
- const pick = await prompt.select(`Which phases do you want to approve? (${summary})`, [
336
+ const rows = [
337
+ `${policy.levels.goal ? "[x]" : "[ ]"} goals`,
338
+ `${policy.levels.sprint ? "[x]" : "[ ]"} sprints`,
339
+ `${policy.levels.feature ? "[x]" : "[ ]"} features`,
340
+ `${policy.levels.task ? "[x]" : "[ ]"} tasks`,
341
+ `[${policy.levels.subtask}] subtasks — none · active (only the task being worked) · all`,
342
+ `${policy.counts ? "[x]" : "[ ]"} done/total counts on goals, sprints and features`,
343
+ `${policy.dependencies ? "[x]" : "[ ]"} dependency labels (← #3)`,
344
+ `${policy.criteria ? "[x]" : "[ ]"} acceptance criteria (dashboard only)`,
345
+ `${policy.rail ? "[x]" : "[ ]"} the phase rail`,
346
+ `${policy.progress ? "[x]" : "[ ]"} the progress meter`,
347
+ `${policy.alerts ? "[x]" : "[ ]"} the alert strip (blocked, rework, retries, approvals)`,
348
+ `[${policy.taskWindow}] rows of plan in the terminal before it scrolls`,
349
+ ];
350
+ const SAVE = "save this as a template I can reuse";
351
+ const hit = await prompt.select("What should the widget and the dashboard show?", [
153
352
  ...rows,
154
- APPROVALS_DONE,
353
+ SAVE,
354
+ DONE,
155
355
  ]);
156
- if (pick === undefined) return undefined;
157
- if (pick === APPROVALS_DONE) break;
158
- const hit = options[rows.indexOf(pick)];
159
- if (!hit) break;
160
- if (chosen.has(hit.value)) chosen.delete(hit.value);
161
- else chosen.add(hit.value);
162
- }
356
+ if (hit === undefined) return undefined;
357
+ if (hit === DONE) return { ...policy, preset: "custom" };
163
358
 
164
- return {
165
- research: chosen.has("research"),
166
- define: chosen.has("define"),
167
- plan: chosen.has("plan"),
168
- };
359
+ if (hit === SAVE) {
360
+ const name = await prompt.input("Call it what?", "e.g. sprint-view, my-focus");
361
+ if (name && name.trim()) {
362
+ const saved = saveDisplay({ name: name.trim(), policy }, env);
363
+ if (saved.ok && saved.template) {
364
+ prompt.notify(`Saved "${saved.template.name}".`, "info");
365
+ return saved.template.policy;
366
+ }
367
+ prompt.notify(saved.error ?? "Could not save that.", "warning");
368
+ }
369
+ continue;
370
+ }
371
+
372
+ const idx = rows.indexOf(hit);
373
+ switch (idx) {
374
+ case 0:
375
+ policy = { ...policy, levels: { ...policy.levels, goal: !policy.levels.goal } };
376
+ break;
377
+ case 1:
378
+ policy = { ...policy, levels: { ...policy.levels, sprint: !policy.levels.sprint } };
379
+ break;
380
+ case 2:
381
+ policy = { ...policy, levels: { ...policy.levels, feature: !policy.levels.feature } };
382
+ break;
383
+ case 3:
384
+ policy = { ...policy, levels: { ...policy.levels, task: !policy.levels.task } };
385
+ break;
386
+ case 4: {
387
+ const next = SUBTASK_CYCLE[(SUBTASK_CYCLE.indexOf(policy.levels.subtask) + 1) % SUBTASK_CYCLE.length]!;
388
+ policy = { ...policy, levels: { ...policy.levels, subtask: next } };
389
+ break;
390
+ }
391
+ case 5:
392
+ policy = { ...policy, counts: !policy.counts };
393
+ break;
394
+ case 6:
395
+ policy = { ...policy, dependencies: !policy.dependencies };
396
+ break;
397
+ case 7:
398
+ policy = { ...policy, criteria: !policy.criteria };
399
+ break;
400
+ case 8:
401
+ policy = { ...policy, rail: !policy.rail };
402
+ break;
403
+ case 9:
404
+ policy = { ...policy, progress: !policy.progress };
405
+ break;
406
+ case 10:
407
+ policy = { ...policy, alerts: !policy.alerts };
408
+ break;
409
+ case 11: {
410
+ const answer = await prompt.input("How many rows?", String(policy.taskWindow));
411
+ const n = Number(String(answer ?? "").trim());
412
+ if (Number.isFinite(n)) policy = normalizeDisplay({ ...policy, taskWindow: n });
413
+ break;
414
+ }
415
+ default:
416
+ return { ...policy, preset: "custom" };
417
+ }
418
+ }
169
419
  }
170
420
 
171
421
  /**
172
422
  * The wizard's answers when nobody is there to give any.
173
423
  *
174
424
  * A headless run must not stall on a prompt, and it must not silently become
175
- * a mode the human did not pick. The safe default is autopilot with nothing
176
- * approved — because a run with an approval gate and no human to answer it
177
- * would park forever — plus a loud warning that says exactly that.
425
+ * a workflow the human did not pick. The safe default is autopilot with
426
+ * nothing approved — because an approval gate with no human to answer it would
427
+ * park forever — plus a loud warning that says exactly that.
178
428
  */
179
429
  export function unattendedIntake(brief: string | null, phases?: Phase[]): IntakePlan {
180
- const plan = planIntake({
181
- mode: "autopilot",
182
- brief: brief ?? "",
183
- research: false,
184
- approvals: { research: false, define: false, plan: false },
185
- handoff: "phase",
186
- phases,
187
- });
430
+ const ordered = normalizePhases(phases ?? [...DEFAULT_ENABLED_PHASES]);
431
+ const workflow: Workflow = {
432
+ id: "autopilot",
433
+ name: "autopilot",
434
+ description: "You approve nothing.",
435
+ builtIn: true,
436
+ phases: ordered,
437
+ modes: normalizeModes({}, ordered),
438
+ };
439
+ const plan = planIntake({ workflow, brief: brief ?? "", handoff: "phase" });
188
440
  return {
189
441
  ...plan,
190
442
  warnings: [