infinity-harness 2.2.1 → 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.
- package/CHANGELOG.md +114 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +742 -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 +2 -2
- 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,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
|
-
|
|
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 (
|
|
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
|
-
|
|
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", {
|
|
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.
|
|
284
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
{
|
|
304
|
-
|
|
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 (
|
|
675
|
+
if (loopBusy || handingOff) return;
|
|
676
|
+
if (!loopArmed(dir)) return;
|
|
324
677
|
|
|
325
678
|
loopBusy = true;
|
|
326
679
|
try {
|
|
327
|
-
const
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
719
|
+
disarmRun(dir, decision.detail);
|
|
342
720
|
notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
|
|
343
721
|
break;
|
|
344
722
|
case "stop":
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-${
|
|
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
|
-
//
|
|
1279
|
-
|
|
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
|
-
|
|
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-${
|
|
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
|
-
|
|
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
|
-
|
|
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 }> };
|