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/CHANGELOG.md +134 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +770 -51
- package/harness/docs/agents/researcher.md +14 -0
- package/harness/docs/phases/research.md +64 -0
- package/harness/skills/grilling.md +1 -1
- package/harness/skills/research.md +1 -1
- package/package.json +1 -1
- package/src/approval.ts +194 -0
- package/src/core/brief.ts +51 -2
- package/src/core/config.ts +32 -13
- package/src/core/fsx.ts +10 -0
- package/src/core/gates.ts +15 -0
- package/src/core/init.ts +43 -2
- package/src/core/paths.ts +14 -0
- package/src/core/settings.ts +56 -0
- package/src/core/types.ts +65 -2
- package/src/handoff.ts +197 -0
- package/src/intake.ts +263 -0
- package/src/loop.ts +88 -10
- package/src/remote.ts +22 -1
- package/src/runState.ts +121 -0
- package/src/ui/dashboard.ts +140 -16
- package/src/ui/planTree.ts +287 -0
- package/src/ui/theme.ts +15 -0
- package/src/ui/widget.ts +230 -67
- package/src/ui/wizard.ts +195 -0
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
229
|
+
deps: string[] | undefined,
|
|
175
230
|
indexByKey: Map<string, number>,
|
|
176
231
|
g: GlyphSet,
|
|
177
232
|
s: Styler,
|
|
178
233
|
): string {
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
// --
|
|
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
|
|
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
|
|
293
|
-
const
|
|
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 =
|
|
492
|
+
const hiddenAfter = rows.length - bounds.end;
|
|
296
493
|
|
|
297
494
|
if (hiddenBefore > 0) {
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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 + "
|
|
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
|
package/src/ui/wizard.ts
ADDED
|
@@ -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
|
+
}
|