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 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
- return renderBrief(brief, config);
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 task = nextActionableTask(list);
305
- const flat = task ? loadFeatureList(dir).list.features?.find((f) => f.id === task.featureId) ?? null : null;
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
- const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
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
- return { systemPrompt: `${base}\n\n${contract}` };
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
- // On a passing gate in autopilot, the tool itself advances the phase
1094
- // so a run without the continuous loop armed still moves forward when
1095
- // the agent calls infinity_validate that's what the brief says will
1096
- // happen ("PASS the harness advances") and what stopped research
1097
- // from ever reaching DEFINE until someone typed "continue".
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
- try {
1100
- const { needsApproval } = await import("../../src/approval.ts");
1101
- const fresh = loadConfig(dir).config;
1102
- if (!needsApproval(fresh, fresh.currentPhase)) {
1103
- const { advancePhase } = await import("../../src/core/phases.ts");
1104
- const moved = await advancePhase(dir);
1105
- if (moved.ok && moved.to) {
1106
- refreshWidget(ctx as ExtensionContext);
1107
- const brief = await briefText(dir);
1108
- return {
1109
- content: [
1110
- {
1111
- type: "text",
1112
- text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1113
- },
1114
- ],
1115
- details: { ...gate, advanced: moved } as unknown as typeof gate,
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
- } catch {}
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.5.0",
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 nextTask = nextActionableTask(list);
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;