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.
- package/CHANGELOG.md +134 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +770 -51
- package/harness/docs/agents/researcher.md +14 -0
- package/harness/docs/phases/research.md +64 -0
- package/harness/skills/grilling.md +1 -1
- package/harness/skills/research.md +1 -1
- package/package.json +1 -1
- package/src/approval.ts +194 -0
- package/src/core/brief.ts +51 -2
- package/src/core/config.ts +32 -13
- package/src/core/fsx.ts +10 -0
- package/src/core/gates.ts +15 -0
- package/src/core/init.ts +43 -2
- package/src/core/paths.ts +14 -0
- package/src/core/settings.ts +56 -0
- package/src/core/types.ts +65 -2
- package/src/handoff.ts +197 -0
- package/src/intake.ts +263 -0
- package/src/loop.ts +88 -10
- package/src/remote.ts +22 -1
- package/src/runState.ts +121 -0
- package/src/ui/dashboard.ts +140 -16
- package/src/ui/planTree.ts +287 -0
- package/src/ui/theme.ts +15 -0
- package/src/ui/widget.ts +230 -67
- package/src/ui/wizard.ts +195 -0
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
* - refusing tool calls that would skip a phase
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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", {
|
|
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.
|
|
284
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
{
|
|
304
|
-
|
|
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 (
|
|
696
|
+
if (loopBusy || handingOff) return;
|
|
697
|
+
if (!loopArmed(dir)) return;
|
|
324
698
|
|
|
325
699
|
loopBusy = true;
|
|
326
700
|
try {
|
|
327
|
-
const
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
740
|
+
disarmRun(dir, decision.detail);
|
|
342
741
|
notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
|
|
343
742
|
break;
|
|
344
743
|
case "stop":
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-${
|
|
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
|
-
//
|
|
1279
|
-
|
|
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
|
-
|
|
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-${
|
|
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
|
-
|
|
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
|
-
|
|
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 }> };
|