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/core/init.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { DEFAULT_ENABLED_PHASES, PHASE_ORDER, PHASE_ROLE } from "./types.ts";
|
|
|
29
29
|
import { defaultConfig, saveConfig } from "./config.ts";
|
|
30
30
|
import { emptyFeatureList, saveFeatureList } from "./featureList.ts";
|
|
31
31
|
import * as P from "./paths.ts";
|
|
32
|
+
import { normalizeDisplay } from "../ui/display.ts";
|
|
32
33
|
|
|
33
34
|
export type StackId = "node" | "python" | "rust" | "go" | "unknown";
|
|
34
35
|
|
|
@@ -149,12 +150,21 @@ export type InitOptions = {
|
|
|
149
150
|
commands?: Partial<ProjectCommands>;
|
|
150
151
|
/** Re-scaffold missing files in a project that already has a config. */
|
|
151
152
|
force?: boolean;
|
|
152
|
-
/**
|
|
153
|
+
/** Legacy three-phase approval switch, kept in step with `phaseModes`. */
|
|
153
154
|
approvals?: Partial<HarnessConfig["approvals"]>;
|
|
155
|
+
/** Mode per phase — which of them stop for a human signature. */
|
|
156
|
+
phaseModes?: HarnessConfig["phaseModes"];
|
|
157
|
+
/** Which named workflow those modes came from. */
|
|
158
|
+
workflow?: HarnessConfig["workflow"];
|
|
159
|
+
/** What the widget and the dashboard draw. */
|
|
160
|
+
display?: HarnessConfig["display"];
|
|
154
161
|
/** Session-handoff policy. Defaults to a fresh session per phase. */
|
|
155
162
|
session?: Partial<HarnessConfig["session"]>;
|
|
156
163
|
/** What the human said they want built. Recorded, and read by the first brief. */
|
|
157
164
|
brief?: string | null;
|
|
165
|
+
/** Model routing for difficulty tiers and consulting. */
|
|
166
|
+
router?: Partial<import("../../src/modelRouter.ts").RouterConfig>;
|
|
167
|
+
|
|
158
168
|
};
|
|
159
169
|
|
|
160
170
|
export type InitResult = {
|
|
@@ -210,6 +220,20 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
210
220
|
config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
|
|
211
221
|
config.approvals = { ...config.approvals, ...stripUndefined(options.approvals ?? {}) };
|
|
212
222
|
config.session = { ...config.session, ...stripUndefined(options.session ?? {}) };
|
|
223
|
+
// Every enabled phase gets a mode, so a phase list and a mode map cannot
|
|
224
|
+
// disagree about which phases exist. A caller that still passes the 2.3
|
|
225
|
+
// `approvals` shape and no modes gets what it asked for rather than silently
|
|
226
|
+
// getting autopilot — the same rule `loadConfig` applies to an older file.
|
|
227
|
+
const legacy = (options.approvals ?? {}) as Record<string, unknown>;
|
|
228
|
+
const hasModes = options.phaseModes && Object.keys(options.phaseModes).length > 0;
|
|
229
|
+
config.phaseModes = Object.fromEntries(
|
|
230
|
+
phases.map((p) => [
|
|
231
|
+
p,
|
|
232
|
+
(hasModes ? options.phaseModes?.[p] === "copilot" : legacy[p] === true) ? "copilot" : "autopilot",
|
|
233
|
+
]),
|
|
234
|
+
);
|
|
235
|
+
if (options.workflow) config.workflow = options.workflow;
|
|
236
|
+
if (options.display) config.display = normalizeDisplay(options.display);
|
|
213
237
|
if (options.brief !== undefined) {
|
|
214
238
|
config.intake = {
|
|
215
239
|
completed: true,
|
|
@@ -217,6 +241,23 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
217
241
|
at: new Date().toISOString(),
|
|
218
242
|
};
|
|
219
243
|
}
|
|
244
|
+
if (options.router) {
|
|
245
|
+
try {
|
|
246
|
+
const routerPath = P.modelRouterPath(targetDir);
|
|
247
|
+
mkdirSync(dirname(routerPath), { recursive: true });
|
|
248
|
+
let existing: Record<string, unknown> = {};
|
|
249
|
+
try { if (existsSync(routerPath)) existing = JSON.parse(readFileSync(routerPath, "utf-8")); } catch { /* ignore corrupt */ }
|
|
250
|
+
const incoming = options.router as Record<string, unknown>;
|
|
251
|
+
const merged: Record<string, unknown> = { ...existing, ...incoming };
|
|
252
|
+
if ((incoming as { byDifficulty?: unknown }).byDifficulty && typeof (incoming as { byDifficulty?: unknown }).byDifficulty === "object") {
|
|
253
|
+
merged.byDifficulty = { ...((existing.byDifficulty as Record<string,string>) ?? {}), ...(incoming.byDifficulty as Record<string,string>) };
|
|
254
|
+
}
|
|
255
|
+
if ((incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty && typeof (incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty === "object") {
|
|
256
|
+
merged.thinkingByDifficulty = { ...((existing.thinkingByDifficulty as Record<string,string>) ?? {}), ...(incoming.thinkingByDifficulty as Record<string,string>) };
|
|
257
|
+
}
|
|
258
|
+
writeFileSync(routerPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
259
|
+
} catch { /* best-effort */ }
|
|
260
|
+
}
|
|
220
261
|
|
|
221
262
|
const write = (path: string, body: string) => {
|
|
222
263
|
const rel = path.slice(targetDir.length + 1);
|
package/src/core/paths.ts
CHANGED
|
@@ -5,10 +5,35 @@
|
|
|
5
5
|
* Nothing outside this module hardcodes a harness path.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { homedir } from "node:os";
|
|
8
9
|
import { resolve } from "node:path";
|
|
9
10
|
|
|
10
11
|
export const HARNESS_DIRNAME = "harness";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Where things that belong to the *person* live, rather than to a project.
|
|
15
|
+
*
|
|
16
|
+
* A workflow someone designed and named is worth exactly as much on their next
|
|
17
|
+
* project as on this one, so it cannot live under `harness/`. This follows
|
|
18
|
+
* pi's own config directory, honouring the same override pi honours, so a
|
|
19
|
+
* sandboxed or rebranded install keeps everything in one place.
|
|
20
|
+
*/
|
|
21
|
+
export function userDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
22
|
+
const override = env.PI_CODING_AGENT_DIR;
|
|
23
|
+
if (override && override.trim()) return resolve(override.trim(), "infinity-harness");
|
|
24
|
+
return resolve(homedir(), ".pi", "agent", "infinity-harness");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The workflows this person has saved, reusable across every project. */
|
|
28
|
+
export function userWorkflowsPath(env?: NodeJS.ProcessEnv): string {
|
|
29
|
+
return resolve(userDir(env), "workflows.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The display templates this person has saved. */
|
|
33
|
+
export function userDisplayPath(env?: NodeJS.ProcessEnv): string {
|
|
34
|
+
return resolve(userDir(env), "displays.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export function harnessDir(targetDir: string): string {
|
|
13
38
|
return resolve(targetDir, HARNESS_DIRNAME);
|
|
14
39
|
}
|
package/src/core/settings.ts
CHANGED
|
@@ -28,7 +28,8 @@ export type SettingType =
|
|
|
28
28
|
| { kind: "choice"; choices: readonly string[] }
|
|
29
29
|
| { kind: "multi"; choices: readonly string[] }
|
|
30
30
|
/** Resolved at runtime from the models pi has configured. */
|
|
31
|
-
| { kind: "model" }
|
|
31
|
+
| { kind: "model" }
|
|
32
|
+
| { kind: "thinking" };
|
|
32
33
|
|
|
33
34
|
export type Setting = {
|
|
34
35
|
/** Dotted path within the file. */
|
|
@@ -49,6 +50,9 @@ export type SettingsGroup = {
|
|
|
49
50
|
|
|
50
51
|
// ── The schema ──────────────────────────────────────────────────────────────
|
|
51
52
|
|
|
53
|
+
/** The two things a phase can do when its gate passes. */
|
|
54
|
+
const PHASE_MODE_CHOICES = ["autopilot", "copilot"] as const;
|
|
55
|
+
|
|
52
56
|
const DIFFICULTY_HELP =
|
|
53
57
|
"Tasks the planner marked at this difficulty run on this model. Empty means: use whatever model pi is already on.";
|
|
54
58
|
|
|
@@ -72,6 +76,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
72
76
|
help: DIFFICULTY_HELP,
|
|
73
77
|
type: { kind: "model" },
|
|
74
78
|
},
|
|
79
|
+
{
|
|
80
|
+
path: "thinkingByDifficulty.easy",
|
|
81
|
+
file: "router",
|
|
82
|
+
label: "Easy thinking",
|
|
83
|
+
help: "Thinking level for easy tasks. Empty inherits pi's current level.",
|
|
84
|
+
type: { kind: "thinking" },
|
|
85
|
+
},
|
|
75
86
|
{
|
|
76
87
|
path: "byDifficulty.moderate",
|
|
77
88
|
file: "router",
|
|
@@ -79,6 +90,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
79
90
|
help: DIFFICULTY_HELP,
|
|
80
91
|
type: { kind: "model" },
|
|
81
92
|
},
|
|
93
|
+
{
|
|
94
|
+
path: "thinkingByDifficulty.moderate",
|
|
95
|
+
file: "router",
|
|
96
|
+
label: "Moderate thinking",
|
|
97
|
+
help: "Thinking level for moderate tasks. Empty inherits pi's current level.",
|
|
98
|
+
type: { kind: "thinking" },
|
|
99
|
+
},
|
|
82
100
|
{
|
|
83
101
|
path: "byDifficulty.difficult",
|
|
84
102
|
file: "router",
|
|
@@ -86,6 +104,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
86
104
|
help: DIFFICULTY_HELP,
|
|
87
105
|
type: { kind: "model" },
|
|
88
106
|
},
|
|
107
|
+
{
|
|
108
|
+
path: "thinkingByDifficulty.difficult",
|
|
109
|
+
file: "router",
|
|
110
|
+
label: "Difficult thinking",
|
|
111
|
+
help: "Thinking level for difficult tasks. Empty inherits pi's current level.",
|
|
112
|
+
type: { kind: "thinking" },
|
|
113
|
+
},
|
|
89
114
|
{
|
|
90
115
|
path: "master",
|
|
91
116
|
file: "router",
|
|
@@ -93,6 +118,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
93
118
|
help: "Never assigned to a task directly — reached only when the ladder is exhausted and the harness asks for one opinion.",
|
|
94
119
|
type: { kind: "model" },
|
|
95
120
|
},
|
|
121
|
+
{
|
|
122
|
+
path: "thinkingMaster",
|
|
123
|
+
file: "router",
|
|
124
|
+
label: "Master thinking",
|
|
125
|
+
help: "Thinking level for the master consultation model.",
|
|
126
|
+
type: { kind: "thinking" },
|
|
127
|
+
},
|
|
96
128
|
{
|
|
97
129
|
path: "default",
|
|
98
130
|
file: "router",
|
|
@@ -100,6 +132,13 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
100
132
|
help: "Used when nothing more specific matches. Empty means pi's current model.",
|
|
101
133
|
type: { kind: "model" },
|
|
102
134
|
},
|
|
135
|
+
{
|
|
136
|
+
path: "thinkingDefault",
|
|
137
|
+
file: "router",
|
|
138
|
+
label: "Default thinking",
|
|
139
|
+
help: "Fallback thinking level when no tier-specific level is set.",
|
|
140
|
+
type: { kind: "thinking" },
|
|
141
|
+
},
|
|
103
142
|
{
|
|
104
143
|
path: "consultation.enabled",
|
|
105
144
|
file: "router",
|
|
@@ -152,31 +191,157 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
152
191
|
],
|
|
153
192
|
},
|
|
154
193
|
{
|
|
155
|
-
id: "
|
|
156
|
-
label: "
|
|
157
|
-
help: "Which phases stop and wait for your signature
|
|
194
|
+
id: "workflow",
|
|
195
|
+
label: "Workflow",
|
|
196
|
+
help: "Which phases stop and wait for your signature, one phase at a time. `/infinity:workflow` picks a named one or builds a new one.",
|
|
158
197
|
settings: [
|
|
159
198
|
{
|
|
160
|
-
path: "
|
|
199
|
+
path: "phaseModes.research",
|
|
161
200
|
file: "config",
|
|
162
|
-
label: "
|
|
163
|
-
help: "
|
|
164
|
-
type: { kind: "
|
|
201
|
+
label: "RESEARCH",
|
|
202
|
+
help: "copilot stops so you can read harness/docs/RESEARCH.md before anything is specified.",
|
|
203
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
165
204
|
},
|
|
166
205
|
{
|
|
167
|
-
path: "
|
|
206
|
+
path: "phaseModes.define",
|
|
168
207
|
file: "config",
|
|
169
|
-
label: "
|
|
208
|
+
label: "DEFINE",
|
|
170
209
|
help: "The highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
|
|
210
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
path: "phaseModes.plan",
|
|
214
|
+
file: "config",
|
|
215
|
+
label: "PLAN",
|
|
216
|
+
help: "copilot shows you the whole task list before a line of it is built.",
|
|
217
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
path: "phaseModes.build",
|
|
221
|
+
file: "config",
|
|
222
|
+
label: "BUILD",
|
|
223
|
+
help: "copilot stops once the code passes its gate, so you can read the diff.",
|
|
224
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
path: "phaseModes.verify",
|
|
228
|
+
file: "config",
|
|
229
|
+
label: "VERIFY",
|
|
230
|
+
help: "copilot asks whether the tests prove the thing works or only that it runs.",
|
|
231
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
path: "phaseModes.simplify",
|
|
235
|
+
file: "config",
|
|
236
|
+
label: "SIMPLIFY",
|
|
237
|
+
help: "copilot shows you what was deleted before it moves on.",
|
|
238
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
path: "phaseModes.review",
|
|
242
|
+
file: "config",
|
|
243
|
+
label: "REVIEW",
|
|
244
|
+
help: "copilot asks whether you would approve this if someone else had written it.",
|
|
245
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
path: "phaseModes.ship",
|
|
249
|
+
file: "config",
|
|
250
|
+
label: "SHIP",
|
|
251
|
+
help: "copilot stops before the tag goes on. The last chance to say no.",
|
|
252
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "display",
|
|
258
|
+
label: "Display",
|
|
259
|
+
help: "What the terminal widget and the web dashboard draw. `/infinity:display` picks a template or edits this level by level.",
|
|
260
|
+
settings: [
|
|
261
|
+
{
|
|
262
|
+
path: "display.levels.goal",
|
|
263
|
+
file: "config",
|
|
264
|
+
label: "Goals",
|
|
265
|
+
help: "The outermost level. Off on a plan with one goal costs you nothing.",
|
|
171
266
|
type: { kind: "boolean" },
|
|
172
267
|
},
|
|
173
268
|
{
|
|
174
|
-
path: "
|
|
269
|
+
path: "display.levels.sprint",
|
|
175
270
|
file: "config",
|
|
176
|
-
label: "
|
|
177
|
-
help: "
|
|
271
|
+
label: "Sprints",
|
|
272
|
+
help: "Off hides the sprint rows; the features under them still show, one level shallower.",
|
|
178
273
|
type: { kind: "boolean" },
|
|
179
274
|
},
|
|
275
|
+
{
|
|
276
|
+
path: "display.levels.feature",
|
|
277
|
+
file: "config",
|
|
278
|
+
label: "Features",
|
|
279
|
+
help: "Off hides the feature rows; their tasks still show.",
|
|
280
|
+
type: { kind: "boolean" },
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
path: "display.levels.task",
|
|
284
|
+
file: "config",
|
|
285
|
+
label: "Tasks",
|
|
286
|
+
help: "Off leaves the shape of the run without the work — see the `overview` template.",
|
|
287
|
+
type: { kind: "boolean" },
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
path: "display.levels.subtask",
|
|
291
|
+
file: "config",
|
|
292
|
+
label: "Subtasks",
|
|
293
|
+
help: "active shows them only on the task being worked, which is what fits in a widget.",
|
|
294
|
+
type: { kind: "choice", choices: ["none", "active", "all"] },
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
path: "display.counts",
|
|
298
|
+
file: "config",
|
|
299
|
+
label: "Counts",
|
|
300
|
+
help: "The done/total figure on goals, sprints and features.",
|
|
301
|
+
type: { kind: "boolean" },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
path: "display.dependencies",
|
|
305
|
+
file: "config",
|
|
306
|
+
label: "Dependency labels",
|
|
307
|
+
help: "The `← #3` markers that say which task a task is waiting on.",
|
|
308
|
+
type: { kind: "boolean" },
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
path: "display.criteria",
|
|
312
|
+
file: "config",
|
|
313
|
+
label: "Acceptance criteria",
|
|
314
|
+
help: "Shown under each feature on the dashboard. No room for them in a terminal widget.",
|
|
315
|
+
type: { kind: "boolean" },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
path: "display.rail",
|
|
319
|
+
file: "config",
|
|
320
|
+
label: "Phase rail",
|
|
321
|
+
help: "The `define ─ plan ─ BUILD ─ …` strip.",
|
|
322
|
+
type: { kind: "boolean" },
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
path: "display.progress",
|
|
326
|
+
file: "config",
|
|
327
|
+
label: "Progress meter",
|
|
328
|
+
help: "The bar and the task/feature counts beside it.",
|
|
329
|
+
type: { kind: "boolean" },
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
path: "display.alerts",
|
|
333
|
+
file: "config",
|
|
334
|
+
label: "Alert strip",
|
|
335
|
+
help: "Blocked and rework counts, retries, sessions, and any phase waiting for you.",
|
|
336
|
+
type: { kind: "boolean" },
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
path: "display.taskWindow",
|
|
340
|
+
file: "config",
|
|
341
|
+
label: "Widget rows",
|
|
342
|
+
help: "How many rows of plan the terminal shows before it starts scrolling.",
|
|
343
|
+
type: { kind: "number", min: 3, max: 60 },
|
|
344
|
+
},
|
|
180
345
|
],
|
|
181
346
|
},
|
|
182
347
|
{
|
|
@@ -188,8 +353,8 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
188
353
|
path: "session.handoff",
|
|
189
354
|
file: "config",
|
|
190
355
|
label: "Fresh session",
|
|
191
|
-
help: "phase:
|
|
192
|
-
type: { kind: "choice", choices: ["off", "phase", "task"] },
|
|
356
|
+
help: "goal: one session · phase: per phase (old) · sprint/feature: when plan grouping changes · task: every task (default) · subtask: every subtask. Coarser levels still fire.",
|
|
357
|
+
type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
|
|
193
358
|
},
|
|
194
359
|
{
|
|
195
360
|
path: "session.contextThreshold",
|
|
@@ -409,6 +574,8 @@ export function formatValue(setting: Setting, value: unknown): string {
|
|
|
409
574
|
return value ? "on" : "off";
|
|
410
575
|
case "model":
|
|
411
576
|
return typeof value === "string" && value.trim() ? value : "(pi's current model)";
|
|
577
|
+
case "thinking":
|
|
578
|
+
return typeof value === "string" && value.trim() ? value : "(inherit)";
|
|
412
579
|
case "text":
|
|
413
580
|
return typeof value === "string" && value.trim() ? value : "(not set)";
|
|
414
581
|
case "multi":
|
|
@@ -454,6 +621,8 @@ export function parseDuration(input: string): number | null {
|
|
|
454
621
|
|
|
455
622
|
export type ValidationResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
456
623
|
|
|
624
|
+
export const THINKING_CHOICES = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
625
|
+
|
|
457
626
|
/** Coerce and bounds-check a raw answer for `setting`. */
|
|
458
627
|
export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
459
628
|
const t = setting.type;
|
|
@@ -467,6 +636,13 @@ export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
|
467
636
|
if (t.max !== undefined && n > t.max) return { ok: false, error: `must be at most ${t.max}` };
|
|
468
637
|
return { ok: true, value: n };
|
|
469
638
|
}
|
|
639
|
+
case "thinking": {
|
|
640
|
+
const v = raw.trim();
|
|
641
|
+
if (!v || v === "(inherit)") return { ok: true, value: "" };
|
|
642
|
+
const allowed = new Set(THINKING_CHOICES.slice(1) as readonly string[]);
|
|
643
|
+
if (!allowed.has(v)) return { ok: false, error: `must be one of: ${THINKING_CHOICES.join(", ")}` };
|
|
644
|
+
return { ok: true, value: v };
|
|
645
|
+
}
|
|
470
646
|
case "text":
|
|
471
647
|
case "model": {
|
|
472
648
|
const v = raw.trim();
|
package/src/core/types.ts
CHANGED
|
@@ -158,14 +158,19 @@ export type RetryBucket = {
|
|
|
158
158
|
* session boundary costs nothing but the brief, and the brief is what the
|
|
159
159
|
* agent should be working from anyway.
|
|
160
160
|
*/
|
|
161
|
+
export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
|
|
162
|
+
|
|
161
163
|
export type SessionPolicy = {
|
|
162
164
|
/**
|
|
163
165
|
* When to hand off to a fresh session.
|
|
164
|
-
* off
|
|
165
|
-
* phase
|
|
166
|
-
*
|
|
166
|
+
* off/goal never — one session for the whole run (the old behaviour)
|
|
167
|
+
* phase when the pipeline advances a phase
|
|
168
|
+
* sprint when the active sprint changes (or phase)
|
|
169
|
+
* feature when the active feature changes (or coarser)
|
|
170
|
+
* task when the active task changes (or coarser) — default
|
|
171
|
+
* subtask when the active subtask changes (or coarser)
|
|
167
172
|
*/
|
|
168
|
-
handoff:
|
|
173
|
+
handoff: HandoffGranularity;
|
|
169
174
|
/**
|
|
170
175
|
* Hand off early once the context is this full, as a fraction of the
|
|
171
176
|
* window. 0 disables it. This is what keeps a long BUILD phase — which may
|
|
@@ -176,13 +181,55 @@ export type SessionPolicy = {
|
|
|
176
181
|
carryNotes: boolean;
|
|
177
182
|
};
|
|
178
183
|
|
|
179
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Which phases stop and wait for a human signature before the run continues.
|
|
186
|
+
*
|
|
187
|
+
* Superseded by `HarnessConfig.phaseModes`, which says the same thing for
|
|
188
|
+
* *every* phase rather than only these three. Kept because configs written by
|
|
189
|
+
* 2.3 have it, and `loadConfig` migrates them on read.
|
|
190
|
+
*/
|
|
180
191
|
export type ApprovalPolicy = {
|
|
181
192
|
research: boolean;
|
|
182
193
|
define: boolean;
|
|
183
194
|
plan: boolean;
|
|
184
195
|
};
|
|
185
196
|
|
|
197
|
+
/** What happens when a phase's gate passes: stop for the human, or advance. */
|
|
198
|
+
export type PhaseMode = "copilot" | "autopilot";
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Which parts of the plan a surface draws.
|
|
202
|
+
*
|
|
203
|
+
* Two people watching the same run want different things on screen: one works
|
|
204
|
+
* in sprints and never opens a subtask, the next has no sprints at all and
|
|
205
|
+
* lives in the subtask list. Rather than pick a winner, the levels are a
|
|
206
|
+
* setting, and the widget and the dashboard read the same one.
|
|
207
|
+
*/
|
|
208
|
+
export type DisplayPolicy = {
|
|
209
|
+
/** Name of the template this came from, or "custom" once it is edited. */
|
|
210
|
+
preset: string;
|
|
211
|
+
levels: {
|
|
212
|
+
goal: boolean;
|
|
213
|
+
sprint: boolean;
|
|
214
|
+
feature: boolean;
|
|
215
|
+
task: boolean;
|
|
216
|
+
/** "active" shows them only on the task being worked. */
|
|
217
|
+
subtask: "none" | "active" | "all";
|
|
218
|
+
};
|
|
219
|
+
/** `2/5` counts on the grouping rows. */
|
|
220
|
+
counts: boolean;
|
|
221
|
+
/** `← #3` dependency labels on tasks. */
|
|
222
|
+
dependencies: boolean;
|
|
223
|
+
/** The phase rail, the progress meter and the alert strip. */
|
|
224
|
+
rail: boolean;
|
|
225
|
+
progress: boolean;
|
|
226
|
+
alerts: boolean;
|
|
227
|
+
/** Acceptance criteria under each feature. Dashboard only — no room in a widget. */
|
|
228
|
+
criteria: boolean;
|
|
229
|
+
/** Rows of plan in the terminal widget before it starts scrolling. */
|
|
230
|
+
taskWindow: number;
|
|
231
|
+
};
|
|
232
|
+
|
|
186
233
|
/** What the start-up wizard settled, so it is never asked twice. */
|
|
187
234
|
export type IntakeState = {
|
|
188
235
|
/** True once the wizard has run to completion for this project. */
|
|
@@ -227,7 +274,13 @@ export type HarnessConfig = {
|
|
|
227
274
|
phases: { enabled: Phase[] };
|
|
228
275
|
roles: { strict: boolean };
|
|
229
276
|
session: SessionPolicy;
|
|
277
|
+
/** Legacy: the three-phase approval switch 2.3 shipped. Migrated to `phaseModes`. */
|
|
230
278
|
approvals: ApprovalPolicy;
|
|
279
|
+
/** Mode per phase — the setting `approvals` became. */
|
|
280
|
+
phaseModes: Partial<Record<Phase, PhaseMode>>;
|
|
281
|
+
/** Which named workflow the modes above came from, before any hand-editing. */
|
|
282
|
+
workflow: { id: string; name: string } | null;
|
|
283
|
+
display: DisplayPolicy;
|
|
231
284
|
intake: IntakeState;
|
|
232
285
|
/** Set when a gate passed but the phase needs a human signature first. */
|
|
233
286
|
awaitingApproval: Phase | null;
|
package/src/handoff.ts
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
* that, only from a command handler, and the adapter is where pi lives.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { HarnessConfig, Phase, SessionPolicy } from "./core/types.ts";
|
|
26
|
+
import type { HarnessConfig, HandoffGranularity, Phase, SessionPolicy } from "./core/types.ts";
|
|
27
27
|
import { pendingSessionPath } from "./core/paths.ts";
|
|
28
28
|
import { readJsonSafe, writeJsonAtomic, removeFile, fileExists } from "./core/fsx.ts";
|
|
29
29
|
|
|
30
30
|
/** Why a session is being replaced. Shown to the human and to the next agent. */
|
|
31
|
-
export type HandoffReason =
|
|
31
|
+
export type HandoffReason = HandoffGranularity | "context" | "goal-pass" | "manual";
|
|
32
32
|
|
|
33
33
|
export type PendingHandoff = {
|
|
34
34
|
reason: HandoffReason;
|
|
@@ -51,22 +51,40 @@ export type HandoffSignals = {
|
|
|
51
51
|
/** Composite key of the task in focus before and after. */
|
|
52
52
|
fromTask: string | null;
|
|
53
53
|
toTask: string | null;
|
|
54
|
+
/** IDs for the coarser plan levels (goal/feature/sprint/subtask). Null means "no active one". */
|
|
55
|
+
fromGoal?: string | null;
|
|
56
|
+
toGoal?: string | null;
|
|
57
|
+
fromSprint?: string | null;
|
|
58
|
+
toSprint?: string | null;
|
|
59
|
+
fromFeature?: string | null;
|
|
60
|
+
toFeature?: string | null;
|
|
61
|
+
fromSubtask?: string | null;
|
|
62
|
+
toSubtask?: string | null;
|
|
54
63
|
/** Fraction of the context window in use, 0..1, or null when unknown. */
|
|
55
64
|
contextRatio: number | null;
|
|
56
65
|
};
|
|
57
66
|
|
|
58
67
|
export type HandoffDecision = { handoff: false } | { handoff: true; reason: HandoffReason; detail: string };
|
|
59
68
|
|
|
69
|
+
const GRANULARITIES: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
|
|
70
|
+
|
|
71
|
+
export function isHandoffGranularity(v: unknown): v is HandoffGranularity {
|
|
72
|
+
return typeof v === "string" && (GRANULARITIES as readonly string[]).includes(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
export function defaultSessionPolicy(): SessionPolicy {
|
|
61
|
-
return { handoff: "
|
|
76
|
+
return { handoff: "task", contextThreshold: 0.6, carryNotes: true };
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
function policyOf(config: HarnessConfig): SessionPolicy {
|
|
65
80
|
const p = (config.session ?? {}) as Partial<SessionPolicy>;
|
|
66
|
-
const handoff = p.handoff
|
|
67
|
-
|
|
81
|
+
const handoff: HandoffGranularity = isHandoffGranularity(p.handoff) ? p.handoff : "task";
|
|
82
|
+
// "goal" is an alias for the single-session behaviour; keep the storage
|
|
83
|
+
// as "goal" so the wizard round-trips, but treat it as "off" here.
|
|
84
|
+
const effective: HandoffGranularity = handoff === "goal" ? "off" : handoff;
|
|
85
|
+
const raw = typeof p.contextThreshold === "number" ? p.contextThreshold : 0.6;
|
|
68
86
|
return {
|
|
69
|
-
handoff,
|
|
87
|
+
handoff: effective,
|
|
70
88
|
// A threshold of 1 or more can never fire and a negative one always would;
|
|
71
89
|
// both are configuration mistakes, and clamping is kinder than either.
|
|
72
90
|
contextThreshold: raw <= 0 ? 0 : Math.min(0.95, raw),
|
|
@@ -81,6 +99,14 @@ function policyOf(config: HarnessConfig): SessionPolicy {
|
|
|
81
99
|
* arrives after compaction has already happened has arrived too late to be the
|
|
82
100
|
* thing that prevented it.
|
|
83
101
|
*/
|
|
102
|
+
/** Coarsest → finest. Handoff fires at the chosen level and everything coarser. */
|
|
103
|
+
const LEVEL_ORDER: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
|
|
104
|
+
|
|
105
|
+
function rank(g: HandoffGranularity): number {
|
|
106
|
+
const i = (LEVEL_ORDER as readonly string[]).indexOf(g);
|
|
107
|
+
return i < 0 ? 5 : i;
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
85
111
|
const policy = policyOf(signals.config);
|
|
86
112
|
if (policy.handoff === "off") return { handoff: false };
|
|
@@ -94,20 +120,62 @@ export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
|
94
120
|
};
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
|
|
123
|
+
const lvl = rank(policy.handoff);
|
|
124
|
+
|
|
125
|
+
// Hierarchy: off(0) < goal(1) < phase(2) < sprint(3) < feature(4) < task(5) < subtask(6).
|
|
126
|
+
// Finer granularity implies coarser boundaries too (task change implies feature/sprint/phase
|
|
127
|
+
// may have changed, but we check coarsest first so the reason reflects the highest level).
|
|
128
|
+
// Only boundaries at or coarser than the configured granularity? No —
|
|
129
|
+
// the knob is "how fine do you want to go". Choosing "task" means
|
|
130
|
+
// phase/feature/sprint/goal AND task boundaries fire; choosing "phase"
|
|
131
|
+
// means only phase (and coarser goal) fires. So a boundary fires iff
|
|
132
|
+
// its rank <= chosen rank. task (5) should not fire when handoff is phase (2). Hence <= lvl.
|
|
133
|
+
// Fine-grained choice: the wizard knob is the *coarsest* level that still
|
|
134
|
+
// gets a fresh session. Picking "task" means every task gets its own
|
|
135
|
+
// session (feature/sprint/phase do too, implicitly). So a boundary fires
|
|
136
|
+
// iff chosenRank >= boundaryRank.
|
|
137
|
+
// Phase always hands off (except off/goal) because phases are the harness
|
|
138
|
+
// backbone; a phase change must never ride the old session's context.
|
|
139
|
+
if (signals.toPhase && signals.fromPhase !== signals.toPhase && lvl >= 2) {
|
|
98
140
|
return {
|
|
99
141
|
handoff: true,
|
|
100
142
|
reason: "phase",
|
|
101
143
|
detail: `${(signals.fromPhase ?? "start").toUpperCase()} → ${signals.toPhase.toUpperCase()}`,
|
|
102
144
|
};
|
|
103
145
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
// "goal/off" never fires here — off early-returned, "goal" was mapped to off.
|
|
147
|
+
// Keep for completeness if rank comparison changes; guarded by lvl so it
|
|
148
|
+
// doesn't resurrect. retain dead code removed check.
|
|
149
|
+
void lvl;
|
|
150
|
+
if (signals.fromSprint !== undefined || signals.toSprint !== undefined) {
|
|
151
|
+
const sFrom = (signals.fromSprint ?? null)?.trim() || null;
|
|
152
|
+
const sTo = (signals.toSprint ?? null)?.trim() || null;
|
|
153
|
+
if (sFrom !== sTo && (sTo || sFrom) && lvl >= 3) {
|
|
154
|
+
return { handoff: true, reason: "sprint" as HandoffReason, detail: `${sFrom ?? "no sprint"} → ${sTo ?? "no sprint"}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (signals.fromFeature !== undefined || signals.toFeature !== undefined) {
|
|
158
|
+
const fFrom = (signals.fromFeature ?? null)?.trim() || null;
|
|
159
|
+
const fTo = (signals.toFeature ?? null)?.trim() || null;
|
|
160
|
+
if (fFrom !== fTo && (fTo || fFrom) && lvl >= 4) {
|
|
161
|
+
return { handoff: true, reason: "feature" as HandoffReason, detail: `${fFrom ?? "no feature"} → ${fTo ?? "no feature"}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (lvl >= 5) {
|
|
165
|
+
if ((signals.fromTask ?? null) !== (signals.toTask ?? null) && (signals.fromTask || signals.toTask)) {
|
|
166
|
+
return {
|
|
167
|
+
handoff: true,
|
|
168
|
+
reason: "task",
|
|
169
|
+
detail: `${signals.fromTask ?? "no task"} → ${signals.toTask ?? "no task"}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (lvl >= 6) {
|
|
174
|
+
const stFrom = (signals.fromSubtask ?? null)?.trim() || null;
|
|
175
|
+
const stTo = (signals.toSubtask ?? null)?.trim() || null;
|
|
176
|
+
if (stFrom !== stTo && (stTo || stFrom)) {
|
|
177
|
+
return { handoff: true, reason: "subtask" as HandoffReason, detail: `${stFrom ?? "no subtask"} → ${stTo ?? "no subtask"}` };
|
|
178
|
+
}
|
|
111
179
|
}
|
|
112
180
|
|
|
113
181
|
return { handoff: false };
|
|
@@ -168,10 +236,15 @@ export function composeKickoff(
|
|
|
168
236
|
detail: string,
|
|
169
237
|
carry: string | null,
|
|
170
238
|
): string {
|
|
171
|
-
const why: Record<
|
|
239
|
+
const why: Record<string, string> = {
|
|
172
240
|
phase: "The pipeline advanced, so the run continues in a clean session.",
|
|
173
241
|
task: "The run moved to a different task, so it continues in a clean session.",
|
|
242
|
+
sprint: "The active sprint changed, so the run continues in a clean session.",
|
|
243
|
+
feature: "The active feature changed, so the run continues in a clean session.",
|
|
244
|
+
goal: "The active goal changed, so the run continues in a clean session.",
|
|
245
|
+
subtask: "The active subtask changed, so the run continues in a clean session.",
|
|
174
246
|
context: "The previous session's context was filling up, so the run continues in a clean one.",
|
|
247
|
+
off: "Session handoff is off.",
|
|
175
248
|
"goal-pass": "A goal pass finished, so the next pass starts in a clean session.",
|
|
176
249
|
manual: "A human asked for a fresh session.",
|
|
177
250
|
};
|