infinity-harness 2.2.1 → 2.3.1

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.
@@ -16,11 +16,15 @@
16
16
  * - refusing tool calls that would skip a phase
17
17
  */
18
18
 
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import type {
20
+ ExtensionAPI,
21
+ ExtensionCommandContext,
22
+ ExtensionContext,
23
+ } from "@earendil-works/pi-coding-agent";
20
24
  import { randomUUID } from "node:crypto";
21
25
 
22
26
  import { isHarnessProject, loadConfig, saveConfig } from "../../src/core/config.ts";
23
- import { loadFeatureList, computeProgress } from "../../src/core/featureList.ts";
27
+ import { loadFeatureList, computeProgress, nextActionableTask } from "../../src/core/featureList.ts";
24
28
  import { buildBrief, renderBrief } from "../../src/core/brief.ts";
25
29
  import { runChecks } from "../../src/core/gates.ts";
26
30
  import { advancePhase } from "../../src/core/phases.ts";
@@ -36,7 +40,7 @@ import {
36
40
  import { writeTaskList, summarizeApply, type TaskInput } from "../../src/taskList.ts";
37
41
  import { renderWidget, renderStatusLine, type WidgetState } from "../../src/ui/widget.ts";
38
42
  import { createStyler, detectGlyphs } from "../../src/ui/theme.ts";
39
- import { decideNext, stopFilePath, loopStatePath } from "../../src/loop.ts";
43
+ import { decideNext, fingerprint, stopFilePath, loopStatePath } from "../../src/loop.ts";
40
44
  import { runConfigMenu, renderSettings, type ModelChoice, type Prompter } from "../../src/ui/config.ts";
41
45
  import { SETTINGS, readAll, readSetting, formatValue } from "../../src/core/settings.ts";
42
46
  import { detectStack, describeInit, initHarness, type StackId } from "../../src/core/init.ts";
@@ -56,6 +60,27 @@ import {
56
60
  type ReviewInput,
57
61
  } from "../../src/goal.ts";
58
62
  import { flattenTasks } from "../../src/core/featureList.ts";
63
+ import {
64
+ armRun,
65
+ countSession,
66
+ disarmRun,
67
+ loadRunState,
68
+ runIdFor,
69
+ } from "../../src/runState.ts";
70
+ import {
71
+ clearHandoff,
72
+ composeKickoff,
73
+ describeHandoff,
74
+ hasPendingHandoff,
75
+ requestHandoff,
76
+ shouldHandoff,
77
+ takeHandoff,
78
+ type HandoffReason,
79
+ } from "../../src/handoff.ts";
80
+ import { needsApproval, resolveApproval, approvedPhases } from "../../src/approval.ts";
81
+ import { runIntakeWizard, unattendedIntake } from "../../src/ui/wizard.ts";
82
+ import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW, type WidgetView } from "../../src/ui/widget.ts";
83
+ import { buildPlanRows } from "../../src/ui/planTree.ts";
59
84
 
60
85
  const CHECKPOINT = "infinity:checkpoint";
61
86
  const WIDGET_KEY = "infinity-harness";
@@ -79,13 +104,42 @@ function notify(ctx: unknown, message: string, level: "info" | "warning" | "erro
79
104
 
80
105
  export default function (pi: ExtensionAPI): void {
81
106
  // -- session-scoped state -------------------------------------------------
82
- const runId = randomUUID();
107
+ //
108
+ // Everything a *run* needs outlives this session and lives in `harness/`.
109
+ // What is left here is genuinely per-session: this session's own id, and
110
+ // where the human has scrolled the widget.
111
+ const sessionId = randomUUID();
83
112
  let llmCalls = 0;
84
- let loopEnabled = false;
85
113
  let loopBusy = false;
114
+ let handingOff = false;
86
115
  let lastBriefPhase: string | null = null;
87
116
  let remoteServer: { url: string; close: () => Promise<void> } | null = null;
88
117
  let remoteDir: string | null = null;
118
+ let view: WidgetView = defaultView();
119
+
120
+ /**
121
+ * Is this instance's session still the live one?
122
+ *
123
+ * After `ctx.newSession()` pi tears the old runtime down and rebinds
124
+ * extensions, but this closure and its registered handlers still exist. Any
125
+ * of them that touches `pi` or a captured `ctx` afterwards gets
126
+ * "This extension ctx is stale after session replacement" — which is what a
127
+ * handoff produced on every single turn until this flag existed.
128
+ */
129
+ let sessionLive = true;
130
+
131
+ /**
132
+ * Is a continuous run armed?
133
+ *
134
+ * Read from disk, not from a closure variable. The old `let loopEnabled`
135
+ * died with the pi session that held it, which meant the first session
136
+ * handoff — the whole point of the fresh-session policy — silently ended
137
+ * the run it was supposed to continue.
138
+ */
139
+ const loopArmed = (dir: string): boolean => loadRunState(dir)?.armed === true;
140
+
141
+ /** The run this session belongs to, or this session, when nothing is armed. */
142
+ const runFor = (dir: string): string => runIdFor(dir, sessionId);
89
143
 
90
144
  const styler = createStyler();
91
145
  const glyphs = detectGlyphs();
@@ -104,8 +158,13 @@ export default function (pi: ExtensionAPI): void {
104
158
  const lastRung = loop?.escalations?.[loop.escalations.length - 1]?.strategy ?? null;
105
159
  const pass = typeof config.goalPass === "number" ? config.goalPass : null;
106
160
  const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
161
+ const run = loadRunState(dir);
107
162
  return {
108
163
  list,
164
+ view,
165
+ sessions: run?.sessions ?? null,
166
+ intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
167
+ awaitingApproval: config.awaitingApproval ?? null,
109
168
  phase: config.currentPhase,
110
169
  enabledPhases: config.phases?.enabled,
111
170
  paused: Boolean(config.paused),
@@ -135,6 +194,31 @@ export default function (pi: ExtensionAPI): void {
135
194
  }
136
195
  };
137
196
 
197
+ /** How many rows the plan currently has — the bound for scrolling. */
198
+ const planRowCount = (dir: string): number => {
199
+ try {
200
+ const { list } = loadFeatureList(dir);
201
+ return buildPlanRows(list, null, { expandSubtasks: view.expanded }).length;
202
+ } catch {
203
+ return 0;
204
+ }
205
+ };
206
+
207
+ /**
208
+ * The widget is a window onto the plan, and the window has to move.
209
+ *
210
+ * A fixed nine-row slice of a sixty-row plan is a widget that is *truncated*,
211
+ * which is exactly how it read: the rows outside the window may as well not
212
+ * exist. They exist; these keys reach them.
213
+ */
214
+ const moveView = (ctx: ExtensionContext, delta: number): void => {
215
+ const dir = projectDir(ctx);
216
+ const rows = planRowCount(dir);
217
+ const windowRows = view.expanded ? EXPANDED_WINDOW : TASK_WINDOW;
218
+ view = scrollView(view, delta, rows, windowRows);
219
+ refreshWidget(ctx);
220
+ };
221
+
138
222
  // -- brief ----------------------------------------------------------------
139
223
 
140
224
  const briefText = async (dir: string, includeGate = false): Promise<string> => {
@@ -188,38 +272,295 @@ export default function (pi: ExtensionAPI): void {
188
272
  notify: (message, level) => notify(ctx, message, level ?? "info"),
189
273
  });
190
274
 
275
+ // -- session handoff ------------------------------------------------------
276
+
277
+ /** The task the pipeline is on right now, or null. */
278
+ const activeTaskKey = (dir: string): string | null => {
279
+ try {
280
+ const { list } = loadFeatureList(dir);
281
+ return nextActionableTask(list)?.compositeKey ?? null;
282
+ } catch {
283
+ return null;
284
+ }
285
+ };
286
+
287
+ /** How full this session's context is, 0..1, or null when pi cannot say. */
288
+ const contextRatio = (ctx: ExtensionContext): number | null => {
289
+ try {
290
+ const usage = ctx.getContextUsage?.();
291
+ if (!usage || typeof usage.percent !== "number") return null;
292
+ return usage.percent > 1 ? usage.percent / 100 : usage.percent;
293
+ } catch {
294
+ return null;
295
+ }
296
+ };
297
+
298
+ /**
299
+ * Continue the run in a fresh session, if the policy says to.
300
+ *
301
+ * Returns true when a handoff was started, in which case the caller must not
302
+ * also send the brief — the replacement session will.
303
+ *
304
+ * `ctx.newSession` deadlocks if it is called from an event handler, so the
305
+ * actual switch happens in the `/infinity:handoff` command. Queuing that
306
+ * command as a follow-up user message is the documented way to reach a
307
+ * command from a handler.
308
+ */
309
+ const maybeHandOff = async (
310
+ ctx: ExtensionContext,
311
+ dir: string,
312
+ brief: string,
313
+ fromPhase: Phase | null,
314
+ toPhase: Phase | null,
315
+ fromTask: string | null,
316
+ ): Promise<boolean> => {
317
+ // A handoff that was asked for and never happened would wedge the run:
318
+ // this session stops driving and the replacement never arrives. One
319
+ // attempt, then carry on here — a run that continues in a fat session is
320
+ // far better than a run that stops.
321
+ // A one-shot `pi -p` run has no next turn to hand anything to: replacing
322
+ // its session mid-flight produces a stale-context error and nothing else.
323
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") return false;
324
+
325
+ if (handingOff) {
326
+ if (hasPendingHandoff(dir)) {
327
+ notify(ctx, "infinity-harness: the new session never started — continuing here.", "warning");
328
+ clearHandoff(dir);
329
+ }
330
+ handingOff = false;
331
+ return false;
332
+ }
333
+ try {
334
+ const { config } = loadConfig(dir);
335
+ const decision = shouldHandoff({
336
+ config,
337
+ fromPhase,
338
+ toPhase,
339
+ fromTask,
340
+ toTask: activeTaskKey(dir),
341
+ contextRatio: contextRatio(ctx),
342
+ });
343
+ if (!decision.handoff) return false;
344
+
345
+ requestHandoff(dir, {
346
+ reason: decision.reason,
347
+ detail: decision.detail,
348
+ kickoff: composeKickoff(brief, decision.reason, decision.detail, carryNote(dir)),
349
+ carry: carryNote(dir),
350
+ runId: runFor(dir),
351
+ });
352
+ handingOff = true;
353
+ // The replacement session announces itself on arrival; saying it twice
354
+ // here would just make the log look like two handoffs happened.
355
+ pi.sendUserMessage("/infinity:handoff", {
356
+ deliverAs: "followUp",
357
+ expandPromptTemplates: true,
358
+ });
359
+ return true;
360
+ } catch (e) {
361
+ // A handoff that cannot be arranged must never end the run. Fall back to
362
+ // continuing in this session, which is exactly the old behaviour.
363
+ notify(ctx, `infinity-harness: staying in this session — ${errMsg(e)}`, "warning");
364
+ handingOff = false;
365
+ clearHandoff(dir);
366
+ return false;
367
+ }
368
+ };
369
+
370
+ /** One line on where the run stands, carried into the next session. */
371
+ const carryNote = (dir: string): string | null => {
372
+ try {
373
+ const { config } = loadConfig(dir);
374
+ if (config.session?.carryNotes === false) return null;
375
+ const { list } = loadFeatureList(dir);
376
+ const p = computeProgress(list);
377
+ const recent = (config.gateHistory ?? []).slice(-3).map((g) => `${g.phase}:${g.result}`);
378
+ return (
379
+ ` ${p.tasksDone}/${p.tasksTotal} tasks done, ${p.featuresDone}/${p.featuresTotal} features` +
380
+ (recent.length ? `; recent gates ${recent.join(", ")}` : "")
381
+ );
382
+ } catch {
383
+ return null;
384
+ }
385
+ };
386
+
387
+ // -- approvals ------------------------------------------------------------
388
+
389
+ /**
390
+ * Collect the human's signature on a phase.
391
+ *
392
+ * With dialogs, ask straight away — the human is right there and the run is
393
+ * stopped for them. Without dialogs there is nobody to ask, so the run parks
394
+ * and says loudly what it is waiting for, because auto-approving a phase the
395
+ * human explicitly asked to sign would make the setting a lie.
396
+ */
397
+ const askForApproval = async (ctx: ExtensionContext, dir: string, phase: Phase): Promise<void> => {
398
+ if (!ctx.hasUI) {
399
+ disarmRun(dir, `${phase} is waiting for approval`);
400
+ notify(
401
+ ctx,
402
+ `infinity-harness: ${phase.toUpperCase()} needs your approval and this mode has no dialogs. ` +
403
+ `Run \`/infinity:approve\` (optionally with what is wrong) to continue.`,
404
+ "warning",
405
+ );
406
+ return;
407
+ }
408
+
409
+ const APPROVE = "approve — continue the run";
410
+ const REJECT = "send it back — I will say what is wrong";
411
+ const LATER = "not now — park the run";
412
+ const choice = await ctx.ui.select(`${phase.toUpperCase()} is waiting for you`, [APPROVE, REJECT, LATER]);
413
+
414
+ if (choice === REJECT) {
415
+ const note = await ctx.ui.input("What needs to change?", "the criteria do not cover refunds");
416
+ await applyApproval(ctx, dir, note ?? "");
417
+ return;
418
+ }
419
+ if (choice === APPROVE) {
420
+ await applyApproval(ctx, dir, "");
421
+ return;
422
+ }
423
+ disarmRun(dir, `${phase} is waiting for approval`);
424
+ notify(ctx, `infinity-harness: parked. \`/infinity:approve\` continues.`, "info");
425
+ refreshWidget(ctx);
426
+ };
427
+
428
+ /** Record the verdict and get the run moving again. */
429
+ const applyApproval = async (ctx: ExtensionContext, dir: string, note: string): Promise<void> => {
430
+ // Pin a rejection to the project as it is right now, so the run knows
431
+ // whether the agent has actually done anything about it before asking the
432
+ // human the same question again.
433
+ const outcome = resolveApproval(dir, note, note.trim() ? await fingerprint(dir) : "");
434
+ if (!outcome.ok) {
435
+ notify(ctx, `infinity-harness: ${outcome.error}`, "warning");
436
+ return;
437
+ }
438
+ refreshWidget(ctx);
439
+
440
+ if (outcome.approved) {
441
+ notify(ctx, `infinity-harness: ${outcome.phase.toUpperCase()} approved.`, "info");
442
+ // The gate already passed; re-settling lets the loop advance normally.
443
+ const moved = await advancePhase(dir);
444
+ refreshWidget(ctx);
445
+ if (!moved.ok) {
446
+ notify(ctx, `infinity-harness: could not advance — ${moved.error}`, "error");
447
+ return;
448
+ }
449
+ const brief = await briefText(dir);
450
+ lastBriefPhase = moved.to;
451
+ if (loopArmed(dir) && (await maybeHandOff(ctx, dir, brief, outcome.phase, moved.to, null))) return;
452
+ pi.sendUserMessage(brief, { deliverAs: "followUp" });
453
+ return;
454
+ }
455
+
456
+ notify(
457
+ ctx,
458
+ `infinity-harness: ${outcome.phase.toUpperCase()} sent back — ${outcome.note}`,
459
+ "warning",
460
+ );
461
+ pi.sendUserMessage(
462
+ `A human reviewed ${outcome.phase.toUpperCase()} and sent it back:\n\n${outcome.note}\n\n` +
463
+ `Address that, then validate again.\n\n${await briefText(dir)}`,
464
+ { deliverAs: "followUp" },
465
+ );
466
+ };
467
+
191
468
  // -- lifecycle ------------------------------------------------------------
192
469
 
193
- pi.on("session_start", async (_event, ctx) => {
470
+ pi.on("session_start", async (event, ctx) => {
194
471
  const dir = projectDir(ctx);
195
472
  if (!isHarnessProject(dir)) return;
196
473
 
474
+ view = defaultView();
197
475
  refreshWidget(ctx);
198
476
  const { config } = loadConfig(dir);
199
477
  lastBriefPhase = config.currentPhase;
200
478
 
201
- notify(ctx, `infinity-harness active · ${config.currentPhase ?? "not started"}`, "info");
479
+ const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
480
+ const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
481
+ const armed = run?.armed === true;
482
+
483
+ notify(
484
+ ctx,
485
+ `infinity-harness active · ${config.currentPhase ?? "not started"}` +
486
+ (armed ? ` · run continuing (session ${run?.sessions ?? 1})` : ""),
487
+ "info",
488
+ );
202
489
  try {
203
- pi.appendEntry("infinity:session", { runId, dir, phase: config.currentPhase });
490
+ pi.appendEntry("infinity:session", {
491
+ sessionId,
492
+ runId: runFor(dir),
493
+ reason,
494
+ dir,
495
+ phase: config.currentPhase,
496
+ });
204
497
  } catch {
205
498
  /* entry log is best-effort */
206
499
  }
207
500
 
501
+ // A handoff written by the session this one replaces. It carries the brief
502
+ // plus the reason the previous session ended, so the agent does not spend
503
+ // its first turn working out why it woke up mid-run.
504
+ const pending = takeHandoff(dir);
505
+ if (pending && pending.runId === runFor(dir)) {
506
+ try {
507
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
508
+ notify(ctx, `infinity-harness: ${describeHandoff(pending)}`, "info");
509
+ } catch (e) {
510
+ notify(ctx, `infinity-harness: handoff failed — ${errMsg(e)}`, "error");
511
+ }
512
+ return;
513
+ }
514
+
208
515
  // The brief is delivered as a message rather than a notification so the
209
516
  // model actually reads it. Without this the agent starts from whatever
210
517
  // the user typed and ignores the pipeline entirely.
518
+ //
519
+ // `nextTurn` is right in a terminal, where a human is about to type. It is
520
+ // a deadlock in `pi -p`, which has no next turn and waits forever for one:
521
+ // the harness made every headless run hang on startup. Non-interactive
522
+ // modes get `steer`, which folds the brief into the turn already starting.
211
523
  try {
212
524
  const text = await briefText(dir);
525
+ const interactive = ctx.mode === "tui" || ctx.mode === "rpc";
213
526
  pi.sendMessage(
214
527
  { customType: "infinity:brief", content: text, display: true, details: { phase: config.currentPhase } },
215
- { triggerTurn: false, deliverAs: "nextTurn" },
528
+ { triggerTurn: false, deliverAs: interactive ? "nextTurn" : "steer" },
216
529
  );
217
530
  } catch (e) {
218
531
  notify(ctx, `infinity-harness: could not build brief — ${errMsg(e)}`, "warning");
219
532
  }
220
533
  });
221
534
 
535
+ /**
536
+ * The harness contract, in the system prompt.
537
+ *
538
+ * Everything else the harness tells the model is a message in the
539
+ * transcript, and every message in the transcript is something compaction
540
+ * can summarise into "the assistant was working on a harness". That is how a
541
+ * long run loses the plot: not by forgetting the plan — the plan is on disk
542
+ * — but by forgetting that it is *supposed to* work from the plan, stop when
543
+ * a gate fails, and never mark its own work complete.
544
+ *
545
+ * The system prompt is rebuilt from scratch every turn and is never
546
+ * summarised. Anything the run cannot afford to forget belongs here.
547
+ */
548
+ pi.on("before_agent_start", async (event, ctx) => {
549
+ if (!sessionLive) return;
550
+ const dir = projectDir(ctx);
551
+ if (!isHarnessProject(dir)) return;
552
+ try {
553
+ const contract = harnessContract(dir);
554
+ if (!contract) return;
555
+ const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
556
+ return { systemPrompt: `${base}\n\n${contract}` };
557
+ } catch {
558
+ return;
559
+ }
560
+ });
561
+
222
562
  pi.on("session_tree", async (_event, ctx) => {
563
+ if (!sessionLive) return;
223
564
  refreshWidget(ctx);
224
565
  });
225
566
 
@@ -229,6 +570,7 @@ export default function (pi: ExtensionAPI): void {
229
570
  * few calls costs little and keeps the plan honest.
230
571
  */
231
572
  pi.on("context", async (event, ctx) => {
573
+ if (!sessionLive) return;
232
574
  const dir = projectDir(ctx);
233
575
  if (!isHarnessProject(dir)) return;
234
576
 
@@ -280,28 +622,53 @@ export default function (pi: ExtensionAPI): void {
280
622
  });
281
623
 
282
624
  /**
283
- * Compaction drops the transcript. The plan lives on disk so it survives,
284
- * but the model's *awareness* of it does not — so we re-state it afterwards.
625
+ * Compaction drops the transcript.
626
+ *
627
+ * The plan survives — it is on disk — and since 2.3 so do the rules, because
628
+ * they live in the system prompt (`before_agent_start`) where no summariser
629
+ * can reach them. What is left to restore is the *current* brief, so the
630
+ * agent picks up on the same task rather than re-deriving one from a summary
631
+ * of a summary.
285
632
  */
286
633
  pi.on("session_before_compact", async (_event, ctx) => {
287
634
  const dir = projectDir(ctx);
288
635
  if (!isHarnessProject(dir)) return;
289
636
  try {
290
637
  const { list } = loadFeatureList(dir);
291
- pi.appendEntry(CHECKPOINT, { revision: list.baseRevision, at: new Date().toISOString() });
638
+ const { config } = loadConfig(dir);
639
+ pi.appendEntry(CHECKPOINT, {
640
+ revision: list.baseRevision,
641
+ phase: config.currentPhase,
642
+ runId: runFor(dir),
643
+ at: new Date().toISOString(),
644
+ });
292
645
  } catch {
293
646
  /* checkpoint is advisory */
294
647
  }
295
648
  });
296
649
 
297
- pi.on("session_compact", async (_event, ctx) => {
650
+ pi.on("session_compact", async (event, ctx) => {
651
+ if (!sessionLive) return;
298
652
  const dir = projectDir(ctx);
299
653
  if (!isHarnessProject(dir)) return;
300
654
  try {
301
655
  const text = await briefText(dir);
656
+
657
+ // Delivery mode is the whole bug here. `nextTurn` waits for a human to
658
+ // type, which never happens in an unattended run — so the re-brief that
659
+ // was supposed to rescue the agent after compaction sat in a queue while
660
+ // the agent carried on without it. Overflow compaction retries the
661
+ // aborted turn immediately, so the brief has to land *in* that turn.
662
+ const willRetry = (event as { willRetry?: boolean } | undefined)?.willRetry === true;
663
+ const running = willRetry || !ctx.isIdle?.();
302
664
  pi.sendMessage(
303
- { customType: "infinity:brief", content: text, display: false, details: { after: "compaction" } },
304
- { triggerTurn: false, deliverAs: "nextTurn" },
665
+ {
666
+ customType: "infinity:brief",
667
+ content: text,
668
+ display: false,
669
+ details: { after: "compaction", reason: (event as { reason?: string })?.reason ?? null },
670
+ },
671
+ { triggerTurn: false, deliverAs: running ? "steer" : "nextTurn" },
305
672
  );
306
673
  refreshWidget(ctx);
307
674
  } catch {
@@ -310,39 +677,71 @@ export default function (pi: ExtensionAPI): void {
310
677
  });
311
678
 
312
679
  pi.on("turn_end", async (_event, ctx) => {
680
+ if (!sessionLive) return;
313
681
  refreshWidget(ctx);
314
682
  });
315
683
 
316
684
  /**
317
685
  * The loop. `agent_settled` fires when the agent has stopped working, which
318
686
  * is the only safe moment to run the gate and decide what happens next.
687
+ *
688
+ * The run's armed flag is read from disk on every tick rather than held in a
689
+ * closure, so a run survives the session handoffs it now performs, plus
690
+ * `/reload`, `/resume`, and pi being restarted.
319
691
  */
320
692
  pi.on("agent_settled", async (_event, ctx) => {
693
+ if (!sessionLive) return;
321
694
  const dir = projectDir(ctx);
322
695
  if (!isHarnessProject(dir)) return;
323
- if (!loopEnabled || loopBusy) return;
696
+ if (loopBusy || handingOff) return;
697
+ if (!loopArmed(dir)) return;
324
698
 
325
699
  loopBusy = true;
326
700
  try {
327
- const { decision } = await decideNext({ targetDir: dir, runId });
701
+ const before = loadConfig(dir).config;
702
+ const beforeTask = activeTaskKey(dir);
703
+ const { decision } = await decideNext({ targetDir: dir, runId: runFor(dir) });
328
704
  refreshWidget(ctx);
329
705
 
330
706
  switch (decision.action) {
331
- case "advanced":
707
+ case "advanced": {
332
708
  notify(ctx, `infinity-harness: gate passed → ${decision.toPhase}`, "info");
333
709
  lastBriefPhase = decision.toPhase;
710
+ if (await maybeHandOff(ctx, dir, decision.message, before.currentPhase, decision.toPhase, beforeTask)) {
711
+ break;
712
+ }
713
+ pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
714
+ break;
715
+ }
716
+ case "continue": {
717
+ // Say *why* it is going round again. "gate failed" was printed even
718
+ // when the gate had passed and the run was waiting on a rejection
719
+ // the agent had not acted on, which reads as a different bug.
720
+ notify(ctx, `infinity-harness: ${decision.reason} — re-briefing`, "warning");
721
+ // A failed gate on the same phase is normally the same session's
722
+ // problem to fix. The exception is context pressure: carrying on in
723
+ // a session that is about to compact is how a run degrades into
724
+ // summaries of summaries.
725
+ if (await maybeHandOff(ctx, dir, decision.message, before.currentPhase, before.currentPhase, beforeTask)) {
726
+ break;
727
+ }
334
728
  pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
335
729
  break;
336
- case "continue":
337
- notify(ctx, `infinity-harness: gate failed — re-briefing`, "warning");
730
+ }
731
+ case "approve": {
732
+ // Not a stop. The run is parked on a human, and the widget, the
733
+ // status line and the notification all say so.
734
+ notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
338
735
  pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
736
+ await askForApproval(ctx, dir, decision.phase);
339
737
  break;
738
+ }
340
739
  case "wait":
341
- loopEnabled = false;
740
+ disarmRun(dir, decision.detail);
342
741
  notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
343
742
  break;
344
743
  case "stop":
345
- loopEnabled = false;
744
+ disarmRun(dir, decision.detail);
346
745
  notify(
347
746
  ctx,
348
747
  `infinity-harness: run finished — ${decision.detail}`,
@@ -355,8 +754,9 @@ export default function (pi: ExtensionAPI): void {
355
754
  }
356
755
  break;
357
756
  }
757
+ refreshWidget(ctx);
358
758
  } catch (e) {
359
- loopEnabled = false;
759
+ disarmRun(dir, `loop error: ${errMsg(e)}`);
360
760
  notify(ctx, `infinity-harness: loop error, stopping — ${errMsg(e)}`, "error");
361
761
  } finally {
362
762
  loopBusy = false;
@@ -370,6 +770,7 @@ export default function (pi: ExtensionAPI): void {
370
770
  * touch of the config would stop the harness configuring itself.
371
771
  */
372
772
  pi.on("tool_call", async (event, ctx) => {
773
+ if (!sessionLive) return;
373
774
  const dir = projectDir(ctx);
374
775
  if (!isHarnessProject(dir)) return;
375
776
 
@@ -412,6 +813,7 @@ export default function (pi: ExtensionAPI): void {
412
813
  });
413
814
 
414
815
  pi.on("session_shutdown", async () => {
816
+ sessionLive = false;
415
817
  if (remoteServer) {
416
818
  try {
417
819
  await remoteServer.close();
@@ -421,7 +823,6 @@ export default function (pi: ExtensionAPI): void {
421
823
  remoteServer = null;
422
824
  remoteDir = null;
423
825
  }
424
- loopEnabled = false;
425
826
  loopBusy = false;
426
827
  });
427
828
 
@@ -784,7 +1185,19 @@ export default function (pi: ExtensionAPI): void {
784
1185
  const DEFAULT_LADDER = ["retry", "reframe", "consult", "rework", "replan", "master"];
785
1186
 
786
1187
  /** Everything the pipeline can run. INIT is not a phase you choose. */
787
- const SELECTABLE_PHASES: Phase[] = ["define", "plan", "build", "verify", "simplify", "review", "ship"];
1188
+ // Everything except INIT, which is not a phase anyone chooses. RESEARCH is
1189
+ // here because it is a real, optional phase — omitting it from the picker
1190
+ // was the difference between a feature and a feature nobody can find.
1191
+ const SELECTABLE_PHASES: Phase[] = [
1192
+ "research",
1193
+ "define",
1194
+ "plan",
1195
+ "build",
1196
+ "verify",
1197
+ "simplify",
1198
+ "review",
1199
+ "ship",
1200
+ ];
788
1201
 
789
1202
  pi.registerTool({
790
1203
  name: "infinity_init",
@@ -833,10 +1246,11 @@ export default function (pi: ExtensionAPI): void {
833
1246
  });
834
1247
 
835
1248
  pi.registerCommand("infinity:init", {
836
- description: "Create a harness in this project",
1249
+ description: "Set up a harness here mode, goal, research, approvals, sessions",
837
1250
  handler: async (args: string, ctx: ExtensionContext) => {
838
1251
  const dir = projectDir(ctx);
839
1252
  const force = /\bforce\b/.test(args);
1253
+ const goalFromArgs = args.replace(/\bforce\b/g, "").trim();
840
1254
 
841
1255
  if (isHarnessProject(dir) && !force) {
842
1256
  notify(
@@ -848,28 +1262,34 @@ export default function (pi: ExtensionAPI): void {
848
1262
  }
849
1263
 
850
1264
  const detected = detectStack(dir);
851
- let mode: "copilot" | "autopilot" = "copilot";
852
1265
  let phases: Phase[] | undefined;
853
1266
 
854
- // With dialogs, ask the two questions whose answers we cannot infer.
855
- // Without them, take the detected defaults and say so — an unattended
856
- // run must not stall on a prompt nobody will answer.
1267
+ // Two things used to be wrong here, and they compounded.
1268
+ //
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".
1273
+ //
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.
1277
+ //
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.
857
1282
  if (ctx.hasUI) {
858
1283
  const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
859
1284
  const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
860
1285
  const go = await ctx.ui.select(
861
1286
  `Create a harness here? ${detected.label} · ${summary}`,
862
- ["yes, use these defaults", "yes, but let me choose the phases", "cancel"],
1287
+ ["yes", "yes, and let me choose the phases", "cancel"],
863
1288
  );
864
1289
  if (go === undefined || go === "cancel") {
865
1290
  notify(ctx, "init cancelled — nothing was written.", "info");
866
1291
  return;
867
1292
  }
868
- const picked = await ctx.ui.select("How should it run?", [
869
- "copilot — you stay in the loop",
870
- "autopilot — it drives itself",
871
- ]);
872
- if (picked?.startsWith("autopilot")) mode = "autopilot";
873
1293
 
874
1294
  if (go.includes("phases")) {
875
1295
  const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
@@ -886,22 +1306,154 @@ export default function (pi: ExtensionAPI): void {
886
1306
  }
887
1307
  }
888
1308
 
889
- const result = initHarness(dir, { mode, phases, force });
1309
+ 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
+
1317
+ if (wizard.cancelled) {
1318
+ notify(ctx, "init cancelled — nothing was written.", "info");
1319
+ return;
1320
+ }
1321
+ const plan = wizard.plan;
1322
+
1323
+ const result = initHarness(dir, {
1324
+ mode: plan.mode,
1325
+ phases: plan.phases,
1326
+ approvals: plan.approvals,
1327
+ session: plan.session,
1328
+ brief: plan.brief,
1329
+ force,
1330
+ });
890
1331
  if (!result.ok) {
891
1332
  notify(ctx, result.error ?? "init failed", "error");
892
1333
  return;
893
1334
  }
894
1335
 
895
- notify(ctx, describeInit(result), "info");
1336
+ // The wizard already showed the summary before the human confirmed it;
1337
+ // repeating it verbatim here is noise. Warnings do repeat — they are the
1338
+ // part worth seeing twice.
1339
+ const lines = [describeInit(result)];
1340
+ if (plan.warnings.length) lines.push("", ...plan.warnings.map((w) => `! ${w}`));
1341
+ notify(ctx, lines.join("\n"), plan.warnings.length ? "warning" : "info");
896
1342
  refreshWidget(ctx);
1343
+
897
1344
  // Hand the model the brief straight away, so the session that created
898
- // the harness is also the session that starts using it.
899
- pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
1345
+ // the harness is also the session that starts using it. Without a goal
1346
+ // the first thing it must do is ask for one — never guess one.
1347
+ const brief = await briefText(dir);
1348
+ const opener = plan.brief
1349
+ ? brief
1350
+ : `The human has not said what they want built yet. Ask them, in one short question, ` +
1351
+ `and do not start any work or invent a scope until they answer.\n\n${brief}`;
1352
+ pi.sendUserMessage(opener, { deliverAs: "followUp" });
900
1353
  },
901
1354
  });
902
1355
 
1356
+ /**
1357
+ * Continue the run in a replacement session.
1358
+ *
1359
+ * This is a command rather than something the loop does directly because
1360
+ * `ctx.newSession` is only safe from a command handler — pi deadlocks if an
1361
+ * event handler calls it. The loop queues `/infinity:handoff` as a follow-up
1362
+ * and this does the switch.
1363
+ *
1364
+ * Everything the next session needs is already on disk. `withSession` may
1365
+ * only touch the context it is handed: the old `pi` and `ctx` are dead by
1366
+ * the time it runs.
1367
+ */
1368
+ pi.registerCommand("infinity:handoff", {
1369
+ description: "Continue this run in a fresh session, carrying the brief",
1370
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
1371
+ const dir = projectDir(ctx);
1372
+ if (!isHarnessProject(dir)) {
1373
+ notify(ctx, NO_HARNESS, "warning");
1374
+ return;
1375
+ }
903
1376
 
904
- // -- escalation, rework, replan --------------------------------------------
1377
+ // Asked for by hand, with no handoff queued: make one.
1378
+ if (!hasPendingHandoff(dir)) {
1379
+ const brief = await briefText(dir);
1380
+ requestHandoff(dir, {
1381
+ reason: "manual",
1382
+ detail: args.trim() || "requested by hand",
1383
+ kickoff: composeKickoff(brief, "manual", args.trim() || "requested by hand", carryNote(dir)),
1384
+ carry: carryNote(dir),
1385
+ runId: runFor(dir),
1386
+ });
1387
+ }
1388
+
1389
+ const pending = takeHandoff(dir);
1390
+ if (!pending) {
1391
+ notify(ctx, "infinity-harness: nothing to hand off.", "warning");
1392
+ return;
1393
+ }
1394
+
1395
+ // The replacement session reads this back from disk in `session_start`;
1396
+ // put it back so the claim above does not consume it.
1397
+ requestHandoff(dir, {
1398
+ reason: pending.reason,
1399
+ detail: pending.detail,
1400
+ kickoff: pending.kickoff,
1401
+ carry: pending.carry,
1402
+ runId: pending.runId,
1403
+ });
1404
+
1405
+ handingOff = false;
1406
+ try {
1407
+ await ctx.waitForIdle?.();
1408
+ const parent = ctx.sessionManager?.getSessionFile?.() ?? undefined;
1409
+ const result = await ctx.newSession({ parentSession: parent ?? undefined });
1410
+ if (result?.cancelled) {
1411
+ clearHandoff(dir);
1412
+ notify(ctx, "infinity-harness: handoff cancelled — continuing here.", "warning");
1413
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
1414
+ }
1415
+ } catch (e) {
1416
+ // A handoff that cannot happen must never end the run: fall back to
1417
+ // carrying on in this session, which is the pre-2.3 behaviour.
1418
+ //
1419
+ // Unless the session is already gone — `newSession` can fail *after*
1420
+ // replacing the runtime, and reaching for the old `pi` then is the
1421
+ // "stale ctx" error rather than a recovery.
1422
+ clearHandoff(dir);
1423
+ if (!sessionLive) return;
1424
+ notify(ctx, `infinity-harness: could not start a new session — ${errMsg(e)}`, "warning");
1425
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
1426
+ }
1427
+ },
1428
+ });
1429
+
1430
+ pi.registerCommand("infinity:approve", {
1431
+ description: "Approve the phase waiting for you — or send it back with a note",
1432
+ handler: async (args: string, ctx: ExtensionContext) => {
1433
+ const dir = projectDir(ctx);
1434
+ if (!isHarnessProject(dir)) {
1435
+ notify(ctx, NO_HARNESS, "warning");
1436
+ return;
1437
+ }
1438
+ const { config } = loadConfig(dir);
1439
+ if (!config.awaitingApproval) {
1440
+ const signing = approvedPhases(config);
1441
+ notify(
1442
+ ctx,
1443
+ signing.length
1444
+ ? `Nothing is waiting. You are signing: ${signing.map((p) => p.toUpperCase()).join(", ")}.`
1445
+ : "Nothing is waiting, and you are not signing any phase. /infinity:config changes that.",
1446
+ "info",
1447
+ );
1448
+ return;
1449
+ }
1450
+ // Approving re-arms the run: the human answering is them saying carry on.
1451
+ if (!loopArmed(dir)) armRun(dir, sessionId);
1452
+ await applyApproval(ctx, dir, args.trim());
1453
+ },
1454
+ });
1455
+
1456
+ // -- escalation, rework, replan -------------------------------------------- // -- escalation, rework, replan --------------------------------------------
905
1457
 
906
1458
  pi.registerTool({
907
1459
  name: "infinity_rework",
@@ -945,7 +1497,7 @@ export default function (pi: ExtensionAPI): void {
945
1497
  taskId: target.id,
946
1498
  key: target.key,
947
1499
  reason: params.reason ?? "rework requested",
948
- runId,
1500
+ runId: runFor(dir),
949
1501
  maxImpactDepth: params.maxImpactDepth,
950
1502
  });
951
1503
  refreshWidget(ctx as ExtensionContext);
@@ -1134,7 +1686,7 @@ export default function (pi: ExtensionAPI): void {
1134
1686
  try {
1135
1687
  const result = await spawnIsolatedWorker({
1136
1688
  projectDir: dir,
1137
- runId,
1689
+ runId: runFor(dir),
1138
1690
  featureId: target.featureId,
1139
1691
  taskId: target.id,
1140
1692
  prompt: params.prompt,
@@ -1223,7 +1775,7 @@ export default function (pi: ExtensionAPI): void {
1223
1775
  const { state } = await startGoal({
1224
1776
  targetDir: dir,
1225
1777
  goal: params.goal,
1226
- runId: `goal-${runId}`,
1778
+ runId: `goal-${runFor(dir)}`,
1227
1779
  maxIterations: params.maxIterations,
1228
1780
  });
1229
1781
  refreshWidget(ctx as ExtensionContext);
@@ -1275,8 +1827,31 @@ export default function (pi: ExtensionAPI): void {
1275
1827
  });
1276
1828
  refreshWidget(ctx as ExtensionContext);
1277
1829
  if (!outcome.terminal) {
1278
- // Rewinding the pipeline means the next brief is a different one.
1279
- pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
1830
+ // A new goal pass is the largest context boundary there is: the
1831
+ // pipeline has rewound to its first phase and the next pass plans
1832
+ // for what is left, not for what the last one already built.
1833
+ // Carrying a whole finished pass of conversation into it is the
1834
+ // worst case of the problem session handoff exists to solve.
1835
+ const brief = await briefText(dir);
1836
+ const { config } = loadConfig(dir);
1837
+ if (config.session?.handoff !== "off" && loopArmed(dir)) {
1838
+ const detail = `goal pass ${config.goalPass ?? "next"}`;
1839
+ requestHandoff(dir, {
1840
+ reason: "goal-pass",
1841
+ detail,
1842
+ kickoff: composeKickoff(brief, "goal-pass", detail, carryNote(dir)),
1843
+ carry: carryNote(dir),
1844
+ runId: runFor(dir),
1845
+ });
1846
+ handingOff = true;
1847
+ pi.sendUserMessage("/infinity:handoff", {
1848
+ deliverAs: "followUp",
1849
+ expandPromptTemplates: true,
1850
+ });
1851
+ } else {
1852
+ // Rewinding the pipeline means the next brief is a different one.
1853
+ pi.sendUserMessage(brief, { deliverAs: "followUp" });
1854
+ }
1280
1855
  }
1281
1856
  return { content: [{ type: "text", text: outcome.message }], details: viewOf(outcome.state) };
1282
1857
  }
@@ -1327,6 +1902,12 @@ export default function (pi: ExtensionAPI): void {
1327
1902
  description: "Print the current brief",
1328
1903
  handler: async (_args: string, ctx: ExtensionContext) => {
1329
1904
  const dir = projectDir(ctx);
1905
+ // Without this guard it printed a brief for a project with no harness —
1906
+ // a page of pipeline instructions for a pipeline that does not exist.
1907
+ if (!isHarnessProject(dir)) {
1908
+ notify(ctx, NO_HARNESS, "warning");
1909
+ return;
1910
+ }
1330
1911
  notify(ctx, await briefText(dir), "info");
1331
1912
  },
1332
1913
  });
@@ -1335,6 +1916,10 @@ export default function (pi: ExtensionAPI): void {
1335
1916
  description: "Run the gate for the current phase",
1336
1917
  handler: async (_args: string, ctx: ExtensionContext) => {
1337
1918
  const dir = projectDir(ctx);
1919
+ if (!isHarnessProject(dir)) {
1920
+ notify(ctx, NO_HARNESS, "warning");
1921
+ return;
1922
+ }
1338
1923
  const { config } = loadConfig(dir);
1339
1924
  if (!config.currentPhase) {
1340
1925
  notify(ctx, "No current phase.", "warning");
@@ -1355,7 +1940,7 @@ export default function (pi: ExtensionAPI): void {
1355
1940
  notify(ctx, NO_HARNESS, "warning");
1356
1941
  return;
1357
1942
  }
1358
- loopEnabled = true;
1943
+ armRun(dir, sessionId);
1359
1944
  notify(
1360
1945
  ctx,
1361
1946
  `infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
@@ -1399,7 +1984,7 @@ export default function (pi: ExtensionAPI): void {
1399
1984
  }
1400
1985
 
1401
1986
  try {
1402
- const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runId}` });
1987
+ const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runFor(dir)}` });
1403
1988
  refreshWidget(ctx);
1404
1989
  notify(
1405
1990
  ctx,
@@ -1484,7 +2069,7 @@ export default function (pi: ExtensionAPI): void {
1484
2069
  taskId: target.id,
1485
2070
  key: target.key,
1486
2071
  reason: "rework from /infinity:rework",
1487
- runId,
2072
+ runId: runFor(dir),
1488
2073
  });
1489
2074
  refreshWidget(ctx);
1490
2075
  notify(
@@ -1504,8 +2089,15 @@ export default function (pi: ExtensionAPI): void {
1504
2089
  pi.registerCommand("infinity:halt", {
1505
2090
  description: "Stop the continuous loop after the current turn",
1506
2091
  handler: async (_args: string, ctx: ExtensionContext) => {
1507
- loopEnabled = false;
2092
+ const dir = projectDir(ctx);
2093
+ if (!isHarnessProject(dir)) {
2094
+ notify(ctx, NO_HARNESS, "warning");
2095
+ return;
2096
+ }
2097
+ disarmRun(dir, "halted from /infinity:halt");
2098
+ clearHandoff(dir);
1508
2099
  notify(ctx, "infinity-harness: continuous run stopped.", "info");
2100
+ refreshWidget(ctx);
1509
2101
  },
1510
2102
  });
1511
2103
 
@@ -1513,13 +2105,17 @@ export default function (pi: ExtensionAPI): void {
1513
2105
  description: "Pause the pipeline (persisted in harness/config.json)",
1514
2106
  handler: async (_args: string, ctx: ExtensionContext) => {
1515
2107
  const dir = projectDir(ctx);
2108
+ if (!isHarnessProject(dir)) {
2109
+ notify(ctx, NO_HARNESS, "warning");
2110
+ return;
2111
+ }
1516
2112
  const { value } = await withLock(configPath(dir), () => {
1517
2113
  const { config, ok } = loadConfig(dir);
1518
2114
  if (!ok) return false;
1519
2115
  config.paused = true;
1520
2116
  return saveConfig(dir, config).ok;
1521
2117
  });
1522
- loopEnabled = false;
2118
+ disarmRun(dir, "paused from /infinity:pause");
1523
2119
  notify(ctx, value ? "infinity-harness: paused." : "Could not pause — config unreadable.", value ? "info" : "error");
1524
2120
  refreshWidget(ctx);
1525
2121
  },
@@ -1529,6 +2125,10 @@ export default function (pi: ExtensionAPI): void {
1529
2125
  description: "Unpause the pipeline",
1530
2126
  handler: async (_args: string, ctx: ExtensionContext) => {
1531
2127
  const dir = projectDir(ctx);
2128
+ if (!isHarnessProject(dir)) {
2129
+ notify(ctx, NO_HARNESS, "warning");
2130
+ return;
2131
+ }
1532
2132
  const { value } = await withLock(configPath(dir), () => {
1533
2133
  const { config, ok } = loadConfig(dir);
1534
2134
  if (!ok) return false;
@@ -1601,10 +2201,83 @@ export default function (pi: ExtensionAPI): void {
1601
2201
  },
1602
2202
  });
1603
2203
 
2204
+ // -- keys -----------------------------------------------------------------
2205
+ //
2206
+ // The widget is nine rows of a plan that is routinely sixty. Without these
2207
+ // the other fifty-one are unreachable without opening the dashboard, which
2208
+ // is not a thing anyone does mid-glance.
2209
+ //
2210
+ // `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
2211
+ // to line end) and ctrl+o (expand tool output). Shadowing an editor key to
2212
+ // scroll a widget would be a worse bug than the one being fixed.
2213
+
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
+ });
2231
+
2232
+ pi.registerCommand("infinity:scroll", {
2233
+ description: "Move the plan widget — up, down, top, bottom, expand, follow",
2234
+ handler: async (args: string, ctx: ExtensionContext) => {
2235
+ const dir = projectDir(ctx);
2236
+ if (!isHarnessProject(dir)) {
2237
+ notify(ctx, NO_HARNESS, "warning");
2238
+ return;
2239
+ }
2240
+ const what = args.trim().toLowerCase() || "down";
2241
+ const rows = planRowCount(dir);
2242
+ switch (what) {
2243
+ case "up":
2244
+ moveView(ctx, -SCROLL_STEP);
2245
+ return;
2246
+ case "down":
2247
+ moveView(ctx, SCROLL_STEP);
2248
+ return;
2249
+ case "top":
2250
+ view = { ...view, scroll: 0 };
2251
+ break;
2252
+ case "bottom":
2253
+ view = { ...view, scroll: rows };
2254
+ break;
2255
+ case "expand":
2256
+ view = { ...view, expanded: true };
2257
+ break;
2258
+ case "collapse":
2259
+ view = { ...view, expanded: false };
2260
+ break;
2261
+ case "follow":
2262
+ // Back to tracking the active task, which is where it starts.
2263
+ view = defaultView();
2264
+ break;
2265
+ default:
2266
+ notify(ctx, "Use: up · down · top · bottom · expand · collapse · follow", "warning");
2267
+ return;
2268
+ }
2269
+ refreshWidget(ctx);
2270
+ },
2271
+ });
2272
+
1604
2273
  pi.registerCommand("infinity:dashboard", {
1605
2274
  description: "Open the read-only web dashboard for this run",
1606
2275
  handler: async (_args: string, ctx: ExtensionContext) => {
1607
2276
  const dir = projectDir(ctx);
2277
+ if (!isHarnessProject(dir)) {
2278
+ notify(ctx, NO_HARNESS, "warning");
2279
+ return;
2280
+ }
1608
2281
  const remote = await import("../../src/remote.ts");
1609
2282
  if (remoteServer) {
1610
2283
  notify(ctx, `Dashboard already live at ${remoteServer.url}`, "info");
@@ -1622,6 +2295,52 @@ function errMsg(e: unknown): string {
1622
2295
  return e instanceof Error ? e.message : String(e);
1623
2296
  }
1624
2297
 
2298
+ /**
2299
+ * The few sentences the run cannot afford to have summarised away.
2300
+ *
2301
+ * Short on purpose: this is paid for on every single request, and a long
2302
+ * system prompt crowds out the work. It carries only what stops the agent
2303
+ * going freelance after a compaction — where the pipeline is, what it is
2304
+ * working on, and the three rules that make the harness a harness.
2305
+ */
2306
+ function harnessContract(dir: string): string | null {
2307
+ const { config, ok } = loadConfig(dir);
2308
+ if (!ok || !config.currentPhase) return null;
2309
+
2310
+ const { list } = loadFeatureList(dir);
2311
+ const progress = computeProgress(list);
2312
+ const phase = config.currentPhase.toUpperCase();
2313
+ const role = config.currentRole ?? "";
2314
+
2315
+ const L: string[] = [];
2316
+ L.push("## infinity-harness");
2317
+ L.push("");
2318
+ L.push(
2319
+ `This project is driven by the infinity-harness pipeline. You are at **${phase}**` +
2320
+ (role ? ` wearing the ${role} hat` : "") +
2321
+ `, with ${progress.tasksDone}/${progress.tasksTotal} tasks done across ` +
2322
+ `${progress.featuresTotal} feature(s). Plan revision ${list.baseRevision}.`,
2323
+ );
2324
+ L.push("");
2325
+ L.push("Rules that do not change, whatever the conversation above says:");
2326
+ L.push("");
2327
+ L.push("1. The plan of record is `harness/features/feature-list.json`, reached through the");
2328
+ L.push(" `infinity_plan` tool. It is the truth; your memory of it is not.");
2329
+ L.push("2. You never advance a phase and never mark your own work complete. Call");
2330
+ L.push(" `infinity_validate`; the gate is the only referee. Do not edit");
2331
+ L.push(" `harness/config.json` by hand.");
2332
+ L.push("3. If you do not know what to do next, call `infinity_brief` rather than guessing.");
2333
+ if (config.awaitingApproval) {
2334
+ L.push(
2335
+ `4. ${String(config.awaitingApproval).toUpperCase()} is waiting for a human signature. Do not start the next phase.`,
2336
+ );
2337
+ }
2338
+ if (config.paused) {
2339
+ L.push("4. The pipeline is PAUSED. Do not continue autonomously — report and stop.");
2340
+ }
2341
+ return L.join("\n");
2342
+ }
2343
+
1625
2344
  /** Our injected reminders, so they can be pruned before the next call. */
1626
2345
  function isOurReminder(m: unknown): boolean {
1627
2346
  const msg = m as { role?: string; content?: Array<{ type?: string; text?: string }> };