infinity-harness 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/widget.ts CHANGED
@@ -13,8 +13,9 @@
13
13
  */
14
14
 
15
15
  import type { FeatureList, Phase, TaskStatus } from "../core/types.ts";
16
- import { computeProgress, flattenTasks, type FlatTask } from "../core/featureList.ts";
16
+ import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.ts";
17
17
  import { getPhaseOrder } from "../core/phases.ts";
18
+ import { buildPlanRows, focusRowIndex, type PlanRow } from "./planTree.ts";
18
19
  import {
19
20
  createStyler,
20
21
  detectGlyphs,
@@ -28,10 +29,14 @@ import {
28
29
  } from "./theme.ts";
29
30
 
30
31
  export const DEFAULT_WIDTH = 76;
31
- /** Task rows shown at once. Enough for context, short enough to stay glanceable. */
32
+ /** Plan rows shown at once. Enough for context, short enough to stay glanceable. */
32
33
  export const TASK_WINDOW = 9;
33
34
  /** Completed rows kept above the active task so progress stays visible. */
34
35
  export const COMPLETED_CONTEXT = 3;
36
+ /** How many rows one scroll step moves. */
37
+ export const SCROLL_STEP = 3;
38
+ /** Rows shown when the human expands the widget. */
39
+ export const EXPANDED_WINDOW = 28;
35
40
 
36
41
  export type WidgetState = {
37
42
  list: FeatureList;
@@ -50,8 +55,58 @@ export type WidgetState = {
50
55
  goalPass?: { current: number; max: number } | null;
51
56
  /** The last rung the escalation ladder took, and what it has spent. */
52
57
  escalation?: { strategy: string | null; reworks: number; replans: number } | null;
58
+ /**
59
+ * A phase whose gate passed and which is waiting for a human signature.
60
+ *
61
+ * Without this the widget of a run parked on *you* is indistinguishable from
62
+ * the widget of a run that has quietly died — which is exactly the moment
63
+ * someone walks away and comes back an hour later to no progress.
64
+ */
65
+ awaitingApproval?: string | null;
66
+ /**
67
+ * How the human is looking at the plan right now.
68
+ *
69
+ * `scroll: null` follows the run — the window stays centred on the active
70
+ * task, which is what you want while it is working. A number means the
71
+ * human took the wheel and the widget stops moving under them.
72
+ */
73
+ view?: WidgetView | null;
74
+ /** Sessions this run has spent. Only meaningful once handoff is on. */
75
+ sessions?: number | null;
76
+ /**
77
+ * What the human asked for, before a plan exists to hold a goal.
78
+ *
79
+ * Between init and the first `infinity_plan` call the widget had nothing to
80
+ * say about what the run was even for, which is the exact window in which
81
+ * someone wants to check that it understood them.
82
+ */
83
+ intake?: string | null;
84
+ };
85
+
86
+ export type WidgetView = {
87
+ /** First visible row, or null to follow the active task. */
88
+ scroll: number | null;
89
+ /** Show every subtask, and more rows at once. */
90
+ expanded: boolean;
53
91
  };
54
92
 
93
+ export function defaultView(): WidgetView {
94
+ return { scroll: null, expanded: false };
95
+ }
96
+
97
+ /**
98
+ * Move the view. Returns a new view; the caller stores it.
99
+ *
100
+ * Scrolling to the very top is how the human gets back to "following the run":
101
+ * a widget that has to be reset from a menu is a widget that stays stuck.
102
+ */
103
+ export function scrollView(view: WidgetView, delta: number, rowCount: number, windowRows: number): WidgetView {
104
+ const max = Math.max(0, rowCount - windowRows);
105
+ const current = view.scroll ?? 0;
106
+ const next = Math.max(0, Math.min(max, current + delta));
107
+ return { ...view, scroll: next };
108
+ }
109
+
55
110
  export type WidgetOptions = {
56
111
  width?: number;
57
112
  styler?: Styler;
@@ -171,13 +226,12 @@ export function phaseRail(
171
226
  }
172
227
 
173
228
  function depLabel(
174
- task: FlatTask,
229
+ deps: string[] | undefined,
175
230
  indexByKey: Map<string, number>,
176
231
  g: GlyphSet,
177
232
  s: Styler,
178
233
  ): string {
179
- const deps = task.dependsOn ?? [];
180
- if (deps.length === 0) return "";
234
+ if (!deps || deps.length === 0) return "";
181
235
  const nums = deps.map((d) => {
182
236
  const i = indexByKey.get(d);
183
237
  return i === undefined ? d : "#" + i;
@@ -185,6 +239,122 @@ function depLabel(
185
239
  return s.fg("muted", g.arrow + " " + nums.join(", "));
186
240
  }
187
241
 
242
+ /** `2/5` — how much of a grouping row's branch is finished. */
243
+ function countTag(row: PlanRow, s: Styler): string {
244
+ if (row.total === 0) return s.fg("rule", "empty");
245
+ const role: Role = row.done === row.total ? "success" : row.done > 0 ? "active" : "muted";
246
+ return s.fg(role, row.done + "/" + row.total);
247
+ }
248
+
249
+ const LEVEL_ROLE: Record<PlanRow["level"], Role> = {
250
+ goal: "brand",
251
+ sprint: "accent",
252
+ feature: "muted",
253
+ task: "text",
254
+ subtask: "muted",
255
+ };
256
+
257
+ /**
258
+ * One plan row, drawn.
259
+ *
260
+ * Indentation is by level, so the five levels of the plan are five columns on
261
+ * screen and the shape of the plan is readable without counting ids. The
262
+ * grouping rows carry a `done/total` tag on the right, which is what turns a
263
+ * long plan into something you can judge at a glance instead of reading.
264
+ */
265
+ function renderRow(
266
+ row: PlanRow,
267
+ inner: number,
268
+ indexByKey: Map<string, number>,
269
+ g: GlyphSet,
270
+ s: Styler,
271
+ ): string[] {
272
+ const indent = " ".repeat(row.depth);
273
+
274
+ if (row.level === "goal" || row.level === "sprint") {
275
+ const icon = s.fg(LEVEL_ROLE[row.level], row.level === "goal" ? g.goal : g.sprint);
276
+ const tag = countTag(row, s);
277
+ const prefix = indent + icon + " ";
278
+ const head =
279
+ s.bold(s.fg(LEVEL_ROLE[row.level], row.title)) +
280
+ (row.label && row.label !== row.title ? s.fg("rule", " " + row.label) : "");
281
+ const body = truncate(prefix + head, Math.max(8, inner - width(tag) - 1));
282
+ const gap = Math.max(1, inner - width(body) - width(tag));
283
+ return [body + " ".repeat(gap) + tag];
284
+ }
285
+
286
+ if (row.level === "feature") {
287
+ const tag = countTag(row, s);
288
+ const prefix = indent + s.fg("muted", g.branch + " ");
289
+ 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));
291
+ const gap = Math.max(1, inner - width(body) - width(tag));
292
+ return [body + " ".repeat(gap) + tag];
293
+ }
294
+
295
+ if (row.level === "subtask") {
296
+ const icon =
297
+ row.status === "complete"
298
+ ? s.fg("success", g.subDone)
299
+ : row.status === "in_progress"
300
+ ? s.fg("active", g.subActive)
301
+ : s.fg("rule", g.subPending);
302
+ const prefix = indent + icon + " ";
303
+ return [truncate(prefix + s.fg("muted", row.title), inner)];
304
+ }
305
+
306
+ // -- a task ---------------------------------------------------------------
307
+ const role = statusRole(row.status ?? "pending");
308
+ const icon = s.fg(role, statusGlyph(row.status ?? "pending", g));
309
+ const num = s.fg("rule", row.label);
310
+ const dep = depLabel(row.dependsOn, indexByKey, g, s);
311
+ const prefix = indent + icon + " " + num + " ";
312
+ const prefixW = width(prefix);
313
+ const depW = dep ? width(dep) + 1 : 0;
314
+ const titleMax = Math.max(8, inner - prefixW - depW);
315
+
316
+ const titleLines = wrap(row.title || row.id, titleMax);
317
+ const first = titleLines[0] ?? row.id;
318
+ const head = row.active
319
+ ? s.bold(s.fg("text", first))
320
+ : s.fg(role === "success" ? "muted" : "text", first);
321
+
322
+ let line = prefix + head;
323
+ if (dep) {
324
+ const spacer = Math.max(1, inner - width(line) - width(dep));
325
+ line += " ".repeat(spacer) + dep;
326
+ }
327
+ const out = [line];
328
+ for (const extra of titleLines.slice(1)) out.push(" ".repeat(prefixW) + s.fg("muted", extra));
329
+ return out;
330
+ }
331
+
332
+ /**
333
+ * Which slice of rows to show.
334
+ *
335
+ * With no explicit scroll the window follows the run: centred on the active
336
+ * task, biased so a few finished rows stay above it, because "what just got
337
+ * done" is most of what makes progress legible.
338
+ */
339
+ export function rowWindow(
340
+ rows: PlanRow[],
341
+ limit: number,
342
+ scroll: number | null,
343
+ context = COMPLETED_CONTEXT,
344
+ ): { start: number; end: number } {
345
+ const total = rows.length;
346
+ if (total <= limit) return { start: 0, end: total };
347
+ if (scroll !== null) {
348
+ const start = Math.max(0, Math.min(scroll, total - limit));
349
+ return { start, end: start + limit };
350
+ }
351
+ const focus = focusRowIndex(rows);
352
+ if (focus < limit - context) return { start: 0, end: limit };
353
+ if (focus >= total - (limit - context)) return { start: total - limit, end: total };
354
+ const start = Math.max(0, Math.min(focus - context, total - limit));
355
+ return { start, end: start + limit };
356
+ }
357
+
188
358
  /**
189
359
  * Render the widget as terminal lines.
190
360
  *
@@ -198,7 +368,8 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
198
368
  const boxed = options.boxed ?? false;
199
369
  const pad = boxed ? 2 : 0;
200
370
  const inner = Math.max(24, total - pad * 2);
201
- const limit = options.taskWindow ?? TASK_WINDOW;
371
+ const view = state.view ?? defaultView();
372
+ const limit = options.taskWindow ?? (view.expanded ? EXPANDED_WINDOW : TASK_WINDOW);
202
373
 
203
374
  const out: string[] = [];
204
375
  const push = (line = ""): void => {
@@ -222,11 +393,19 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
222
393
  push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
223
394
 
224
395
  // -- goal -----------------------------------------------------------------
225
- const goal = (state.list.goals ?? [])[0];
226
- if (goal?.title) {
227
- for (const line of wrap(goal.title, inner - 2)) {
228
- push(s.fg("muted", g.branch + " ") + s.fg("text", line));
229
- }
396
+ //
397
+ // A single goal is the run's headline and belongs at the top, not buried in
398
+ // the tree `buildPlanRows` collapses it there for exactly this reason.
399
+ // Several goals are structure, and structure belongs in the tree.
400
+ const goals = state.list.goals ?? [];
401
+ const headline = goals.length === 1 ? (goals[0]?.title ?? null) : goals.length === 0 ? (state.intake ?? null) : null;
402
+ if (headline) {
403
+ const wrapped = wrap(headline, inner - 2);
404
+ wrapped.forEach((line, i) => {
405
+ // The marker belongs to the goal, not to every line of it. Repeating it
406
+ // down the left edge reads as a list of goals rather than one wrapped.
407
+ push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
408
+ });
230
409
  }
231
410
 
232
411
  // -- phase rail -----------------------------------------------------------
@@ -270,15 +449,29 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
270
449
  if (state.escalation?.strategy) {
271
450
  alerts.push(s.fg("rework", g.rework + " " + state.escalation.strategy));
272
451
  }
452
+ if (state.awaitingApproval) {
453
+ alerts.unshift(
454
+ s.bold(s.fg("active", g.rework + " " + state.awaitingApproval.toUpperCase() + " needs your OK")),
455
+ );
456
+ }
457
+ if (typeof state.sessions === "number" && state.sessions > 1) {
458
+ // Proof the handoff is working. Without it a run that quietly stopped
459
+ // starting fresh sessions looks exactly like one that never did.
460
+ alerts.push(s.fg("muted", "session " + state.sessions));
461
+ }
273
462
  if (state.gate && !state.gate.overall) {
274
463
  alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
275
464
  }
276
465
  if (alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
277
466
 
278
- // -- tasks ----------------------------------------------------------------
467
+ // -- the plan -------------------------------------------------------------
468
+ //
469
+ // All five levels, windowed. The window is the answer to "the widget is
470
+ // truncated": the rows above and below are not gone, they are one keypress
471
+ // away, and the widget says how many there are so nobody has to guess.
279
472
  push();
280
- if (tasks.length === 0) {
281
- push(s.fg("muted", " no tasks planned yet"));
473
+ if (tasks.length === 0 && (state.list.features ?? []).length === 0) {
474
+ push(s.fg("muted", " no plan yet"));
282
475
  return frame(out, total, boxed, s, g);
283
476
  }
284
477
 
@@ -289,72 +482,41 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
289
482
  if (t.key) indexByKey.set(t.key, t.index);
290
483
  }
291
484
 
292
- const bounds = taskWindowBounds(tasks, limit);
293
- const visible = tasks.slice(bounds.start, bounds.end);
485
+ const active = nextActionableTask(state.list);
486
+ const rows = buildPlanRows(state.list, active?.compositeKey ?? null, {
487
+ expandSubtasks: view.expanded,
488
+ });
489
+
490
+ const bounds = rowWindow(rows, limit, view.scroll);
294
491
  const hiddenBefore = bounds.start;
295
- const hiddenAfter = tasks.length - bounds.end;
492
+ const hiddenAfter = rows.length - bounds.end;
296
493
 
297
494
  if (hiddenBefore > 0) {
298
- push(s.fg("rule", " " + g.more + " " + hiddenBefore + " earlier"));
495
+ const hint = view.scroll === null ? "" : s.fg("rule", " " + hintKeys(g));
496
+ push(s.fg("rule", " " + g.more + " " + hiddenBefore + " above") + hint);
299
497
  }
300
498
 
301
- let lastFeature: string | null = null;
302
- const numW = String(tasks.length).length;
303
-
304
- for (const t of visible) {
305
- if (t.featureId !== lastFeature) {
306
- lastFeature = t.featureId;
307
- const label = t.featureId + s.fg("rule", " · ") + t.featureName;
308
- push(s.fg("muted", g.branch + " ") + truncate(label, inner - 2));
309
- }
310
-
311
- const role = statusRole(t.status);
312
- const icon = s.fg(role, statusGlyph(t.status, g));
313
- const num = s.fg("rule", String(t.index).padStart(numW));
314
- const dep = depLabel(t, indexByKey, g, s);
315
- const prefix = " " + icon + " " + num + " ";
316
- const prefixW = width(prefix);
317
- const depW = dep ? width(dep) + 1 : 0;
318
- const titleMax = Math.max(8, inner - prefixW - depW);
319
-
320
- const isActive = t.status === "in_progress" || t.status === "rework";
321
- const titleLines = wrap(t.description || t.compositeKey, titleMax);
322
- const head = isActive ? s.bold(s.fg("text", titleLines[0]!)) : s.fg(role === "success" ? "muted" : "text", titleLines[0]!);
323
-
324
- let row = prefix + head;
325
- if (dep) {
326
- const rowW = width(row);
327
- const spacer = Math.max(1, inner - rowW - width(dep));
328
- row += " ".repeat(spacer) + dep;
329
- }
330
- push(row);
331
- for (const extra of titleLines.slice(1)) {
332
- push(" ".repeat(prefixW) + s.fg("muted", extra));
333
- }
334
-
335
- // Subtasks only for the task actually being worked — otherwise the window
336
- // fills with detail nobody is acting on.
337
- if (isActive) {
338
- for (const sub of t.subtasks ?? []) {
339
- const sIcon =
340
- sub.status === "complete"
341
- ? s.fg("success", g.subDone)
342
- : sub.status === "in_progress"
343
- ? s.fg("active", g.subActive)
344
- : s.fg("rule", g.subPending);
345
- const sTitle = truncate(sub.title, inner - prefixW - 4);
346
- push(" ".repeat(prefixW) + sIcon + " " + s.fg("muted", sTitle));
347
- }
348
- }
499
+ for (const row of rows.slice(bounds.start, bounds.end)) {
500
+ for (const line of renderRow(row, inner, indexByKey, g, s)) push(line);
349
501
  }
350
502
 
351
503
  if (hiddenAfter > 0) {
352
- push(s.fg("rule", " " + g.more + " " + hiddenAfter + " more"));
504
+ push(s.fg("rule", " " + g.more + " " + hiddenAfter + " below " + hintKeys(g)));
353
505
  }
354
506
 
355
507
  return frame(out, total, boxed, s, g);
356
508
  }
357
509
 
510
+ /**
511
+ * The keys that move the window, said once, where the window runs out.
512
+ *
513
+ * `alt+` rather than `ctrl+`: pi binds ctrl+j, ctrl+k and ctrl+o in the editor
514
+ * already, and a widget is not worth shadowing an editor key for.
515
+ */
516
+ function hintKeys(_g: GlyphSet): string {
517
+ return "alt+j/k scroll · alt+o expand";
518
+ }
519
+
358
520
  function frame(lines: string[], total: number, boxed: boolean, s: Styler, g: GlyphSet): string[] {
359
521
  // Unboxed still has to honour the requested width: the caller sized the
360
522
  // widget to a terminal, and a row that overruns wraps and breaks the layout.
@@ -371,6 +533,7 @@ function frame(lines: string[], total: number, boxed: boolean, s: Styler, g: Gly
371
533
  export function renderStatusLine(state: WidgetState, g: GlyphSet = detectGlyphs()): string {
372
534
  const p = computeProgress(state.list);
373
535
  if (state.paused) return "paused";
536
+ if (state.awaitingApproval) return `${state.awaitingApproval} · needs your OK`;
374
537
  if (p.tasksTotal === 0) return state.phase ?? "idle";
375
538
  const mark =
376
539
  p.blocked > 0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * infinity-harness — the start-up wizard, as a flow.
3
+ *
4
+ * `src/intake.ts` decides what a set of answers *means*. This asks the
5
+ * questions. Like the settings menu it talks to a `Prompter` rather than to
6
+ * pi, so the whole conversation can be driven by a test — and, in the E2E
7
+ * suite, by a script answering over pi's RPC extension-UI protocol, which is
8
+ * as close to watching a human use it as this gets.
9
+ *
10
+ * The flow is short on purpose. Five questions, three of them one keypress:
11
+ *
12
+ * 1. copilot or autopilot?
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?
17
+ *
18
+ * Cancelling any question cancels the wizard. Nothing is written until the
19
+ * human has seen the summary and said yes, because a wizard that half-commits
20
+ * leaves a project in a state nobody chose.
21
+ */
22
+
23
+ import type { Prompter } from "./config.ts";
24
+ import type { ApprovalPolicy, Phase } from "../core/types.ts";
25
+ import {
26
+ BRIEF_QUESTION,
27
+ HANDOFF_QUESTION,
28
+ MODE_QUESTION,
29
+ RESEARCH_QUESTION,
30
+ approvalOptions,
31
+ planIntake,
32
+ type IntakeAnswers,
33
+ type IntakePlan,
34
+ type Mode,
35
+ } from "../intake.ts";
36
+
37
+ export type WizardOptions = {
38
+ 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>`. */
42
+ brief?: string | null;
43
+ /** Skip the final confirmation. Used when the caller does its own. */
44
+ skipConfirm?: boolean;
45
+ };
46
+
47
+ export type WizardResult =
48
+ | { cancelled: true }
49
+ | { cancelled: false; plan: IntakePlan; answers: IntakeAnswers };
50
+
51
+ const CONFIRM = "start with these settings";
52
+ const RESTART = "change something";
53
+ const CANCEL = "cancel";
54
+
55
+ /** Render a choice as one selectable line: the label, then why you would pick it. */
56
+ function line(label: string, help: string): string {
57
+ return `${label} — ${help}`;
58
+ }
59
+
60
+ export async function runIntakeWizard(options: WizardOptions): Promise<WizardResult> {
61
+ const { prompt } = options;
62
+
63
+ 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;
70
+
71
+ // -- 2. what are we building -------------------------------------------
72
+ //
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.
76
+ const brief =
77
+ (await prompt.input(BRIEF_QUESTION.title, BRIEF_QUESTION.placeholder)) ?? options.brief ?? "";
78
+
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 --------------------------------------------------------
99
+ const handoffOptions = HANDOFF_QUESTION.options ?? [];
100
+ const handoffLabels = handoffOptions.map((o) => line(o.label, o.help));
101
+ const handoffPick = await prompt.select(HANDOFF_QUESTION.title, handoffLabels);
102
+ if (handoffPick === undefined) return { cancelled: true };
103
+ const handoff = (handoffOptions[handoffLabels.indexOf(handoffPick)]?.value ?? "phase") as
104
+ | "off"
105
+ | "phase"
106
+ | "task";
107
+
108
+ const answers: IntakeAnswers = {
109
+ mode,
110
+ brief,
111
+ research,
112
+ approvals,
113
+ handoff,
114
+ phases: options.phases,
115
+ };
116
+ const plan = planIntake(answers);
117
+
118
+ if (options.skipConfirm) return { cancelled: false, plan, answers };
119
+
120
+ const body = [plan.summary, ...(plan.warnings.length ? ["", ...plan.warnings.map((w) => `! ${w}`)] : [])].join(
121
+ "\n",
122
+ );
123
+ prompt.notify(body, plan.warnings.length ? "warning" : "info");
124
+
125
+ const confirm = await prompt.select("Ready?", [CONFIRM, RESTART, CANCEL]);
126
+ if (confirm === undefined || confirm === CANCEL) return { cancelled: true };
127
+ if (confirm === RESTART) continue;
128
+ return { cancelled: false, plan, answers };
129
+ }
130
+ }
131
+
132
+ const APPROVALS_DONE = "✓ done";
133
+
134
+ /**
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.
137
+ */
138
+ async function pickApprovals(
139
+ 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"]);
146
+
147
+ 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})`, [
153
+ ...rows,
154
+ APPROVALS_DONE,
155
+ ]);
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
+ }
163
+
164
+ return {
165
+ research: chosen.has("research"),
166
+ define: chosen.has("define"),
167
+ plan: chosen.has("plan"),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * The wizard's answers when nobody is there to give any.
173
+ *
174
+ * 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.
178
+ */
179
+ 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
+ });
188
+ return {
189
+ ...plan,
190
+ warnings: [
191
+ "No dialogs are available here, so the wizard was skipped: autopilot, nothing approved by you.",
192
+ ...plan.warnings,
193
+ ],
194
+ };
195
+ }