infinity-harness 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/extensions/infinity-harness/index.ts +148 -30
- package/harness/docs/plans/2.6-unified-phase-parallel-workers.md +234 -0
- package/package.json +1 -1
- package/src/core/brief.ts +2 -1
- package/src/core/config.ts +80 -12
- package/src/core/featureList.ts +44 -7
- package/src/core/gates.ts +18 -4
- package/src/core/init.ts +2 -0
- package/src/core/phases.ts +60 -0
- package/src/core/settings.ts +115 -10
- package/src/core/types.ts +19 -0
- package/src/escalate.ts +17 -3
- package/src/intake.ts +6 -0
- package/src/loop.ts +115 -9
- package/src/modelRouter.ts +14 -0
- package/src/remote.ts +11 -0
- package/src/replan.ts +8 -3
- package/src/scheduler.ts +205 -0
- package/src/taskList.ts +3 -1
- package/src/ui/wizard.ts +22 -2
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
318
|
+
resetRetryLevel(config, "task");
|
|
253
319
|
}
|
|
254
320
|
export function incrementTaskRetry(config: HarnessConfig): number {
|
|
255
|
-
|
|
256
|
-
return config.taskRetryCount;
|
|
321
|
+
return incrementRetryLevel(config, "task");
|
|
257
322
|
}
|
|
258
323
|
export function resetFeatureRetry(config: HarnessConfig): void {
|
|
259
|
-
config
|
|
324
|
+
resetRetryLevel(config, "feature");
|
|
260
325
|
}
|
|
261
326
|
export function incrementFeatureRetry(config: HarnessConfig): number {
|
|
262
|
-
|
|
263
|
-
return config.featureRetryCount;
|
|
327
|
+
return incrementRetryLevel(config, "feature");
|
|
264
328
|
}
|
|
265
329
|
export function resetPhaseRetry(config: HarnessConfig): void {
|
|
266
|
-
config
|
|
267
|
-
config.retryCount = 0;
|
|
330
|
+
resetRetryLevel(config, "phase");
|
|
268
331
|
}
|
|
269
332
|
export function incrementPhaseRetry(config: HarnessConfig): number {
|
|
270
|
-
|
|
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" };
|
package/src/core/featureList.ts
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
340
|
-
|
|
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)
|
|
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
|
package/src/core/phases.ts
CHANGED
|
@@ -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);
|
package/src/core/settings.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
489
|
-
help: "
|
|
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
|
|
496
|
-
help: "
|
|
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
|
|
503
|
-
help: "
|
|
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
|
|
510
|
-
help: "
|
|
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}` :
|
|
214
|
+
applied: model ? `consulting ${model} [${lvl}]` : `consult [${lvl}]`,
|
|
201
215
|
next: carry("consult", { consultedCount: state.consultedCount + 1 }),
|
|
202
216
|
};
|
|
203
217
|
}
|