infinity-harness 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -165,6 +187,7 @@ export default function (pi: ExtensionAPI): void {
165
187
  sessions: run?.sessions ?? null,
166
188
  intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
167
189
  awaitingApproval: config.awaitingApproval ?? null,
190
+ display: normalizeDisplay(config.display),
168
191
  phase: config.currentPhase,
169
192
  enabledPhases: config.phases?.enabled,
170
193
  paused: Boolean(config.paused),
@@ -274,15 +297,33 @@ export default function (pi: ExtensionAPI): void {
274
297
 
275
298
  // -- session handoff ------------------------------------------------------
276
299
 
277
- /** The task the pipeline is on right now, or null. */
278
- const activeTaskKey = (dir: string): string | null => {
300
+ /** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
301
+ const activePlanKeys = (dir: string): { task: string | null; feature: string | null; sprint: string | null; goal: string | null; subtask: string | null; } => {
279
302
  try {
280
303
  const { list } = loadFeatureList(dir);
281
- return nextActionableTask(list)?.compositeKey ?? null;
304
+ const task = nextActionableTask(list);
305
+ const flat = task ? loadFeatureList(dir).list.features?.find((f) => f.id === task.featureId) ?? null : null;
306
+ // Resolve sprint/goal via list, and active subtask of the focused task.
307
+ const taskKey = task?.compositeKey ?? null;
308
+ const featureId = task?.featureId ?? null;
309
+ const feature = featureId ? (list.features ?? []).find((f) => f.id === featureId) ?? null : null;
310
+ const sprintId = feature?.sprintId ?? null;
311
+ const goalId = feature?.goalId ?? (sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId)?.goalId ?? null : null) ?? (list.goals?.[0]?.id ?? null);
312
+ const sprint = sprintId ? sprintId : null;
313
+ const goal = goalId ? goalId : null;
314
+ // First non-complete subtask of the active task.
315
+ let subtask: string | null = null;
316
+ const rawTask = feature && task ? feature.tasks.find((t) => t.id === task.id || t.key === task.key) ?? null : null;
317
+ if (rawTask?.subtasks?.length) {
318
+ const cur = rawTask.subtasks.find((s) => s.status !== "complete") ?? null;
319
+ if (cur) subtask = `${taskKey}#${cur.id ?? cur.title}`;
320
+ }
321
+ return { task: taskKey, feature: featureId, sprint, goal, subtask };
282
322
  } catch {
283
- return null;
323
+ return { task: null, feature: null, sprint: null, goal: null, subtask: null };
284
324
  }
285
325
  };
326
+ const activeTaskKey = (dir: string): string | null => activePlanKeys(dir).task;
286
327
 
287
328
  /** How full this session's context is, 0..1, or null when pi cannot say. */
288
329
  const contextRatio = (ctx: ExtensionContext): number | null => {
@@ -332,12 +373,41 @@ export default function (pi: ExtensionAPI): void {
332
373
  }
333
374
  try {
334
375
  const { config } = loadConfig(dir);
376
+ const toKeys = activePlanKeys(dir);
377
+ // Map caller's fromTask (a compositeKey) back to its feature/sprint etc for the "from" side.
378
+ // We derive them from the plan so goal/sprint/feature boundaries are comparable.
379
+ let fromGoal: string | null = null;
380
+ let fromSprint: string | null = null;
381
+ let fromFeature: string | null = null;
382
+ try {
383
+ const { list } = loadFeatureList(dir);
384
+ if (fromTask) {
385
+ const ft = ((): { featureId: string } | null => {
386
+ for (const f of list.features ?? []) for (const t of f.tasks ?? []) if (t.key === fromTask || `${f.id}/${t.id}` === fromTask || t.id === fromTask) return { featureId: f.id };
387
+ return null;
388
+ })();
389
+ if (ft) {
390
+ fromFeature = ft.featureId;
391
+ const feat = list.features.find((f) => f.id === ft.featureId) ?? null;
392
+ fromSprint = feat?.sprintId ?? null;
393
+ fromGoal = feat?.goalId ?? (fromSprint ? (list.sprints ?? []).find((s) => s.id === fromSprint)?.goalId ?? null : null) ?? null;
394
+ }
395
+ }
396
+ } catch {}
335
397
  const decision = shouldHandoff({
336
398
  config,
337
399
  fromPhase,
338
400
  toPhase,
339
401
  fromTask,
340
- toTask: activeTaskKey(dir),
402
+ toTask: toKeys.task,
403
+ fromGoal,
404
+ toGoal: toKeys.goal,
405
+ fromSprint,
406
+ toSprint: toKeys.sprint,
407
+ fromFeature,
408
+ toFeature: toKeys.feature,
409
+ fromSubtask: null, // subtask delta is derived from task payload; tracked via fromTask composite + activePlanKeys
410
+ toSubtask: toKeys.subtask,
341
411
  contextRatio: contextRatio(ctx),
342
412
  });
343
413
  if (!decision.handoff) return false;
@@ -473,6 +543,7 @@ export default function (pi: ExtensionAPI): void {
473
543
 
474
544
  view = defaultView();
475
545
  refreshWidget(ctx);
546
+ installTerminalShortcuts(ctx);
476
547
  const { config } = loadConfig(dir);
477
548
  lastBriefPhase = config.currentPhase;
478
549
 
@@ -1019,6 +1090,34 @@ export default function (pi: ExtensionAPI): void {
1019
1090
  const lines = gate.checks
1020
1091
  .map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
1021
1092
  .join("\n");
1093
+ // On a passing gate in autopilot, the tool itself advances the phase
1094
+ // so a run without the continuous loop armed still moves forward when
1095
+ // the agent calls infinity_validate — that's what the brief says will
1096
+ // happen ("PASS → the harness advances") and what stopped research
1097
+ // from ever reaching DEFINE until someone typed "continue".
1098
+ if (gate.overall && !params?.feature && !params?.task) {
1099
+ try {
1100
+ const { needsApproval } = await import("../../src/approval.ts");
1101
+ const fresh = loadConfig(dir).config;
1102
+ if (!needsApproval(fresh, fresh.currentPhase)) {
1103
+ const { advancePhase } = await import("../../src/core/phases.ts");
1104
+ const moved = await advancePhase(dir);
1105
+ if (moved.ok && moved.to) {
1106
+ refreshWidget(ctx as ExtensionContext);
1107
+ const brief = await briefText(dir);
1108
+ return {
1109
+ content: [
1110
+ {
1111
+ type: "text",
1112
+ text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1113
+ },
1114
+ ],
1115
+ details: { ...gate, advanced: moved } as unknown as typeof gate,
1116
+ };
1117
+ }
1118
+ }
1119
+ } catch {}
1120
+ }
1022
1121
  return {
1023
1122
  content: [
1024
1123
  {
@@ -1246,7 +1345,7 @@ export default function (pi: ExtensionAPI): void {
1246
1345
  });
1247
1346
 
1248
1347
  pi.registerCommand("infinity:init", {
1249
- description: "Set up a harness here — mode, goal, research, approvals, sessions",
1348
+ description: "Set up a harness here — workflow, goal, sessions, display",
1250
1349
  handler: async (args: string, ctx: ExtensionContext) => {
1251
1350
  const dir = projectDir(ctx);
1252
1351
  const force = /\bforce\b/.test(args);
@@ -1262,57 +1361,36 @@ export default function (pi: ExtensionAPI): void {
1262
1361
  }
1263
1362
 
1264
1363
  const detected = detectStack(dir);
1265
- let phases: Phase[] | undefined;
1266
1364
 
1267
- // Two things used to be wrong here, and they compounded.
1365
+ // Three things used to be wrong here, and they compounded.
1268
1366
  //
1269
- // First, the wizard never asked what was being built — so picking
1270
- // "autopilot" started a run with no idea and no scope, and the harness
1271
- // invented a project and began building it. Autopilot was being read as
1272
- // "you decide everything, including what I want".
1367
+ // The wizard never asked what was being built — so picking "autopilot"
1368
+ // started a run with no idea and no scope, and the harness invented a
1369
+ // project and began building it.
1273
1370
  //
1274
- // Second, "mode" was the only question. There was no way to say "drive
1275
- // yourself, but show me the plan before you build it", which is what
1276
- // most people actually want from an unattended run.
1371
+ // "mode" was the only question, and one switch cannot say "drive
1372
+ // yourself, but show me the plan before you build it".
1277
1373
  //
1278
- // The wizard now asks for the goal in both modes, offers an optional
1279
- // research phase, and in autopilot — lets the human pick exactly which
1280
- // of RESEARCH / DEFINE / PLAN they sign. `src/intake.ts` owns what the
1281
- // answers mean; `src/ui/wizard.ts` owns asking them.
1374
+ // And even that switch only reached three phases. It is a mode per
1375
+ // phase now, chosen from a workflow the human can build, name and reuse.
1376
+ // `src/workflow.ts` owns what a workflow is, `src/intake.ts` what the
1377
+ // answers mean, `src/ui/wizard.ts` how they are asked.
1282
1378
  if (ctx.hasUI) {
1283
1379
  const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
1284
1380
  const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
1285
- const go = await ctx.ui.select(
1286
- `Create a harness here? ${detected.label} · ${summary}`,
1287
- ["yes", "yes, and let me choose the phases", "cancel"],
1288
- );
1381
+ const go = await ctx.ui.select(`Create a harness here? ${detected.label} · ${summary}`, [
1382
+ "yes",
1383
+ "cancel",
1384
+ ]);
1289
1385
  if (go === undefined || go === "cancel") {
1290
1386
  notify(ctx, "init cancelled — nothing was written.", "info");
1291
1387
  return;
1292
1388
  }
1293
-
1294
- if (go.includes("phases")) {
1295
- const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
1296
- for (;;) {
1297
- const rows = SELECTABLE_PHASES.map((p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p}`);
1298
- const hit = await ctx.ui.select("Phases to run", [...rows, "✓ done"]);
1299
- if (hit === undefined || hit === "✓ done") break;
1300
- const key = SELECTABLE_PHASES[rows.indexOf(hit)];
1301
- if (!key) break;
1302
- if (chosen.has(key)) chosen.delete(key);
1303
- else chosen.add(key);
1304
- }
1305
- phases = [...chosen];
1306
- }
1307
1389
  }
1308
1390
 
1309
1391
  const wizard = ctx.hasUI
1310
- ? await runIntakeWizard({
1311
- prompt: prompterFor(ctx),
1312
- phases,
1313
- brief: goalFromArgs || null,
1314
- })
1315
- : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null, phases) } as const);
1392
+ ? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null, models: () => availableModels(ctx) })
1393
+ : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
1316
1394
 
1317
1395
  if (wizard.cancelled) {
1318
1396
  notify(ctx, "init cancelled — nothing was written.", "info");
@@ -1324,8 +1402,22 @@ export default function (pi: ExtensionAPI): void {
1324
1402
  mode: plan.mode,
1325
1403
  phases: plan.phases,
1326
1404
  approvals: plan.approvals,
1405
+ phaseModes: plan.phaseModes,
1406
+ workflow: plan.workflow,
1407
+ display: plan.display,
1327
1408
  session: plan.session,
1328
1409
  brief: plan.brief,
1410
+ router: plan.router
1411
+ ? ({
1412
+ enabled: !!plan.router.enabled,
1413
+ byDifficulty: plan.router.byDifficulty as unknown as Record<string, string>,
1414
+ thinkingByDifficulty: plan.router.thinkingByDifficulty as unknown as Record<string, string>,
1415
+ master: plan.router.master ?? "",
1416
+ thinkingMaster: plan.router.thinkingMaster as unknown as string,
1417
+ default: plan.router.default ?? "",
1418
+ thinkingDefault: plan.router.thinkingDefault as unknown as string,
1419
+ } as Partial<import("../../src/modelRouter.ts").RouterConfig>)
1420
+ : undefined,
1329
1421
  force,
1330
1422
  });
1331
1423
  if (!result.ok) {
@@ -1353,6 +1445,143 @@ export default function (pi: ExtensionAPI): void {
1353
1445
  },
1354
1446
  });
1355
1447
 
1448
+ /**
1449
+ * Change the workflow mid-run.
1450
+ *
1451
+ * Any of this is editable at any time and takes effect on the next gate —
1452
+ * a run three phases deep is exactly when someone realises they do want to
1453
+ * see the review after all.
1454
+ */
1455
+ pi.registerCommand("infinity:workflow", {
1456
+ description: "Choose or build the workflow — which phases run, and which stop for you",
1457
+ handler: async (args: string, ctx: ExtensionContext) => {
1458
+ const dir = projectDir(ctx);
1459
+ if (!isHarnessProject(dir)) {
1460
+ notify(ctx, NO_HARNESS, "warning");
1461
+ return;
1462
+ }
1463
+
1464
+ const { config } = loadConfig(dir);
1465
+ const arg = args.trim();
1466
+
1467
+ if (arg === "" && !ctx.hasUI) {
1468
+ notify(ctx, describeCurrentWorkflow(dir), "info");
1469
+ return;
1470
+ }
1471
+ if (arg === "show" || arg === "list") {
1472
+ const rows = listWorkflows().map((w) => ` ${w.builtIn ? " " : "*"} ${w.name} — ${w.description}`);
1473
+ notify(
1474
+ ctx,
1475
+ `${describeCurrentWorkflow(dir)}\n\nAvailable (* = yours):\n${rows.join("\n")}`,
1476
+ "info",
1477
+ );
1478
+ return;
1479
+ }
1480
+
1481
+ // `/infinity:workflow <name>` switches without a menu, which is what a
1482
+ // second run in the same terminal wants.
1483
+ let chosen = arg ? findWorkflow(arg) : undefined;
1484
+ if (arg && !chosen) {
1485
+ notify(ctx, `No workflow called "${arg}". \`/infinity:workflow list\` shows them.`, "warning");
1486
+ return;
1487
+ }
1488
+ if (!chosen) {
1489
+ if (!ctx.hasUI) {
1490
+ notify(ctx, "This mode has no dialogs — `/infinity:workflow <name>` switches directly.", "warning");
1491
+ return;
1492
+ }
1493
+ chosen = (await pickWorkflow(prompterFor(ctx))) ?? undefined;
1494
+ if (!chosen) {
1495
+ notify(ctx, "Unchanged.", "info");
1496
+ return;
1497
+ }
1498
+ }
1499
+
1500
+ const { value } = await withLock(configPath(dir), () => {
1501
+ const fresh = loadConfig(dir);
1502
+ if (!fresh.ok) return false;
1503
+ applyWorkflow(fresh.config, chosen!);
1504
+ // Keep the legacy field in step so a 2.3 tool reading this config
1505
+ // still sees the same three answers it understands.
1506
+ fresh.config.approvals = {
1507
+ research: fresh.config.phaseModes?.research === "copilot",
1508
+ define: fresh.config.phaseModes?.define === "copilot",
1509
+ plan: fresh.config.phaseModes?.plan === "copilot",
1510
+ };
1511
+ fresh.config.mode = signedPhases(fresh.config).length > 0 ? "copilot" : "autopilot";
1512
+ return saveConfig(dir, fresh.config).ok;
1513
+ });
1514
+
1515
+ if (!value) {
1516
+ notify(ctx, "Could not save the workflow — config unreadable.", "error");
1517
+ return;
1518
+ }
1519
+ notify(ctx, `${renderWorkflow(chosen)}\n\nIt takes effect at the next gate.`, "info");
1520
+ refreshWidget(ctx);
1521
+ void config;
1522
+ },
1523
+ });
1524
+
1525
+ pi.registerCommand("infinity:display", {
1526
+ description: "Choose what the widget and the dashboard show, level by level",
1527
+ handler: async (args: string, ctx: ExtensionContext) => {
1528
+ const dir = projectDir(ctx);
1529
+ if (!isHarnessProject(dir)) {
1530
+ notify(ctx, NO_HARNESS, "warning");
1531
+ return;
1532
+ }
1533
+ const arg = args.trim();
1534
+ const current = normalizeDisplay(loadConfig(dir).config.display);
1535
+
1536
+ if (arg === "show" || arg === "list") {
1537
+ const rows = listDisplays().map((d) => ` ${d.builtIn ? " " : "*"} ${d.name} — ${d.description}`);
1538
+ notify(
1539
+ ctx,
1540
+ `Now: ${summarizeDisplay(current)}\n\nTemplates (* = yours):\n${rows.join("\n")}`,
1541
+ "info",
1542
+ );
1543
+ return;
1544
+ }
1545
+
1546
+ let next: DisplayPolicy | undefined;
1547
+ if (arg) {
1548
+ const template = findDisplay(arg);
1549
+ if (!template) {
1550
+ notify(ctx, `No template called "${arg}". \`/infinity:display list\` shows them.`, "warning");
1551
+ return;
1552
+ }
1553
+ next = template.policy;
1554
+ } else {
1555
+ if (!ctx.hasUI) {
1556
+ notify(ctx, `Now: ${summarizeDisplay(current)}. \`/infinity:display <template>\` switches.`, "info");
1557
+ return;
1558
+ }
1559
+ next = await pickDisplay(prompterFor(ctx));
1560
+ if (!next) {
1561
+ notify(ctx, "Unchanged.", "info");
1562
+ return;
1563
+ }
1564
+ }
1565
+
1566
+ const { value } = await withLock(configPath(dir), () => {
1567
+ const fresh = loadConfig(dir);
1568
+ if (!fresh.ok) return false;
1569
+ fresh.config.display = normalizeDisplay(next);
1570
+ return saveConfig(dir, fresh.config).ok;
1571
+ });
1572
+
1573
+ if (!value) {
1574
+ notify(ctx, "Could not save the display settings — config unreadable.", "error");
1575
+ return;
1576
+ }
1577
+ // The widget is the answer to "did that do what I wanted", so redraw it
1578
+ // before saying anything about it.
1579
+ view = defaultView();
1580
+ refreshWidget(ctx);
1581
+ notify(ctx, `Showing: ${summarizeDisplay(normalizeDisplay(next))}`, "info");
1582
+ },
1583
+ });
1584
+
1356
1585
  /**
1357
1586
  * Continue the run in a replacement session.
1358
1587
  *
@@ -2210,24 +2439,58 @@ export default function (pi: ExtensionAPI): void {
2210
2439
  // `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
2211
2440
  // to line end) and ctrl+o (expand tool output). Shadowing an editor key to
2212
2441
  // scroll a widget would be a worse bug than the one being fixed.
2442
+ //
2443
+ // Shortcuts are editor-focused via registerShortcut, but also handled as a
2444
+ // raw terminal fallback so they work when an overlay or selector has focus
2445
+ // or when the terminal sends the legacy ESC+j sequence that the editor
2446
+ // otherwise swallows as text.
2447
+
2448
+ const scrollDown = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, SCROLL_STEP);
2449
+ const scrollUp = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, -SCROLL_STEP);
2450
+ const toggleExpand = async (ctx: ExtensionContext): Promise<void> => {
2451
+ view = { ...view, expanded: !view.expanded };
2452
+ refreshWidget(ctx);
2453
+ };
2213
2454
 
2214
- pi.registerShortcut("alt+j", {
2215
- description: "infinity-harness: scroll the plan down",
2216
- handler: async (ctx: ExtensionContext) => moveView(ctx, SCROLL_STEP),
2217
- });
2218
-
2219
- pi.registerShortcut("alt+k", {
2220
- description: "infinity-harness: scroll the plan up",
2221
- handler: async (ctx: ExtensionContext) => moveView(ctx, -SCROLL_STEP),
2222
- });
2223
-
2224
- pi.registerShortcut("alt+o", {
2225
- description: "infinity-harness: expand or collapse the plan widget",
2226
- handler: async (ctx: ExtensionContext) => {
2227
- view = { ...view, expanded: !view.expanded };
2228
- refreshWidget(ctx);
2229
- },
2230
- });
2455
+ pi.registerShortcut("alt+j", { description: "infinity-harness: scroll the plan down", handler: scrollDown });
2456
+ pi.registerShortcut("alt+k", { description: "infinity-harness: scroll the plan up", handler: scrollUp });
2457
+ pi.registerShortcut("alt+o", { description: "infinity-harness: expand or collapse the plan widget", handler: toggleExpand });
2458
+ // Uppercase handling covered by the raw terminal fallback below which
2459
+ // lowercases data before matching; KeyId type only allows lowercase.
2460
+
2461
+ // Fallback raw input handler — runs even when the editor is not the
2462
+ // focused component (e.g. a selector is open). Must be installed per-
2463
+ // session because onTerminalInput is a UI session thing, not a global.
2464
+ let removeTerminalShortcut: (() => void) | null = null;
2465
+ const installTerminalShortcuts = (ctx: ExtensionContext): void => {
2466
+ try {
2467
+ removeTerminalShortcut?.();
2468
+ } catch {}
2469
+ try {
2470
+ // matchesKey lives in pi-tui but re-exported by pi; use the extension
2471
+ // input raw matcher via string compare for ESC-prefixed alt.
2472
+ removeTerminalShortcut = ctx.ui.onTerminalInput((data: string) => {
2473
+ // Legacy alt+letter is ESC + lower letter. Kitty may send CSI-u; both
2474
+ // are handled by normalising to lookahead then matching via the same
2475
+ // strings registerShortcut uses.
2476
+ const lower = data.toLowerCase();
2477
+ // Fast path: alt+j/k/o as ESC + letter (\x1bj) or higher-plane.
2478
+ if (data === "\x1bj" || data === "\x1bJ" || lower === "\x1bj") {
2479
+ void scrollDown(ctx);
2480
+ return { consume: true };
2481
+ }
2482
+ if (data === "\x1bk" || data === "\x1bK" || lower === "\x1bk") {
2483
+ void scrollUp(ctx);
2484
+ return { consume: true };
2485
+ }
2486
+ if (data === "\x1bo" || data === "\x1bO" || lower === "\x1bo") {
2487
+ void toggleExpand(ctx);
2488
+ return { consume: true };
2489
+ }
2490
+ return undefined;
2491
+ });
2492
+ } catch {}
2493
+ };
2231
2494
 
2232
2495
  pi.registerCommand("infinity:scroll", {
2233
2496
  description: "Move the plan widget — up, down, top, bottom, expand, follow",
@@ -2295,6 +2558,20 @@ function errMsg(e: unknown): string {
2295
2558
  return e instanceof Error ? e.message : String(e);
2296
2559
  }
2297
2560
 
2561
+ /** The workflow this project is on, and whether it still matches a named one. */
2562
+ function describeCurrentWorkflow(dir: string): string {
2563
+ const { config } = loadConfig(dir);
2564
+ const named = matchWorkflow(config);
2565
+ const head = `Workflow: ${summarizeWorkflow(config)}`;
2566
+ const rail = (config.phases?.enabled ?? [])
2567
+ .map((p) => (config.phaseModes?.[p] === "copilot" ? `[${p}]` : p))
2568
+ .join(" → ");
2569
+ const drift = named
2570
+ ? ""
2571
+ : "\n\nThese settings do not match any saved workflow. `/infinity:workflow` can save them as one.";
2572
+ return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
2573
+ }
2574
+
2298
2575
  /**
2299
2576
  * The few sentences the run cannot afford to have summarised away.
2300
2577
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
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": [
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
 
@@ -49,8 +50,11 @@ export function defaultConfig(): HarnessConfig {
49
50
  },
50
51
  phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
51
52
  roles: { strict: false },
52
- session: { handoff: "phase", contextThreshold: 0.7, carryNotes: true },
53
+ session: { handoff: "task", contextThreshold: 0.6, 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 };