infinity-harness 2.3.0 → 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.
@@ -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: "approvals",
156
- label: "Your approvals",
157
- help: "Which phases stop and wait for your signature. These are the three that decide WHAT gets built after PLAN, a wrong turn fails a gate and retries.",
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: "approvals.research",
163
+ path: "phaseModes.research",
161
164
  file: "config",
162
- label: "Sign off RESEARCH",
163
- help: "You read harness/docs/RESEARCH.md and say whether it is looking at the right problem. Only applies when the RESEARCH phase is enabled.",
164
- type: { kind: "boolean" },
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: "approvals.define",
170
+ path: "phaseModes.define",
168
171
  file: "config",
169
- label: "Sign off DEFINE",
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: "approvals.plan",
240
+ path: "display.levels.feature",
175
241
  file: "config",
176
- label: "Sign off PLAN",
177
- help: "You see the whole task list before a line of it is built, and can send it back with a note.",
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
- /** Which phases stop and wait for a human signature before the run continues. */
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;
package/src/intake.ts CHANGED
@@ -7,100 +7,98 @@
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;
55
56
  };
56
57
 
57
58
  export type IntakePlan = {
59
+ /** Derived: "copilot" when the run stops for the human anywhere, else "autopilot". */
58
60
  mode: Mode;
61
+ workflow: { id: string; name: string };
59
62
  brief: string | null;
60
63
  phases: Phase[];
64
+ phaseModes: PhaseModes;
65
+ /** Kept in step with `phaseModes` so a 2.3 config read by a 2.3 tool still works. */
61
66
  approvals: ApprovalPolicy;
62
67
  session: SessionPolicy;
68
+ display: DisplayPolicy;
63
69
  /** What the human should be told about what they just chose. */
64
70
  summary: string;
65
71
  /** Things that will bite later if left as they are. */
66
72
  warnings: string[];
67
73
  };
68
74
 
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
- };
75
+ export function builtInByName(name: string): Workflow | null {
76
+ return BUILTIN_WORKFLOWS.find((w) => w.id === name || w.name === name) ?? null;
80
77
  }
81
78
 
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];
79
+ /** Build an ad-hoc workflow from a phase list and a mode per phase. */
80
+ export function customWorkflow(
81
+ phases: Phase[],
82
+ modes: PhaseModes,
83
+ name = "custom",
84
+ ): Workflow {
85
+ const ordered = normalizePhases(phases);
86
+ const normalized = normalizeModes(modes, ordered);
87
+ return {
88
+ id: "custom",
89
+ name,
90
+ description: describeModes(ordered, normalized),
91
+ builtIn: false,
92
+ phases: ordered,
93
+ modes: normalized,
94
+ };
89
95
  }
90
96
 
91
97
  /** Turn the wizard's answers into everything `initHarness` needs. */
92
98
  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
- };
99
+ const workflow = answers.workflow;
100
+ const phases = normalizePhases(workflow.phases);
101
+ const phaseModes = normalizeModes(workflow.modes, phases);
104
102
 
105
103
  const handoff = answers.handoff ?? "phase";
106
104
  const session: SessionPolicy = {
@@ -109,7 +107,10 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
109
107
  carryNotes: true,
110
108
  };
111
109
 
110
+ const display = normalizeDisplay(answers.display ?? defaultDisplay());
112
111
  const brief = answers.brief?.trim() ? answers.brief.trim() : null;
112
+ const signed = PHASE_ORDER.filter((p) => phaseModes[p] === "copilot");
113
+ const mode: Mode = signed.length > 0 ? "copilot" : "autopilot";
113
114
 
114
115
  const warnings: string[] = [];
115
116
  if (!brief) {
@@ -118,13 +119,19 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
118
119
  "It will not guess a project.",
119
120
  );
120
121
  }
121
- if (answers.mode === "autopilot" && !approvals.define && !approvals.plan && !approvals.research) {
122
+ if (signed.length === 0) {
122
123
  warnings.push(
123
124
  "Nothing is being approved by you. The model decides what to build and how, " +
124
125
  "and you see it when it is done. This is the right setting for a run you " +
125
126
  "want to walk away from — and the wrong one if the goal is vague.",
126
127
  );
127
128
  }
129
+ if (signed.length === phases.length) {
130
+ warnings.push(
131
+ "Every phase stops for you. Nothing moves while you are away, which is the " +
132
+ "point — just do not expect to start this and go to bed.",
133
+ );
134
+ }
128
135
  if (handoff === "off") {
129
136
  warnings.push(
130
137
  "Session handoff is off, so the whole run shares one context window. " +
@@ -132,20 +139,36 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
132
139
  );
133
140
  }
134
141
 
135
- return { mode: answers.mode, brief, phases, approvals, session, summary: summarize(answers.mode, phases, approvals, session, brief), warnings };
142
+ return {
143
+ mode,
144
+ workflow: { id: workflow.id, name: workflow.name },
145
+ brief,
146
+ phases,
147
+ phaseModes,
148
+ approvals: {
149
+ research: phaseModes.research === "copilot",
150
+ define: phaseModes.define === "copilot",
151
+ plan: phaseModes.plan === "copilot",
152
+ },
153
+ session,
154
+ display,
155
+ summary: summarize(workflow, phases, phaseModes, session, display, brief),
156
+ warnings,
157
+ };
136
158
  }
137
159
 
138
160
  function summarize(
139
- mode: Mode,
161
+ workflow: Workflow,
140
162
  phases: Phase[],
141
- approvals: ApprovalPolicy,
163
+ modes: PhaseModes,
142
164
  session: SessionPolicy,
165
+ display: DisplayPolicy,
143
166
  brief: string | null,
144
167
  ): string {
145
- const signed = (["research", "define", "plan"] as const).filter((p) => approvals[p]);
168
+ const signed = phases.filter((p) => modes[p] === "copilot");
146
169
  const L: string[] = [];
147
- L.push(`Mode ${mode}`);
148
- L.push(`Pipeline ${phases.join(" → ")}`);
170
+ L.push(`Workflow ${workflow.name}`);
171
+ L.push(`Pipeline ${phases.map((p) => (modes[p] === "copilot" ? `[${p}]` : p)).join(" → ")}`);
149
172
  L.push(
150
173
  `You sign ${signed.length ? signed.map((s) => s.toUpperCase()).join(", ") : "nothing — the model decides and runs"}`,
151
174
  );
@@ -158,6 +181,7 @@ function summarize(
158
181
  : "fresh session per phase"
159
182
  }`,
160
183
  );
184
+ L.push(`Display ${display.preset}`);
161
185
  L.push(`Goal ${brief ?? "(none yet — you will be asked first thing)"}`);
162
186
  return L.join("\n");
163
187
  }
@@ -177,21 +201,9 @@ export type Question = {
177
201
  placeholder?: string;
178
202
  };
179
203
 
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
- ],
204
+ export const WORKFLOW_QUESTION: Question = {
205
+ id: "workflow",
206
+ title: "How should this run which phases, and which of them stop for you?",
195
207
  };
196
208
 
197
209
  export const BRIEF_QUESTION: Question = {
@@ -200,23 +212,6 @@ export const BRIEF_QUESTION: Question = {
200
212
  placeholder: "e.g. a CLI that reconciles Stripe payouts against our ledger",
201
213
  };
202
214
 
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
215
  export const HANDOFF_QUESTION: Question = {
221
216
  id: "handoff",
222
217
  title: "When should the run start a fresh session?",
@@ -239,25 +234,39 @@ export const HANDOFF_QUESTION: Question = {
239
234
  ],
240
235
  };
241
236
 
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
- }
237
+ export const DISPLAY_QUESTION: Question = {
238
+ id: "display",
239
+ title: "How much of the plan do you want on screen?",
240
+ };
241
+
242
+ /** The two modes, as a question about one phase. */
243
+ export const PHASE_MODE_OPTIONS: { value: PhaseMode; label: string; help: string }[] = [
244
+ {
245
+ value: "autopilot",
246
+ label: "autopilot — it passes the gate and moves on",
247
+ help: "The deterministic gate is the only referee for this phase.",
248
+ },
249
+ {
250
+ value: "copilot",
251
+ label: "copilot — it stops and waits for you",
252
+ help: "When the gate passes, the run parks and asks you to sign it off before advancing.",
253
+ },
254
+ ];
255
+
256
+ /** What each phase is for, one line, shown while picking modes. */
257
+ export const PHASE_PURPOSE: Record<Phase, string> = {
258
+ init: "set the project up",
259
+ research: "find out what it actually has to be",
260
+ define: "write down what is being built, and the criteria",
261
+ plan: "break it into ordered, dependency-aware tasks",
262
+ build: "implement it, one task at a time",
263
+ verify: "prove it behaves; hunt what the tests miss",
264
+ simplify: "delete more than you add",
265
+ review: "judge it as if someone else wrote it",
266
+ ship: "tag, changelog, leave the tree clean",
267
+ };
268
+
269
+ /** Phases someone can put in a pipeline. INIT is not one of them. */
270
+ export const SELECTABLE_PHASES: Phase[] = PHASE_ORDER.filter((p) => p !== "init");
271
+
272
+ export { DEFAULT_ENABLED_PHASES, signedIn as signedPhases };
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";