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 +17 -0
- package/extensions/infinity-harness/index.ts +144 -28
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
}
|
|
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.
|
|
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": [
|