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.
@@ -78,7 +78,29 @@ import {
78
78
  type HandoffReason,
79
79
  } from "../../src/handoff.ts";
80
80
  import { needsApproval, resolveApproval, approvedPhases } from "../../src/approval.ts";
81
- import { runIntakeWizard, unattendedIntake } from "../../src/ui/wizard.ts";
81
+ import {
82
+ buildDisplay,
83
+ pickDisplay,
84
+ pickWorkflow,
85
+ runIntakeWizard,
86
+ unattendedIntake,
87
+ } from "../../src/ui/wizard.ts";
88
+ import {
89
+ applyWorkflow,
90
+ findWorkflow,
91
+ listWorkflows,
92
+ matchWorkflow,
93
+ renderWorkflow,
94
+ signedPhases,
95
+ summarizeWorkflow,
96
+ } from "../../src/workflow.ts";
97
+ import {
98
+ findDisplay,
99
+ listDisplays,
100
+ normalizeDisplay,
101
+ summarizeDisplay,
102
+ } from "../../src/ui/display.ts";
103
+ import type { DisplayPolicy } from "../../src/core/types.ts";
82
104
  import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW, type WidgetView } from "../../src/ui/widget.ts";
83
105
  import { buildPlanRows } from "../../src/ui/planTree.ts";
84
106
 
@@ -117,6 +139,17 @@ export default function (pi: ExtensionAPI): void {
117
139
  let remoteDir: string | null = null;
118
140
  let view: WidgetView = defaultView();
119
141
 
142
+ /**
143
+ * Is this instance's session still the live one?
144
+ *
145
+ * After `ctx.newSession()` pi tears the old runtime down and rebinds
146
+ * extensions, but this closure and its registered handlers still exist. Any
147
+ * of them that touches `pi` or a captured `ctx` afterwards gets
148
+ * "This extension ctx is stale after session replacement" — which is what a
149
+ * handoff produced on every single turn until this flag existed.
150
+ */
151
+ let sessionLive = true;
152
+
120
153
  /**
121
154
  * Is a continuous run armed?
122
155
  *
@@ -154,6 +187,7 @@ export default function (pi: ExtensionAPI): void {
154
187
  sessions: run?.sessions ?? null,
155
188
  intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
156
189
  awaitingApproval: config.awaitingApproval ?? null,
190
+ display: normalizeDisplay(config.display),
157
191
  phase: config.currentPhase,
158
192
  enabledPhases: config.phases?.enabled,
159
193
  paused: Boolean(config.paused),
@@ -307,6 +341,10 @@ export default function (pi: ExtensionAPI): void {
307
341
  // this session stops driving and the replacement never arrives. One
308
342
  // attempt, then carry on here — a run that continues in a fat session is
309
343
  // far better than a run that stops.
344
+ // A one-shot `pi -p` run has no next turn to hand anything to: replacing
345
+ // its session mid-flight produces a stale-context error and nothing else.
346
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") return false;
347
+
310
348
  if (handingOff) {
311
349
  if (hasPendingHandoff(dir)) {
312
350
  notify(ctx, "infinity-harness: the new session never started — continuing here.", "warning");
@@ -531,6 +569,7 @@ export default function (pi: ExtensionAPI): void {
531
569
  * summarised. Anything the run cannot afford to forget belongs here.
532
570
  */
533
571
  pi.on("before_agent_start", async (event, ctx) => {
572
+ if (!sessionLive) return;
534
573
  const dir = projectDir(ctx);
535
574
  if (!isHarnessProject(dir)) return;
536
575
  try {
@@ -544,6 +583,7 @@ export default function (pi: ExtensionAPI): void {
544
583
  });
545
584
 
546
585
  pi.on("session_tree", async (_event, ctx) => {
586
+ if (!sessionLive) return;
547
587
  refreshWidget(ctx);
548
588
  });
549
589
 
@@ -553,6 +593,7 @@ export default function (pi: ExtensionAPI): void {
553
593
  * few calls costs little and keeps the plan honest.
554
594
  */
555
595
  pi.on("context", async (event, ctx) => {
596
+ if (!sessionLive) return;
556
597
  const dir = projectDir(ctx);
557
598
  if (!isHarnessProject(dir)) return;
558
599
 
@@ -630,6 +671,7 @@ export default function (pi: ExtensionAPI): void {
630
671
  });
631
672
 
632
673
  pi.on("session_compact", async (event, ctx) => {
674
+ if (!sessionLive) return;
633
675
  const dir = projectDir(ctx);
634
676
  if (!isHarnessProject(dir)) return;
635
677
  try {
@@ -658,6 +700,7 @@ export default function (pi: ExtensionAPI): void {
658
700
  });
659
701
 
660
702
  pi.on("turn_end", async (_event, ctx) => {
703
+ if (!sessionLive) return;
661
704
  refreshWidget(ctx);
662
705
  });
663
706
 
@@ -670,6 +713,7 @@ export default function (pi: ExtensionAPI): void {
670
713
  * `/reload`, `/resume`, and pi being restarted.
671
714
  */
672
715
  pi.on("agent_settled", async (_event, ctx) => {
716
+ if (!sessionLive) return;
673
717
  const dir = projectDir(ctx);
674
718
  if (!isHarnessProject(dir)) return;
675
719
  if (loopBusy || handingOff) return;
@@ -749,6 +793,7 @@ export default function (pi: ExtensionAPI): void {
749
793
  * touch of the config would stop the harness configuring itself.
750
794
  */
751
795
  pi.on("tool_call", async (event, ctx) => {
796
+ if (!sessionLive) return;
752
797
  const dir = projectDir(ctx);
753
798
  if (!isHarnessProject(dir)) return;
754
799
 
@@ -791,6 +836,7 @@ export default function (pi: ExtensionAPI): void {
791
836
  });
792
837
 
793
838
  pi.on("session_shutdown", async () => {
839
+ sessionLive = false;
794
840
  if (remoteServer) {
795
841
  try {
796
842
  await remoteServer.close();
@@ -1223,7 +1269,7 @@ export default function (pi: ExtensionAPI): void {
1223
1269
  });
1224
1270
 
1225
1271
  pi.registerCommand("infinity:init", {
1226
- description: "Set up a harness here — mode, goal, research, approvals, sessions",
1272
+ description: "Set up a harness here — workflow, goal, sessions, display",
1227
1273
  handler: async (args: string, ctx: ExtensionContext) => {
1228
1274
  const dir = projectDir(ctx);
1229
1275
  const force = /\bforce\b/.test(args);
@@ -1239,57 +1285,36 @@ export default function (pi: ExtensionAPI): void {
1239
1285
  }
1240
1286
 
1241
1287
  const detected = detectStack(dir);
1242
- let phases: Phase[] | undefined;
1243
1288
 
1244
- // Two things used to be wrong here, and they compounded.
1289
+ // Three things used to be wrong here, and they compounded.
1245
1290
  //
1246
- // First, the wizard never asked what was being built — so picking
1247
- // "autopilot" started a run with no idea and no scope, and the harness
1248
- // invented a project and began building it. Autopilot was being read as
1249
- // "you decide everything, including what I want".
1291
+ // The wizard never asked what was being built — so picking "autopilot"
1292
+ // started a run with no idea and no scope, and the harness invented a
1293
+ // project and began building it.
1250
1294
  //
1251
- // Second, "mode" was the only question. There was no way to say "drive
1252
- // yourself, but show me the plan before you build it", which is what
1253
- // most people actually want from an unattended run.
1295
+ // "mode" was the only question, and one switch cannot say "drive
1296
+ // yourself, but show me the plan before you build it".
1254
1297
  //
1255
- // The wizard now asks for the goal in both modes, offers an optional
1256
- // research phase, and in autopilot — lets the human pick exactly which
1257
- // of RESEARCH / DEFINE / PLAN they sign. `src/intake.ts` owns what the
1258
- // answers mean; `src/ui/wizard.ts` owns asking them.
1298
+ // And even that switch only reached three phases. It is a mode per
1299
+ // phase now, chosen from a workflow the human can build, name and reuse.
1300
+ // `src/workflow.ts` owns what a workflow is, `src/intake.ts` what the
1301
+ // answers mean, `src/ui/wizard.ts` how they are asked.
1259
1302
  if (ctx.hasUI) {
1260
1303
  const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
1261
1304
  const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
1262
- const go = await ctx.ui.select(
1263
- `Create a harness here? ${detected.label} · ${summary}`,
1264
- ["yes", "yes, and let me choose the phases", "cancel"],
1265
- );
1305
+ const go = await ctx.ui.select(`Create a harness here? ${detected.label} · ${summary}`, [
1306
+ "yes",
1307
+ "cancel",
1308
+ ]);
1266
1309
  if (go === undefined || go === "cancel") {
1267
1310
  notify(ctx, "init cancelled — nothing was written.", "info");
1268
1311
  return;
1269
1312
  }
1270
-
1271
- if (go.includes("phases")) {
1272
- const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
1273
- for (;;) {
1274
- const rows = SELECTABLE_PHASES.map((p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p}`);
1275
- const hit = await ctx.ui.select("Phases to run", [...rows, "✓ done"]);
1276
- if (hit === undefined || hit === "✓ done") break;
1277
- const key = SELECTABLE_PHASES[rows.indexOf(hit)];
1278
- if (!key) break;
1279
- if (chosen.has(key)) chosen.delete(key);
1280
- else chosen.add(key);
1281
- }
1282
- phases = [...chosen];
1283
- }
1284
1313
  }
1285
1314
 
1286
1315
  const wizard = ctx.hasUI
1287
- ? await runIntakeWizard({
1288
- prompt: prompterFor(ctx),
1289
- phases,
1290
- brief: goalFromArgs || null,
1291
- })
1292
- : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null, phases) } as const);
1316
+ ? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null })
1317
+ : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
1293
1318
 
1294
1319
  if (wizard.cancelled) {
1295
1320
  notify(ctx, "init cancelled — nothing was written.", "info");
@@ -1301,6 +1326,9 @@ export default function (pi: ExtensionAPI): void {
1301
1326
  mode: plan.mode,
1302
1327
  phases: plan.phases,
1303
1328
  approvals: plan.approvals,
1329
+ phaseModes: plan.phaseModes,
1330
+ workflow: plan.workflow,
1331
+ display: plan.display,
1304
1332
  session: plan.session,
1305
1333
  brief: plan.brief,
1306
1334
  force,
@@ -1330,6 +1358,143 @@ export default function (pi: ExtensionAPI): void {
1330
1358
  },
1331
1359
  });
1332
1360
 
1361
+ /**
1362
+ * Change the workflow mid-run.
1363
+ *
1364
+ * Any of this is editable at any time and takes effect on the next gate —
1365
+ * a run three phases deep is exactly when someone realises they do want to
1366
+ * see the review after all.
1367
+ */
1368
+ pi.registerCommand("infinity:workflow", {
1369
+ description: "Choose or build the workflow — which phases run, and which stop for you",
1370
+ handler: async (args: string, ctx: ExtensionContext) => {
1371
+ const dir = projectDir(ctx);
1372
+ if (!isHarnessProject(dir)) {
1373
+ notify(ctx, NO_HARNESS, "warning");
1374
+ return;
1375
+ }
1376
+
1377
+ const { config } = loadConfig(dir);
1378
+ const arg = args.trim();
1379
+
1380
+ if (arg === "" && !ctx.hasUI) {
1381
+ notify(ctx, describeCurrentWorkflow(dir), "info");
1382
+ return;
1383
+ }
1384
+ if (arg === "show" || arg === "list") {
1385
+ const rows = listWorkflows().map((w) => ` ${w.builtIn ? " " : "*"} ${w.name} — ${w.description}`);
1386
+ notify(
1387
+ ctx,
1388
+ `${describeCurrentWorkflow(dir)}\n\nAvailable (* = yours):\n${rows.join("\n")}`,
1389
+ "info",
1390
+ );
1391
+ return;
1392
+ }
1393
+
1394
+ // `/infinity:workflow <name>` switches without a menu, which is what a
1395
+ // second run in the same terminal wants.
1396
+ let chosen = arg ? findWorkflow(arg) : undefined;
1397
+ if (arg && !chosen) {
1398
+ notify(ctx, `No workflow called "${arg}". \`/infinity:workflow list\` shows them.`, "warning");
1399
+ return;
1400
+ }
1401
+ if (!chosen) {
1402
+ if (!ctx.hasUI) {
1403
+ notify(ctx, "This mode has no dialogs — `/infinity:workflow <name>` switches directly.", "warning");
1404
+ return;
1405
+ }
1406
+ chosen = (await pickWorkflow(prompterFor(ctx))) ?? undefined;
1407
+ if (!chosen) {
1408
+ notify(ctx, "Unchanged.", "info");
1409
+ return;
1410
+ }
1411
+ }
1412
+
1413
+ const { value } = await withLock(configPath(dir), () => {
1414
+ const fresh = loadConfig(dir);
1415
+ if (!fresh.ok) return false;
1416
+ applyWorkflow(fresh.config, chosen!);
1417
+ // Keep the legacy field in step so a 2.3 tool reading this config
1418
+ // still sees the same three answers it understands.
1419
+ fresh.config.approvals = {
1420
+ research: fresh.config.phaseModes?.research === "copilot",
1421
+ define: fresh.config.phaseModes?.define === "copilot",
1422
+ plan: fresh.config.phaseModes?.plan === "copilot",
1423
+ };
1424
+ fresh.config.mode = signedPhases(fresh.config).length > 0 ? "copilot" : "autopilot";
1425
+ return saveConfig(dir, fresh.config).ok;
1426
+ });
1427
+
1428
+ if (!value) {
1429
+ notify(ctx, "Could not save the workflow — config unreadable.", "error");
1430
+ return;
1431
+ }
1432
+ notify(ctx, `${renderWorkflow(chosen)}\n\nIt takes effect at the next gate.`, "info");
1433
+ refreshWidget(ctx);
1434
+ void config;
1435
+ },
1436
+ });
1437
+
1438
+ pi.registerCommand("infinity:display", {
1439
+ description: "Choose what the widget and the dashboard show, level by level",
1440
+ handler: async (args: string, ctx: ExtensionContext) => {
1441
+ const dir = projectDir(ctx);
1442
+ if (!isHarnessProject(dir)) {
1443
+ notify(ctx, NO_HARNESS, "warning");
1444
+ return;
1445
+ }
1446
+ const arg = args.trim();
1447
+ const current = normalizeDisplay(loadConfig(dir).config.display);
1448
+
1449
+ if (arg === "show" || arg === "list") {
1450
+ const rows = listDisplays().map((d) => ` ${d.builtIn ? " " : "*"} ${d.name} — ${d.description}`);
1451
+ notify(
1452
+ ctx,
1453
+ `Now: ${summarizeDisplay(current)}\n\nTemplates (* = yours):\n${rows.join("\n")}`,
1454
+ "info",
1455
+ );
1456
+ return;
1457
+ }
1458
+
1459
+ let next: DisplayPolicy | undefined;
1460
+ if (arg) {
1461
+ const template = findDisplay(arg);
1462
+ if (!template) {
1463
+ notify(ctx, `No template called "${arg}". \`/infinity:display list\` shows them.`, "warning");
1464
+ return;
1465
+ }
1466
+ next = template.policy;
1467
+ } else {
1468
+ if (!ctx.hasUI) {
1469
+ notify(ctx, `Now: ${summarizeDisplay(current)}. \`/infinity:display <template>\` switches.`, "info");
1470
+ return;
1471
+ }
1472
+ next = await pickDisplay(prompterFor(ctx));
1473
+ if (!next) {
1474
+ notify(ctx, "Unchanged.", "info");
1475
+ return;
1476
+ }
1477
+ }
1478
+
1479
+ const { value } = await withLock(configPath(dir), () => {
1480
+ const fresh = loadConfig(dir);
1481
+ if (!fresh.ok) return false;
1482
+ fresh.config.display = normalizeDisplay(next);
1483
+ return saveConfig(dir, fresh.config).ok;
1484
+ });
1485
+
1486
+ if (!value) {
1487
+ notify(ctx, "Could not save the display settings — config unreadable.", "error");
1488
+ return;
1489
+ }
1490
+ // The widget is the answer to "did that do what I wanted", so redraw it
1491
+ // before saying anything about it.
1492
+ view = defaultView();
1493
+ refreshWidget(ctx);
1494
+ notify(ctx, `Showing: ${summarizeDisplay(normalizeDisplay(next))}`, "info");
1495
+ },
1496
+ });
1497
+
1333
1498
  /**
1334
1499
  * Continue the run in a replacement session.
1335
1500
  *
@@ -1392,7 +1557,12 @@ export default function (pi: ExtensionAPI): void {
1392
1557
  } catch (e) {
1393
1558
  // A handoff that cannot happen must never end the run: fall back to
1394
1559
  // carrying on in this session, which is the pre-2.3 behaviour.
1560
+ //
1561
+ // Unless the session is already gone — `newSession` can fail *after*
1562
+ // replacing the runtime, and reaching for the old `pi` then is the
1563
+ // "stale ctx" error rather than a recovery.
1395
1564
  clearHandoff(dir);
1565
+ if (!sessionLive) return;
1396
1566
  notify(ctx, `infinity-harness: could not start a new session — ${errMsg(e)}`, "warning");
1397
1567
  pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
1398
1568
  }
@@ -2267,6 +2437,20 @@ function errMsg(e: unknown): string {
2267
2437
  return e instanceof Error ? e.message : String(e);
2268
2438
  }
2269
2439
 
2440
+ /** The workflow this project is on, and whether it still matches a named one. */
2441
+ function describeCurrentWorkflow(dir: string): string {
2442
+ const { config } = loadConfig(dir);
2443
+ const named = matchWorkflow(config);
2444
+ const head = `Workflow: ${summarizeWorkflow(config)}`;
2445
+ const rail = (config.phases?.enabled ?? [])
2446
+ .map((p) => (config.phaseModes?.[p] === "copilot" ? `[${p}]` : p))
2447
+ .join(" → ");
2448
+ const drift = named
2449
+ ? ""
2450
+ : "\n\nThese settings do not match any saved workflow. `/infinity:workflow` can save them as one.";
2451
+ return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
2452
+ }
2453
+
2270
2454
  /**
2271
2455
  * The few sentences the run cannot afford to have summarised away.
2272
2456
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.3.0",
4
- "description": "A pi agent extension that runs a gated build pipeline unattended enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
3
+ "version": "2.4.0",
4
+ "description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
package/src/approval.ts CHANGED
@@ -19,8 +19,8 @@
19
19
  */
20
20
 
21
21
  import type { HarnessConfig, Phase } from "./core/types.ts";
22
- import { APPROVABLE_PHASES } from "./core/types.ts";
23
22
  import { loadConfig, saveConfig } from "./core/config.ts";
23
+ import { modeFor, signedPhases, SIGNABLE_PHASES } from "./workflow.ts";
24
24
 
25
25
  export type ApprovalRequest = {
26
26
  phase: Phase;
@@ -29,26 +29,31 @@ export type ApprovalRequest = {
29
29
  prompt: string;
30
30
  };
31
31
 
32
+ /** Every phase except INIT, which is plumbing rather than work. */
32
33
  export function isApprovable(phase: Phase | null): boolean {
33
- return phase !== null && (APPROVABLE_PHASES as readonly string[]).includes(phase);
34
+ return phase !== null && SIGNABLE_PHASES.includes(phase);
34
35
  }
35
36
 
36
37
  /** Does `phase` need a signature before the pipeline may leave it? */
37
38
  export function needsApproval(config: HarnessConfig, phase: Phase | null): boolean {
38
39
  if (!isApprovable(phase)) return false;
39
- const approvals = (config.approvals ?? {}) as Record<string, unknown>;
40
- return approvals[phase as string] === true;
40
+ return modeFor(config, phase) === "copilot";
41
41
  }
42
42
 
43
43
  /** Which phases the human has asked to sign, in pipeline order. */
44
44
  export function approvedPhases(config: HarnessConfig): Phase[] {
45
- return APPROVABLE_PHASES.filter((p) => needsApproval(config, p)) as Phase[];
45
+ return signedPhases(config);
46
46
  }
47
47
 
48
48
  const ARTIFACTS: Record<string, string[]> = {
49
49
  research: ["harness/docs/RESEARCH.md"],
50
50
  define: ["specs/prd.md", "harness/sprint-contract.md", "the acceptance criteria in the plan"],
51
51
  plan: ["the task list in the widget, or `/infinity:dashboard`"],
52
+ build: ["the diff since the last phase — `git diff`", "the tasks marked complete in the plan"],
53
+ verify: ["the test output", "what the tests still do not cover"],
54
+ simplify: ["what was deleted — `git diff --stat`"],
55
+ review: ["harness/evaluator-rubric.md and the score against it", "README.md and harness/docs/"],
56
+ ship: ["CHANGELOG.md", "the tag, and `git log` since the last one"],
52
57
  };
53
58
 
54
59
  const ASKS: Record<string, string> = {
@@ -57,6 +62,11 @@ const ASKS: Record<string, string> = {
57
62
  define:
58
63
  "Is this the thing you want built, and would meeting these criteria convince you it works?",
59
64
  plan: "Does this plan build that thing, in an order that makes sense, with nothing important missing?",
65
+ build: "Is this the code you wanted written, and would you be happy to own it?",
66
+ verify: "Do these tests actually prove the thing works, or only that it runs?",
67
+ simplify: "Is what is left simpler, and is anything missing that should not be?",
68
+ review: "Would you approve this if someone else had written it?",
69
+ ship: "Is this ready to go out, under this version, with this changelog?",
60
70
  };
61
71
 
62
72
  export function describeApproval(phase: Phase): ApprovalRequest {
@@ -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
  }