infinity-harness 2.3.1 → 2.5.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 +94 -0
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +338 -61
- package/package.json +1 -1
- package/src/approval.ts +15 -5
- package/src/core/config.ts +45 -2
- package/src/core/init.ts +42 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +191 -15
- package/src/core/types.ts +58 -5
- package/src/handoff.ts +88 -15
- package/src/intake.ts +164 -123
- package/src/modelRouter.ts +55 -2
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +13 -0
- package/src/ui/dashboard.ts +110 -31
- package/src/ui/display.ts +267 -0
- package/src/ui/planTree.ts +38 -16
- package/src/ui/widget.ts +50 -18
- package/src/ui/wizard.ts +343 -91
- package/src/workflow.ts +309 -0
|
@@ -78,7 +78,29 @@ import {
|
|
|
78
78
|
type HandoffReason,
|
|
79
79
|
} from "../../src/handoff.ts";
|
|
80
80
|
import { needsApproval, resolveApproval, approvedPhases } from "../../src/approval.ts";
|
|
81
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
buildDisplay,
|
|
83
|
+
pickDisplay,
|
|
84
|
+
pickWorkflow,
|
|
85
|
+
runIntakeWizard,
|
|
86
|
+
unattendedIntake,
|
|
87
|
+
} from "../../src/ui/wizard.ts";
|
|
88
|
+
import {
|
|
89
|
+
applyWorkflow,
|
|
90
|
+
findWorkflow,
|
|
91
|
+
listWorkflows,
|
|
92
|
+
matchWorkflow,
|
|
93
|
+
renderWorkflow,
|
|
94
|
+
signedPhases,
|
|
95
|
+
summarizeWorkflow,
|
|
96
|
+
} from "../../src/workflow.ts";
|
|
97
|
+
import {
|
|
98
|
+
findDisplay,
|
|
99
|
+
listDisplays,
|
|
100
|
+
normalizeDisplay,
|
|
101
|
+
summarizeDisplay,
|
|
102
|
+
} from "../../src/ui/display.ts";
|
|
103
|
+
import type { DisplayPolicy } from "../../src/core/types.ts";
|
|
82
104
|
import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW, type WidgetView } from "../../src/ui/widget.ts";
|
|
83
105
|
import { buildPlanRows } from "../../src/ui/planTree.ts";
|
|
84
106
|
|
|
@@ -165,6 +187,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
165
187
|
sessions: run?.sessions ?? null,
|
|
166
188
|
intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
|
|
167
189
|
awaitingApproval: config.awaitingApproval ?? null,
|
|
190
|
+
display: normalizeDisplay(config.display),
|
|
168
191
|
phase: config.currentPhase,
|
|
169
192
|
enabledPhases: config.phases?.enabled,
|
|
170
193
|
paused: Boolean(config.paused),
|
|
@@ -274,15 +297,33 @@ export default function (pi: ExtensionAPI): void {
|
|
|
274
297
|
|
|
275
298
|
// -- session handoff ------------------------------------------------------
|
|
276
299
|
|
|
277
|
-
/** The task the pipeline is on right now, or null. */
|
|
278
|
-
const
|
|
300
|
+
/** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
|
|
301
|
+
const activePlanKeys = (dir: string): { task: string | null; feature: string | null; sprint: string | null; goal: string | null; subtask: string | null; } => {
|
|
279
302
|
try {
|
|
280
303
|
const { list } = loadFeatureList(dir);
|
|
281
|
-
|
|
304
|
+
const task = nextActionableTask(list);
|
|
305
|
+
const flat = task ? loadFeatureList(dir).list.features?.find((f) => f.id === task.featureId) ?? null : null;
|
|
306
|
+
// Resolve sprint/goal via list, and active subtask of the focused task.
|
|
307
|
+
const taskKey = task?.compositeKey ?? null;
|
|
308
|
+
const featureId = task?.featureId ?? null;
|
|
309
|
+
const feature = featureId ? (list.features ?? []).find((f) => f.id === featureId) ?? null : null;
|
|
310
|
+
const sprintId = feature?.sprintId ?? null;
|
|
311
|
+
const goalId = feature?.goalId ?? (sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId)?.goalId ?? null : null) ?? (list.goals?.[0]?.id ?? null);
|
|
312
|
+
const sprint = sprintId ? sprintId : null;
|
|
313
|
+
const goal = goalId ? goalId : null;
|
|
314
|
+
// First non-complete subtask of the active task.
|
|
315
|
+
let subtask: string | null = null;
|
|
316
|
+
const rawTask = feature && task ? feature.tasks.find((t) => t.id === task.id || t.key === task.key) ?? null : null;
|
|
317
|
+
if (rawTask?.subtasks?.length) {
|
|
318
|
+
const cur = rawTask.subtasks.find((s) => s.status !== "complete") ?? null;
|
|
319
|
+
if (cur) subtask = `${taskKey}#${cur.id ?? cur.title}`;
|
|
320
|
+
}
|
|
321
|
+
return { task: taskKey, feature: featureId, sprint, goal, subtask };
|
|
282
322
|
} catch {
|
|
283
|
-
return null;
|
|
323
|
+
return { task: null, feature: null, sprint: null, goal: null, subtask: null };
|
|
284
324
|
}
|
|
285
325
|
};
|
|
326
|
+
const activeTaskKey = (dir: string): string | null => activePlanKeys(dir).task;
|
|
286
327
|
|
|
287
328
|
/** How full this session's context is, 0..1, or null when pi cannot say. */
|
|
288
329
|
const contextRatio = (ctx: ExtensionContext): number | null => {
|
|
@@ -332,12 +373,41 @@ export default function (pi: ExtensionAPI): void {
|
|
|
332
373
|
}
|
|
333
374
|
try {
|
|
334
375
|
const { config } = loadConfig(dir);
|
|
376
|
+
const toKeys = activePlanKeys(dir);
|
|
377
|
+
// Map caller's fromTask (a compositeKey) back to its feature/sprint etc for the "from" side.
|
|
378
|
+
// We derive them from the plan so goal/sprint/feature boundaries are comparable.
|
|
379
|
+
let fromGoal: string | null = null;
|
|
380
|
+
let fromSprint: string | null = null;
|
|
381
|
+
let fromFeature: string | null = null;
|
|
382
|
+
try {
|
|
383
|
+
const { list } = loadFeatureList(dir);
|
|
384
|
+
if (fromTask) {
|
|
385
|
+
const ft = ((): { featureId: string } | null => {
|
|
386
|
+
for (const f of list.features ?? []) for (const t of f.tasks ?? []) if (t.key === fromTask || `${f.id}/${t.id}` === fromTask || t.id === fromTask) return { featureId: f.id };
|
|
387
|
+
return null;
|
|
388
|
+
})();
|
|
389
|
+
if (ft) {
|
|
390
|
+
fromFeature = ft.featureId;
|
|
391
|
+
const feat = list.features.find((f) => f.id === ft.featureId) ?? null;
|
|
392
|
+
fromSprint = feat?.sprintId ?? null;
|
|
393
|
+
fromGoal = feat?.goalId ?? (fromSprint ? (list.sprints ?? []).find((s) => s.id === fromSprint)?.goalId ?? null : null) ?? null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} catch {}
|
|
335
397
|
const decision = shouldHandoff({
|
|
336
398
|
config,
|
|
337
399
|
fromPhase,
|
|
338
400
|
toPhase,
|
|
339
401
|
fromTask,
|
|
340
|
-
toTask:
|
|
402
|
+
toTask: toKeys.task,
|
|
403
|
+
fromGoal,
|
|
404
|
+
toGoal: toKeys.goal,
|
|
405
|
+
fromSprint,
|
|
406
|
+
toSprint: toKeys.sprint,
|
|
407
|
+
fromFeature,
|
|
408
|
+
toFeature: toKeys.feature,
|
|
409
|
+
fromSubtask: null, // subtask delta is derived from task payload; tracked via fromTask composite + activePlanKeys
|
|
410
|
+
toSubtask: toKeys.subtask,
|
|
341
411
|
contextRatio: contextRatio(ctx),
|
|
342
412
|
});
|
|
343
413
|
if (!decision.handoff) return false;
|
|
@@ -473,6 +543,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
473
543
|
|
|
474
544
|
view = defaultView();
|
|
475
545
|
refreshWidget(ctx);
|
|
546
|
+
installTerminalShortcuts(ctx);
|
|
476
547
|
const { config } = loadConfig(dir);
|
|
477
548
|
lastBriefPhase = config.currentPhase;
|
|
478
549
|
|
|
@@ -1019,6 +1090,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1019
1090
|
const lines = gate.checks
|
|
1020
1091
|
.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
|
|
1021
1092
|
.join("\n");
|
|
1093
|
+
// On a passing gate in autopilot, the tool itself advances the phase
|
|
1094
|
+
// so a run without the continuous loop armed still moves forward when
|
|
1095
|
+
// the agent calls infinity_validate — that's what the brief says will
|
|
1096
|
+
// happen ("PASS → the harness advances") and what stopped research
|
|
1097
|
+
// from ever reaching DEFINE until someone typed "continue".
|
|
1098
|
+
if (gate.overall && !params?.feature && !params?.task) {
|
|
1099
|
+
try {
|
|
1100
|
+
const { needsApproval } = await import("../../src/approval.ts");
|
|
1101
|
+
const fresh = loadConfig(dir).config;
|
|
1102
|
+
if (!needsApproval(fresh, fresh.currentPhase)) {
|
|
1103
|
+
const { advancePhase } = await import("../../src/core/phases.ts");
|
|
1104
|
+
const moved = await advancePhase(dir);
|
|
1105
|
+
if (moved.ok && moved.to) {
|
|
1106
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1107
|
+
const brief = await briefText(dir);
|
|
1108
|
+
return {
|
|
1109
|
+
content: [
|
|
1110
|
+
{
|
|
1111
|
+
type: "text",
|
|
1112
|
+
text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
|
|
1113
|
+
},
|
|
1114
|
+
],
|
|
1115
|
+
details: { ...gate, advanced: moved } as unknown as typeof gate,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} catch {}
|
|
1120
|
+
}
|
|
1022
1121
|
return {
|
|
1023
1122
|
content: [
|
|
1024
1123
|
{
|
|
@@ -1246,7 +1345,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1246
1345
|
});
|
|
1247
1346
|
|
|
1248
1347
|
pi.registerCommand("infinity:init", {
|
|
1249
|
-
description: "Set up a harness here —
|
|
1348
|
+
description: "Set up a harness here — workflow, goal, sessions, display",
|
|
1250
1349
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1251
1350
|
const dir = projectDir(ctx);
|
|
1252
1351
|
const force = /\bforce\b/.test(args);
|
|
@@ -1262,57 +1361,36 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1262
1361
|
}
|
|
1263
1362
|
|
|
1264
1363
|
const detected = detectStack(dir);
|
|
1265
|
-
let phases: Phase[] | undefined;
|
|
1266
1364
|
|
|
1267
|
-
//
|
|
1365
|
+
// Three things used to be wrong here, and they compounded.
|
|
1268
1366
|
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
// "you decide everything, including what I want".
|
|
1367
|
+
// The wizard never asked what was being built — so picking "autopilot"
|
|
1368
|
+
// started a run with no idea and no scope, and the harness invented a
|
|
1369
|
+
// project and began building it.
|
|
1273
1370
|
//
|
|
1274
|
-
//
|
|
1275
|
-
// yourself, but show me the plan before you build it"
|
|
1276
|
-
// most people actually want from an unattended run.
|
|
1371
|
+
// "mode" was the only question, and one switch cannot say "drive
|
|
1372
|
+
// yourself, but show me the plan before you build it".
|
|
1277
1373
|
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
// answers mean
|
|
1374
|
+
// And even that switch only reached three phases. It is a mode per
|
|
1375
|
+
// phase now, chosen from a workflow the human can build, name and reuse.
|
|
1376
|
+
// `src/workflow.ts` owns what a workflow is, `src/intake.ts` what the
|
|
1377
|
+
// answers mean, `src/ui/wizard.ts` how they are asked.
|
|
1282
1378
|
if (ctx.hasUI) {
|
|
1283
1379
|
const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
|
|
1284
1380
|
const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
|
|
1285
|
-
const go = await ctx.ui.select(
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
);
|
|
1381
|
+
const go = await ctx.ui.select(`Create a harness here? ${detected.label} · ${summary}`, [
|
|
1382
|
+
"yes",
|
|
1383
|
+
"cancel",
|
|
1384
|
+
]);
|
|
1289
1385
|
if (go === undefined || go === "cancel") {
|
|
1290
1386
|
notify(ctx, "init cancelled — nothing was written.", "info");
|
|
1291
1387
|
return;
|
|
1292
1388
|
}
|
|
1293
|
-
|
|
1294
|
-
if (go.includes("phases")) {
|
|
1295
|
-
const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
|
|
1296
|
-
for (;;) {
|
|
1297
|
-
const rows = SELECTABLE_PHASES.map((p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p}`);
|
|
1298
|
-
const hit = await ctx.ui.select("Phases to run", [...rows, "✓ done"]);
|
|
1299
|
-
if (hit === undefined || hit === "✓ done") break;
|
|
1300
|
-
const key = SELECTABLE_PHASES[rows.indexOf(hit)];
|
|
1301
|
-
if (!key) break;
|
|
1302
|
-
if (chosen.has(key)) chosen.delete(key);
|
|
1303
|
-
else chosen.add(key);
|
|
1304
|
-
}
|
|
1305
|
-
phases = [...chosen];
|
|
1306
|
-
}
|
|
1307
1389
|
}
|
|
1308
1390
|
|
|
1309
1391
|
const wizard = ctx.hasUI
|
|
1310
|
-
? await runIntakeWizard({
|
|
1311
|
-
|
|
1312
|
-
phases,
|
|
1313
|
-
brief: goalFromArgs || null,
|
|
1314
|
-
})
|
|
1315
|
-
: ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null, phases) } as const);
|
|
1392
|
+
? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null, models: () => availableModels(ctx) })
|
|
1393
|
+
: ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
|
|
1316
1394
|
|
|
1317
1395
|
if (wizard.cancelled) {
|
|
1318
1396
|
notify(ctx, "init cancelled — nothing was written.", "info");
|
|
@@ -1324,8 +1402,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1324
1402
|
mode: plan.mode,
|
|
1325
1403
|
phases: plan.phases,
|
|
1326
1404
|
approvals: plan.approvals,
|
|
1405
|
+
phaseModes: plan.phaseModes,
|
|
1406
|
+
workflow: plan.workflow,
|
|
1407
|
+
display: plan.display,
|
|
1327
1408
|
session: plan.session,
|
|
1328
1409
|
brief: plan.brief,
|
|
1410
|
+
router: plan.router
|
|
1411
|
+
? ({
|
|
1412
|
+
enabled: !!plan.router.enabled,
|
|
1413
|
+
byDifficulty: plan.router.byDifficulty as unknown as Record<string, string>,
|
|
1414
|
+
thinkingByDifficulty: plan.router.thinkingByDifficulty as unknown as Record<string, string>,
|
|
1415
|
+
master: plan.router.master ?? "",
|
|
1416
|
+
thinkingMaster: plan.router.thinkingMaster as unknown as string,
|
|
1417
|
+
default: plan.router.default ?? "",
|
|
1418
|
+
thinkingDefault: plan.router.thinkingDefault as unknown as string,
|
|
1419
|
+
} as Partial<import("../../src/modelRouter.ts").RouterConfig>)
|
|
1420
|
+
: undefined,
|
|
1329
1421
|
force,
|
|
1330
1422
|
});
|
|
1331
1423
|
if (!result.ok) {
|
|
@@ -1353,6 +1445,143 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1353
1445
|
},
|
|
1354
1446
|
});
|
|
1355
1447
|
|
|
1448
|
+
/**
|
|
1449
|
+
* Change the workflow mid-run.
|
|
1450
|
+
*
|
|
1451
|
+
* Any of this is editable at any time and takes effect on the next gate —
|
|
1452
|
+
* a run three phases deep is exactly when someone realises they do want to
|
|
1453
|
+
* see the review after all.
|
|
1454
|
+
*/
|
|
1455
|
+
pi.registerCommand("infinity:workflow", {
|
|
1456
|
+
description: "Choose or build the workflow — which phases run, and which stop for you",
|
|
1457
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1458
|
+
const dir = projectDir(ctx);
|
|
1459
|
+
if (!isHarnessProject(dir)) {
|
|
1460
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const { config } = loadConfig(dir);
|
|
1465
|
+
const arg = args.trim();
|
|
1466
|
+
|
|
1467
|
+
if (arg === "" && !ctx.hasUI) {
|
|
1468
|
+
notify(ctx, describeCurrentWorkflow(dir), "info");
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (arg === "show" || arg === "list") {
|
|
1472
|
+
const rows = listWorkflows().map((w) => ` ${w.builtIn ? " " : "*"} ${w.name} — ${w.description}`);
|
|
1473
|
+
notify(
|
|
1474
|
+
ctx,
|
|
1475
|
+
`${describeCurrentWorkflow(dir)}\n\nAvailable (* = yours):\n${rows.join("\n")}`,
|
|
1476
|
+
"info",
|
|
1477
|
+
);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// `/infinity:workflow <name>` switches without a menu, which is what a
|
|
1482
|
+
// second run in the same terminal wants.
|
|
1483
|
+
let chosen = arg ? findWorkflow(arg) : undefined;
|
|
1484
|
+
if (arg && !chosen) {
|
|
1485
|
+
notify(ctx, `No workflow called "${arg}". \`/infinity:workflow list\` shows them.`, "warning");
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (!chosen) {
|
|
1489
|
+
if (!ctx.hasUI) {
|
|
1490
|
+
notify(ctx, "This mode has no dialogs — `/infinity:workflow <name>` switches directly.", "warning");
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
chosen = (await pickWorkflow(prompterFor(ctx))) ?? undefined;
|
|
1494
|
+
if (!chosen) {
|
|
1495
|
+
notify(ctx, "Unchanged.", "info");
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const { value } = await withLock(configPath(dir), () => {
|
|
1501
|
+
const fresh = loadConfig(dir);
|
|
1502
|
+
if (!fresh.ok) return false;
|
|
1503
|
+
applyWorkflow(fresh.config, chosen!);
|
|
1504
|
+
// Keep the legacy field in step so a 2.3 tool reading this config
|
|
1505
|
+
// still sees the same three answers it understands.
|
|
1506
|
+
fresh.config.approvals = {
|
|
1507
|
+
research: fresh.config.phaseModes?.research === "copilot",
|
|
1508
|
+
define: fresh.config.phaseModes?.define === "copilot",
|
|
1509
|
+
plan: fresh.config.phaseModes?.plan === "copilot",
|
|
1510
|
+
};
|
|
1511
|
+
fresh.config.mode = signedPhases(fresh.config).length > 0 ? "copilot" : "autopilot";
|
|
1512
|
+
return saveConfig(dir, fresh.config).ok;
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
if (!value) {
|
|
1516
|
+
notify(ctx, "Could not save the workflow — config unreadable.", "error");
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
notify(ctx, `${renderWorkflow(chosen)}\n\nIt takes effect at the next gate.`, "info");
|
|
1520
|
+
refreshWidget(ctx);
|
|
1521
|
+
void config;
|
|
1522
|
+
},
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
pi.registerCommand("infinity:display", {
|
|
1526
|
+
description: "Choose what the widget and the dashboard show, level by level",
|
|
1527
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1528
|
+
const dir = projectDir(ctx);
|
|
1529
|
+
if (!isHarnessProject(dir)) {
|
|
1530
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
const arg = args.trim();
|
|
1534
|
+
const current = normalizeDisplay(loadConfig(dir).config.display);
|
|
1535
|
+
|
|
1536
|
+
if (arg === "show" || arg === "list") {
|
|
1537
|
+
const rows = listDisplays().map((d) => ` ${d.builtIn ? " " : "*"} ${d.name} — ${d.description}`);
|
|
1538
|
+
notify(
|
|
1539
|
+
ctx,
|
|
1540
|
+
`Now: ${summarizeDisplay(current)}\n\nTemplates (* = yours):\n${rows.join("\n")}`,
|
|
1541
|
+
"info",
|
|
1542
|
+
);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
let next: DisplayPolicy | undefined;
|
|
1547
|
+
if (arg) {
|
|
1548
|
+
const template = findDisplay(arg);
|
|
1549
|
+
if (!template) {
|
|
1550
|
+
notify(ctx, `No template called "${arg}". \`/infinity:display list\` shows them.`, "warning");
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
next = template.policy;
|
|
1554
|
+
} else {
|
|
1555
|
+
if (!ctx.hasUI) {
|
|
1556
|
+
notify(ctx, `Now: ${summarizeDisplay(current)}. \`/infinity:display <template>\` switches.`, "info");
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
next = await pickDisplay(prompterFor(ctx));
|
|
1560
|
+
if (!next) {
|
|
1561
|
+
notify(ctx, "Unchanged.", "info");
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const { value } = await withLock(configPath(dir), () => {
|
|
1567
|
+
const fresh = loadConfig(dir);
|
|
1568
|
+
if (!fresh.ok) return false;
|
|
1569
|
+
fresh.config.display = normalizeDisplay(next);
|
|
1570
|
+
return saveConfig(dir, fresh.config).ok;
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
if (!value) {
|
|
1574
|
+
notify(ctx, "Could not save the display settings — config unreadable.", "error");
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
// The widget is the answer to "did that do what I wanted", so redraw it
|
|
1578
|
+
// before saying anything about it.
|
|
1579
|
+
view = defaultView();
|
|
1580
|
+
refreshWidget(ctx);
|
|
1581
|
+
notify(ctx, `Showing: ${summarizeDisplay(normalizeDisplay(next))}`, "info");
|
|
1582
|
+
},
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1356
1585
|
/**
|
|
1357
1586
|
* Continue the run in a replacement session.
|
|
1358
1587
|
*
|
|
@@ -2210,24 +2439,58 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2210
2439
|
// `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
|
|
2211
2440
|
// to line end) and ctrl+o (expand tool output). Shadowing an editor key to
|
|
2212
2441
|
// scroll a widget would be a worse bug than the one being fixed.
|
|
2442
|
+
//
|
|
2443
|
+
// Shortcuts are editor-focused via registerShortcut, but also handled as a
|
|
2444
|
+
// raw terminal fallback so they work when an overlay or selector has focus
|
|
2445
|
+
// or when the terminal sends the legacy ESC+j sequence that the editor
|
|
2446
|
+
// otherwise swallows as text.
|
|
2447
|
+
|
|
2448
|
+
const scrollDown = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, SCROLL_STEP);
|
|
2449
|
+
const scrollUp = async (ctx: ExtensionContext): Promise<void> => moveView(ctx, -SCROLL_STEP);
|
|
2450
|
+
const toggleExpand = async (ctx: ExtensionContext): Promise<void> => {
|
|
2451
|
+
view = { ...view, expanded: !view.expanded };
|
|
2452
|
+
refreshWidget(ctx);
|
|
2453
|
+
};
|
|
2213
2454
|
|
|
2214
|
-
pi.registerShortcut("alt+j", {
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2455
|
+
pi.registerShortcut("alt+j", { description: "infinity-harness: scroll the plan down", handler: scrollDown });
|
|
2456
|
+
pi.registerShortcut("alt+k", { description: "infinity-harness: scroll the plan up", handler: scrollUp });
|
|
2457
|
+
pi.registerShortcut("alt+o", { description: "infinity-harness: expand or collapse the plan widget", handler: toggleExpand });
|
|
2458
|
+
// Uppercase handling covered by the raw terminal fallback below which
|
|
2459
|
+
// lowercases data before matching; KeyId type only allows lowercase.
|
|
2460
|
+
|
|
2461
|
+
// Fallback raw input handler — runs even when the editor is not the
|
|
2462
|
+
// focused component (e.g. a selector is open). Must be installed per-
|
|
2463
|
+
// session because onTerminalInput is a UI session thing, not a global.
|
|
2464
|
+
let removeTerminalShortcut: (() => void) | null = null;
|
|
2465
|
+
const installTerminalShortcuts = (ctx: ExtensionContext): void => {
|
|
2466
|
+
try {
|
|
2467
|
+
removeTerminalShortcut?.();
|
|
2468
|
+
} catch {}
|
|
2469
|
+
try {
|
|
2470
|
+
// matchesKey lives in pi-tui but re-exported by pi; use the extension
|
|
2471
|
+
// input raw matcher via string compare for ESC-prefixed alt.
|
|
2472
|
+
removeTerminalShortcut = ctx.ui.onTerminalInput((data: string) => {
|
|
2473
|
+
// Legacy alt+letter is ESC + lower letter. Kitty may send CSI-u; both
|
|
2474
|
+
// are handled by normalising to lookahead then matching via the same
|
|
2475
|
+
// strings registerShortcut uses.
|
|
2476
|
+
const lower = data.toLowerCase();
|
|
2477
|
+
// Fast path: alt+j/k/o as ESC + letter (\x1bj) or higher-plane.
|
|
2478
|
+
if (data === "\x1bj" || data === "\x1bJ" || lower === "\x1bj") {
|
|
2479
|
+
void scrollDown(ctx);
|
|
2480
|
+
return { consume: true };
|
|
2481
|
+
}
|
|
2482
|
+
if (data === "\x1bk" || data === "\x1bK" || lower === "\x1bk") {
|
|
2483
|
+
void scrollUp(ctx);
|
|
2484
|
+
return { consume: true };
|
|
2485
|
+
}
|
|
2486
|
+
if (data === "\x1bo" || data === "\x1bO" || lower === "\x1bo") {
|
|
2487
|
+
void toggleExpand(ctx);
|
|
2488
|
+
return { consume: true };
|
|
2489
|
+
}
|
|
2490
|
+
return undefined;
|
|
2491
|
+
});
|
|
2492
|
+
} catch {}
|
|
2493
|
+
};
|
|
2231
2494
|
|
|
2232
2495
|
pi.registerCommand("infinity:scroll", {
|
|
2233
2496
|
description: "Move the plan widget — up, down, top, bottom, expand, follow",
|
|
@@ -2295,6 +2558,20 @@ function errMsg(e: unknown): string {
|
|
|
2295
2558
|
return e instanceof Error ? e.message : String(e);
|
|
2296
2559
|
}
|
|
2297
2560
|
|
|
2561
|
+
/** The workflow this project is on, and whether it still matches a named one. */
|
|
2562
|
+
function describeCurrentWorkflow(dir: string): string {
|
|
2563
|
+
const { config } = loadConfig(dir);
|
|
2564
|
+
const named = matchWorkflow(config);
|
|
2565
|
+
const head = `Workflow: ${summarizeWorkflow(config)}`;
|
|
2566
|
+
const rail = (config.phases?.enabled ?? [])
|
|
2567
|
+
.map((p) => (config.phaseModes?.[p] === "copilot" ? `[${p}]` : p))
|
|
2568
|
+
.join(" → ");
|
|
2569
|
+
const drift = named
|
|
2570
|
+
? ""
|
|
2571
|
+
: "\n\nThese settings do not match any saved workflow. `/infinity:workflow` can save them as one.";
|
|
2572
|
+
return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2298
2575
|
/**
|
|
2299
2576
|
* The few sentences the run cannot afford to have summarised away.
|
|
2300
2577
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/src/approval.ts
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { HarnessConfig, Phase } from "./core/types.ts";
|
|
22
|
-
import { APPROVABLE_PHASES } from "./core/types.ts";
|
|
23
22
|
import { loadConfig, saveConfig } from "./core/config.ts";
|
|
23
|
+
import { modeFor, signedPhases, SIGNABLE_PHASES } from "./workflow.ts";
|
|
24
24
|
|
|
25
25
|
export type ApprovalRequest = {
|
|
26
26
|
phase: Phase;
|
|
@@ -29,26 +29,31 @@ export type ApprovalRequest = {
|
|
|
29
29
|
prompt: string;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
/** Every phase except INIT, which is plumbing rather than work. */
|
|
32
33
|
export function isApprovable(phase: Phase | null): boolean {
|
|
33
|
-
return phase !== null &&
|
|
34
|
+
return phase !== null && SIGNABLE_PHASES.includes(phase);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** Does `phase` need a signature before the pipeline may leave it? */
|
|
37
38
|
export function needsApproval(config: HarnessConfig, phase: Phase | null): boolean {
|
|
38
39
|
if (!isApprovable(phase)) return false;
|
|
39
|
-
|
|
40
|
-
return approvals[phase as string] === true;
|
|
40
|
+
return modeFor(config, phase) === "copilot";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Which phases the human has asked to sign, in pipeline order. */
|
|
44
44
|
export function approvedPhases(config: HarnessConfig): Phase[] {
|
|
45
|
-
return
|
|
45
|
+
return signedPhases(config);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const ARTIFACTS: Record<string, string[]> = {
|
|
49
49
|
research: ["harness/docs/RESEARCH.md"],
|
|
50
50
|
define: ["specs/prd.md", "harness/sprint-contract.md", "the acceptance criteria in the plan"],
|
|
51
51
|
plan: ["the task list in the widget, or `/infinity:dashboard`"],
|
|
52
|
+
build: ["the diff since the last phase — `git diff`", "the tasks marked complete in the plan"],
|
|
53
|
+
verify: ["the test output", "what the tests still do not cover"],
|
|
54
|
+
simplify: ["what was deleted — `git diff --stat`"],
|
|
55
|
+
review: ["harness/evaluator-rubric.md and the score against it", "README.md and harness/docs/"],
|
|
56
|
+
ship: ["CHANGELOG.md", "the tag, and `git log` since the last one"],
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
const ASKS: Record<string, string> = {
|
|
@@ -57,6 +62,11 @@ const ASKS: Record<string, string> = {
|
|
|
57
62
|
define:
|
|
58
63
|
"Is this the thing you want built, and would meeting these criteria convince you it works?",
|
|
59
64
|
plan: "Does this plan build that thing, in an order that makes sense, with nothing important missing?",
|
|
65
|
+
build: "Is this the code you wanted written, and would you be happy to own it?",
|
|
66
|
+
verify: "Do these tests actually prove the thing works, or only that it runs?",
|
|
67
|
+
simplify: "Is what is left simpler, and is anything missing that should not be?",
|
|
68
|
+
review: "Would you approve this if someone else had written it?",
|
|
69
|
+
ship: "Is this ready to go out, under this version, with this changelog?",
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
export function describeApproval(phase: Phase): ApprovalRequest {
|
package/src/core/config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { HarnessConfig, GateHistoryEntry, Phase, Role } from "./types.ts";
|
|
11
11
|
import { DEFAULT_ENABLED_PHASES, PHASE_ROLE } from "./types.ts";
|
|
12
|
+
import { defaultDisplay, normalizeDisplay } from "../ui/display.ts";
|
|
12
13
|
import { configPath } from "./paths.ts";
|
|
13
14
|
import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
|
|
14
15
|
|
|
@@ -49,8 +50,11 @@ export function defaultConfig(): HarnessConfig {
|
|
|
49
50
|
},
|
|
50
51
|
phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
|
|
51
52
|
roles: { strict: false },
|
|
52
|
-
session: { handoff: "
|
|
53
|
+
session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
|
|
53
54
|
approvals: { research: false, define: false, plan: false },
|
|
55
|
+
phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
|
|
56
|
+
workflow: { id: "autopilot", name: "autopilot" },
|
|
57
|
+
display: defaultDisplay(),
|
|
54
58
|
intake: { completed: false, brief: null, at: null },
|
|
55
59
|
awaitingApproval: null,
|
|
56
60
|
loop: {
|
|
@@ -89,6 +93,45 @@ function deepMerge<T>(defaults: T, partial: unknown): T {
|
|
|
89
93
|
return out as T;
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Bring an older config forward on read.
|
|
98
|
+
*
|
|
99
|
+
* 2.3 had a three-phase `approvals` switch; 2.4 has a mode for every phase.
|
|
100
|
+
* A project mid-run must not lose the approvals it was configured with just
|
|
101
|
+
* because the shape moved, and nobody should have to edit JSON to upgrade.
|
|
102
|
+
* The migration is read-only — it takes effect on the next save like any other
|
|
103
|
+
* change — so a downgrade still finds the old field where it left it.
|
|
104
|
+
*/
|
|
105
|
+
function migrate(config: HarnessConfig, stored: Partial<HarnessConfig>): HarnessConfig {
|
|
106
|
+
const out = config as Record<string, unknown>;
|
|
107
|
+
const phases = Array.isArray(config.phases?.enabled) ? config.phases.enabled : [...DEFAULT_ENABLED_PHASES];
|
|
108
|
+
|
|
109
|
+
// The signal is what the *file* had, not what the merge produced: defaults
|
|
110
|
+
// supply a `phaseModes` for every phase, so a merged config always looks
|
|
111
|
+
// migrated and the old approvals would be silently dropped.
|
|
112
|
+
const hadModes =
|
|
113
|
+
typeof stored.phaseModes === "object" &&
|
|
114
|
+
stored.phaseModes !== null &&
|
|
115
|
+
Object.keys(stored.phaseModes).length > 0;
|
|
116
|
+
|
|
117
|
+
if (!hadModes) {
|
|
118
|
+
const approvals = (stored.approvals ?? {}) as Record<string, unknown>;
|
|
119
|
+
const next: Record<string, string> = {};
|
|
120
|
+
for (const p of phases) next[p] = approvals[p] === true ? "copilot" : "autopilot";
|
|
121
|
+
out.phaseModes = next;
|
|
122
|
+
if (!stored.workflow) {
|
|
123
|
+
const signed = phases.filter((p) => next[p] === "copilot");
|
|
124
|
+
out.workflow =
|
|
125
|
+
signed.length === 0
|
|
126
|
+
? { id: "autopilot", name: "autopilot" }
|
|
127
|
+
: { id: "copilot", name: "copilot" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
config.display = normalizeDisplay(config.display);
|
|
132
|
+
return config;
|
|
133
|
+
}
|
|
134
|
+
|
|
92
135
|
export type LoadResult = {
|
|
93
136
|
ok: boolean;
|
|
94
137
|
config: HarnessConfig;
|
|
@@ -114,7 +157,7 @@ export function loadConfig(targetDir: string): LoadResult {
|
|
|
114
157
|
if (raw === null) {
|
|
115
158
|
return { ok: false, config: defaultConfig(), error: "harness/config.json is empty", seeded: true };
|
|
116
159
|
}
|
|
117
|
-
return { ok: true, config: deepMerge(defaultConfig(), raw), error: null, seeded: false };
|
|
160
|
+
return { ok: true, config: migrate(deepMerge(defaultConfig(), raw), raw), error: null, seeded: false };
|
|
118
161
|
} catch (e) {
|
|
119
162
|
const msg = e instanceof Error ? e.message : String(e);
|
|
120
163
|
return { ok: false, config: defaultConfig(), error: msg, seeded: false };
|