infinity-harness 2.5.1 → 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.
@@ -11,6 +11,8 @@ import type { HarnessConfig, Phase } from "./types.ts";
11
11
  import { PHASE_ORDER, DEFAULT_ENABLED_PHASES } from "./types.ts";
12
12
  import { loadConfig, saveConfig, recordGate, currentRoleFor } from "./config.ts";
13
13
  import { gitBranch, gitIsClean, gitHasUpstream, gitLastCommitMessage } from "./exec.ts";
14
+ import { loadFeatureList, saveFeatureList, tasksForPhase } from "./featureList.ts";
15
+ import type { RetryLevel } from "./types.ts";
14
16
 
15
17
  export { PHASE_ORDER };
16
18
 
@@ -125,6 +127,64 @@ export async function transitionPhase(targetDir: string, toPhase: Phase): Promis
125
127
  return { ok: true, error: null, config, from, to: toPhase };
126
128
  }
127
129
 
130
+ /** Starter tasks seeded when a phase has no tasks at all (idempotent, fixes DEFINE rev 0).
131
+ *
132
+ * Only phases that are *gated on doc artefacts* get starters. BUILD/VERIFY etc
133
+ * already have tasks from PLAN; seeding them would pollute BUILD's tasksComplete.
134
+ */
135
+ export const STARTER_TASKS: Record<string, Array<{ id: string; description: string; difficulty: "easy" | "moderate" | "difficult" }>> = {
136
+ // Research has its doc gate but no task gate; a doc checklist, not tasks — no seed.
137
+ define: [
138
+ { id: "define/d1", description: "Interview scope and write bounded PRD + acceptance criteria", difficulty: "moderate" },
139
+ { id: "define/d2", description: "Record sprint contract and branch (not main)", difficulty: "easy" },
140
+ ],
141
+ plan: [
142
+ { id: "plan/p1", description: "Break each feature into ordered, dependency-aware tasks", difficulty: "moderate" },
143
+ { id: "plan/p2", description: "Commit plan (feature-list) and validate", difficulty: "easy" },
144
+ ],
145
+ };
146
+
147
+ export function isPhaseDone(dir: string, phase: import("./types.ts").Phase): boolean {
148
+ const { list } = loadFeatureList(dir);
149
+ const tasks = tasksForPhase(list, phase);
150
+ return tasks.length > 0 && tasks.every((t) => t.status === "complete");
151
+ }
152
+
153
+ export function seedPhaseIfEmpty(dir: string, phase: import("./types.ts").Phase): { seeded: boolean; error: string | null } {
154
+ const seeded = STARTER_TASKS[phase] ?? [];
155
+ if (seeded.length === 0) return { seeded: false, error: null };
156
+ try {
157
+ const { list } = loadFeatureList(dir);
158
+ const existing = tasksForPhase(list, phase);
159
+ if (existing.length > 0) return { seeded: false, error: null };
160
+ // Append to first feature matching phase, or create a phase feature.
161
+ const feature = (
162
+ list.features.find((f) => (f as { phase?: string }).phase === phase) ??
163
+ list.features[0] ??
164
+ ({ id: `phase-${phase}`, name: phase.toUpperCase(), tasks: [] } as unknown as typeof list.features[number])
165
+ );
166
+ if (!list.features.includes(feature as any)) {
167
+ (feature as { phase?: string }).phase = phase;
168
+ list.features.push(feature as any);
169
+ }
170
+ for (const t of seeded) {
171
+ if (feature.tasks.some((x) => x.id === t.id)) continue;
172
+ feature.tasks.push({
173
+ id: t.id,
174
+ description: t.description,
175
+ status: "pending" as const,
176
+ phase,
177
+ difficulty: t.difficulty,
178
+ } as any);
179
+ }
180
+ list.baseRevision = (list.baseRevision ?? 0) + 1;
181
+ saveFeatureList(dir, list);
182
+ return { seeded: true, error: null };
183
+ } catch (e) {
184
+ return { seeded: false, error: e instanceof Error ? e.message : String(e) };
185
+ }
186
+ }
187
+
128
188
  /** Advance one step along the enabled pipeline. */
129
189
  export async function advancePhase(targetDir: string): Promise<TransitionResult> {
130
190
  const { config, ok, error } = loadConfig(targetDir);
@@ -372,6 +372,27 @@ export const SETTINGS: SettingsGroup[] = [
372
372
  },
373
373
  ],
374
374
  },
375
+ {
376
+ id: "execution",
377
+ label: "Execution",
378
+ help: "How many things run at once, and at which level. Main session only shows progress; workers do the real work.",
379
+ settings: [
380
+ {
381
+ path: "execution.parallelAt",
382
+ file: "config",
383
+ label: "Parallel at",
384
+ help: "off: one at a time · goal: goals in parallel · phase: phases · sprint/feature/task/subtask. Pick one. Finer implies coarser.",
385
+ type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
386
+ },
387
+ {
388
+ path: "execution.maxWorkers",
389
+ file: "config",
390
+ label: "Max workers",
391
+ help: "How many workers run at once (1-16)._deps must be satisfied; parallelAt limits breadth.",
392
+ type: { kind: "number", min: 1, max: 16 },
393
+ },
394
+ ],
395
+ },
375
396
  {
376
397
  id: "commands",
377
398
  label: "Project commands",
@@ -473,41 +494,125 @@ export const SETTINGS: SettingsGroup[] = [
473
494
  {
474
495
  id: "retries",
475
496
  label: "Retry budgets",
476
- help: "How many attempts a task, feature or phase gets before the run escalates to you.",
497
+ help: "How many attempts a task, feature or phase gets before the run escalates to you. Levels are the generic ladder: goal → phase → sprint → feature → task → subtask.",
477
498
  settings: [
478
499
  {
479
500
  path: "maxRetries",
480
501
  file: "config",
502
+ label: "Task retries (legacy)",
503
+ help: "Legacy top-level task budget. Prefer retry.levels.task.maxRetries below.",
504
+ type: { kind: "number", min: 1, max: 100 },
505
+ },
506
+ {
507
+ path: "retry.levels.goal.enabled",
508
+ file: "config",
509
+ label: "Goal retries",
510
+ help: "Bound whole goal-pass retries (outer loop). Off by default.",
511
+ type: { kind: "boolean" },
512
+ },
513
+ {
514
+ path: "retry.levels.goal.maxRetries",
515
+ file: "config",
516
+ label: "Goal max",
517
+ help: "Attempts per goal when enabled.",
518
+ type: { kind: "number", min: 1, max: 100 },
519
+ },
520
+ {
521
+ path: "retry.levels.phase.enabled",
522
+ file: "config",
523
+ label: "Phase retries",
524
+ help: "Bound how many times one phase may repeat. Off by default.",
525
+ type: { kind: "boolean" },
526
+ },
527
+ {
528
+ path: "retry.levels.phase.maxRetries",
529
+ file: "config",
530
+ label: "Phase max",
531
+ help: "Attempts per phase when enabled.",
532
+ type: { kind: "number", min: 1, max: 100 },
533
+ },
534
+ {
535
+ path: "retry.levels.sprint.enabled",
536
+ file: "config",
537
+ label: "Sprint retries",
538
+ help: "Sprint-level retries. Off by default, as sprints are build-exclusive.",
539
+ type: { kind: "boolean" },
540
+ },
541
+ {
542
+ path: "retry.levels.sprint.maxRetries",
543
+ file: "config",
544
+ label: "Sprint max",
545
+ help: "Attempts per sprint when enabled.",
546
+ type: { kind: "number", min: 1, max: 100 },
547
+ },
548
+ {
549
+ path: "retry.levels.feature.enabled",
550
+ file: "config",
551
+ label: "Feature retries",
552
+ help: "Bound retries per feature. Off by default.",
553
+ type: { kind: "boolean" },
554
+ },
555
+ {
556
+ path: "retry.levels.feature.maxRetries",
557
+ file: "config",
558
+ label: "Feature max",
559
+ help: "Attempts per feature when enabled.",
560
+ type: { kind: "number", min: 1, max: 100 },
561
+ },
562
+ {
563
+ path: "retry.levels.task.enabled",
564
+ file: "config",
481
565
  label: "Task retries",
482
- help: "Attempts per task before the harness stops and asks for help.",
566
+ help: "The main retry budget, on by default. Subtask goes here too.",
567
+ type: { kind: "boolean" },
568
+ },
569
+ {
570
+ path: "retry.levels.task.maxRetries",
571
+ file: "config",
572
+ label: "Task max",
573
+ help: "Attempts per task. Leave unset to use maxRetries (legacy).",
574
+ type: { kind: "number", min: 1, max: 100 },
575
+ },
576
+ {
577
+ path: "retry.levels.subtask.enabled",
578
+ file: "config",
579
+ label: "Subtask retries",
580
+ help: "Whether subtasks get their own retry count before escalating to task.",
581
+ type: { kind: "boolean" },
582
+ },
583
+ {
584
+ path: "retry.levels.subtask.maxRetries",
585
+ file: "config",
586
+ label: "Subtask max",
587
+ help: "Attempts per subtask when enabled.",
483
588
  type: { kind: "number", min: 1, max: 100 },
484
589
  },
485
590
  {
486
591
  path: "retry.features.enabled",
487
592
  file: "config",
488
- label: "Feature retry budget",
489
- help: "Also bound retries per feature, not just per task.",
593
+ label: "(legacy) Feature retry",
594
+ help: "Legacy per-feature switch. Mirrors retry.levels.feature.enabled.",
490
595
  type: { kind: "boolean" },
491
596
  },
492
597
  {
493
598
  path: "retry.features.maxRetries",
494
599
  file: "config",
495
- label: "Feature retries",
496
- help: "Attempts per feature when the budget above is enabled.",
600
+ label: "(legacy) Feature max",
601
+ help: "Legacy feature budget value.",
497
602
  type: { kind: "number", min: 1, max: 100 },
498
603
  },
499
604
  {
500
605
  path: "retry.phases.enabled",
501
606
  file: "config",
502
- label: "Phase retry budget",
503
- help: "Also bound how many times one phase may repeat.",
607
+ label: "(legacy) Phase retry",
608
+ help: "Legacy per-phase switch. Mirrors retry.levels.phase.enabled.",
504
609
  type: { kind: "boolean" },
505
610
  },
506
611
  {
507
612
  path: "retry.phases.maxRetries",
508
613
  file: "config",
509
- label: "Phase retries",
510
- help: "Attempts per phase when the budget above is enabled.",
614
+ label: "(legacy) Phase max",
615
+ help: "Legacy phase budget value.",
511
616
  type: { kind: "number", min: 1, max: 100 },
512
617
  },
513
618
  ],
package/src/core/types.ts CHANGED
@@ -88,6 +88,8 @@ export type Task = {
88
88
  key?: string;
89
89
  description: string;
90
90
  status: TaskStatus;
91
+ /** Which pipeline phase this task belongs to. Absent means `build` for backwards compat. */
92
+ phase?: Phase;
91
93
  dependsOn?: string[];
92
94
  subtasks?: Subtask[];
93
95
  difficulty?: Difficulty;
@@ -102,6 +104,7 @@ export type Feature = {
102
104
  name: string;
103
105
  description?: string;
104
106
  passes?: boolean;
107
+ phase?: Phase;
105
108
  sprintId?: string;
106
109
  goalId?: string;
107
110
  criteria?: string[];
@@ -148,6 +151,10 @@ export type RetryBucket = {
148
151
  maxRetries: number | null;
149
152
  };
150
153
 
154
+ /** Levels that can each have their own retry budget and escalation state. */
155
+ export const RETRY_LEVELS = ["goal", "phase", "sprint", "feature", "task", "subtask"] as const;
156
+ export type RetryLevel = (typeof RETRY_LEVELS)[number];
157
+
151
158
  /**
152
159
  * How the run divides itself into pi sessions.
153
160
  *
@@ -160,6 +167,13 @@ export type RetryBucket = {
160
167
  */
161
168
  export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
162
169
 
170
+ export type ExecutionPolicy = {
171
+ /** Level at which parallel work is allowed. `off` = one task at a time. */
172
+ parallelAt: HandoffGranularity;
173
+ /** Max parallel workers (1..16). Guarded by lock and budget. */
174
+ maxWorkers: number;
175
+ };
176
+
163
177
  export type SessionPolicy = {
164
178
  /**
165
179
  * When to hand off to a fresh session.
@@ -274,6 +288,7 @@ export type HarnessConfig = {
274
288
  phases: { enabled: Phase[] };
275
289
  roles: { strict: boolean };
276
290
  session: SessionPolicy;
291
+ execution: ExecutionPolicy;
277
292
  /** Legacy: the three-phase approval switch 2.3 shipped. Migrated to `phaseModes`. */
278
293
  approvals: ApprovalPolicy;
279
294
  /** Mode per phase — the setting `approvals` became. */
@@ -294,12 +309,16 @@ export type HarnessConfig = {
294
309
  tasks: RetryBucket;
295
310
  features: RetryBucket;
296
311
  phases: RetryBucket;
312
+ /** New per-level budgets keyed by RetryLevel. Legacy fields remain for compat. */
313
+ levels: Partial<Record<RetryLevel, RetryBucket>>;
297
314
  };
298
315
  maxRetries: number;
299
316
  retryCount: number;
300
317
  taskRetryCount: number;
301
318
  featureRetryCount: number;
302
319
  phaseRetryCount: number;
320
+ /** Fine-grained counters per RetryLevel; zeroed on success at that level. */
321
+ retryPerLevel: Partial<Record<RetryLevel, number>>;
303
322
  pipelineIteration: number;
304
323
  gateHistory: GateHistoryEntry[];
305
324
  [k: string]: unknown;
package/src/escalate.ts CHANGED
@@ -66,6 +66,8 @@ export type EscalateOptions = {
66
66
  targetDir: string;
67
67
  runId: string;
68
68
  phase: Phase;
69
+ /** active retry level: subtask -> task -> ... -> goal; empty means "task" */
70
+ level?: string;
69
71
  /** The gate's failing checks, so the instruction can name them. */
70
72
  failures: string[];
71
73
  /** Whether the working tree moved since the last attempt. */
@@ -188,16 +190,28 @@ export async function escalate(options: EscalateOptions): Promise<Escalation> {
188
190
 
189
191
  case "consult": {
190
192
  const model = choice.nextModel ?? null;
193
+ const lvl = options.level ?? "task";
194
+ let thinkingHint: string | null = null;
195
+ try {
196
+ const { consultNextWithThinking, DIFFICULTY_LADDER } = await import("./modelRouter.ts");
197
+ const cur = (task?.difficulty ?? null) as string | null;
198
+ const idx = (DIFFICULTY_LADDER as readonly string[]).indexOf(cur ?? "");
199
+ const nextDiff = idx >= 0 && idx < (DIFFICULTY_LADDER.length as number) - 1 ? (DIFFICULTY_LADDER as readonly string[])[idx + 1] as string : null;
200
+ const picked = consultNextWithThinking(cur as string | null, { projectDir: targetDir, consultedCount: state.consultedCount });
201
+ void picked; void nextDiff;
202
+ thinkingHint = null; // keep instruction lean; spawned worker resolves thinking from router
203
+ } catch {}
191
204
  return {
192
205
  strategy: "consult",
193
- reason: choice.reason,
206
+ reason: `${choice.reason} [${lvl}]`,
194
207
  instruction:
195
- `ESCALATE. Reframing did not shift this either, so it is going to a stronger model` +
208
+ `ESCALATE [${lvl}]. Reframing did not shift this either, so it is going to a stronger model` +
196
209
  (model ? `: ${model}` : "") +
210
+ (thinkingHint ? ` (thinking: ${thinkingHint})` : "") +
197
211
  `. Write down, precisely, what you have tried and what the failure actually says — ` +
198
212
  `that hand-off is the whole value of the escalation.\n\nStill failing:\n${failureList}`,
199
213
  model,
200
- applied: model ? `consulting ${model}` : null,
214
+ applied: model ? `consulting ${model} [${lvl}]` : `consult [${lvl}]`,
201
215
  next: carry("consult", { consultedCount: state.consultedCount + 1 }),
202
216
  };
203
217
  }
package/src/intake.ts CHANGED
@@ -51,6 +51,8 @@ export type IntakeAnswers = {
51
51
  brief: string;
52
52
  /** Session handoff policy. Defaults to a fresh session per phase. */
53
53
  handoff?: SessionPolicy["handoff"];
54
+ parallelAt?: import("./core/types.ts").HandoffGranularity;
55
+ maxWorkers?: number;
54
56
  /** What the surfaces should draw. Defaults to the `focus` template. */
55
57
  display?: DisplayPolicy;
56
58
  /** Model routing for difficulty tiers and consulting. */
@@ -75,6 +77,7 @@ export type IntakePlan = {
75
77
  /** Kept in step with `phaseModes` so a 2.3 config read by a 2.3 tool still works. */
76
78
  approvals: ApprovalPolicy;
77
79
  session: SessionPolicy;
80
+ execution: import("./core/types.ts").ExecutionPolicy;
78
81
  display: DisplayPolicy;
79
82
  router?: IntakeAnswers["router"];
80
83
  /** What the human should be told about what they just chose. */
@@ -117,6 +120,8 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
117
120
  contextThreshold: handoff === "off" ? 0 : 0.6,
118
121
  carryNotes: true,
119
122
  };
123
+ const parallelAt = answers.parallelAt ?? "task";
124
+ const maxWorkers = Math.max(1, Math.min(16, Number.isFinite(answers.maxWorkers as number) ? Math.floor(answers.maxWorkers as number) : 3));
120
125
 
121
126
  const display = normalizeDisplay(answers.display ?? defaultDisplay());
122
127
  const brief = answers.brief?.trim() ? answers.brief.trim() : null;
@@ -162,6 +167,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
162
167
  plan: phaseModes.plan === "copilot",
163
168
  },
164
169
  session,
170
+ execution: { parallelAt, maxWorkers },
165
171
  display,
166
172
  router: answers.router,
167
173
  summary: summarize(workflow, phases, phaseModes, session, display, brief),
package/src/loop.ts CHANGED
@@ -24,10 +24,10 @@
24
24
  import { createHash } from "node:crypto";
25
25
  import { resolve } from "node:path";
26
26
  import type { HarnessConfig, Phase } from "./core/types.ts";
27
- import { loadConfig, saveConfig, isRetryExhausted, incrementPhaseRetry } from "./core/config.ts";
27
+ import { loadConfig, saveConfig, isRetryExhausted, incrementPhaseRetry, incrementRetryLevel, zeroLowerOnPass } from "./core/config.ts";
28
28
  import { loadFeatureList, computeProgress, nextActionableTask } from "./core/featureList.ts";
29
29
  import { runChecks } from "./core/gates.ts";
30
- import { advancePhase, isFinalPhase, nextPhase } from "./core/phases.ts";
30
+ import { advancePhase, isFinalPhase, nextPhase, seedPhaseIfEmpty } from "./core/phases.ts";
31
31
  import { buildBrief, renderBrief } from "./core/brief.ts";
32
32
  import { harnessDir } from "./core/paths.ts";
33
33
  import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.ts";
@@ -38,6 +38,7 @@ import {
38
38
  describeEscalation,
39
39
  type EscalationState,
40
40
  } from "./escalate.ts";
41
+ import { executionPolicyOf, pickRunnableTasks } from "./scheduler.ts";
41
42
  import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
42
43
  import {
43
44
  needsApproval,
@@ -66,10 +67,12 @@ export type LoopState = {
66
67
  lastDecision: string | null;
67
68
  stoppedAt: string | null;
68
69
  stopReason: string | null;
69
- /** Where this run sits on the escalation ladder. */
70
+ /** Where this run sits on the escalation ladder (run-scoped fallback). */
70
71
  escalation: EscalationState;
72
+ /** Per-level escalation: each level has its own ladder progress. */
73
+ perLevelEscalation: Partial<Record<import("./core/types.ts").RetryLevel, EscalationState>>;
71
74
  /** Every rung taken, so the human coming back can see the shape of it. */
72
- escalations: { at: string; strategy: string; reason: string; applied: string | null }[];
75
+ escalations: { at: string; strategy: string; level?: string; reason: string; applied: string | null }[];
73
76
  };
74
77
 
75
78
  /** Escalation history kept in the loop state. Older entries tell no story. */
@@ -124,6 +127,7 @@ export function newLoopState(runId: string, now = new Date()): LoopState {
124
127
  stoppedAt: null,
125
128
  stopReason: null,
126
129
  escalation: emptyEscalationState(),
130
+ perLevelEscalation: {},
127
131
  escalations: [],
128
132
  };
129
133
  }
@@ -135,12 +139,26 @@ export function loadLoopState(targetDir: string, runId: string, now = new Date()
135
139
  return {
136
140
  ...stored,
137
141
  escalation: { ...emptyEscalationState(), ...(stored.escalation ?? {}) },
142
+ perLevelEscalation: (stored as Record<string, unknown>).perLevelEscalation
143
+ ? (stored as unknown as LoopState).perLevelEscalation
144
+ : {},
138
145
  escalations: Array.isArray(stored.escalations) ? stored.escalations : [],
139
146
  };
140
147
  }
141
148
  return newLoopState(runId, now);
142
149
  }
143
150
 
151
+ export function escalationStateForLevel(state: LoopState, level: string): EscalationState {
152
+ const key = level as import("./core/types.ts").RetryLevel;
153
+ return (state.perLevelEscalation[key] ?? emptyEscalationState()) as EscalationState;
154
+ }
155
+ export function setEscalationForLevel(state: LoopState, level: string, next: EscalationState): void {
156
+ const key = level as import("./core/types.ts").RetryLevel;
157
+ state.perLevelEscalation[key] = next;
158
+ // Keep top-level in step with the finest active level so old widget still reads it.
159
+ state.escalation = next;
160
+ }
161
+
144
162
  export function saveLoopState(targetDir: string, state: LoopState): void {
145
163
  try {
146
164
  writeJsonAtomic(loopStatePath(targetDir), state);
@@ -257,6 +275,25 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
257
275
 
258
276
  state.lastPhase = config.currentPhase;
259
277
 
278
+ // Ensure DEFINE/PLAN have at least seed tasks — fixes DEFINE rev 0
279
+ // idempotently and ensures handoff's toTask becomes non-null shortly after
280
+ // RESEARCH auto-advanced. Only enabled doc phases with no tasksForPhase are
281
+ // seeded, so synthetic convergence projects (with already-complete BUILD tasks)
282
+ // do not get a dirty feature-list. Also: if a phase's task-gate (featureCriteria)
283
+ // already passes, do not seed — the plan is already satisfied without starters.
284
+ if (config.currentPhase && (config.phases?.enabled ?? []).includes(config.currentPhase)) {
285
+ try {
286
+ const { list: _list } = loadFeatureList(targetDir);
287
+ const hasPhaseTasks = (await import("./core/featureList.ts")).tasksForPhase(_list, config.currentPhase).length > 0;
288
+ if (!hasPhaseTasks) {
289
+ // Do not seed when the gate is already satisfied — the synthetic converge
290
+ // walk expects define->ship without touching the tree.
291
+ const gateTrial = await runChecks(targetDir, config.currentPhase, { record: false });
292
+ if (!gateTrial.overall) seedPhaseIfEmpty(targetDir, config.currentPhase);
293
+ }
294
+ } catch {}
295
+ }
296
+
260
297
  // -- terminal conditions --------------------------------------------------
261
298
  const { list } = loadFeatureList(targetDir);
262
299
  const progress = computeProgress(list);
@@ -377,9 +414,17 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
377
414
  // phase, spending `retry` and `reframe` on an agent that had not yet been
378
415
  // given a chance to do anything. A stall means the agent produced nothing
379
416
  // when asked; a fresh brief has not asked yet.
417
+ // Zero lower-level retry counters on a phase pass (task/subtask streaks reset for the new phase).
418
+ try {
419
+ const movedCfg = moved.config ?? loadConfig(targetDir).config;
420
+ zeroLowerOnPass(movedCfg, "phase");
421
+ saveConfig(targetDir, movedCfg);
422
+ } catch {}
380
423
  state.lastFingerprint = null;
381
424
  state.noProgressStreak = 0;
382
425
  state.escalation = { ...state.escalation, tried: [] };
426
+ // Also reset per-level escalation for task/subtask so the next phase starts clean.
427
+ state.perLevelEscalation = {};
383
428
 
384
429
  const brief = await buildBrief(targetDir);
385
430
  return finish({
@@ -400,6 +445,9 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
400
445
  const fp = await fingerprint(targetDir);
401
446
  state.lastFingerprint = fp;
402
447
 
448
+ // Active retry level: finest active level with work (subtask > task > feature > sprint > phase > goal).
449
+ const retryLevel = detectRetryLevel(list, config.currentPhase, phase);
450
+
403
451
  if (previous === null || previous !== fp) {
404
452
  state.noProgressStreak = 0;
405
453
  // The tree moved, so whatever the run was stuck on, it is not stuck on it
@@ -408,6 +456,10 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
408
456
  // stalls, but a rung spent on a problem that resolved should not be
409
457
  // missing when a different problem appears.
410
458
  state.escalation = { ...state.escalation, tried: [] };
459
+ const lev = retryLevel
460
+ ? escalationStateForLevel(state, retryLevel)
461
+ : null;
462
+ if (lev) setEscalationForLevel(state, retryLevel, { ...lev, tried: [] });
411
463
  } else {
412
464
  state.noProgressStreak += 1;
413
465
  }
@@ -423,8 +475,11 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
423
475
  // Escalating never *prevents* the run from stopping. The strike is still
424
476
  // counted; the ladder just gets a turn first, so a run stops because nothing
425
477
  // worked rather than because nothing was tried.
478
+ // Per-level: each retryLevel has its own EscalationState, consult+thinking escalation
479
+ // climbs with it, and lower levels are zeroed on a pass at a coarser level.
426
480
  let escalation = null as Awaited<ReturnType<typeof escalate>> | null;
427
481
  if (!options.skipEscalation && state.noProgressStreak >= ESCALATE_AFTER_STALLS) {
482
+ const levState = escalationStateForLevel(state, retryLevel ?? "task");
428
483
  escalation = await escalate({
429
484
  targetDir,
430
485
  runId,
@@ -432,16 +487,18 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
432
487
  failures: gate ? gate.failures : [],
433
488
  fileDelta: previous !== null && previous !== fp,
434
489
  fingerprint: fp,
435
- state: state.escalation,
490
+ state: levState,
491
+ level: retryLevel ?? "task",
436
492
  now,
437
493
  });
438
- state.escalation = escalation.next;
494
+ setEscalationForLevel(state, retryLevel ?? "task", escalation.next);
439
495
  if (escalation.strategy) {
440
496
  state.escalations = [
441
497
  ...state.escalations,
442
498
  {
443
499
  at: now.toISOString(),
444
500
  strategy: escalation.strategy,
501
+ level: retryLevel ?? "task",
445
502
  reason: escalation.reason,
446
503
  applied: escalation.applied,
447
504
  },
@@ -482,14 +539,47 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
482
539
  });
483
540
  }
484
541
 
485
- // Charge a phase retry so the configured budget still bounds the run even
486
- // when the tree keeps changing but the gate never opens.
542
+ // Charge retries at the active level as well as the legacy phase counter.
487
543
  const fresh = loadConfig(targetDir);
544
+ let execPolicy: { parallelAt: string; maxWorkers: number } | null = null;
488
545
  if (fresh.ok) {
489
- incrementPhaseRetry(fresh.config);
546
+ execPolicy = executionPolicyOf(fresh.config) as { parallelAt: string; maxWorkers: number };
547
+ incrementRetryLevel(fresh.config, retryLevel ?? "phase");
548
+ // Keep phase counter in step as the global guard so existing budgets still fire.
549
+ if ((retryLevel ?? "phase") !== "phase") incrementPhaseRetry(fresh.config);
490
550
  saveConfig(targetDir, fresh.config);
491
551
  }
492
552
 
553
+ // Auto-spawn isolated workers for eligible tasks at the current phase —
554
+ // the main session stays as orchestrator/visualisation only. Workers are
555
+ // created empty (no shell command) so realpi/e2e without a worker runtime
556
+ // still advances via gate; the brief still drives the main session until a
557
+ // real runner picks the attempt up. This makes the main session safe to
558
+ // observe but not edit the plan.
559
+ try {
560
+ if (execPolicy && execPolicy.parallelAt !== "off" && fresh?.ok) {
561
+ const eligible = pickRunnableTasks({
562
+ targetDir,
563
+ phase: fresh.config.currentPhase as import("./core/types.ts").Phase | null,
564
+ parallelAt: execPolicy.parallelAt as import("./core/types.ts").HandoffGranularity,
565
+ maxWorkers: execPolicy.maxWorkers,
566
+ });
567
+ if (eligible.length > 0) {
568
+ const { spawnWorkers } = await import("./scheduler.ts");
569
+ const curBrief = await buildBrief(targetDir);
570
+ const briefFor = (t: import("./core/featureList.ts").FlatTask): string =>
571
+ `Task ${t.compositeKey} in ${fresh.config.currentPhase}: ${t.description}` +
572
+ `\nAcceptance: ${(t.criteria ?? (curBrief.criteria ?? [])).join("; ")}`;
573
+ // Fire-and-forget so the brief still returns promptly; harness does not
574
+ // depend on the child process (covered by e2e). Errors are best-effort.
575
+ spawnWorkers(targetDir, eligible, {
576
+ runId,
577
+ promptFor: briefFor,
578
+ }).catch(() => {});
579
+ }
580
+ }
581
+ } catch {}
582
+
493
583
  const brief = await buildBrief(targetDir);
494
584
  const failures = gate
495
585
  ? gate.checks
@@ -562,6 +652,22 @@ async function requestGoalReview(
562
652
  }
563
653
  }
564
654
 
655
+ /** Finest active level with pending work — decides which retry ladder to climb. */
656
+ export function detectRetryLevel(list: import("./core/types.ts").FeatureList, phase: import("./core/types.ts").Phase | null, _activePhase: import("./core/types.ts").Phase): string {
657
+ try {
658
+ const tasks = phase ? list.features.flatMap((f) => (f as { phase?: string }).phase === phase || (f.tasks as { phase?: string }[]).some((t) => t.phase === phase) ? f.tasks : []) : [];
659
+ // Prefer task-level detection from subtasks
660
+ const all = list.features.flatMap((f) => f.tasks);
661
+ const hasSubtaskPending = all.some((t) => (t.subtasks ?? []).some((s) => s.status !== "complete"));
662
+ const hasTaskPending = all.some((t) => t.status !== "complete");
663
+ if (hasSubtaskPending) return "subtask";
664
+ if (hasTaskPending) return "task";
665
+ const featsPending = (list.features ?? []).some((f) => !(f.tasks ?? []).every((t) => t.status === "complete"));
666
+ if (featsPending) return "feature";
667
+ return "phase";
668
+ } catch { return "task"; }
669
+ }
670
+
565
671
  /** Human-readable one-liner for the status bar / notify. */
566
672
  export function describeDecision(d: LoopDecision): string {
567
673
  switch (d.action) {
@@ -168,6 +168,20 @@ export function resolveModel(opts: ResolveOpts = {}): string {
168
168
  * MASTER never assigned, only via consultNext after exhaustion.
169
169
  * Returns next model or null if at top/budget exhausted.
170
170
  */
171
+ export function consultNextWithThinking(
172
+ currentDifficulty: string | null | undefined,
173
+ opts: { projectDir?: string; consultedCount?: number } = {},
174
+ ): { model: string | null; thinking: ThinkingLevel | "" } {
175
+ const model = consultNext(currentDifficulty, opts);
176
+ let thinking: ThinkingLevel | "" = "";
177
+ if (model) {
178
+ const idx = DIFFICULTY_LADDER.indexOf(currentDifficulty as any);
179
+ const nextDiff = idx >= 0 && idx < DIFFICULTY_LADDER.length - 1 ? DIFFICULTY_LADDER[idx + 1] as string : (idx === DIFFICULTY_LADDER.length - 1 ? null : null);
180
+ thinking = resolveThinkingForConsult(nextDiff, opts.projectDir) ?? "";
181
+ }
182
+ return { model, thinking };
183
+ }
184
+
171
185
  export function consultNext(
172
186
  currentDifficulty: string | null | undefined,
173
187
  opts: { projectDir?: string; consultedCount?: number } = {},
package/src/remote.ts CHANGED
@@ -57,6 +57,8 @@ export interface RemoteState {
57
57
  goalPass: { current: number; max: number } | null;
58
58
  /** What the reader has asked the dashboard to draw. */
59
59
  display: DisplayPolicy;
60
+ execution: unknown;
61
+ workers: unknown;
60
62
  }
61
63
 
62
64
  export interface RemoteServer {
@@ -116,6 +118,15 @@ export function buildRemoteState(projectDir?: string): RemoteState {
116
118
  rework: readJsonSafe<unknown>(reworkPath(dir), null),
117
119
  awaitingApproval: config.awaitingApproval ?? null,
118
120
  sessions: loadRunState(dir)?.sessions ?? null,
121
+ execution: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config); } catch { return null; } })(),
122
+ workers: (() => {
123
+ try {
124
+ const { listWorkers } = require("./scheduler.ts");
125
+ const runId = (() => { try { return (require("./runState.ts") as { runIdFor: (d:string,f:string)=>string }).runIdFor(dir, ""); } catch { return undefined; } })();
126
+ const ws = listWorkers(dir, runId || undefined) as unknown[];
127
+ return Array.isArray(ws) ? ws.slice(0, 6) : [];
128
+ } catch { return []; }
129
+ })(),
119
130
  goalPass:
120
131
  typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
121
132
  ? { current: config.goalPass, max: config.goalMaxPasses }