infinity-harness 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ 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
+
7
24
  ## [2.5.0] — 2026-08-25
8
25
 
9
26
  ### Added
@@ -247,7 +247,9 @@ export default function (pi: ExtensionAPI): void {
247
247
  const briefText = async (dir: string, includeGate = false): Promise<string> => {
248
248
  const { config } = loadConfig(dir);
249
249
  const brief = await buildBrief(dir, { includeGate });
250
- return renderBrief(brief, config);
250
+ const routed = await routingSummaryForBrief(dir).catch(() => null as string | null);
251
+ const text = renderBrief(brief, config);
252
+ return routed ? `${text}\n\n${routed}` : text;
251
253
  };
252
254
 
253
255
  // -- configuration --------------------------------------------------------
@@ -295,6 +297,101 @@ 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
397
  /** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
@@ -544,10 +641,15 @@ export default function (pi: ExtensionAPI): void {
544
641
  view = defaultView();
545
642
  refreshWidget(ctx);
546
643
  installTerminalShortcuts(ctx);
644
+ const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
547
645
  const { config } = loadConfig(dir);
548
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 {} })();
549
651
 
550
- const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
652
+
551
653
  const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
552
654
  const armed = run?.armed === true;
553
655
 
@@ -620,11 +722,18 @@ export default function (pi: ExtensionAPI): void {
620
722
  if (!sessionLive) return;
621
723
  const dir = projectDir(ctx);
622
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 {}
623
730
  try {
624
731
  const contract = harnessContract(dir);
625
732
  if (!contract) return;
626
733
  const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
627
- return { systemPrompt: `${base}\n\n${contract}` };
734
+ const routed = await routingSummaryForBrief(dir);
735
+ const suffix = routed ? `\n\n${routed}` : "";
736
+ return { systemPrompt: `${base}${suffix}\n\n${contract}` };
628
737
  } catch {
629
738
  return;
630
739
  }
@@ -1090,33 +1199,40 @@ export default function (pi: ExtensionAPI): void {
1090
1199
  const lines = gate.checks
1091
1200
  .map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
1092
1201
  .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".
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.
1098
1211
  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
- };
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
+ }
1117
1233
  }
1118
- }
1119
- } catch {}
1234
+ } catch {}
1235
+ }
1120
1236
  }
1121
1237
  return {
1122
1238
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [