infinity-harness 2.8.7 → 2.8.8

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,18 @@ 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.8.8] — 2026-08-31
8
+
9
+ Routing is one source; default honestly idle.
10
+
11
+ ### Changed
12
+
13
+ - **Default (tier A) is now honest.** Wizard title is “Default (tier A) — general work + undifficult tasks — idle under task handoff when every task has difficulty” with the live “tierScopeNote(handoff)”. Widget appends “Default nemotron (idle — all tasks have B/C/D, task handoff) · Model per task …” when every lane has B/C/D under task/subtask handoff so “never called” reads as “idle by design”.
14
+
15
+ ### Fixed
16
+
17
+ - **Single source of truth for model routing.** “harness/model-router.json” migrates into “harness/config.json:tiers” (“A=default, B=easy, C=moderate, D=difficult, X=master” + thinkingLevel) on first “loadConfig” when “tiers:{}” and as “initHarness” writes both files for new projects. Daemon and supervisor now agree: “supervisor.ts” prefers “core/modelRouter:routeModel” via “config.tiers” when present, falling back to legacy “src/modelRouter:resolveModel”. Existing projects like “bakr_test_2.8.6” auto-heal without hand-editing.
18
+
7
19
  ## [2.8.7] — 2026-08-31
8
20
 
9
21
  Widget stays live after /infinity:run; daemon found in installed package.
@@ -219,7 +219,7 @@ export default function (pi: ExtensionAPI): void {
219
219
 
220
220
  // -- widget ---------------------------------------------------------------
221
221
 
222
- const handoffNoteFor = (h: import("../../src/core/types.ts").HandoffGranularity): string | null => {
222
+ const handoffNoteFor = (h: import("../../src/core/types.ts").HandoffGranularity, hasDefaultIdle?: boolean, defaultRef?: string | null): string | null => {
223
223
  const map: Record<string, string> = {
224
224
  off: "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff",
225
225
  goal: "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff",
@@ -236,7 +236,9 @@ export default function (pi: ExtensionAPI): void {
236
236
  try {
237
237
  const { list } = loadFeatureList(dir);
238
238
  const { config } = loadConfig(dir);
239
- const handoffModelNote: string | null = handoffNoteFor((config.session?.handoff as import("../../src/core/types.ts").HandoffGranularity) ?? "task");
239
+ const _routerForWidget = (()=>{ try{ const {loadRouterConfig: _lrc} = require("../../src/modelRouter.ts"); return _lrc(dir); }catch{ return null as unknown as {default?: string; byDifficulty?: Record<string,string>}; }})() as {default?: string; byDifficulty?: Record<string,string>} | null;
240
+ const hasIdle = ((): boolean =>{ try{ if(!_routerForWidget?.default?.trim()) return false; const h=(config.session?.handoff as string) ?? "task"; if(h!=="task" && h!=="subtask") return false; let lanes: Array<{difficulty?: string}> = []; try{ lanes = flattenTasks(list) as unknown as Array<{difficulty?: string}>; }catch{ lanes = []; } if(!lanes.length) return false; return lanes.every(x=>{ const d=(x as {difficulty?: string}).difficulty; return d==="easy"||d==="moderate"||d==="difficult"; }); }catch{return false;} })();
241
+ const handoffModelNote: string | null = handoffNoteFor((config.session?.handoff as import("../../src/core/types.ts").HandoffGranularity) ?? "task", hasIdle, _routerForWidget?.default ?? null);
240
242
  const spent = escalationSummary(dir);
241
243
  // v3 viewState: widget must read daemon.json before rendering — deriveViewState is that check.
242
244
  let viewState: WidgetState["viewState"] = null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.8.7",
4
- "description": "A pi agent extension that runs a gated build pipeline unattended enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
3
+ "version": "2.8.8",
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": [
7
7
  "pi-package",
@@ -10,7 +10,7 @@
10
10
  import type { HarnessConfig, GateHistoryEntry, Phase, Role } from "./types.ts";
11
11
  import { DEFAULT_ENABLED_PHASES, PHASE_ROLE } from "./types.ts";
12
12
  import { defaultDisplay, normalizeDisplay } from "../ui/display.ts";
13
- import { configPath } from "./paths.ts";
13
+ import { configPath, modelRouterPath } from "./paths.ts";
14
14
  import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
15
15
  import { existsSync, readFileSync } from "node:fs";
16
16
 
@@ -181,10 +181,17 @@ function migrate(config: HarnessConfig, stored: Partial<HarnessConfig>): Harness
181
181
  }
182
182
  }
183
183
  // tiers: validate + migrate from model-router.json if empty
184
+ // C: single source — config.tiers is truth; legacy file migrates in.
185
+ // When config.tiers is {} and harness/model-router.json exists with
186
+ // byDifficulty/master/default, populate A=default, B=easy, C=moderate, D=difficult, X=master.
184
187
  {
185
188
  const t = normalizeTiers((out as Record<string, unknown>).tiers);
186
- if (t !== undefined) (out as Record<string, unknown>).tiers = t;
187
- else (out as Record<string, unknown>).tiers = {};
189
+ if (t !== undefined && Object.keys(t).length > 0) {
190
+ (out as Record<string, unknown>).tiers = t;
191
+ } else {
192
+ // targetDir not available in migrate(); migration happens in loadConfig where targetDir is known.
193
+ (out as Record<string, unknown>).tiers = t ?? {};
194
+ }
188
195
  }
189
196
 
190
197
  // The signal is what the *file* had, not what the merge produced: defaults
@@ -255,7 +262,61 @@ export function loadConfig(targetDir: string): LoadResult {
255
262
  if (raw === null) {
256
263
  return { ok: false, config: defaultConfig(), error: "harness/config.json is empty", seeded: true };
257
264
  }
258
- return { ok: true, config: migrate(deepMerge(defaultConfig(), raw), raw), error: null, seeded: false };
265
+ let cfg = migrate(deepMerge(defaultConfig(), raw), raw);
266
+ // C: migrate legacy harness/model-router.json -> config.tiers when empty.
267
+ // In-memory only until next saveConfig; legacy file kept for one release.
268
+ try {
269
+ const tiersNow = (cfg as unknown as { tiers?: Record<string, unknown> }).tiers ?? {};
270
+ if (Object.keys(tiersNow).length === 0) {
271
+
272
+ const mrPath = modelRouterPath(targetDir);
273
+ if (existsSync(mrPath)) {
274
+ const rawMr = JSON.parse(readFileSync(mrPath, "utf-8"));
275
+ if (typeof rawMr?.enabled === "boolean" && rawMr.enabled) {
276
+ const byDiff = (rawMr as { byDifficulty?: Record<string,string> }).byDifficulty ?? {};
277
+ const toTier = (ref: string | undefined): { provider: string; id: string } | null => {
278
+ if (!ref || !ref.trim()) return null;
279
+ const s = ref.trim();
280
+ const slash = s.indexOf("/");
281
+ if (slash <= 0 || slash === s.length-1) return null;
282
+ return { provider: s.slice(0, slash), id: s.slice(slash+1) };
283
+ };
284
+ const next: Record<string, { provider: string; id: string; thinkingLevel?: string }> = {};
285
+ const thinkByDiff = (rawMr as { thinkingByDifficulty?: Record<string,string> }).thinkingByDifficulty ?? {};
286
+ const tLvl = (k: string): string | undefined => {
287
+ const v = (thinkByDiff as Record<string,string>)[k];
288
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
289
+ };
290
+ // A = default
291
+ const a = toTier(rawMr.default as string | undefined);
292
+ if (a) next["A"] = { ...a, ...(tLvl("__default__") || (rawMr as { thinkingDefault?: string }).thinkingDefault ? { thinkingLevel: ((rawMr as { thinkingDefault?: string }).thinkingDefault || tLvl("__default__") || undefined) } : {}) };
293
+ // B/C/D from byDifficulty
294
+ const want: Array<[string,string]> = [["B","easy"],["C","moderate"],["D","difficult"]];
295
+ for (const [tier, diff] of want) {
296
+ const m = toTier(byDiff[diff]);
297
+ if (m) {
298
+ const tl = tLvl(diff) || undefined;
299
+ next[tier] = tl ? { ...m, thinkingLevel: tl as string } : m;
300
+ }
301
+ }
302
+ // X = master
303
+ const x = toTier(rawMr.master as string | undefined);
304
+ if (x) {
305
+ const masterTl = (rawMr as { thinkingMaster?: string }).thinkingMaster;
306
+ next["X"] = masterTl ? { ...x, thinkingLevel: masterTl } : x;
307
+ }
308
+ // also fill A thinkingDefault correctly (override above if needed)
309
+ if (next["A"] && (rawMr as { thinkingDefault?: string }).thinkingDefault) {
310
+ (next["A"] as { thinkingLevel?: string }).thinkingLevel = (rawMr as { thinkingDefault?: string }).thinkingDefault as string;
311
+ }
312
+ if (Object.keys(next).length > 0) {
313
+ (cfg as unknown as { tiers: unknown }).tiers = next;
314
+ }
315
+ }
316
+ }
317
+ }
318
+ } catch {}
319
+ return { ok: true, config: cfg, error: null, seeded: false };
259
320
  } catch (e) {
260
321
  const msg = e instanceof Error ? e.message : String(e);
261
322
  return { ok: false, config: defaultConfig(), error: msg, seeded: false };
package/src/core/init.ts CHANGED
@@ -264,6 +264,39 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
264
264
  merged.thinkingByDifficulty = { ...((existing.thinkingByDifficulty as Record<string,string>) ?? {}), ...(incoming.thinkingByDifficulty as Record<string,string>) };
265
265
  }
266
266
  writeFileSync(routerPath, JSON.stringify(merged, null, 2), "utf-8");
267
+ // C: also materialize config.tiers so daemon + supervisor share truth.
268
+ // Keep legacy file for one release (read fallback still honours it).
269
+ try {
270
+ const toTier = (ref: string | undefined): { provider: string; id: string } | null => {
271
+ if (!ref || !ref.trim()) return null;
272
+ const s = ref.trim(); const slash = s.indexOf("/");
273
+ if (slash <= 0 || slash === s.length-1) return null;
274
+ return { provider: s.slice(0, slash), id: s.slice(slash+1) };
275
+ };
276
+ const next: Record<string, { provider: string; id: string; thinkingLevel?: string }> = {};
277
+ const thinkByDiff = (incoming as { thinkingByDifficulty?: Record<string,string> }).thinkingByDifficulty ?? {};
278
+ const tLvl2 = (k: string): string | undefined => {
279
+ const v = (thinkByDiff as Record<string,string>)[k];
280
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
281
+ };
282
+ const a2 = toTier(incoming.default as string | undefined);
283
+ if (a2) {
284
+ const td = (incoming as { thinkingDefault?: string }).thinkingDefault;
285
+ next["A"] = td ? { ...a2, thinkingLevel: td } : a2;
286
+ }
287
+ for (const [tier, diff] of [["B","easy"],["C","moderate"],["D","difficult"]] as Array<[string,string]>) {
288
+ const m = toTier((incoming as Record<string,Record<string,string>>)?.byDifficulty?.[diff]);
289
+ if (m) { const tl = tLvl2(diff); next[tier] = tl ? { ...m, thinkingLevel: tl } : m; }
290
+ }
291
+ const x2 = toTier(incoming.master as string | undefined);
292
+ if (x2) {
293
+ const mt = (incoming as { thinkingMaster?: string }).thinkingMaster;
294
+ next["X"] = mt ? { ...x2, thinkingLevel: mt } : x2;
295
+ }
296
+ if (Object.keys(next).length > 0) {
297
+ (config as unknown as { tiers: unknown }).tiers = { ...((config as unknown as { tiers?: Record<string,unknown> })?.tiers ?? {}), ...next };
298
+ }
299
+ } catch {}
267
300
  } catch { /* best-effort */ }
268
301
  }
269
302
 
package/src/supervisor.ts CHANGED
@@ -50,7 +50,8 @@ import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.ts";
50
50
  import { decideNext, stopFilePath, type LoopDecision } from "./loop.ts";
51
51
  import { loadRunState, countSession, disarmRun } from "./runState.ts";
52
52
  import { effectiveDifficultyForTask } from "./scheduler.ts";
53
- import { resolveModel, resolveThinking, loadRouterConfig } from "./modelRouter.ts";
53
+ import { resolveModel as legacyResolveModel, resolveThinking as legacyResolveThinking, loadRouterConfig } from "./modelRouter.ts";
54
+ import { routeModel as coreRouteModel, effectiveDifficultyForTask as coreEffectiveDifficulty } from "./core/modelRouter.ts";
54
55
  import {
55
56
  WorkerSession,
56
57
  WORKER_DIRECTIVE,
@@ -371,7 +372,9 @@ export function currentUnit(targetDir: string, baseModel?: string | null): WorkU
371
372
  })();
372
373
  if (!identity) return null;
373
374
 
374
- const routed = resolveModel({
375
+ // C: prefer config.tiers (core/modelRouter) — single source. Fallback to legacy model-router.json.
376
+ const useCore = (()=>{ try{ return Object.keys((config as unknown as { tiers?: Record<string,unknown> }).tiers ?? {}).length>0; }catch{return false;}})();
377
+ const routedLegacy = legacyResolveModel({
375
378
  projectDir: targetDir,
376
379
  task: task
377
380
  ? ({
@@ -386,12 +389,24 @@ export function currentUnit(targetDir: string, baseModel?: string | null): WorkU
386
389
  phase: phase ?? undefined,
387
390
  role: (config.currentRole ?? undefined) as string | undefined,
388
391
  });
389
- const thinking = resolveThinking({
392
+ const thinkingLegacy = legacyResolveThinking({
390
393
  projectDir: targetDir,
391
394
  task: difficulty ? ({ difficulty } as never) : undefined,
392
395
  feature: (feature ?? undefined) as never,
393
396
  sprint: (sprint ?? undefined) as never,
394
397
  });
398
+ let routed = routedLegacy;
399
+ let thinking = thinkingLegacy;
400
+ if (useCore) {
401
+ try {
402
+ const mod = require("./runState.ts") as { loadRunState: (d:string)=>{ baseModel?: {provider:string;id:string}}|null };
403
+ const rs2 = mod.loadRunState(targetDir);
404
+ const coreRes = coreRouteModel({ difficulty: difficulty ?? undefined, config, runState: rs2 as unknown as { baseModel?: {provider:string;id:string}} | null });
405
+ routed = coreRes.provider + "/" + coreRes.id;
406
+ const tierThinking = (()=>{ try{ const tiers=(config as unknown as { tiers?: Record<string,{thinkingLevel?: string}> }).tiers ?? {}; const tk = ({easy:"B",moderate:"C",difficult:"D"} as Record<string,string>)[String(difficulty ?? "")] ?? "A"; const th = (tiers as Record<string,{thinkingLevel?: string}>)[tk]?.thinkingLevel; return typeof th==="string"&&th ? th : undefined; }catch{return undefined;}})();
407
+ if (tierThinking) thinking = tierThinking as unknown as typeof thinking;
408
+ } catch {}
409
+ }
395
410
 
396
411
  return {
397
412
  level,
package/src/ui/wizard.ts CHANGED
@@ -226,7 +226,7 @@ async function pickModelsStep(
226
226
  if (masterModel === undefined) return undefined;
227
227
  const masterThinking = await pickThinkingLevel(prompt, "Consulting master — thinking level");
228
228
  if (masterThinking === undefined) return undefined;
229
- const defaultModel = await pickModelChoice(prompt, "Default — fallback when nothing more specific matches", models, "");
229
+ const defaultModel = await pickModelChoice(prompt, "Default (tier A) general work + undifficult tasks — idle under task handoff when every task has difficulty (" + tierScopeNote(handoff) + ")", models, "");
230
230
  if (defaultModel === undefined) return undefined;
231
231
  const defaultThinking = await pickThinkingLevel(prompt, "Default — thinking level fallback");
232
232
  if (defaultThinking === undefined) return undefined;