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 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 activeTaskKey = (dir: string): string | null => {
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
- return nextActionableTask(list)?.compositeKey ?? null;
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: activeTaskKey(dir),
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
- 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
- });
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.4.0",
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": [
@@ -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 };