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.
- package/CHANGELOG.md +33 -0
- package/extensions/infinity-harness/index.ts +4 -2
- 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/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
|
}
|
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:
|
|
490
|
+
state: levState,
|
|
491
|
+
level: retryLevel ?? "task",
|
|
436
492
|
now,
|
|
437
493
|
});
|
|
438
|
-
state
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/modelRouter.ts
CHANGED
|
@@ -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 }
|