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/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
|
|
402
|
-
const
|
|
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.
|
|
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
|
|
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;
|
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
|