infinity-harness 2.2.0 → 2.3.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.
@@ -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,31 @@ 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 a continuous run armed?
122
+ *
123
+ * Read from disk, not from a closure variable. The old `let loopEnabled`
124
+ * died with the pi session that held it, which meant the first session
125
+ * handoff — the whole point of the fresh-session policy — silently ended
126
+ * the run it was supposed to continue.
127
+ */
128
+ const loopArmed = (dir: string): boolean => loadRunState(dir)?.armed === true;
129
+
130
+ /** The run this session belongs to, or this session, when nothing is armed. */
131
+ const runFor = (dir: string): string => runIdFor(dir, sessionId);
89
132
 
90
133
  const styler = createStyler();
91
134
  const glyphs = detectGlyphs();
@@ -104,8 +147,13 @@ export default function (pi: ExtensionAPI): void {
104
147
  const lastRung = loop?.escalations?.[loop.escalations.length - 1]?.strategy ?? null;
105
148
  const pass = typeof config.goalPass === "number" ? config.goalPass : null;
106
149
  const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
150
+ const run = loadRunState(dir);
107
151
  return {
108
152
  list,
153
+ view,
154
+ sessions: run?.sessions ?? null,
155
+ intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
156
+ awaitingApproval: config.awaitingApproval ?? null,
109
157
  phase: config.currentPhase,
110
158
  enabledPhases: config.phases?.enabled,
111
159
  paused: Boolean(config.paused),
@@ -135,6 +183,31 @@ export default function (pi: ExtensionAPI): void {
135
183
  }
136
184
  };
137
185
 
186
+ /** How many rows the plan currently has — the bound for scrolling. */
187
+ const planRowCount = (dir: string): number => {
188
+ try {
189
+ const { list } = loadFeatureList(dir);
190
+ return buildPlanRows(list, null, { expandSubtasks: view.expanded }).length;
191
+ } catch {
192
+ return 0;
193
+ }
194
+ };
195
+
196
+ /**
197
+ * The widget is a window onto the plan, and the window has to move.
198
+ *
199
+ * A fixed nine-row slice of a sixty-row plan is a widget that is *truncated*,
200
+ * which is exactly how it read: the rows outside the window may as well not
201
+ * exist. They exist; these keys reach them.
202
+ */
203
+ const moveView = (ctx: ExtensionContext, delta: number): void => {
204
+ const dir = projectDir(ctx);
205
+ const rows = planRowCount(dir);
206
+ const windowRows = view.expanded ? EXPANDED_WINDOW : TASK_WINDOW;
207
+ view = scrollView(view, delta, rows, windowRows);
208
+ refreshWidget(ctx);
209
+ };
210
+
138
211
  // -- brief ----------------------------------------------------------------
139
212
 
140
213
  const briefText = async (dir: string, includeGate = false): Promise<string> => {
@@ -188,37 +261,288 @@ export default function (pi: ExtensionAPI): void {
188
261
  notify: (message, level) => notify(ctx, message, level ?? "info"),
189
262
  });
190
263
 
264
+ // -- session handoff ------------------------------------------------------
265
+
266
+ /** The task the pipeline is on right now, or null. */
267
+ const activeTaskKey = (dir: string): string | null => {
268
+ try {
269
+ const { list } = loadFeatureList(dir);
270
+ return nextActionableTask(list)?.compositeKey ?? null;
271
+ } catch {
272
+ return null;
273
+ }
274
+ };
275
+
276
+ /** How full this session's context is, 0..1, or null when pi cannot say. */
277
+ const contextRatio = (ctx: ExtensionContext): number | null => {
278
+ try {
279
+ const usage = ctx.getContextUsage?.();
280
+ if (!usage || typeof usage.percent !== "number") return null;
281
+ return usage.percent > 1 ? usage.percent / 100 : usage.percent;
282
+ } catch {
283
+ return null;
284
+ }
285
+ };
286
+
287
+ /**
288
+ * Continue the run in a fresh session, if the policy says to.
289
+ *
290
+ * Returns true when a handoff was started, in which case the caller must not
291
+ * also send the brief — the replacement session will.
292
+ *
293
+ * `ctx.newSession` deadlocks if it is called from an event handler, so the
294
+ * actual switch happens in the `/infinity:handoff` command. Queuing that
295
+ * command as a follow-up user message is the documented way to reach a
296
+ * command from a handler.
297
+ */
298
+ const maybeHandOff = async (
299
+ ctx: ExtensionContext,
300
+ dir: string,
301
+ brief: string,
302
+ fromPhase: Phase | null,
303
+ toPhase: Phase | null,
304
+ fromTask: string | null,
305
+ ): Promise<boolean> => {
306
+ // A handoff that was asked for and never happened would wedge the run:
307
+ // this session stops driving and the replacement never arrives. One
308
+ // attempt, then carry on here — a run that continues in a fat session is
309
+ // far better than a run that stops.
310
+ if (handingOff) {
311
+ if (hasPendingHandoff(dir)) {
312
+ notify(ctx, "infinity-harness: the new session never started — continuing here.", "warning");
313
+ clearHandoff(dir);
314
+ }
315
+ handingOff = false;
316
+ return false;
317
+ }
318
+ try {
319
+ const { config } = loadConfig(dir);
320
+ const decision = shouldHandoff({
321
+ config,
322
+ fromPhase,
323
+ toPhase,
324
+ fromTask,
325
+ toTask: activeTaskKey(dir),
326
+ contextRatio: contextRatio(ctx),
327
+ });
328
+ if (!decision.handoff) return false;
329
+
330
+ requestHandoff(dir, {
331
+ reason: decision.reason,
332
+ detail: decision.detail,
333
+ kickoff: composeKickoff(brief, decision.reason, decision.detail, carryNote(dir)),
334
+ carry: carryNote(dir),
335
+ runId: runFor(dir),
336
+ });
337
+ handingOff = true;
338
+ // The replacement session announces itself on arrival; saying it twice
339
+ // here would just make the log look like two handoffs happened.
340
+ pi.sendUserMessage("/infinity:handoff", {
341
+ deliverAs: "followUp",
342
+ expandPromptTemplates: true,
343
+ });
344
+ return true;
345
+ } catch (e) {
346
+ // A handoff that cannot be arranged must never end the run. Fall back to
347
+ // continuing in this session, which is exactly the old behaviour.
348
+ notify(ctx, `infinity-harness: staying in this session — ${errMsg(e)}`, "warning");
349
+ handingOff = false;
350
+ clearHandoff(dir);
351
+ return false;
352
+ }
353
+ };
354
+
355
+ /** One line on where the run stands, carried into the next session. */
356
+ const carryNote = (dir: string): string | null => {
357
+ try {
358
+ const { config } = loadConfig(dir);
359
+ if (config.session?.carryNotes === false) return null;
360
+ const { list } = loadFeatureList(dir);
361
+ const p = computeProgress(list);
362
+ const recent = (config.gateHistory ?? []).slice(-3).map((g) => `${g.phase}:${g.result}`);
363
+ return (
364
+ ` ${p.tasksDone}/${p.tasksTotal} tasks done, ${p.featuresDone}/${p.featuresTotal} features` +
365
+ (recent.length ? `; recent gates ${recent.join(", ")}` : "")
366
+ );
367
+ } catch {
368
+ return null;
369
+ }
370
+ };
371
+
372
+ // -- approvals ------------------------------------------------------------
373
+
374
+ /**
375
+ * Collect the human's signature on a phase.
376
+ *
377
+ * With dialogs, ask straight away — the human is right there and the run is
378
+ * stopped for them. Without dialogs there is nobody to ask, so the run parks
379
+ * and says loudly what it is waiting for, because auto-approving a phase the
380
+ * human explicitly asked to sign would make the setting a lie.
381
+ */
382
+ const askForApproval = async (ctx: ExtensionContext, dir: string, phase: Phase): Promise<void> => {
383
+ if (!ctx.hasUI) {
384
+ disarmRun(dir, `${phase} is waiting for approval`);
385
+ notify(
386
+ ctx,
387
+ `infinity-harness: ${phase.toUpperCase()} needs your approval and this mode has no dialogs. ` +
388
+ `Run \`/infinity:approve\` (optionally with what is wrong) to continue.`,
389
+ "warning",
390
+ );
391
+ return;
392
+ }
393
+
394
+ const APPROVE = "approve — continue the run";
395
+ const REJECT = "send it back — I will say what is wrong";
396
+ const LATER = "not now — park the run";
397
+ const choice = await ctx.ui.select(`${phase.toUpperCase()} is waiting for you`, [APPROVE, REJECT, LATER]);
398
+
399
+ if (choice === REJECT) {
400
+ const note = await ctx.ui.input("What needs to change?", "the criteria do not cover refunds");
401
+ await applyApproval(ctx, dir, note ?? "");
402
+ return;
403
+ }
404
+ if (choice === APPROVE) {
405
+ await applyApproval(ctx, dir, "");
406
+ return;
407
+ }
408
+ disarmRun(dir, `${phase} is waiting for approval`);
409
+ notify(ctx, `infinity-harness: parked. \`/infinity:approve\` continues.`, "info");
410
+ refreshWidget(ctx);
411
+ };
412
+
413
+ /** Record the verdict and get the run moving again. */
414
+ const applyApproval = async (ctx: ExtensionContext, dir: string, note: string): Promise<void> => {
415
+ // Pin a rejection to the project as it is right now, so the run knows
416
+ // whether the agent has actually done anything about it before asking the
417
+ // human the same question again.
418
+ const outcome = resolveApproval(dir, note, note.trim() ? await fingerprint(dir) : "");
419
+ if (!outcome.ok) {
420
+ notify(ctx, `infinity-harness: ${outcome.error}`, "warning");
421
+ return;
422
+ }
423
+ refreshWidget(ctx);
424
+
425
+ if (outcome.approved) {
426
+ notify(ctx, `infinity-harness: ${outcome.phase.toUpperCase()} approved.`, "info");
427
+ // The gate already passed; re-settling lets the loop advance normally.
428
+ const moved = await advancePhase(dir);
429
+ refreshWidget(ctx);
430
+ if (!moved.ok) {
431
+ notify(ctx, `infinity-harness: could not advance — ${moved.error}`, "error");
432
+ return;
433
+ }
434
+ const brief = await briefText(dir);
435
+ lastBriefPhase = moved.to;
436
+ if (loopArmed(dir) && (await maybeHandOff(ctx, dir, brief, outcome.phase, moved.to, null))) return;
437
+ pi.sendUserMessage(brief, { deliverAs: "followUp" });
438
+ return;
439
+ }
440
+
441
+ notify(
442
+ ctx,
443
+ `infinity-harness: ${outcome.phase.toUpperCase()} sent back — ${outcome.note}`,
444
+ "warning",
445
+ );
446
+ pi.sendUserMessage(
447
+ `A human reviewed ${outcome.phase.toUpperCase()} and sent it back:\n\n${outcome.note}\n\n` +
448
+ `Address that, then validate again.\n\n${await briefText(dir)}`,
449
+ { deliverAs: "followUp" },
450
+ );
451
+ };
452
+
191
453
  // -- lifecycle ------------------------------------------------------------
192
454
 
193
- pi.on("session_start", async (_event, ctx) => {
455
+ pi.on("session_start", async (event, ctx) => {
194
456
  const dir = projectDir(ctx);
195
457
  if (!isHarnessProject(dir)) return;
196
458
 
459
+ view = defaultView();
197
460
  refreshWidget(ctx);
198
461
  const { config } = loadConfig(dir);
199
462
  lastBriefPhase = config.currentPhase;
200
463
 
201
- notify(ctx, `infinity-harness active · ${config.currentPhase ?? "not started"}`, "info");
464
+ const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
465
+ const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
466
+ const armed = run?.armed === true;
467
+
468
+ notify(
469
+ ctx,
470
+ `infinity-harness active · ${config.currentPhase ?? "not started"}` +
471
+ (armed ? ` · run continuing (session ${run?.sessions ?? 1})` : ""),
472
+ "info",
473
+ );
202
474
  try {
203
- pi.appendEntry("infinity:session", { runId, dir, phase: config.currentPhase });
475
+ pi.appendEntry("infinity:session", {
476
+ sessionId,
477
+ runId: runFor(dir),
478
+ reason,
479
+ dir,
480
+ phase: config.currentPhase,
481
+ });
204
482
  } catch {
205
483
  /* entry log is best-effort */
206
484
  }
207
485
 
486
+ // A handoff written by the session this one replaces. It carries the brief
487
+ // plus the reason the previous session ended, so the agent does not spend
488
+ // its first turn working out why it woke up mid-run.
489
+ const pending = takeHandoff(dir);
490
+ if (pending && pending.runId === runFor(dir)) {
491
+ try {
492
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
493
+ notify(ctx, `infinity-harness: ${describeHandoff(pending)}`, "info");
494
+ } catch (e) {
495
+ notify(ctx, `infinity-harness: handoff failed — ${errMsg(e)}`, "error");
496
+ }
497
+ return;
498
+ }
499
+
208
500
  // The brief is delivered as a message rather than a notification so the
209
501
  // model actually reads it. Without this the agent starts from whatever
210
502
  // the user typed and ignores the pipeline entirely.
503
+ //
504
+ // `nextTurn` is right in a terminal, where a human is about to type. It is
505
+ // a deadlock in `pi -p`, which has no next turn and waits forever for one:
506
+ // the harness made every headless run hang on startup. Non-interactive
507
+ // modes get `steer`, which folds the brief into the turn already starting.
211
508
  try {
212
509
  const text = await briefText(dir);
510
+ const interactive = ctx.mode === "tui" || ctx.mode === "rpc";
213
511
  pi.sendMessage(
214
512
  { customType: "infinity:brief", content: text, display: true, details: { phase: config.currentPhase } },
215
- { triggerTurn: false, deliverAs: "nextTurn" },
513
+ { triggerTurn: false, deliverAs: interactive ? "nextTurn" : "steer" },
216
514
  );
217
515
  } catch (e) {
218
516
  notify(ctx, `infinity-harness: could not build brief — ${errMsg(e)}`, "warning");
219
517
  }
220
518
  });
221
519
 
520
+ /**
521
+ * The harness contract, in the system prompt.
522
+ *
523
+ * Everything else the harness tells the model is a message in the
524
+ * transcript, and every message in the transcript is something compaction
525
+ * can summarise into "the assistant was working on a harness". That is how a
526
+ * long run loses the plot: not by forgetting the plan — the plan is on disk
527
+ * — but by forgetting that it is *supposed to* work from the plan, stop when
528
+ * a gate fails, and never mark its own work complete.
529
+ *
530
+ * The system prompt is rebuilt from scratch every turn and is never
531
+ * summarised. Anything the run cannot afford to forget belongs here.
532
+ */
533
+ pi.on("before_agent_start", async (event, ctx) => {
534
+ const dir = projectDir(ctx);
535
+ if (!isHarnessProject(dir)) return;
536
+ try {
537
+ const contract = harnessContract(dir);
538
+ if (!contract) return;
539
+ const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
540
+ return { systemPrompt: `${base}\n\n${contract}` };
541
+ } catch {
542
+ return;
543
+ }
544
+ });
545
+
222
546
  pi.on("session_tree", async (_event, ctx) => {
223
547
  refreshWidget(ctx);
224
548
  });
@@ -280,28 +604,52 @@ export default function (pi: ExtensionAPI): void {
280
604
  });
281
605
 
282
606
  /**
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.
607
+ * Compaction drops the transcript.
608
+ *
609
+ * The plan survives — it is on disk — and since 2.3 so do the rules, because
610
+ * they live in the system prompt (`before_agent_start`) where no summariser
611
+ * can reach them. What is left to restore is the *current* brief, so the
612
+ * agent picks up on the same task rather than re-deriving one from a summary
613
+ * of a summary.
285
614
  */
286
615
  pi.on("session_before_compact", async (_event, ctx) => {
287
616
  const dir = projectDir(ctx);
288
617
  if (!isHarnessProject(dir)) return;
289
618
  try {
290
619
  const { list } = loadFeatureList(dir);
291
- pi.appendEntry(CHECKPOINT, { revision: list.baseRevision, at: new Date().toISOString() });
620
+ const { config } = loadConfig(dir);
621
+ pi.appendEntry(CHECKPOINT, {
622
+ revision: list.baseRevision,
623
+ phase: config.currentPhase,
624
+ runId: runFor(dir),
625
+ at: new Date().toISOString(),
626
+ });
292
627
  } catch {
293
628
  /* checkpoint is advisory */
294
629
  }
295
630
  });
296
631
 
297
- pi.on("session_compact", async (_event, ctx) => {
632
+ pi.on("session_compact", async (event, ctx) => {
298
633
  const dir = projectDir(ctx);
299
634
  if (!isHarnessProject(dir)) return;
300
635
  try {
301
636
  const text = await briefText(dir);
637
+
638
+ // Delivery mode is the whole bug here. `nextTurn` waits for a human to
639
+ // type, which never happens in an unattended run — so the re-brief that
640
+ // was supposed to rescue the agent after compaction sat in a queue while
641
+ // the agent carried on without it. Overflow compaction retries the
642
+ // aborted turn immediately, so the brief has to land *in* that turn.
643
+ const willRetry = (event as { willRetry?: boolean } | undefined)?.willRetry === true;
644
+ const running = willRetry || !ctx.isIdle?.();
302
645
  pi.sendMessage(
303
- { customType: "infinity:brief", content: text, display: false, details: { after: "compaction" } },
304
- { triggerTurn: false, deliverAs: "nextTurn" },
646
+ {
647
+ customType: "infinity:brief",
648
+ content: text,
649
+ display: false,
650
+ details: { after: "compaction", reason: (event as { reason?: string })?.reason ?? null },
651
+ },
652
+ { triggerTurn: false, deliverAs: running ? "steer" : "nextTurn" },
305
653
  );
306
654
  refreshWidget(ctx);
307
655
  } catch {
@@ -316,33 +664,63 @@ export default function (pi: ExtensionAPI): void {
316
664
  /**
317
665
  * The loop. `agent_settled` fires when the agent has stopped working, which
318
666
  * is the only safe moment to run the gate and decide what happens next.
667
+ *
668
+ * The run's armed flag is read from disk on every tick rather than held in a
669
+ * closure, so a run survives the session handoffs it now performs, plus
670
+ * `/reload`, `/resume`, and pi being restarted.
319
671
  */
320
672
  pi.on("agent_settled", async (_event, ctx) => {
321
673
  const dir = projectDir(ctx);
322
674
  if (!isHarnessProject(dir)) return;
323
- if (!loopEnabled || loopBusy) return;
675
+ if (loopBusy || handingOff) return;
676
+ if (!loopArmed(dir)) return;
324
677
 
325
678
  loopBusy = true;
326
679
  try {
327
- const { decision } = await decideNext({ targetDir: dir, runId });
680
+ const before = loadConfig(dir).config;
681
+ const beforeTask = activeTaskKey(dir);
682
+ const { decision } = await decideNext({ targetDir: dir, runId: runFor(dir) });
328
683
  refreshWidget(ctx);
329
684
 
330
685
  switch (decision.action) {
331
- case "advanced":
686
+ case "advanced": {
332
687
  notify(ctx, `infinity-harness: gate passed → ${decision.toPhase}`, "info");
333
688
  lastBriefPhase = decision.toPhase;
689
+ if (await maybeHandOff(ctx, dir, decision.message, before.currentPhase, decision.toPhase, beforeTask)) {
690
+ break;
691
+ }
334
692
  pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
335
693
  break;
336
- case "continue":
337
- notify(ctx, `infinity-harness: gate failed — re-briefing`, "warning");
694
+ }
695
+ case "continue": {
696
+ // Say *why* it is going round again. "gate failed" was printed even
697
+ // when the gate had passed and the run was waiting on a rejection
698
+ // the agent had not acted on, which reads as a different bug.
699
+ notify(ctx, `infinity-harness: ${decision.reason} — re-briefing`, "warning");
700
+ // A failed gate on the same phase is normally the same session's
701
+ // problem to fix. The exception is context pressure: carrying on in
702
+ // a session that is about to compact is how a run degrades into
703
+ // summaries of summaries.
704
+ if (await maybeHandOff(ctx, dir, decision.message, before.currentPhase, before.currentPhase, beforeTask)) {
705
+ break;
706
+ }
707
+ pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
708
+ break;
709
+ }
710
+ case "approve": {
711
+ // Not a stop. The run is parked on a human, and the widget, the
712
+ // status line and the notification all say so.
713
+ notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
338
714
  pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
715
+ await askForApproval(ctx, dir, decision.phase);
339
716
  break;
717
+ }
340
718
  case "wait":
341
- loopEnabled = false;
719
+ disarmRun(dir, decision.detail);
342
720
  notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
343
721
  break;
344
722
  case "stop":
345
- loopEnabled = false;
723
+ disarmRun(dir, decision.detail);
346
724
  notify(
347
725
  ctx,
348
726
  `infinity-harness: run finished — ${decision.detail}`,
@@ -355,8 +733,9 @@ export default function (pi: ExtensionAPI): void {
355
733
  }
356
734
  break;
357
735
  }
736
+ refreshWidget(ctx);
358
737
  } catch (e) {
359
- loopEnabled = false;
738
+ disarmRun(dir, `loop error: ${errMsg(e)}`);
360
739
  notify(ctx, `infinity-harness: loop error, stopping — ${errMsg(e)}`, "error");
361
740
  } finally {
362
741
  loopBusy = false;
@@ -421,7 +800,6 @@ export default function (pi: ExtensionAPI): void {
421
800
  remoteServer = null;
422
801
  remoteDir = null;
423
802
  }
424
- loopEnabled = false;
425
803
  loopBusy = false;
426
804
  });
427
805
 
@@ -784,7 +1162,19 @@ export default function (pi: ExtensionAPI): void {
784
1162
  const DEFAULT_LADDER = ["retry", "reframe", "consult", "rework", "replan", "master"];
785
1163
 
786
1164
  /** Everything the pipeline can run. INIT is not a phase you choose. */
787
- const SELECTABLE_PHASES: Phase[] = ["define", "plan", "build", "verify", "simplify", "review", "ship"];
1165
+ // Everything except INIT, which is not a phase anyone chooses. RESEARCH is
1166
+ // here because it is a real, optional phase — omitting it from the picker
1167
+ // was the difference between a feature and a feature nobody can find.
1168
+ const SELECTABLE_PHASES: Phase[] = [
1169
+ "research",
1170
+ "define",
1171
+ "plan",
1172
+ "build",
1173
+ "verify",
1174
+ "simplify",
1175
+ "review",
1176
+ "ship",
1177
+ ];
788
1178
 
789
1179
  pi.registerTool({
790
1180
  name: "infinity_init",
@@ -833,10 +1223,11 @@ export default function (pi: ExtensionAPI): void {
833
1223
  });
834
1224
 
835
1225
  pi.registerCommand("infinity:init", {
836
- description: "Create a harness in this project",
1226
+ description: "Set up a harness here mode, goal, research, approvals, sessions",
837
1227
  handler: async (args: string, ctx: ExtensionContext) => {
838
1228
  const dir = projectDir(ctx);
839
1229
  const force = /\bforce\b/.test(args);
1230
+ const goalFromArgs = args.replace(/\bforce\b/g, "").trim();
840
1231
 
841
1232
  if (isHarnessProject(dir) && !force) {
842
1233
  notify(
@@ -848,28 +1239,34 @@ export default function (pi: ExtensionAPI): void {
848
1239
  }
849
1240
 
850
1241
  const detected = detectStack(dir);
851
- let mode: "copilot" | "autopilot" = "copilot";
852
1242
  let phases: Phase[] | undefined;
853
1243
 
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.
1244
+ // Two things used to be wrong here, and they compounded.
1245
+ //
1246
+ // First, the wizard never asked what was being built — so picking
1247
+ // "autopilot" started a run with no idea and no scope, and the harness
1248
+ // invented a project and began building it. Autopilot was being read as
1249
+ // "you decide everything, including what I want".
1250
+ //
1251
+ // Second, "mode" was the only question. There was no way to say "drive
1252
+ // yourself, but show me the plan before you build it", which is what
1253
+ // most people actually want from an unattended run.
1254
+ //
1255
+ // The wizard now asks for the goal in both modes, offers an optional
1256
+ // research phase, and — in autopilot — lets the human pick exactly which
1257
+ // of RESEARCH / DEFINE / PLAN they sign. `src/intake.ts` owns what the
1258
+ // answers mean; `src/ui/wizard.ts` owns asking them.
857
1259
  if (ctx.hasUI) {
858
1260
  const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
859
1261
  const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
860
1262
  const go = await ctx.ui.select(
861
1263
  `Create a harness here? ${detected.label} · ${summary}`,
862
- ["yes, use these defaults", "yes, but let me choose the phases", "cancel"],
1264
+ ["yes", "yes, and let me choose the phases", "cancel"],
863
1265
  );
864
1266
  if (go === undefined || go === "cancel") {
865
1267
  notify(ctx, "init cancelled — nothing was written.", "info");
866
1268
  return;
867
1269
  }
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
1270
 
874
1271
  if (go.includes("phases")) {
875
1272
  const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
@@ -886,22 +1283,149 @@ export default function (pi: ExtensionAPI): void {
886
1283
  }
887
1284
  }
888
1285
 
889
- const result = initHarness(dir, { mode, phases, force });
1286
+ const wizard = ctx.hasUI
1287
+ ? await runIntakeWizard({
1288
+ prompt: prompterFor(ctx),
1289
+ phases,
1290
+ brief: goalFromArgs || null,
1291
+ })
1292
+ : ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null, phases) } as const);
1293
+
1294
+ if (wizard.cancelled) {
1295
+ notify(ctx, "init cancelled — nothing was written.", "info");
1296
+ return;
1297
+ }
1298
+ const plan = wizard.plan;
1299
+
1300
+ const result = initHarness(dir, {
1301
+ mode: plan.mode,
1302
+ phases: plan.phases,
1303
+ approvals: plan.approvals,
1304
+ session: plan.session,
1305
+ brief: plan.brief,
1306
+ force,
1307
+ });
890
1308
  if (!result.ok) {
891
1309
  notify(ctx, result.error ?? "init failed", "error");
892
1310
  return;
893
1311
  }
894
1312
 
895
- notify(ctx, describeInit(result), "info");
1313
+ // The wizard already showed the summary before the human confirmed it;
1314
+ // repeating it verbatim here is noise. Warnings do repeat — they are the
1315
+ // part worth seeing twice.
1316
+ const lines = [describeInit(result)];
1317
+ if (plan.warnings.length) lines.push("", ...plan.warnings.map((w) => `! ${w}`));
1318
+ notify(ctx, lines.join("\n"), plan.warnings.length ? "warning" : "info");
896
1319
  refreshWidget(ctx);
1320
+
897
1321
  // 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" });
1322
+ // the harness is also the session that starts using it. Without a goal
1323
+ // the first thing it must do is ask for one — never guess one.
1324
+ const brief = await briefText(dir);
1325
+ const opener = plan.brief
1326
+ ? brief
1327
+ : `The human has not said what they want built yet. Ask them, in one short question, ` +
1328
+ `and do not start any work or invent a scope until they answer.\n\n${brief}`;
1329
+ pi.sendUserMessage(opener, { deliverAs: "followUp" });
1330
+ },
1331
+ });
1332
+
1333
+ /**
1334
+ * Continue the run in a replacement session.
1335
+ *
1336
+ * This is a command rather than something the loop does directly because
1337
+ * `ctx.newSession` is only safe from a command handler — pi deadlocks if an
1338
+ * event handler calls it. The loop queues `/infinity:handoff` as a follow-up
1339
+ * and this does the switch.
1340
+ *
1341
+ * Everything the next session needs is already on disk. `withSession` may
1342
+ * only touch the context it is handed: the old `pi` and `ctx` are dead by
1343
+ * the time it runs.
1344
+ */
1345
+ pi.registerCommand("infinity:handoff", {
1346
+ description: "Continue this run in a fresh session, carrying the brief",
1347
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
1348
+ const dir = projectDir(ctx);
1349
+ if (!isHarnessProject(dir)) {
1350
+ notify(ctx, NO_HARNESS, "warning");
1351
+ return;
1352
+ }
1353
+
1354
+ // Asked for by hand, with no handoff queued: make one.
1355
+ if (!hasPendingHandoff(dir)) {
1356
+ const brief = await briefText(dir);
1357
+ requestHandoff(dir, {
1358
+ reason: "manual",
1359
+ detail: args.trim() || "requested by hand",
1360
+ kickoff: composeKickoff(brief, "manual", args.trim() || "requested by hand", carryNote(dir)),
1361
+ carry: carryNote(dir),
1362
+ runId: runFor(dir),
1363
+ });
1364
+ }
1365
+
1366
+ const pending = takeHandoff(dir);
1367
+ if (!pending) {
1368
+ notify(ctx, "infinity-harness: nothing to hand off.", "warning");
1369
+ return;
1370
+ }
1371
+
1372
+ // The replacement session reads this back from disk in `session_start`;
1373
+ // put it back so the claim above does not consume it.
1374
+ requestHandoff(dir, {
1375
+ reason: pending.reason,
1376
+ detail: pending.detail,
1377
+ kickoff: pending.kickoff,
1378
+ carry: pending.carry,
1379
+ runId: pending.runId,
1380
+ });
1381
+
1382
+ handingOff = false;
1383
+ try {
1384
+ await ctx.waitForIdle?.();
1385
+ const parent = ctx.sessionManager?.getSessionFile?.() ?? undefined;
1386
+ const result = await ctx.newSession({ parentSession: parent ?? undefined });
1387
+ if (result?.cancelled) {
1388
+ clearHandoff(dir);
1389
+ notify(ctx, "infinity-harness: handoff cancelled — continuing here.", "warning");
1390
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
1391
+ }
1392
+ } catch (e) {
1393
+ // A handoff that cannot happen must never end the run: fall back to
1394
+ // carrying on in this session, which is the pre-2.3 behaviour.
1395
+ clearHandoff(dir);
1396
+ notify(ctx, `infinity-harness: could not start a new session — ${errMsg(e)}`, "warning");
1397
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
1398
+ }
900
1399
  },
901
1400
  });
902
1401
 
1402
+ pi.registerCommand("infinity:approve", {
1403
+ description: "Approve the phase waiting for you — or send it back with a note",
1404
+ handler: async (args: string, ctx: ExtensionContext) => {
1405
+ const dir = projectDir(ctx);
1406
+ if (!isHarnessProject(dir)) {
1407
+ notify(ctx, NO_HARNESS, "warning");
1408
+ return;
1409
+ }
1410
+ const { config } = loadConfig(dir);
1411
+ if (!config.awaitingApproval) {
1412
+ const signing = approvedPhases(config);
1413
+ notify(
1414
+ ctx,
1415
+ signing.length
1416
+ ? `Nothing is waiting. You are signing: ${signing.map((p) => p.toUpperCase()).join(", ")}.`
1417
+ : "Nothing is waiting, and you are not signing any phase. /infinity:config changes that.",
1418
+ "info",
1419
+ );
1420
+ return;
1421
+ }
1422
+ // Approving re-arms the run: the human answering is them saying carry on.
1423
+ if (!loopArmed(dir)) armRun(dir, sessionId);
1424
+ await applyApproval(ctx, dir, args.trim());
1425
+ },
1426
+ });
903
1427
 
904
- // -- escalation, rework, replan --------------------------------------------
1428
+ // -- escalation, rework, replan -------------------------------------------- // -- escalation, rework, replan --------------------------------------------
905
1429
 
906
1430
  pi.registerTool({
907
1431
  name: "infinity_rework",
@@ -945,7 +1469,7 @@ export default function (pi: ExtensionAPI): void {
945
1469
  taskId: target.id,
946
1470
  key: target.key,
947
1471
  reason: params.reason ?? "rework requested",
948
- runId,
1472
+ runId: runFor(dir),
949
1473
  maxImpactDepth: params.maxImpactDepth,
950
1474
  });
951
1475
  refreshWidget(ctx as ExtensionContext);
@@ -1134,7 +1658,7 @@ export default function (pi: ExtensionAPI): void {
1134
1658
  try {
1135
1659
  const result = await spawnIsolatedWorker({
1136
1660
  projectDir: dir,
1137
- runId,
1661
+ runId: runFor(dir),
1138
1662
  featureId: target.featureId,
1139
1663
  taskId: target.id,
1140
1664
  prompt: params.prompt,
@@ -1223,7 +1747,7 @@ export default function (pi: ExtensionAPI): void {
1223
1747
  const { state } = await startGoal({
1224
1748
  targetDir: dir,
1225
1749
  goal: params.goal,
1226
- runId: `goal-${runId}`,
1750
+ runId: `goal-${runFor(dir)}`,
1227
1751
  maxIterations: params.maxIterations,
1228
1752
  });
1229
1753
  refreshWidget(ctx as ExtensionContext);
@@ -1275,8 +1799,31 @@ export default function (pi: ExtensionAPI): void {
1275
1799
  });
1276
1800
  refreshWidget(ctx as ExtensionContext);
1277
1801
  if (!outcome.terminal) {
1278
- // Rewinding the pipeline means the next brief is a different one.
1279
- pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
1802
+ // A new goal pass is the largest context boundary there is: the
1803
+ // pipeline has rewound to its first phase and the next pass plans
1804
+ // for what is left, not for what the last one already built.
1805
+ // Carrying a whole finished pass of conversation into it is the
1806
+ // worst case of the problem session handoff exists to solve.
1807
+ const brief = await briefText(dir);
1808
+ const { config } = loadConfig(dir);
1809
+ if (config.session?.handoff !== "off" && loopArmed(dir)) {
1810
+ const detail = `goal pass ${config.goalPass ?? "next"}`;
1811
+ requestHandoff(dir, {
1812
+ reason: "goal-pass",
1813
+ detail,
1814
+ kickoff: composeKickoff(brief, "goal-pass", detail, carryNote(dir)),
1815
+ carry: carryNote(dir),
1816
+ runId: runFor(dir),
1817
+ });
1818
+ handingOff = true;
1819
+ pi.sendUserMessage("/infinity:handoff", {
1820
+ deliverAs: "followUp",
1821
+ expandPromptTemplates: true,
1822
+ });
1823
+ } else {
1824
+ // Rewinding the pipeline means the next brief is a different one.
1825
+ pi.sendUserMessage(brief, { deliverAs: "followUp" });
1826
+ }
1280
1827
  }
1281
1828
  return { content: [{ type: "text", text: outcome.message }], details: viewOf(outcome.state) };
1282
1829
  }
@@ -1327,6 +1874,12 @@ export default function (pi: ExtensionAPI): void {
1327
1874
  description: "Print the current brief",
1328
1875
  handler: async (_args: string, ctx: ExtensionContext) => {
1329
1876
  const dir = projectDir(ctx);
1877
+ // Without this guard it printed a brief for a project with no harness —
1878
+ // a page of pipeline instructions for a pipeline that does not exist.
1879
+ if (!isHarnessProject(dir)) {
1880
+ notify(ctx, NO_HARNESS, "warning");
1881
+ return;
1882
+ }
1330
1883
  notify(ctx, await briefText(dir), "info");
1331
1884
  },
1332
1885
  });
@@ -1335,6 +1888,10 @@ export default function (pi: ExtensionAPI): void {
1335
1888
  description: "Run the gate for the current phase",
1336
1889
  handler: async (_args: string, ctx: ExtensionContext) => {
1337
1890
  const dir = projectDir(ctx);
1891
+ if (!isHarnessProject(dir)) {
1892
+ notify(ctx, NO_HARNESS, "warning");
1893
+ return;
1894
+ }
1338
1895
  const { config } = loadConfig(dir);
1339
1896
  if (!config.currentPhase) {
1340
1897
  notify(ctx, "No current phase.", "warning");
@@ -1355,7 +1912,7 @@ export default function (pi: ExtensionAPI): void {
1355
1912
  notify(ctx, NO_HARNESS, "warning");
1356
1913
  return;
1357
1914
  }
1358
- loopEnabled = true;
1915
+ armRun(dir, sessionId);
1359
1916
  notify(
1360
1917
  ctx,
1361
1918
  `infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
@@ -1399,7 +1956,7 @@ export default function (pi: ExtensionAPI): void {
1399
1956
  }
1400
1957
 
1401
1958
  try {
1402
- const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runId}` });
1959
+ const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runFor(dir)}` });
1403
1960
  refreshWidget(ctx);
1404
1961
  notify(
1405
1962
  ctx,
@@ -1484,7 +2041,7 @@ export default function (pi: ExtensionAPI): void {
1484
2041
  taskId: target.id,
1485
2042
  key: target.key,
1486
2043
  reason: "rework from /infinity:rework",
1487
- runId,
2044
+ runId: runFor(dir),
1488
2045
  });
1489
2046
  refreshWidget(ctx);
1490
2047
  notify(
@@ -1504,8 +2061,15 @@ export default function (pi: ExtensionAPI): void {
1504
2061
  pi.registerCommand("infinity:halt", {
1505
2062
  description: "Stop the continuous loop after the current turn",
1506
2063
  handler: async (_args: string, ctx: ExtensionContext) => {
1507
- loopEnabled = false;
2064
+ const dir = projectDir(ctx);
2065
+ if (!isHarnessProject(dir)) {
2066
+ notify(ctx, NO_HARNESS, "warning");
2067
+ return;
2068
+ }
2069
+ disarmRun(dir, "halted from /infinity:halt");
2070
+ clearHandoff(dir);
1508
2071
  notify(ctx, "infinity-harness: continuous run stopped.", "info");
2072
+ refreshWidget(ctx);
1509
2073
  },
1510
2074
  });
1511
2075
 
@@ -1513,13 +2077,17 @@ export default function (pi: ExtensionAPI): void {
1513
2077
  description: "Pause the pipeline (persisted in harness/config.json)",
1514
2078
  handler: async (_args: string, ctx: ExtensionContext) => {
1515
2079
  const dir = projectDir(ctx);
2080
+ if (!isHarnessProject(dir)) {
2081
+ notify(ctx, NO_HARNESS, "warning");
2082
+ return;
2083
+ }
1516
2084
  const { value } = await withLock(configPath(dir), () => {
1517
2085
  const { config, ok } = loadConfig(dir);
1518
2086
  if (!ok) return false;
1519
2087
  config.paused = true;
1520
2088
  return saveConfig(dir, config).ok;
1521
2089
  });
1522
- loopEnabled = false;
2090
+ disarmRun(dir, "paused from /infinity:pause");
1523
2091
  notify(ctx, value ? "infinity-harness: paused." : "Could not pause — config unreadable.", value ? "info" : "error");
1524
2092
  refreshWidget(ctx);
1525
2093
  },
@@ -1529,6 +2097,10 @@ export default function (pi: ExtensionAPI): void {
1529
2097
  description: "Unpause the pipeline",
1530
2098
  handler: async (_args: string, ctx: ExtensionContext) => {
1531
2099
  const dir = projectDir(ctx);
2100
+ if (!isHarnessProject(dir)) {
2101
+ notify(ctx, NO_HARNESS, "warning");
2102
+ return;
2103
+ }
1532
2104
  const { value } = await withLock(configPath(dir), () => {
1533
2105
  const { config, ok } = loadConfig(dir);
1534
2106
  if (!ok) return false;
@@ -1601,10 +2173,83 @@ export default function (pi: ExtensionAPI): void {
1601
2173
  },
1602
2174
  });
1603
2175
 
2176
+ // -- keys -----------------------------------------------------------------
2177
+ //
2178
+ // The widget is nine rows of a plan that is routinely sixty. Without these
2179
+ // the other fifty-one are unreachable without opening the dashboard, which
2180
+ // is not a thing anyone does mid-glance.
2181
+ //
2182
+ // `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
2183
+ // to line end) and ctrl+o (expand tool output). Shadowing an editor key to
2184
+ // scroll a widget would be a worse bug than the one being fixed.
2185
+
2186
+ pi.registerShortcut("alt+j", {
2187
+ description: "infinity-harness: scroll the plan down",
2188
+ handler: async (ctx: ExtensionContext) => moveView(ctx, SCROLL_STEP),
2189
+ });
2190
+
2191
+ pi.registerShortcut("alt+k", {
2192
+ description: "infinity-harness: scroll the plan up",
2193
+ handler: async (ctx: ExtensionContext) => moveView(ctx, -SCROLL_STEP),
2194
+ });
2195
+
2196
+ pi.registerShortcut("alt+o", {
2197
+ description: "infinity-harness: expand or collapse the plan widget",
2198
+ handler: async (ctx: ExtensionContext) => {
2199
+ view = { ...view, expanded: !view.expanded };
2200
+ refreshWidget(ctx);
2201
+ },
2202
+ });
2203
+
2204
+ pi.registerCommand("infinity:scroll", {
2205
+ description: "Move the plan widget — up, down, top, bottom, expand, follow",
2206
+ handler: async (args: string, ctx: ExtensionContext) => {
2207
+ const dir = projectDir(ctx);
2208
+ if (!isHarnessProject(dir)) {
2209
+ notify(ctx, NO_HARNESS, "warning");
2210
+ return;
2211
+ }
2212
+ const what = args.trim().toLowerCase() || "down";
2213
+ const rows = planRowCount(dir);
2214
+ switch (what) {
2215
+ case "up":
2216
+ moveView(ctx, -SCROLL_STEP);
2217
+ return;
2218
+ case "down":
2219
+ moveView(ctx, SCROLL_STEP);
2220
+ return;
2221
+ case "top":
2222
+ view = { ...view, scroll: 0 };
2223
+ break;
2224
+ case "bottom":
2225
+ view = { ...view, scroll: rows };
2226
+ break;
2227
+ case "expand":
2228
+ view = { ...view, expanded: true };
2229
+ break;
2230
+ case "collapse":
2231
+ view = { ...view, expanded: false };
2232
+ break;
2233
+ case "follow":
2234
+ // Back to tracking the active task, which is where it starts.
2235
+ view = defaultView();
2236
+ break;
2237
+ default:
2238
+ notify(ctx, "Use: up · down · top · bottom · expand · collapse · follow", "warning");
2239
+ return;
2240
+ }
2241
+ refreshWidget(ctx);
2242
+ },
2243
+ });
2244
+
1604
2245
  pi.registerCommand("infinity:dashboard", {
1605
2246
  description: "Open the read-only web dashboard for this run",
1606
2247
  handler: async (_args: string, ctx: ExtensionContext) => {
1607
2248
  const dir = projectDir(ctx);
2249
+ if (!isHarnessProject(dir)) {
2250
+ notify(ctx, NO_HARNESS, "warning");
2251
+ return;
2252
+ }
1608
2253
  const remote = await import("../../src/remote.ts");
1609
2254
  if (remoteServer) {
1610
2255
  notify(ctx, `Dashboard already live at ${remoteServer.url}`, "info");
@@ -1622,6 +2267,52 @@ function errMsg(e: unknown): string {
1622
2267
  return e instanceof Error ? e.message : String(e);
1623
2268
  }
1624
2269
 
2270
+ /**
2271
+ * The few sentences the run cannot afford to have summarised away.
2272
+ *
2273
+ * Short on purpose: this is paid for on every single request, and a long
2274
+ * system prompt crowds out the work. It carries only what stops the agent
2275
+ * going freelance after a compaction — where the pipeline is, what it is
2276
+ * working on, and the three rules that make the harness a harness.
2277
+ */
2278
+ function harnessContract(dir: string): string | null {
2279
+ const { config, ok } = loadConfig(dir);
2280
+ if (!ok || !config.currentPhase) return null;
2281
+
2282
+ const { list } = loadFeatureList(dir);
2283
+ const progress = computeProgress(list);
2284
+ const phase = config.currentPhase.toUpperCase();
2285
+ const role = config.currentRole ?? "";
2286
+
2287
+ const L: string[] = [];
2288
+ L.push("## infinity-harness");
2289
+ L.push("");
2290
+ L.push(
2291
+ `This project is driven by the infinity-harness pipeline. You are at **${phase}**` +
2292
+ (role ? ` wearing the ${role} hat` : "") +
2293
+ `, with ${progress.tasksDone}/${progress.tasksTotal} tasks done across ` +
2294
+ `${progress.featuresTotal} feature(s). Plan revision ${list.baseRevision}.`,
2295
+ );
2296
+ L.push("");
2297
+ L.push("Rules that do not change, whatever the conversation above says:");
2298
+ L.push("");
2299
+ L.push("1. The plan of record is `harness/features/feature-list.json`, reached through the");
2300
+ L.push(" `infinity_plan` tool. It is the truth; your memory of it is not.");
2301
+ L.push("2. You never advance a phase and never mark your own work complete. Call");
2302
+ L.push(" `infinity_validate`; the gate is the only referee. Do not edit");
2303
+ L.push(" `harness/config.json` by hand.");
2304
+ L.push("3. If you do not know what to do next, call `infinity_brief` rather than guessing.");
2305
+ if (config.awaitingApproval) {
2306
+ L.push(
2307
+ `4. ${String(config.awaitingApproval).toUpperCase()} is waiting for a human signature. Do not start the next phase.`,
2308
+ );
2309
+ }
2310
+ if (config.paused) {
2311
+ L.push("4. The pipeline is PAUSED. Do not continue autonomously — report and stop.");
2312
+ }
2313
+ return L.join("\n");
2314
+ }
2315
+
1625
2316
  /** Our injected reminders, so they can be pruned before the next call. */
1626
2317
  function isOurReminder(m: unknown): boolean {
1627
2318
  const msg = m as { role?: string; content?: Array<{ type?: string; text?: string }> };