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 +47 -0
- package/extensions/infinity-harness/index.ts +263 -26
- package/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/core/init.ts +20 -0
- package/src/core/settings.ts +50 -3
- package/src/core/types.ts +9 -4
- package/src/handoff.ts +88 -15
- package/src/intake.ts +40 -8
- package/src/modelRouter.ts +55 -2
- package/src/ui/config.ts +7 -0
- package/src/ui/dashboard.ts +22 -6
- package/src/ui/wizard.ts +88 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
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.
|
|
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": [
|
package/src/core/config.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function defaultConfig(): HarnessConfig {
|
|
|
50
50
|
},
|
|
51
51
|
phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
|
|
52
52
|
roles: { strict: false },
|
|
53
|
-
session: { handoff: "
|
|
53
|
+
session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
|
|
54
54
|
approvals: { research: false, define: false, plan: false },
|
|
55
55
|
phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
|
|
56
56
|
workflow: { id: "autopilot", name: "autopilot" },
|
package/src/core/init.ts
CHANGED
|
@@ -162,6 +162,9 @@ export type InitOptions = {
|
|
|
162
162
|
session?: Partial<HarnessConfig["session"]>;
|
|
163
163
|
/** What the human said they want built. Recorded, and read by the first brief. */
|
|
164
164
|
brief?: string | null;
|
|
165
|
+
/** Model routing for difficulty tiers and consulting. */
|
|
166
|
+
router?: Partial<import("../../src/modelRouter.ts").RouterConfig>;
|
|
167
|
+
|
|
165
168
|
};
|
|
166
169
|
|
|
167
170
|
export type InitResult = {
|
|
@@ -238,6 +241,23 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
238
241
|
at: new Date().toISOString(),
|
|
239
242
|
};
|
|
240
243
|
}
|
|
244
|
+
if (options.router) {
|
|
245
|
+
try {
|
|
246
|
+
const routerPath = P.modelRouterPath(targetDir);
|
|
247
|
+
mkdirSync(dirname(routerPath), { recursive: true });
|
|
248
|
+
let existing: Record<string, unknown> = {};
|
|
249
|
+
try { if (existsSync(routerPath)) existing = JSON.parse(readFileSync(routerPath, "utf-8")); } catch { /* ignore corrupt */ }
|
|
250
|
+
const incoming = options.router as Record<string, unknown>;
|
|
251
|
+
const merged: Record<string, unknown> = { ...existing, ...incoming };
|
|
252
|
+
if ((incoming as { byDifficulty?: unknown }).byDifficulty && typeof (incoming as { byDifficulty?: unknown }).byDifficulty === "object") {
|
|
253
|
+
merged.byDifficulty = { ...((existing.byDifficulty as Record<string,string>) ?? {}), ...(incoming.byDifficulty as Record<string,string>) };
|
|
254
|
+
}
|
|
255
|
+
if ((incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty && typeof (incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty === "object") {
|
|
256
|
+
merged.thinkingByDifficulty = { ...((existing.thinkingByDifficulty as Record<string,string>) ?? {}), ...(incoming.thinkingByDifficulty as Record<string,string>) };
|
|
257
|
+
}
|
|
258
|
+
writeFileSync(routerPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
259
|
+
} catch { /* best-effort */ }
|
|
260
|
+
}
|
|
241
261
|
|
|
242
262
|
const write = (path: string, body: string) => {
|
|
243
263
|
const rel = path.slice(targetDir.length + 1);
|
package/src/core/settings.ts
CHANGED
|
@@ -28,7 +28,8 @@ export type SettingType =
|
|
|
28
28
|
| { kind: "choice"; choices: readonly string[] }
|
|
29
29
|
| { kind: "multi"; choices: readonly string[] }
|
|
30
30
|
/** Resolved at runtime from the models pi has configured. */
|
|
31
|
-
| { kind: "model" }
|
|
31
|
+
| { kind: "model" }
|
|
32
|
+
| { kind: "thinking" };
|
|
32
33
|
|
|
33
34
|
export type Setting = {
|
|
34
35
|
/** Dotted path within the file. */
|
|
@@ -75,6 +76,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
75
76
|
help: DIFFICULTY_HELP,
|
|
76
77
|
type: { kind: "model" },
|
|
77
78
|
},
|
|
79
|
+
{
|
|
80
|
+
path: "thinkingByDifficulty.easy",
|
|
81
|
+
file: "router",
|
|
82
|
+
label: "Easy thinking",
|
|
83
|
+
help: "Thinking level for easy tasks. Empty inherits pi's current level.",
|
|
84
|
+
type: { kind: "thinking" },
|
|
85
|
+
},
|
|
78
86
|
{
|
|
79
87
|
path: "byDifficulty.moderate",
|
|
80
88
|
file: "router",
|
|
@@ -82,6 +90,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
82
90
|
help: DIFFICULTY_HELP,
|
|
83
91
|
type: { kind: "model" },
|
|
84
92
|
},
|
|
93
|
+
{
|
|
94
|
+
path: "thinkingByDifficulty.moderate",
|
|
95
|
+
file: "router",
|
|
96
|
+
label: "Moderate thinking",
|
|
97
|
+
help: "Thinking level for moderate tasks. Empty inherits pi's current level.",
|
|
98
|
+
type: { kind: "thinking" },
|
|
99
|
+
},
|
|
85
100
|
{
|
|
86
101
|
path: "byDifficulty.difficult",
|
|
87
102
|
file: "router",
|
|
@@ -89,6 +104,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
89
104
|
help: DIFFICULTY_HELP,
|
|
90
105
|
type: { kind: "model" },
|
|
91
106
|
},
|
|
107
|
+
{
|
|
108
|
+
path: "thinkingByDifficulty.difficult",
|
|
109
|
+
file: "router",
|
|
110
|
+
label: "Difficult thinking",
|
|
111
|
+
help: "Thinking level for difficult tasks. Empty inherits pi's current level.",
|
|
112
|
+
type: { kind: "thinking" },
|
|
113
|
+
},
|
|
92
114
|
{
|
|
93
115
|
path: "master",
|
|
94
116
|
file: "router",
|
|
@@ -96,6 +118,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
96
118
|
help: "Never assigned to a task directly — reached only when the ladder is exhausted and the harness asks for one opinion.",
|
|
97
119
|
type: { kind: "model" },
|
|
98
120
|
},
|
|
121
|
+
{
|
|
122
|
+
path: "thinkingMaster",
|
|
123
|
+
file: "router",
|
|
124
|
+
label: "Master thinking",
|
|
125
|
+
help: "Thinking level for the master consultation model.",
|
|
126
|
+
type: { kind: "thinking" },
|
|
127
|
+
},
|
|
99
128
|
{
|
|
100
129
|
path: "default",
|
|
101
130
|
file: "router",
|
|
@@ -103,6 +132,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
103
132
|
help: "Used when nothing more specific matches. Empty means pi's current model.",
|
|
104
133
|
type: { kind: "model" },
|
|
105
134
|
},
|
|
135
|
+
{
|
|
136
|
+
path: "thinkingDefault",
|
|
137
|
+
file: "router",
|
|
138
|
+
label: "Default thinking",
|
|
139
|
+
help: "Fallback thinking level when no tier-specific level is set.",
|
|
140
|
+
type: { kind: "thinking" },
|
|
141
|
+
},
|
|
106
142
|
{
|
|
107
143
|
path: "consultation.enabled",
|
|
108
144
|
file: "router",
|
|
@@ -317,8 +353,8 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
317
353
|
path: "session.handoff",
|
|
318
354
|
file: "config",
|
|
319
355
|
label: "Fresh session",
|
|
320
|
-
help: "phase:
|
|
321
|
-
type: { kind: "choice", choices: ["off", "phase", "task"] },
|
|
356
|
+
help: "goal: one session · phase: per phase (old) · sprint/feature: when plan grouping changes · task: every task (default) · subtask: every subtask. Coarser levels still fire.",
|
|
357
|
+
type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
|
|
322
358
|
},
|
|
323
359
|
{
|
|
324
360
|
path: "session.contextThreshold",
|
|
@@ -538,6 +574,8 @@ export function formatValue(setting: Setting, value: unknown): string {
|
|
|
538
574
|
return value ? "on" : "off";
|
|
539
575
|
case "model":
|
|
540
576
|
return typeof value === "string" && value.trim() ? value : "(pi's current model)";
|
|
577
|
+
case "thinking":
|
|
578
|
+
return typeof value === "string" && value.trim() ? value : "(inherit)";
|
|
541
579
|
case "text":
|
|
542
580
|
return typeof value === "string" && value.trim() ? value : "(not set)";
|
|
543
581
|
case "multi":
|
|
@@ -583,6 +621,8 @@ export function parseDuration(input: string): number | null {
|
|
|
583
621
|
|
|
584
622
|
export type ValidationResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
585
623
|
|
|
624
|
+
export const THINKING_CHOICES = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
625
|
+
|
|
586
626
|
/** Coerce and bounds-check a raw answer for `setting`. */
|
|
587
627
|
export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
588
628
|
const t = setting.type;
|
|
@@ -596,6 +636,13 @@ export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
|
596
636
|
if (t.max !== undefined && n > t.max) return { ok: false, error: `must be at most ${t.max}` };
|
|
597
637
|
return { ok: true, value: n };
|
|
598
638
|
}
|
|
639
|
+
case "thinking": {
|
|
640
|
+
const v = raw.trim();
|
|
641
|
+
if (!v || v === "(inherit)") return { ok: true, value: "" };
|
|
642
|
+
const allowed = new Set(THINKING_CHOICES.slice(1) as readonly string[]);
|
|
643
|
+
if (!allowed.has(v)) return { ok: false, error: `must be one of: ${THINKING_CHOICES.join(", ")}` };
|
|
644
|
+
return { ok: true, value: v };
|
|
645
|
+
}
|
|
599
646
|
case "text":
|
|
600
647
|
case "model": {
|
|
601
648
|
const v = raw.trim();
|
package/src/core/types.ts
CHANGED
|
@@ -158,14 +158,19 @@ export type RetryBucket = {
|
|
|
158
158
|
* session boundary costs nothing but the brief, and the brief is what the
|
|
159
159
|
* agent should be working from anyway.
|
|
160
160
|
*/
|
|
161
|
+
export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
|
|
162
|
+
|
|
161
163
|
export type SessionPolicy = {
|
|
162
164
|
/**
|
|
163
165
|
* When to hand off to a fresh session.
|
|
164
|
-
* off
|
|
165
|
-
* phase
|
|
166
|
-
*
|
|
166
|
+
* off/goal never — one session for the whole run (the old behaviour)
|
|
167
|
+
* phase when the pipeline advances a phase
|
|
168
|
+
* sprint when the active sprint changes (or phase)
|
|
169
|
+
* feature when the active feature changes (or coarser)
|
|
170
|
+
* task when the active task changes (or coarser) — default
|
|
171
|
+
* subtask when the active subtask changes (or coarser)
|
|
167
172
|
*/
|
|
168
|
-
handoff:
|
|
173
|
+
handoff: HandoffGranularity;
|
|
169
174
|
/**
|
|
170
175
|
* Hand off early once the context is this full, as a fraction of the
|
|
171
176
|
* window. 0 disables it. This is what keeps a long BUILD phase — which may
|
package/src/handoff.ts
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
* that, only from a command handler, and the adapter is where pi lives.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { HarnessConfig, Phase, SessionPolicy } from "./core/types.ts";
|
|
26
|
+
import type { HarnessConfig, HandoffGranularity, Phase, SessionPolicy } from "./core/types.ts";
|
|
27
27
|
import { pendingSessionPath } from "./core/paths.ts";
|
|
28
28
|
import { readJsonSafe, writeJsonAtomic, removeFile, fileExists } from "./core/fsx.ts";
|
|
29
29
|
|
|
30
30
|
/** Why a session is being replaced. Shown to the human and to the next agent. */
|
|
31
|
-
export type HandoffReason =
|
|
31
|
+
export type HandoffReason = HandoffGranularity | "context" | "goal-pass" | "manual";
|
|
32
32
|
|
|
33
33
|
export type PendingHandoff = {
|
|
34
34
|
reason: HandoffReason;
|
|
@@ -51,22 +51,40 @@ export type HandoffSignals = {
|
|
|
51
51
|
/** Composite key of the task in focus before and after. */
|
|
52
52
|
fromTask: string | null;
|
|
53
53
|
toTask: string | null;
|
|
54
|
+
/** IDs for the coarser plan levels (goal/feature/sprint/subtask). Null means "no active one". */
|
|
55
|
+
fromGoal?: string | null;
|
|
56
|
+
toGoal?: string | null;
|
|
57
|
+
fromSprint?: string | null;
|
|
58
|
+
toSprint?: string | null;
|
|
59
|
+
fromFeature?: string | null;
|
|
60
|
+
toFeature?: string | null;
|
|
61
|
+
fromSubtask?: string | null;
|
|
62
|
+
toSubtask?: string | null;
|
|
54
63
|
/** Fraction of the context window in use, 0..1, or null when unknown. */
|
|
55
64
|
contextRatio: number | null;
|
|
56
65
|
};
|
|
57
66
|
|
|
58
67
|
export type HandoffDecision = { handoff: false } | { handoff: true; reason: HandoffReason; detail: string };
|
|
59
68
|
|
|
69
|
+
const GRANULARITIES: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
|
|
70
|
+
|
|
71
|
+
export function isHandoffGranularity(v: unknown): v is HandoffGranularity {
|
|
72
|
+
return typeof v === "string" && (GRANULARITIES as readonly string[]).includes(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
export function defaultSessionPolicy(): SessionPolicy {
|
|
61
|
-
return { handoff: "
|
|
76
|
+
return { handoff: "task", contextThreshold: 0.6, carryNotes: true };
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
function policyOf(config: HarnessConfig): SessionPolicy {
|
|
65
80
|
const p = (config.session ?? {}) as Partial<SessionPolicy>;
|
|
66
|
-
const handoff = p.handoff
|
|
67
|
-
|
|
81
|
+
const handoff: HandoffGranularity = isHandoffGranularity(p.handoff) ? p.handoff : "task";
|
|
82
|
+
// "goal" is an alias for the single-session behaviour; keep the storage
|
|
83
|
+
// as "goal" so the wizard round-trips, but treat it as "off" here.
|
|
84
|
+
const effective: HandoffGranularity = handoff === "goal" ? "off" : handoff;
|
|
85
|
+
const raw = typeof p.contextThreshold === "number" ? p.contextThreshold : 0.6;
|
|
68
86
|
return {
|
|
69
|
-
handoff,
|
|
87
|
+
handoff: effective,
|
|
70
88
|
// A threshold of 1 or more can never fire and a negative one always would;
|
|
71
89
|
// both are configuration mistakes, and clamping is kinder than either.
|
|
72
90
|
contextThreshold: raw <= 0 ? 0 : Math.min(0.95, raw),
|
|
@@ -81,6 +99,14 @@ function policyOf(config: HarnessConfig): SessionPolicy {
|
|
|
81
99
|
* arrives after compaction has already happened has arrived too late to be the
|
|
82
100
|
* thing that prevented it.
|
|
83
101
|
*/
|
|
102
|
+
/** Coarsest → finest. Handoff fires at the chosen level and everything coarser. */
|
|
103
|
+
const LEVEL_ORDER: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
|
|
104
|
+
|
|
105
|
+
function rank(g: HandoffGranularity): number {
|
|
106
|
+
const i = (LEVEL_ORDER as readonly string[]).indexOf(g);
|
|
107
|
+
return i < 0 ? 5 : i;
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
85
111
|
const policy = policyOf(signals.config);
|
|
86
112
|
if (policy.handoff === "off") return { handoff: false };
|
|
@@ -94,20 +120,62 @@ export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
|
94
120
|
};
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
|
|
123
|
+
const lvl = rank(policy.handoff);
|
|
124
|
+
|
|
125
|
+
// Hierarchy: off(0) < goal(1) < phase(2) < sprint(3) < feature(4) < task(5) < subtask(6).
|
|
126
|
+
// Finer granularity implies coarser boundaries too (task change implies feature/sprint/phase
|
|
127
|
+
// may have changed, but we check coarsest first so the reason reflects the highest level).
|
|
128
|
+
// Only boundaries at or coarser than the configured granularity? No —
|
|
129
|
+
// the knob is "how fine do you want to go". Choosing "task" means
|
|
130
|
+
// phase/feature/sprint/goal AND task boundaries fire; choosing "phase"
|
|
131
|
+
// means only phase (and coarser goal) fires. So a boundary fires iff
|
|
132
|
+
// its rank <= chosen rank. task (5) should not fire when handoff is phase (2). Hence <= lvl.
|
|
133
|
+
// Fine-grained choice: the wizard knob is the *coarsest* level that still
|
|
134
|
+
// gets a fresh session. Picking "task" means every task gets its own
|
|
135
|
+
// session (feature/sprint/phase do too, implicitly). So a boundary fires
|
|
136
|
+
// iff chosenRank >= boundaryRank.
|
|
137
|
+
// Phase always hands off (except off/goal) because phases are the harness
|
|
138
|
+
// backbone; a phase change must never ride the old session's context.
|
|
139
|
+
if (signals.toPhase && signals.fromPhase !== signals.toPhase && lvl >= 2) {
|
|
98
140
|
return {
|
|
99
141
|
handoff: true,
|
|
100
142
|
reason: "phase",
|
|
101
143
|
detail: `${(signals.fromPhase ?? "start").toUpperCase()} → ${signals.toPhase.toUpperCase()}`,
|
|
102
144
|
};
|
|
103
145
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
// "goal/off" never fires here — off early-returned, "goal" was mapped to off.
|
|
147
|
+
// Keep for completeness if rank comparison changes; guarded by lvl so it
|
|
148
|
+
// doesn't resurrect. retain dead code removed check.
|
|
149
|
+
void lvl;
|
|
150
|
+
if (signals.fromSprint !== undefined || signals.toSprint !== undefined) {
|
|
151
|
+
const sFrom = (signals.fromSprint ?? null)?.trim() || null;
|
|
152
|
+
const sTo = (signals.toSprint ?? null)?.trim() || null;
|
|
153
|
+
if (sFrom !== sTo && (sTo || sFrom) && lvl >= 3) {
|
|
154
|
+
return { handoff: true, reason: "sprint" as HandoffReason, detail: `${sFrom ?? "no sprint"} → ${sTo ?? "no sprint"}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (signals.fromFeature !== undefined || signals.toFeature !== undefined) {
|
|
158
|
+
const fFrom = (signals.fromFeature ?? null)?.trim() || null;
|
|
159
|
+
const fTo = (signals.toFeature ?? null)?.trim() || null;
|
|
160
|
+
if (fFrom !== fTo && (fTo || fFrom) && lvl >= 4) {
|
|
161
|
+
return { handoff: true, reason: "feature" as HandoffReason, detail: `${fFrom ?? "no feature"} → ${fTo ?? "no feature"}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (lvl >= 5) {
|
|
165
|
+
if ((signals.fromTask ?? null) !== (signals.toTask ?? null) && (signals.fromTask || signals.toTask)) {
|
|
166
|
+
return {
|
|
167
|
+
handoff: true,
|
|
168
|
+
reason: "task",
|
|
169
|
+
detail: `${signals.fromTask ?? "no task"} → ${signals.toTask ?? "no task"}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (lvl >= 6) {
|
|
174
|
+
const stFrom = (signals.fromSubtask ?? null)?.trim() || null;
|
|
175
|
+
const stTo = (signals.toSubtask ?? null)?.trim() || null;
|
|
176
|
+
if (stFrom !== stTo && (stTo || stFrom)) {
|
|
177
|
+
return { handoff: true, reason: "subtask" as HandoffReason, detail: `${stFrom ?? "no subtask"} → ${stTo ?? "no subtask"}` };
|
|
178
|
+
}
|
|
111
179
|
}
|
|
112
180
|
|
|
113
181
|
return { handoff: false };
|
|
@@ -168,10 +236,15 @@ export function composeKickoff(
|
|
|
168
236
|
detail: string,
|
|
169
237
|
carry: string | null,
|
|
170
238
|
): string {
|
|
171
|
-
const why: Record<
|
|
239
|
+
const why: Record<string, string> = {
|
|
172
240
|
phase: "The pipeline advanced, so the run continues in a clean session.",
|
|
173
241
|
task: "The run moved to a different task, so it continues in a clean session.",
|
|
242
|
+
sprint: "The active sprint changed, so the run continues in a clean session.",
|
|
243
|
+
feature: "The active feature changed, so the run continues in a clean session.",
|
|
244
|
+
goal: "The active goal changed, so the run continues in a clean session.",
|
|
245
|
+
subtask: "The active subtask changed, so the run continues in a clean session.",
|
|
174
246
|
context: "The previous session's context was filling up, so the run continues in a clean one.",
|
|
247
|
+
off: "Session handoff is off.",
|
|
175
248
|
"goal-pass": "A goal pass finished, so the next pass starts in a clean session.",
|
|
176
249
|
manual: "A human asked for a fresh session.",
|
|
177
250
|
};
|
package/src/intake.ts
CHANGED
|
@@ -53,6 +53,16 @@ export type IntakeAnswers = {
|
|
|
53
53
|
handoff?: SessionPolicy["handoff"];
|
|
54
54
|
/** What the surfaces should draw. Defaults to the `focus` template. */
|
|
55
55
|
display?: DisplayPolicy;
|
|
56
|
+
/** Model routing for difficulty tiers and consulting. */
|
|
57
|
+
router?: {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
byDifficulty: Record<string, string>;
|
|
60
|
+
thinkingByDifficulty?: Partial<Record<string, string>>;
|
|
61
|
+
master?: string;
|
|
62
|
+
thinkingMaster?: string;
|
|
63
|
+
default?: string;
|
|
64
|
+
thinkingDefault?: string;
|
|
65
|
+
};
|
|
56
66
|
};
|
|
57
67
|
|
|
58
68
|
export type IntakePlan = {
|
|
@@ -66,6 +76,7 @@ export type IntakePlan = {
|
|
|
66
76
|
approvals: ApprovalPolicy;
|
|
67
77
|
session: SessionPolicy;
|
|
68
78
|
display: DisplayPolicy;
|
|
79
|
+
router?: IntakeAnswers["router"];
|
|
69
80
|
/** What the human should be told about what they just chose. */
|
|
70
81
|
summary: string;
|
|
71
82
|
/** Things that will bite later if left as they are. */
|
|
@@ -100,10 +111,10 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
100
111
|
const phases = normalizePhases(workflow.phases);
|
|
101
112
|
const phaseModes = normalizeModes(workflow.modes, phases);
|
|
102
113
|
|
|
103
|
-
const handoff = answers.handoff ?? "
|
|
114
|
+
const handoff = answers.handoff ?? "task";
|
|
104
115
|
const session: SessionPolicy = {
|
|
105
116
|
handoff,
|
|
106
|
-
contextThreshold: handoff === "off" ? 0 : 0.
|
|
117
|
+
contextThreshold: handoff === "off" ? 0 : 0.6,
|
|
107
118
|
carryNotes: true,
|
|
108
119
|
};
|
|
109
120
|
|
|
@@ -152,6 +163,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
152
163
|
},
|
|
153
164
|
session,
|
|
154
165
|
display,
|
|
166
|
+
router: answers.router,
|
|
155
167
|
summary: summarize(workflow, phases, phaseModes, session, display, brief),
|
|
156
168
|
warnings,
|
|
157
169
|
};
|
|
@@ -216,20 +228,40 @@ export const HANDOFF_QUESTION: Question = {
|
|
|
216
228
|
id: "handoff",
|
|
217
229
|
title: "When should the run start a fresh session?",
|
|
218
230
|
options: [
|
|
231
|
+
{
|
|
232
|
+
value: "goal",
|
|
233
|
+
label: "per goal — one session for the whole run",
|
|
234
|
+
help: "The old single-session run. Every task accumulates context until compaction.",
|
|
235
|
+
},
|
|
219
236
|
{
|
|
220
237
|
value: "phase",
|
|
221
|
-
label: "every phase
|
|
222
|
-
help: "Each phase starts clean
|
|
238
|
+
label: "every phase",
|
|
239
|
+
help: "Old default. Each phase starts clean from the brief.",
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
value: "sprint",
|
|
243
|
+
label: "every sprint",
|
|
244
|
+
help: "New session whenever the active sprint changes (or phase).",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
value: "feature",
|
|
248
|
+
label: "every feature",
|
|
249
|
+
help: "New session on each feature boundary (and sprint/phase).",
|
|
223
250
|
},
|
|
224
251
|
{
|
|
225
252
|
value: "task",
|
|
226
|
-
label: "every task",
|
|
227
|
-
help: "
|
|
253
|
+
label: "every task (recommended)",
|
|
254
|
+
help: "Each task gets a clean session. Best isolation; one extra brief per task.",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
value: "subtask",
|
|
258
|
+
label: "every subtask",
|
|
259
|
+
help: "Finest grain — each subtask gets a fresh session. Most isolation, most churn.",
|
|
228
260
|
},
|
|
229
261
|
{
|
|
230
262
|
value: "off",
|
|
231
|
-
label: "never —
|
|
232
|
-
help: "
|
|
263
|
+
label: "never — alias for per goal",
|
|
264
|
+
help: "Same as per goal — one long session without fresh starts.",
|
|
233
265
|
},
|
|
234
266
|
],
|
|
235
267
|
};
|
package/src/modelRouter.ts
CHANGED
|
@@ -11,6 +11,14 @@ import { writeJsonAtomic, stripBom } from "./core/fsx.ts";
|
|
|
11
11
|
export const ROUTER_FILE = "harness/model-router.json";
|
|
12
12
|
export const ROUTER_VERSION = 1;
|
|
13
13
|
|
|
14
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
15
|
+
|
|
16
|
+
export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
|
|
18
|
+
export function isThinkingLevel(v: unknown): v is ThinkingLevel {
|
|
19
|
+
return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v);
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
export interface RouterConfig {
|
|
15
23
|
version: number;
|
|
16
24
|
enabled: boolean;
|
|
@@ -24,6 +32,10 @@ export interface RouterConfig {
|
|
|
24
32
|
byTask?: Record<string, string>;
|
|
25
33
|
consultation?: { enabled: boolean; maxPerTask: number; oneStepOnly: boolean; requireExhaustion: boolean };
|
|
26
34
|
budgets?: { maxReworksPerRun: number; maxReplansPerRun: number; maxReviewBounces: number };
|
|
35
|
+
/** Thinking level per tier, and for master/default. Empty means inherit pi's current level. */
|
|
36
|
+
thinkingByDifficulty?: Partial<Record<string, ThinkingLevel | "">>;
|
|
37
|
+
thinkingMaster?: ThinkingLevel | "";
|
|
38
|
+
thinkingDefault?: ThinkingLevel | "";
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
@@ -52,6 +64,9 @@ export const DEFAULT_ROUTER: RouterConfig = {
|
|
|
52
64
|
byTask: {},
|
|
53
65
|
consultation: { enabled: true, maxPerTask: 1, oneStepOnly: true, requireExhaustion: true },
|
|
54
66
|
budgets: { maxReworksPerRun: 3, maxReplansPerRun: 2, maxReviewBounces: 2 },
|
|
67
|
+
thinkingByDifficulty: { easy: "" as ThinkingLevel | "", moderate: "" as ThinkingLevel | "", difficult: "" as ThinkingLevel | "" } as Partial<Record<string, ThinkingLevel | "">>,
|
|
68
|
+
thinkingMaster: "" as ThinkingLevel | "",
|
|
69
|
+
thinkingDefault: "" as ThinkingLevel | "",
|
|
55
70
|
};
|
|
56
71
|
|
|
57
72
|
export const DIFFICULTY_LADDER: Array<"easy" | "moderate" | "difficult"> = ["easy", "moderate", "difficult"];
|
|
@@ -68,9 +83,13 @@ export function saveRouterConfig(projectDir: string, cfg: RouterConfig): void {
|
|
|
68
83
|
writeJsonAtomic(routerPath(projectDir), cfg);
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
function normalizeThinking(v: unknown): ThinkingLevel | "" {
|
|
87
|
+
return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v) ? (v as ThinkingLevel) : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
71
90
|
export function loadRouterConfig(projectDir?: string): RouterConfig {
|
|
72
91
|
const p = routerPath(projectDir);
|
|
73
|
-
if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! } };
|
|
92
|
+
if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! }, thinkingByDifficulty: { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) }, thinkingMaster: DEFAULT_ROUTER.thinkingMaster, thinkingDefault: DEFAULT_ROUTER.thinkingDefault };
|
|
74
93
|
try {
|
|
75
94
|
const raw = JSON.parse(stripBom(readFileSync(p, "utf-8")));
|
|
76
95
|
// merge with defaults to ensure fields
|
|
@@ -87,8 +106,18 @@ export function loadRouterConfig(projectDir?: string): RouterConfig {
|
|
|
87
106
|
byTask: raw.byTask ?? {},
|
|
88
107
|
consultation: raw.consultation ?? { ...DEFAULT_ROUTER.consultation! },
|
|
89
108
|
budgets: raw.budgets ?? { ...DEFAULT_ROUTER.budgets! },
|
|
109
|
+
thinkingByDifficulty: (() => {
|
|
110
|
+
const cur = raw.thinkingByDifficulty;
|
|
111
|
+
if (!cur || typeof cur !== "object") return { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) };
|
|
112
|
+
const out: Record<string, ThinkingLevel | ""> = {};
|
|
113
|
+
for (const k of DIFFICULTY_LADDER) out[k] = normalizeThinking((cur as Record<string, unknown>)[k]);
|
|
114
|
+
return out;
|
|
115
|
+
})(),
|
|
116
|
+
thinkingMaster: normalizeThinking(raw.thinkingMaster),
|
|
117
|
+
thinkingDefault: normalizeThinking(raw.thinkingDefault),
|
|
90
118
|
};
|
|
91
119
|
if (!cfg.byDifficulty) cfg.byDifficulty = { ...DEFAULT_ROUTER.byDifficulty! };
|
|
120
|
+
if (!cfg.thinkingByDifficulty) cfg.thinkingByDifficulty = { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) };
|
|
92
121
|
return cfg;
|
|
93
122
|
} catch {
|
|
94
123
|
return { ...DEFAULT_ROUTER };
|
|
@@ -170,14 +199,38 @@ export function consultNext(
|
|
|
170
199
|
return null;
|
|
171
200
|
}
|
|
172
201
|
|
|
202
|
+
export function resolveThinking(opts: ResolveOpts = {}): ThinkingLevel | "" {
|
|
203
|
+
const cfg = loadRouterConfig(opts.projectDir);
|
|
204
|
+
// Thinking is orthogonal to routing-enabled; when disabled, fall through to default/inherit.
|
|
205
|
+
const difficulty = opts.difficulty ?? opts.task?.difficulty ?? opts.feature?.difficulty ?? opts.sprint?.difficulty;
|
|
206
|
+
if (difficulty && cfg.thinkingByDifficulty && (cfg.thinkingByDifficulty as Record<string, ThinkingLevel | "">)[difficulty]) {
|
|
207
|
+
const v = (cfg.thinkingByDifficulty as Record<string, ThinkingLevel | "">)[difficulty];
|
|
208
|
+
if (v) return v;
|
|
209
|
+
}
|
|
210
|
+
// Master thinking only via consultation path; here just check difficulty default fallback
|
|
211
|
+
if (cfg.thinkingDefault) return cfg.thinkingDefault;
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function resolveThinkingForConsult(nextDifficulty: string | null, projectDir?: string): ThinkingLevel | "" {
|
|
216
|
+
const cfg = loadRouterConfig(projectDir);
|
|
217
|
+
if (!nextDifficulty) return cfg.thinkingMaster ?? "";
|
|
218
|
+
const v = cfg.thinkingByDifficulty?.[nextDifficulty as string] as ThinkingLevel | "" | undefined;
|
|
219
|
+
if (v) return v;
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
/** For widget/remote read-only exposure */
|
|
174
|
-
export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; master: string; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
|
|
224
|
+
export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; thinkingByDifficulty: Record<string, ThinkingLevel | "">; master: string; thinkingMaster: ThinkingLevel | ""; thinkingDefault: ThinkingLevel | ""; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
|
|
175
225
|
const cfg = loadRouterConfig(projectDir);
|
|
176
226
|
return {
|
|
177
227
|
enabled: cfg.enabled,
|
|
178
228
|
default: cfg.default,
|
|
179
229
|
byDifficulty: { ...(cfg.byDifficulty ?? {}) } as Record<string, string>,
|
|
230
|
+
thinkingByDifficulty: { ...(cfg.thinkingByDifficulty ?? {}) } as Record<string, ThinkingLevel | "">,
|
|
180
231
|
master: cfg.master ?? DEFAULT_ROUTER.master!,
|
|
232
|
+
thinkingMaster: (cfg.thinkingMaster ?? "") as ThinkingLevel | "",
|
|
233
|
+
thinkingDefault: (cfg.thinkingDefault ?? "") as ThinkingLevel | "",
|
|
181
234
|
budgets: cfg.budgets,
|
|
182
235
|
consultation: cfg.consultation,
|
|
183
236
|
};
|
package/src/ui/config.ts
CHANGED
|
@@ -166,6 +166,13 @@ async function editSetting(setting: Setting, options: ConfigMenuOptions): Promis
|
|
|
166
166
|
raw = picked;
|
|
167
167
|
break;
|
|
168
168
|
}
|
|
169
|
+
case "thinking": {
|
|
170
|
+
const choices = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
171
|
+
const picked = await prompt.select(`${setting.label} — ${setting.help}`, [...choices, BACK]);
|
|
172
|
+
if (picked === undefined || picked === BACK) return false;
|
|
173
|
+
raw = picked;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
169
176
|
case "model": {
|
|
170
177
|
raw = await pickModel(setting, options, typeof current === "string" ? current : "");
|
|
171
178
|
if (raw === undefined) return false;
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -549,10 +549,12 @@ function renderFeature(
|
|
|
549
549
|
sprintName: string | null,
|
|
550
550
|
goalName: string | null,
|
|
551
551
|
display: DisplayPolicy,
|
|
552
|
+
isCurrent = false,
|
|
552
553
|
): string {
|
|
553
554
|
const counts = countByStatus(tasks);
|
|
554
555
|
const total = tasks.length;
|
|
555
556
|
const complete = total > 0 && counts.complete === total;
|
|
557
|
+
const current = isCurrent && !complete;
|
|
556
558
|
|
|
557
559
|
const chips = [
|
|
558
560
|
sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
|
|
@@ -578,7 +580,7 @@ function renderFeature(
|
|
|
578
580
|
.join("")}</ul>`
|
|
579
581
|
: "";
|
|
580
582
|
|
|
581
|
-
return `<section class="card feature${complete ? " is-complete" : ""}">
|
|
583
|
+
return `<section class="card feature${complete ? " is-complete" : ""}${current ? " is-current" : ""}">
|
|
582
584
|
<div class="feature-head">
|
|
583
585
|
<div class="feature-id">
|
|
584
586
|
<h2 class="feature-name">${esc(feature.name ?? feature.id ?? "")}</h2>
|
|
@@ -609,14 +611,16 @@ function renderGoalGroup(
|
|
|
609
611
|
indexByKey: ReadonlyMap<string, number>,
|
|
610
612
|
show: { showGoal: boolean; showSprints: boolean },
|
|
611
613
|
display: DisplayPolicy,
|
|
614
|
+
activeFeatureId?: string | null,
|
|
612
615
|
): string {
|
|
616
|
+
const activeGoal = activeFeatureId ? group.sprints.some((sg) => sg.features.some((f) => f.id === activeFeatureId)) : false;
|
|
613
617
|
const sprints = group.sprints
|
|
614
|
-
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display))
|
|
618
|
+
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display, activeFeatureId))
|
|
615
619
|
.join("");
|
|
616
620
|
|
|
617
621
|
if (!show.showGoal || !group.goal) return sprints;
|
|
618
622
|
|
|
619
|
-
return `<details class="tier tier-goal" open>
|
|
623
|
+
return `<details class="tier tier-goal${activeGoal ? " is-current" : ""}" open>
|
|
620
624
|
<summary class="tier-head">
|
|
621
625
|
<span class="tier-kind">goal</span>
|
|
622
626
|
<span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
|
|
@@ -633,10 +637,12 @@ function renderSprintGroup(
|
|
|
633
637
|
indexByKey: ReadonlyMap<string, number>,
|
|
634
638
|
showSprints: boolean,
|
|
635
639
|
display: DisplayPolicy,
|
|
640
|
+
activeFeatureId?: string | null,
|
|
636
641
|
): string {
|
|
642
|
+
const activeSprint = activeFeatureId ? group.features.some((f) => f.id === activeFeatureId) : false;
|
|
637
643
|
const features = display.levels.feature
|
|
638
644
|
? group.features
|
|
639
|
-
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display))
|
|
645
|
+
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display, f.id === activeFeatureId))
|
|
640
646
|
.join("")
|
|
641
647
|
: // Hiding the feature card must not hide its tasks: they move up into the
|
|
642
648
|
// sprint, which is what "hide features" has to mean on a page whose whole
|
|
@@ -645,7 +651,7 @@ function renderSprintGroup(
|
|
|
645
651
|
|
|
646
652
|
if (!showSprints || !group.sprint) return features;
|
|
647
653
|
|
|
648
|
-
return `<details class="tier tier-sprint" open>
|
|
654
|
+
return `<details class="tier tier-sprint${activeSprint ? " is-current" : ""}" open>
|
|
649
655
|
<summary class="tier-head">
|
|
650
656
|
<span class="tier-kind">sprint</span>
|
|
651
657
|
<span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
|
|
@@ -965,11 +971,17 @@ table.tasks tr:last-child td{border-bottom:0}
|
|
|
965
971
|
.task-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}
|
|
966
972
|
.task-desc{overflow-wrap:anywhere}
|
|
967
973
|
.row-complete .task-desc{color:var(--muted)}
|
|
968
|
-
.row.is-active{background:rgba(var(--rgb-active),.07)}
|
|
974
|
+
.row.is-active{background:rgba(var(--rgb-active),.07);animation:taskBlink 1.2s ease-in-out infinite}
|
|
969
975
|
.row.is-active .task-desc{font-weight:600}
|
|
970
976
|
.row.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-active)}
|
|
971
977
|
.row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
|
|
972
978
|
.row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
|
|
979
|
+
@keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
|
|
980
|
+
/* While-developed blinking for the whole current branch */
|
|
981
|
+
.tier.is-current,.feature.is-current{animation:cardPulse 1.4s ease-in-out infinite}
|
|
982
|
+
.tier.is-current .tier-name,.feature.is-current .feature-name{animation:textPulse 1.2s ease-in-out infinite}
|
|
983
|
+
@keyframes cardPulse{0%,100%{box-shadow:var(--shadow)}50%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.22),var(--shadow)}}
|
|
984
|
+
@keyframes textPulse{0%,100%{opacity:1}50%{opacity:.65}}
|
|
973
985
|
.row-blocked{background:rgba(var(--rgb-blocked),.07)}
|
|
974
986
|
.row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
|
|
975
987
|
.deps{color:var(--faint);white-space:nowrap}
|
|
@@ -1188,6 +1200,9 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1188
1200
|
// everything.
|
|
1189
1201
|
const display = normalizeDisplay(state.display ?? defaultDisplay());
|
|
1190
1202
|
const groups = groupPlan(list);
|
|
1203
|
+
// Which feature/sprint/goal is currently being worked (for blinking).
|
|
1204
|
+
const activeTask = tasks.find((t) => t.status === "in_progress" || t.status === "rework") ?? tasks.find((t) => t.status === "pending") ?? null;
|
|
1205
|
+
const activeFeatureId = activeTask?.featureId ?? null;
|
|
1191
1206
|
const body = features.length
|
|
1192
1207
|
? groups
|
|
1193
1208
|
.map((group) =>
|
|
@@ -1200,6 +1215,7 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1200
1215
|
showSprints: display.levels.sprint && sprints.length > 0,
|
|
1201
1216
|
},
|
|
1202
1217
|
display,
|
|
1218
|
+
activeFeatureId,
|
|
1203
1219
|
),
|
|
1204
1220
|
)
|
|
1205
1221
|
.join("")
|
package/src/ui/wizard.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* leaves a project in a state nobody chose.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { Prompter } from "./config.ts";
|
|
26
|
+
import type { ModelChoice, Prompter } from "./config.ts";
|
|
27
27
|
import type { DisplayPolicy, Phase } from "../core/types.ts";
|
|
28
28
|
import {
|
|
29
29
|
BRIEF_QUESTION,
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
} from "../workflow.ts";
|
|
51
51
|
import { DEFAULT_ENABLED_PHASES } from "../core/types.ts";
|
|
52
52
|
import { defaultDisplay, listDisplays, normalizeDisplay, saveDisplay } from "./display.ts";
|
|
53
|
+
import type { ThinkingLevel } from "../modelRouter.ts";
|
|
53
54
|
|
|
54
55
|
export type WizardOptions = {
|
|
55
56
|
prompt: Prompter;
|
|
@@ -59,6 +60,8 @@ export type WizardOptions = {
|
|
|
59
60
|
skipConfirm?: boolean;
|
|
60
61
|
/** Where saved workflows and templates live. Tests point this elsewhere. */
|
|
61
62
|
env?: NodeJS.ProcessEnv;
|
|
63
|
+
/** Models pi can use — offered for each tier and for consulting. */
|
|
64
|
+
models?: () => ModelChoice[] | Promise<ModelChoice[]>;
|
|
62
65
|
};
|
|
63
66
|
|
|
64
67
|
export type WizardResult =
|
|
@@ -76,6 +79,84 @@ function line(label: string, help: string): string {
|
|
|
76
79
|
return `${label} — ${help}`;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
const MODEL_STEP_TITLE = "Which models for the difficulty tiers, and the consulting master?";
|
|
83
|
+
const INHERIT = "(use pi's current model)";
|
|
84
|
+
const CUSTOM_MODEL = "type a model id…";
|
|
85
|
+
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
86
|
+
const THINK_INHERIT = "(inherit)";
|
|
87
|
+
|
|
88
|
+
async function pickModelChoice(prompt: Prompter, title: string, models: ModelChoice[], current: string): Promise<string | undefined> {
|
|
89
|
+
if (models.length === 0) {
|
|
90
|
+
const typed = await prompt.input(title, current || "provider/model-id");
|
|
91
|
+
return typed;
|
|
92
|
+
}
|
|
93
|
+
const rows = models.map((m) => (m.ref === current ? `${m.label} ← current` : m.label));
|
|
94
|
+
const picked = await prompt.select(title, [INHERIT, ...rows, CUSTOM_MODEL]);
|
|
95
|
+
if (picked === undefined) return undefined;
|
|
96
|
+
if (picked === INHERIT) return "";
|
|
97
|
+
if (picked === CUSTOM_MODEL) {
|
|
98
|
+
const typed = await prompt.input(`${title} — model id`, current || "provider/model-id");
|
|
99
|
+
return typed;
|
|
100
|
+
}
|
|
101
|
+
const model = models[rows.indexOf(picked)];
|
|
102
|
+
return model?.ref;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pickThinkingLevel(prompt: Prompter, title: string): Promise<ThinkingLevel | "" | undefined> {
|
|
106
|
+
const picked = await prompt.select(title, [THINK_INHERIT, ...THINKING_LEVELS]);
|
|
107
|
+
if (picked === undefined) return undefined;
|
|
108
|
+
if (picked === THINK_INHERIT) return "";
|
|
109
|
+
return picked as ThinkingLevel;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function pickModelsStep(prompt: Prompter, modelsFn?: WizardOptions["models"]): Promise<{ router: NonNullable<IntakeAnswers["router"]> } | undefined> {
|
|
113
|
+
const models = modelsFn ? (await modelsFn()) ?? [] : [];
|
|
114
|
+
// First ask whether routing is even wanted — most runs don't need it, and
|
|
115
|
+
// skipping the 8 follow-up questions keeps the wizard short. Old tests that
|
|
116
|
+
// don't know about this step get routing off by default so they keep passing.
|
|
117
|
+
const ROUTE_ON = "yes — pick models per tier";
|
|
118
|
+
const ROUTE_OFF = "no — use pi's current model for everything";
|
|
119
|
+
const enablePick = await prompt.select("Route work by difficulty to different models?", [ROUTE_ON, ROUTE_OFF]);
|
|
120
|
+
if (enablePick === undefined) {
|
|
121
|
+
// No answer scripted (e.g. an older test) → treat as "off" so the
|
|
122
|
+
// wizard doesn't look cancelled to callers that only scripted four steps.
|
|
123
|
+
return { router: { enabled: false, byDifficulty: { easy: "", moderate: "", difficult: "" }, thinkingByDifficulty: { easy: "", moderate: "", difficult: "" }, master: "", thinkingMaster: "", default: "", thinkingDefault: "" } };
|
|
124
|
+
}
|
|
125
|
+
if (enablePick === ROUTE_OFF) {
|
|
126
|
+
return { router: { enabled: false, byDifficulty: { easy: "", moderate: "", difficult: "" }, thinkingByDifficulty: { easy: "", moderate: "", difficult: "" }, master: "", thinkingMaster: "", default: "", thinkingDefault: "" } };
|
|
127
|
+
}
|
|
128
|
+
const tiers = ["easy", "moderate", "difficult"] as const;
|
|
129
|
+
const byDifficulty: Record<string, string> = {};
|
|
130
|
+
const thinkingByDifficulty: Partial<Record<string, ThinkingLevel | "">> = {};
|
|
131
|
+
for (const tier of tiers) {
|
|
132
|
+
const model = await pickModelChoice(prompt, `${tier.toUpperCase()} tier — model`, models, "");
|
|
133
|
+
if (model === undefined) return undefined;
|
|
134
|
+
byDifficulty[tier] = model;
|
|
135
|
+
const thinking = await pickThinkingLevel(prompt, `${tier.toUpperCase()} tier — thinking level`);
|
|
136
|
+
if (thinking === undefined) return undefined;
|
|
137
|
+
thinkingByDifficulty[tier] = thinking;
|
|
138
|
+
}
|
|
139
|
+
const masterModel = await pickModelChoice(prompt, "Consulting master — model (used only when the ladder is exhausted)", models, "");
|
|
140
|
+
if (masterModel === undefined) return undefined;
|
|
141
|
+
const masterThinking = await pickThinkingLevel(prompt, "Consulting master — thinking level");
|
|
142
|
+
if (masterThinking === undefined) return undefined;
|
|
143
|
+
const defaultModel = await pickModelChoice(prompt, "Default — fallback when nothing more specific matches", models, "");
|
|
144
|
+
if (defaultModel === undefined) return undefined;
|
|
145
|
+
const defaultThinking = await pickThinkingLevel(prompt, "Default — thinking level fallback");
|
|
146
|
+
if (defaultThinking === undefined) return undefined;
|
|
147
|
+
return {
|
|
148
|
+
router: {
|
|
149
|
+
enabled: true,
|
|
150
|
+
byDifficulty,
|
|
151
|
+
thinkingByDifficulty,
|
|
152
|
+
master: masterModel ?? "",
|
|
153
|
+
thinkingMaster: masterThinking ?? "",
|
|
154
|
+
default: defaultModel ?? "",
|
|
155
|
+
thinkingDefault: defaultThinking ?? "",
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
79
160
|
export async function runIntakeWizard(options: WizardOptions): Promise<WizardResult> {
|
|
80
161
|
const { prompt, env } = options;
|
|
81
162
|
|
|
@@ -102,11 +183,15 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
|
|
|
102
183
|
| "phase"
|
|
103
184
|
| "task";
|
|
104
185
|
|
|
105
|
-
// -- 4.
|
|
186
|
+
// -- 4. models ----------------------------------------------------------
|
|
187
|
+
const modelsAnswer = await pickModelsStep(prompt, options.models);
|
|
188
|
+
if (modelsAnswer === undefined) return { cancelled: true };
|
|
189
|
+
|
|
190
|
+
// -- 5. display ---------------------------------------------------------
|
|
106
191
|
const display = await pickDisplay(prompt, env);
|
|
107
192
|
if (display === undefined) return { cancelled: true };
|
|
108
193
|
|
|
109
|
-
const answers: IntakeAnswers = { workflow, brief, handoff, display };
|
|
194
|
+
const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router };
|
|
110
195
|
const plan = planIntake(answers);
|
|
111
196
|
|
|
112
197
|
if (options.skipConfirm) return { cancelled: false, plan, answers };
|