infinity-harness 2.3.1 → 2.4.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 +64 -0
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +195 -39
- package/package.json +1 -1
- package/src/approval.ts +15 -5
- package/src/core/config.ts +44 -1
- package/src/core/init.ts +22 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +141 -12
- package/src/core/types.ts +49 -1
- package/src/intake.ts +124 -115
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +6 -0
- package/src/ui/dashboard.ts +90 -27
- 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 +257 -90
- package/src/workflow.ts +309 -0
package/src/core/config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { HarnessConfig, GateHistoryEntry, Phase, Role } from "./types.ts";
|
|
11
11
|
import { DEFAULT_ENABLED_PHASES, PHASE_ROLE } from "./types.ts";
|
|
12
|
+
import { defaultDisplay, normalizeDisplay } from "../ui/display.ts";
|
|
12
13
|
import { configPath } from "./paths.ts";
|
|
13
14
|
import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
|
|
14
15
|
|
|
@@ -51,6 +52,9 @@ export function defaultConfig(): HarnessConfig {
|
|
|
51
52
|
roles: { strict: false },
|
|
52
53
|
session: { handoff: "phase", contextThreshold: 0.7, carryNotes: true },
|
|
53
54
|
approvals: { research: false, define: false, plan: false },
|
|
55
|
+
phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
|
|
56
|
+
workflow: { id: "autopilot", name: "autopilot" },
|
|
57
|
+
display: defaultDisplay(),
|
|
54
58
|
intake: { completed: false, brief: null, at: null },
|
|
55
59
|
awaitingApproval: null,
|
|
56
60
|
loop: {
|
|
@@ -89,6 +93,45 @@ function deepMerge<T>(defaults: T, partial: unknown): T {
|
|
|
89
93
|
return out as T;
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Bring an older config forward on read.
|
|
98
|
+
*
|
|
99
|
+
* 2.3 had a three-phase `approvals` switch; 2.4 has a mode for every phase.
|
|
100
|
+
* A project mid-run must not lose the approvals it was configured with just
|
|
101
|
+
* because the shape moved, and nobody should have to edit JSON to upgrade.
|
|
102
|
+
* The migration is read-only — it takes effect on the next save like any other
|
|
103
|
+
* change — so a downgrade still finds the old field where it left it.
|
|
104
|
+
*/
|
|
105
|
+
function migrate(config: HarnessConfig, stored: Partial<HarnessConfig>): HarnessConfig {
|
|
106
|
+
const out = config as Record<string, unknown>;
|
|
107
|
+
const phases = Array.isArray(config.phases?.enabled) ? config.phases.enabled : [...DEFAULT_ENABLED_PHASES];
|
|
108
|
+
|
|
109
|
+
// The signal is what the *file* had, not what the merge produced: defaults
|
|
110
|
+
// supply a `phaseModes` for every phase, so a merged config always looks
|
|
111
|
+
// migrated and the old approvals would be silently dropped.
|
|
112
|
+
const hadModes =
|
|
113
|
+
typeof stored.phaseModes === "object" &&
|
|
114
|
+
stored.phaseModes !== null &&
|
|
115
|
+
Object.keys(stored.phaseModes).length > 0;
|
|
116
|
+
|
|
117
|
+
if (!hadModes) {
|
|
118
|
+
const approvals = (stored.approvals ?? {}) as Record<string, unknown>;
|
|
119
|
+
const next: Record<string, string> = {};
|
|
120
|
+
for (const p of phases) next[p] = approvals[p] === true ? "copilot" : "autopilot";
|
|
121
|
+
out.phaseModes = next;
|
|
122
|
+
if (!stored.workflow) {
|
|
123
|
+
const signed = phases.filter((p) => next[p] === "copilot");
|
|
124
|
+
out.workflow =
|
|
125
|
+
signed.length === 0
|
|
126
|
+
? { id: "autopilot", name: "autopilot" }
|
|
127
|
+
: { id: "copilot", name: "copilot" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
config.display = normalizeDisplay(config.display);
|
|
132
|
+
return config;
|
|
133
|
+
}
|
|
134
|
+
|
|
92
135
|
export type LoadResult = {
|
|
93
136
|
ok: boolean;
|
|
94
137
|
config: HarnessConfig;
|
|
@@ -114,7 +157,7 @@ export function loadConfig(targetDir: string): LoadResult {
|
|
|
114
157
|
if (raw === null) {
|
|
115
158
|
return { ok: false, config: defaultConfig(), error: "harness/config.json is empty", seeded: true };
|
|
116
159
|
}
|
|
117
|
-
return { ok: true, config: deepMerge(defaultConfig(), raw), error: null, seeded: false };
|
|
160
|
+
return { ok: true, config: migrate(deepMerge(defaultConfig(), raw), raw), error: null, seeded: false };
|
|
118
161
|
} catch (e) {
|
|
119
162
|
const msg = e instanceof Error ? e.message : String(e);
|
|
120
163
|
return { ok: false, config: defaultConfig(), error: msg, seeded: false };
|
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,8 +150,14 @@ 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. */
|
|
@@ -210,6 +217,20 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
210
217
|
config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
|
|
211
218
|
config.approvals = { ...config.approvals, ...stripUndefined(options.approvals ?? {}) };
|
|
212
219
|
config.session = { ...config.session, ...stripUndefined(options.session ?? {}) };
|
|
220
|
+
// Every enabled phase gets a mode, so a phase list and a mode map cannot
|
|
221
|
+
// disagree about which phases exist. A caller that still passes the 2.3
|
|
222
|
+
// `approvals` shape and no modes gets what it asked for rather than silently
|
|
223
|
+
// getting autopilot — the same rule `loadConfig` applies to an older file.
|
|
224
|
+
const legacy = (options.approvals ?? {}) as Record<string, unknown>;
|
|
225
|
+
const hasModes = options.phaseModes && Object.keys(options.phaseModes).length > 0;
|
|
226
|
+
config.phaseModes = Object.fromEntries(
|
|
227
|
+
phases.map((p) => [
|
|
228
|
+
p,
|
|
229
|
+
(hasModes ? options.phaseModes?.[p] === "copilot" : legacy[p] === true) ? "copilot" : "autopilot",
|
|
230
|
+
]),
|
|
231
|
+
);
|
|
232
|
+
if (options.workflow) config.workflow = options.workflow;
|
|
233
|
+
if (options.display) config.display = normalizeDisplay(options.display);
|
|
213
234
|
if (options.brief !== undefined) {
|
|
214
235
|
config.intake = {
|
|
215
236
|
completed: true,
|
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
|
@@ -49,6 +49,9 @@ export type SettingsGroup = {
|
|
|
49
49
|
|
|
50
50
|
// ── The schema ──────────────────────────────────────────────────────────────
|
|
51
51
|
|
|
52
|
+
/** The two things a phase can do when its gate passes. */
|
|
53
|
+
const PHASE_MODE_CHOICES = ["autopilot", "copilot"] as const;
|
|
54
|
+
|
|
52
55
|
const DIFFICULTY_HELP =
|
|
53
56
|
"Tasks the planner marked at this difficulty run on this model. Empty means: use whatever model pi is already on.";
|
|
54
57
|
|
|
@@ -152,31 +155,157 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
152
155
|
],
|
|
153
156
|
},
|
|
154
157
|
{
|
|
155
|
-
id: "
|
|
156
|
-
label: "
|
|
157
|
-
help: "Which phases stop and wait for your signature
|
|
158
|
+
id: "workflow",
|
|
159
|
+
label: "Workflow",
|
|
160
|
+
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
161
|
settings: [
|
|
159
162
|
{
|
|
160
|
-
path: "
|
|
163
|
+
path: "phaseModes.research",
|
|
161
164
|
file: "config",
|
|
162
|
-
label: "
|
|
163
|
-
help: "
|
|
164
|
-
type: { kind: "
|
|
165
|
+
label: "RESEARCH",
|
|
166
|
+
help: "copilot stops so you can read harness/docs/RESEARCH.md before anything is specified.",
|
|
167
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
165
168
|
},
|
|
166
169
|
{
|
|
167
|
-
path: "
|
|
170
|
+
path: "phaseModes.define",
|
|
168
171
|
file: "config",
|
|
169
|
-
label: "
|
|
172
|
+
label: "DEFINE",
|
|
170
173
|
help: "The highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
|
|
174
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
path: "phaseModes.plan",
|
|
178
|
+
file: "config",
|
|
179
|
+
label: "PLAN",
|
|
180
|
+
help: "copilot shows you the whole task list before a line of it is built.",
|
|
181
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
path: "phaseModes.build",
|
|
185
|
+
file: "config",
|
|
186
|
+
label: "BUILD",
|
|
187
|
+
help: "copilot stops once the code passes its gate, so you can read the diff.",
|
|
188
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
path: "phaseModes.verify",
|
|
192
|
+
file: "config",
|
|
193
|
+
label: "VERIFY",
|
|
194
|
+
help: "copilot asks whether the tests prove the thing works or only that it runs.",
|
|
195
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
path: "phaseModes.simplify",
|
|
199
|
+
file: "config",
|
|
200
|
+
label: "SIMPLIFY",
|
|
201
|
+
help: "copilot shows you what was deleted before it moves on.",
|
|
202
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
path: "phaseModes.review",
|
|
206
|
+
file: "config",
|
|
207
|
+
label: "REVIEW",
|
|
208
|
+
help: "copilot asks whether you would approve this if someone else had written it.",
|
|
209
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
path: "phaseModes.ship",
|
|
213
|
+
file: "config",
|
|
214
|
+
label: "SHIP",
|
|
215
|
+
help: "copilot stops before the tag goes on. The last chance to say no.",
|
|
216
|
+
type: { kind: "choice", choices: PHASE_MODE_CHOICES },
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "display",
|
|
222
|
+
label: "Display",
|
|
223
|
+
help: "What the terminal widget and the web dashboard draw. `/infinity:display` picks a template or edits this level by level.",
|
|
224
|
+
settings: [
|
|
225
|
+
{
|
|
226
|
+
path: "display.levels.goal",
|
|
227
|
+
file: "config",
|
|
228
|
+
label: "Goals",
|
|
229
|
+
help: "The outermost level. Off on a plan with one goal costs you nothing.",
|
|
230
|
+
type: { kind: "boolean" },
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
path: "display.levels.sprint",
|
|
234
|
+
file: "config",
|
|
235
|
+
label: "Sprints",
|
|
236
|
+
help: "Off hides the sprint rows; the features under them still show, one level shallower.",
|
|
171
237
|
type: { kind: "boolean" },
|
|
172
238
|
},
|
|
173
239
|
{
|
|
174
|
-
path: "
|
|
240
|
+
path: "display.levels.feature",
|
|
175
241
|
file: "config",
|
|
176
|
-
label: "
|
|
177
|
-
help: "
|
|
242
|
+
label: "Features",
|
|
243
|
+
help: "Off hides the feature rows; their tasks still show.",
|
|
178
244
|
type: { kind: "boolean" },
|
|
179
245
|
},
|
|
246
|
+
{
|
|
247
|
+
path: "display.levels.task",
|
|
248
|
+
file: "config",
|
|
249
|
+
label: "Tasks",
|
|
250
|
+
help: "Off leaves the shape of the run without the work — see the `overview` template.",
|
|
251
|
+
type: { kind: "boolean" },
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
path: "display.levels.subtask",
|
|
255
|
+
file: "config",
|
|
256
|
+
label: "Subtasks",
|
|
257
|
+
help: "active shows them only on the task being worked, which is what fits in a widget.",
|
|
258
|
+
type: { kind: "choice", choices: ["none", "active", "all"] },
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
path: "display.counts",
|
|
262
|
+
file: "config",
|
|
263
|
+
label: "Counts",
|
|
264
|
+
help: "The done/total figure on goals, sprints and features.",
|
|
265
|
+
type: { kind: "boolean" },
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
path: "display.dependencies",
|
|
269
|
+
file: "config",
|
|
270
|
+
label: "Dependency labels",
|
|
271
|
+
help: "The `← #3` markers that say which task a task is waiting on.",
|
|
272
|
+
type: { kind: "boolean" },
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
path: "display.criteria",
|
|
276
|
+
file: "config",
|
|
277
|
+
label: "Acceptance criteria",
|
|
278
|
+
help: "Shown under each feature on the dashboard. No room for them in a terminal widget.",
|
|
279
|
+
type: { kind: "boolean" },
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
path: "display.rail",
|
|
283
|
+
file: "config",
|
|
284
|
+
label: "Phase rail",
|
|
285
|
+
help: "The `define ─ plan ─ BUILD ─ …` strip.",
|
|
286
|
+
type: { kind: "boolean" },
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
path: "display.progress",
|
|
290
|
+
file: "config",
|
|
291
|
+
label: "Progress meter",
|
|
292
|
+
help: "The bar and the task/feature counts beside it.",
|
|
293
|
+
type: { kind: "boolean" },
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
path: "display.alerts",
|
|
297
|
+
file: "config",
|
|
298
|
+
label: "Alert strip",
|
|
299
|
+
help: "Blocked and rework counts, retries, sessions, and any phase waiting for you.",
|
|
300
|
+
type: { kind: "boolean" },
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
path: "display.taskWindow",
|
|
304
|
+
file: "config",
|
|
305
|
+
label: "Widget rows",
|
|
306
|
+
help: "How many rows of plan the terminal shows before it starts scrolling.",
|
|
307
|
+
type: { kind: "number", min: 3, max: 60 },
|
|
308
|
+
},
|
|
180
309
|
],
|
|
181
310
|
},
|
|
182
311
|
{
|
package/src/core/types.ts
CHANGED
|
@@ -176,13 +176,55 @@ export type SessionPolicy = {
|
|
|
176
176
|
carryNotes: boolean;
|
|
177
177
|
};
|
|
178
178
|
|
|
179
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Which phases stop and wait for a human signature before the run continues.
|
|
181
|
+
*
|
|
182
|
+
* Superseded by `HarnessConfig.phaseModes`, which says the same thing for
|
|
183
|
+
* *every* phase rather than only these three. Kept because configs written by
|
|
184
|
+
* 2.3 have it, and `loadConfig` migrates them on read.
|
|
185
|
+
*/
|
|
180
186
|
export type ApprovalPolicy = {
|
|
181
187
|
research: boolean;
|
|
182
188
|
define: boolean;
|
|
183
189
|
plan: boolean;
|
|
184
190
|
};
|
|
185
191
|
|
|
192
|
+
/** What happens when a phase's gate passes: stop for the human, or advance. */
|
|
193
|
+
export type PhaseMode = "copilot" | "autopilot";
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Which parts of the plan a surface draws.
|
|
197
|
+
*
|
|
198
|
+
* Two people watching the same run want different things on screen: one works
|
|
199
|
+
* in sprints and never opens a subtask, the next has no sprints at all and
|
|
200
|
+
* lives in the subtask list. Rather than pick a winner, the levels are a
|
|
201
|
+
* setting, and the widget and the dashboard read the same one.
|
|
202
|
+
*/
|
|
203
|
+
export type DisplayPolicy = {
|
|
204
|
+
/** Name of the template this came from, or "custom" once it is edited. */
|
|
205
|
+
preset: string;
|
|
206
|
+
levels: {
|
|
207
|
+
goal: boolean;
|
|
208
|
+
sprint: boolean;
|
|
209
|
+
feature: boolean;
|
|
210
|
+
task: boolean;
|
|
211
|
+
/** "active" shows them only on the task being worked. */
|
|
212
|
+
subtask: "none" | "active" | "all";
|
|
213
|
+
};
|
|
214
|
+
/** `2/5` counts on the grouping rows. */
|
|
215
|
+
counts: boolean;
|
|
216
|
+
/** `← #3` dependency labels on tasks. */
|
|
217
|
+
dependencies: boolean;
|
|
218
|
+
/** The phase rail, the progress meter and the alert strip. */
|
|
219
|
+
rail: boolean;
|
|
220
|
+
progress: boolean;
|
|
221
|
+
alerts: boolean;
|
|
222
|
+
/** Acceptance criteria under each feature. Dashboard only — no room in a widget. */
|
|
223
|
+
criteria: boolean;
|
|
224
|
+
/** Rows of plan in the terminal widget before it starts scrolling. */
|
|
225
|
+
taskWindow: number;
|
|
226
|
+
};
|
|
227
|
+
|
|
186
228
|
/** What the start-up wizard settled, so it is never asked twice. */
|
|
187
229
|
export type IntakeState = {
|
|
188
230
|
/** True once the wizard has run to completion for this project. */
|
|
@@ -227,7 +269,13 @@ export type HarnessConfig = {
|
|
|
227
269
|
phases: { enabled: Phase[] };
|
|
228
270
|
roles: { strict: boolean };
|
|
229
271
|
session: SessionPolicy;
|
|
272
|
+
/** Legacy: the three-phase approval switch 2.3 shipped. Migrated to `phaseModes`. */
|
|
230
273
|
approvals: ApprovalPolicy;
|
|
274
|
+
/** Mode per phase — the setting `approvals` became. */
|
|
275
|
+
phaseModes: Partial<Record<Phase, PhaseMode>>;
|
|
276
|
+
/** Which named workflow the modes above came from, before any hand-editing. */
|
|
277
|
+
workflow: { id: string; name: string } | null;
|
|
278
|
+
display: DisplayPolicy;
|
|
231
279
|
intake: IntakeState;
|
|
232
280
|
/** Set when a gate passed but the phase needs a human signature first. */
|
|
233
281
|
awaitingApproval: Phase | null;
|