infinity-harness 2.3.0 → 2.4.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 +85 -1
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +223 -39
- package/package.json +2 -2
- package/src/approval.ts +15 -5
- package/src/core/config.ts +44 -1
- package/src/core/init.ts +22 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +141 -12
- package/src/core/types.ts +49 -1
- package/src/intake.ts +124 -115
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +6 -0
- package/src/ui/dashboard.ts +90 -27
- 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 +257 -90
- 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
|
|
|
@@ -117,6 +139,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
117
139
|
let remoteDir: string | null = null;
|
|
118
140
|
let view: WidgetView = defaultView();
|
|
119
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Is this instance's session still the live one?
|
|
144
|
+
*
|
|
145
|
+
* After `ctx.newSession()` pi tears the old runtime down and rebinds
|
|
146
|
+
* extensions, but this closure and its registered handlers still exist. Any
|
|
147
|
+
* of them that touches `pi` or a captured `ctx` afterwards gets
|
|
148
|
+
* "This extension ctx is stale after session replacement" — which is what a
|
|
149
|
+
* handoff produced on every single turn until this flag existed.
|
|
150
|
+
*/
|
|
151
|
+
let sessionLive = true;
|
|
152
|
+
|
|
120
153
|
/**
|
|
121
154
|
* Is a continuous run armed?
|
|
122
155
|
*
|
|
@@ -154,6 +187,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
187
|
sessions: run?.sessions ?? null,
|
|
155
188
|
intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
|
|
156
189
|
awaitingApproval: config.awaitingApproval ?? null,
|
|
190
|
+
display: normalizeDisplay(config.display),
|
|
157
191
|
phase: config.currentPhase,
|
|
158
192
|
enabledPhases: config.phases?.enabled,
|
|
159
193
|
paused: Boolean(config.paused),
|
|
@@ -307,6 +341,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
307
341
|
// this session stops driving and the replacement never arrives. One
|
|
308
342
|
// attempt, then carry on here — a run that continues in a fat session is
|
|
309
343
|
// far better than a run that stops.
|
|
344
|
+
// A one-shot `pi -p` run has no next turn to hand anything to: replacing
|
|
345
|
+
// its session mid-flight produces a stale-context error and nothing else.
|
|
346
|
+
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return false;
|
|
347
|
+
|
|
310
348
|
if (handingOff) {
|
|
311
349
|
if (hasPendingHandoff(dir)) {
|
|
312
350
|
notify(ctx, "infinity-harness: the new session never started — continuing here.", "warning");
|
|
@@ -531,6 +569,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
531
569
|
* summarised. Anything the run cannot afford to forget belongs here.
|
|
532
570
|
*/
|
|
533
571
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
572
|
+
if (!sessionLive) return;
|
|
534
573
|
const dir = projectDir(ctx);
|
|
535
574
|
if (!isHarnessProject(dir)) return;
|
|
536
575
|
try {
|
|
@@ -544,6 +583,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
544
583
|
});
|
|
545
584
|
|
|
546
585
|
pi.on("session_tree", async (_event, ctx) => {
|
|
586
|
+
if (!sessionLive) return;
|
|
547
587
|
refreshWidget(ctx);
|
|
548
588
|
});
|
|
549
589
|
|
|
@@ -553,6 +593,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
553
593
|
* few calls costs little and keeps the plan honest.
|
|
554
594
|
*/
|
|
555
595
|
pi.on("context", async (event, ctx) => {
|
|
596
|
+
if (!sessionLive) return;
|
|
556
597
|
const dir = projectDir(ctx);
|
|
557
598
|
if (!isHarnessProject(dir)) return;
|
|
558
599
|
|
|
@@ -630,6 +671,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
630
671
|
});
|
|
631
672
|
|
|
632
673
|
pi.on("session_compact", async (event, ctx) => {
|
|
674
|
+
if (!sessionLive) return;
|
|
633
675
|
const dir = projectDir(ctx);
|
|
634
676
|
if (!isHarnessProject(dir)) return;
|
|
635
677
|
try {
|
|
@@ -658,6 +700,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
658
700
|
});
|
|
659
701
|
|
|
660
702
|
pi.on("turn_end", async (_event, ctx) => {
|
|
703
|
+
if (!sessionLive) return;
|
|
661
704
|
refreshWidget(ctx);
|
|
662
705
|
});
|
|
663
706
|
|
|
@@ -670,6 +713,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
670
713
|
* `/reload`, `/resume`, and pi being restarted.
|
|
671
714
|
*/
|
|
672
715
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
716
|
+
if (!sessionLive) return;
|
|
673
717
|
const dir = projectDir(ctx);
|
|
674
718
|
if (!isHarnessProject(dir)) return;
|
|
675
719
|
if (loopBusy || handingOff) return;
|
|
@@ -749,6 +793,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
749
793
|
* touch of the config would stop the harness configuring itself.
|
|
750
794
|
*/
|
|
751
795
|
pi.on("tool_call", async (event, ctx) => {
|
|
796
|
+
if (!sessionLive) return;
|
|
752
797
|
const dir = projectDir(ctx);
|
|
753
798
|
if (!isHarnessProject(dir)) return;
|
|
754
799
|
|
|
@@ -791,6 +836,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
791
836
|
});
|
|
792
837
|
|
|
793
838
|
pi.on("session_shutdown", async () => {
|
|
839
|
+
sessionLive = false;
|
|
794
840
|
if (remoteServer) {
|
|
795
841
|
try {
|
|
796
842
|
await remoteServer.close();
|
|
@@ -1223,7 +1269,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1223
1269
|
});
|
|
1224
1270
|
|
|
1225
1271
|
pi.registerCommand("infinity:init", {
|
|
1226
|
-
description: "Set up a harness here —
|
|
1272
|
+
description: "Set up a harness here — workflow, goal, sessions, display",
|
|
1227
1273
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1228
1274
|
const dir = projectDir(ctx);
|
|
1229
1275
|
const force = /\bforce\b/.test(args);
|
|
@@ -1239,57 +1285,36 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1239
1285
|
}
|
|
1240
1286
|
|
|
1241
1287
|
const detected = detectStack(dir);
|
|
1242
|
-
let phases: Phase[] | undefined;
|
|
1243
1288
|
|
|
1244
|
-
//
|
|
1289
|
+
// Three things used to be wrong here, and they compounded.
|
|
1245
1290
|
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
// "you decide everything, including what I want".
|
|
1291
|
+
// The wizard never asked what was being built — so picking "autopilot"
|
|
1292
|
+
// started a run with no idea and no scope, and the harness invented a
|
|
1293
|
+
// project and began building it.
|
|
1250
1294
|
//
|
|
1251
|
-
//
|
|
1252
|
-
// yourself, but show me the plan before you build it"
|
|
1253
|
-
// most people actually want from an unattended run.
|
|
1295
|
+
// "mode" was the only question, and one switch cannot say "drive
|
|
1296
|
+
// yourself, but show me the plan before you build it".
|
|
1254
1297
|
//
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
// answers mean
|
|
1298
|
+
// And even that switch only reached three phases. It is a mode per
|
|
1299
|
+
// phase now, chosen from a workflow the human can build, name and reuse.
|
|
1300
|
+
// `src/workflow.ts` owns what a workflow is, `src/intake.ts` what the
|
|
1301
|
+
// answers mean, `src/ui/wizard.ts` how they are asked.
|
|
1259
1302
|
if (ctx.hasUI) {
|
|
1260
1303
|
const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
|
|
1261
1304
|
const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
|
|
1262
|
-
const go = await ctx.ui.select(
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
);
|
|
1305
|
+
const go = await ctx.ui.select(`Create a harness here? ${detected.label} · ${summary}`, [
|
|
1306
|
+
"yes",
|
|
1307
|
+
"cancel",
|
|
1308
|
+
]);
|
|
1266
1309
|
if (go === undefined || go === "cancel") {
|
|
1267
1310
|
notify(ctx, "init cancelled — nothing was written.", "info");
|
|
1268
1311
|
return;
|
|
1269
1312
|
}
|
|
1270
|
-
|
|
1271
|
-
if (go.includes("phases")) {
|
|
1272
|
-
const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
|
|
1273
|
-
for (;;) {
|
|
1274
|
-
const rows = SELECTABLE_PHASES.map((p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p}`);
|
|
1275
|
-
const hit = await ctx.ui.select("Phases to run", [...rows, "✓ done"]);
|
|
1276
|
-
if (hit === undefined || hit === "✓ done") break;
|
|
1277
|
-
const key = SELECTABLE_PHASES[rows.indexOf(hit)];
|
|
1278
|
-
if (!key) break;
|
|
1279
|
-
if (chosen.has(key)) chosen.delete(key);
|
|
1280
|
-
else chosen.add(key);
|
|
1281
|
-
}
|
|
1282
|
-
phases = [...chosen];
|
|
1283
|
-
}
|
|
1284
1313
|
}
|
|
1285
1314
|
|
|
1286
1315
|
const wizard = ctx.hasUI
|
|
1287
|
-
? await runIntakeWizard({
|
|
1288
|
-
|
|
1289
|
-
phases,
|
|
1290
|
-
brief: goalFromArgs || null,
|
|
1291
|
-
})
|
|
1292
|
-
: ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null, phases) } as const);
|
|
1316
|
+
? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null })
|
|
1317
|
+
: ({ cancelled: false, plan: unattendedIntake(goalFromArgs || null) } as const);
|
|
1293
1318
|
|
|
1294
1319
|
if (wizard.cancelled) {
|
|
1295
1320
|
notify(ctx, "init cancelled — nothing was written.", "info");
|
|
@@ -1301,6 +1326,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1301
1326
|
mode: plan.mode,
|
|
1302
1327
|
phases: plan.phases,
|
|
1303
1328
|
approvals: plan.approvals,
|
|
1329
|
+
phaseModes: plan.phaseModes,
|
|
1330
|
+
workflow: plan.workflow,
|
|
1331
|
+
display: plan.display,
|
|
1304
1332
|
session: plan.session,
|
|
1305
1333
|
brief: plan.brief,
|
|
1306
1334
|
force,
|
|
@@ -1330,6 +1358,143 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1330
1358
|
},
|
|
1331
1359
|
});
|
|
1332
1360
|
|
|
1361
|
+
/**
|
|
1362
|
+
* Change the workflow mid-run.
|
|
1363
|
+
*
|
|
1364
|
+
* Any of this is editable at any time and takes effect on the next gate —
|
|
1365
|
+
* a run three phases deep is exactly when someone realises they do want to
|
|
1366
|
+
* see the review after all.
|
|
1367
|
+
*/
|
|
1368
|
+
pi.registerCommand("infinity:workflow", {
|
|
1369
|
+
description: "Choose or build the workflow — which phases run, and which stop for you",
|
|
1370
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1371
|
+
const dir = projectDir(ctx);
|
|
1372
|
+
if (!isHarnessProject(dir)) {
|
|
1373
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const { config } = loadConfig(dir);
|
|
1378
|
+
const arg = args.trim();
|
|
1379
|
+
|
|
1380
|
+
if (arg === "" && !ctx.hasUI) {
|
|
1381
|
+
notify(ctx, describeCurrentWorkflow(dir), "info");
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (arg === "show" || arg === "list") {
|
|
1385
|
+
const rows = listWorkflows().map((w) => ` ${w.builtIn ? " " : "*"} ${w.name} — ${w.description}`);
|
|
1386
|
+
notify(
|
|
1387
|
+
ctx,
|
|
1388
|
+
`${describeCurrentWorkflow(dir)}\n\nAvailable (* = yours):\n${rows.join("\n")}`,
|
|
1389
|
+
"info",
|
|
1390
|
+
);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// `/infinity:workflow <name>` switches without a menu, which is what a
|
|
1395
|
+
// second run in the same terminal wants.
|
|
1396
|
+
let chosen = arg ? findWorkflow(arg) : undefined;
|
|
1397
|
+
if (arg && !chosen) {
|
|
1398
|
+
notify(ctx, `No workflow called "${arg}". \`/infinity:workflow list\` shows them.`, "warning");
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
if (!chosen) {
|
|
1402
|
+
if (!ctx.hasUI) {
|
|
1403
|
+
notify(ctx, "This mode has no dialogs — `/infinity:workflow <name>` switches directly.", "warning");
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
chosen = (await pickWorkflow(prompterFor(ctx))) ?? undefined;
|
|
1407
|
+
if (!chosen) {
|
|
1408
|
+
notify(ctx, "Unchanged.", "info");
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const { value } = await withLock(configPath(dir), () => {
|
|
1414
|
+
const fresh = loadConfig(dir);
|
|
1415
|
+
if (!fresh.ok) return false;
|
|
1416
|
+
applyWorkflow(fresh.config, chosen!);
|
|
1417
|
+
// Keep the legacy field in step so a 2.3 tool reading this config
|
|
1418
|
+
// still sees the same three answers it understands.
|
|
1419
|
+
fresh.config.approvals = {
|
|
1420
|
+
research: fresh.config.phaseModes?.research === "copilot",
|
|
1421
|
+
define: fresh.config.phaseModes?.define === "copilot",
|
|
1422
|
+
plan: fresh.config.phaseModes?.plan === "copilot",
|
|
1423
|
+
};
|
|
1424
|
+
fresh.config.mode = signedPhases(fresh.config).length > 0 ? "copilot" : "autopilot";
|
|
1425
|
+
return saveConfig(dir, fresh.config).ok;
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
if (!value) {
|
|
1429
|
+
notify(ctx, "Could not save the workflow — config unreadable.", "error");
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
notify(ctx, `${renderWorkflow(chosen)}\n\nIt takes effect at the next gate.`, "info");
|
|
1433
|
+
refreshWidget(ctx);
|
|
1434
|
+
void config;
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
pi.registerCommand("infinity:display", {
|
|
1439
|
+
description: "Choose what the widget and the dashboard show, level by level",
|
|
1440
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
1441
|
+
const dir = projectDir(ctx);
|
|
1442
|
+
if (!isHarnessProject(dir)) {
|
|
1443
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const arg = args.trim();
|
|
1447
|
+
const current = normalizeDisplay(loadConfig(dir).config.display);
|
|
1448
|
+
|
|
1449
|
+
if (arg === "show" || arg === "list") {
|
|
1450
|
+
const rows = listDisplays().map((d) => ` ${d.builtIn ? " " : "*"} ${d.name} — ${d.description}`);
|
|
1451
|
+
notify(
|
|
1452
|
+
ctx,
|
|
1453
|
+
`Now: ${summarizeDisplay(current)}\n\nTemplates (* = yours):\n${rows.join("\n")}`,
|
|
1454
|
+
"info",
|
|
1455
|
+
);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
let next: DisplayPolicy | undefined;
|
|
1460
|
+
if (arg) {
|
|
1461
|
+
const template = findDisplay(arg);
|
|
1462
|
+
if (!template) {
|
|
1463
|
+
notify(ctx, `No template called "${arg}". \`/infinity:display list\` shows them.`, "warning");
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
next = template.policy;
|
|
1467
|
+
} else {
|
|
1468
|
+
if (!ctx.hasUI) {
|
|
1469
|
+
notify(ctx, `Now: ${summarizeDisplay(current)}. \`/infinity:display <template>\` switches.`, "info");
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
next = await pickDisplay(prompterFor(ctx));
|
|
1473
|
+
if (!next) {
|
|
1474
|
+
notify(ctx, "Unchanged.", "info");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const { value } = await withLock(configPath(dir), () => {
|
|
1480
|
+
const fresh = loadConfig(dir);
|
|
1481
|
+
if (!fresh.ok) return false;
|
|
1482
|
+
fresh.config.display = normalizeDisplay(next);
|
|
1483
|
+
return saveConfig(dir, fresh.config).ok;
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
if (!value) {
|
|
1487
|
+
notify(ctx, "Could not save the display settings — config unreadable.", "error");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
// The widget is the answer to "did that do what I wanted", so redraw it
|
|
1491
|
+
// before saying anything about it.
|
|
1492
|
+
view = defaultView();
|
|
1493
|
+
refreshWidget(ctx);
|
|
1494
|
+
notify(ctx, `Showing: ${summarizeDisplay(normalizeDisplay(next))}`, "info");
|
|
1495
|
+
},
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1333
1498
|
/**
|
|
1334
1499
|
* Continue the run in a replacement session.
|
|
1335
1500
|
*
|
|
@@ -1392,7 +1557,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1392
1557
|
} catch (e) {
|
|
1393
1558
|
// A handoff that cannot happen must never end the run: fall back to
|
|
1394
1559
|
// carrying on in this session, which is the pre-2.3 behaviour.
|
|
1560
|
+
//
|
|
1561
|
+
// Unless the session is already gone — `newSession` can fail *after*
|
|
1562
|
+
// replacing the runtime, and reaching for the old `pi` then is the
|
|
1563
|
+
// "stale ctx" error rather than a recovery.
|
|
1395
1564
|
clearHandoff(dir);
|
|
1565
|
+
if (!sessionLive) return;
|
|
1396
1566
|
notify(ctx, `infinity-harness: could not start a new session — ${errMsg(e)}`, "warning");
|
|
1397
1567
|
pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
|
|
1398
1568
|
}
|
|
@@ -2267,6 +2437,20 @@ function errMsg(e: unknown): string {
|
|
|
2267
2437
|
return e instanceof Error ? e.message : String(e);
|
|
2268
2438
|
}
|
|
2269
2439
|
|
|
2440
|
+
/** The workflow this project is on, and whether it still matches a named one. */
|
|
2441
|
+
function describeCurrentWorkflow(dir: string): string {
|
|
2442
|
+
const { config } = loadConfig(dir);
|
|
2443
|
+
const named = matchWorkflow(config);
|
|
2444
|
+
const head = `Workflow: ${summarizeWorkflow(config)}`;
|
|
2445
|
+
const rail = (config.phases?.enabled ?? [])
|
|
2446
|
+
.map((p) => (config.phaseModes?.[p] === "copilot" ? `[${p}]` : p))
|
|
2447
|
+
.join(" → ");
|
|
2448
|
+
const drift = named
|
|
2449
|
+
? ""
|
|
2450
|
+
: "\n\nThese settings do not match any saved workflow. `/infinity:workflow` can save them as one.";
|
|
2451
|
+
return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2270
2454
|
/**
|
|
2271
2455
|
* The few sentences the run cannot afford to have summarised away.
|
|
2272
2456
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "A pi agent extension that runs a gated build pipeline unattended
|
|
3
|
+
"version": "2.4.0",
|
|
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": [
|
|
7
7
|
"pi-package",
|
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
|
|
|
@@ -51,6 +52,9 @@ export function defaultConfig(): HarnessConfig {
|
|
|
51
52
|
roles: { strict: false },
|
|
52
53
|
session: { handoff: "phase", contextThreshold: 0.7, 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 };
|
package/src/core/init.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { DEFAULT_ENABLED_PHASES, PHASE_ORDER, PHASE_ROLE } from "./types.ts";
|
|
|
29
29
|
import { defaultConfig, saveConfig } from "./config.ts";
|
|
30
30
|
import { emptyFeatureList, saveFeatureList } from "./featureList.ts";
|
|
31
31
|
import * as P from "./paths.ts";
|
|
32
|
+
import { normalizeDisplay } from "../ui/display.ts";
|
|
32
33
|
|
|
33
34
|
export type StackId = "node" | "python" | "rust" | "go" | "unknown";
|
|
34
35
|
|
|
@@ -149,8 +150,14 @@ export type InitOptions = {
|
|
|
149
150
|
commands?: Partial<ProjectCommands>;
|
|
150
151
|
/** Re-scaffold missing files in a project that already has a config. */
|
|
151
152
|
force?: boolean;
|
|
152
|
-
/**
|
|
153
|
+
/** Legacy three-phase approval switch, kept in step with `phaseModes`. */
|
|
153
154
|
approvals?: Partial<HarnessConfig["approvals"]>;
|
|
155
|
+
/** Mode per phase — which of them stop for a human signature. */
|
|
156
|
+
phaseModes?: HarnessConfig["phaseModes"];
|
|
157
|
+
/** Which named workflow those modes came from. */
|
|
158
|
+
workflow?: HarnessConfig["workflow"];
|
|
159
|
+
/** What the widget and the dashboard draw. */
|
|
160
|
+
display?: HarnessConfig["display"];
|
|
154
161
|
/** Session-handoff policy. Defaults to a fresh session per phase. */
|
|
155
162
|
session?: Partial<HarnessConfig["session"]>;
|
|
156
163
|
/** What the human said they want built. Recorded, and read by the first brief. */
|
|
@@ -210,6 +217,20 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
210
217
|
config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
|
|
211
218
|
config.approvals = { ...config.approvals, ...stripUndefined(options.approvals ?? {}) };
|
|
212
219
|
config.session = { ...config.session, ...stripUndefined(options.session ?? {}) };
|
|
220
|
+
// Every enabled phase gets a mode, so a phase list and a mode map cannot
|
|
221
|
+
// disagree about which phases exist. A caller that still passes the 2.3
|
|
222
|
+
// `approvals` shape and no modes gets what it asked for rather than silently
|
|
223
|
+
// getting autopilot — the same rule `loadConfig` applies to an older file.
|
|
224
|
+
const legacy = (options.approvals ?? {}) as Record<string, unknown>;
|
|
225
|
+
const hasModes = options.phaseModes && Object.keys(options.phaseModes).length > 0;
|
|
226
|
+
config.phaseModes = Object.fromEntries(
|
|
227
|
+
phases.map((p) => [
|
|
228
|
+
p,
|
|
229
|
+
(hasModes ? options.phaseModes?.[p] === "copilot" : legacy[p] === true) ? "copilot" : "autopilot",
|
|
230
|
+
]),
|
|
231
|
+
);
|
|
232
|
+
if (options.workflow) config.workflow = options.workflow;
|
|
233
|
+
if (options.display) config.display = normalizeDisplay(options.display);
|
|
213
234
|
if (options.brief !== undefined) {
|
|
214
235
|
config.intake = {
|
|
215
236
|
completed: true,
|
package/src/core/paths.ts
CHANGED
|
@@ -5,10 +5,35 @@
|
|
|
5
5
|
* Nothing outside this module hardcodes a harness path.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { homedir } from "node:os";
|
|
8
9
|
import { resolve } from "node:path";
|
|
9
10
|
|
|
10
11
|
export const HARNESS_DIRNAME = "harness";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Where things that belong to the *person* live, rather than to a project.
|
|
15
|
+
*
|
|
16
|
+
* A workflow someone designed and named is worth exactly as much on their next
|
|
17
|
+
* project as on this one, so it cannot live under `harness/`. This follows
|
|
18
|
+
* pi's own config directory, honouring the same override pi honours, so a
|
|
19
|
+
* sandboxed or rebranded install keeps everything in one place.
|
|
20
|
+
*/
|
|
21
|
+
export function userDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
22
|
+
const override = env.PI_CODING_AGENT_DIR;
|
|
23
|
+
if (override && override.trim()) return resolve(override.trim(), "infinity-harness");
|
|
24
|
+
return resolve(homedir(), ".pi", "agent", "infinity-harness");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The workflows this person has saved, reusable across every project. */
|
|
28
|
+
export function userWorkflowsPath(env?: NodeJS.ProcessEnv): string {
|
|
29
|
+
return resolve(userDir(env), "workflows.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The display templates this person has saved. */
|
|
33
|
+
export function userDisplayPath(env?: NodeJS.ProcessEnv): string {
|
|
34
|
+
return resolve(userDir(env), "displays.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export function harnessDir(targetDir: string): string {
|
|
13
38
|
return resolve(targetDir, HARNESS_DIRNAME);
|
|
14
39
|
}
|