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.
@@ -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
- /** Which phases stop for a human signature. See SessionPolicy / ApprovalPolicy. */
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
  }
@@ -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;