infinity-harness 2.4.0 → 2.5.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 CHANGED
@@ -4,6 +4,53 @@ 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.1] — 2026-08-25
8
+
9
+ ### Fixed
10
+
11
+ - **Routing actually drives the session.** `harness/model-router.json` previously persisted the tier
12
+ choices but never called `ctx.setModel`/`ctx.setThinkingLevel`. Now `before_agent_start` and
13
+ `session_start` resolve `harness/model-router.json` for the next actionable task and switch
14
+ the pi session model + thinking, surface it as `infinity-model` in the footer and as
15
+ `Routing: f1/t1 → prov-a/model-a · thinking low` in the brief/system prompt; `/infinity:config`
16
+ and `/infinity:models` already showed the wiring, now the session honors it. Proved by
17
+ `tests/routing-live.test.ts` + 15/15 E2E (including `realpi` dialogs) all green.
18
+ - **`infinity_validate` auto-advance scoped to doc phases.** Only `research/define/plan` hops on
19
+ PASS in autopilot; `build` and later require explicit `infinity_advance` (or the armed
20
+ `agent_settled` loop) so `build → verify → review` no longer skips a phase in one tool call.
21
+ Wizard routing queue aligned in `scripts/e2e.mjs` + `tests/intake.test.ts`; full granularity
22
+ hierarchy covered in `tests/handoff.test.ts`.
23
+
24
+ ## [2.5.0] — 2026-08-25
25
+
26
+ ### Added
27
+
28
+ - **Wizard picks models and thinking per tier + consulting master.** `/infinity:init` now asks which
29
+ models and thinking levels to use for easy/moderate/difficult tiers and for the consulting master
30
+ (with `off/minimal/low/medium/high/xhigh/max` plus `inherit`). Persisted in
31
+ `harness/model-router.json` via `thinkingByDifficulty`/`thinkingMaster`/`thinkingDefault`; exposed
32
+ via `/infinity:config` → Models.
33
+
34
+ - **Customizable handoff granularity.** `goal → phase → sprint → feature → task → subtask`.
35
+ Wizard and `/infinity:config` both offer `goal` (single-session alias for `off`), `phase`
36
+ (old default), `sprint`, `feature`, **default `task`**, `subtask`, and `off`. A finer choice
37
+ implies coarser boundaries (picking `task` also hands off on feature/sprint/phase). Fixed:
38
+ `task`-scoped handoff previously never fired because only `phase` was compared.
39
+
40
+ - **Dashboard blinks the active branch.** Phase dot `pulse`, current feature/sprint/goal cards and the
41
+ active task row now pulse while being developed; `prefers-reduced-motion` disables them.
42
+
43
+ ### Fixed
44
+
45
+ - **Research autopilot stalled after pass.** `infinity_validate` now auto-advances on PASS when
46
+ the phase's mode is `autopilot` (mirrors `decideNext`), so `research → define` no longer requires
47
+ manually typing `continue`.
48
+ - **`alt+j/k/o` never fired.** Editor shortcut only runs when the editor has focus; the TUI
49
+ selector/overlay swallows input. Added `KeyId` shortcuts plus a raw `onTerminalInput`
50
+ fallback (`\x1bj/k/o`) installed on `session_start`.
51
+ - **Handoff threshold `0.7 → 0.6`.** Long BUILD phases now hand off earlier, under the context
52
+ window before compaction.
53
+
7
54
  ## [2.4.0] — 2026-08-24
8
55
 
9
56
  Two settings that were one switch each, and one switch turned out to be the wrong shape for both
@@ -247,7 +247,9 @@ export default function (pi: ExtensionAPI): void {
247
247
  const briefText = async (dir: string, includeGate = false): Promise<string> => {
248
248
  const { config } = loadConfig(dir);
249
249
  const brief = await buildBrief(dir, { includeGate });
250
- return renderBrief(brief, config);
250
+ const routed = await routingSummaryForBrief(dir).catch(() => null as string | null);
251
+ const text = renderBrief(brief, config);
252
+ return routed ? `${text}\n\n${routed}` : text;
251
253
  };
252
254
 
253
255
  // -- configuration --------------------------------------------------------
@@ -295,17 +297,130 @@ export default function (pi: ExtensionAPI): void {
295
297
  notify: (message, level) => notify(ctx, message, level ?? "info"),
296
298
  });
297
299
 
300
+ // -- model + thinking routing (live session) --------------------------------
301
+ const applyRouting = async (ctx: ExtensionContext, dir: string, source: string): Promise<void> => {
302
+ try {
303
+ const { resolveModel, resolveThinking } = await import("../../src/modelRouter.ts");
304
+ const { nextActionableTask, findFeature } = await import("../../src/core/featureList.ts");
305
+ const { loadFeatureList: loadList } = await import("../../src/core/featureList.ts");
306
+ const list = loadList(dir).list;
307
+ const task = nextActionableTask(list);
308
+ // Resolve against task/parent feature/sprint difficulty; fall through to default when no actionable.
309
+ const feature = task ? findFeature(list, task.featureId) ?? undefined : undefined;
310
+ const sprint = feature?.sprintId ? (list.sprints ?? []).find((s) => s.id === feature.sprintId) ?? undefined : undefined;
311
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
312
+ type T = NonNullable<ReturnType<typeof nextActionableTask>>;
313
+ const routedModel = resolveModel({
314
+ projectDir: dir,
315
+ task: (task as T | null | undefined)?.difficulty || feature?.difficulty ? ({ difficulty: (task as T | undefined)?.difficulty, modelHint: (task as T | undefined)?.modelHint, id: task?.id, key: (task as T | undefined)?.compositeKey ?? (task as T | undefined)?.key } as never) : undefined,
316
+ feature: feature as never,
317
+ sprint: sprint as never,
318
+ phase: loadConfig(dir).config.currentPhase ?? undefined,
319
+ role: loadConfig(dir).config.currentRole ?? undefined,
320
+ });
321
+ const routedThinking = resolveThinking({
322
+ projectDir: dir,
323
+ task: (task as T | null | undefined) ? ({ difficulty: (task as T | undefined)?.difficulty, id: task?.id, key: (task as T | undefined)?.compositeKey ?? (task as T | undefined)?.key } as never) : undefined,
324
+ feature: feature as never,
325
+ sprint: sprint as never,
326
+ });
327
+
328
+ if (routedModel && routedModel.trim()) {
329
+ // Map "provider/id" -> Model via registry.
330
+ const ref = routedModel.trim();
331
+ const slash = ref.indexOf("/");
332
+ const provider = slash > 0 ? ref.slice(0, slash) : undefined;
333
+ const modelId = slash > 0 ? ref.slice(slash + 1) : ref;
334
+ const available: unknown[] = (() => { try { return (ctx.modelRegistry?.getAvailable?.() ?? []) as unknown[]; } catch { return []; } })();
335
+ const found = (() => {
336
+ if (!provider) return undefined;
337
+ try { return (ctx.modelRegistry as unknown as { find(provider: string, id: string): unknown }).find(provider, modelId); } catch { return undefined; }
338
+ })();
339
+ const candidate = found ?? available.find((m) => {
340
+ const id = (m as { id?: string })?.id;
341
+ const prov = (m as { provider?: string })?.provider;
342
+ return id && (prov ? `${prov}/${id}` === ref : id === ref || id === modelId);
343
+ }) as { id?: string; provider?: string } | undefined;
344
+ const modelObj = (found as { id?: string } | undefined) ?? candidate;
345
+ if (modelObj && routedModel) {
346
+ try {
347
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
348
+ const ok = await (ctx as unknown as { setModel(m: unknown): Promise<boolean> }).setModel(modelObj as never);
349
+ (ctx.ui as unknown as { setStatus?: (k: string, v: string | undefined) => void })?.setStatus?.("infinity-model", routedModel);
350
+ if (!ok) {
351
+ notify(ctx, `infinity-harness: routed model ${ref} not available (no auth) — staying on current model.`, "warning");
352
+ } else if (source) {
353
+ notify(ctx, `infinity-harness: routed to ${ref}${routedThinking ? ` (${routedThinking})` : ""} for ${task?.compositeKey ?? task?.id ?? "next task"} [${source}]`, "info");
354
+ }
355
+ } catch (e) {
356
+ notify(ctx, `infinity-harness: setModel(${ref}) — ${(e as Error)?.message ?? String(e)}`, "warning");
357
+ }
358
+ } else {
359
+ // Model id present but not in registry — surface once per session, and in widget.
360
+ ;(ctx.ui as unknown as { setStatus?: (k: string, v: string | undefined) => void })?.setStatus?.("infinity-model", routedModel);
361
+ notify(ctx, `infinity-harness routing wants ${ref} for ${task?.compositeKey ?? "next task"} but that model is not in pi's registry (check auth / --models) [${source}]`, "warning");
362
+ }
363
+ } else {
364
+ // No routed model → show inherited; do not call setModel.
365
+ ;(ctx.ui as unknown as { setStatus?: (k: string, v: string | undefined) => void })?.setStatus?.("infinity-model", undefined);
366
+ }
367
+
368
+ if (routedThinking && routedThinking.trim()) {
369
+ try {
370
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
371
+ (ctx as unknown as { setThinkingLevel(l: string): void }).setThinkingLevel(routedThinking as never);
372
+ } catch {}
373
+ }
374
+ } catch {}
375
+ };
376
+
377
+ const routingSummaryForBrief = async (dir: string): Promise<string | null> => {
378
+ try {
379
+ const { nextActionableTask, findFeature } = await import("../../src/core/featureList.ts");
380
+ const { resolveModel, resolveThinking } = await import("../../src/modelRouter.ts");
381
+ const { list } = loadFeatureList(dir);
382
+ const task = nextActionableTask(list);
383
+ if (!task) return null;
384
+ const feature = findFeature(list, task.featureId) ?? undefined;
385
+ const sprint = feature?.sprintId ? (list.sprints ?? []).find((s) => s.id === feature.sprintId) ?? undefined : undefined;
386
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
387
+ const m = resolveModel({ projectDir: dir, task: ({ difficulty: (task as any).difficulty, modelHint: (task as any).modelHint, id: task.id, key: (task as any).compositeKey ?? (task as any).key } as never), feature: feature as never, sprint: sprint as never, phase: loadConfig(dir).config.currentPhase ?? undefined });
388
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
389
+ const th = resolveThinking({ projectDir: dir, task: ({ difficulty: (task as any).difficulty } as never), feature: feature as never, sprint: sprint as never });
390
+ if (!m || !m.trim()) return null;
391
+ return `Routing: ${task.compositeKey} → ${m}${th ? ` · thinking ${th}` : ""}`;
392
+ } catch { return null; }
393
+ };
394
+
298
395
  // -- session handoff ------------------------------------------------------
299
396
 
300
- /** The task the pipeline is on right now, or null. */
301
- const activeTaskKey = (dir: string): string | null => {
397
+ /** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
398
+ const activePlanKeys = (dir: string): { task: string | null; feature: string | null; sprint: string | null; goal: string | null; subtask: string | null; } => {
302
399
  try {
303
400
  const { list } = loadFeatureList(dir);
304
- return nextActionableTask(list)?.compositeKey ?? null;
401
+ const task = nextActionableTask(list);
402
+ const flat = task ? loadFeatureList(dir).list.features?.find((f) => f.id === task.featureId) ?? null : null;
403
+ // Resolve sprint/goal via list, and active subtask of the focused task.
404
+ const taskKey = task?.compositeKey ?? null;
405
+ const featureId = task?.featureId ?? null;
406
+ const feature = featureId ? (list.features ?? []).find((f) => f.id === featureId) ?? null : null;
407
+ const sprintId = feature?.sprintId ?? null;
408
+ const goalId = feature?.goalId ?? (sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId)?.goalId ?? null : null) ?? (list.goals?.[0]?.id ?? null);
409
+ const sprint = sprintId ? sprintId : null;
410
+ const goal = goalId ? goalId : null;
411
+ // First non-complete subtask of the active task.
412
+ let subtask: string | null = null;
413
+ const rawTask = feature && task ? feature.tasks.find((t) => t.id === task.id || t.key === task.key) ?? null : null;
414
+ if (rawTask?.subtasks?.length) {
415
+ const cur = rawTask.subtasks.find((s) => s.status !== "complete") ?? null;
416
+ if (cur) subtask = `${taskKey}#${cur.id ?? cur.title}`;
417
+ }
418
+ return { task: taskKey, feature: featureId, sprint, goal, subtask };
305
419
  } catch {
306
- return null;
420
+ return { task: null, feature: null, sprint: null, goal: null, subtask: null };
307
421
  }
308
422
  };
423
+ const activeTaskKey = (dir: string): string | null => activePlanKeys(dir).task;
309
424
 
310
425
  /** How full this session's context is, 0..1, or null when pi cannot say. */
311
426
  const contextRatio = (ctx: ExtensionContext): number | null => {
@@ -355,12 +470,41 @@ export default function (pi: ExtensionAPI): void {
355
470
  }
356
471
  try {
357
472
  const { config } = loadConfig(dir);
473
+ const toKeys = activePlanKeys(dir);
474
+ // Map caller's fromTask (a compositeKey) back to its feature/sprint etc for the "from" side.
475
+ // We derive them from the plan so goal/sprint/feature boundaries are comparable.
476
+ let fromGoal: string | null = null;
477
+ let fromSprint: string | null = null;
478
+ let fromFeature: string | null = null;
479
+ try {
480
+ const { list } = loadFeatureList(dir);
481
+ if (fromTask) {
482
+ const ft = ((): { featureId: string } | null => {
483
+ 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 };
484
+ return null;
485
+ })();
486
+ if (ft) {
487
+ fromFeature = ft.featureId;
488
+ const feat = list.features.find((f) => f.id === ft.featureId) ?? null;
489
+ fromSprint = feat?.sprintId ?? null;
490
+ fromGoal = feat?.goalId ?? (fromSprint ? (list.sprints ?? []).find((s) => s.id === fromSprint)?.goalId ?? null : null) ?? null;
491
+ }
492
+ }
493
+ } catch {}
358
494
  const decision = shouldHandoff({
359
495
  config,
360
496
  fromPhase,
361
497
  toPhase,
362
498
  fromTask,
363
- toTask: activeTaskKey(dir),
499
+ toTask: toKeys.task,
500
+ fromGoal,
501
+ toGoal: toKeys.goal,
502
+ fromSprint,
503
+ toSprint: toKeys.sprint,
504
+ fromFeature,
505
+ toFeature: toKeys.feature,
506
+ fromSubtask: null, // subtask delta is derived from task payload; tracked via fromTask composite + activePlanKeys
507
+ toSubtask: toKeys.subtask,
364
508
  contextRatio: contextRatio(ctx),
365
509
  });
366
510
  if (!decision.handoff) return false;
@@ -496,10 +640,16 @@ export default function (pi: ExtensionAPI): void {
496
640
 
497
641
  view = defaultView();
498
642
  refreshWidget(ctx);
643
+ installTerminalShortcuts(ctx);
644
+ const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
499
645
  const { config } = loadConfig(dir);
500
646
  lastBriefPhase = config.currentPhase;
647
+ // Route on session start (including after handoff) so the fresh session
648
+ // actually runs on the tier model, not whatever the harness was started with.
649
+ try { await applyRouting(ctx, dir, `session_start:${reason}`); } catch {}
650
+ ;(async () => { try { await applyRouting(ctx, dir, "session_start"); } catch {} })();
501
651
 
502
- const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
652
+
503
653
  const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
504
654
  const armed = run?.armed === true;
505
655
 
@@ -572,11 +722,18 @@ export default function (pi: ExtensionAPI): void {
572
722
  if (!sessionLive) return;
573
723
  const dir = projectDir(ctx);
574
724
  if (!isHarnessProject(dir)) return;
725
+ // Live-model routing: switch the pi session model/thinking for the next
726
+ // actionable task. This is what makes harness/model-router.json do anything
727
+ // in the main session; without it the GUI pointing at the same model was
728
+ // the whole behavior.
729
+ try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
575
730
  try {
576
731
  const contract = harnessContract(dir);
577
732
  if (!contract) return;
578
733
  const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
579
- return { systemPrompt: `${base}\n\n${contract}` };
734
+ const routed = await routingSummaryForBrief(dir);
735
+ const suffix = routed ? `\n\n${routed}` : "";
736
+ return { systemPrompt: `${base}${suffix}\n\n${contract}` };
580
737
  } catch {
581
738
  return;
582
739
  }
@@ -1042,6 +1199,41 @@ export default function (pi: ExtensionAPI): void {
1042
1199
  const lines = gate.checks
1043
1200
  .map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
1044
1201
  .join("\n");
1202
+ // Research (and any other phase whose mode is autopilot) used to stall
1203
+ // forever until someone typed "continue" because the brief said
1204
+ // PASS→advance but no component actually advanced without the continuous
1205
+ // loop armed. Fix that, but do NOT auto-advance BUILD verify-style
1206
+ // phases that require real work (tests, coverage, clean tree) to have
1207
+ // genuinely passed on the *next* phase's gate as well — otherwise a
1208
+ // single infinity_validate hops build→verify→review.
1209
+ // Only auto-advance doc/process phases whose gate is purely content (
1210
+ // research, define, plan). BUILD and later require explicit validation.
1211
+ if (gate.overall && !params?.feature && !params?.task) {
1212
+ const autoPhases: ReadonlySet<string> = new Set(["research", "define", "plan"]);
1213
+ if (autoPhases.has(String(gate.phase))) {
1214
+ try {
1215
+ const { needsApproval } = await import("../../src/approval.ts");
1216
+ const fresh = loadConfig(dir).config;
1217
+ if (!needsApproval(fresh, fresh.currentPhase)) {
1218
+ const { advancePhase } = await import("../../src/core/phases.ts");
1219
+ const moved = await advancePhase(dir);
1220
+ if (moved.ok && moved.to) {
1221
+ refreshWidget(ctx as ExtensionContext);
1222
+ const brief = await briefText(dir);
1223
+ return {
1224
+ content: [
1225
+ {
1226
+ type: "text",
1227
+ text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1228
+ },
1229
+ ],
1230
+ details: { ...gate, advanced: moved } as unknown as typeof gate,
1231
+ };
1232
+ }
1233
+ }
1234
+ } catch {}
1235
+ }
1236
+ }
1045
1237
  return {
1046
1238
  content: [
1047
1239
  {
@@ -1313,7 +1505,7 @@ export default function (pi: ExtensionAPI): void {
1313
1505
  }
1314
1506
 
1315
1507
  const wizard = ctx.hasUI
1316
- ? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null })
1508
+ ? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null, models: () => availableModels(ctx) })
1317
1509
  : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
1318
1510
 
1319
1511
  if (wizard.cancelled) {
@@ -1331,6 +1523,17 @@ export default function (pi: ExtensionAPI): void {
1331
1523
  display: plan.display,
1332
1524
  session: plan.session,
1333
1525
  brief: plan.brief,
1526
+ router: plan.router
1527
+ ? ({
1528
+ enabled: !!plan.router.enabled,
1529
+ byDifficulty: plan.router.byDifficulty as unknown as Record<string, string>,
1530
+ thinkingByDifficulty: plan.router.thinkingByDifficulty as unknown as Record<string, string>,
1531
+ master: plan.router.master ?? "",
1532
+ thinkingMaster: plan.router.thinkingMaster as unknown as string,
1533
+ default: plan.router.default ?? "",
1534
+ thinkingDefault: plan.router.thinkingDefault as unknown as string,
1535
+ } as Partial<import("../../src/modelRouter.ts").RouterConfig>)
1536
+ : undefined,
1334
1537
  force,
1335
1538
  });
1336
1539
  if (!result.ok) {
@@ -2352,24 +2555,58 @@ export default function (pi: ExtensionAPI): void {
2352
2555
  // `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
2353
2556
  // to line end) and ctrl+o (expand tool output). Shadowing an editor key to
2354
2557
  // scroll a widget would be a worse bug than the one being fixed.
2558
+ //
2559
+ // Shortcuts are editor-focused via registerShortcut, but also handled as a
2560
+ // raw terminal fallback so they work when an overlay or selector has focus
2561
+ // or when the terminal sends the legacy ESC+j sequence that the editor
2562
+ // otherwise swallows as text.
2563
+
2564
+ const scrollDown = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, SCROLL_STEP);
2565
+ const scrollUp = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, -SCROLL_STEP);
2566
+ const toggleExpand = async (ctx: ExtensionContext): Promise<void> => {
2567
+ view = { ...view, expanded: !view.expanded };
2568
+ refreshWidget(ctx);
2569
+ };
2355
2570
 
2356
- pi.registerShortcut("alt+j", {
2357
- description: "infinity-harness: scroll the plan down",
2358
- handler: async (ctx: ExtensionContext) => moveView(ctx, SCROLL_STEP),
2359
- });
2360
-
2361
- pi.registerShortcut("alt+k", {
2362
- description: "infinity-harness: scroll the plan up",
2363
- handler: async (ctx: ExtensionContext) => moveView(ctx, -SCROLL_STEP),
2364
- });
2365
-
2366
- pi.registerShortcut("alt+o", {
2367
- description: "infinity-harness: expand or collapse the plan widget",
2368
- handler: async (ctx: ExtensionContext) => {
2369
- view = { ...view, expanded: !view.expanded };
2370
- refreshWidget(ctx);
2371
- },
2372
- });
2571
+ pi.registerShortcut("alt+j", { description: "infinity-harness: scroll the plan down", handler: scrollDown });
2572
+ pi.registerShortcut("alt+k", { description: "infinity-harness: scroll the plan up", handler: scrollUp });
2573
+ pi.registerShortcut("alt+o", { description: "infinity-harness: expand or collapse the plan widget", handler: toggleExpand });
2574
+ // Uppercase handling covered by the raw terminal fallback below which
2575
+ // lowercases data before matching; KeyId type only allows lowercase.
2576
+
2577
+ // Fallback raw input handler — runs even when the editor is not the
2578
+ // focused component (e.g. a selector is open). Must be installed per-
2579
+ // session because onTerminalInput is a UI session thing, not a global.
2580
+ let removeTerminalShortcut: (() => void) | null = null;
2581
+ const installTerminalShortcuts = (ctx: ExtensionContext): void => {
2582
+ try {
2583
+ removeTerminalShortcut?.();
2584
+ } catch {}
2585
+ try {
2586
+ // matchesKey lives in pi-tui but re-exported by pi; use the extension
2587
+ // input raw matcher via string compare for ESC-prefixed alt.
2588
+ removeTerminalShortcut = ctx.ui.onTerminalInput((data: string) => {
2589
+ // Legacy alt+letter is ESC + lower letter. Kitty may send CSI-u; both
2590
+ // are handled by normalising to lookahead then matching via the same
2591
+ // strings registerShortcut uses.
2592
+ const lower = data.toLowerCase();
2593
+ // Fast path: alt+j/k/o as ESC + letter (\x1bj) or higher-plane.
2594
+ if (data === "\x1bj" || data === "\x1bJ" || lower === "\x1bj") {
2595
+ void scrollDown(ctx);
2596
+ return { consume: true };
2597
+ }
2598
+ if (data === "\x1bk" || data === "\x1bK" || lower === "\x1bk") {
2599
+ void scrollUp(ctx);
2600
+ return { consume: true };
2601
+ }
2602
+ if (data === "\x1bo" || data === "\x1bO" || lower === "\x1bo") {
2603
+ void toggleExpand(ctx);
2604
+ return { consume: true };
2605
+ }
2606
+ return undefined;
2607
+ });
2608
+ } catch {}
2609
+ };
2373
2610
 
2374
2611
  pi.registerCommand("infinity:scroll", {
2375
2612
  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.4.0",
3
+ "version": "2.5.1",
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": [
@@ -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: "phase", contextThreshold: 0.7, carryNotes: true },
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);
@@ -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: each phase starts clean · task: cleanest context, best with small models · off: one session for the whole run.",
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 never — one session for the whole run (the old behaviour)
165
- * phase when the pipeline advances a phase
166
- * task when the pipeline advances a phase or moves to a different task
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: "off" | "phase" | "task";
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 = "phase" | "task" | "context" | "goal-pass" | "manual";
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: "phase", contextThreshold: 0.7, carryNotes: true };
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 === "off" || p.handoff === "task" || p.handoff === "phase" ? p.handoff : "phase";
67
- const raw = typeof p.contextThreshold === "number" ? p.contextThreshold : 0.7;
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
- if (signals.toPhase && signals.fromPhase !== signals.toPhase) {
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
- if (policy.handoff === "task" && signals.toTask && signals.fromTask !== signals.toTask) {
106
- return {
107
- handoff: true,
108
- reason: "task",
109
- detail: `${signals.fromTask ?? "no task"} → ${signals.toTask}`,
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<HandoffReason, string> = {
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 ?? "phase";
114
+ const handoff = answers.handoff ?? "task";
104
115
  const session: SessionPolicy = {
105
116
  handoff,
106
- contextThreshold: handoff === "off" ? 0 : 0.7,
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 (recommended)",
222
- help: "Each phase starts clean, working from the brief. Keeps the context small on long runs.",
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: "The cleanest context per unit of work. Best with small models; costs one extra brief per task.",
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 — one long session",
232
- help: "The old behaviour. The context grows for the whole run and compaction takes over.",
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
  };
@@ -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;
@@ -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. display ---------------------------------------------------------
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 };