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/CHANGELOG.md +94 -0
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +338 -61
- package/package.json +1 -1
- package/src/approval.ts +15 -5
- package/src/core/config.ts +45 -2
- package/src/core/init.ts +42 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +191 -15
- package/src/core/types.ts +58 -5
- package/src/handoff.ts +88 -15
- package/src/intake.ts +164 -123
- package/src/modelRouter.ts +55 -2
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +13 -0
- package/src/ui/dashboard.ts +110 -31
- package/src/ui/display.ts +267 -0
- package/src/ui/planTree.ts +38 -16
- package/src/ui/widget.ts +50 -18
- package/src/ui/wizard.ts +343 -91
- package/src/workflow.ts +309 -0
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
|
|
10
|
+
* The fix was to separate two questions that were tangled together:
|
|
11
11
|
*
|
|
12
|
-
* 1. What are we building? — always asked
|
|
13
|
-
* 2. Who signs off on what? — the
|
|
12
|
+
* 1. What are we building? — always asked
|
|
13
|
+
* 2. Who signs off on what? — the workflow
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 = ["
|
|
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
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
94
|
-
const phases = normalizePhases(
|
|
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 ?? "
|
|
114
|
+
const handoff = answers.handoff ?? "task";
|
|
106
115
|
const session: SessionPolicy = {
|
|
107
116
|
handoff,
|
|
108
|
-
contextThreshold: handoff === "off" ? 0 : 0.
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
173
|
+
workflow: Workflow,
|
|
140
174
|
phases: Phase[],
|
|
141
|
-
|
|
175
|
+
modes: PhaseModes,
|
|
142
176
|
session: SessionPolicy,
|
|
177
|
+
display: DisplayPolicy,
|
|
143
178
|
brief: string | null,
|
|
144
179
|
): string {
|
|
145
|
-
const signed =
|
|
180
|
+
const signed = phases.filter((p) => modes[p] === "copilot");
|
|
146
181
|
const L: string[] = [];
|
|
147
|
-
L.push(`
|
|
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
|
|
181
|
-
id: "
|
|
182
|
-
title: "How
|
|
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
|
|
227
|
-
help: "Each phase starts clean
|
|
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: "
|
|
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 —
|
|
237
|
-
help: "
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 };
|
package/src/modelRouter.ts
CHANGED
|
@@ -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;
|