infinity-harness 2.6.4 → 2.6.5

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,40 @@ 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.5] — 2026-08-27
8
+
9
+ Routing is honest about handoff, research knows how deep to go, and the human reads the same wiring the runner does.
10
+
11
+ ### Added
12
+
13
+ - **Research depth tiers.** Wizard asks `How deep should research go?` only when `research` is in the pipeline — `standard` (Deep 3 tasks ≥5 sources ~800), `deep` (Very Deep 5 tasks ≥7 sources ~1800), `comprehensive` (Literature Review 7 tasks ≥15 annotated ~5000). `harness/config.json: researchDepth` persists it, `STARTER_TASKS_BY_DEPTH` feeds `seedPhaseIfEmpty`, `checkResearchDoc` enforces the right floor, and `harness/skills/deep-research.md` codifies the `process` skill per depth. `src/intake.ts` surfaces `Research <depth>` in the intake summary.
14
+
15
+ - **`harness/skills/deep-research.md`.** Process skill for the RESEARCH phase — depth-gated task/proof table, prior-art citation mandate, falsification experiment.
16
+
17
+ - **Dashboard actually shows its URL and the handoff→model contract.** `WidgetState.dashboardUrl` + `WidgetState.handoffModelNote` are populated from the live `remoteServer.url` and from `handoffModelNote(handoff)`; terminal header renders an `OSC 8` clickable `Dashboard: …` line, web dashboard renders `dash-url` + `handoff-note` under the masthead.
18
+
19
+ ### Changed
20
+
21
+ - **Model routing follows handoff (Option A: hardest wins in the bucket).** `src/scheduler.ts: effectiveDifficultyForTask(task, handoff, list)` — `task/subtask` keeps own difficulty (subtasks inherit parent), `phase/feature/sprint/goal/off` collapses the bucket to its hardest (`easy < moderate < difficult`). `spawnWorkers` and `extensions/infinity-harness:index.ts` (`applyRouting` + `routingSummaryForBrief` + brief line) all read `config.session.handoff` each call; `src/intake.ts: HANDOFF_QUESTION` help text now states `Model per …` per choice. `src/handoff.ts` wording updated to match.
22
+
23
+ - **Seeding + config defaults.** `src/core/init.ts` writes `researchDepth` (`deep` when research enabled), `src/core/config.ts` defaults it, `src/core/phases.ts` returns the depth-appropriate starter set via `loadConfig` (no raw file read), `src/remote.ts` keeps the read-only dashboard read via `require` deferred import so pack audit stays green (`42` modules reachable).
24
+
25
+ ### Fixed
26
+
27
+ - **Wizard ate its own answer.** Real-pi `research-first` and `build one` → research handoff paths previously timed out because depth answer matched the question title not option text and consumed the wrong line; e2e now answers depth with regex on option (`/Very Deep/`), research gate fixture length raised to `40×` so `deep = 1800` passes, and both realpi `wizard` + `custom` + `coldstart` `a custom workflow …` are green.
28
+
29
+ - **Second reuse init lost its saved workflow.** `coldstart-reuse` answered with a bare `Client work \(yours\)` line that no longer matched after research re-enabled depth intake; now answered `Very Deep` so the workflow survives.
30
+
31
+ - **Gate fixture regression.** `research.md` fixture at `20×` was `1152` chars — short of the new `deep 1800` floor — caused `the whole pipeline …` stall; corrected to `40×` (`2280`) so `research: pass` remains single-shot.
32
+
33
+ - **`src/intake.ts` CRLF drift.** Full-file rewrite was display-only (line endings); fixed to LF and only the 25-line surgical change retained.
34
+
35
+ - **Extension ESM require shim.** Prior `require("../../src/scheduler.ts")` in `widgetStateFor` broke ESM and never surfaced `handoffModelNote`; inlined pure map instead.
36
+
37
+ ### Verified
38
+
39
+ - `tsc --noEmit` clean, `34/34` unit, `15/15` e2e (`coldstart 12/12`, `realpi 10/10`), `42` modules reachable via `package` scenario. `tests/skills.test.ts` bumps shipped skill count `28 → 29`, `tests/intake.test.ts` asserts `How deep …` + `Research deep` summary.
40
+
7
41
  ## [2.6.4] — 2026-08-25
8
42
 
9
43
  `feature-criteria` now ignores seeded `phase-*` scaffolding so `DEFINE` does not pass on the
@@ -168,10 +168,24 @@ export default function (pi: ExtensionAPI): void {
168
168
 
169
169
  // -- widget ---------------------------------------------------------------
170
170
 
171
+ const handoffNoteFor = (h: import("../../src/core/types.ts").HandoffGranularity): string | null => {
172
+ const map: Record<string, string> = {
173
+ off: "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff",
174
+ goal: "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff",
175
+ phase: "Model per phase — tasks & subtasks in a phase share the hardest model in that phase",
176
+ sprint: "Model per sprint — tasks & subtasks in a sprint share the hardest model in that sprint",
177
+ feature: "Model per feature — tasks & subtasks in a feature share the hardest model in that feature",
178
+ task: "Model per task — subtasks share their parent task's model",
179
+ subtask: "Model per subtask — each subtask may use its own model (needs subtask difficulty)",
180
+ };
181
+ return map[h] ?? null;
182
+ };
183
+
171
184
  const widgetStateFor = (dir: string): WidgetState | null => {
172
185
  try {
173
186
  const { list } = loadFeatureList(dir);
174
187
  const { config } = loadConfig(dir);
188
+ const handoffModelNote: string | null = handoffNoteFor((config.session?.handoff as import("../../src/core/types.ts").HandoffGranularity) ?? "task");
175
189
  const spent = escalationSummary(dir);
176
190
  const loop = readJsonSafe<{ escalations?: { strategy: string }[] } | null>(
177
191
  loopStatePath(dir),
@@ -184,6 +198,8 @@ export default function (pi: ExtensionAPI): void {
184
198
  return {
185
199
  list,
186
200
  view,
201
+ dashboardUrl: remoteServer?.url ?? null,
202
+ handoffModelNote,
187
203
  sessions: run?.sessions ?? null,
188
204
  intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
189
205
  awaitingApproval: config.awaitingApproval ?? null,
@@ -301,10 +317,15 @@ export default function (pi: ExtensionAPI): void {
301
317
  const applyRouting = async (ctx: ExtensionContext, dir: string, source: string): Promise<void> => {
302
318
  try {
303
319
  const { resolveModel, resolveThinking } = await import("../../src/modelRouter.ts");
320
+ const { effectiveDifficultyForTask } = await import("../../src/scheduler.ts");
304
321
  const { nextActionableTask, findFeature } = await import("../../src/core/featureList.ts");
305
322
  const { loadFeatureList: loadList } = await import("../../src/core/featureList.ts");
306
323
  const list = loadList(dir).list;
307
324
  const task = nextActionableTask(list);
325
+ const cfg = loadConfig(dir).config;
326
+ const handoff = (cfg.session?.handoff ?? "task") as import("../../src/core/types.ts").HandoffGranularity;
327
+ // Effective difficulty honors handoff bucket: phase/feature/sprint tasks share hardest in that bucket.
328
+ const effDiff = task && list ? (effectiveDifficultyForTask(task as import("../../src/core/featureList.ts").FlatTask, handoff, list) ?? (task as { difficulty?: string }).difficulty) : (task as { difficulty?: string } | null)?.difficulty;
308
329
  // Resolve against task/parent feature/sprint difficulty; fall through to default when no actionable.
309
330
  const feature = task ? findFeature(list, task.featureId) ?? undefined : undefined;
310
331
  const sprint = feature?.sprintId ? (list.sprints ?? []).find((s) => s.id === feature.sprintId) ?? undefined : undefined;
@@ -312,7 +333,7 @@ export default function (pi: ExtensionAPI): void {
312
333
  type T = NonNullable<ReturnType<typeof nextActionableTask>>;
313
334
  const routedModel = resolveModel({
314
335
  projectDir: dir,
315
- task: (task as T | null | undefined)?.difficulty || feature?.difficulty ? ({ difficulty: (task as T | undefined)?.difficulty, modelHint: (task as T | undefined)?.modelHint, id: task?.id, key: (task as T | undefined)?.compositeKey ?? (task as T | undefined)?.key } as never) : undefined,
336
+ task: effDiff || (feature as { difficulty?: string } | undefined)?.difficulty ? ({ difficulty: effDiff as string | undefined, modelHint: (task as T | undefined)?.modelHint, id: task?.id, key: (task as T | undefined)?.compositeKey ?? (task as T | undefined)?.key } as never) : undefined,
316
337
  feature: feature as never,
317
338
  sprint: sprint as never,
318
339
  phase: loadConfig(dir).config.currentPhase ?? undefined,
@@ -320,7 +341,7 @@ export default function (pi: ExtensionAPI): void {
320
341
  });
321
342
  const routedThinking = resolveThinking({
322
343
  projectDir: dir,
323
- task: (task as T | null | undefined) ? ({ difficulty: (task as T | undefined)?.difficulty, id: task?.id, key: (task as T | undefined)?.compositeKey ?? (task as T | undefined)?.key } as never) : undefined,
344
+ task: effDiff ? ({ difficulty: effDiff as string } as never) : (task as T | null | undefined) ? ({ difficulty: (task as T | undefined)?.difficulty, id: task?.id, key: (task as T | undefined)?.compositeKey ?? (task as T | undefined)?.key } as never) : undefined,
324
345
  feature: feature as never,
325
346
  sprint: sprint as never,
326
347
  });
@@ -378,15 +399,19 @@ export default function (pi: ExtensionAPI): void {
378
399
  try {
379
400
  const { nextActionableTask, findFeature } = await import("../../src/core/featureList.ts");
380
401
  const { resolveModel, resolveThinking } = await import("../../src/modelRouter.ts");
402
+ const { effectiveDifficultyForTask } = await import("../../src/scheduler.ts");
381
403
  const { list } = loadFeatureList(dir);
382
404
  const task = nextActionableTask(list);
383
405
  if (!task) return null;
406
+ const cfg = loadConfig(dir).config;
407
+ const handoff = (cfg.session?.handoff ?? "task") as import("../../src/core/types.ts").HandoffGranularity;
408
+ const effDiff = effectiveDifficultyForTask(task as import("../../src/core/featureList.ts").FlatTask, handoff, list) ?? (task as { difficulty?: string }).difficulty;
384
409
  const feature = findFeature(list, task.featureId) ?? undefined;
385
410
  const sprint = feature?.sprintId ? (list.sprints ?? []).find((s) => s.id === feature.sprintId) ?? undefined : undefined;
386
411
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
387
- const m = resolveModel({ projectDir: dir, task: ({ difficulty: (task as any).difficulty, modelHint: (task as any).modelHint, id: task.id, key: (task as any).compositeKey ?? (task as any).key } as never), feature: feature as never, sprint: sprint as never, phase: loadConfig(dir).config.currentPhase ?? undefined });
412
+ const m = resolveModel({ projectDir: dir, task: ({ difficulty: effDiff as string | undefined, modelHint: (task as any).modelHint, id: task.id, key: (task as any).compositeKey ?? (task as any).key } as never), feature: feature as never, sprint: sprint as never, phase: loadConfig(dir).config.currentPhase ?? undefined });
388
413
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
389
- const th = resolveThinking({ projectDir: dir, task: ({ difficulty: (task as any).difficulty } as never), feature: feature as never, sprint: sprint as never });
414
+ const th = resolveThinking({ projectDir: dir, task: ({ difficulty: effDiff as string | undefined } as never), feature: feature as never, sprint: sprint as never });
390
415
  if (!m || !m.trim()) return null;
391
416
  return `Routing: ${task.compositeKey} → ${m}${th ? ` · thinking ${th}` : ""}`;
392
417
  } catch { return null; }
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: deep-research
3
+ description: Deep prior-art sweep, synthesis and tradeoff analysis — literature-review level when needed
4
+ tags: [research, literature, prior-art, synthesis, constraints, tradeoffs, recommendation, falsification]
5
+ when: research depth is standard/deep/comprehensive and the question needs more than a web search
6
+ phases: [research]
7
+ kind: process
8
+ ---
9
+
10
+ # Deep research
11
+
12
+ Use when `config.researchDepth` is set — `standard` (Deep), `deep` (Very Deep) or `comprehensive` (Literature Review). The wizard only asks when `research` is in the pipeline.
13
+
14
+ ## Depth — what the harness expects
15
+
16
+ | Depth | Tasks | Sources | Gate | What you deliver |
17
+ |-------|-------|---------|------|-----------------|
18
+ | `standard` (Deep) | 3 | ≥5 primary, all with URLs | ~800 chars | comparison table, constraints table, ≥3 options |
19
+ | `deep` (Very Deep) | 5 | ≥7 primary + gap analysis | ~1800 chars | above + competitive matrix, cost/risk model, risk register |
20
+ | `comprehensive` (Literature Review) | 7 | ≥15 annotated bibliography | ~5000 chars | above + benchmarks on a toy case, synthesis, gap analysis, ADR |
21
+
22
+ Deeper = longer `harness/docs/RESEARCH.md`. The gate reads `config.researchDepth` and enforces the char floor.
23
+
24
+ ## Process
25
+
26
+ 1. Collect primary sources only — official docs, specs, first-party APIs, papers with DOIs, postmortems. Every claim needs a URL or citation.
27
+ 2. Fill the constraints table `given vs inferred` (inferred = question for DEFINE).
28
+ 3. Benchmark or reason about ≥1 approach on a minimal case where possible (comprehensive must).
29
+ 4. Lay out genuine options (standard ≥3) with architecture sketch, cost, risk, team & time. A list of one is a decision wearing a disguise.
30
+ 5. Write an ADR: recommendation + what would have to be true for it to be wrong (falsification + experiment design).
31
+ 6. Risk register: known unknowns vs unknown unknowns, mitigations.
32
+ 7. Ranked open questions for DEFINE (standard ≥5, deep ≥8, comprehensive ≥12) + glossary delta for `DOMAIN.md`.
33
+
34
+ ## Anti-patterns
35
+
36
+ - No open questions — you did not look hard enough.
37
+ - One option presented as inevitable.
38
+ - Findings with no source, stated as confidently as sourced ones.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.6.4",
3
+ "version": "2.6.5",
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": [
@@ -50,6 +50,7 @@ export function defaultConfig(): HarnessConfig {
50
50
  },
51
51
  phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
52
52
  roles: { strict: false },
53
+ researchDepth: "deep" as import("./types.ts").ResearchDepth,
53
54
  session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
54
55
  execution: { parallelAt: "task", maxWorkers: 3 },
55
56
  approvals: { research: false, define: false, plan: false },
package/src/core/gates.ts CHANGED
@@ -231,8 +231,15 @@ async function checkChangelog({ targetDir }: Ctx): Promise<CheckResult> {
231
231
  * bar deliberately — the gate judges that work happened, the human judges
232
232
  * whether it was any good.
233
233
  */
234
- async function checkResearchDoc({ targetDir }: Ctx): Promise<CheckResult> {
235
- return docCheck("research-doc", P.researchPath(targetDir), 400, "harness/docs/RESEARCH.md");
234
+ async function checkResearchDoc({ targetDir, config }: Ctx): Promise<CheckResult> {
235
+ // Depth-dependent threshold — Literature needs a real review, Standard is the old 400 baseline.
236
+ const depth = (config as { researchDepth?: string }).researchDepth as string | undefined;
237
+ const minChars = depth === "comprehensive" ? 5000 : depth === "deep" ? 1800 : 800;
238
+ // Note: depth="deep" is the default, 800 ≈ old 400 doubled but Standard is a true lite mode.
239
+ // Comprehensive still passes if Standard doc exists — depth is about work produced, not gate strictness,
240
+ // but the line below makes the harness actually demand the depth the wizard promised.
241
+ const effectiveMin = depth ? minChars : 400;
242
+ return docCheck("research-doc", P.researchPath(targetDir), effectiveMin, "harness/docs/RESEARCH.md");
236
243
  }
237
244
 
238
245
  async function checkArchitectureDoc({ targetDir }: Ctx): Promise<CheckResult> {
package/src/core/init.ts CHANGED
@@ -146,6 +146,7 @@ function pythonCommands(targetDir: string): ProjectCommands {
146
146
  export type InitOptions = {
147
147
  stack?: StackId;
148
148
  mode?: "copilot" | "autopilot";
149
+ researchDepth?: import("./types.ts").ResearchDepth;
149
150
  phases?: Phase[];
150
151
  commands?: Partial<ProjectCommands>;
151
152
  /** Re-scaffold missing files in a project that already has a config. */
@@ -213,6 +214,11 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
213
214
  const phase = phases[0] ?? "define";
214
215
 
215
216
  const config = defaultConfig();
217
+ if (options.researchDepth && (options.researchDepth === "standard" || options.researchDepth === "deep" || options.researchDepth === "comprehensive")) {
218
+ config.researchDepth = options.researchDepth;
219
+ } else if (phases.includes("research")) {
220
+ config.researchDepth = "deep";
221
+ }
216
222
  config.stack = stack.id === "unknown" ? null : stack.id;
217
223
  config.mode = options.mode ?? "copilot";
218
224
  config.phases = { enabled: phases };
@@ -140,27 +140,37 @@ export type StarterTask = {
140
140
  difficulty: "easy" | "moderate" | "difficult";
141
141
  subtasks?: string[];
142
142
  };
143
- export const STARTER_TASKS: Record<string, StarterTask[]> = {
144
- research: [
145
- {
146
- id: "research/r1",
147
- description: "Collect prior art — 3 sources, what exists, where it stops",
148
- difficulty: "moderate",
149
- subtasks: ["source 1 + summary", "source 2 + summary", "source 3 + summary"],
150
- },
151
- {
152
- id: "research/r2",
153
- description: "Name constraints (given vs inferred) and lay out 2+ options with costs",
154
- difficulty: "moderate",
155
- subtasks: ["constraints given vs inferred", "option A cost/benefit", "option B cost/benefit"],
156
- },
157
- {
158
- id: "research/r3",
159
- description: "Recommend one option, falsification condition, and open questions for DEFINE",
160
- difficulty: "moderate",
161
- subtasks: ["recommendation + falsification", "open questions list"],
162
- },
143
+ /** Depth: Standard(3 tasks/9 subtasks) < Deep(5/15) < Comprehensive(10/30+). Default deep. */
144
+ export type ResearchDepth = "standard" | "deep" | "comprehensive";
145
+ export const STARTER_TASKS_BY_DEPTH: Record<ResearchDepth, StarterTask[]> = {
146
+ standard: [
147
+ { id: "research/r1", description: "Collect prior art — 3 primary sources with URLs, what exists, where it stops", difficulty: "moderate", subtasks: ["source 1 + URL + summary", "source 2 + URL + summary", "source 3 + URL + summary"] },
148
+ { id: "research/r2", description: "Name constraints (given vs inferred) and lay out 2+ options with costs", difficulty: "moderate", subtasks: ["constraints given vs inferred table", "option A cost/benefit", "option B cost/benefit"] },
149
+ { id: "research/r3", description: "Recommend one option, falsification condition, and open questions for DEFINE", difficulty: "moderate", subtasks: ["recommendation + falsification", "open questions list (≥5)"] },
150
+ ],
151
+ deep: [
152
+ { id: "research/r1", description: "Prior art: ≥5 primary sources with URLs (docs/specs/repos), what each gets right and where it stops", difficulty: "moderate", subtasks: ["sources 1-3 + URLs + summaries", "sources 4-5 + URLs + gap analysis", "comparison table: feature × prior art"] },
153
+ { id: "research/r2", description: "Constraints & domain model: given vs inferred, glossary terms, actors & data", difficulty: "moderate", subtasks: ["constraints given vs inferred (table)", "domain glossary + actors", "data & platform constraints"] },
154
+ { id: "research/r3", description: "Options: ≥3 genuine alternatives with architecture, cost, risk and trade-offs", difficulty: "difficult", subtasks: ["option A: arch + cost + risk", "option B: arch + cost + risk", "option C / hybrid + trade-off matrix"] },
155
+ { id: "research/r4", description: "Recommendation with falsification: what must be true, what would prove it wrong", difficulty: "moderate", subtasks: ["recommendation + rationale", "falsification condition + experiment"] },
156
+ { id: "research/r5", description: "Open questions for DEFINE: ranked questions only a human can answer", difficulty: "easy", subtasks: ["open questions (≥8) ranked", "DEFINE interview agenda"] },
157
+ ],
158
+ comprehensive: [
159
+ { id: "research/r1", description: "Literature sweep: ≥15 primary sources — papers, RFCs, repos, postmortems annotated", difficulty: "difficult", subtasks: ["sources 1-5 annotated", "sources 6-10 annotated", "sources 11-15 annotated", "citation map + gaps in literature"] },
160
+ { id: "research/r2", description: "Domain & constraints synthesis: glossary, actors, data, regulatory & platform limits", difficulty: "difficult", subtasks: ["constraints given vs inferred (full table)", "domain glossary + bounded contexts", "actors, data flows & invariants"] },
161
+ { id: "research/r3", description: "Benchmark prior work: reproduce or reason about 3+ approaches on a toy case", difficulty: "difficult", subtasks: ["approach A benchmark", "approach B benchmark", "approach C benchmark + comparison matrix"] },
162
+ { id: "research/r4", description: "Architecture options: ≥3 with diagrams, cost model, risk register, team & time", difficulty: "difficult", subtasks: ["option A: diagram + cost + risk", "option B: diagram + cost + risk", "option C: diagram + cost + risk", "trade-off matrix + decision criteria"] },
163
+ { id: "research/r5", description: "Recommendation as a decision record + what falsifies it", difficulty: "moderate", subtasks: ["ADR: recommendation + alternatives rejected", "falsification condition + experiment design"] },
164
+ { id: "research/r6", description: "Risk & unknowns register: known unknowns, unknown unknowns, mitigations", difficulty: "moderate", subtasks: ["risk register", "mitigations + owners", "open unknowns vs knowns"] },
165
+ { id: "research/r7", description: "DEFINE handoff: ranked open questions (≥12) + interview agenda + glossary delta", difficulty: "easy", subtasks: ["open questions (≥12) ranked", "DEFINE interview agenda", "glossary delta for DOMAIN.md"] },
163
166
  ],
167
+ };
168
+ // Back-compat: default deep
169
+ const DEFAULT_RESEARCH_DEPTH: ResearchDepth = "deep";
170
+ export const STARTER_TASKS: Record<string, StarterTask[]> = {
171
+ get research(): StarterTask[] { return STARTER_TASKS_BY_DEPTH[DEFAULT_RESEARCH_DEPTH]; },
172
+ set research(v: StarterTask[]) { (STARTER_TASKS_BY_DEPTH as Record<string, StarterTask[]>)[DEFAULT_RESEARCH_DEPTH] = v; },
173
+
164
174
  define: [
165
175
  {
166
176
  id: "define/d1",
@@ -197,8 +207,18 @@ export function isPhaseDone(dir: string, phase: import("./types.ts").Phase): boo
197
207
  return tasks.length > 0 && tasks.every((t) => t.status === "complete");
198
208
  }
199
209
 
210
+ function startersForPhase(dir: string, phase: string): StarterTask[] {
211
+ if (phase !== "research") return STARTER_TASKS[phase] ?? [];
212
+ try {
213
+ const { config } = loadConfig(dir);
214
+ const depth = (config as { researchDepth?: ResearchDepth }).researchDepth;
215
+ if (depth && STARTER_TASKS_BY_DEPTH[depth]) return STARTER_TASKS_BY_DEPTH[depth];
216
+ } catch {}
217
+ return STARTER_TASKS_BY_DEPTH[DEFAULT_RESEARCH_DEPTH] ?? [];
218
+ }
219
+
200
220
  export function seedPhaseIfEmpty(dir: string, phase: import("./types.ts").Phase): { seeded: boolean; error: string | null } {
201
- const seeded = STARTER_TASKS[phase] ?? [];
221
+ const seeded = startersForPhase(dir, phase);
202
222
  if (seeded.length === 0) return { seeded: false, error: null };
203
223
  try {
204
224
  const { list } = loadFeatureList(dir);
package/src/core/types.ts CHANGED
@@ -244,6 +244,10 @@ export type DisplayPolicy = {
244
244
  taskWindow: number;
245
245
  };
246
246
 
247
+ /** How deep the research phase goes, when enabled. Only asked when research is in the pipeline. */
248
+ export type ResearchDepth = "standard" | "deep" | "comprehensive";
249
+ export const RESEARCH_DEPTHS: readonly ResearchDepth[] = ["standard", "deep", "comprehensive"] as const;
250
+
247
251
  /** What the start-up wizard settled, so it is never asked twice. */
248
252
  export type IntakeState = {
249
253
  /** True once the wizard has run to completion for this project. */
@@ -257,6 +261,8 @@ export type IntakeState = {
257
261
  export type HarnessConfig = {
258
262
  version: string;
259
263
  stack: string | null;
264
+ /** Research depth — only meaningful when research is in phases.enabled. */
265
+ researchDepth?: ResearchDepth;
260
266
  mode: "copilot" | "autopilot";
261
267
  currentPhase: Phase | null;
262
268
  currentRole: Role | null;
package/src/intake.ts CHANGED
@@ -44,9 +44,13 @@ export type Mode = "copilot" | "autopilot";
44
44
  export const INTAKE_STEPS = ["workflow", "brief", "handoff", "display"] as const;
45
45
  export type IntakeStep = (typeof INTAKE_STEPS)[number];
46
46
 
47
+ export type ResearchDepth = import("./core/types.ts").ResearchDepth;
48
+
47
49
  export type IntakeAnswers = {
48
50
  /** The chosen workflow: a built-in, one they saved, or one they just built. */
49
51
  workflow: Workflow;
52
+ /** Research depth — only when research is in the pipeline. */
53
+ researchDepth?: ResearchDepth;
50
54
  /** What the human wants built, in their words. Empty is allowed but warned about. */
51
55
  brief: string;
52
56
  /** Session handoff policy. Defaults to a fresh session per phase. */
@@ -71,6 +75,7 @@ export type IntakePlan = {
71
75
  /** Derived: "copilot" when the run stops for the human anywhere, else "autopilot". */
72
76
  mode: Mode;
73
77
  workflow: { id: string; name: string };
78
+ researchDepth?: ResearchDepth;
74
79
  brief: string | null;
75
80
  phases: Phase[];
76
81
  phaseModes: PhaseModes;
@@ -155,9 +160,11 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
155
160
  );
156
161
  }
157
162
 
163
+ const _researchDepth: ResearchDepth | undefined = (phases.includes("research" as Phase) ? ((answers.researchDepth as ResearchDepth) ?? "deep") : undefined) as ResearchDepth | undefined;
158
164
  return {
159
165
  mode,
160
166
  workflow: { id: workflow.id, name: workflow.name },
167
+ researchDepth: _researchDepth,
161
168
  brief,
162
169
  phases,
163
170
  phaseModes,
@@ -170,7 +177,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
170
177
  execution: { parallelAt, maxWorkers },
171
178
  display,
172
179
  router: answers.router,
173
- summary: summarize(workflow, phases, phaseModes, session, display, brief),
180
+ summary: summarize(workflow, phases, phaseModes, session, display, brief, _researchDepth),
174
181
  warnings,
175
182
  };
176
183
  }
@@ -182,6 +189,7 @@ function summarize(
182
189
  session: SessionPolicy,
183
190
  display: DisplayPolicy,
184
191
  brief: string | null,
192
+ researchDepth?: ResearchDepth | undefined,
185
193
  ): string {
186
194
  const signed = phases.filter((p) => modes[p] === "copilot");
187
195
  const L: string[] = [];
@@ -200,6 +208,7 @@ function summarize(
200
208
  }`,
201
209
  );
202
210
  L.push(`Display ${display.preset}`);
211
+ if (researchDepth) L.push(`Research ${researchDepth}`);
203
212
  L.push(`Goal ${brief ?? "(none yet — you will be asked first thing)"}`);
204
213
  return L.join("\n");
205
214
  }
@@ -237,37 +246,37 @@ export const HANDOFF_QUESTION: Question = {
237
246
  {
238
247
  value: "goal",
239
248
  label: "per goal — one session for the whole run",
240
- help: "The old single-session run. Every task accumulates context until compaction.",
249
+ help: "The old single-session run. Model per run (off/goal) whole run shares its hardest model. For true per-task models use task/subtask.",
241
250
  },
242
251
  {
243
252
  value: "phase",
244
253
  label: "every phase",
245
- help: "Old default. Each phase starts clean from the brief.",
254
+ help: "Model per phase — tasks & subtasks in a phase share the hardest model in that phase. Old default. Each phase starts clean from the brief.",
246
255
  },
247
256
  {
248
257
  value: "sprint",
249
258
  label: "every sprint",
250
- help: "New session whenever the active sprint changes (or phase).",
259
+ help: "Model per sprint — tasks & subtasks in a sprint share the hardest model in that sprint. New session whenever the sprint changes (or phase).",
251
260
  },
252
261
  {
253
262
  value: "feature",
254
263
  label: "every feature",
255
- help: "New session on each feature boundary (and sprint/phase).",
264
+ help: "Model per feature — tasks & subtasks in a feature share the hardest model in that feature. New session on each feature boundary (and sprint/phase).",
256
265
  },
257
266
  {
258
267
  value: "task",
259
268
  label: "every task (recommended)",
260
- help: "Each task gets a clean session. Best isolation; one extra brief per task.",
269
+ help: "Model per task — subtasks share their parent task's model. Each task gets a clean session. Best isolation; one extra brief per task.",
261
270
  },
262
271
  {
263
272
  value: "subtask",
264
273
  label: "every subtask",
265
- help: "Finest grain — each subtask gets a fresh session. Most isolation, most churn.",
274
+ help: "Model per subtask — each subtask may use its own model (needs subtask difficulty). Finest grain. Most isolation, most churn.",
266
275
  },
267
276
  {
268
277
  value: "off",
269
278
  label: "never — alias for per goal",
270
- help: "Same as per goal one long session without fresh starts.",
279
+ help: "Model per run — same as per goal, one long session.",
271
280
  },
272
281
  ],
273
282
  };
package/src/scheduler.ts CHANGED
@@ -7,10 +7,107 @@
7
7
  */
8
8
 
9
9
  import type { HarnessConfig, HandoffGranularity, Phase } from "./core/types.ts";
10
- import { loadFeatureList, tasksForPhase, type FlatTask } from "./core/featureList.ts";
10
+ import { loadFeatureList, tasksForPhase, type FlatTask, flattenTasks } from "./core/featureList.ts";
11
11
  import { loadRouterConfig } from "./modelRouter.ts";
12
12
  import { spawnIsolatedWorker, type SpawnWorkerResult } from "./worker.ts";
13
13
  import { runIdFor } from "./runState.ts";
14
+ import { loadConfig } from "./core/config.ts";
15
+
16
+ /** Difficulty ranking — higher wins when collapsing a bucket to its hardest. */
17
+ const DIFFICULTY_RANK: Record<string, number> = { easy: 1, moderate: 2, difficult: 3 };
18
+
19
+ function hardestDifficulty(tasks: Array<{ difficulty?: string }>): string | undefined {
20
+ let best: string | undefined;
21
+ let bestRank = -1;
22
+ for (const t of tasks) {
23
+ const d = (t as { difficulty?: string }).difficulty;
24
+ if (!d) continue;
25
+ const r = DIFFICULTY_RANK[d] ?? -1;
26
+ if (r > bestRank) { bestRank = r; best = d; }
27
+ }
28
+ return best;
29
+ }
30
+
31
+ function goalIdForTask(task: FlatTask, list: import("./core/types.ts").FeatureList): string | null {
32
+ const feat = list.features.find((f) => f.id === task.featureId) as { goalId?: string; sprintId?: string } | undefined;
33
+ if (!feat) return (list.goals?.[0]?.id ?? null) as string | null;
34
+ if (feat.goalId) return feat.goalId;
35
+ if (feat.sprintId) {
36
+ const spr = (list.sprints ?? []).find((s) => s.id === feat.sprintId) as { goalId?: string } | undefined;
37
+ if (spr?.goalId) return spr.goalId;
38
+ }
39
+ return (list.goals?.[0]?.id ?? null) as string | null;
40
+ }
41
+
42
+ /**
43
+ * Effective difficulty for a task given the session handoff granularity.
44
+ *
45
+ * Design choice (Option A): the handoff bucket is the model bucket.
46
+ * Everything finer than the handoff shares the hardest model in that bucket:
47
+ * - handoff phase → all tasks in that phase share one model (hardest in phase)
48
+ * - handoff feature → tasks in feature share hardest in feature
49
+ * - handoff task → subtasks share their parent task's model
50
+ * Shown in wizard + dashboard so the user knows the trade-off.
51
+ */
52
+ export function effectiveDifficultyForTask(
53
+ task: FlatTask,
54
+ handoff: HandoffGranularity,
55
+ list: import("./core/types.ts").FeatureList,
56
+ ): string | undefined {
57
+ const own = (task as { difficulty?: string }).difficulty;
58
+ if (handoff === "task" || handoff === "subtask" || handoff === "off") {
59
+ // task/subtask: subtasks are not separate tasks, so they inherit the task
60
+ // off: one session for whole run — hardest in whole plan (most conservative)
61
+ if (handoff === "off") {
62
+ const globalHardest = hardestDifficulty(flattenTasks(list) as unknown as Array<{ difficulty?: string }>);
63
+ return globalHardest ?? own;
64
+ }
65
+ return own;
66
+ }
67
+ let bucket: FlatTask[] = [];
68
+ const all = flattenTasks(list);
69
+ if (handoff === "phase") {
70
+ const phase = (task as { effectivePhase?: string }).effectivePhase ?? "build";
71
+ bucket = all.filter((t) => (t as { effectivePhase?: string }).effectivePhase === phase);
72
+ } else if (handoff === "feature") {
73
+ bucket = all.filter((t) => t.featureId === task.featureId);
74
+ } else if (handoff === "sprint") {
75
+ const feat = list.features.find((f) => f.id === task.featureId) as { sprintId?: string } | undefined;
76
+ const sid = feat?.sprintId;
77
+ if (!sid) return own;
78
+ bucket = all.filter((t) => {
79
+ const f = list.features.find((ff) => ff.id === t.featureId) as { sprintId?: string } | undefined;
80
+ return f?.sprintId === sid;
81
+ });
82
+ } else if (handoff === "goal") {
83
+ const gid = goalIdForTask(task, list);
84
+ if (!gid) return own;
85
+ bucket = all.filter((t) => goalIdForTask(t, list) === gid);
86
+ } else {
87
+ return own;
88
+ }
89
+ return hardestDifficulty(bucket as unknown as Array<{ difficulty?: string }>) ?? own;
90
+ }
91
+
92
+ export function handoffModelNote(handoff: HandoffGranularity): string {
93
+ switch (handoff) {
94
+ case "off":
95
+ case "goal":
96
+ return "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff";
97
+ case "phase":
98
+ return "Model per phase — tasks & subtasks in a phase share the hardest model in that phase";
99
+ case "sprint":
100
+ return "Model per sprint — tasks & subtasks in a sprint share the hardest model in that sprint";
101
+ case "feature":
102
+ return "Model per feature — tasks & subtasks in a feature share the hardest model in that feature";
103
+ case "task":
104
+ return "Model per task — subtasks share their parent task's model";
105
+ case "subtask":
106
+ return "Model per subtask — each subtask may use its own model (needs subtask difficulty)";
107
+ default:
108
+ return "";
109
+ }
110
+ }
14
111
 
15
112
  export type PickOpts = {
16
113
  targetDir: string;
@@ -36,8 +133,8 @@ export type WorkerSnapshot = {
36
133
  /** Tail a worker attempt's output.log (best-effort, never throws). */
37
134
  export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
38
135
  try {
39
- const { readFileSync, existsSync } = require("node:fs");
40
- const p = require("node:path").join(attemptDir, "output.log");
136
+ const { readFileSync, existsSync } = require("node:fs") as typeof import("node:fs");
137
+ const p = (require("node:path") as typeof import("node:path")).join(attemptDir, "output.log");
41
138
  if (!existsSync(p)) return "";
42
139
  const raw = readFileSync(p, "utf-8") as string;
43
140
  return raw.slice(-bytes);
@@ -46,8 +143,8 @@ export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
46
143
 
47
144
  export function listWorkers(targetDir: string, runId?: string): WorkerSnapshot[] {
48
145
  try {
49
- const { readdirSync, existsSync } = require("node:fs");
50
- const path = require("node:path");
146
+ const { readdirSync, existsSync } = require("node:fs") as typeof import("node:fs");
147
+ const path = require("node:path") as typeof import("node:path");
51
148
  const root = path.resolve(targetDir, "tmp/infinity-harness", runId ?? "");
52
149
  const roots: string[] = [];
53
150
  if (runId) {
@@ -92,7 +189,7 @@ export function listWorkers(targetDir: string, runId?: string): WorkerSnapshot[]
92
189
 
93
190
  export function nextModelForTask(targetDir: string, difficulty?: string, taskId?: string, key?: string): { model?: string; thinking?: string } {
94
191
  try {
95
- const { resolveModel, resolveThinking } = require("./modelRouter.ts");
192
+ const { resolveModel, resolveThinking } = require("./modelRouter.ts") as typeof import("./modelRouter.ts");
96
193
  return {
97
194
  model: resolveModel({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
98
195
  thinking: resolveThinking({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
@@ -104,8 +201,7 @@ export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
104
201
  const { list } = loadFeatureList(opts.targetDir);
105
202
  const phase = (opts.phase ?? null) as Phase | null;
106
203
  // Phase-filtered pool when phase given, else all tasks across phases.
107
- const { flattenTasks } = require("./core/featureList.ts");
108
- const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]);
204
+ const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]); // imported above
109
205
  // Build key map for dep check
110
206
  const byKey = new Map<string, FlatTask>();
111
207
  for (const t of all) {
@@ -172,13 +268,20 @@ export async function spawnWorkers(
172
268
  ): Promise<SpawnWorkerResult[]> {
173
269
  const { resolveModel } = await import("./modelRouter.ts");
174
270
  const runId = opts?.runId ?? runIdFor(targetDir, "sched");
271
+ // handoff bucket determines effective difficulty — read once
272
+ let handoff: HandoffGranularity = "task";
273
+ try { handoff = (loadConfig(targetDir).config.session?.handoff as HandoffGranularity) ?? "task"; } catch {}
274
+ const allList = (()=>{ try{ return loadFeatureList(targetDir).list; }catch{ return null as unknown as import("./core/types.ts").FeatureList; } })();
175
275
  const results: SpawnWorkerResult[] = [];
176
276
  for (const t of tasks) {
177
277
  const prompt = opts.promptFor(t);
178
278
  const router = loadRouterConfig(targetDir);
179
279
  let modelHint: string | undefined;
180
280
  if (router.enabled) {
181
- try { modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: (t as { difficulty?: string }).difficulty, id: t.id, key: t.compositeKey } }); } catch {}
281
+ try {
282
+ const effDiff = allList ? effectiveDifficultyForTask(t, handoff, allList) : (t as { difficulty?: string }).difficulty;
283
+ modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: effDiff as string | undefined, id: t.id, key: t.compositeKey } });
284
+ } catch {}
182
285
  }
183
286
  const res = await spawnIsolatedWorker({
184
287
  projectDir: targetDir,
@@ -51,6 +51,8 @@ export type DashboardState = {
51
51
  baseRevision: number;
52
52
  timestamp: string;
53
53
  retries?: { task: number; max: number };
54
+ dashboardUrl?: string | null;
55
+ handoffModelNote?: string | null;
54
56
  /** Model-router config. Opaque here — rendered as a badge, never interpreted. */
55
57
  router?: unknown;
56
58
  /** Rework record. Opaque here — rendered as a badge, never interpreted. */
@@ -987,10 +989,12 @@ table.tasks tr:last-child td{border-bottom:0}
987
989
  .row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
988
990
  @keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
989
991
  /* While-developed: the whole active branch pulses — every active box, not just one feature. */
990
- .tier.is-current,.feature.is-current{animation:cardPulse 0.9s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 2px rgba(var(--rgb-accent),.35), var(--shadow)}
991
- .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent)}
992
- .row.is-active{animation:taskBlink 0.9s ease-in-out infinite; outline:2px solid var(--c-active); outline-offset:-2px}
993
- @keyframes cardPulse{0%,100%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.35),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.14),var(--shadow); border-color:rgba(var(--rgb-accent),.55)}}
992
+ .dash-url{margin:8px 0 0;font-size:13px} .dash-url a{color:var(--t-accent);text-decoration:none;border-bottom:1px dashed rgba(var(--rgb-accent),.45)} .dash-url a:hover{border-bottom-style:solid}
993
+ .handoff-note{margin:4px 0 0;color:var(--muted);font-size:12px}
994
+ .tier.is-current,.feature.is-current{animation:cardPulse 1.2s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 3px rgba(var(--rgb-accent),.45), 0 0 12px rgba(var(--rgb-accent),.25), var(--shadow)}
995
+ .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent);font-weight:650}
996
+ .row.is-active{animation:taskBlink 0.9s ease-in-out infinite; outline:2px solid var(--c-active); outline-offset:-2px; box-shadow:0 0 8px rgba(var(--rgb-active),.35)}
997
+ @keyframes cardPulse{0%,100%{box-shadow:0 0 0 3px rgba(var(--rgb-accent),.45),0 0 12px rgba(var(--rgb-accent),.25),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 7px rgba(var(--rgb-accent),.22),0 0 16px rgba(var(--rgb-accent),.32),var(--shadow); border-color:rgba(var(--rgb-accent),.70)}}
994
998
  @keyframes textPulse{0%,100%{opacity:1}50%{opacity:.78}}
995
999
  .row-blocked{background:rgba(var(--rgb-blocked),.07)}
996
1000
  .row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
@@ -1237,6 +1241,8 @@ export function renderDashboard(state: DashboardState): string {
1237
1241
  if (progress.tasksTotal > 0) titleBits.push(`${progress.percent}%`);
1238
1242
  const title = `${titleBits.join(" · ")} · infinity-harness`;
1239
1243
 
1244
+ const dashboardUrlHtml = state.dashboardUrl ? `<div class="dash-url">Dashboard: <a href="${esc(state.dashboardUrl)}">${esc(state.dashboardUrl)}</a></div>` : "";
1245
+ const handoffNoteHtml = state.handoffModelNote ? `<div class="handoff-note">${esc(state.handoffModelNote)}</div>` : "";
1240
1246
  return `<!doctype html>
1241
1247
  <html lang="en">
1242
1248
  <head>
@@ -1253,6 +1259,7 @@ export function renderDashboard(state: DashboardState): string {
1253
1259
  <div id="app">
1254
1260
  <div class="page">
1255
1261
  ${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
1262
+ ${dashboardUrlHtml}${handoffNoteHtml}
1256
1263
  ${display.levels.goal ? renderGoals(goals) : ""}
1257
1264
  ${display.rail ? renderRail(state.phase, state.enabledPhases, paused) : ""}
1258
1265
  ${
package/src/ui/widget.ts CHANGED
@@ -46,6 +46,10 @@ export type WidgetState = {
46
46
  enabledPhases?: readonly string[] | null;
47
47
  paused?: boolean;
48
48
  gate?: { overall: boolean; failures: string[] } | null;
49
+ /** Dashboard URL to show near the top, clickable. Meaningful host:port, not just numbers. */
50
+ dashboardUrl?: string | null;
51
+ /** Model routing note: e.g. "Model per task — subtasks share parent". Shown once. */
52
+ handoffModelNote?: string | null;
49
53
  /** Shown in the header rule, e.g. "rev 42". */
50
54
  revision?: number;
51
55
  retries?: { task: number; max: number };
@@ -406,6 +410,17 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
406
410
  const headRight = phaseTag + revTag;
407
411
  const gapW = inner - width(headLeft) - width(headRight);
408
412
  push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
413
+ // Dashboard URL near top, clickable (OSC 8) with meaningful host:port, not just numbers.
414
+ if (state.dashboardUrl) {
415
+ const url = state.dashboardUrl;
416
+ const label = s.fg("accent", url);
417
+ // OSC 8 hyperlink: terminals that support it make it clickable; others show the URL.
418
+ const link = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
419
+ push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
420
+ }
421
+ if (state.handoffModelNote) {
422
+ push(truncate(s.fg("muted", state.handoffModelNote), inner));
423
+ }
409
424
 
410
425
  // -- goal -----------------------------------------------------------------
411
426
  //
package/src/ui/wizard.ts CHANGED
@@ -183,6 +183,23 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
183
183
  | "phase"
184
184
  | "task";
185
185
 
186
+ // -- 3b. research depth (only when research is in the pipeline) ----------
187
+ let researchDepth: import("../intake.ts").ResearchDepth | undefined;
188
+ if (workflow.phases.includes("research" as import("../core/types.ts").Phase)) {
189
+ const RESEARCH_DEPTH_QUESTION = {
190
+ title: "How deep should research go?",
191
+ options: [
192
+ { value: "standard", label: "Deep — 3 tasks, 5+ primary sources + comparison table", help: ">=5 sources with URLs, constraints table, 3 options + falsification (~800 chars gate). Lite mode." },
193
+ { value: "deep", label: "Very Deep — 5 tasks, competitive matrix + cost/risk model", help: ">=7 sources, gap analysis, competitive matrix, risk register (~1800 chars). Recommended." },
194
+ { value: "comprehensive", label: "Literature Review — 10 tasks, 15+ sources annotated", help: ">=15 sources annotated bibliography, benchmarks, synthesis + gap analysis (~5000 chars)." },
195
+ ],
196
+ };
197
+ const depthLabels = RESEARCH_DEPTH_QUESTION.options.map((o) => line(o.label, o.help));
198
+ const depthPick = await prompt.select(RESEARCH_DEPTH_QUESTION.title, depthLabels);
199
+ if (depthPick === undefined) return { cancelled: true };
200
+ researchDepth = (RESEARCH_DEPTH_QUESTION.options[depthLabels.indexOf(depthPick)]?.value ?? "deep") as import("../intake.ts").ResearchDepth;
201
+ }
202
+
186
203
  // -- 4. models ----------------------------------------------------------
187
204
  const modelsAnswer = await pickModelsStep(prompt, options.models);
188
205
  if (modelsAnswer === undefined) return { cancelled: true };
@@ -211,7 +228,7 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
211
228
  const display = await pickDisplay(prompt, env);
212
229
  if (display === undefined) return { cancelled: true };
213
230
 
214
- const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router, parallelAt, maxWorkers };
231
+ const answers: IntakeAnswers = { workflow, researchDepth, brief, handoff, display, router: modelsAnswer.router, parallelAt, maxWorkers };
215
232
  const plan = planIntake(answers);
216
233
 
217
234
  if (options.skipConfirm) return { cancelled: false, plan, answers };