infinity-harness 2.3.1 → 2.5.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/src/intake.ts CHANGED
@@ -7,109 +7,121 @@
7
7
  * "you decide everything, including what I want", which is not a mode anybody
8
8
  * asked for.
9
9
  *
10
- * The fix is to separate two questions that were tangled together:
10
+ * The fix was to separate two questions that were tangled together:
11
11
  *
12
- * 1. What are we building? — always asked, in both modes
13
- * 2. Who signs off on what? — the mode's actual meaning
12
+ * 1. What are we building? — always asked
13
+ * 2. Who signs off on what? — the workflow
14
14
  *
15
- * copilot the human is in the loop. DEFINE and PLAN are theirs to approve
16
- * (and RESEARCH too, when it is on). Not negotiable that is what
17
- * the word means.
18
- * autopilot the human is *optionally* in the loop. They pick which of
19
- * RESEARCH / DEFINE / PLAN they want to sign and which they hand
20
- * to the model. Forfeiting all three is the "give it a goal and
21
- * walk away" mode; keeping DEFINE is the common middle.
22
- *
23
- * RESEARCH is a separate, optional phase that runs before DEFINE: the human
24
- * gives an idea, the model goes and finds out what it actually has to be.
15
+ * And the second of those turned out to be tangled too. "copilot" and
16
+ * "autopilot" are one switch, and one switch cannot say "let it define and
17
+ * plan on its own but show me the review". So the answer is a **workflow**: a
18
+ * mode per phase, with the two familiar words as two named points in that
19
+ * space rather than the only two points in it. `src/workflow.ts` owns what a
20
+ * workflow is and where saved ones live.
25
21
  *
26
22
  * Nothing here talks to pi. It takes answers and returns a plan of record, so
27
23
  * the flow can be unit-tested without a terminal and driven from the adapter,
28
24
  * from a test, or from a config file.
29
25
  */
30
26
 
31
- import type { ApprovalPolicy, Phase, SessionPolicy } from "./core/types.ts";
27
+ import type { ApprovalPolicy, DisplayPolicy, Phase, SessionPolicy } from "./core/types.ts";
32
28
  import { DEFAULT_ENABLED_PHASES, PHASE_ORDER } from "./core/types.ts";
29
+ import {
30
+ BUILTIN_WORKFLOWS,
31
+ describeModes,
32
+ normalizeModes,
33
+ normalizePhases,
34
+ signedPhases as signedIn,
35
+ type PhaseMode,
36
+ type PhaseModes,
37
+ type Workflow,
38
+ } from "./workflow.ts";
39
+ import { defaultDisplay, normalizeDisplay } from "./ui/display.ts";
33
40
 
34
41
  export type Mode = "copilot" | "autopilot";
35
42
 
36
43
  /** The wizard's questions, in the order they are asked. */
37
- export const INTAKE_STEPS = ["mode", "brief", "research", "approvals", "handoff"] as const;
44
+ export const INTAKE_STEPS = ["workflow", "brief", "handoff", "display"] as const;
38
45
  export type IntakeStep = (typeof INTAKE_STEPS)[number];
39
46
 
40
47
  export type IntakeAnswers = {
41
- mode: Mode;
48
+ /** The chosen workflow: a built-in, one they saved, or one they just built. */
49
+ workflow: Workflow;
42
50
  /** What the human wants built, in their words. Empty is allowed but warned about. */
43
51
  brief: string;
44
- /** Run the optional RESEARCH phase before DEFINE. */
45
- research: boolean;
46
- /**
47
- * Which phases the human wants to sign, for autopilot only.
48
- * Ignored in copilot, where all three are always signed.
49
- */
50
- approvals?: Partial<ApprovalPolicy>;
51
52
  /** Session handoff policy. Defaults to a fresh session per phase. */
52
53
  handoff?: SessionPolicy["handoff"];
53
- /** Phases the human explicitly chose. Overrides the research toggle. */
54
- phases?: Phase[];
54
+ /** What the surfaces should draw. Defaults to the `focus` template. */
55
+ display?: DisplayPolicy;
56
+ /** Model routing for difficulty tiers and consulting. */
57
+ router?: {
58
+ enabled: boolean;
59
+ byDifficulty: Record<string, string>;
60
+ thinkingByDifficulty?: Partial<Record<string, string>>;
61
+ master?: string;
62
+ thinkingMaster?: string;
63
+ default?: string;
64
+ thinkingDefault?: string;
65
+ };
55
66
  };
56
67
 
57
68
  export type IntakePlan = {
69
+ /** Derived: "copilot" when the run stops for the human anywhere, else "autopilot". */
58
70
  mode: Mode;
71
+ workflow: { id: string; name: string };
59
72
  brief: string | null;
60
73
  phases: Phase[];
74
+ phaseModes: PhaseModes;
75
+ /** Kept in step with `phaseModes` so a 2.3 config read by a 2.3 tool still works. */
61
76
  approvals: ApprovalPolicy;
62
77
  session: SessionPolicy;
78
+ display: DisplayPolicy;
79
+ router?: IntakeAnswers["router"];
63
80
  /** What the human should be told about what they just chose. */
64
81
  summary: string;
65
82
  /** Things that will bite later if left as they are. */
66
83
  warnings: string[];
67
84
  };
68
85
 
69
- /**
70
- * In copilot the human is in the loop by definition, so every approvable
71
- * phase that is enabled is theirs. Making this configurable would make
72
- * "copilot" mean nothing.
73
- */
74
- export function copilotApprovals(phases: Phase[]): ApprovalPolicy {
75
- return {
76
- research: phases.includes("research"),
77
- define: true,
78
- plan: true,
79
- };
86
+ export function builtInByName(name: string): Workflow | null {
87
+ return BUILTIN_WORKFLOWS.find((w) => w.id === name || w.name === name) ?? null;
80
88
  }
81
89
 
82
- function normalizePhases(base: Phase[], research: boolean): Phase[] {
83
- const wanted = new Set<Phase>(base.filter((p) => (PHASE_ORDER as readonly string[]).includes(p)));
84
- wanted.delete("init");
85
- if (research) wanted.add("research");
86
- else wanted.delete("research");
87
- const ordered = PHASE_ORDER.filter((p) => wanted.has(p));
88
- return ordered.length ? [...ordered] : [...DEFAULT_ENABLED_PHASES];
90
+ /** Build an ad-hoc workflow from a phase list and a mode per phase. */
91
+ export function customWorkflow(
92
+ phases: Phase[],
93
+ modes: PhaseModes,
94
+ name = "custom",
95
+ ): Workflow {
96
+ const ordered = normalizePhases(phases);
97
+ const normalized = normalizeModes(modes, ordered);
98
+ return {
99
+ id: "custom",
100
+ name,
101
+ description: describeModes(ordered, normalized),
102
+ builtIn: false,
103
+ phases: ordered,
104
+ modes: normalized,
105
+ };
89
106
  }
90
107
 
91
108
  /** Turn the wizard's answers into everything `initHarness` needs. */
92
109
  export function planIntake(answers: IntakeAnswers): IntakePlan {
93
- const research = answers.research === true;
94
- const phases = normalizePhases(answers.phases ?? [...DEFAULT_ENABLED_PHASES], research);
95
-
96
- const approvals: ApprovalPolicy =
97
- answers.mode === "copilot"
98
- ? copilotApprovals(phases)
99
- : {
100
- research: phases.includes("research") && answers.approvals?.research === true,
101
- define: answers.approvals?.define === true,
102
- plan: answers.approvals?.plan === true,
103
- };
110
+ const workflow = answers.workflow;
111
+ const phases = normalizePhases(workflow.phases);
112
+ const phaseModes = normalizeModes(workflow.modes, phases);
104
113
 
105
- const handoff = answers.handoff ?? "phase";
114
+ const handoff = answers.handoff ?? "task";
106
115
  const session: SessionPolicy = {
107
116
  handoff,
108
- contextThreshold: handoff === "off" ? 0 : 0.7,
117
+ contextThreshold: handoff === "off" ? 0 : 0.6,
109
118
  carryNotes: true,
110
119
  };
111
120
 
121
+ const display = normalizeDisplay(answers.display ?? defaultDisplay());
112
122
  const brief = answers.brief?.trim() ? answers.brief.trim() : null;
123
+ const signed = PHASE_ORDER.filter((p) => phaseModes[p] === "copilot");
124
+ const mode: Mode = signed.length > 0 ? "copilot" : "autopilot";
113
125
 
114
126
  const warnings: string[] = [];
115
127
  if (!brief) {
@@ -118,13 +130,19 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
118
130
  "It will not guess a project.",
119
131
  );
120
132
  }
121
- if (answers.mode === "autopilot" && !approvals.define && !approvals.plan && !approvals.research) {
133
+ if (signed.length === 0) {
122
134
  warnings.push(
123
135
  "Nothing is being approved by you. The model decides what to build and how, " +
124
136
  "and you see it when it is done. This is the right setting for a run you " +
125
137
  "want to walk away from — and the wrong one if the goal is vague.",
126
138
  );
127
139
  }
140
+ if (signed.length === phases.length) {
141
+ warnings.push(
142
+ "Every phase stops for you. Nothing moves while you are away, which is the " +
143
+ "point — just do not expect to start this and go to bed.",
144
+ );
145
+ }
128
146
  if (handoff === "off") {
129
147
  warnings.push(
130
148
  "Session handoff is off, so the whole run shares one context window. " +
@@ -132,20 +150,37 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
132
150
  );
133
151
  }
134
152
 
135
- return { mode: answers.mode, brief, phases, approvals, session, summary: summarize(answers.mode, phases, approvals, session, brief), warnings };
153
+ return {
154
+ mode,
155
+ workflow: { id: workflow.id, name: workflow.name },
156
+ brief,
157
+ phases,
158
+ phaseModes,
159
+ approvals: {
160
+ research: phaseModes.research === "copilot",
161
+ define: phaseModes.define === "copilot",
162
+ plan: phaseModes.plan === "copilot",
163
+ },
164
+ session,
165
+ display,
166
+ router: answers.router,
167
+ summary: summarize(workflow, phases, phaseModes, session, display, brief),
168
+ warnings,
169
+ };
136
170
  }
137
171
 
138
172
  function summarize(
139
- mode: Mode,
173
+ workflow: Workflow,
140
174
  phases: Phase[],
141
- approvals: ApprovalPolicy,
175
+ modes: PhaseModes,
142
176
  session: SessionPolicy,
177
+ display: DisplayPolicy,
143
178
  brief: string | null,
144
179
  ): string {
145
- const signed = (["research", "define", "plan"] as const).filter((p) => approvals[p]);
180
+ const signed = phases.filter((p) => modes[p] === "copilot");
146
181
  const L: string[] = [];
147
- L.push(`Mode ${mode}`);
148
- L.push(`Pipeline ${phases.join(" → ")}`);
182
+ L.push(`Workflow ${workflow.name}`);
183
+ L.push(`Pipeline ${phases.map((p) => (modes[p] === "copilot" ? `[${p}]` : p)).join(" → ")}`);
149
184
  L.push(
150
185
  `You sign ${signed.length ? signed.map((s) => s.toUpperCase()).join(", ") : "nothing — the model decides and runs"}`,
151
186
  );
@@ -158,6 +193,7 @@ function summarize(
158
193
  : "fresh session per phase"
159
194
  }`,
160
195
  );
196
+ L.push(`Display ${display.preset}`);
161
197
  L.push(`Goal ${brief ?? "(none yet — you will be asked first thing)"}`);
162
198
  return L.join("\n");
163
199
  }
@@ -177,21 +213,9 @@ export type Question = {
177
213
  placeholder?: string;
178
214
  };
179
215
 
180
- export const MODE_QUESTION: Question = {
181
- id: "mode",
182
- title: "How much do you want to be involved?",
183
- options: [
184
- {
185
- value: "copilot",
186
- label: "copilot — I approve the definition and the plan",
187
- help: "The run stops and shows you its work before it starts building. You can send any phase back with a note.",
188
- },
189
- {
190
- value: "autopilot",
191
- label: "autopilot — I choose what to approve, if anything",
192
- help: "You pick which of research, definition and plan you sign. Approve none of them and the run is yours to walk away from.",
193
- },
194
- ],
216
+ export const WORKFLOW_QUESTION: Question = {
217
+ id: "workflow",
218
+ title: "How should this run which phases, and which of them stop for you?",
195
219
  };
196
220
 
197
221
  export const BRIEF_QUESTION: Question = {
@@ -200,64 +224,81 @@ export const BRIEF_QUESTION: Question = {
200
224
  placeholder: "e.g. a CLI that reconciles Stripe payouts against our ledger",
201
225
  };
202
226
 
203
- export const RESEARCH_QUESTION: Question = {
204
- id: "research",
205
- title: "Research the idea first?",
206
- options: [
207
- {
208
- value: "no",
209
- label: "no — go straight to defining it",
210
- help: "Right when you already know what has to be built.",
211
- },
212
- {
213
- value: "yes",
214
- label: "yes — find out what it has to be first",
215
- help: "Adds a RESEARCH phase before DEFINE: prior art, constraints, options with costs, a recommendation, and the questions only you can answer.",
216
- },
217
- ],
218
- };
219
-
220
227
  export const HANDOFF_QUESTION: Question = {
221
228
  id: "handoff",
222
229
  title: "When should the run start a fresh session?",
223
230
  options: [
231
+ {
232
+ value: "goal",
233
+ label: "per goal — one session for the whole run",
234
+ help: "The old single-session run. Every task accumulates context until compaction.",
235
+ },
224
236
  {
225
237
  value: "phase",
226
- label: "every phase (recommended)",
227
- help: "Each phase starts clean, working from the brief. Keeps the context small on long runs.",
238
+ label: "every phase",
239
+ help: "Old default. Each phase starts clean from the brief.",
240
+ },
241
+ {
242
+ value: "sprint",
243
+ label: "every sprint",
244
+ help: "New session whenever the active sprint changes (or phase).",
245
+ },
246
+ {
247
+ value: "feature",
248
+ label: "every feature",
249
+ help: "New session on each feature boundary (and sprint/phase).",
228
250
  },
229
251
  {
230
252
  value: "task",
231
- label: "every task",
232
- help: "The cleanest context per unit of work. Best with small models; costs one extra brief per task.",
253
+ label: "every task (recommended)",
254
+ help: "Each task gets a clean session. Best isolation; one extra brief per task.",
255
+ },
256
+ {
257
+ value: "subtask",
258
+ label: "every subtask",
259
+ help: "Finest grain — each subtask gets a fresh session. Most isolation, most churn.",
233
260
  },
234
261
  {
235
262
  value: "off",
236
- label: "never — one long session",
237
- help: "The old behaviour. The context grows for the whole run and compaction takes over.",
263
+ label: "never — alias for per goal",
264
+ help: "Same as per goal one long session without fresh starts.",
238
265
  },
239
266
  ],
240
267
  };
241
268
 
242
- /** The approval checklist, offered only in autopilot. */
243
- export function approvalOptions(research: boolean): { value: keyof ApprovalPolicy; label: string; help: string }[] {
244
- const out: { value: keyof ApprovalPolicy; label: string; help: string }[] = [];
245
- if (research) {
246
- out.push({
247
- value: "research",
248
- label: "RESEARCH what it found before anything is specified",
249
- help: "You read harness/docs/RESEARCH.md and say whether it is looking at the right problem.",
250
- });
251
- }
252
- out.push({
253
- value: "define",
254
- label: "DEFINE — the scope and the acceptance criteria",
255
- help: "The single highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
256
- });
257
- out.push({
258
- value: "plan",
259
- label: "PLAN — the task list before any code is written",
260
- help: "You see the whole decomposition and can send it back before it is built.",
261
- });
262
- return out;
263
- }
269
+ export const DISPLAY_QUESTION: Question = {
270
+ id: "display",
271
+ title: "How much of the plan do you want on screen?",
272
+ };
273
+
274
+ /** The two modes, as a question about one phase. */
275
+ export const PHASE_MODE_OPTIONS: { value: PhaseMode; label: string; help: string }[] = [
276
+ {
277
+ value: "autopilot",
278
+ label: "autopilot — it passes the gate and moves on",
279
+ help: "The deterministic gate is the only referee for this phase.",
280
+ },
281
+ {
282
+ value: "copilot",
283
+ label: "copilot — it stops and waits for you",
284
+ help: "When the gate passes, the run parks and asks you to sign it off before advancing.",
285
+ },
286
+ ];
287
+
288
+ /** What each phase is for, one line, shown while picking modes. */
289
+ export const PHASE_PURPOSE: Record<Phase, string> = {
290
+ init: "set the project up",
291
+ research: "find out what it actually has to be",
292
+ define: "write down what is being built, and the criteria",
293
+ plan: "break it into ordered, dependency-aware tasks",
294
+ build: "implement it, one task at a time",
295
+ verify: "prove it behaves; hunt what the tests miss",
296
+ simplify: "delete more than you add",
297
+ review: "judge it as if someone else wrote it",
298
+ ship: "tag, changelog, leave the tree clean",
299
+ };
300
+
301
+ /** Phases someone can put in a pipeline. INIT is not one of them. */
302
+ export const SELECTABLE_PHASES: Phase[] = PHASE_ORDER.filter((p) => p !== "init");
303
+
304
+ export { DEFAULT_ENABLED_PHASES, signedIn as signedPhases };
@@ -11,6 +11,14 @@ import { writeJsonAtomic, stripBom } from "./core/fsx.ts";
11
11
  export const ROUTER_FILE = "harness/model-router.json";
12
12
  export const ROUTER_VERSION = 1;
13
13
 
14
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
+
16
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
17
+
18
+ export function isThinkingLevel(v: unknown): v is ThinkingLevel {
19
+ return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v);
20
+ }
21
+
14
22
  export interface RouterConfig {
15
23
  version: number;
16
24
  enabled: boolean;
@@ -24,6 +32,10 @@ export interface RouterConfig {
24
32
  byTask?: Record<string, string>;
25
33
  consultation?: { enabled: boolean; maxPerTask: number; oneStepOnly: boolean; requireExhaustion: boolean };
26
34
  budgets?: { maxReworksPerRun: number; maxReplansPerRun: number; maxReviewBounces: number };
35
+ /** Thinking level per tier, and for master/default. Empty means inherit pi's current level. */
36
+ thinkingByDifficulty?: Partial<Record<string, ThinkingLevel | "">>;
37
+ thinkingMaster?: ThinkingLevel | "";
38
+ thinkingDefault?: ThinkingLevel | "";
27
39
  }
28
40
 
29
41
  /**
@@ -52,6 +64,9 @@ export const DEFAULT_ROUTER: RouterConfig = {
52
64
  byTask: {},
53
65
  consultation: { enabled: true, maxPerTask: 1, oneStepOnly: true, requireExhaustion: true },
54
66
  budgets: { maxReworksPerRun: 3, maxReplansPerRun: 2, maxReviewBounces: 2 },
67
+ thinkingByDifficulty: { easy: "" as ThinkingLevel | "", moderate: "" as ThinkingLevel | "", difficult: "" as ThinkingLevel | "" } as Partial<Record<string, ThinkingLevel | "">>,
68
+ thinkingMaster: "" as ThinkingLevel | "",
69
+ thinkingDefault: "" as ThinkingLevel | "",
55
70
  };
56
71
 
57
72
  export const DIFFICULTY_LADDER: Array<"easy" | "moderate" | "difficult"> = ["easy", "moderate", "difficult"];
@@ -68,9 +83,13 @@ export function saveRouterConfig(projectDir: string, cfg: RouterConfig): void {
68
83
  writeJsonAtomic(routerPath(projectDir), cfg);
69
84
  }
70
85
 
86
+ function normalizeThinking(v: unknown): ThinkingLevel | "" {
87
+ return typeof v === "string" && (THINKING_LEVELS as readonly string[]).includes(v) ? (v as ThinkingLevel) : "";
88
+ }
89
+
71
90
  export function loadRouterConfig(projectDir?: string): RouterConfig {
72
91
  const p = routerPath(projectDir);
73
- if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! } };
92
+ if (!existsSync(p)) return { ...DEFAULT_ROUTER, byDifficulty: { ...DEFAULT_ROUTER.byDifficulty! }, byPhase: {}, byRole: {}, byFeature: {}, bySprint: {}, byTask: {}, consultation: { ...DEFAULT_ROUTER.consultation! }, budgets: { ...DEFAULT_ROUTER.budgets! }, thinkingByDifficulty: { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) }, thinkingMaster: DEFAULT_ROUTER.thinkingMaster, thinkingDefault: DEFAULT_ROUTER.thinkingDefault };
74
93
  try {
75
94
  const raw = JSON.parse(stripBom(readFileSync(p, "utf-8")));
76
95
  // merge with defaults to ensure fields
@@ -87,8 +106,18 @@ export function loadRouterConfig(projectDir?: string): RouterConfig {
87
106
  byTask: raw.byTask ?? {},
88
107
  consultation: raw.consultation ?? { ...DEFAULT_ROUTER.consultation! },
89
108
  budgets: raw.budgets ?? { ...DEFAULT_ROUTER.budgets! },
109
+ thinkingByDifficulty: (() => {
110
+ const cur = raw.thinkingByDifficulty;
111
+ if (!cur || typeof cur !== "object") return { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) };
112
+ const out: Record<string, ThinkingLevel | ""> = {};
113
+ for (const k of DIFFICULTY_LADDER) out[k] = normalizeThinking((cur as Record<string, unknown>)[k]);
114
+ return out;
115
+ })(),
116
+ thinkingMaster: normalizeThinking(raw.thinkingMaster),
117
+ thinkingDefault: normalizeThinking(raw.thinkingDefault),
90
118
  };
91
119
  if (!cfg.byDifficulty) cfg.byDifficulty = { ...DEFAULT_ROUTER.byDifficulty! };
120
+ if (!cfg.thinkingByDifficulty) cfg.thinkingByDifficulty = { ...(DEFAULT_ROUTER.thinkingByDifficulty as Record<string, ThinkingLevel | "">) };
92
121
  return cfg;
93
122
  } catch {
94
123
  return { ...DEFAULT_ROUTER };
@@ -170,14 +199,38 @@ export function consultNext(
170
199
  return null;
171
200
  }
172
201
 
202
+ export function resolveThinking(opts: ResolveOpts = {}): ThinkingLevel | "" {
203
+ const cfg = loadRouterConfig(opts.projectDir);
204
+ // Thinking is orthogonal to routing-enabled; when disabled, fall through to default/inherit.
205
+ const difficulty = opts.difficulty ?? opts.task?.difficulty ?? opts.feature?.difficulty ?? opts.sprint?.difficulty;
206
+ if (difficulty && cfg.thinkingByDifficulty && (cfg.thinkingByDifficulty as Record<string, ThinkingLevel | "">)[difficulty]) {
207
+ const v = (cfg.thinkingByDifficulty as Record<string, ThinkingLevel | "">)[difficulty];
208
+ if (v) return v;
209
+ }
210
+ // Master thinking only via consultation path; here just check difficulty default fallback
211
+ if (cfg.thinkingDefault) return cfg.thinkingDefault;
212
+ return "";
213
+ }
214
+
215
+ export function resolveThinkingForConsult(nextDifficulty: string | null, projectDir?: string): ThinkingLevel | "" {
216
+ const cfg = loadRouterConfig(projectDir);
217
+ if (!nextDifficulty) return cfg.thinkingMaster ?? "";
218
+ const v = cfg.thinkingByDifficulty?.[nextDifficulty as string] as ThinkingLevel | "" | undefined;
219
+ if (v) return v;
220
+ return "";
221
+ }
222
+
173
223
  /** For widget/remote read-only exposure */
174
- export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; master: string; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
224
+ export function routerSummary(projectDir?: string): { enabled: boolean; default: string; byDifficulty: Record<string, string>; thinkingByDifficulty: Record<string, ThinkingLevel | "">; master: string; thinkingMaster: ThinkingLevel | ""; thinkingDefault: ThinkingLevel | ""; budgets: RouterConfig["budgets"]; consultation: RouterConfig["consultation"] } {
175
225
  const cfg = loadRouterConfig(projectDir);
176
226
  return {
177
227
  enabled: cfg.enabled,
178
228
  default: cfg.default,
179
229
  byDifficulty: { ...(cfg.byDifficulty ?? {}) } as Record<string, string>,
230
+ thinkingByDifficulty: { ...(cfg.thinkingByDifficulty ?? {}) } as Record<string, ThinkingLevel | "">,
180
231
  master: cfg.master ?? DEFAULT_ROUTER.master!,
232
+ thinkingMaster: (cfg.thinkingMaster ?? "") as ThinkingLevel | "",
233
+ thinkingDefault: (cfg.thinkingDefault ?? "") as ThinkingLevel | "",
181
234
  budgets: cfg.budgets,
182
235
  consultation: cfg.consultation,
183
236
  };
package/src/remote.ts CHANGED
@@ -18,12 +18,13 @@ import { createServer, type Server } from "node:http";
18
18
  import type { AddressInfo } from "node:net";
19
19
  import { resolve } from "node:path";
20
20
 
21
- import type { FeatureList, Feature, Goal, GateResult, Phase, Sprint } from "./core/types.ts";
21
+ import type { DisplayPolicy, FeatureList, Feature, Goal, GateResult, Phase, Sprint } from "./core/types.ts";
22
22
  import { loadFeatureList, computeProgress } from "./core/featureList.ts";
23
23
  import { loadConfig } from "./core/config.ts";
24
24
  import { modelRouterPath, reworkPath } from "./core/paths.ts";
25
25
  import { readJsonSafe } from "./core/fsx.ts";
26
26
  import { loadRunState } from "./runState.ts";
27
+ import { normalizeDisplay } from "./ui/display.ts";
27
28
  import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
28
29
 
29
30
  export { escapeHtml };
@@ -54,6 +55,8 @@ export interface RemoteState {
54
55
  /** pi sessions this run has spent — proof the handoff is doing its job. */
55
56
  sessions: number | null;
56
57
  goalPass: { current: number; max: number } | null;
58
+ /** What the reader has asked the dashboard to draw. */
59
+ display: DisplayPolicy;
57
60
  }
58
61
 
59
62
  export interface RemoteServer {
@@ -118,6 +121,7 @@ export function buildRemoteState(projectDir?: string): RemoteState {
118
121
  ? { current: config.goalPass, max: config.goalMaxPasses }
119
122
  : null,
120
123
  sprints: list.sprints ?? [],
124
+ display: normalizeDisplay(config.display),
121
125
  };
122
126
  }
123
127
 
@@ -136,6 +140,7 @@ function toDashboardState(s: RemoteState): DashboardState {
136
140
  awaitingApproval: s.awaitingApproval,
137
141
  sessions: s.sessions,
138
142
  goalPass: s.goalPass,
143
+ display: s.display,
139
144
  };
140
145
  }
141
146
 
@@ -159,6 +164,7 @@ export function buildApiPayload(state: RemoteState): Record<string, unknown> {
159
164
  sessions: state.sessions,
160
165
  goalPass: state.goalPass,
161
166
  sprints: state.sprints,
167
+ display: state.display,
162
168
  features: state.features.map((f) => ({
163
169
  id: f.id,
164
170
  name: f.name,
package/src/ui/config.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import type { Setting, SettingsGroup } from "../core/settings.ts";
18
+ import { summarizeWorkflow } from "../workflow.ts";
19
+ import { normalizeDisplay, summarizeDisplay } from "./display.ts";
18
20
  import {
19
21
  SETTINGS,
20
22
  coerce,
@@ -92,6 +94,10 @@ function summarize(group: SettingsGroup, io: ReturnType<typeof readAll>): string
92
94
  }
93
95
  case "pipeline":
94
96
  return (io.config.phases?.enabled ?? []).join(" → ") || "(none)";
97
+ case "workflow":
98
+ return summarizeWorkflow(io.config);
99
+ case "display":
100
+ return summarizeDisplay(normalizeDisplay(io.config.display));
95
101
  case "commands": {
96
102
  const set = Object.entries(io.config.commands ?? {}).filter(([, v]) => Boolean(v));
97
103
  return set.length ? set.map(([k]) => k).join(", ") : "none set";
@@ -160,6 +166,13 @@ async function editSetting(setting: Setting, options: ConfigMenuOptions): Promis
160
166
  raw = picked;
161
167
  break;
162
168
  }
169
+ case "thinking": {
170
+ const choices = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
171
+ const picked = await prompt.select(`${setting.label} — ${setting.help}`, [...choices, BACK]);
172
+ if (picked === undefined || picked === BACK) return false;
173
+ raw = picked;
174
+ break;
175
+ }
163
176
  case "model": {
164
177
  raw = await pickModel(setting, options, typeof current === "string" ? current : "");
165
178
  if (raw === undefined) return false;