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.
@@ -51,6 +51,7 @@ export function defaultConfig(): HarnessConfig {
51
51
  phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
52
52
  roles: { strict: false },
53
53
  session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
54
+ execution: { parallelAt: "task", maxWorkers: 3 },
54
55
  approvals: { research: false, define: false, plan: false },
55
56
  phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
56
57
  workflow: { id: "autopilot", name: "autopilot" },
@@ -66,12 +67,21 @@ export function defaultConfig(): HarnessConfig {
66
67
  tasks: { enabled: true, maxRetries: null },
67
68
  features: { enabled: false, maxRetries: DEFAULT_FEATURE_RETRIES },
68
69
  phases: { enabled: false, maxRetries: DEFAULT_PHASE_RETRIES },
70
+ levels: {
71
+ goal: { enabled: false, maxRetries: 2 },
72
+ phase: { enabled: false, maxRetries: DEFAULT_PHASE_RETRIES },
73
+ sprint: { enabled: false, maxRetries: DEFAULT_FEATURE_RETRIES },
74
+ feature: { enabled: false, maxRetries: DEFAULT_FEATURE_RETRIES },
75
+ task: { enabled: true, maxRetries: null },
76
+ subtask: { enabled: false, maxRetries: 3 },
77
+ },
69
78
  },
70
79
  maxRetries: DEFAULT_MAX_RETRIES,
71
80
  retryCount: 0,
72
81
  taskRetryCount: 0,
73
82
  featureRetryCount: 0,
74
83
  phaseRetryCount: 0,
84
+ retryPerLevel: {},
75
85
  pipelineIteration: 0,
76
86
  gateHistory: [],
77
87
  };
@@ -235,46 +245,104 @@ export type EffectiveRetry = {
235
245
  tasks: { enabled: boolean; max: number };
236
246
  features: { enabled: boolean; max: number };
237
247
  phases: { enabled: boolean; max: number };
248
+ levels: Record<string, { enabled: boolean; max: number }>;
249
+ };
250
+
251
+ const LEVEL_DEFAULTS: Record<string, number> = {
252
+ goal: 2,
253
+ phase: DEFAULT_PHASE_RETRIES,
254
+ sprint: DEFAULT_FEATURE_RETRIES,
255
+ feature: DEFAULT_FEATURE_RETRIES,
256
+ task: DEFAULT_MAX_RETRIES,
257
+ subtask: 3,
238
258
  };
239
259
 
240
260
  /** Resolve retry budgets, seeding task retries from the legacy `maxRetries`. */
241
261
  export function getRetryConfig(config: HarnessConfig): EffectiveRetry {
242
262
  const legacy = typeof config.maxRetries === "number" ? config.maxRetries : DEFAULT_MAX_RETRIES;
243
- const r = config.retry ?? defaultConfig().retry;
263
+ const r = (config.retry ?? defaultConfig().retry) as typeof defaultConfig.prototype.retry & { levels?: Record<string, { enabled?: boolean; maxRetries?: number | null }> };
264
+ const levels: Record<string, { enabled: boolean; max: number }> = {};
265
+ for (const k of ["goal","phase","sprint","feature","task","subtask"]) {
266
+ const bucket = (r as Record<string, unknown>).levels ? ((r as Record<string, unknown>).levels as Record<string, { enabled?: boolean; maxRetries?: number | null }>)[k] : undefined;
267
+ const defEnabled = k === "task";
268
+ const defMax = LEVEL_DEFAULTS[k] ?? DEFAULT_MAX_RETRIES;
269
+ const enabled = bucket ? (bucket.enabled ?? defEnabled) : defEnabled;
270
+ const rawMax = bucket ? (bucket.maxRetries ?? null) : null;
271
+ // Unset task level inherits legacy maxRetries
272
+ const max = rawMax === null ? (k === "task" ? legacy : defMax) : rawMax;
273
+ levels[k] = { enabled, max };
274
+ }
275
+ // Also keep legacy per-name budgets for compatibility
244
276
  return {
245
277
  tasks: { enabled: r.tasks?.enabled ?? true, max: r.tasks?.maxRetries ?? legacy },
246
278
  features: { enabled: r.features?.enabled ?? false, max: r.features?.maxRetries ?? DEFAULT_FEATURE_RETRIES },
247
279
  phases: { enabled: r.phases?.enabled ?? false, max: r.phases?.maxRetries ?? DEFAULT_PHASE_RETRIES },
280
+ levels,
248
281
  };
249
282
  }
250
283
 
284
+ /** Generic per-level retry counter helpers (zero on pass, escalate on exhaustion). */
285
+ export function getRetryLevel(config: HarnessConfig, level: string): number {
286
+ const m = (config.retryPerLevel ?? {}) as Record<string, number>;
287
+ return typeof m[level] === "number" ? m[level]! : 0;
288
+ }
289
+ export function resetRetryLevel(config: HarnessConfig, level: string): void {
290
+ if (!config.retryPerLevel) config.retryPerLevel = {};
291
+ (config.retryPerLevel as Record<string, number>)[level] = 0;
292
+ // keep legacy counters in step
293
+ if (level === "task") config.taskRetryCount = 0;
294
+ if (level === "feature") config.featureRetryCount = 0;
295
+ if (level === "phase") { config.phaseRetryCount = 0; config.retryCount = 0; }
296
+ }
297
+ export function incrementRetryLevel(config: HarnessConfig, level: string): number {
298
+ if (!config.retryPerLevel) config.retryPerLevel = {};
299
+ const m = config.retryPerLevel as Record<string, number>;
300
+ m[level] = (m[level] ?? 0) + 1;
301
+ if (level === "task") config.taskRetryCount = m[level]!;
302
+ if (level === "feature") config.featureRetryCount = m[level]!;
303
+ if (level === "phase") { config.phaseRetryCount = m[level]!; config.retryCount = m[level]!; }
304
+ return m[level]!;
305
+ }
306
+ /** Zero all strictly lower levels than `passedLevel` on a pass (e.g. task pass zeroes subtask). */
307
+ export function zeroLowerOnPass(config: HarnessConfig, passedLevel: string): void {
308
+ const order = ["goal","phase","sprint","feature","task","subtask"];
309
+ const idx = order.indexOf(passedLevel);
310
+ if (idx === -1) return;
311
+ for (let i = idx + 1; i < order.length; i++) {
312
+ const lower = order[i]!;
313
+ if (getRetryLevel(config, lower) !== 0) resetRetryLevel(config, lower);
314
+ }
315
+ }
316
+
251
317
  export function resetTaskRetry(config: HarnessConfig): void {
252
- config.taskRetryCount = 0;
318
+ resetRetryLevel(config, "task");
253
319
  }
254
320
  export function incrementTaskRetry(config: HarnessConfig): number {
255
- config.taskRetryCount = (config.taskRetryCount ?? 0) + 1;
256
- return config.taskRetryCount;
321
+ return incrementRetryLevel(config, "task");
257
322
  }
258
323
  export function resetFeatureRetry(config: HarnessConfig): void {
259
- config.featureRetryCount = 0;
324
+ resetRetryLevel(config, "feature");
260
325
  }
261
326
  export function incrementFeatureRetry(config: HarnessConfig): number {
262
- config.featureRetryCount = (config.featureRetryCount ?? 0) + 1;
263
- return config.featureRetryCount;
327
+ return incrementRetryLevel(config, "feature");
264
328
  }
265
329
  export function resetPhaseRetry(config: HarnessConfig): void {
266
- config.phaseRetryCount = 0;
267
- config.retryCount = 0;
330
+ resetRetryLevel(config, "phase");
268
331
  }
269
332
  export function incrementPhaseRetry(config: HarnessConfig): number {
270
- config.phaseRetryCount = (config.phaseRetryCount ?? 0) + 1;
271
- config.retryCount = (config.retryCount ?? 0) + 1;
272
- return config.phaseRetryCount;
333
+ return incrementRetryLevel(config, "phase");
273
334
  }
274
335
 
275
336
  /** True when any *enabled* retry budget is exhausted — the signal to escalate. */
276
337
  export function isRetryExhausted(config: HarnessConfig): { exhausted: boolean; which: string | null } {
277
338
  const r = getRetryConfig(config);
339
+ // Prefer per-level levels when enabled, but retain legacy task/feature/phase order for compatibility.
340
+ for (const lvl of ["subtask","task","feature","sprint","phase","goal"]) {
341
+ const b = r.levels[lvl];
342
+ if (!b) continue;
343
+ const cnt = getRetryLevel(config as unknown as HarnessConfig, lvl);
344
+ if (b.enabled && cnt >= b.max) return { exhausted: true, which: lvl };
345
+ }
278
346
  if (r.tasks.enabled && (config.taskRetryCount ?? 0) >= r.tasks.max) return { exhausted: true, which: "task" };
279
347
  if (r.features.enabled && (config.featureRetryCount ?? 0) >= r.features.max) return { exhausted: true, which: "feature" };
280
348
  if (r.phases.enabled && (config.phaseRetryCount ?? 0) >= r.phases.max) return { exhausted: true, which: "phase" };
@@ -124,8 +124,11 @@ function normalizeList(raw: FeatureList): FeatureList {
124
124
  features: Array.isArray(raw.features) ? raw.features : [],
125
125
  };
126
126
  for (const f of list.features) {
127
+ // Keep optional `phase` absent when not set so strict round-trip equality holds for legacy files.
128
+ if ((f as { phase?: unknown }).phase !== undefined && typeof (f as { phase?: unknown }).phase !== "string") delete (f as { phase?: unknown }).phase;
127
129
  if (!Array.isArray(f.tasks)) f.tasks = [];
128
130
  for (const t of f.tasks) {
131
+ if ((t as { phase?: unknown }).phase !== undefined && typeof (t as { phase?: unknown }).phase !== "string") delete (t as { phase?: unknown }).phase;
129
132
  if (!Array.isArray(t.dependsOn)) t.dependsOn = [];
130
133
  if (!Array.isArray(t.subtasks)) t.subtasks = [];
131
134
  try {
@@ -151,6 +154,8 @@ export type FlatTask = Task & {
151
154
  compositeKey: string;
152
155
  featureId: string;
153
156
  featureName: string;
157
+ /** Effective phase of this task: `task.phase ?? feature.phase ?? "build"`. */
158
+ effectivePhase: import("./types.ts").Phase | undefined;
154
159
  /** 1-based position in the flattened plan, used for `← #3` dep labels. */
155
160
  index: number;
156
161
  };
@@ -160,13 +165,16 @@ export function flattenTasks(list: FeatureList): FlatTask[] {
160
165
  const out: FlatTask[] = [];
161
166
  let i = 0;
162
167
  for (const f of list.features ?? []) {
168
+ const featurePhase = (f as { phase?: string }).phase as import("./types.ts").Phase | undefined;
163
169
  for (const t of f.tasks ?? []) {
164
170
  i += 1;
171
+ const eff = (t as { phase?: string }).phase as string | undefined ?? featurePhase ?? "build";
165
172
  out.push({
166
173
  ...t,
167
174
  compositeKey: t.key ?? `${f.id}/${t.id}`,
168
175
  featureId: f.id,
169
176
  featureName: f.name,
177
+ effectivePhase: eff as FlatTask["effectivePhase"],
170
178
  index: i,
171
179
  });
172
180
  }
@@ -208,10 +216,40 @@ export type Progress = {
208
216
  percent: number;
209
217
  };
210
218
 
211
- export function computeProgress(list: FeatureList): Progress {
212
- const tasks = flattenTasks(list);
219
+ /** Phase-filtered view: include only tasks whose effectivePhase matches. Pass nothing for global. */
220
+ export function tasksForPhase(list: FeatureList, phase?: string | null): FlatTask[] {
221
+ const all = flattenTasks(list);
222
+ if (!phase) return all;
223
+ return all.filter((t) => t.effectivePhase === phase);
224
+ }
225
+
226
+ export function featuresForPhase(list: FeatureList, phase?: string | null): import("./types.ts").Feature[] {
227
+ if (!phase) return list.features ?? [];
228
+ return (list.features ?? []).filter((f) => (f as { phase?: string }).phase === phase);
229
+ }
230
+
231
+ export function computeProgress(list: FeatureList, phase?: string | null): Progress {
232
+ if (!phase) {
233
+ const tasks = flattenTasks(list);
234
+ const tasksDone = tasks.filter((t) => isDone(t.status)).length;
235
+ const features = list.features ?? [];
236
+ const featuresDone = features.filter(
237
+ (f) => (f.tasks ?? []).length > 0 && (f.tasks ?? []).every((t) => isDone(t.status)),
238
+ ).length;
239
+ return {
240
+ tasksDone,
241
+ tasksTotal: tasks.length,
242
+ featuresDone,
243
+ featuresTotal: features.length,
244
+ blocked: tasks.filter((t) => t.status === "blocked").length,
245
+ inProgress: tasks.filter((t) => t.status === "in_progress").length,
246
+ rework: tasks.filter((t) => t.status === "rework").length,
247
+ percent: tasks.length === 0 ? 0 : Math.round((tasksDone / tasks.length) * 100),
248
+ };
249
+ }
250
+ const tasks = tasksForPhase(list, phase);
213
251
  const tasksDone = tasks.filter((t) => isDone(t.status)).length;
214
- const features = list.features ?? [];
252
+ const features = featuresForPhase(list, phase);
215
253
  const featuresDone = features.filter(
216
254
  (f) => (f.tasks ?? []).length > 0 && (f.tasks ?? []).every((t) => isDone(t.status)),
217
255
  ).length;
@@ -228,12 +266,11 @@ export function computeProgress(list: FeatureList): Progress {
228
266
  }
229
267
 
230
268
  /**
231
- * The next task the pipeline should work on: the first in_progress task,
232
- * else the first pending task whose dependencies are all complete.
269
+ * The next task the pipeline should work on (optionally scoped to one phase).
233
270
  * Returns null when everything is done or everything left is blocked.
234
271
  */
235
- export function nextActionableTask(list: FeatureList): FlatTask | null {
236
- const tasks = flattenTasks(list);
272
+ export function nextActionableTask(list: FeatureList, phase?: string | null): FlatTask | null {
273
+ const tasks = phase ? tasksForPhase(list, phase) : flattenTasks(list);
237
274
  const byKey = new Map<string, FlatTask>();
238
275
  for (const t of tasks) {
239
276
  byKey.set(t.compositeKey, t);
package/src/core/gates.ts CHANGED
@@ -336,11 +336,25 @@ async function checkFeatureCriteria({ targetDir }: Ctx): Promise<CheckResult> {
336
336
  : fail("feature-criteria", `features without criteria: ${missing.join(", ")}`);
337
337
  }
338
338
 
339
- /** Every task in the plan must be complete before the phase gate opens. */
340
- async function checkTasksComplete({ targetDir }: Ctx): Promise<CheckResult> {
339
+ /** Tasks of the phase being checked must be complete, not tasks from other phases.
340
+ * When a task has no `phase` it is treated as "build" (backwards compat via effectivePhase).
341
+ * The difference between `feature-iterate` (task) and `deliverable-retry` (checklist) phases is now
342
+ * that seeded `define`/`plan` tasks are phase-tagged, so BUILD progress ignores them — and a completed BUILD
343
+ * does not stall because pending SHIP review tasks exist elsewhere.
344
+ */
345
+ async function checkTasksComplete({ targetDir, config }: Ctx): Promise<CheckResult> {
341
346
  const { list } = loadFeatureList(targetDir);
342
- const p = computeProgress(list);
343
- if (p.tasksTotal === 0) return fail("tasks-complete", "no tasks planned");
347
+ const p = computeProgress(list, config.currentPhase as string | null);
348
+ if (p.tasksTotal === 0) {
349
+ // Un-tagged builds with no effective BUILD task yet (only define/plan tasks seeded) are not gated on tasks.
350
+ // Once BUILD has tasks, they must all complete. This preserves mkSatisfiableProject converge walk.
351
+ const global = computeProgress(list);
352
+ const buildScoped = computeProgress(list, "build");
353
+ if (config.currentPhase === "build" && global.tasksTotal > 0 && buildScoped.tasksTotal === 0) {
354
+ return pass("tasks-complete", `${global.tasksDone}/${global.tasksTotal} tasks (no build tasks yet, gated on other phases)`);
355
+ }
356
+ return fail("tasks-complete", "no tasks planned");
357
+ }
344
358
  if (p.tasksDone === p.tasksTotal) return pass("tasks-complete", `${p.tasksDone}/${p.tasksTotal} tasks complete`);
345
359
  const remaining = p.tasksTotal - p.tasksDone;
346
360
  return fail(
package/src/core/init.ts CHANGED
@@ -160,6 +160,7 @@ export type InitOptions = {
160
160
  display?: HarnessConfig["display"];
161
161
  /** Session-handoff policy. Defaults to a fresh session per phase. */
162
162
  session?: Partial<HarnessConfig["session"]>;
163
+ execution?: Partial<HarnessConfig["execution"]>;
163
164
  /** What the human said they want built. Recorded, and read by the first brief. */
164
165
  brief?: string | null;
165
166
  /** Model routing for difficulty tiers and consulting. */
@@ -220,6 +221,7 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
220
221
  config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
221
222
  config.approvals = { ...config.approvals, ...stripUndefined(options.approvals ?? {}) };
222
223
  config.session = { ...config.session, ...stripUndefined(options.session ?? {}) };
224
+ config.execution = { ...config.execution, ...stripUndefined(options.execution ?? {}) };
223
225
  // Every enabled phase gets a mode, so a phase list and a mode map cannot
224
226
  // disagree about which phases exist. A caller that still passes the 2.3
225
227
  // `approvals` shape and no modes gets what it asked for rather than silently
@@ -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
  }