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 +12 -0
- package/extensions/infinity-harness/index.ts +4 -2
- package/package.json +2 -2
- package/src/core/config.ts +65 -4
- package/src/core/init.ts +33 -0
- package/src/supervisor.ts +18 -3
- package/src/ui/wizard.ts +1 -1
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
|
|
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.
|
|
4
|
-
"description": "A pi agent extension that runs a gated build pipeline unattended
|
|
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",
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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;
|