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/ui/wizard.ts CHANGED
@@ -7,13 +7,16 @@
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
@@ -21,27 +24,41 @@
21
24
  */
22
25
 
23
26
  import type { Prompter } from "./config.ts";
24
- import type { ApprovalPolicy, Phase } from "../core/types.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";
36
53
 
37
54
  export type WizardOptions = {
38
55
  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>`. */
56
+ /** Pre-fill the goal, e.g. from `/infinity:init <goal>`. */
42
57
  brief?: string | null;
43
58
  /** Skip the final confirmation. Used when the caller does its own. */
44
59
  skipConfirm?: boolean;
60
+ /** Where saved workflows and templates live. Tests point this elsewhere. */
61
+ env?: NodeJS.ProcessEnv;
45
62
  };
46
63
 
47
64
  export type WizardResult =
@@ -51,6 +68,8 @@ export type WizardResult =
51
68
  const CONFIRM = "start with these settings";
52
69
  const RESTART = "change something";
53
70
  const CANCEL = "cancel";
71
+ const BUILD_ONE = "build one — I choose the phases and which of them stop for me";
72
+ const DONE = "✓ done";
54
73
 
55
74
  /** Render a choice as one selectable line: the label, then why you would pick it. */
56
75
  function line(label: string, help: string): string {
@@ -58,44 +77,22 @@ function line(label: string, help: string): string {
58
77
  }
59
78
 
60
79
  export async function runIntakeWizard(options: WizardOptions): Promise<WizardResult> {
61
- const { prompt } = options;
80
+ const { prompt, env } = options;
62
81
 
63
82
  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;
83
+ // -- 1. the workflow ----------------------------------------------------
84
+ const workflow = await pickWorkflow(prompt, env);
85
+ if (workflow === undefined) return { cancelled: true };
70
86
 
71
87
  // -- 2. what are we building -------------------------------------------
72
88
  //
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.
89
+ // Asked whatever the workflow is. This is the question the old flow
90
+ // skipped, and skipping it is why autopilot used to invent a project and
91
+ // start building it.
76
92
  const brief =
77
93
  (await prompt.input(BRIEF_QUESTION.title, BRIEF_QUESTION.placeholder)) ?? options.brief ?? "";
78
94
 
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 --------------------------------------------------------
95
+ // -- 3. sessions --------------------------------------------------------
99
96
  const handoffOptions = HANDOFF_QUESTION.options ?? [];
100
97
  const handoffLabels = handoffOptions.map((o) => line(o.label, o.help));
101
98
  const handoffPick = await prompt.select(HANDOFF_QUESTION.title, handoffLabels);
@@ -105,14 +102,11 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
105
102
  | "phase"
106
103
  | "task";
107
104
 
108
- const answers: IntakeAnswers = {
109
- mode,
110
- brief,
111
- research,
112
- approvals,
113
- handoff,
114
- phases: options.phases,
115
- };
105
+ // -- 4. display ---------------------------------------------------------
106
+ const display = await pickDisplay(prompt, env);
107
+ if (display === undefined) return { cancelled: true };
108
+
109
+ const answers: IntakeAnswers = { workflow, brief, handoff, display };
116
110
  const plan = planIntake(answers);
117
111
 
118
112
  if (options.skipConfirm) return { cancelled: false, plan, answers };
@@ -129,62 +123,235 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
129
123
  }
130
124
  }
131
125
 
132
- const APPROVALS_DONE = "✓ done";
126
+ // ── choosing a workflow ─────────────────────────────────────────────────────
133
127
 
134
128
  /**
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.
129
+ * Pick a workflow, or build one.
130
+ *
131
+ * Built-ins first, then whatever this person has saved, then "build one".
132
+ * Undefined means they cancelled.
137
133
  */
138
- async function pickApprovals(
134
+ export async function pickWorkflow(
139
135
  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"]);
136
+ env?: NodeJS.ProcessEnv,
137
+ ): Promise<Workflow | undefined> {
138
+ const available = listWorkflows(env);
139
+ const rows = available.map((w) => line(w.builtIn ? w.name : `${w.name} (yours)`, w.description));
140
+ const picked = await prompt.select(WORKFLOW_QUESTION.title, [...rows, BUILD_ONE]);
141
+ if (picked === undefined) return undefined;
142
+ if (picked === BUILD_ONE) return buildWorkflow(prompt, env);
143
+ return available[rows.indexOf(picked)];
144
+ }
146
145
 
146
+ /**
147
+ * The custom flow: phases, then a mode for each, then keep it or don't.
148
+ *
149
+ * Saving is offered rather than required. Someone trying a one-off shape for
150
+ * one project should not have to name it, and someone who has found the shape
151
+ * they always want should not have to rebuild it every time.
152
+ */
153
+ export async function buildWorkflow(
154
+ prompt: Prompter,
155
+ env?: NodeJS.ProcessEnv,
156
+ ): Promise<Workflow | undefined> {
157
+ // -- which phases run ---------------------------------------------------
158
+ const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
147
159
  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})`, [
160
+ const rows = SELECTABLE_PHASES.map(
161
+ (p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p} — ${PHASE_PURPOSE[p]}`,
162
+ );
163
+ const hit = await prompt.select(
164
+ `Which phases should run? (${chosen.size ? [...SELECTABLE_PHASES].filter((p) => chosen.has(p)).join(" ") : "none yet"})`,
165
+ [...rows, DONE],
166
+ );
167
+ if (hit === undefined) return undefined;
168
+ if (hit === DONE) break;
169
+ const key = SELECTABLE_PHASES[rows.indexOf(hit)];
170
+ if (!key) break;
171
+ if (chosen.has(key)) chosen.delete(key);
172
+ else chosen.add(key);
173
+ }
174
+
175
+ const phases = normalizePhases([...chosen]);
176
+
177
+ // -- a mode for each ----------------------------------------------------
178
+ //
179
+ // Asked one phase at a time rather than as a checklist, because "does this
180
+ // one stop for me" is a different question for RESEARCH than for SHIP and
181
+ // the help text is the useful part.
182
+ const modes: PhaseModes = {};
183
+ const modeLabels = PHASE_MODE_OPTIONS.map((o) => line(o.label, o.help));
184
+ for (const phase of phases) {
185
+ const answer = await prompt.select(
186
+ `${phase.toUpperCase()} — ${PHASE_PURPOSE[phase]}`,
187
+ modeLabels,
188
+ );
189
+ if (answer === undefined) return undefined;
190
+ modes[phase] = (PHASE_MODE_OPTIONS[modeLabels.indexOf(answer)]?.value ?? "autopilot") as PhaseMode;
191
+ }
192
+
193
+ const draft = customWorkflow(phases, modes);
194
+ prompt.notify(renderWorkflow(draft), "info");
195
+
196
+ // -- keep it? -----------------------------------------------------------
197
+ const KEEP = "save it under a name I can reuse";
198
+ const ONCE = "just use it here";
199
+ const keep = await prompt.select("Keep this workflow?", [KEEP, ONCE]);
200
+ if (keep === undefined) return undefined;
201
+ if (keep !== KEEP) return draft;
202
+
203
+ for (;;) {
204
+ const name = await prompt.input("Call it what?", "e.g. spec-heavy, weekend-run, client-work");
205
+ if (name === undefined || !name.trim()) return draft;
206
+ const saved = saveWorkflow(
207
+ { name: name.trim(), phases, modes },
208
+ env,
209
+ );
210
+ if (saved.ok && saved.workflow) {
211
+ prompt.notify(`Saved "${saved.workflow.name}". It will be offered on every project.`, "info");
212
+ return saved.workflow;
213
+ }
214
+ prompt.notify(saved.error ?? "Could not save that.", "warning");
215
+ }
216
+ }
217
+
218
+ // ── choosing a display template ─────────────────────────────────────────────
219
+
220
+ const CUSTOMISE = "choose level by level";
221
+
222
+ export async function pickDisplay(
223
+ prompt: Prompter,
224
+ env?: NodeJS.ProcessEnv,
225
+ ): Promise<DisplayPolicy | undefined> {
226
+ const available = listDisplays(env);
227
+ const rows = available.map((d) => line(d.builtIn ? d.name : `${d.name} (yours)`, d.description));
228
+ const picked = await prompt.select(DISPLAY_QUESTION.title, [...rows, CUSTOMISE]);
229
+ if (picked === undefined) return undefined;
230
+ if (picked !== CUSTOMISE) return available[rows.indexOf(picked)]?.policy ?? defaultDisplay();
231
+ return buildDisplay(prompt, defaultDisplay(), env);
232
+ }
233
+
234
+ const SUBTASK_CYCLE: DisplayPolicy["levels"]["subtask"][] = ["none", "active", "all"];
235
+
236
+ /**
237
+ * The level-by-level editor, shared by the wizard and `/infinity:display`.
238
+ *
239
+ * Every row toggles; subtasks cycle through none → active → all, because
240
+ * "only on the task being worked" is the answer most people want and a plain
241
+ * on/off cannot express it.
242
+ */
243
+ export async function buildDisplay(
244
+ prompt: Prompter,
245
+ starting: DisplayPolicy,
246
+ env?: NodeJS.ProcessEnv,
247
+ ): Promise<DisplayPolicy | undefined> {
248
+ let policy = normalizeDisplay(starting);
249
+
250
+ for (;;) {
251
+ const rows = [
252
+ `${policy.levels.goal ? "[x]" : "[ ]"} goals`,
253
+ `${policy.levels.sprint ? "[x]" : "[ ]"} sprints`,
254
+ `${policy.levels.feature ? "[x]" : "[ ]"} features`,
255
+ `${policy.levels.task ? "[x]" : "[ ]"} tasks`,
256
+ `[${policy.levels.subtask}] subtasks — none · active (only the task being worked) · all`,
257
+ `${policy.counts ? "[x]" : "[ ]"} done/total counts on goals, sprints and features`,
258
+ `${policy.dependencies ? "[x]" : "[ ]"} dependency labels (← #3)`,
259
+ `${policy.criteria ? "[x]" : "[ ]"} acceptance criteria (dashboard only)`,
260
+ `${policy.rail ? "[x]" : "[ ]"} the phase rail`,
261
+ `${policy.progress ? "[x]" : "[ ]"} the progress meter`,
262
+ `${policy.alerts ? "[x]" : "[ ]"} the alert strip (blocked, rework, retries, approvals)`,
263
+ `[${policy.taskWindow}] rows of plan in the terminal before it scrolls`,
264
+ ];
265
+ const SAVE = "save this as a template I can reuse";
266
+ const hit = await prompt.select("What should the widget and the dashboard show?", [
153
267
  ...rows,
154
- APPROVALS_DONE,
268
+ SAVE,
269
+ DONE,
155
270
  ]);
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
- }
271
+ if (hit === undefined) return undefined;
272
+ if (hit === DONE) return { ...policy, preset: "custom" };
163
273
 
164
- return {
165
- research: chosen.has("research"),
166
- define: chosen.has("define"),
167
- plan: chosen.has("plan"),
168
- };
274
+ if (hit === SAVE) {
275
+ const name = await prompt.input("Call it what?", "e.g. sprint-view, my-focus");
276
+ if (name && name.trim()) {
277
+ const saved = saveDisplay({ name: name.trim(), policy }, env);
278
+ if (saved.ok && saved.template) {
279
+ prompt.notify(`Saved "${saved.template.name}".`, "info");
280
+ return saved.template.policy;
281
+ }
282
+ prompt.notify(saved.error ?? "Could not save that.", "warning");
283
+ }
284
+ continue;
285
+ }
286
+
287
+ const idx = rows.indexOf(hit);
288
+ switch (idx) {
289
+ case 0:
290
+ policy = { ...policy, levels: { ...policy.levels, goal: !policy.levels.goal } };
291
+ break;
292
+ case 1:
293
+ policy = { ...policy, levels: { ...policy.levels, sprint: !policy.levels.sprint } };
294
+ break;
295
+ case 2:
296
+ policy = { ...policy, levels: { ...policy.levels, feature: !policy.levels.feature } };
297
+ break;
298
+ case 3:
299
+ policy = { ...policy, levels: { ...policy.levels, task: !policy.levels.task } };
300
+ break;
301
+ case 4: {
302
+ const next = SUBTASK_CYCLE[(SUBTASK_CYCLE.indexOf(policy.levels.subtask) + 1) % SUBTASK_CYCLE.length]!;
303
+ policy = { ...policy, levels: { ...policy.levels, subtask: next } };
304
+ break;
305
+ }
306
+ case 5:
307
+ policy = { ...policy, counts: !policy.counts };
308
+ break;
309
+ case 6:
310
+ policy = { ...policy, dependencies: !policy.dependencies };
311
+ break;
312
+ case 7:
313
+ policy = { ...policy, criteria: !policy.criteria };
314
+ break;
315
+ case 8:
316
+ policy = { ...policy, rail: !policy.rail };
317
+ break;
318
+ case 9:
319
+ policy = { ...policy, progress: !policy.progress };
320
+ break;
321
+ case 10:
322
+ policy = { ...policy, alerts: !policy.alerts };
323
+ break;
324
+ case 11: {
325
+ const answer = await prompt.input("How many rows?", String(policy.taskWindow));
326
+ const n = Number(String(answer ?? "").trim());
327
+ if (Number.isFinite(n)) policy = normalizeDisplay({ ...policy, taskWindow: n });
328
+ break;
329
+ }
330
+ default:
331
+ return { ...policy, preset: "custom" };
332
+ }
333
+ }
169
334
  }
170
335
 
171
336
  /**
172
337
  * The wizard's answers when nobody is there to give any.
173
338
  *
174
339
  * 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.
340
+ * a workflow the human did not pick. The safe default is autopilot with
341
+ * nothing approved — because an approval gate with no human to answer it would
342
+ * park forever — plus a loud warning that says exactly that.
178
343
  */
179
344
  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
- });
345
+ const ordered = normalizePhases(phases ?? [...DEFAULT_ENABLED_PHASES]);
346
+ const workflow: Workflow = {
347
+ id: "autopilot",
348
+ name: "autopilot",
349
+ description: "You approve nothing.",
350
+ builtIn: true,
351
+ phases: ordered,
352
+ modes: normalizeModes({}, ordered),
353
+ };
354
+ const plan = planIntake({ workflow, brief: brief ?? "", handoff: "phase" });
188
355
  return {
189
356
  ...plan,
190
357
  warnings: [