infinity-harness 2.4.0 → 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 +30 -0
- package/extensions/infinity-harness/index.ts +144 -23
- package/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/core/init.ts +20 -0
- package/src/core/settings.ts +50 -3
- package/src/core/types.ts +9 -4
- package/src/handoff.ts +88 -15
- package/src/intake.ts +40 -8
- package/src/modelRouter.ts +55 -2
- package/src/ui/config.ts +7 -0
- package/src/ui/dashboard.ts +22 -6
- package/src/ui/wizard.ts +88 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [2.5.0] — 2026-08-25
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Wizard picks models and thinking per tier + consulting master.** `/infinity:init` now asks which
|
|
12
|
+
models and thinking levels to use for easy/moderate/difficult tiers and for the consulting master
|
|
13
|
+
(with `off/minimal/low/medium/high/xhigh/max` plus `inherit`). Persisted in
|
|
14
|
+
`harness/model-router.json` via `thinkingByDifficulty`/`thinkingMaster`/`thinkingDefault`; exposed
|
|
15
|
+
via `/infinity:config` → Models.
|
|
16
|
+
|
|
17
|
+
- **Customizable handoff granularity.** `goal → phase → sprint → feature → task → subtask`.
|
|
18
|
+
Wizard and `/infinity:config` both offer `goal` (single-session alias for `off`), `phase`
|
|
19
|
+
(old default), `sprint`, `feature`, **default `task`**, `subtask`, and `off`. A finer choice
|
|
20
|
+
implies coarser boundaries (picking `task` also hands off on feature/sprint/phase). Fixed:
|
|
21
|
+
`task`-scoped handoff previously never fired because only `phase` was compared.
|
|
22
|
+
|
|
23
|
+
- **Dashboard blinks the active branch.** Phase dot `pulse`, current feature/sprint/goal cards and the
|
|
24
|
+
active task row now pulse while being developed; `prefers-reduced-motion` disables them.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Research autopilot stalled after pass.** `infinity_validate` now auto-advances on PASS when
|
|
29
|
+
the phase's mode is `autopilot` (mirrors `decideNext`), so `research → define` no longer requires
|
|
30
|
+
manually typing `continue`.
|
|
31
|
+
- **`alt+j/k/o` never fired.** Editor shortcut only runs when the editor has focus; the TUI
|
|
32
|
+
selector/overlay swallows input. Added `KeyId` shortcuts plus a raw `onTerminalInput`
|
|
33
|
+
fallback (`\x1bj/k/o`) installed on `session_start`.
|
|
34
|
+
- **Handoff threshold `0.7 → 0.6`.** Long BUILD phases now hand off earlier, under the context
|
|
35
|
+
window before compaction.
|
|
36
|
+
|
|
7
37
|
## [2.4.0] — 2026-08-24
|
|
8
38
|
|
|
9
39
|
Two settings that were one switch each, and one switch turned out to be the wrong shape for both
|
|
@@ -297,15 +297,33 @@ export default function (pi: ExtensionAPI): void {
|
|
|
297
297
|
|
|
298
298
|
// -- session handoff ------------------------------------------------------
|
|
299
299
|
|
|
300
|
-
/** The task the pipeline is on right now, or null. */
|
|
301
|
-
const
|
|
300
|
+
/** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
|
|
301
|
+
const activePlanKeys = (dir: string): { task: string | null; feature: string | null; sprint: string | null; goal: string | null; subtask: string | null; } => {
|
|
302
302
|
try {
|
|
303
303
|
const { list } = loadFeatureList(dir);
|
|
304
|
-
|
|
304
|
+
const task = nextActionableTask(list);
|
|
305
|
+
const flat = task ? loadFeatureList(dir).list.features?.find((f) => f.id === task.featureId) ?? null : null;
|
|
306
|
+
// Resolve sprint/goal via list, and active subtask of the focused task.
|
|
307
|
+
const taskKey = task?.compositeKey ?? null;
|
|
308
|
+
const featureId = task?.featureId ?? null;
|
|
309
|
+
const feature = featureId ? (list.features ?? []).find((f) => f.id === featureId) ?? null : null;
|
|
310
|
+
const sprintId = feature?.sprintId ?? null;
|
|
311
|
+
const goalId = feature?.goalId ?? (sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId)?.goalId ?? null : null) ?? (list.goals?.[0]?.id ?? null);
|
|
312
|
+
const sprint = sprintId ? sprintId : null;
|
|
313
|
+
const goal = goalId ? goalId : null;
|
|
314
|
+
// First non-complete subtask of the active task.
|
|
315
|
+
let subtask: string | null = null;
|
|
316
|
+
const rawTask = feature && task ? feature.tasks.find((t) => t.id === task.id || t.key === task.key) ?? null : null;
|
|
317
|
+
if (rawTask?.subtasks?.length) {
|
|
318
|
+
const cur = rawTask.subtasks.find((s) => s.status !== "complete") ?? null;
|
|
319
|
+
if (cur) subtask = `${taskKey}#${cur.id ?? cur.title}`;
|
|
320
|
+
}
|
|
321
|
+
return { task: taskKey, feature: featureId, sprint, goal, subtask };
|
|
305
322
|
} catch {
|
|
306
|
-
return null;
|
|
323
|
+
return { task: null, feature: null, sprint: null, goal: null, subtask: null };
|
|
307
324
|
}
|
|
308
325
|
};
|
|
326
|
+
const activeTaskKey = (dir: string): string | null => activePlanKeys(dir).task;
|
|
309
327
|
|
|
310
328
|
/** How full this session's context is, 0..1, or null when pi cannot say. */
|
|
311
329
|
const contextRatio = (ctx: ExtensionContext): number | null => {
|
|
@@ -355,12 +373,41 @@ export default function (pi: ExtensionAPI): void {
|
|
|
355
373
|
}
|
|
356
374
|
try {
|
|
357
375
|
const { config } = loadConfig(dir);
|
|
376
|
+
const toKeys = activePlanKeys(dir);
|
|
377
|
+
// Map caller's fromTask (a compositeKey) back to its feature/sprint etc for the "from" side.
|
|
378
|
+
// We derive them from the plan so goal/sprint/feature boundaries are comparable.
|
|
379
|
+
let fromGoal: string | null = null;
|
|
380
|
+
let fromSprint: string | null = null;
|
|
381
|
+
let fromFeature: string | null = null;
|
|
382
|
+
try {
|
|
383
|
+
const { list } = loadFeatureList(dir);
|
|
384
|
+
if (fromTask) {
|
|
385
|
+
const ft = ((): { featureId: string } | null => {
|
|
386
|
+
for (const f of list.features ?? []) for (const t of f.tasks ?? []) if (t.key === fromTask || `${f.id}/${t.id}` === fromTask || t.id === fromTask) return { featureId: f.id };
|
|
387
|
+
return null;
|
|
388
|
+
})();
|
|
389
|
+
if (ft) {
|
|
390
|
+
fromFeature = ft.featureId;
|
|
391
|
+
const feat = list.features.find((f) => f.id === ft.featureId) ?? null;
|
|
392
|
+
fromSprint = feat?.sprintId ?? null;
|
|
393
|
+
fromGoal = feat?.goalId ?? (fromSprint ? (list.sprints ?? []).find((s) => s.id === fromSprint)?.goalId ?? null : null) ?? null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} catch {}
|
|
358
397
|
const decision = shouldHandoff({
|
|
359
398
|
config,
|
|
360
399
|
fromPhase,
|
|
361
400
|
toPhase,
|
|
362
401
|
fromTask,
|
|
363
|
-
toTask:
|
|
402
|
+
toTask: toKeys.task,
|
|
403
|
+
fromGoal,
|
|
404
|
+
toGoal: toKeys.goal,
|
|
405
|
+
fromSprint,
|
|
406
|
+
toSprint: toKeys.sprint,
|
|
407
|
+
fromFeature,
|
|
408
|
+
toFeature: toKeys.feature,
|
|
409
|
+
fromSubtask: null, // subtask delta is derived from task payload; tracked via fromTask composite + activePlanKeys
|
|
410
|
+
toSubtask: toKeys.subtask,
|
|
364
411
|
contextRatio: contextRatio(ctx),
|
|
365
412
|
});
|
|
366
413
|
if (!decision.handoff) return false;
|
|
@@ -496,6 +543,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
496
543
|
|
|
497
544
|
view = defaultView();
|
|
498
545
|
refreshWidget(ctx);
|
|
546
|
+
installTerminalShortcuts(ctx);
|
|
499
547
|
const { config } = loadConfig(dir);
|
|
500
548
|
lastBriefPhase = config.currentPhase;
|
|
501
549
|
|
|
@@ -1042,6 +1090,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1042
1090
|
const lines = gate.checks
|
|
1043
1091
|
.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
|
|
1044
1092
|
.join("\n");
|
|
1093
|
+
// On a passing gate in autopilot, the tool itself advances the phase
|
|
1094
|
+
// so a run without the continuous loop armed still moves forward when
|
|
1095
|
+
// the agent calls infinity_validate — that's what the brief says will
|
|
1096
|
+
// happen ("PASS → the harness advances") and what stopped research
|
|
1097
|
+
// from ever reaching DEFINE until someone typed "continue".
|
|
1098
|
+
if (gate.overall && !params?.feature && !params?.task) {
|
|
1099
|
+
try {
|
|
1100
|
+
const { needsApproval } = await import("../../src/approval.ts");
|
|
1101
|
+
const fresh = loadConfig(dir).config;
|
|
1102
|
+
if (!needsApproval(fresh, fresh.currentPhase)) {
|
|
1103
|
+
const { advancePhase } = await import("../../src/core/phases.ts");
|
|
1104
|
+
const moved = await advancePhase(dir);
|
|
1105
|
+
if (moved.ok && moved.to) {
|
|
1106
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1107
|
+
const brief = await briefText(dir);
|
|
1108
|
+
return {
|
|
1109
|
+
content: [
|
|
1110
|
+
{
|
|
1111
|
+
type: "text",
|
|
1112
|
+
text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
|
|
1113
|
+
},
|
|
1114
|
+
],
|
|
1115
|
+
details: { ...gate, advanced: moved } as unknown as typeof gate,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} catch {}
|
|
1120
|
+
}
|
|
1045
1121
|
return {
|
|
1046
1122
|
content: [
|
|
1047
1123
|
{
|
|
@@ -1313,7 +1389,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1313
1389
|
}
|
|
1314
1390
|
|
|
1315
1391
|
const wizard = ctx.hasUI
|
|
1316
|
-
? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null })
|
|
1392
|
+
? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null, models: () => availableModels(ctx) })
|
|
1317
1393
|
: ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
|
|
1318
1394
|
|
|
1319
1395
|
if (wizard.cancelled) {
|
|
@@ -1331,6 +1407,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1331
1407
|
display: plan.display,
|
|
1332
1408
|
session: plan.session,
|
|
1333
1409
|
brief: plan.brief,
|
|
1410
|
+
router: plan.router
|
|
1411
|
+
? ({
|
|
1412
|
+
enabled: !!plan.router.enabled,
|
|
1413
|
+
byDifficulty: plan.router.byDifficulty as unknown as Record<string, string>,
|
|
1414
|
+
thinkingByDifficulty: plan.router.thinkingByDifficulty as unknown as Record<string, string>,
|
|
1415
|
+
master: plan.router.master ?? "",
|
|
1416
|
+
thinkingMaster: plan.router.thinkingMaster as unknown as string,
|
|
1417
|
+
default: plan.router.default ?? "",
|
|
1418
|
+
thinkingDefault: plan.router.thinkingDefault as unknown as string,
|
|
1419
|
+
} as Partial<import("../../src/modelRouter.ts").RouterConfig>)
|
|
1420
|
+
: undefined,
|
|
1334
1421
|
force,
|
|
1335
1422
|
});
|
|
1336
1423
|
if (!result.ok) {
|
|
@@ -2352,24 +2439,58 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2352
2439
|
// `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
|
|
2353
2440
|
// to line end) and ctrl+o (expand tool output). Shadowing an editor key to
|
|
2354
2441
|
// scroll a widget would be a worse bug than the one being fixed.
|
|
2442
|
+
//
|
|
2443
|
+
// Shortcuts are editor-focused via registerShortcut, but also handled as a
|
|
2444
|
+
// raw terminal fallback so they work when an overlay or selector has focus
|
|
2445
|
+
// or when the terminal sends the legacy ESC+j sequence that the editor
|
|
2446
|
+
// otherwise swallows as text.
|
|
2447
|
+
|
|
2448
|
+
const scrollDown = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, SCROLL_STEP);
|
|
2449
|
+
const scrollUp = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, -SCROLL_STEP);
|
|
2450
|
+
const toggleExpand = async (ctx: ExtensionContext): Promise<void> => {
|
|
2451
|
+
view = { ...view, expanded: !view.expanded };
|
|
2452
|
+
refreshWidget(ctx);
|
|
2453
|
+
};
|
|
2355
2454
|
|
|
2356
|
-
pi.registerShortcut("alt+j", {
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2455
|
+
pi.registerShortcut("alt+j", { description: "infinity-harness: scroll the plan down", handler: scrollDown });
|
|
2456
|
+
pi.registerShortcut("alt+k", { description: "infinity-harness: scroll the plan up", handler: scrollUp });
|
|
2457
|
+
pi.registerShortcut("alt+o", { description: "infinity-harness: expand or collapse the plan widget", handler: toggleExpand });
|
|
2458
|
+
// Uppercase handling covered by the raw terminal fallback below which
|
|
2459
|
+
// lowercases data before matching; KeyId type only allows lowercase.
|
|
2460
|
+
|
|
2461
|
+
// Fallback raw input handler — runs even when the editor is not the
|
|
2462
|
+
// focused component (e.g. a selector is open). Must be installed per-
|
|
2463
|
+
// session because onTerminalInput is a UI session thing, not a global.
|
|
2464
|
+
let removeTerminalShortcut: (() => void) | null = null;
|
|
2465
|
+
const installTerminalShortcuts = (ctx: ExtensionContext): void => {
|
|
2466
|
+
try {
|
|
2467
|
+
removeTerminalShortcut?.();
|
|
2468
|
+
} catch {}
|
|
2469
|
+
try {
|
|
2470
|
+
// matchesKey lives in pi-tui but re-exported by pi; use the extension
|
|
2471
|
+
// input raw matcher via string compare for ESC-prefixed alt.
|
|
2472
|
+
removeTerminalShortcut = ctx.ui.onTerminalInput((data: string) => {
|
|
2473
|
+
// Legacy alt+letter is ESC + lower letter. Kitty may send CSI-u; both
|
|
2474
|
+
// are handled by normalising to lookahead then matching via the same
|
|
2475
|
+
// strings registerShortcut uses.
|
|
2476
|
+
const lower = data.toLowerCase();
|
|
2477
|
+
// Fast path: alt+j/k/o as ESC + letter (\x1bj) or higher-plane.
|
|
2478
|
+
if (data === "\x1bj" || data === "\x1bJ" || lower === "\x1bj") {
|
|
2479
|
+
void scrollDown(ctx);
|
|
2480
|
+
return { consume: true };
|
|
2481
|
+
}
|
|
2482
|
+
if (data === "\x1bk" || data === "\x1bK" || lower === "\x1bk") {
|
|
2483
|
+
void scrollUp(ctx);
|
|
2484
|
+
return { consume: true };
|
|
2485
|
+
}
|
|
2486
|
+
if (data === "\x1bo" || data === "\x1bO" || lower === "\x1bo") {
|
|
2487
|
+
void toggleExpand(ctx);
|
|
2488
|
+
return { consume: true };
|
|
2489
|
+
}
|
|
2490
|
+
return undefined;
|
|
2491
|
+
});
|
|
2492
|
+
} catch {}
|
|
2493
|
+
};
|
|
2373
2494
|
|
|
2374
2495
|
pi.registerCommand("infinity:scroll", {
|
|
2375
2496
|
description: "Move the plan widget — up, down, top, bottom, expand, follow",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/src/core/config.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function defaultConfig(): HarnessConfig {
|
|
|
50
50
|
},
|
|
51
51
|
phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
|
|
52
52
|
roles: { strict: false },
|
|
53
|
-
session: { handoff: "
|
|
53
|
+
session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
|
|
54
54
|
approvals: { research: false, define: false, plan: false },
|
|
55
55
|
phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
|
|
56
56
|
workflow: { id: "autopilot", name: "autopilot" },
|
package/src/core/init.ts
CHANGED
|
@@ -162,6 +162,9 @@ export type InitOptions = {
|
|
|
162
162
|
session?: Partial<HarnessConfig["session"]>;
|
|
163
163
|
/** What the human said they want built. Recorded, and read by the first brief. */
|
|
164
164
|
brief?: string | null;
|
|
165
|
+
/** Model routing for difficulty tiers and consulting. */
|
|
166
|
+
router?: Partial<import("../../src/modelRouter.ts").RouterConfig>;
|
|
167
|
+
|
|
165
168
|
};
|
|
166
169
|
|
|
167
170
|
export type InitResult = {
|
|
@@ -238,6 +241,23 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
238
241
|
at: new Date().toISOString(),
|
|
239
242
|
};
|
|
240
243
|
}
|
|
244
|
+
if (options.router) {
|
|
245
|
+
try {
|
|
246
|
+
const routerPath = P.modelRouterPath(targetDir);
|
|
247
|
+
mkdirSync(dirname(routerPath), { recursive: true });
|
|
248
|
+
let existing: Record<string, unknown> = {};
|
|
249
|
+
try { if (existsSync(routerPath)) existing = JSON.parse(readFileSync(routerPath, "utf-8")); } catch { /* ignore corrupt */ }
|
|
250
|
+
const incoming = options.router as Record<string, unknown>;
|
|
251
|
+
const merged: Record<string, unknown> = { ...existing, ...incoming };
|
|
252
|
+
if ((incoming as { byDifficulty?: unknown }).byDifficulty && typeof (incoming as { byDifficulty?: unknown }).byDifficulty === "object") {
|
|
253
|
+
merged.byDifficulty = { ...((existing.byDifficulty as Record<string,string>) ?? {}), ...(incoming.byDifficulty as Record<string,string>) };
|
|
254
|
+
}
|
|
255
|
+
if ((incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty && typeof (incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty === "object") {
|
|
256
|
+
merged.thinkingByDifficulty = { ...((existing.thinkingByDifficulty as Record<string,string>) ?? {}), ...(incoming.thinkingByDifficulty as Record<string,string>) };
|
|
257
|
+
}
|
|
258
|
+
writeFileSync(routerPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
259
|
+
} catch { /* best-effort */ }
|
|
260
|
+
}
|
|
241
261
|
|
|
242
262
|
const write = (path: string, body: string) => {
|
|
243
263
|
const rel = path.slice(targetDir.length + 1);
|
package/src/core/settings.ts
CHANGED
|
@@ -28,7 +28,8 @@ export type SettingType =
|
|
|
28
28
|
| { kind: "choice"; choices: readonly string[] }
|
|
29
29
|
| { kind: "multi"; choices: readonly string[] }
|
|
30
30
|
/** Resolved at runtime from the models pi has configured. */
|
|
31
|
-
| { kind: "model" }
|
|
31
|
+
| { kind: "model" }
|
|
32
|
+
| { kind: "thinking" };
|
|
32
33
|
|
|
33
34
|
export type Setting = {
|
|
34
35
|
/** Dotted path within the file. */
|
|
@@ -75,6 +76,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
75
76
|
help: DIFFICULTY_HELP,
|
|
76
77
|
type: { kind: "model" },
|
|
77
78
|
},
|
|
79
|
+
{
|
|
80
|
+
path: "thinkingByDifficulty.easy",
|
|
81
|
+
file: "router",
|
|
82
|
+
label: "Easy thinking",
|
|
83
|
+
help: "Thinking level for easy tasks. Empty inherits pi's current level.",
|
|
84
|
+
type: { kind: "thinking" },
|
|
85
|
+
},
|
|
78
86
|
{
|
|
79
87
|
path: "byDifficulty.moderate",
|
|
80
88
|
file: "router",
|
|
@@ -82,6 +90,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
82
90
|
help: DIFFICULTY_HELP,
|
|
83
91
|
type: { kind: "model" },
|
|
84
92
|
},
|
|
93
|
+
{
|
|
94
|
+
path: "thinkingByDifficulty.moderate",
|
|
95
|
+
file: "router",
|
|
96
|
+
label: "Moderate thinking",
|
|
97
|
+
help: "Thinking level for moderate tasks. Empty inherits pi's current level.",
|
|
98
|
+
type: { kind: "thinking" },
|
|
99
|
+
},
|
|
85
100
|
{
|
|
86
101
|
path: "byDifficulty.difficult",
|
|
87
102
|
file: "router",
|
|
@@ -89,6 +104,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
89
104
|
help: DIFFICULTY_HELP,
|
|
90
105
|
type: { kind: "model" },
|
|
91
106
|
},
|
|
107
|
+
{
|
|
108
|
+
path: "thinkingByDifficulty.difficult",
|
|
109
|
+
file: "router",
|
|
110
|
+
label: "Difficult thinking",
|
|
111
|
+
help: "Thinking level for difficult tasks. Empty inherits pi's current level.",
|
|
112
|
+
type: { kind: "thinking" },
|
|
113
|
+
},
|
|
92
114
|
{
|
|
93
115
|
path: "master",
|
|
94
116
|
file: "router",
|
|
@@ -96,6 +118,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
96
118
|
help: "Never assigned to a task directly — reached only when the ladder is exhausted and the harness asks for one opinion.",
|
|
97
119
|
type: { kind: "model" },
|
|
98
120
|
},
|
|
121
|
+
{
|
|
122
|
+
path: "thinkingMaster",
|
|
123
|
+
file: "router",
|
|
124
|
+
label: "Master thinking",
|
|
125
|
+
help: "Thinking level for the master consultation model.",
|
|
126
|
+
type: { kind: "thinking" },
|
|
127
|
+
},
|
|
99
128
|
{
|
|
100
129
|
path: "default",
|
|
101
130
|
file: "router",
|
|
@@ -103,6 +132,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
103
132
|
help: "Used when nothing more specific matches. Empty means pi's current model.",
|
|
104
133
|
type: { kind: "model" },
|
|
105
134
|
},
|
|
135
|
+
{
|
|
136
|
+
path: "thinkingDefault",
|
|
137
|
+
file: "router",
|
|
138
|
+
label: "Default thinking",
|
|
139
|
+
help: "Fallback thinking level when no tier-specific level is set.",
|
|
140
|
+
type: { kind: "thinking" },
|
|
141
|
+
},
|
|
106
142
|
{
|
|
107
143
|
path: "consultation.enabled",
|
|
108
144
|
file: "router",
|
|
@@ -317,8 +353,8 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
317
353
|
path: "session.handoff",
|
|
318
354
|
file: "config",
|
|
319
355
|
label: "Fresh session",
|
|
320
|
-
help: "phase:
|
|
321
|
-
type: { kind: "choice", choices: ["off", "phase", "task"] },
|
|
356
|
+
help: "goal: one session · phase: per phase (old) · sprint/feature: when plan grouping changes · task: every task (default) · subtask: every subtask. Coarser levels still fire.",
|
|
357
|
+
type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
|
|
322
358
|
},
|
|
323
359
|
{
|
|
324
360
|
path: "session.contextThreshold",
|
|
@@ -538,6 +574,8 @@ export function formatValue(setting: Setting, value: unknown): string {
|
|
|
538
574
|
return value ? "on" : "off";
|
|
539
575
|
case "model":
|
|
540
576
|
return typeof value === "string" && value.trim() ? value : "(pi's current model)";
|
|
577
|
+
case "thinking":
|
|
578
|
+
return typeof value === "string" && value.trim() ? value : "(inherit)";
|
|
541
579
|
case "text":
|
|
542
580
|
return typeof value === "string" && value.trim() ? value : "(not set)";
|
|
543
581
|
case "multi":
|
|
@@ -583,6 +621,8 @@ export function parseDuration(input: string): number | null {
|
|
|
583
621
|
|
|
584
622
|
export type ValidationResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
585
623
|
|
|
624
|
+
export const THINKING_CHOICES = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
625
|
+
|
|
586
626
|
/** Coerce and bounds-check a raw answer for `setting`. */
|
|
587
627
|
export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
588
628
|
const t = setting.type;
|
|
@@ -596,6 +636,13 @@ export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
|
596
636
|
if (t.max !== undefined && n > t.max) return { ok: false, error: `must be at most ${t.max}` };
|
|
597
637
|
return { ok: true, value: n };
|
|
598
638
|
}
|
|
639
|
+
case "thinking": {
|
|
640
|
+
const v = raw.trim();
|
|
641
|
+
if (!v || v === "(inherit)") return { ok: true, value: "" };
|
|
642
|
+
const allowed = new Set(THINKING_CHOICES.slice(1) as readonly string[]);
|
|
643
|
+
if (!allowed.has(v)) return { ok: false, error: `must be one of: ${THINKING_CHOICES.join(", ")}` };
|
|
644
|
+
return { ok: true, value: v };
|
|
645
|
+
}
|
|
599
646
|
case "text":
|
|
600
647
|
case "model": {
|
|
601
648
|
const v = raw.trim();
|
package/src/core/types.ts
CHANGED
|
@@ -158,14 +158,19 @@ export type RetryBucket = {
|
|
|
158
158
|
* session boundary costs nothing but the brief, and the brief is what the
|
|
159
159
|
* agent should be working from anyway.
|
|
160
160
|
*/
|
|
161
|
+
export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
|
|
162
|
+
|
|
161
163
|
export type SessionPolicy = {
|
|
162
164
|
/**
|
|
163
165
|
* When to hand off to a fresh session.
|
|
164
|
-
* off
|
|
165
|
-
* phase
|
|
166
|
-
*
|
|
166
|
+
* off/goal never — one session for the whole run (the old behaviour)
|
|
167
|
+
* phase when the pipeline advances a phase
|
|
168
|
+
* sprint when the active sprint changes (or phase)
|
|
169
|
+
* feature when the active feature changes (or coarser)
|
|
170
|
+
* task when the active task changes (or coarser) — default
|
|
171
|
+
* subtask when the active subtask changes (or coarser)
|
|
167
172
|
*/
|
|
168
|
-
handoff:
|
|
173
|
+
handoff: HandoffGranularity;
|
|
169
174
|
/**
|
|
170
175
|
* Hand off early once the context is this full, as a fraction of the
|
|
171
176
|
* window. 0 disables it. This is what keeps a long BUILD phase — which may
|
package/src/handoff.ts
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
* that, only from a command handler, and the adapter is where pi lives.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { HarnessConfig, Phase, SessionPolicy } from "./core/types.ts";
|
|
26
|
+
import type { HarnessConfig, HandoffGranularity, Phase, SessionPolicy } from "./core/types.ts";
|
|
27
27
|
import { pendingSessionPath } from "./core/paths.ts";
|
|
28
28
|
import { readJsonSafe, writeJsonAtomic, removeFile, fileExists } from "./core/fsx.ts";
|
|
29
29
|
|
|
30
30
|
/** Why a session is being replaced. Shown to the human and to the next agent. */
|
|
31
|
-
export type HandoffReason =
|
|
31
|
+
export type HandoffReason = HandoffGranularity | "context" | "goal-pass" | "manual";
|
|
32
32
|
|
|
33
33
|
export type PendingHandoff = {
|
|
34
34
|
reason: HandoffReason;
|
|
@@ -51,22 +51,40 @@ export type HandoffSignals = {
|
|
|
51
51
|
/** Composite key of the task in focus before and after. */
|
|
52
52
|
fromTask: string | null;
|
|
53
53
|
toTask: string | null;
|
|
54
|
+
/** IDs for the coarser plan levels (goal/feature/sprint/subtask). Null means "no active one". */
|
|
55
|
+
fromGoal?: string | null;
|
|
56
|
+
toGoal?: string | null;
|
|
57
|
+
fromSprint?: string | null;
|
|
58
|
+
toSprint?: string | null;
|
|
59
|
+
fromFeature?: string | null;
|
|
60
|
+
toFeature?: string | null;
|
|
61
|
+
fromSubtask?: string | null;
|
|
62
|
+
toSubtask?: string | null;
|
|
54
63
|
/** Fraction of the context window in use, 0..1, or null when unknown. */
|
|
55
64
|
contextRatio: number | null;
|
|
56
65
|
};
|
|
57
66
|
|
|
58
67
|
export type HandoffDecision = { handoff: false } | { handoff: true; reason: HandoffReason; detail: string };
|
|
59
68
|
|
|
69
|
+
const GRANULARITIES: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
|
|
70
|
+
|
|
71
|
+
export function isHandoffGranularity(v: unknown): v is HandoffGranularity {
|
|
72
|
+
return typeof v === "string" && (GRANULARITIES as readonly string[]).includes(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
export function defaultSessionPolicy(): SessionPolicy {
|
|
61
|
-
return { handoff: "
|
|
76
|
+
return { handoff: "task", contextThreshold: 0.6, carryNotes: true };
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
function policyOf(config: HarnessConfig): SessionPolicy {
|
|
65
80
|
const p = (config.session ?? {}) as Partial<SessionPolicy>;
|
|
66
|
-
const handoff = p.handoff
|
|
67
|
-
|
|
81
|
+
const handoff: HandoffGranularity = isHandoffGranularity(p.handoff) ? p.handoff : "task";
|
|
82
|
+
// "goal" is an alias for the single-session behaviour; keep the storage
|
|
83
|
+
// as "goal" so the wizard round-trips, but treat it as "off" here.
|
|
84
|
+
const effective: HandoffGranularity = handoff === "goal" ? "off" : handoff;
|
|
85
|
+
const raw = typeof p.contextThreshold === "number" ? p.contextThreshold : 0.6;
|
|
68
86
|
return {
|
|
69
|
-
handoff,
|
|
87
|
+
handoff: effective,
|
|
70
88
|
// A threshold of 1 or more can never fire and a negative one always would;
|
|
71
89
|
// both are configuration mistakes, and clamping is kinder than either.
|
|
72
90
|
contextThreshold: raw <= 0 ? 0 : Math.min(0.95, raw),
|
|
@@ -81,6 +99,14 @@ function policyOf(config: HarnessConfig): SessionPolicy {
|
|
|
81
99
|
* arrives after compaction has already happened has arrived too late to be the
|
|
82
100
|
* thing that prevented it.
|
|
83
101
|
*/
|
|
102
|
+
/** Coarsest → finest. Handoff fires at the chosen level and everything coarser. */
|
|
103
|
+
const LEVEL_ORDER: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
|
|
104
|
+
|
|
105
|
+
function rank(g: HandoffGranularity): number {
|
|
106
|
+
const i = (LEVEL_ORDER as readonly string[]).indexOf(g);
|
|
107
|
+
return i < 0 ? 5 : i;
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
85
111
|
const policy = policyOf(signals.config);
|
|
86
112
|
if (policy.handoff === "off") return { handoff: false };
|
|
@@ -94,20 +120,62 @@ export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
|
94
120
|
};
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
|
|
123
|
+
const lvl = rank(policy.handoff);
|
|
124
|
+
|
|
125
|
+
// Hierarchy: off(0) < goal(1) < phase(2) < sprint(3) < feature(4) < task(5) < subtask(6).
|
|
126
|
+
// Finer granularity implies coarser boundaries too (task change implies feature/sprint/phase
|
|
127
|
+
// may have changed, but we check coarsest first so the reason reflects the highest level).
|
|
128
|
+
// Only boundaries at or coarser than the configured granularity? No —
|
|
129
|
+
// the knob is "how fine do you want to go". Choosing "task" means
|
|
130
|
+
// phase/feature/sprint/goal AND task boundaries fire; choosing "phase"
|
|
131
|
+
// means only phase (and coarser goal) fires. So a boundary fires iff
|
|
132
|
+
// its rank <= chosen rank. task (5) should not fire when handoff is phase (2). Hence <= lvl.
|
|
133
|
+
// Fine-grained choice: the wizard knob is the *coarsest* level that still
|
|
134
|
+
// gets a fresh session. Picking "task" means every task gets its own
|
|
135
|
+
// session (feature/sprint/phase do too, implicitly). So a boundary fires
|
|
136
|
+
// iff chosenRank >= boundaryRank.
|
|
137
|
+
// Phase always hands off (except off/goal) because phases are the harness
|
|
138
|
+
// backbone; a phase change must never ride the old session's context.
|
|
139
|
+
if (signals.toPhase && signals.fromPhase !== signals.toPhase && lvl >= 2) {
|
|
98
140
|
return {
|
|
99
141
|
handoff: true,
|
|
100
142
|
reason: "phase",
|
|
101
143
|
detail: `${(signals.fromPhase ?? "start").toUpperCase()} → ${signals.toPhase.toUpperCase()}`,
|
|
102
144
|
};
|
|
103
145
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
// "goal/off" never fires here — off early-returned, "goal" was mapped to off.
|
|
147
|
+
// Keep for completeness if rank comparison changes; guarded by lvl so it
|
|
148
|
+
// doesn't resurrect. retain dead code removed check.
|
|
149
|
+
void lvl;
|
|
150
|
+
if (signals.fromSprint !== undefined || signals.toSprint !== undefined) {
|
|
151
|
+
const sFrom = (signals.fromSprint ?? null)?.trim() || null;
|
|
152
|
+
const sTo = (signals.toSprint ?? null)?.trim() || null;
|
|
153
|
+
if (sFrom !== sTo && (sTo || sFrom) && lvl >= 3) {
|
|
154
|
+
return { handoff: true, reason: "sprint" as HandoffReason, detail: `${sFrom ?? "no sprint"} → ${sTo ?? "no sprint"}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (signals.fromFeature !== undefined || signals.toFeature !== undefined) {
|
|
158
|
+
const fFrom = (signals.fromFeature ?? null)?.trim() || null;
|
|
159
|
+
const fTo = (signals.toFeature ?? null)?.trim() || null;
|
|
160
|
+
if (fFrom !== fTo && (fTo || fFrom) && lvl >= 4) {
|
|
161
|
+
return { handoff: true, reason: "feature" as HandoffReason, detail: `${fFrom ?? "no feature"} → ${fTo ?? "no feature"}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (lvl >= 5) {
|
|
165
|
+
if ((signals.fromTask ?? null) !== (signals.toTask ?? null) && (signals.fromTask || signals.toTask)) {
|
|
166
|
+
return {
|
|
167
|
+
handoff: true,
|
|
168
|
+
reason: "task",
|
|
169
|
+
detail: `${signals.fromTask ?? "no task"} → ${signals.toTask ?? "no task"}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (lvl >= 6) {
|
|
174
|
+
const stFrom = (signals.fromSubtask ?? null)?.trim() || null;
|
|
175
|
+
const stTo = (signals.toSubtask ?? null)?.trim() || null;
|
|
176
|
+
if (stFrom !== stTo && (stTo || stFrom)) {
|
|
177
|
+
return { handoff: true, reason: "subtask" as HandoffReason, detail: `${stFrom ?? "no subtask"} → ${stTo ?? "no subtask"}` };
|
|
178
|
+
}
|
|
111
179
|
}
|
|
112
180
|
|
|
113
181
|
return { handoff: false };
|
|
@@ -168,10 +236,15 @@ export function composeKickoff(
|
|
|
168
236
|
detail: string,
|
|
169
237
|
carry: string | null,
|
|
170
238
|
): string {
|
|
171
|
-
const why: Record<
|
|
239
|
+
const why: Record<string, string> = {
|
|
172
240
|
phase: "The pipeline advanced, so the run continues in a clean session.",
|
|
173
241
|
task: "The run moved to a different task, so it continues in a clean session.",
|
|
242
|
+
sprint: "The active sprint changed, so the run continues in a clean session.",
|
|
243
|
+
feature: "The active feature changed, so the run continues in a clean session.",
|
|
244
|
+
goal: "The active goal changed, so the run continues in a clean session.",
|
|
245
|
+
subtask: "The active subtask changed, so the run continues in a clean session.",
|
|
174
246
|
context: "The previous session's context was filling up, so the run continues in a clean one.",
|
|
247
|
+
off: "Session handoff is off.",
|
|
175
248
|
"goal-pass": "A goal pass finished, so the next pass starts in a clean session.",
|
|
176
249
|
manual: "A human asked for a fresh session.",
|
|
177
250
|
};
|
package/src/intake.ts
CHANGED
|
@@ -53,6 +53,16 @@ export type IntakeAnswers = {
|
|
|
53
53
|
handoff?: SessionPolicy["handoff"];
|
|
54
54
|
/** What the surfaces should draw. Defaults to the `focus` template. */
|
|
55
55
|
display?: DisplayPolicy;
|
|
56
|
+
/** Model routing for difficulty tiers and consulting. */
|
|
57
|
+
router?: {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
byDifficulty: Record<string, string>;
|
|
60
|
+
thinkingByDifficulty?: Partial<Record<string, string>>;
|
|
61
|
+
master?: string;
|
|
62
|
+
thinkingMaster?: string;
|
|
63
|
+
default?: string;
|
|
64
|
+
thinkingDefault?: string;
|
|
65
|
+
};
|
|
56
66
|
};
|
|
57
67
|
|
|
58
68
|
export type IntakePlan = {
|
|
@@ -66,6 +76,7 @@ export type IntakePlan = {
|
|
|
66
76
|
approvals: ApprovalPolicy;
|
|
67
77
|
session: SessionPolicy;
|
|
68
78
|
display: DisplayPolicy;
|
|
79
|
+
router?: IntakeAnswers["router"];
|
|
69
80
|
/** What the human should be told about what they just chose. */
|
|
70
81
|
summary: string;
|
|
71
82
|
/** Things that will bite later if left as they are. */
|
|
@@ -100,10 +111,10 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
100
111
|
const phases = normalizePhases(workflow.phases);
|
|
101
112
|
const phaseModes = normalizeModes(workflow.modes, phases);
|
|
102
113
|
|
|
103
|
-
const handoff = answers.handoff ?? "
|
|
114
|
+
const handoff = answers.handoff ?? "task";
|
|
104
115
|
const session: SessionPolicy = {
|
|
105
116
|
handoff,
|
|
106
|
-
contextThreshold: handoff === "off" ? 0 : 0.
|
|
117
|
+
contextThreshold: handoff === "off" ? 0 : 0.6,
|
|
107
118
|
carryNotes: true,
|
|
108
119
|
};
|
|
109
120
|
|
|
@@ -152,6 +163,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
152
163
|
},
|
|
153
164
|
session,
|
|
154
165
|
display,
|
|
166
|
+
router: answers.router,
|
|
155
167
|
summary: summarize(workflow, phases, phaseModes, session, display, brief),
|
|
156
168
|
warnings,
|
|
157
169
|
};
|
|
@@ -216,20 +228,40 @@ export const HANDOFF_QUESTION: Question = {
|
|
|
216
228
|
id: "handoff",
|
|
217
229
|
title: "When should the run start a fresh session?",
|
|
218
230
|
options: [
|
|
231
|
+
{
|
|
232
|
+
value: "goal",
|
|
233
|
+
label: "per goal — one session for the whole run",
|
|
234
|
+
help: "The old single-session run. Every task accumulates context until compaction.",
|
|
235
|
+
},
|
|
219
236
|
{
|
|
220
237
|
value: "phase",
|
|
221
|
-
label: "every phase
|
|
222
|
-
help: "Each phase starts clean
|
|
238
|
+
label: "every phase",
|
|
239
|
+
help: "Old default. Each phase starts clean from the brief.",
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
value: "sprint",
|
|
243
|
+
label: "every sprint",
|
|
244
|
+
help: "New session whenever the active sprint changes (or phase).",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
value: "feature",
|
|
248
|
+
label: "every feature",
|
|
249
|
+
help: "New session on each feature boundary (and sprint/phase).",
|
|
223
250
|
},
|
|
224
251
|
{
|
|
225
252
|
value: "task",
|
|
226
|
-
label: "every task",
|
|
227
|
-
help: "
|
|
253
|
+
label: "every task (recommended)",
|
|
254
|
+
help: "Each task gets a clean session. Best isolation; one extra brief per task.",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
value: "subtask",
|
|
258
|
+
label: "every subtask",
|
|
259
|
+
help: "Finest grain — each subtask gets a fresh session. Most isolation, most churn.",
|
|
228
260
|
},
|
|
229
261
|
{
|
|
230
262
|
value: "off",
|
|
231
|
-
label: "never —
|
|
232
|
-
help: "
|
|
263
|
+
label: "never — alias for per goal",
|
|
264
|
+
help: "Same as per goal — one long session without fresh starts.",
|
|
233
265
|
},
|
|
234
266
|
],
|
|
235
267
|
};
|
package/src/modelRouter.ts
CHANGED
|
@@ -11,6 +11,14 @@ import { writeJsonAtomic, stripBom } from "./core/fsx.ts";
|
|
|
11
11
|
export const ROUTER_FILE = "harness/model-router.json";
|
|
12
12
|
export const ROUTER_VERSION = 1;
|
|
13
13
|
|
|
14
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
15
|
+
|
|
16
|
+
export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
|
|
18
|
+
export function isThinkingLevel(v: unknown): v is ThinkingLevel {
|
|
19
|
+
return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v);
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
export interface RouterConfig {
|
|
15
23
|
version: number;
|
|
16
24
|
enabled: boolean;
|
|
@@ -24,6 +32,10 @@ export interface RouterConfig {
|
|
|
24
32
|
byTask?: Record<string, string>;
|
|
25
33
|
consultation?: { enabled: boolean; maxPerTask: number; oneStepOnly: boolean; requireExhaustion: boolean };
|
|
26
34
|
budgets?: { maxReworksPerRun: number; maxReplansPerRun: number; maxReviewBounces: number };
|
|
35
|
+
/** Thinking level per tier, and for master/default. Empty means inherit pi's current level. */
|
|
36
|
+
thinkingByDifficulty?: Partial<Record<string, ThinkingLevel | "">>;
|
|
37
|
+
thinkingMaster?: ThinkingLevel | "";
|
|
38
|
+
thinkingDefault?: ThinkingLevel | "";
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
@@ -52,6 +64,9 @@ export const DEFAULT_ROUTER: RouterConfig = {
|
|
|
52
64
|
byTask: {},
|
|
53
65
|
consultation: { enabled: true, maxPerTask: 1, oneStepOnly: true, requireExhaustion: true },
|
|
54
66
|
budgets: { maxReworksPerRun: 3, maxReplansPerRun: 2, maxReviewBounces: 2 },
|
|
67
|
+
thinkingByDifficulty: { easy: "" as ThinkingLevel | "", moderate: "" as ThinkingLevel | "", difficult: "" as ThinkingLevel | "" } as Partial<Record<string, ThinkingLevel | "">>,
|
|
68
|
+
thinkingMaster: "" as ThinkingLevel | "",
|
|
69
|
+
thinkingDefault: "" as ThinkingLevel | "",
|
|
55
70
|
};
|
|
56
71
|
|
|
57
72
|
export const DIFFICULTY_LADDER: Array<"easy" | "moderate" | "difficult"> = ["easy", "moderate", "difficult"];
|
|
@@ -68,9 +83,13 @@ export function saveRouterConfig(projectDir: string, cfg: RouterConfig): void {
|
|
|
68
83
|
writeJsonAtomic(routerPath(projectDir), cfg);
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
function normalizeThinking(v: unknown): ThinkingLevel | "" {
|
|
87
|
+
return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v) ? (v as ThinkingLevel) : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
71
90
|
export function loadRouterConfig(projectDir?: string): RouterConfig {
|
|
72
91
|
const p = routerPath(projectDir);
|
|
73
|
-
if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! } };
|
|
92
|
+
if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! }, thinkingByDifficulty: { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) }, thinkingMaster: DEFAULT_ROUTER.thinkingMaster, thinkingDefault: DEFAULT_ROUTER.thinkingDefault };
|
|
74
93
|
try {
|
|
75
94
|
const raw = JSON.parse(stripBom(readFileSync(p, "utf-8")));
|
|
76
95
|
// merge with defaults to ensure fields
|
|
@@ -87,8 +106,18 @@ export function loadRouterConfig(projectDir?: string): RouterConfig {
|
|
|
87
106
|
byTask: raw.byTask ?? {},
|
|
88
107
|
consultation: raw.consultation ?? { ...DEFAULT_ROUTER.consultation! },
|
|
89
108
|
budgets: raw.budgets ?? { ...DEFAULT_ROUTER.budgets! },
|
|
109
|
+
thinkingByDifficulty: (() => {
|
|
110
|
+
const cur = raw.thinkingByDifficulty;
|
|
111
|
+
if (!cur || typeof cur !== "object") return { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) };
|
|
112
|
+
const out: Record<string, ThinkingLevel | ""> = {};
|
|
113
|
+
for (const k of DIFFICULTY_LADDER) out[k] = normalizeThinking((cur as Record<string, unknown>)[k]);
|
|
114
|
+
return out;
|
|
115
|
+
})(),
|
|
116
|
+
thinkingMaster: normalizeThinking(raw.thinkingMaster),
|
|
117
|
+
thinkingDefault: normalizeThinking(raw.thinkingDefault),
|
|
90
118
|
};
|
|
91
119
|
if (!cfg.byDifficulty) cfg.byDifficulty = { ...DEFAULT_ROUTER.byDifficulty! };
|
|
120
|
+
if (!cfg.thinkingByDifficulty) cfg.thinkingByDifficulty = { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) };
|
|
92
121
|
return cfg;
|
|
93
122
|
} catch {
|
|
94
123
|
return { ...DEFAULT_ROUTER };
|
|
@@ -170,14 +199,38 @@ export function consultNext(
|
|
|
170
199
|
return null;
|
|
171
200
|
}
|
|
172
201
|
|
|
202
|
+
export function resolveThinking(opts: ResolveOpts = {}): ThinkingLevel | "" {
|
|
203
|
+
const cfg = loadRouterConfig(opts.projectDir);
|
|
204
|
+
// Thinking is orthogonal to routing-enabled; when disabled, fall through to default/inherit.
|
|
205
|
+
const difficulty = opts.difficulty ?? opts.task?.difficulty ?? opts.feature?.difficulty ?? opts.sprint?.difficulty;
|
|
206
|
+
if (difficulty && cfg.thinkingByDifficulty && (cfg.thinkingByDifficulty as Record<string, ThinkingLevel | "">)[difficulty]) {
|
|
207
|
+
const v = (cfg.thinkingByDifficulty as Record<string, ThinkingLevel | "">)[difficulty];
|
|
208
|
+
if (v) return v;
|
|
209
|
+
}
|
|
210
|
+
// Master thinking only via consultation path; here just check difficulty default fallback
|
|
211
|
+
if (cfg.thinkingDefault) return cfg.thinkingDefault;
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function resolveThinkingForConsult(nextDifficulty: string | null, projectDir?: string): ThinkingLevel | "" {
|
|
216
|
+
const cfg = loadRouterConfig(projectDir);
|
|
217
|
+
if (!nextDifficulty) return cfg.thinkingMaster ?? "";
|
|
218
|
+
const v = cfg.thinkingByDifficulty?.[nextDifficulty as string] as ThinkingLevel | "" | undefined;
|
|
219
|
+
if (v) return v;
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
/** For widget/remote read-only exposure */
|
|
174
|
-
export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; master: string; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
|
|
224
|
+
export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; thinkingByDifficulty: Record<string, ThinkingLevel | "">; master: string; thinkingMaster: ThinkingLevel | ""; thinkingDefault: ThinkingLevel | ""; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
|
|
175
225
|
const cfg = loadRouterConfig(projectDir);
|
|
176
226
|
return {
|
|
177
227
|
enabled: cfg.enabled,
|
|
178
228
|
default: cfg.default,
|
|
179
229
|
byDifficulty: { ...(cfg.byDifficulty ?? {}) } as Record<string, string>,
|
|
230
|
+
thinkingByDifficulty: { ...(cfg.thinkingByDifficulty ?? {}) } as Record<string, ThinkingLevel | "">,
|
|
180
231
|
master: cfg.master ?? DEFAULT_ROUTER.master!,
|
|
232
|
+
thinkingMaster: (cfg.thinkingMaster ?? "") as ThinkingLevel | "",
|
|
233
|
+
thinkingDefault: (cfg.thinkingDefault ?? "") as ThinkingLevel | "",
|
|
181
234
|
budgets: cfg.budgets,
|
|
182
235
|
consultation: cfg.consultation,
|
|
183
236
|
};
|
package/src/ui/config.ts
CHANGED
|
@@ -166,6 +166,13 @@ async function editSetting(setting: Setting, options: ConfigMenuOptions): Promis
|
|
|
166
166
|
raw = picked;
|
|
167
167
|
break;
|
|
168
168
|
}
|
|
169
|
+
case "thinking": {
|
|
170
|
+
const choices = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
171
|
+
const picked = await prompt.select(`${setting.label} — ${setting.help}`, [...choices, BACK]);
|
|
172
|
+
if (picked === undefined || picked === BACK) return false;
|
|
173
|
+
raw = picked;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
169
176
|
case "model": {
|
|
170
177
|
raw = await pickModel(setting, options, typeof current === "string" ? current : "");
|
|
171
178
|
if (raw === undefined) return false;
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -549,10 +549,12 @@ function renderFeature(
|
|
|
549
549
|
sprintName: string | null,
|
|
550
550
|
goalName: string | null,
|
|
551
551
|
display: DisplayPolicy,
|
|
552
|
+
isCurrent = false,
|
|
552
553
|
): string {
|
|
553
554
|
const counts = countByStatus(tasks);
|
|
554
555
|
const total = tasks.length;
|
|
555
556
|
const complete = total > 0 && counts.complete === total;
|
|
557
|
+
const current = isCurrent && !complete;
|
|
556
558
|
|
|
557
559
|
const chips = [
|
|
558
560
|
sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
|
|
@@ -578,7 +580,7 @@ function renderFeature(
|
|
|
578
580
|
.join("")}</ul>`
|
|
579
581
|
: "";
|
|
580
582
|
|
|
581
|
-
return `<section class="card feature${complete ? " is-complete" : ""}">
|
|
583
|
+
return `<section class="card feature${complete ? " is-complete" : ""}${current ? " is-current" : ""}">
|
|
582
584
|
<div class="feature-head">
|
|
583
585
|
<div class="feature-id">
|
|
584
586
|
<h2 class="feature-name">${esc(feature.name ?? feature.id ?? "")}</h2>
|
|
@@ -609,14 +611,16 @@ function renderGoalGroup(
|
|
|
609
611
|
indexByKey: ReadonlyMap<string, number>,
|
|
610
612
|
show: { showGoal: boolean; showSprints: boolean },
|
|
611
613
|
display: DisplayPolicy,
|
|
614
|
+
activeFeatureId?: string | null,
|
|
612
615
|
): string {
|
|
616
|
+
const activeGoal = activeFeatureId ? group.sprints.some((sg) => sg.features.some((f) => f.id === activeFeatureId)) : false;
|
|
613
617
|
const sprints = group.sprints
|
|
614
|
-
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display))
|
|
618
|
+
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display, activeFeatureId))
|
|
615
619
|
.join("");
|
|
616
620
|
|
|
617
621
|
if (!show.showGoal || !group.goal) return sprints;
|
|
618
622
|
|
|
619
|
-
return `<details class="tier tier-goal" open>
|
|
623
|
+
return `<details class="tier tier-goal${activeGoal ? " is-current" : ""}" open>
|
|
620
624
|
<summary class="tier-head">
|
|
621
625
|
<span class="tier-kind">goal</span>
|
|
622
626
|
<span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
|
|
@@ -633,10 +637,12 @@ function renderSprintGroup(
|
|
|
633
637
|
indexByKey: ReadonlyMap<string, number>,
|
|
634
638
|
showSprints: boolean,
|
|
635
639
|
display: DisplayPolicy,
|
|
640
|
+
activeFeatureId?: string | null,
|
|
636
641
|
): string {
|
|
642
|
+
const activeSprint = activeFeatureId ? group.features.some((f) => f.id === activeFeatureId) : false;
|
|
637
643
|
const features = display.levels.feature
|
|
638
644
|
? group.features
|
|
639
|
-
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display))
|
|
645
|
+
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display, f.id === activeFeatureId))
|
|
640
646
|
.join("")
|
|
641
647
|
: // Hiding the feature card must not hide its tasks: they move up into the
|
|
642
648
|
// sprint, which is what "hide features" has to mean on a page whose whole
|
|
@@ -645,7 +651,7 @@ function renderSprintGroup(
|
|
|
645
651
|
|
|
646
652
|
if (!showSprints || !group.sprint) return features;
|
|
647
653
|
|
|
648
|
-
return `<details class="tier tier-sprint" open>
|
|
654
|
+
return `<details class="tier tier-sprint${activeSprint ? " is-current" : ""}" open>
|
|
649
655
|
<summary class="tier-head">
|
|
650
656
|
<span class="tier-kind">sprint</span>
|
|
651
657
|
<span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
|
|
@@ -965,11 +971,17 @@ table.tasks tr:last-child td{border-bottom:0}
|
|
|
965
971
|
.task-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}
|
|
966
972
|
.task-desc{overflow-wrap:anywhere}
|
|
967
973
|
.row-complete .task-desc{color:var(--muted)}
|
|
968
|
-
.row.is-active{background:rgba(var(--rgb-active),.07)}
|
|
974
|
+
.row.is-active{background:rgba(var(--rgb-active),.07);animation:taskBlink 1.2s ease-in-out infinite}
|
|
969
975
|
.row.is-active .task-desc{font-weight:600}
|
|
970
976
|
.row.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-active)}
|
|
971
977
|
.row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
|
|
972
978
|
.row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
|
|
979
|
+
@keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
|
|
980
|
+
/* While-developed blinking for the whole current branch */
|
|
981
|
+
.tier.is-current,.feature.is-current{animation:cardPulse 1.4s ease-in-out infinite}
|
|
982
|
+
.tier.is-current .tier-name,.feature.is-current .feature-name{animation:textPulse 1.2s ease-in-out infinite}
|
|
983
|
+
@keyframes cardPulse{0%,100%{box-shadow:var(--shadow)}50%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.22),var(--shadow)}}
|
|
984
|
+
@keyframes textPulse{0%,100%{opacity:1}50%{opacity:.65}}
|
|
973
985
|
.row-blocked{background:rgba(var(--rgb-blocked),.07)}
|
|
974
986
|
.row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
|
|
975
987
|
.deps{color:var(--faint);white-space:nowrap}
|
|
@@ -1188,6 +1200,9 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1188
1200
|
// everything.
|
|
1189
1201
|
const display = normalizeDisplay(state.display ?? defaultDisplay());
|
|
1190
1202
|
const groups = groupPlan(list);
|
|
1203
|
+
// Which feature/sprint/goal is currently being worked (for blinking).
|
|
1204
|
+
const activeTask = tasks.find((t) => t.status === "in_progress" || t.status === "rework") ?? tasks.find((t) => t.status === "pending") ?? null;
|
|
1205
|
+
const activeFeatureId = activeTask?.featureId ?? null;
|
|
1191
1206
|
const body = features.length
|
|
1192
1207
|
? groups
|
|
1193
1208
|
.map((group) =>
|
|
@@ -1200,6 +1215,7 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1200
1215
|
showSprints: display.levels.sprint && sprints.length > 0,
|
|
1201
1216
|
},
|
|
1202
1217
|
display,
|
|
1218
|
+
activeFeatureId,
|
|
1203
1219
|
),
|
|
1204
1220
|
)
|
|
1205
1221
|
.join("")
|
package/src/ui/wizard.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* leaves a project in a state nobody chose.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { Prompter } from "./config.ts";
|
|
26
|
+
import type { ModelChoice, Prompter } from "./config.ts";
|
|
27
27
|
import type { DisplayPolicy, Phase } from "../core/types.ts";
|
|
28
28
|
import {
|
|
29
29
|
BRIEF_QUESTION,
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
} from "../workflow.ts";
|
|
51
51
|
import { DEFAULT_ENABLED_PHASES } from "../core/types.ts";
|
|
52
52
|
import { defaultDisplay, listDisplays, normalizeDisplay, saveDisplay } from "./display.ts";
|
|
53
|
+
import type { ThinkingLevel } from "../modelRouter.ts";
|
|
53
54
|
|
|
54
55
|
export type WizardOptions = {
|
|
55
56
|
prompt: Prompter;
|
|
@@ -59,6 +60,8 @@ export type WizardOptions = {
|
|
|
59
60
|
skipConfirm?: boolean;
|
|
60
61
|
/** Where saved workflows and templates live. Tests point this elsewhere. */
|
|
61
62
|
env?: NodeJS.ProcessEnv;
|
|
63
|
+
/** Models pi can use — offered for each tier and for consulting. */
|
|
64
|
+
models?: () => ModelChoice[] | Promise<ModelChoice[]>;
|
|
62
65
|
};
|
|
63
66
|
|
|
64
67
|
export type WizardResult =
|
|
@@ -76,6 +79,84 @@ function line(label: string, help: string): string {
|
|
|
76
79
|
return `${label} — ${help}`;
|
|
77
80
|
}
|
|
78
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
|
+
|
|
79
160
|
export async function runIntakeWizard(options: WizardOptions): Promise<WizardResult> {
|
|
80
161
|
const { prompt, env } = options;
|
|
81
162
|
|
|
@@ -102,11 +183,15 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
|
|
|
102
183
|
| "phase"
|
|
103
184
|
| "task";
|
|
104
185
|
|
|
105
|
-
// -- 4.
|
|
186
|
+
// -- 4. models ----------------------------------------------------------
|
|
187
|
+
const modelsAnswer = await pickModelsStep(prompt, options.models);
|
|
188
|
+
if (modelsAnswer === undefined) return { cancelled: true };
|
|
189
|
+
|
|
190
|
+
// -- 5. display ---------------------------------------------------------
|
|
106
191
|
const display = await pickDisplay(prompt, env);
|
|
107
192
|
if (display === undefined) return { cancelled: true };
|
|
108
193
|
|
|
109
|
-
const answers: IntakeAnswers = { workflow, brief, handoff, display };
|
|
194
|
+
const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router };
|
|
110
195
|
const plan = planIntake(answers);
|
|
111
196
|
|
|
112
197
|
if (options.skipConfirm) return { cancelled: false, plan, answers };
|