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/CHANGELOG.md +94 -0
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +338 -61
- package/package.json +1 -1
- package/src/approval.ts +15 -5
- package/src/core/config.ts +45 -2
- package/src/core/init.ts +42 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +191 -15
- package/src/core/types.ts +58 -5
- package/src/handoff.ts +88 -15
- package/src/intake.ts +164 -123
- package/src/modelRouter.ts +55 -2
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +13 -0
- package/src/ui/dashboard.ts +110 -31
- package/src/ui/display.ts +267 -0
- package/src/ui/planTree.ts +38 -16
- package/src/ui/widget.ts +50 -18
- package/src/ui/wizard.ts +343 -91
- package/src/workflow.ts +309 -0
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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.
|
|
10
|
+
* The flow is short on purpose. Four questions, three of them one keypress:
|
|
11
11
|
*
|
|
12
|
-
* 1.
|
|
12
|
+
* 1. which workflow? — a built-in, one you saved, or "build one"
|
|
13
13
|
* 2. what are you building?
|
|
14
|
-
* 3.
|
|
15
|
-
* 4.
|
|
16
|
-
*
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
/**
|
|
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.
|
|
65
|
-
const
|
|
66
|
-
|
|
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
|
|
74
|
-
// skipping it is why autopilot used to invent a project and
|
|
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.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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
|
|
328
|
+
export async function buildDisplay(
|
|
139
329
|
prompt: Prompter,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 =
|
|
149
|
-
|
|
150
|
-
?
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
353
|
+
SAVE,
|
|
354
|
+
DONE,
|
|
155
355
|
]);
|
|
156
|
-
if (
|
|
157
|
-
if (
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
176
|
-
* approved — because
|
|
177
|
-
*
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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: [
|