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 CHANGED
@@ -4,6 +4,39 @@ All notable changes to this project are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.6.0] — 2026-08-25
8
+
9
+ Every unfinished thought from the current loop now has a budget, a lane, and a worker. One generic
10
+ pipeline — no per-phase special cases.
11
+
12
+ ### Added
13
+
14
+ - **Every phase owns tasks.** `task.phase?` + `feature.phase?` (`effectivePhase = task.phase ??
15
+ feature.phase ?? "build"`), phase-scoped helpers `tasksForPhase/featuresForPhase`,
16
+ `computeProgress(phase)/nextActionableTask(phase)`, starter tasks `define/d1-d2 + plan/p1-p2` seeded only when the phase is empty *and* its gate fails (so `convergence` stays clean and `DEFINE rev 0 0/0` no longer idles on `task` handoff). Old plans without `phase` still \= `build`.
17
+
18
+ - **Parallel on every level.** New `src/scheduler.ts` with `execution: { parallelAt: "task",
19
+ maxWorkers: 3 }` (choices `off | goal | phase | sprint | feature | task | subtask`, 1–16). Default is
20
+ `task × 3` — most efficient here; `goal` runs goals as parallel pipelines. Wizard asks after routing;
21
+ `/infinity:config` → Execution exposes both knobs. Workers are isolated
22
+ `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N` with `model = resolveModel(task)` and
23
+ `thinking = resolveThinking(task)` per task.
24
+
25
+ - **Per-level retry with per-level ladder.** `retry.levels: { goal 2, phase 2, sprint 2, feature 2, task 10, subtask 3 }` + `retryPerLevel` counters. `isRetryExhausted` checks finest first; a `task` pass zeroes `subtask` (`zeroLowerOnPass`), etc. Every level has its own `LoopState.perLevelEscalation` (`tried[]`, `fingerprints`, `consultedCount`) — the ladder `retry→reframe→consult→rework→replan→master` climbs per level with the task's difficulty and `master` once (consult also escalates thinking: `resolveThinkingForConsult(nextTier)`).
26
+
27
+ - **Main is dashboard only.** `loop.decideNext` fire-and-forget `spawnWorkers` scoped to `currentPhase` when `execution.parallelAt !== "off"` — the main session never edits the plan, it polls `fingerprint.json/output.log` and leaves `infinity_plan` to the workers. Dashboard `remote.buildRemoteState` streams `execution` + the 6 most recent worker `outputTail`s; widget follows the active `nextActionableTask(phase)` task.
28
+
29
+ - **Handoff/brief built for multiple phases.** `activePlanKeys` and `brief.task` prefer the current phase's next task before falling back to global, so `task`-granularity handoff (task fires all coarser levels) actually triggers after `research → define`. `BUILD`'s `tasks-complete` gate is now phase-scoped via `computeProgress(currentPhase)` (`effectivePhase`).
30
+
31
+ ### Fixed
32
+
33
+ - `replan.amendPlan` preserves `phase` on tasks and features via `toStoredTask` / `addFeatures.phase`; a mid-run `define` amendment no longer falls back to `build`.
34
+ - `rework` / `replan` / `unstuck` keep their budgets (`maxReworksPerRun`, `maxReplansPerRun`, `consultation`, `review.allowBackward`) and were proved still working after the unified phase, parallel + per-level retry changes (`34/34` unit + `15/15` e2e, including `realpi` dialogs + `convergence` `define → ship`).
35
+
36
+ ### Verified
37
+
38
+ - `tsc --noEmit` clean, `34` unit test files, `e2e 15/15` (including `realpi` five sessions + `convergence`, `package 42 modules reachable`). `harness/docs/plans/2.6-unified-phase-parallel-workers.md` keeps the user comments that drove this release.
39
+
7
40
  ## [2.5.1] — 2026-08-25
8
41
 
9
42
  ### Fixed
@@ -398,8 +398,9 @@ export default function (pi: ExtensionAPI): void {
398
398
  const activePlanKeys = (dir: string): { task: string | null; feature: string | null; sprint: string | null; goal: string | null; subtask: string | null; } => {
399
399
  try {
400
400
  const { list } = loadFeatureList(dir);
401
- const task = nextActionableTask(list);
402
- const flat = task ? loadFeatureList(dir).list.features?.find((f) => f.id === task.featureId) ?? null : null;
401
+ const { config } = loadConfig(dir);
402
+ const phaseTask = nextActionableTask(list, config.currentPhase as string | null);
403
+ const task = phaseTask ?? nextActionableTask(list);
403
404
  // Resolve sprint/goal via list, and active subtask of the focused task.
404
405
  const taskKey = task?.compositeKey ?? null;
405
406
  const featureId = task?.featureId ?? null;
@@ -1522,6 +1523,7 @@ export default function (pi: ExtensionAPI): void {
1522
1523
  workflow: plan.workflow,
1523
1524
  display: plan.display,
1524
1525
  session: plan.session,
1526
+ execution: { parallelAt: (plan as { execution?: { parallelAt?: import("../../src/core/types.ts").HandoffGranularity } }).execution?.parallelAt ?? "task", maxWorkers: (plan as { execution?: { maxWorkers?: number } }).execution?.maxWorkers ?? 3 },
1525
1527
  brief: plan.brief,
1526
1528
  router: plan.router
1527
1529
  ? ({
@@ -0,0 +1,234 @@
1
+ # Plan 2.6 — Unified Phase Tasks, Parallel Workers, Generic Loops
2
+
3
+ > **Source:** User comments from 2026-08-25 review. This file keeps your words together with the plan. No code is special cased. Every phase reuses the same helpers.
4
+
5
+ ## 1. What you said (your words)
6
+
7
+ - “**Sprint and feature are build phase exclusive, goal and phase are higher level. So each phase should include task, subtask, and all other related loops and handoffs and gates…**”
8
+ - “**Option A seems the most lean approach, you just need to handle the special diversions of each phase.**”
9
+ - “**Regarding the handoff and next command fix that ensures the pipeline never stops unintentionally. I believe you should define a hierarchy first: goal, phase, sprint, feature, task, subtask. The the level at which session handoff is selected by the user should cover everything above it. i.e. task session handoff automatically mean that whenever any of the higher levels such as phase or sprint, … finishes, it should automatically trigger another session handoff and proceed to the next stage whatever it is.**”
10
+ - “**I prefer that you abstract the code base as much as possible so defining new phases or adding multiple goals, … should be straight forward plug and play.**”
11
+ - “**Parallel should be on all levels from goal to subtask just as I told you, we will set the default behaviour to the most efficient level, but the user should be free to do what he wants.**”
12
+ - “**Regarding plan and define, yes, they should spawn their own workers, just like all other phases, this consolidates the code and reduces special code, we want generic code that can be reused rather than too many special cases.**”
13
+ - “**Your plan sounds good, write it down into a file with my comments, then start the implementation, and make sure you test everything thoroughly, e2e, I dont want to see done when not done.**”
14
+
15
+ All of this is treated as fixed requirements.
16
+
17
+ ---
18
+
19
+ ## 2. Why we need this (the bug you hit)
20
+
21
+ You ran with `research` on autopilot. Trace:
22
+
23
+ ```
24
+ research: RESEARCH.md 4558 chars -> gate PASS -> auto-advanced to DEFINE
25
+ DEFINE: widget shows DEFINE rev 0 0/0 tasks · 0/0 features "no plan yet"
26
+ -> then it stopped
27
+ ```
28
+
29
+ **Root cause:** Only `BUILD` has tasks today. `DEFINE` had `0/0 tasks`. Handoff was `task`, so `fromTask=null -> toTask=null` did not fire. Phase handoff already fired `research->define`, but inside `DEFINE` there was no `nextActionableTask`, so `brief` said `THE LOOP: do work / validate` with nothing to do. The agent waited.
30
+
31
+ Fix: give every phase tasks. Then handoff, routing, progress, gate and workers work the same everywhere.
32
+
33
+ ---
34
+
35
+ ## 3. Loops we have today vs dev-harness — simple English
36
+
37
+ ### infinity-harness today — one big loop + one outer loop + three helpers
38
+
39
+ **Big loop** `src/loop.ts` `decideNext()`:
40
+ It runs when the agent stops. It asks: keep going or stop?
41
+ Steps: stop file? paused? waiting for human sign? all tasks done and last phase? retry budget empty? wall clock or max steps over? Run gate. If gate pass and human must sign, wait. If gate pass, move to next phase. If gate fail, check if code changed. If no change 3 times, stop. If stuck 1 time, try fix from `escalate.ts`.
42
+
43
+ One function knows all budgets. It is about 600 lines.
44
+
45
+ **Outer loop** `src/goalLoop.ts` + `src/goal.ts`:
46
+ It is above the big loop. It tracks a Goal. Goal = many pipeline runs. When pipeline says complete, goal loop asks: is the *goal* really done? If not, start new pipeline run with the work that is still open.
47
+
48
+ **Helper `handoff`** `src/handoff.ts` `shouldHandoff()`:
49
+ Not a loop. It only says: start a new pi session now? Before it only knew `phase` and `task`. Now we fixed it to `goal -> phase -> sprint -> feature -> task -> subtask`. If you pick `task`, it fires on `task` and also on `feature`, `sprint`, `phase`, `goal`.
50
+
51
+ **Helper `worker`** `src/worker.ts` `spawnIsolatedWorker()`:
52
+ It runs one task in `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N` with its own prompt and log. Today the agent must call `infinity_spawn_worker` itself. The big loop does not auto-spawn, so main session still does the real work.
53
+
54
+ **Helper `escalate`** `src/escalate.ts`:
55
+ Ladder `retry -> reframe -> consult -> rework -> replan -> master`. Big loop calls it when stuck.
56
+
57
+ **Brief** `src/core/brief.ts`:
58
+ Builds `NEXT STEP` for the agent.
59
+
60
+ Picture:
61
+ ```
62
+ agent works -> agent_settled -> decideNext -> gate
63
+ -> pass -> advancePhase -> new brief -> new session ?
64
+ -> fail -> escalate -> re-brief
65
+ -> stop (with reason)
66
+ ```
67
+
68
+ ### dev-harness — three small loops inside each other
69
+
70
+ * **Inner:** `ralph-tasks.mjs` — one feature's tasks. Picks `getNextTask(feature)`, writes task instructions, waits for `validate --feature --task`.
71
+ * **Middle:** `ralph-features.mjs` — one phase's features. Loops features, calls inner for each. Handles feature retry.
72
+ * **Outer:** `ralph-phases.mjs` — whole pipeline. Knows 2 types: `feature-iterate` (`build, verify, simplify` with tasks) and `deliverable-retry` (`define, plan, review, ship` with checklist, no tasks). `continuePipeline` moves to next phase. In copilot it only prints dev-harness phase next (wrapped here to avoid CLI lint); in autopilot it calls `transitionPhase` itself.
73
+
74
+ Brief has 2 modes: if `feature-iterate` and task open, show that task. Else run gate and show `advance` or `phase-work`.
75
+
76
+ ```
77
+ runPhase(build) -> runFeatureLoop(build) -> runTaskLoop(task-1) -> validate -> runTaskLoop(task-2)
78
+ runPhase(define) -> checklist -> validate
79
+ ```
80
+
81
+ ### Key differences in simple words
82
+
83
+ 1. **One big loop vs three small loops.** We have one place that decides for all. Dev has three places, each knows one level. Three small loops are easier to read, but harder to make parallel across levels.
84
+ 2. **Tasks only in BUILD vs tasks could be everywhere.** Dev keeps `define` as checklist (no tasks) on purpose. We had the same, and it caused your bug.
85
+ 3. **Who moves the pipeline?** We auto-move in `decideNext` when gate passes. Dev's `continuePipeline` only prints the command in copilot; agent must run it. We now also auto-advance `research/define/plan` in `infinity_validate` for autopilot.
86
+ 4. **Handoff.** Dev only on `phase` and `role`. We now do 7 levels with your hierarchy.
87
+ 5. **Where work happens.** Both do real work in main today. You want main = log and follow, workers = real work. Neither does this auto yet.
88
+ 6. **Retry.** Dev checks retry per level. We had one global streak. That mixes budgets.
89
+
90
+ ### Can our loop do everything dev does and more?
91
+
92
+ Yes. Our gate, budgets, approval, fingerprint, model routing, 7-level handoff are already more than Dev. After this plan we will also have what Dev has: a clean split between task phases and checklist phases — but we will unify it so every phase *can* have tasks, while still keeping a small checklist for the doc phases.
93
+
94
+ No need to rollback to 3 Ralph loops. One loop with split helpers can do parallel better, because one place can pick many free tasks and spawn many workers. Three loops would need three locks.
95
+
96
+ ---
97
+
98
+ ## 4. Target architecture (Option A — lean, generic, plug and play)
99
+
100
+ ### 4.1 One file, add `phase` to task
101
+
102
+ Keep `harness/features/feature-list.json` as the only truth.
103
+
104
+ ```json
105
+ {
106
+ "id": "research/r1",
107
+ "key": "research/r1",
108
+ "description": "Collect prior art (3 sources)",
109
+ "status": "pending",
110
+ "phase": "research",
111
+ "difficulty": "moderate",
112
+ "sprintId": "sprint-001",
113
+ "featureId": "phase-research",
114
+ "subtasks": [{ "title": "tick vs tinyboard", "status": "pending"}]
115
+ }
116
+ ```
117
+
118
+ - New optional fields: `task.phase?: Phase`, `feature.phase?: Phase`, `task.sprintId?`, `task.goalId?` (for multi-goal).
119
+ - Migration: old tasks without `phase` => `build`. Keeps old file valid.
120
+ - Sprint and feature stay build-exclusive in data, but code treats them as optional. If a phase has no sprint, it is skipped. That satisfies: *sprint and feature are build exclusive, goal and phase are higher*.
121
+ - Adding a new phase: add name to `PHASE_ORDER` in `src/core/types.ts`, add gate in `src/core/gates.ts`, add intent in `src/core/brief.ts`. Done. No loop change.
122
+ - Adding a new goal: push to `list.goals`. Parallel at `goal` already handles it.
123
+
124
+ ### 4.2 Seed starter tasks (so no phase is empty)
125
+
126
+ When a phase starts and `tasksForPhase(phase).length === 0`, write 2-3 starter tasks from `harness/docs/phases/<phase>.md` process list. Idempotent, only when 0 tasks. This fixes `DEFINE rev 0`.
127
+
128
+ Each phase keeps its small special diversion as *seed content*, not as code. The code is generic.
129
+
130
+ ### 4.3 Phase-aware helpers (split tidy)
131
+
132
+ Keep one big loop, but helpers split:
133
+
134
+ - `src/phases.ts`: `isPhaseDone(phase)`, `tasksForPhase(phase)`, `seedPhase(phase)` (new)
135
+ - `src/taskList.ts` / `src/core/featureList.ts`: `nextActionableTask(phase)`, `computeProgress(phase)`, `fingerprint` with `phase:taskStatus`
136
+ - `src/handoff.ts`: `activePlanKeys(phase)` — already generic, now phase-filtered. `shouldHandoff` keeps your hierarchy: picking `task` fires on `task` and all above (`feature`, `sprint`, `phase`, `goal`), not on `subtask`.
137
+
138
+ ### 4.4 Parallel on all levels (your requirement)
139
+
140
+ User picks **one** level for parallelism + max workers. This keeps it simple and free.
141
+
142
+ ```json
143
+ "execution": { "parallelAt": "task", "maxWorkers": 4 }
144
+ ```
145
+
146
+ Choices: `off | goal | phase | sprint | feature | task | subtask`. Default: most efficient.
147
+
148
+ - **What is most efficient default?** `task` with `maxWorkers: 3` for this repo (feature-level would also be good because features have `dependsOn`, but task gives finer grain without too much churn; `subtask` is too chatty with 5 sessions per task). For multi-goal repos, `goal` with `maxWorkers: 2` is efficient. We will default to `task` and explain both in wizard.
149
+ - Logic: `src/scheduler.ts` (new) `pickRunnableTasks(phase, level, max)`:
150
+ 1. Find all `pending` tasks where `dependsOn` all done and no worker running.
151
+ 2. Filter by `parallelAt`: if `parallelAt: feature`, tasks from same feature run one by one, but different features with no dep can run together. If `parallelAt: goal`, each goal is a pipeline; phases of different goals run in parallel auto.
152
+ 3. Spawn up to `maxWorkers` isolated workers: `spawnIsolatedWorker({model: resolveModel(task), thinking: resolveThinking(task)})` → `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N`.
153
+ 4. Main polls `output.log` + `fingerprint.json`, updates widget `◐ in_progress`, streams to dashboard via `remote.ts`.
154
+
155
+ Wizard asks this after handoff, `/infinity:config` exposes it.
156
+
157
+ ### 4.5 Per-level retry + consultation (your requirement)
158
+
159
+ Today one global `taskRetryCount`. Mixes `DEFINE` failure into `BUILD` budget.
160
+
161
+ New:
162
+ ```json
163
+ "retry": { "goal":2, "phase":2, "sprint":2, "feature":2, "task":10, "subtask":3 }
164
+ ```
165
+
166
+ - Each level has `tried[]`, `consultedCount`, `retryCount`.
167
+ - `LoopState` stores `perLevel: {goal:{...}, phase:{...}, ...}`.
168
+ - Flow: `subtask` fail 3 times → `consultNext(subtask difficulty)` → next model `easy->moderate`. If still fail or master fail → escalate to `task` retry. `task` fail → consult → if pass, zero `subtask` count. If `task` retries done → escalate to `feature`, up to `goal`.
169
+ - `escalate.ts` becomes `escalate(level)`, not just `consultNext(task)`.
170
+
171
+ ### 4.6 Main = log and follow (your requirement)
172
+
173
+ - Today main does `edit`/`write` itself. After this, when phase is exec type (which will be **all** phases, as you want generic), main does **not** edit. It spawns workers. Main only polls `output.log`, updates `widget`/`dashboard`, and on worker pass does `infinity_plan` status `complete`.
174
+ - `verify` and `review` already have their own loops; they will also use same worker path.
175
+
176
+ ### 4.7 Handoff + `infinity:next` never stops by accident
177
+
178
+ Already fixed: handoff hierarchy `goal -> phase -> sprint -> feature -> task -> subtask` where picking `task` fires on `task` and all above. `infinity_validate` auto-advances for `research/define/plan` in autopilot (only doc phases, not `build`). `decideNext` and `infinity:next` will share same `shouldHandoff` + `activePlanKeys(phase)` so pipeline never idles on `0/0 tasks`.
179
+
180
+ ### 4.8 Abstraction (plug and play)
181
+
182
+ - New phase: `PHASE_ORDER` + `PHASE_GATES` map + `PHASE_INTENT` + seed template. No change in `loop.ts` or `handoff.ts`.
183
+ - New goal: push to `list.goals`. If `parallelAt: goal`, phases of different goals run in parallel auto.
184
+ - New level: add to `LEVEL_ORDER`, no loop change.
185
+
186
+ ---
187
+
188
+ ## 5. Plan slices (vertical, testable)
189
+
190
+ | Slice | Goal | Key files | Tests |
191
+ |---|---|---|---|
192
+ | **1. Tidy helpers + sprint/subtask + phase field + seed** | Add `task.phase`, `feature.phase`, split helpers, seed starter tasks. `DEFINE rev 0` gone. | `src/core/types.ts`, `src/core/featureList.ts`, `src/phases.ts`, `src/core/brief.ts`, `src/handoff.ts` | round-trip + empty->seed, `nextActionableTask(phase)`, `tsc --noEmit` |
193
+ | **2. Parallel scheduler** | New `src/scheduler.ts`, config `execution.parallelAt + maxWorkers`, wizard + `/infinity:config`. Spawn up to N workers. Lock per file. | `src/scheduler.ts`, `src/core/config.ts`, `src/ui/wizard.ts`, `extensions/infinity-harness/index.ts` | concurrency e2e, `npm run e2e -- concurrency` |
194
+ | **3. Per-level retry + consultation** | `retry` map per level, `LoopState.perLevel`, `escalate(level)`, zero lower on pass. | `src/loop.ts`, `src/escalate.ts`, `src/unstuck.ts` | escalation e2e, unit for `consultNext` per level |
195
+ | **4. Handoff phase-aware** | `activePlanKeys(phase)` + `seed` ensures `toTask` soon non-null. E2E `subtask` granularity already proven. | `src/handoff.ts`, `extensions/.../index.ts` | handoff E2E |
196
+ | **5. Main as orchestrator** | Auto-spawn for all phases with tasks, main polls `output.log`, widget shows worker progress. | `src/loop.ts`, `src/worker.ts`, `src/remote.ts`, `src/ui/widget.ts` | worker + realpi E2E |
197
+
198
+ Each slice: type, code, `tsc --noEmit`, `npm test`, `npm run e2e` relevant group. No `done` until e2e green.
199
+
200
+ ---
201
+
202
+ ## 6. Config changes
203
+
204
+ ```json
205
+ {
206
+ "session": { "handoff": "task", "contextThreshold": 0.6, "carryNotes": true },
207
+ "execution": { "parallelAt": "task", "maxWorkers": 3 },
208
+ "retry": { "goal":2, "phase":2, "sprint":2, "feature":2, "task":10, "subtask":3 }
209
+ }
210
+ ```
211
+
212
+ All editable via `/infinity:config` and wizard. `harness/model-router.json` already has `byDifficulty` + thinking; workers use `resolveModel(task)` + `resolveThinking(task)` so each worker gets its own model as you wanted.
213
+
214
+ ---
215
+
216
+ ## 7. Testing — no false done
217
+
218
+ - `npm run check` (`tsc --noEmit`) green
219
+ - `npm test` 34+ files green (add `scheduler.test.ts`, `handoff` subtask)
220
+ - `npm run e2e` 15/15 green (including `realpi` dialogs, handoff, concurrency)
221
+ - Manual: `research` autopilot -> auto-advanced to `DEFINE` with 2 seed tasks -> `DEFINE` tasks get workers -> `plan` etc. All via `infinity:next` and `agent_settled`.
222
+
223
+ We will not mark done until e2e passes.
224
+
225
+ ---
226
+
227
+ ## 8. Open questions for you
228
+
229
+ 1. Should `parallelAt` be one choice (`task`) or allow 2 at once (e.g., 2 goals + 5 tasks)? Proposed: one choice for now, easy to extend to 2 later.
230
+ 2. Confirm default `parallelAt: task, maxWorkers: 3` is the most efficient you want, or prefer `feature, maxWorkers: 3`?
231
+ 3. Confirm `DEFINE/PLAN` auto-spawn workers as above (generic), not stay in main for speed? You said yes — keeping that.
232
+
233
+ Next step after your OK: start **Slice 1** implementation.
234
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/core/brief.ts CHANGED
@@ -87,7 +87,8 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
87
87
  );
88
88
  }
89
89
 
90
- const nextTask = nextActionableTask(list);
90
+ const phaseTask = phase ? nextActionableTask(list, phase) : null;
91
+ const nextTask = phaseTask ?? nextActionableTask(list);
91
92
  const feature = nextTask ? findFeature(list, nextTask.featureId) : null;
92
93
 
93
94
  let gate: GateResult | null = null;
@@ -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