infinity-harness 2.5.0 → 2.6.0
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 +50 -0
- package/extensions/infinity-harness/index.ts +148 -30
- package/harness/docs/plans/2.6-unified-phase-parallel-workers.md +234 -0
- package/package.json +1 -1
- package/src/core/brief.ts +2 -1
- package/src/core/config.ts +80 -12
- package/src/core/featureList.ts +44 -7
- package/src/core/gates.ts +18 -4
- package/src/core/init.ts +2 -0
- package/src/core/phases.ts +60 -0
- package/src/core/settings.ts +115 -10
- package/src/core/types.ts +19 -0
- package/src/escalate.ts +17 -3
- package/src/intake.ts +6 -0
- package/src/loop.ts +115 -9
- package/src/modelRouter.ts +14 -0
- package/src/remote.ts +11 -0
- package/src/replan.ts +8 -3
- package/src/scheduler.ts +205 -0
- package/src/taskList.ts +3 -1
- package/src/ui/wizard.ts +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ 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.6.0] — 2026-08-25
|
|
8
|
+
|
|
9
|
+
Every unfinished thought from the current loop now has a budget, a lane, and a worker. One generic
|
|
10
|
+
pipeline — no per-phase special cases.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Every phase owns tasks.** `task.phase?` + `feature.phase?` (`effectivePhase = task.phase ??
|
|
15
|
+
feature.phase ?? "build"`), phase-scoped helpers `tasksForPhase/featuresForPhase`,
|
|
16
|
+
`computeProgress(phase)/nextActionableTask(phase)`, starter tasks `define/d1-d2 + plan/p1-p2` seeded only when the phase is empty *and* its gate fails (so `convergence` stays clean and `DEFINE rev 0 0/0` no longer idles on `task` handoff). Old plans without `phase` still \= `build`.
|
|
17
|
+
|
|
18
|
+
- **Parallel on every level.** New `src/scheduler.ts` with `execution: { parallelAt: "task",
|
|
19
|
+
maxWorkers: 3 }` (choices `off | goal | phase | sprint | feature | task | subtask`, 1–16). Default is
|
|
20
|
+
`task × 3` — most efficient here; `goal` runs goals as parallel pipelines. Wizard asks after routing;
|
|
21
|
+
`/infinity:config` → Execution exposes both knobs. Workers are isolated
|
|
22
|
+
`tmp/infinity-harness/<run>/<feature>/<task>/attempt-N` with `model = resolveModel(task)` and
|
|
23
|
+
`thinking = resolveThinking(task)` per task.
|
|
24
|
+
|
|
25
|
+
- **Per-level retry with per-level ladder.** `retry.levels: { goal 2, phase 2, sprint 2, feature 2, task 10, subtask 3 }` + `retryPerLevel` counters. `isRetryExhausted` checks finest first; a `task` pass zeroes `subtask` (`zeroLowerOnPass`), etc. Every level has its own `LoopState.perLevelEscalation` (`tried[]`, `fingerprints`, `consultedCount`) — the ladder `retry→reframe→consult→rework→replan→master` climbs per level with the task's difficulty and `master` once (consult also escalates thinking: `resolveThinkingForConsult(nextTier)`).
|
|
26
|
+
|
|
27
|
+
- **Main is dashboard only.** `loop.decideNext` fire-and-forget `spawnWorkers` scoped to `currentPhase` when `execution.parallelAt !== "off"` — the main session never edits the plan, it polls `fingerprint.json/output.log` and leaves `infinity_plan` to the workers. Dashboard `remote.buildRemoteState` streams `execution` + the 6 most recent worker `outputTail`s; widget follows the active `nextActionableTask(phase)` task.
|
|
28
|
+
|
|
29
|
+
- **Handoff/brief built for multiple phases.** `activePlanKeys` and `brief.task` prefer the current phase's next task before falling back to global, so `task`-granularity handoff (task fires all coarser levels) actually triggers after `research → define`. `BUILD`'s `tasks-complete` gate is now phase-scoped via `computeProgress(currentPhase)` (`effectivePhase`).
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `replan.amendPlan` preserves `phase` on tasks and features via `toStoredTask` / `addFeatures.phase`; a mid-run `define` amendment no longer falls back to `build`.
|
|
34
|
+
- `rework` / `replan` / `unstuck` keep their budgets (`maxReworksPerRun`, `maxReplansPerRun`, `consultation`, `review.allowBackward`) and were proved still working after the unified phase, parallel + per-level retry changes (`34/34` unit + `15/15` e2e, including `realpi` dialogs + `convergence` `define → ship`).
|
|
35
|
+
|
|
36
|
+
### Verified
|
|
37
|
+
|
|
38
|
+
- `tsc --noEmit` clean, `34` unit test files, `e2e 15/15` (including `realpi` five sessions + `convergence`, `package 42 modules reachable`). `harness/docs/plans/2.6-unified-phase-parallel-workers.md` keeps the user comments that drove this release.
|
|
39
|
+
|
|
40
|
+
## [2.5.1] — 2026-08-25
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **Routing actually drives the session.** `harness/model-router.json` previously persisted the tier
|
|
45
|
+
choices but never called `ctx.setModel`/`ctx.setThinkingLevel`. Now `before_agent_start` and
|
|
46
|
+
`session_start` resolve `harness/model-router.json` for the next actionable task and switch
|
|
47
|
+
the pi session model + thinking, surface it as `infinity-model` in the footer and as
|
|
48
|
+
`Routing: f1/t1 → prov-a/model-a · thinking low` in the brief/system prompt; `/infinity:config`
|
|
49
|
+
and `/infinity:models` already showed the wiring, now the session honors it. Proved by
|
|
50
|
+
`tests/routing-live.test.ts` + 15/15 E2E (including `realpi` dialogs) all green.
|
|
51
|
+
- **`infinity_validate` auto-advance scoped to doc phases.** Only `research/define/plan` hops on
|
|
52
|
+
PASS in autopilot; `build` and later require explicit `infinity_advance` (or the armed
|
|
53
|
+
`agent_settled` loop) so `build → verify → review` no longer skips a phase in one tool call.
|
|
54
|
+
Wizard routing queue aligned in `scripts/e2e.mjs` + `tests/intake.test.ts`; full granularity
|
|
55
|
+
hierarchy covered in `tests/handoff.test.ts`.
|
|
56
|
+
|
|
7
57
|
## [2.5.0] — 2026-08-25
|
|
8
58
|
|
|
9
59
|
### 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,14 +297,110 @@ 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. */
|
|
301
398
|
const activePlanKeys = (dir: string): { task: string | null; feature: string | null; sprint: string | null; goal: string | null; subtask: string | null; } => {
|
|
302
399
|
try {
|
|
303
400
|
const { list } = loadFeatureList(dir);
|
|
304
|
-
const
|
|
305
|
-
const
|
|
401
|
+
const { config } = loadConfig(dir);
|
|
402
|
+
const phaseTask = nextActionableTask(list, config.currentPhase as string | null);
|
|
403
|
+
const task = phaseTask ?? nextActionableTask(list);
|
|
306
404
|
// Resolve sprint/goal via list, and active subtask of the focused task.
|
|
307
405
|
const taskKey = task?.compositeKey ?? null;
|
|
308
406
|
const featureId = task?.featureId ?? null;
|
|
@@ -544,10 +642,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
544
642
|
view = defaultView();
|
|
545
643
|
refreshWidget(ctx);
|
|
546
644
|
installTerminalShortcuts(ctx);
|
|
645
|
+
const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
|
|
547
646
|
const { config } = loadConfig(dir);
|
|
548
647
|
lastBriefPhase = config.currentPhase;
|
|
648
|
+
// Route on session start (including after handoff) so the fresh session
|
|
649
|
+
// actually runs on the tier model, not whatever the harness was started with.
|
|
650
|
+
try { await applyRouting(ctx, dir, `session_start:${reason}`); } catch {}
|
|
651
|
+
;(async () => { try { await applyRouting(ctx, dir, "session_start"); } catch {} })();
|
|
549
652
|
|
|
550
|
-
|
|
653
|
+
|
|
551
654
|
const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
|
|
552
655
|
const armed = run?.armed === true;
|
|
553
656
|
|
|
@@ -620,11 +723,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
620
723
|
if (!sessionLive) return;
|
|
621
724
|
const dir = projectDir(ctx);
|
|
622
725
|
if (!isHarnessProject(dir)) return;
|
|
726
|
+
// Live-model routing: switch the pi session model/thinking for the next
|
|
727
|
+
// actionable task. This is what makes harness/model-router.json do anything
|
|
728
|
+
// in the main session; without it the GUI pointing at the same model was
|
|
729
|
+
// the whole behavior.
|
|
730
|
+
try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
|
|
623
731
|
try {
|
|
624
732
|
const contract = harnessContract(dir);
|
|
625
733
|
if (!contract) return;
|
|
626
734
|
const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
|
|
627
|
-
|
|
735
|
+
const routed = await routingSummaryForBrief(dir);
|
|
736
|
+
const suffix = routed ? `\n\n${routed}` : "";
|
|
737
|
+
return { systemPrompt: `${base}${suffix}\n\n${contract}` };
|
|
628
738
|
} catch {
|
|
629
739
|
return;
|
|
630
740
|
}
|
|
@@ -1090,33 +1200,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1090
1200
|
const lines = gate.checks
|
|
1091
1201
|
.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
|
|
1092
1202
|
.join("\n");
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1203
|
+
// Research (and any other phase whose mode is autopilot) used to stall
|
|
1204
|
+
// forever until someone typed "continue" because the brief said
|
|
1205
|
+
// PASS→advance but no component actually advanced without the continuous
|
|
1206
|
+
// loop armed. Fix that, but do NOT auto-advance BUILD verify-style
|
|
1207
|
+
// phases that require real work (tests, coverage, clean tree) to have
|
|
1208
|
+
// genuinely passed on the *next* phase's gate as well — otherwise a
|
|
1209
|
+
// single infinity_validate hops build→verify→review.
|
|
1210
|
+
// Only auto-advance doc/process phases whose gate is purely content (
|
|
1211
|
+
// research, define, plan). BUILD and later require explicit validation.
|
|
1098
1212
|
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
|
-
|
|
1213
|
+
const autoPhases: ReadonlySet<string> = new Set(["research", "define", "plan"]);
|
|
1214
|
+
if (autoPhases.has(String(gate.phase))) {
|
|
1215
|
+
try {
|
|
1216
|
+
const { needsApproval } = await import("../../src/approval.ts");
|
|
1217
|
+
const fresh = loadConfig(dir).config;
|
|
1218
|
+
if (!needsApproval(fresh, fresh.currentPhase)) {
|
|
1219
|
+
const { advancePhase } = await import("../../src/core/phases.ts");
|
|
1220
|
+
const moved = await advancePhase(dir);
|
|
1221
|
+
if (moved.ok && moved.to) {
|
|
1222
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1223
|
+
const brief = await briefText(dir);
|
|
1224
|
+
return {
|
|
1225
|
+
content: [
|
|
1226
|
+
{
|
|
1227
|
+
type: "text",
|
|
1228
|
+
text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
details: { ...gate, advanced: moved } as unknown as typeof gate,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1117
1234
|
}
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1235
|
+
} catch {}
|
|
1236
|
+
}
|
|
1120
1237
|
}
|
|
1121
1238
|
return {
|
|
1122
1239
|
content: [
|
|
@@ -1406,6 +1523,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1406
1523
|
workflow: plan.workflow,
|
|
1407
1524
|
display: plan.display,
|
|
1408
1525
|
session: plan.session,
|
|
1526
|
+
execution: { parallelAt: (plan as { execution?: { parallelAt?: import("../../src/core/types.ts").HandoffGranularity } }).execution?.parallelAt ?? "task", maxWorkers: (plan as { execution?: { maxWorkers?: number } }).execution?.maxWorkers ?? 3 },
|
|
1409
1527
|
brief: plan.brief,
|
|
1410
1528
|
router: plan.router
|
|
1411
1529
|
? ({
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Plan 2.6 — Unified Phase Tasks, Parallel Workers, Generic Loops
|
|
2
|
+
|
|
3
|
+
> **Source:** User comments from 2026-08-25 review. This file keeps your words together with the plan. No code is special cased. Every phase reuses the same helpers.
|
|
4
|
+
|
|
5
|
+
## 1. What you said (your words)
|
|
6
|
+
|
|
7
|
+
- “**Sprint and feature are build phase exclusive, goal and phase are higher level. So each phase should include task, subtask, and all other related loops and handoffs and gates…**”
|
|
8
|
+
- “**Option A seems the most lean approach, you just need to handle the special diversions of each phase.**”
|
|
9
|
+
- “**Regarding the handoff and next command fix that ensures the pipeline never stops unintentionally. I believe you should define a hierarchy first: goal, phase, sprint, feature, task, subtask. The the level at which session handoff is selected by the user should cover everything above it. i.e. task session handoff automatically mean that whenever any of the higher levels such as phase or sprint, … finishes, it should automatically trigger another session handoff and proceed to the next stage whatever it is.**”
|
|
10
|
+
- “**I prefer that you abstract the code base as much as possible so defining new phases or adding multiple goals, … should be straight forward plug and play.**”
|
|
11
|
+
- “**Parallel should be on all levels from goal to subtask just as I told you, we will set the default behaviour to the most efficient level, but the user should be free to do what he wants.**”
|
|
12
|
+
- “**Regarding plan and define, yes, they should spawn their own workers, just like all other phases, this consolidates the code and reduces special code, we want generic code that can be reused rather than too many special cases.**”
|
|
13
|
+
- “**Your plan sounds good, write it down into a file with my comments, then start the implementation, and make sure you test everything thoroughly, e2e, I dont want to see done when not done.**”
|
|
14
|
+
|
|
15
|
+
All of this is treated as fixed requirements.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 2. Why we need this (the bug you hit)
|
|
20
|
+
|
|
21
|
+
You ran with `research` on autopilot. Trace:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
research: RESEARCH.md 4558 chars -> gate PASS -> auto-advanced to DEFINE
|
|
25
|
+
DEFINE: widget shows DEFINE rev 0 0/0 tasks · 0/0 features "no plan yet"
|
|
26
|
+
-> then it stopped
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Root cause:** Only `BUILD` has tasks today. `DEFINE` had `0/0 tasks`. Handoff was `task`, so `fromTask=null -> toTask=null` did not fire. Phase handoff already fired `research->define`, but inside `DEFINE` there was no `nextActionableTask`, so `brief` said `THE LOOP: do work / validate` with nothing to do. The agent waited.
|
|
30
|
+
|
|
31
|
+
Fix: give every phase tasks. Then handoff, routing, progress, gate and workers work the same everywhere.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 3. Loops we have today vs dev-harness — simple English
|
|
36
|
+
|
|
37
|
+
### infinity-harness today — one big loop + one outer loop + three helpers
|
|
38
|
+
|
|
39
|
+
**Big loop** `src/loop.ts` `decideNext()`:
|
|
40
|
+
It runs when the agent stops. It asks: keep going or stop?
|
|
41
|
+
Steps: stop file? paused? waiting for human sign? all tasks done and last phase? retry budget empty? wall clock or max steps over? Run gate. If gate pass and human must sign, wait. If gate pass, move to next phase. If gate fail, check if code changed. If no change 3 times, stop. If stuck 1 time, try fix from `escalate.ts`.
|
|
42
|
+
|
|
43
|
+
One function knows all budgets. It is about 600 lines.
|
|
44
|
+
|
|
45
|
+
**Outer loop** `src/goalLoop.ts` + `src/goal.ts`:
|
|
46
|
+
It is above the big loop. It tracks a Goal. Goal = many pipeline runs. When pipeline says complete, goal loop asks: is the *goal* really done? If not, start new pipeline run with the work that is still open.
|
|
47
|
+
|
|
48
|
+
**Helper `handoff`** `src/handoff.ts` `shouldHandoff()`:
|
|
49
|
+
Not a loop. It only says: start a new pi session now? Before it only knew `phase` and `task`. Now we fixed it to `goal -> phase -> sprint -> feature -> task -> subtask`. If you pick `task`, it fires on `task` and also on `feature`, `sprint`, `phase`, `goal`.
|
|
50
|
+
|
|
51
|
+
**Helper `worker`** `src/worker.ts` `spawnIsolatedWorker()`:
|
|
52
|
+
It runs one task in `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N` with its own prompt and log. Today the agent must call `infinity_spawn_worker` itself. The big loop does not auto-spawn, so main session still does the real work.
|
|
53
|
+
|
|
54
|
+
**Helper `escalate`** `src/escalate.ts`:
|
|
55
|
+
Ladder `retry -> reframe -> consult -> rework -> replan -> master`. Big loop calls it when stuck.
|
|
56
|
+
|
|
57
|
+
**Brief** `src/core/brief.ts`:
|
|
58
|
+
Builds `NEXT STEP` for the agent.
|
|
59
|
+
|
|
60
|
+
Picture:
|
|
61
|
+
```
|
|
62
|
+
agent works -> agent_settled -> decideNext -> gate
|
|
63
|
+
-> pass -> advancePhase -> new brief -> new session ?
|
|
64
|
+
-> fail -> escalate -> re-brief
|
|
65
|
+
-> stop (with reason)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### dev-harness — three small loops inside each other
|
|
69
|
+
|
|
70
|
+
* **Inner:** `ralph-tasks.mjs` — one feature's tasks. Picks `getNextTask(feature)`, writes task instructions, waits for `validate --feature --task`.
|
|
71
|
+
* **Middle:** `ralph-features.mjs` — one phase's features. Loops features, calls inner for each. Handles feature retry.
|
|
72
|
+
* **Outer:** `ralph-phases.mjs` — whole pipeline. Knows 2 types: `feature-iterate` (`build, verify, simplify` with tasks) and `deliverable-retry` (`define, plan, review, ship` with checklist, no tasks). `continuePipeline` moves to next phase. In copilot it only prints dev-harness phase next (wrapped here to avoid CLI lint); in autopilot it calls `transitionPhase` itself.
|
|
73
|
+
|
|
74
|
+
Brief has 2 modes: if `feature-iterate` and task open, show that task. Else run gate and show `advance` or `phase-work`.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
runPhase(build) -> runFeatureLoop(build) -> runTaskLoop(task-1) -> validate -> runTaskLoop(task-2)
|
|
78
|
+
runPhase(define) -> checklist -> validate
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Key differences in simple words
|
|
82
|
+
|
|
83
|
+
1. **One big loop vs three small loops.** We have one place that decides for all. Dev has three places, each knows one level. Three small loops are easier to read, but harder to make parallel across levels.
|
|
84
|
+
2. **Tasks only in BUILD vs tasks could be everywhere.** Dev keeps `define` as checklist (no tasks) on purpose. We had the same, and it caused your bug.
|
|
85
|
+
3. **Who moves the pipeline?** We auto-move in `decideNext` when gate passes. Dev's `continuePipeline` only prints the command in copilot; agent must run it. We now also auto-advance `research/define/plan` in `infinity_validate` for autopilot.
|
|
86
|
+
4. **Handoff.** Dev only on `phase` and `role`. We now do 7 levels with your hierarchy.
|
|
87
|
+
5. **Where work happens.** Both do real work in main today. You want main = log and follow, workers = real work. Neither does this auto yet.
|
|
88
|
+
6. **Retry.** Dev checks retry per level. We had one global streak. That mixes budgets.
|
|
89
|
+
|
|
90
|
+
### Can our loop do everything dev does and more?
|
|
91
|
+
|
|
92
|
+
Yes. Our gate, budgets, approval, fingerprint, model routing, 7-level handoff are already more than Dev. After this plan we will also have what Dev has: a clean split between task phases and checklist phases — but we will unify it so every phase *can* have tasks, while still keeping a small checklist for the doc phases.
|
|
93
|
+
|
|
94
|
+
No need to rollback to 3 Ralph loops. One loop with split helpers can do parallel better, because one place can pick many free tasks and spawn many workers. Three loops would need three locks.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 4. Target architecture (Option A — lean, generic, plug and play)
|
|
99
|
+
|
|
100
|
+
### 4.1 One file, add `phase` to task
|
|
101
|
+
|
|
102
|
+
Keep `harness/features/feature-list.json` as the only truth.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"id": "research/r1",
|
|
107
|
+
"key": "research/r1",
|
|
108
|
+
"description": "Collect prior art (3 sources)",
|
|
109
|
+
"status": "pending",
|
|
110
|
+
"phase": "research",
|
|
111
|
+
"difficulty": "moderate",
|
|
112
|
+
"sprintId": "sprint-001",
|
|
113
|
+
"featureId": "phase-research",
|
|
114
|
+
"subtasks": [{ "title": "tick vs tinyboard", "status": "pending"}]
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- New optional fields: `task.phase?: Phase`, `feature.phase?: Phase`, `task.sprintId?`, `task.goalId?` (for multi-goal).
|
|
119
|
+
- Migration: old tasks without `phase` => `build`. Keeps old file valid.
|
|
120
|
+
- Sprint and feature stay build-exclusive in data, but code treats them as optional. If a phase has no sprint, it is skipped. That satisfies: *sprint and feature are build exclusive, goal and phase are higher*.
|
|
121
|
+
- Adding a new phase: add name to `PHASE_ORDER` in `src/core/types.ts`, add gate in `src/core/gates.ts`, add intent in `src/core/brief.ts`. Done. No loop change.
|
|
122
|
+
- Adding a new goal: push to `list.goals`. Parallel at `goal` already handles it.
|
|
123
|
+
|
|
124
|
+
### 4.2 Seed starter tasks (so no phase is empty)
|
|
125
|
+
|
|
126
|
+
When a phase starts and `tasksForPhase(phase).length === 0`, write 2-3 starter tasks from `harness/docs/phases/<phase>.md` process list. Idempotent, only when 0 tasks. This fixes `DEFINE rev 0`.
|
|
127
|
+
|
|
128
|
+
Each phase keeps its small special diversion as *seed content*, not as code. The code is generic.
|
|
129
|
+
|
|
130
|
+
### 4.3 Phase-aware helpers (split tidy)
|
|
131
|
+
|
|
132
|
+
Keep one big loop, but helpers split:
|
|
133
|
+
|
|
134
|
+
- `src/phases.ts`: `isPhaseDone(phase)`, `tasksForPhase(phase)`, `seedPhase(phase)` (new)
|
|
135
|
+
- `src/taskList.ts` / `src/core/featureList.ts`: `nextActionableTask(phase)`, `computeProgress(phase)`, `fingerprint` with `phase:taskStatus`
|
|
136
|
+
- `src/handoff.ts`: `activePlanKeys(phase)` — already generic, now phase-filtered. `shouldHandoff` keeps your hierarchy: picking `task` fires on `task` and all above (`feature`, `sprint`, `phase`, `goal`), not on `subtask`.
|
|
137
|
+
|
|
138
|
+
### 4.4 Parallel on all levels (your requirement)
|
|
139
|
+
|
|
140
|
+
User picks **one** level for parallelism + max workers. This keeps it simple and free.
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
"execution": { "parallelAt": "task", "maxWorkers": 4 }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Choices: `off | goal | phase | sprint | feature | task | subtask`. Default: most efficient.
|
|
147
|
+
|
|
148
|
+
- **What is most efficient default?** `task` with `maxWorkers: 3` for this repo (feature-level would also be good because features have `dependsOn`, but task gives finer grain without too much churn; `subtask` is too chatty with 5 sessions per task). For multi-goal repos, `goal` with `maxWorkers: 2` is efficient. We will default to `task` and explain both in wizard.
|
|
149
|
+
- Logic: `src/scheduler.ts` (new) `pickRunnableTasks(phase, level, max)`:
|
|
150
|
+
1. Find all `pending` tasks where `dependsOn` all done and no worker running.
|
|
151
|
+
2. Filter by `parallelAt`: if `parallelAt: feature`, tasks from same feature run one by one, but different features with no dep can run together. If `parallelAt: goal`, each goal is a pipeline; phases of different goals run in parallel auto.
|
|
152
|
+
3. Spawn up to `maxWorkers` isolated workers: `spawnIsolatedWorker({model: resolveModel(task), thinking: resolveThinking(task)})` → `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N`.
|
|
153
|
+
4. Main polls `output.log` + `fingerprint.json`, updates widget `◐ in_progress`, streams to dashboard via `remote.ts`.
|
|
154
|
+
|
|
155
|
+
Wizard asks this after handoff, `/infinity:config` exposes it.
|
|
156
|
+
|
|
157
|
+
### 4.5 Per-level retry + consultation (your requirement)
|
|
158
|
+
|
|
159
|
+
Today one global `taskRetryCount`. Mixes `DEFINE` failure into `BUILD` budget.
|
|
160
|
+
|
|
161
|
+
New:
|
|
162
|
+
```json
|
|
163
|
+
"retry": { "goal":2, "phase":2, "sprint":2, "feature":2, "task":10, "subtask":3 }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- Each level has `tried[]`, `consultedCount`, `retryCount`.
|
|
167
|
+
- `LoopState` stores `perLevel: {goal:{...}, phase:{...}, ...}`.
|
|
168
|
+
- Flow: `subtask` fail 3 times → `consultNext(subtask difficulty)` → next model `easy->moderate`. If still fail or master fail → escalate to `task` retry. `task` fail → consult → if pass, zero `subtask` count. If `task` retries done → escalate to `feature`, up to `goal`.
|
|
169
|
+
- `escalate.ts` becomes `escalate(level)`, not just `consultNext(task)`.
|
|
170
|
+
|
|
171
|
+
### 4.6 Main = log and follow (your requirement)
|
|
172
|
+
|
|
173
|
+
- Today main does `edit`/`write` itself. After this, when phase is exec type (which will be **all** phases, as you want generic), main does **not** edit. It spawns workers. Main only polls `output.log`, updates `widget`/`dashboard`, and on worker pass does `infinity_plan` status `complete`.
|
|
174
|
+
- `verify` and `review` already have their own loops; they will also use same worker path.
|
|
175
|
+
|
|
176
|
+
### 4.7 Handoff + `infinity:next` never stops by accident
|
|
177
|
+
|
|
178
|
+
Already fixed: handoff hierarchy `goal -> phase -> sprint -> feature -> task -> subtask` where picking `task` fires on `task` and all above. `infinity_validate` auto-advances for `research/define/plan` in autopilot (only doc phases, not `build`). `decideNext` and `infinity:next` will share same `shouldHandoff` + `activePlanKeys(phase)` so pipeline never idles on `0/0 tasks`.
|
|
179
|
+
|
|
180
|
+
### 4.8 Abstraction (plug and play)
|
|
181
|
+
|
|
182
|
+
- New phase: `PHASE_ORDER` + `PHASE_GATES` map + `PHASE_INTENT` + seed template. No change in `loop.ts` or `handoff.ts`.
|
|
183
|
+
- New goal: push to `list.goals`. If `parallelAt: goal`, phases of different goals run in parallel auto.
|
|
184
|
+
- New level: add to `LEVEL_ORDER`, no loop change.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 5. Plan slices (vertical, testable)
|
|
189
|
+
|
|
190
|
+
| Slice | Goal | Key files | Tests |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| **1. Tidy helpers + sprint/subtask + phase field + seed** | Add `task.phase`, `feature.phase`, split helpers, seed starter tasks. `DEFINE rev 0` gone. | `src/core/types.ts`, `src/core/featureList.ts`, `src/phases.ts`, `src/core/brief.ts`, `src/handoff.ts` | round-trip + empty->seed, `nextActionableTask(phase)`, `tsc --noEmit` |
|
|
193
|
+
| **2. Parallel scheduler** | New `src/scheduler.ts`, config `execution.parallelAt + maxWorkers`, wizard + `/infinity:config`. Spawn up to N workers. Lock per file. | `src/scheduler.ts`, `src/core/config.ts`, `src/ui/wizard.ts`, `extensions/infinity-harness/index.ts` | concurrency e2e, `npm run e2e -- concurrency` |
|
|
194
|
+
| **3. Per-level retry + consultation** | `retry` map per level, `LoopState.perLevel`, `escalate(level)`, zero lower on pass. | `src/loop.ts`, `src/escalate.ts`, `src/unstuck.ts` | escalation e2e, unit for `consultNext` per level |
|
|
195
|
+
| **4. Handoff phase-aware** | `activePlanKeys(phase)` + `seed` ensures `toTask` soon non-null. E2E `subtask` granularity already proven. | `src/handoff.ts`, `extensions/.../index.ts` | handoff E2E |
|
|
196
|
+
| **5. Main as orchestrator** | Auto-spawn for all phases with tasks, main polls `output.log`, widget shows worker progress. | `src/loop.ts`, `src/worker.ts`, `src/remote.ts`, `src/ui/widget.ts` | worker + realpi E2E |
|
|
197
|
+
|
|
198
|
+
Each slice: type, code, `tsc --noEmit`, `npm test`, `npm run e2e` relevant group. No `done` until e2e green.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 6. Config changes
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"session": { "handoff": "task", "contextThreshold": 0.6, "carryNotes": true },
|
|
207
|
+
"execution": { "parallelAt": "task", "maxWorkers": 3 },
|
|
208
|
+
"retry": { "goal":2, "phase":2, "sprint":2, "feature":2, "task":10, "subtask":3 }
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
All editable via `/infinity:config` and wizard. `harness/model-router.json` already has `byDifficulty` + thinking; workers use `resolveModel(task)` + `resolveThinking(task)` so each worker gets its own model as you wanted.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 7. Testing — no false done
|
|
217
|
+
|
|
218
|
+
- `npm run check` (`tsc --noEmit`) green
|
|
219
|
+
- `npm test` 34+ files green (add `scheduler.test.ts`, `handoff` subtask)
|
|
220
|
+
- `npm run e2e` 15/15 green (including `realpi` dialogs, handoff, concurrency)
|
|
221
|
+
- Manual: `research` autopilot -> auto-advanced to `DEFINE` with 2 seed tasks -> `DEFINE` tasks get workers -> `plan` etc. All via `infinity:next` and `agent_settled`.
|
|
222
|
+
|
|
223
|
+
We will not mark done until e2e passes.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 8. Open questions for you
|
|
228
|
+
|
|
229
|
+
1. Should `parallelAt` be one choice (`task`) or allow 2 at once (e.g., 2 goals + 5 tasks)? Proposed: one choice for now, easy to extend to 2 later.
|
|
230
|
+
2. Confirm default `parallelAt: task, maxWorkers: 3` is the most efficient you want, or prefer `feature, maxWorkers: 3`?
|
|
231
|
+
3. Confirm `DEFINE/PLAN` auto-spawn workers as above (generic), not stay in main for speed? You said yes — keeping that.
|
|
232
|
+
|
|
233
|
+
Next step after your OK: start **Slice 1** implementation.
|
|
234
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
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": [
|
package/src/core/brief.ts
CHANGED
|
@@ -87,7 +87,8 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const
|
|
90
|
+
const phaseTask = phase ? nextActionableTask(list, phase) : null;
|
|
91
|
+
const nextTask = phaseTask ?? nextActionableTask(list);
|
|
91
92
|
const feature = nextTask ? findFeature(list, nextTask.featureId) : null;
|
|
92
93
|
|
|
93
94
|
let gate: GateResult | null = null;
|