infinity-harness 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,70 @@ All notable changes to this project are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.4.0] — 2026-08-24
8
+
9
+ Two settings that were one switch each, and one switch turned out to be the wrong shape for both
10
+ questions.
11
+
12
+ ### Added
13
+
14
+ - **A mode per phase, not a mode per run.** "copilot" and "autopilot" could not say "let it define
15
+ and plan on its own but show me the review", and that is a thing people want. Every phase now
16
+ carries its own mode: `autopilot` advances when the gate passes, `copilot` stops and waits for
17
+ your signature. The two familiar words survive as two named points in that space rather than the
18
+ only two points in it, and every phase except INIT can be a checkpoint — the three that decide
19
+ *what gets built* are the ones that usually pay for themselves, but that is a default rather than
20
+ a limit.
21
+
22
+ - **Five workflows ship, and you can build your own.** `copilot`, `autopilot`, `spec and ship`
23
+ (you sign the scope going in and the release coming out), `research first`, and `every gate`.
24
+ Building one is its own short flow: pick the phases, then say for each whether it stops for you.
25
+ Name it and it is kept — in `~/.pi/agent/infinity-harness/`, with *you* rather than with the
26
+ project, because a workflow you designed is worth as much on the next one. It is then the first
27
+ thing offered there.
28
+
29
+ Built-ins are read-only, and their names cannot be taken. "copilot" has to mean the same thing in
30
+ every conversation about this tool; someone who wants a different copilot makes their own and
31
+ gives it their own name.
32
+
33
+ - **`/infinity:workflow`** — choose one, build one, switch by name (`/infinity:workflow
34
+ spec-and-ship`), or `list` what is available and what you are on. `/infinity:config` →
35
+ **Workflow** edits one phase at a time. All of it changes at any time and takes effect at the next
36
+ gate: three phases into a run is exactly when someone realises they do want to see the review.
37
+
38
+ - **Display templates.** Shipping all five plan levels to everyone was the wrong answer for the same
39
+ reason shipping two was: one person works in sprints and never opens a subtask, the next has no
40
+ sprints and lives in the subtask list. Four ship — `focus`, `everything`, `overview` (the shape,
41
+ no tasks), `worklist` (tasks only, no rail) — and anything else is chosen level by level: the
42
+ five plan levels, the `done/total` counts, the `← #3` dependency labels, the acceptance criteria,
43
+ the phase rail, the progress meter, the alert strip, and how many rows the terminal shows before
44
+ it scrolls. Name what you end up with and it is saved with you like a workflow.
45
+
46
+ - **`/infinity:display`** — pick a template, choose level by level, switch by name, or `list`.
47
+ `/infinity:config` → **Display** edits the same things one at a time.
48
+
49
+ - **The widget and the dashboard read the same setting.** Configure how you like to read a plan
50
+ once, not twice.
51
+
52
+ ### Changed
53
+
54
+ - `harness/config.json` gains `phaseModes`, `workflow` and `display`. A 2.3 config is migrated on
55
+ read: its three-phase `approvals` becomes the equivalent modes and is labelled with the workflow
56
+ it amounts to, so a project mid-run keeps exactly the approvals it was configured with and nobody
57
+ has to edit JSON to upgrade. The legacy field is kept in step on write.
58
+ - Hiding a plan level hides the row, never the work beneath it: turn off sprints on a plan organised
59
+ into sprints and the features move up one indent rather than vanishing, and task numbers do not
60
+ shift, so `← #3` still points at the same task. A task nobody can see is a task that gets stuck
61
+ forever.
62
+
63
+ ### Fixed
64
+
65
+ - **`initHarness` ignored a caller that passed the old `approvals` shape** once `phaseModes`
66
+ existed, silently producing an autopilot run. It falls back to `approvals` when no modes are
67
+ given — the same rule `loadConfig` applies to an older file.
68
+
69
+ ---
70
+
7
71
  ## [2.3.1] — 2026-08-24
8
72
 
9
73
  *(2.3.0 was staged at the registry and never completed; 2.3.1 is that release plus the last two
package/README.md CHANGED
@@ -100,11 +100,10 @@ It detects your stack and its lint/test/build commands, then asks you five quest
100
100
 
101
101
  | | |
102
102
  |---|---|
103
- | **How involved do you want to be?** | copilot you approve the definition and the plan · autopilot you choose what to approve, if anything |
104
- | **What are you building?** | One or two sentences. Asked in *both* modes, because a run with no goal has no business inventing one. |
105
- | **Research it first?** | Adds an optional RESEARCH phase before DEFINE: prior art, constraints, options with costs, a recommendation, and the questions only you can answer. |
106
- | **Which phases do you sign?** *(autopilot only)* | RESEARCH, DEFINE, PLAN — tick any, all or none. None is the walk-away setting. |
103
+ | **Which workflow?** | A built-in, one you saved, or *build one* pick the phases, then say for each whether it stops for you |
104
+ | **What are you building?** | One or two sentences. Asked whatever the workflow, because a run with no goal has no business inventing one. |
107
105
  | **When should it start a fresh session?** | Every phase (default) · every task · never |
106
+ | **How much of the plan on screen?** | A display template — `focus`, `everything`, `overview`, `worklist`, or level by level |
108
107
 
109
108
  Then it writes `harness/` — the config, an empty plan, the phase and role docs, and starters
110
109
  for the documents the review gate will demand — and hands the model its first brief. It never
@@ -134,14 +133,45 @@ the craft skills that match the work, and what to do next. Do the work, then:
134
133
 
135
134
  ## Who decides what
136
135
 
137
- The two words are about **who signs off**, not about how autonomous the agent is. Both modes
138
- run the same pipeline, the same gates and the same loop.
136
+ "copilot" and "autopilot" used to be one switch, and one switch is the wrong shape for the
137
+ question. What people actually want is per-phase: let the model define and plan on its own but
138
+ show me the review; or grill me on the definition and then leave me alone until it ships. Two
139
+ words cannot say that.
139
140
 
140
- | | copilot | autopilot |
141
- |---|---|---|
142
- | RESEARCH, DEFINE, PLAN | you approve each one | you pick which, if any |
143
- | Everything after PLAN | the gate decides | the gate decides |
144
- | When it is right | you care what gets built | you have said what you want and you are leaving |
141
+ So the setting is a **mode per phase**, and the two familiar words are two named points in that
142
+ space rather than the only two points in it.
143
+
144
+ | Mode | When a phase's gate passes |
145
+ |---|---|
146
+ | `autopilot` | it advances |
147
+ | `copilot` | it stops and waits for your signature |
148
+
149
+ Five workflows ship with the package, and none of them can be edited — `copilot` has to mean the
150
+ same thing in every conversation about this tool:
151
+
152
+ | Workflow | Stops at |
153
+ |---|---|
154
+ | **copilot** | DEFINE, PLAN |
155
+ | **autopilot** | nothing |
156
+ | **spec and ship** | DEFINE, SHIP — you sign the scope going in and the release coming out |
157
+ | **research first** | adds a RESEARCH phase, and stops on all three thinking phases |
158
+ | **every gate** | every phase. Slowest, and the one you want on something that matters |
159
+
160
+ Anything else you build yourself: pick the phases, then say for each whether it stops for you. Give
161
+ it a name and it is saved with *you*, not with the project, so it is the first thing offered on your
162
+ next one.
163
+
164
+ ```
165
+ /infinity:workflow choose one, or build one
166
+ /infinity:workflow spec-and-ship switch to a named one, no menu
167
+ /infinity:workflow list what is available, and what you are on now
168
+ ```
169
+
170
+ Any of it changes at any time and takes effect at the next gate — three phases into a run is
171
+ exactly when someone realises they do want to see the review after all. `/infinity:config` →
172
+ **Workflow** edits one phase at a time.
173
+
174
+ ### Signing a phase
145
175
 
146
176
  When a phase you signed up for passes its gate, the run **stops and asks you** rather than
147
177
  advancing. Approving continues it; answering with a sentence sends the phase back carrying your
@@ -152,9 +182,9 @@ words, so it is redone against your objection rather than redone identically:
152
182
  /infinity:approve the criteria say nothing about refunds
153
183
  ```
154
184
 
155
- A rejection is pinned to the state of the project when you made it, so the run will not ask you
156
- the same question again until the agent has actually changed something in response. If it never
157
- does, the run stops and says so instead of nagging forever.
185
+ A rejection is pinned to the state of the project when you made it, so the run will not ask you the
186
+ same question again until the agent has actually changed something in response. If it never does,
187
+ the run stops and says so instead of nagging forever.
158
188
 
159
189
  ## One run, many sessions
160
190
 
@@ -224,7 +254,8 @@ valid; the menu is the same data with prompts and bounds checking attached.
224
254
  |---|---|
225
255
  | **Models** | Which model runs each difficulty tier, the master model, consultation budget |
226
256
  | **Pipeline** | Which phases run, copilot vs autopilot, role strictness, pause |
227
- | **Your approvals** | Which of RESEARCH / DEFINE / PLAN stop and wait for your signature |
257
+ | **Workflow** | The mode for each phase which of them stop and wait for your signature |
258
+ | **Display** | Which plan levels and which chrome the widget and the dashboard draw |
228
259
  | **Sessions** | Fresh session per phase or per task, the context threshold, the carry note |
229
260
  | **Project commands** | lint / test / coverage / build — what the gate actually runs |
230
261
  | **Gates** | Enable, coverage threshold, placeholder rejection |
@@ -349,7 +380,7 @@ The agent edits it by submitting the **complete** task list through the `infinit
349
380
 
350
381
  ## Watching it work
351
382
 
352
- **In the terminal** — the widget above updates on every turn, showing all five levels of the plan:
383
+ **In the terminal** — the widget updates on every turn, showing the plan as deep as you asked for:
353
384
  goal, sprint, feature, task, subtask. It is a *window*, not a truncation — the rows above and
354
385
  below are counted, and one keypress away:
355
386
 
@@ -362,14 +393,43 @@ below are counted, and one keypress away:
362
393
  It is responsive down to ~58 columns, degrades to ASCII when the locale isn't UTF-8, and drops
363
394
  colour under `NO_COLOR`.
364
395
 
365
- **In a browser** — `/infinity:dashboard` serves a live page on loopback: phase rail, stacked progress
366
- meters that show stuck work as colour rather than absence, the whole plan as a collapsible
367
- goal → sprint → feature → task → subtask tree with counts at every level, and the last gate
368
- verdict. It refreshes itself every 5 seconds and reconnects with backoff if the run ends.
396
+ **In a browser** — `/infinity:dashboard` serves a live page on loopback: phase rail, stacked
397
+ progress meters that show stuck work as colour rather than absence, the plan as a collapsible tree
398
+ with counts at every level, and the last gate verdict. It refreshes itself every 5 seconds and
399
+ reconnects with backoff if the run ends.
369
400
 
370
401
  The dashboard is strictly read-only and binds to `127.0.0.1`. It never writes, and never bumps
371
402
  `baseRevision` — opening it can't perturb the run you're watching.
372
403
 
404
+ ### What they show is yours to choose
405
+
406
+ Two people watching the same run want different things on screen. One works in sprints and never
407
+ opens a subtask; the next has no sprints and lives in the subtask list. So it is a setting, and
408
+ **the widget and the dashboard read the same one** — configure how you like to read a plan once,
409
+ not twice.
410
+
411
+ | Template | |
412
+ |---|---|
413
+ | **focus** *(default)* | every level, with subtasks on the task being worked |
414
+ | **everything** | all five levels, every subtask on every task, a taller window |
415
+ | **overview** | goals, sprints and features with their counts. No tasks — the shape, not the work |
416
+ | **worklist** | tasks only. No grouping rows, no rail — for when you already know the plan |
417
+
418
+ ```
419
+ /infinity:display pick a template, or choose level by level
420
+ /infinity:display overview switch straight to one
421
+ /infinity:display list what is available, and what you are on now
422
+ ```
423
+
424
+ Choosing level by level also lets you turn off the phase rail, the progress meter, the alert strip,
425
+ the `done/total` counts, the `← #3` dependency labels and the acceptance criteria, and set how many
426
+ rows the terminal shows before it scrolls. Name what you end up with and it is saved with you,
427
+ offered on your next project. `/infinity:config` → **Display** edits the same things one at a time.
428
+
429
+ Hiding a level never hides what is under it: turn off sprints on a plan organised into sprints and
430
+ the features move up one indent, they do not vanish. A task nobody can see is a task that gets
431
+ stuck forever.
432
+
373
433
  ## Craft skills
374
434
 
375
435
  28 short documents on how to do the work well — how to write a test worth
@@ -447,10 +507,12 @@ infinity-harness/
447
507
  │ │ · featureList (the SSOT) · lock · exec
448
508
  │ │ · skills (match) · skillsAudit (guard)
449
509
  │ ├── ui/ theme · planTree (the five levels, once)
510
+ │ │ · display (what to draw, and the templates)
450
511
  │ │ · widget (terminal) · dashboard (web) · wizard · config
451
512
  │ ├── loop.ts the continuous-run driver and its stop conditions
452
513
  │ ├── runState.ts is a run armed, and which run is it — on disk, across sessions
453
514
  │ ├── handoff.ts when to continue in a fresh session, and what to tell it
515
+ │ ├── workflow.ts a mode per phase, the built-ins, and the ones you save
454
516
  │ ├── approval.ts · intake.ts human sign-off · what the start-up wizard's answers mean
455
517
  │ ├── escalate.ts the ladder's actuator: chooses a rung and takes it
456
518
  │ ├── goal.ts the outer loop: is the thing asked for actually done?
@@ -467,7 +529,10 @@ infinity-harness/
467
529
  │ ├── model-router.json optional routing
468
530
  │ ├── docs/ architecture · decisions · phase and role docs
469
531
  │ └── skills/ 28 craft skills the brief points at
470
- ├── tests/ 31 files, plain node:assert
532
+
533
+ │ ~/.pi/agent/infinity-harness/ the workflows and display templates you saved —
534
+ │ they belong to you, not to a project
535
+ ├── tests/ 33 files, plain node:assert
471
536
  └── scripts/
472
537
  ├── run-tests.mjs
473
538
  ├── e2e.mjs 16 scenarios, including one against a real pi process
@@ -482,7 +547,7 @@ there is one implementation, and the adapter calls it.
482
547
  ```bash
483
548
  npm install
484
549
  npm run check # tsc --noEmit, strict
485
- npm test # 31 test files
550
+ npm test # 33 test files
486
551
  npm run e2e # 16 end-to-end scenarios
487
552
  npm run e2e -- --only realpi # just the ones that drive a real pi process
488
553
  npm run e2e -- --list # what the scenarios are
@@ -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),
@@ -1246,7 +1269,7 @@ export default function (pi: ExtensionAPI): void {
1246
1269
  });
1247
1270
 
1248
1271
  pi.registerCommand("infinity:init", {
1249
- description: "Set up a harness here — mode, goal, research, approvals, sessions",
1272
+ description: "Set up a harness here — workflow, goal, sessions, display",
1250
1273
  handler: async (args: string, ctx: ExtensionContext) => {
1251
1274
  const dir = projectDir(ctx);
1252
1275
  const force = /\bforce\b/.test(args);
@@ -1262,57 +1285,36 @@ export default function (pi: ExtensionAPI): void {
1262
1285
  }
1263
1286
 
1264
1287
  const detected = detectStack(dir);
1265
- let phases: Phase[] | undefined;
1266
1288
 
1267
- // Two things used to be wrong here, and they compounded.
1289
+ // Three things used to be wrong here, and they compounded.
1268
1290
  //
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".
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.
1273
1294
  //
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.
1295
+ // "mode" was the only question, and one switch cannot say "drive
1296
+ // yourself, but show me the plan before you build it".
1277
1297
  //
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.
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.
1282
1302
  if (ctx.hasUI) {
1283
1303
  const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
1284
1304
  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
- );
1305
+ const go = await ctx.ui.select(`Create a harness here? ${detected.label} · ${summary}`, [
1306
+ "yes",
1307
+ "cancel",
1308
+ ]);
1289
1309
  if (go === undefined || go === "cancel") {
1290
1310
  notify(ctx, "init cancelled — nothing was written.", "info");
1291
1311
  return;
1292
1312
  }
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
1313
  }
1308
1314
 
1309
1315
  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);
1316
+ ? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null })
1317
+ : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
1316
1318
 
1317
1319
  if (wizard.cancelled) {
1318
1320
  notify(ctx, "init cancelled — nothing was written.", "info");
@@ -1324,6 +1326,9 @@ export default function (pi: ExtensionAPI): void {
1324
1326
  mode: plan.mode,
1325
1327
  phases: plan.phases,
1326
1328
  approvals: plan.approvals,
1329
+ phaseModes: plan.phaseModes,
1330
+ workflow: plan.workflow,
1331
+ display: plan.display,
1327
1332
  session: plan.session,
1328
1333
  brief: plan.brief,
1329
1334
  force,
@@ -1353,6 +1358,143 @@ export default function (pi: ExtensionAPI): void {
1353
1358
  },
1354
1359
  });
1355
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
+
1356
1498
  /**
1357
1499
  * Continue the run in a replacement session.
1358
1500
  *
@@ -2295,6 +2437,20 @@ function errMsg(e: unknown): string {
2295
2437
  return e instanceof Error ? e.message : String(e);
2296
2438
  }
2297
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
+
2298
2454
  /**
2299
2455
  * The few sentences the run cannot afford to have summarised away.
2300
2456
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.3.1",
3
+ "version": "2.4.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 {