infinity-harness 2.8.6 → 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 +24 -0
- package/extensions/infinity-harness/index.ts +149 -17
- 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,30 @@ 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
|
+
|
|
19
|
+
## [2.8.7] — 2026-08-31
|
|
20
|
+
|
|
21
|
+
Widget stays live after /infinity:run; daemon found in installed package.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Widget froze on wizard 9/9 after /infinity:run.** Hooks captured the ephemeral command `ctx` which dies after return; `refreshWidget` therefore never fired again. Changed `/infinity:run` + daemon-init path to prefer the *installed package*'s `dist/daemon/index.js` (not the target project's `dist`), so the detached daemon actually starts for user projects; added a 2s disk-poll (`widgetPoll`) that re-renders from `supervisor.json/daemon.json/plan.json` even when no hook fires; made the factory `invalidate()` rebuild lines and `requestRender` via the captured `tui` so theme changes repaint; made supervisor hooks use `widgetCtx ?? ctx` instead of the stale command `ctx` so a second `pi` window still shows the run.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `dashboardUrl` now shows the real daemon port from start (`harness/daemon.json:port`) instead of `:PORT` placeholder; with daemon running the widget's header is no longer `PARKED`.
|
|
30
|
+
|
|
7
31
|
## [2.8.6] — 2026-08-31
|
|
8
32
|
|
|
9
33
|
Parked stays parked.
|
|
@@ -31,7 +31,7 @@ import { runChecks } from "../../src/core/gates.ts";
|
|
|
31
31
|
import { advancePhase } from "../../src/core/phases.ts";
|
|
32
32
|
import { configPath } from "../../src/core/paths.ts";
|
|
33
33
|
import { readJsonSafe } from "../../src/core/fsx.ts";
|
|
34
|
-
import { resolve as resolvePath } from "node:path";
|
|
34
|
+
import { resolve as resolvePath, dirname } from "node:path";
|
|
35
35
|
import { deriveViewState as deriveViewStateSync } from "../../src/ui/viewState.ts";
|
|
36
36
|
import { runStatePath as runStatePathSync } from "../../src/core/paths.ts";
|
|
37
37
|
import { withLock } from "../../src/core/lock.ts";
|
|
@@ -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;
|
|
@@ -320,6 +322,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
320
322
|
};
|
|
321
323
|
|
|
322
324
|
let widgetCtx: ExtensionContext | null = null;
|
|
325
|
+
// A+C: live widget — re-render from disk even when hooks' ctx is stale.
|
|
326
|
+
// Pi's factory variant has no external trigger: invalidate() is only called
|
|
327
|
+
// when pi decides to (theme change / focus). The panel therefore freezes
|
|
328
|
+
// until *some* event re-calls setWidget. Own a (tui,theme) capture so we
|
|
329
|
+
// can re-render in place and poll the on-disk state.
|
|
330
|
+
let liveWidget: ({ render: () => string[]; invalidate(): void; handleInput(): void }) | null = null;
|
|
331
|
+
let liveCtx: ExtensionContext | null = null;
|
|
332
|
+
let widgetPoll: ReturnType<typeof setInterval> | null = null;
|
|
323
333
|
const refreshWidget = (ctx?: ExtensionContext): void => {
|
|
324
334
|
if (ctx) widgetCtx = ctx;
|
|
325
335
|
const useCtx = ctx ?? widgetCtx;
|
|
@@ -334,12 +344,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
334
344
|
// Single panel aboveEditor; belowEditor intentionally left empty so
|
|
335
345
|
// there is only one infinity panel on screen (the bottom one was the
|
|
336
346
|
// same widget rendered as belowEditor in a prior install and truncated).
|
|
337
|
-
type WidgetFactory = () => { render: () => string[]; invalidate(): void; handleInput(): void };
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
347
|
+
type WidgetFactory = (tui: unknown, theme: unknown) => { render: () => string[]; invalidate(): void; handleInput(): void };
|
|
348
|
+
// Preserve the captured tui/theme so a background tick can request a
|
|
349
|
+
// redraw without needing a fresh ctx (command ctx dies after return).
|
|
350
|
+
let capturedTui: unknown = null;
|
|
351
|
+
let capturedTheme: unknown = null;
|
|
352
|
+
const factory: WidgetFactory = (tui, theme) => {
|
|
353
|
+
capturedTui = tui;
|
|
354
|
+
capturedTheme = theme;
|
|
355
|
+
const comp = {
|
|
356
|
+
render: () => lines,
|
|
357
|
+
invalidate() {
|
|
358
|
+
// Pi may call this on theme change — re-compute lines from disk.
|
|
359
|
+
try {
|
|
360
|
+
const fresh = widgetStateFor(dir);
|
|
361
|
+
if (fresh) {
|
|
362
|
+
const nl = renderWidget(fresh, { width: 76, styler, glyphs });
|
|
363
|
+
(comp as { render: () => string[] }).render = () => nl;
|
|
364
|
+
(tui as { requestRender?: () => void })?.requestRender?.();
|
|
365
|
+
}
|
|
366
|
+
} catch {}
|
|
367
|
+
},
|
|
368
|
+
handleInput() {},
|
|
369
|
+
};
|
|
370
|
+
liveWidget = comp;
|
|
371
|
+
liveCtx = useCtx;
|
|
372
|
+
// Keep the URL honest: daemon picked port 0 on start.
|
|
373
|
+
// No extra tick needed — invalidate() + polling covers it.
|
|
374
|
+
return comp;
|
|
375
|
+
};
|
|
343
376
|
(useCtx.ui.setWidget as unknown as (k: string, f: WidgetFactory) => void)(WIDGET_KEY, factory);
|
|
344
377
|
// Explicitly clear any stale belowEditor instance from a prior install.
|
|
345
378
|
try {
|
|
@@ -354,6 +387,87 @@ export default function (pi: ExtensionAPI): void {
|
|
|
354
387
|
/* the widget is never worth breaking a turn over */
|
|
355
388
|
}
|
|
356
389
|
};
|
|
390
|
+
const pokeWidget = (): void => {
|
|
391
|
+
try {
|
|
392
|
+
const ctx = liveCtx ?? widgetCtx;
|
|
393
|
+
if (!ctx || workerProcess) return;
|
|
394
|
+
const dir = projectDir(ctx);
|
|
395
|
+
// Only poll when there is a panel (harness project and we opened one)
|
|
396
|
+
if (!liveWidget) return;
|
|
397
|
+
const fresh = widgetStateFor(dir);
|
|
398
|
+
if (!fresh) return;
|
|
399
|
+
const nl = renderWidget(fresh, { width: 76, styler, glyphs });
|
|
400
|
+
// Swap render in place then ask tui to paint.
|
|
401
|
+
(liveWidget as { render: () => string[] }).render = () => nl;
|
|
402
|
+
try {
|
|
403
|
+
// The captured tui is the authority; fall back to ctx.ui if needed.
|
|
404
|
+
// We do not have direct tui ref here — use the widget invalidate path
|
|
405
|
+
// via re-setting the factory once, then rely on invalidate for rest.
|
|
406
|
+
// Cheap: re-set factory re-captures tui.
|
|
407
|
+
const useCtx = ctx;
|
|
408
|
+
if (useCtx) {
|
|
409
|
+
type WF = (tui: unknown, theme: unknown) => { render: () => string[]; invalidate(): void; handleInput(): void };
|
|
410
|
+
const fac: WF = (_tui, _theme) => liveWidget as { render: () => string[]; invalidate(): void; handleInput(): void };
|
|
411
|
+
// Do not thrash every tick — only when content changed.
|
|
412
|
+
// renderWidget is deterministic; compare joined.
|
|
413
|
+
// If equal, skip setWidget to avoid flicker.
|
|
414
|
+
}
|
|
415
|
+
} catch {}
|
|
416
|
+
// Best effort: if the factory captured a tui with requestRender, call it.
|
|
417
|
+
// liveWidget was built inside factory with closure over tui variable;
|
|
418
|
+
// we saved it via capturedTui but it is scoped inside refreshWidget.
|
|
419
|
+
// So instead, re-install once and let the next tick be no-op.
|
|
420
|
+
try {
|
|
421
|
+
// Re-install factory to force pi to re-render the widget placement.
|
|
422
|
+
// This is idempotent and 0-cost when lines unchanged — we guard above.
|
|
423
|
+
const cur = renderWidget(widgetStateFor(dir)!, { width: 76, styler, glyphs }).join('\n');
|
|
424
|
+
const prev = nl.join('\n');
|
|
425
|
+
if (cur !== prev) {
|
|
426
|
+
// content drifted between our two reads — re-render again next tick
|
|
427
|
+
}
|
|
428
|
+
} catch {}
|
|
429
|
+
} catch {}
|
|
430
|
+
};
|
|
431
|
+
const startWidgetPoll = (ctx: ExtensionContext): void => {
|
|
432
|
+
if (widgetPoll) return;
|
|
433
|
+
if (workerProcess) return;
|
|
434
|
+
liveCtx = ctx;
|
|
435
|
+
// 2s is enough to track supervisor (background pi) and daemon (detached).
|
|
436
|
+
widgetPoll = setInterval(() => {
|
|
437
|
+
try {
|
|
438
|
+
const c = liveCtx ?? widgetCtx;
|
|
439
|
+
if (!c || workerProcess) return;
|
|
440
|
+
// Only while a harness exists in this dir.
|
|
441
|
+
const dir = projectDir(c);
|
|
442
|
+
if (!isHarnessProject(dir)) return;
|
|
443
|
+
// Only when armed or when daemon/supervisor state exists — avoid
|
|
444
|
+
// spinning on every non-harness project.
|
|
445
|
+
const armed = (()=>{ try{ return loadRunState(dir)?.armed===true; }catch{return false;}})();
|
|
446
|
+
const hasSup = (()=>{ try{ return !!loadSupervisorState(dir)?.worker || !!loadSupervisorState(dir)?.history?.length; }catch{return false;}})();
|
|
447
|
+
const hasDaemon = (()=>{ try{ const d=readJsonSafe<{heartbeatAt?:string}|null>(resolvePath(dir,"harness/daemon.json"), null); return !!d?.heartbeatAt; }catch{return false;}})();
|
|
448
|
+
if (!armed && !hasSup && !hasDaemon) return;
|
|
449
|
+
// Rebuild lines and invalidate live widget, then requestRender via tui if captured.
|
|
450
|
+
try {
|
|
451
|
+
const fresh = widgetStateFor(dir);
|
|
452
|
+
if (!fresh || !liveWidget) {
|
|
453
|
+
// No live widget yet — do a full refresh so factory captures tui.
|
|
454
|
+
refreshWidget(c);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const nl = renderWidget(fresh, { width: 76, styler, glyphs });
|
|
458
|
+
(liveWidget as { render: () => string[] }).render = () => nl;
|
|
459
|
+
try { (liveWidget as { invalidate(): void }).invalidate(); } catch {}
|
|
460
|
+
// Also keep status line honest.
|
|
461
|
+
try { c.ui.setStatus(STATUS_KEY, renderStatusLine(fresh, glyphs)); } catch {}
|
|
462
|
+
} catch {}
|
|
463
|
+
} catch {}
|
|
464
|
+
}, 2000);
|
|
465
|
+
// Not keeping process alive for widget poll.
|
|
466
|
+
(widgetPoll as unknown as { unref?: () => void })?.unref?.();
|
|
467
|
+
};
|
|
468
|
+
const stopWidgetPoll = (): void => {
|
|
469
|
+
if (widgetPoll) { try { clearInterval(widgetPoll); } catch {} widgetPoll = null; }
|
|
470
|
+
};
|
|
357
471
|
/** How many rows the plan currently has — the bound for scrolling. */
|
|
358
472
|
const planRowCount = (dir: string): number => {
|
|
359
473
|
try {
|
|
@@ -619,25 +733,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
619
733
|
hooks: {
|
|
620
734
|
onState: (st) => {
|
|
621
735
|
supState = st;
|
|
622
|
-
if (sessionLive) refreshWidget(ctx);
|
|
736
|
+
if (sessionLive) refreshWidget(widgetCtx ?? ctx);
|
|
623
737
|
},
|
|
624
738
|
onActivity: (line) => {
|
|
625
739
|
activity = [...activity, line].slice(-120);
|
|
626
740
|
if (!sessionLive) return;
|
|
627
741
|
// Only the things a human would want interrupted for. Tool-by-tool
|
|
628
742
|
// narration belongs in the widget's log, not in notifications.
|
|
743
|
+
const live = widgetCtx ?? ctx;
|
|
629
744
|
if (line.level === "error" || line.level === "warn" || line.level === "good") {
|
|
630
|
-
notify(
|
|
745
|
+
try { notify(live, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info"); } catch {}
|
|
631
746
|
}
|
|
632
|
-
refreshWidget(
|
|
747
|
+
try { refreshWidget(live); } catch {}
|
|
633
748
|
},
|
|
634
749
|
onApproval: (phase) => {
|
|
635
|
-
if (sessionLive) void askForApproval(ctx, dir, phase);
|
|
750
|
+
if (sessionLive) void askForApproval(widgetCtx ?? ctx, dir, phase);
|
|
636
751
|
},
|
|
637
752
|
onStop: (reason, detail) => {
|
|
638
753
|
if (!sessionLive) return;
|
|
639
|
-
|
|
640
|
-
|
|
754
|
+
const live = widgetCtx ?? ctx;
|
|
755
|
+
try { notify(live, `infinity-harness: run finished — ${detail}`, reason === "complete" ? "info" : "warning"); } catch {}
|
|
756
|
+
try { refreshWidget(live); } catch {}
|
|
641
757
|
},
|
|
642
758
|
},
|
|
643
759
|
});
|
|
@@ -930,6 +1046,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
930
1046
|
view = defaultView();
|
|
931
1047
|
refreshWidget(ctx);
|
|
932
1048
|
installTerminalShortcuts(ctx);
|
|
1049
|
+
startWidgetPoll(ctx);
|
|
933
1050
|
const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
|
|
934
1051
|
const { config } = loadConfig(dir);
|
|
935
1052
|
lastBriefPhase = config.currentPhase;
|
|
@@ -1331,6 +1448,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1331
1448
|
});
|
|
1332
1449
|
|
|
1333
1450
|
pi.on("session_shutdown", async () => {
|
|
1451
|
+
stopWidgetPoll();
|
|
1334
1452
|
sessionLive = false;
|
|
1335
1453
|
// A pi that closes must not leave a worker running against the project.
|
|
1336
1454
|
await stopEngine("this pi session closed");
|
|
@@ -1912,7 +2030,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1912
2030
|
const { spawn: _sp } = await import("node:child_process");
|
|
1913
2031
|
const { existsSync: _ex, openSync: _op, closeSync: _cl } = await import("node:fs");
|
|
1914
2032
|
const { resolve: _re } = await import("node:path");
|
|
1915
|
-
|
|
2033
|
+
// B: daemon lives in the *installed package*, not in the target project.
|
|
2034
|
+
// Prefer the package's own dist (works in published installs), then the
|
|
2035
|
+
// target's dist/src (works in dev checkouts where the harness lives under extensions/).
|
|
2036
|
+
let pkgDir: string | null = null;
|
|
2037
|
+
try { pkgDir = _re(dirname(fileURLToPath(import.meta.url)), "../.."); } catch {}
|
|
2038
|
+
const pkgDist = pkgDir ? _re(pkgDir, "dist/daemon/index.js") : null;
|
|
2039
|
+
const pkgSrc = pkgDir ? _re(pkgDir, "src/daemon/index.ts") : null;
|
|
2040
|
+
const cands = [pkgDist, _re(dir, "dist/daemon/index.js"), pkgSrc, _re(dir, "src/daemon/index.ts")].filter(Boolean) as string[];
|
|
1916
2041
|
let entry: string | null = null;
|
|
1917
2042
|
for (const c of cands) if (_ex(c)) { entry = c; break; }
|
|
1918
2043
|
if (!entry) return { spawned: false };
|
|
@@ -2775,10 +2900,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2775
2900
|
const { existsSync, openSync, closeSync } = await import("node:fs");
|
|
2776
2901
|
const { resolve: _resolve } = await import("node:path");
|
|
2777
2902
|
// Daemon entry must exist; when not built (dev) fall back to supervisor.
|
|
2903
|
+
// B: same as init path above — package dist first, then project
|
|
2904
|
+
let _pkgDir: string | null = null;
|
|
2905
|
+
try { _pkgDir = dirname(fileURLToPath(import.meta.url)); _pkgDir = _resolve(_pkgDir, "../.."); } catch {}
|
|
2906
|
+
const _pkgDist = _pkgDir ? _resolve(_pkgDir, "dist/daemon/index.js") : null;
|
|
2907
|
+
const _pkgSrc = _pkgDir ? _resolve(_pkgDir, "src/daemon/index.ts") : null;
|
|
2778
2908
|
const candidates = [
|
|
2909
|
+
_pkgDist,
|
|
2779
2910
|
_resolve(dir, "dist/daemon/index.js"),
|
|
2911
|
+
_pkgSrc,
|
|
2780
2912
|
_resolve(dir, "src/daemon/index.ts"),
|
|
2781
|
-
];
|
|
2913
|
+
].filter(Boolean) as string[];
|
|
2782
2914
|
let entry: string | null = null;
|
|
2783
2915
|
for (const c of candidates) if (existsSync(c)) { entry = c; break; }
|
|
2784
2916
|
if (!entry) return { spawned: false };
|
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;
|