infinity-harness 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ import { buildBrief, renderBrief } from "../../src/core/brief.ts";
25
25
  import { runChecks } from "../../src/core/gates.ts";
26
26
  import { advancePhase } from "../../src/core/phases.ts";
27
27
  import { configPath } from "../../src/core/paths.ts";
28
+ import { readJsonSafe } from "../../src/core/fsx.ts";
28
29
  import { withLock } from "../../src/core/lock.ts";
29
30
  import {
30
31
  DEFAULT_ENABLED_PHASES,
@@ -35,10 +36,26 @@ import {
35
36
  import { writeTaskList, summarizeApply, type TaskInput } from "../../src/taskList.ts";
36
37
  import { renderWidget, renderStatusLine, type WidgetState } from "../../src/ui/widget.ts";
37
38
  import { createStyler, detectGlyphs } from "../../src/ui/theme.ts";
38
- import { decideNext, stopFilePath } from "../../src/loop.ts";
39
+ import { decideNext, stopFilePath, loopStatePath } from "../../src/loop.ts";
39
40
  import { runConfigMenu, renderSettings, type ModelChoice, type Prompter } from "../../src/ui/config.ts";
40
41
  import { SETTINGS, readAll, readSetting, formatValue } from "../../src/core/settings.ts";
41
42
  import { detectStack, describeInit, initHarness, type StackId } from "../../src/core/init.ts";
43
+ import { startRework, loadRework, clearRework } from "../../src/rework.ts";
44
+ import { amendPlan, loadReplanHistory, type ReplanTaskInput } from "../../src/replan.ts";
45
+ import { chooseUnstuckStrategy } from "../../src/unstuck.ts";
46
+ import { escalationSummary } from "../../src/escalate.ts";
47
+ import { spawnIsolatedWorker } from "../../src/worker.ts";
48
+ import {
49
+ startGoal,
50
+ loadGoal,
51
+ reviewGoal,
52
+ cancelGoal,
53
+ recordPipelinePass,
54
+ viewOf,
55
+ describeGoal,
56
+ type ReviewInput,
57
+ } from "../../src/goal.ts";
58
+ import { flattenTasks } from "../../src/core/featureList.ts";
42
59
 
43
60
  const CHECKPOINT = "infinity:checkpoint";
44
61
  const WIDGET_KEY = "infinity-harness";
@@ -79,6 +96,14 @@ export default function (pi: ExtensionAPI): void {
79
96
  try {
80
97
  const { list } = loadFeatureList(dir);
81
98
  const { config } = loadConfig(dir);
99
+ const spent = escalationSummary(dir);
100
+ const loop = readJsonSafe<{ escalations?: { strategy: string }[] } | null>(
101
+ loopStatePath(dir),
102
+ null,
103
+ );
104
+ const lastRung = loop?.escalations?.[loop.escalations.length - 1]?.strategy ?? null;
105
+ const pass = typeof config.goalPass === "number" ? config.goalPass : null;
106
+ const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
82
107
  return {
83
108
  list,
84
109
  phase: config.currentPhase,
@@ -86,6 +111,11 @@ export default function (pi: ExtensionAPI): void {
86
111
  paused: Boolean(config.paused),
87
112
  revision: list.baseRevision,
88
113
  retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
114
+ goalPass: pass && maxPasses ? { current: pass, max: maxPasses } : null,
115
+ escalation:
116
+ lastRung || spent.reworks || spent.replans
117
+ ? { strategy: lastRung, reworks: spent.reworks, replans: spent.replans }
118
+ : null,
89
119
  };
90
120
  } catch {
91
121
  return null;
@@ -750,6 +780,9 @@ export default function (pi: ExtensionAPI): void {
750
780
  */
751
781
  const NO_HARNESS = "No harness in this project yet. Run /infinity:init to create one.";
752
782
 
783
+ /** The escalation ladder, in the order it climbs. */
784
+ const DEFAULT_LADDER = ["retry", "reframe", "consult", "rework", "replan", "master"];
785
+
753
786
  /** Everything the pipeline can run. INIT is not a phase you choose. */
754
787
  const SELECTABLE_PHASES: Phase[] = ["define", "plan", "build", "verify", "simplify", "review", "ship"];
755
788
 
@@ -867,6 +900,411 @@ export default function (pi: ExtensionAPI): void {
867
900
  },
868
901
  });
869
902
 
903
+
904
+ // -- escalation, rework, replan --------------------------------------------
905
+
906
+ pi.registerTool({
907
+ name: "infinity_rework",
908
+ label: "Rework",
909
+ description:
910
+ "Send a task and everything that depends on it back to `rework`. Use when work built on a task " +
911
+ "turns out not to hold up: the dependents were built on the broken thing, so they are suspect " +
912
+ "until re-proved. Bounded by the rework budget.",
913
+ parameters: {
914
+ type: "object",
915
+ required: ["task"],
916
+ properties: {
917
+ task: { type: "string", description: 'Task key, e.g. "feature-001/task-003"' },
918
+ reason: { type: "string", description: "Why this is going backwards" },
919
+ maxImpactDepth: { type: "integer", minimum: 1, maximum: 10 },
920
+ },
921
+ } as never,
922
+ async execute(
923
+ _id: string,
924
+ params: { task: string; reason?: string; maxImpactDepth?: number },
925
+ _signal,
926
+ _onUpdate,
927
+ ctx,
928
+ ) {
929
+ const dir = projectDir(ctx);
930
+ const { list } = loadFeatureList(dir);
931
+ const target = flattenTasks(list).find(
932
+ (t) => t.compositeKey === params.task || t.key === params.task || t.id === params.task,
933
+ );
934
+ if (!target) {
935
+ return {
936
+ content: [{ type: "text", text: `No task matches "${params.task}".` }],
937
+ details: { error: "no-such-task" },
938
+ isError: true,
939
+ };
940
+ }
941
+ try {
942
+ const result = await startRework({
943
+ projectDir: dir,
944
+ featureId: target.featureId,
945
+ taskId: target.id,
946
+ key: target.key,
947
+ reason: params.reason ?? "rework requested",
948
+ runId,
949
+ maxImpactDepth: params.maxImpactDepth,
950
+ });
951
+ refreshWidget(ctx as ExtensionContext);
952
+ const downstream = result.impacted.length
953
+ ? `Also flipped ${result.impacted.length} dependent task(s): ${result.impacted.join(", ")}`
954
+ : "Nothing depends on it, so this is contained.";
955
+ return {
956
+ content: [
957
+ {
958
+ type: "text",
959
+ text: `${target.compositeKey} is back in rework (plan revision ${result.baseRevision}).\n${downstream}\nFix the root task first, then re-prove the rest.`,
960
+ },
961
+ ],
962
+ details: result,
963
+ };
964
+ } catch (e) {
965
+ return {
966
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
967
+ details: { error: "rework-failed" },
968
+ isError: true,
969
+ };
970
+ }
971
+ },
972
+ });
973
+
974
+ pi.registerTool({
975
+ name: "infinity_replan",
976
+ label: "Replan",
977
+ description:
978
+ "Add sprints, features or tasks to the plan mid-run, without resubmitting the whole task list. " +
979
+ "Use when the work turns out to need something that was never planned — the plan is the record, " +
980
+ "and building what it does not contain leaves it lying. Bounded by the replan budget.",
981
+ parameters: {
982
+ type: "object",
983
+ properties: {
984
+ reason: { type: "string", description: "What the plan was missing" },
985
+ addFeatures: {
986
+ type: "array",
987
+ maxItems: 20,
988
+ items: {
989
+ type: "object",
990
+ required: ["id", "name"],
991
+ properties: {
992
+ id: { type: "string" },
993
+ name: { type: "string" },
994
+ description: { type: "string" },
995
+ difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
996
+ },
997
+ },
998
+ },
999
+ addTasks: {
1000
+ type: "array",
1001
+ maxItems: 50,
1002
+ items: {
1003
+ type: "object",
1004
+ required: ["featureId", "task"],
1005
+ properties: {
1006
+ featureId: { type: "string" },
1007
+ task: {
1008
+ type: "object",
1009
+ required: ["id", "description"],
1010
+ properties: {
1011
+ id: { type: "string" },
1012
+ key: { type: "string" },
1013
+ description: { type: "string" },
1014
+ status: { type: "string", enum: ["pending", "in_progress", "complete", "blocked", "rework"] },
1015
+ dependsOn: { type: "array", items: { type: "string" } },
1016
+ difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
1017
+ acceptanceCriteria: { type: "array", items: { type: "string" } },
1018
+ },
1019
+ },
1020
+ },
1021
+ },
1022
+ },
1023
+ },
1024
+ } as never,
1025
+ async execute(
1026
+ _id: string,
1027
+ params: {
1028
+ reason?: string;
1029
+ addFeatures?: { id: string; name: string; description?: string; difficulty?: string }[];
1030
+ addTasks?: { featureId: string; task: ReplanTaskInput }[];
1031
+ },
1032
+ _signal,
1033
+ _onUpdate,
1034
+ ctx,
1035
+ ) {
1036
+ const dir = projectDir(ctx);
1037
+ try {
1038
+ const result = await amendPlan({
1039
+ projectDir: dir,
1040
+ reason: params.reason ?? "mid-run amendment",
1041
+ addFeatures: params.addFeatures,
1042
+ addTasks: params.addTasks,
1043
+ });
1044
+ refreshWidget(ctx as ExtensionContext);
1045
+ return {
1046
+ content: [
1047
+ {
1048
+ type: "text",
1049
+ text:
1050
+ `Plan amended to revision ${result.baseRevision}: ` +
1051
+ `+${result.added.features} feature(s), +${result.added.tasks} task(s), ` +
1052
+ `+${result.added.sprints} sprint(s).`,
1053
+ },
1054
+ ],
1055
+ details: result,
1056
+ };
1057
+ } catch (e) {
1058
+ return {
1059
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
1060
+ details: { error: "replan-failed" },
1061
+ isError: true,
1062
+ };
1063
+ }
1064
+ },
1065
+ });
1066
+
1067
+ pi.registerTool({
1068
+ name: "infinity_unstuck",
1069
+ label: "Unstuck",
1070
+ description:
1071
+ "Ask the escalation ladder what to try next: retry, reframe, consult a stronger model, rework, " +
1072
+ "replan, or master. Read-only — it recommends, it does not act. /infinity:run consults it " +
1073
+ "automatically when a run stalls; call it yourself when you are stuck and want the next rung.",
1074
+ parameters: { type: "object", properties: {} } as never,
1075
+ async execute(_id: string, _params: unknown, _signal, _onUpdate, ctx) {
1076
+ const dir = projectDir(ctx);
1077
+ const { list } = loadFeatureList(dir);
1078
+ const task = flattenTasks(list).find((t) => t.status === "in_progress" || t.status === "rework");
1079
+ const choice = chooseUnstuckStrategy({
1080
+ projectDir: dir,
1081
+ featureId: task?.featureId,
1082
+ taskId: task?.id,
1083
+ currentDifficulty: task?.difficulty ?? null,
1084
+ requireDeltaForRework: false,
1085
+ });
1086
+ const spent = escalationSummary(dir);
1087
+ const text = choice.strategy
1088
+ ? `Next rung: ${choice.strategy} — ${choice.reason}` +
1089
+ (choice.nextModel ? `\nModel: ${choice.nextModel}` : "") +
1090
+ `\nSpent so far: ${spent.reworks} rework(s), ${spent.replans} replan(s)` +
1091
+ (spent.returnTo ? `, returning to ${spent.returnTo}` : "")
1092
+ : `The ladder has nothing left: ${choice.reason}. This needs a human.`;
1093
+ return { content: [{ type: "text", text }], details: { ...choice, spent } };
1094
+ },
1095
+ });
1096
+
1097
+ pi.registerTool({
1098
+ name: "infinity_spawn_worker",
1099
+ label: "Spawn Worker",
1100
+ description:
1101
+ "Run one task in an isolated worker: its own attempt directory, prompt, output log and " +
1102
+ "fingerprint under tmp/. Use for a task worth attempting without the current conversation's " +
1103
+ "context — a clean-room retry. Records the attempt whether or not a command is configured.",
1104
+ parameters: {
1105
+ type: "object",
1106
+ required: ["task", "prompt"],
1107
+ properties: {
1108
+ task: { type: "string", description: 'Task key, e.g. "feature-001/task-003"' },
1109
+ prompt: { type: "string", description: "The complete instruction for the isolated worker" },
1110
+ command: { type: "string", description: "Shell command to run; {promptfile} is substituted" },
1111
+ model: { type: "string", description: "Model reference for the worker" },
1112
+ timeoutMs: { type: "integer", minimum: 1000, maximum: 3_600_000 },
1113
+ },
1114
+ } as never,
1115
+ async execute(
1116
+ _id: string,
1117
+ params: { task: string; prompt: string; command?: string; model?: string; timeoutMs?: number },
1118
+ _signal,
1119
+ _onUpdate,
1120
+ ctx,
1121
+ ) {
1122
+ const dir = projectDir(ctx);
1123
+ const { list } = loadFeatureList(dir);
1124
+ const target = flattenTasks(list).find(
1125
+ (t) => t.compositeKey === params.task || t.key === params.task || t.id === params.task,
1126
+ );
1127
+ if (!target) {
1128
+ return {
1129
+ content: [{ type: "text", text: `No task matches "${params.task}".` }],
1130
+ details: { error: "no-such-task" },
1131
+ isError: true,
1132
+ };
1133
+ }
1134
+ try {
1135
+ const result = await spawnIsolatedWorker({
1136
+ projectDir: dir,
1137
+ runId,
1138
+ featureId: target.featureId,
1139
+ taskId: target.id,
1140
+ prompt: params.prompt,
1141
+ command: params.command,
1142
+ model: params.model,
1143
+ timeoutMs: params.timeoutMs,
1144
+ });
1145
+ const ran = params.command
1146
+ ? `exit ${result.exitCode}${result.timedOut ? " (timed out)" : ""}`
1147
+ : "recorded only — no command configured";
1148
+ return {
1149
+ content: [
1150
+ {
1151
+ type: "text",
1152
+ text:
1153
+ `Worker attempt ${result.attempt} for ${target.compositeKey}: ${ran}\n` +
1154
+ `${result.attemptDir}\n\n${result.output.slice(-4000) || "(no output)"}`,
1155
+ },
1156
+ ],
1157
+ details: result,
1158
+ };
1159
+ } catch (e) {
1160
+ return {
1161
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
1162
+ details: { error: "worker-failed" },
1163
+ isError: true,
1164
+ };
1165
+ }
1166
+ },
1167
+ });
1168
+
1169
+ pi.registerTool({
1170
+ name: "infinity_goal",
1171
+ label: "Goal",
1172
+ description:
1173
+ "The outer loop. `start` states a goal and opens pass 1; `status` reports where it is; " +
1174
+ "`review` judges whether the work so far actually meets the goal and, if it does not, rewinds " +
1175
+ "the pipeline for another pass with the remaining work named; `cancel` stops pursuing it. " +
1176
+ "The phase gate decides whether the WORK is done; this decides whether the GOAL is done.",
1177
+ parameters: {
1178
+ type: "object",
1179
+ required: ["action"],
1180
+ properties: {
1181
+ action: { type: "string", enum: ["start", "status", "review", "cancel"] },
1182
+ goal: { type: "string", description: "start: what this whole run is for, in one sentence" },
1183
+ maxIterations: { type: "integer", minimum: 1, maximum: 50, description: "start: how many passes at most" },
1184
+ decision: {
1185
+ type: "string",
1186
+ enum: ["complete", "incomplete", "blocked", "failed"],
1187
+ description: "review: does the work meet the goal?",
1188
+ },
1189
+ rationale: { type: "string", description: "review: why, judged against the goal not the plan" },
1190
+ remainingWork: {
1191
+ type: "array",
1192
+ items: { type: "string" },
1193
+ description: "review: required unless complete — what is still missing. The next pass is planned from this.",
1194
+ },
1195
+ reason: { type: "string", description: "cancel: why" },
1196
+ },
1197
+ } as never,
1198
+ async execute(
1199
+ _id: string,
1200
+ params: {
1201
+ action: string;
1202
+ goal?: string;
1203
+ maxIterations?: number;
1204
+ decision?: ReviewInput["decision"];
1205
+ rationale?: string;
1206
+ remainingWork?: string[];
1207
+ reason?: string;
1208
+ },
1209
+ _signal,
1210
+ _onUpdate,
1211
+ ctx,
1212
+ ) {
1213
+ const dir = projectDir(ctx);
1214
+ try {
1215
+ if (params.action === "start") {
1216
+ if (!params.goal?.trim()) {
1217
+ return {
1218
+ content: [{ type: "text", text: "A goal needs to say something." }],
1219
+ details: { error: "no-goal" },
1220
+ isError: true,
1221
+ };
1222
+ }
1223
+ const { state } = await startGoal({
1224
+ targetDir: dir,
1225
+ goal: params.goal,
1226
+ runId: `goal-${runId}`,
1227
+ maxIterations: params.maxIterations,
1228
+ });
1229
+ refreshWidget(ctx as ExtensionContext);
1230
+ const view = viewOf(state);
1231
+ return {
1232
+ content: [
1233
+ {
1234
+ type: "text",
1235
+ text:
1236
+ `Goal set: ${view.goal}\nPass 1 of at most ${view.maxIterations}. The pipeline is at the ` +
1237
+ `first phase — define what this needs, plan it, build it. When the pipeline completes, ` +
1238
+ `call infinity_goal with action "review" and judge it against the goal, not the plan.`,
1239
+ },
1240
+ ],
1241
+ details: view,
1242
+ };
1243
+ }
1244
+
1245
+ if (params.action === "status") {
1246
+ const state = await loadGoal(dir);
1247
+ if (!state) {
1248
+ return {
1249
+ content: [{ type: "text", text: "No goal is being pursued in this project." }],
1250
+ details: { active: false },
1251
+ };
1252
+ }
1253
+ const view = viewOf(state);
1254
+ const remaining = view.remainingWork.length
1255
+ ? `\nStill missing:\n${view.remainingWork.map((w) => ` - ${w}`).join("\n")}`
1256
+ : "";
1257
+ return {
1258
+ content: [{ type: "text", text: `${describeGoal(view)}\nPhase: ${view.phase}${remaining}` }],
1259
+ details: view,
1260
+ };
1261
+ }
1262
+
1263
+ if (params.action === "review") {
1264
+ if (!params.decision || !params.rationale?.trim()) {
1265
+ return {
1266
+ content: [{ type: "text", text: "A review needs a decision and a rationale." }],
1267
+ details: { error: "incomplete-review" },
1268
+ isError: true,
1269
+ };
1270
+ }
1271
+ const outcome = await reviewGoal(dir, {
1272
+ decision: params.decision,
1273
+ rationale: params.rationale,
1274
+ remainingWork: params.remainingWork,
1275
+ });
1276
+ refreshWidget(ctx as ExtensionContext);
1277
+ if (!outcome.terminal) {
1278
+ // Rewinding the pipeline means the next brief is a different one.
1279
+ pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
1280
+ }
1281
+ return { content: [{ type: "text", text: outcome.message }], details: viewOf(outcome.state) };
1282
+ }
1283
+
1284
+ if (params.action === "cancel") {
1285
+ const state = await cancelGoal(dir, params.reason ?? "cancelled by request");
1286
+ refreshWidget(ctx as ExtensionContext);
1287
+ return {
1288
+ content: [{ type: "text", text: state ? `Goal cancelled: ${state.goal}` : "No goal to cancel." }],
1289
+ details: state ? viewOf(state) : { active: false },
1290
+ };
1291
+ }
1292
+
1293
+ return {
1294
+ content: [{ type: "text", text: `Unknown action "${params.action}".` }],
1295
+ details: { error: "unknown-action" },
1296
+ isError: true,
1297
+ };
1298
+ } catch (e) {
1299
+ return {
1300
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
1301
+ details: { error: "goal-failed" },
1302
+ isError: true,
1303
+ };
1304
+ }
1305
+ },
1306
+ });
1307
+
870
1308
  // -- commands -------------------------------------------------------------
871
1309
 
872
1310
  pi.registerCommand("infinity:status", {
@@ -929,6 +1367,140 @@ export default function (pi: ExtensionAPI): void {
929
1367
  },
930
1368
  });
931
1369
 
1370
+ pi.registerCommand("infinity:goal", {
1371
+ description: "State a goal and pursue it across passes — or review, cancel, or check one",
1372
+ handler: async (args: string, ctx: ExtensionContext) => {
1373
+ const dir = projectDir(ctx);
1374
+ if (!isHarnessProject(dir)) {
1375
+ notify(ctx, NO_HARNESS, "warning");
1376
+ return;
1377
+ }
1378
+ const text = args.trim();
1379
+
1380
+ if (text === "" || text === "status") {
1381
+ const state = await loadGoal(dir);
1382
+ if (!state) {
1383
+ notify(ctx, 'No goal set. `/infinity:goal <what you want built>` starts one.', "info");
1384
+ return;
1385
+ }
1386
+ const view = viewOf(state);
1387
+ const remaining = view.remainingWork.length
1388
+ ? `\nStill missing:\n${view.remainingWork.map((w) => ` - ${w}`).join("\n")}`
1389
+ : "";
1390
+ notify(ctx, `${describeGoal(view)}\nPhase: ${view.phase}${remaining}`, "info");
1391
+ return;
1392
+ }
1393
+
1394
+ if (text === "cancel") {
1395
+ const state = await cancelGoal(dir, "cancelled from /infinity:goal");
1396
+ notify(ctx, state ? `Goal cancelled: ${state.goal}` : "No goal to cancel.", "info");
1397
+ refreshWidget(ctx);
1398
+ return;
1399
+ }
1400
+
1401
+ try {
1402
+ const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runId}` });
1403
+ refreshWidget(ctx);
1404
+ notify(
1405
+ ctx,
1406
+ `Goal set: ${state.goal}\nPass 1 of at most ${state.limits.maxIterations}. ` +
1407
+ `The pipeline is back at its first phase.`,
1408
+ "info",
1409
+ );
1410
+ pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
1411
+ } catch (e) {
1412
+ notify(ctx, e instanceof Error ? e.message : String(e), "error");
1413
+ }
1414
+ },
1415
+ });
1416
+
1417
+ pi.registerCommand("infinity:unstuck", {
1418
+ description: "What the escalation ladder would try next, and what it has spent",
1419
+ handler: async (_args: string, ctx: ExtensionContext) => {
1420
+ const dir = projectDir(ctx);
1421
+ if (!isHarnessProject(dir)) {
1422
+ notify(ctx, NO_HARNESS, "warning");
1423
+ return;
1424
+ }
1425
+ const { list } = loadFeatureList(dir);
1426
+ const task = flattenTasks(list).find((t) => t.status === "in_progress" || t.status === "rework");
1427
+ const choice = chooseUnstuckStrategy({
1428
+ projectDir: dir,
1429
+ featureId: task?.featureId,
1430
+ taskId: task?.id,
1431
+ currentDifficulty: task?.difficulty ?? null,
1432
+ requireDeltaForRework: false,
1433
+ });
1434
+ const spent = escalationSummary(dir);
1435
+ const rework = loadRework(dir);
1436
+ const lines = [
1437
+ choice.strategy
1438
+ ? `Next rung: ${choice.strategy} — ${choice.reason}`
1439
+ : `The ladder has nothing left: ${choice.reason}`,
1440
+ choice.nextModel ? `Model: ${choice.nextModel}` : null,
1441
+ `Spent: ${spent.reworks} rework(s), ${spent.replans} replan(s)`,
1442
+ rework ? `Returning to ${rework.returnFeature}/${rework.returnTask} — ${rework.reason}` : null,
1443
+ `Ladder: ${DEFAULT_LADDER.join(" → ")}`,
1444
+ ].filter((l): l is string => l !== null);
1445
+ notify(ctx, lines.join("\n"), "info");
1446
+ },
1447
+ });
1448
+
1449
+ pi.registerCommand("infinity:rework", {
1450
+ description: "Send a task and its dependents back to rework",
1451
+ handler: async (args: string, ctx: ExtensionContext) => {
1452
+ const dir = projectDir(ctx);
1453
+ if (!isHarnessProject(dir)) {
1454
+ notify(ctx, NO_HARNESS, "warning");
1455
+ return;
1456
+ }
1457
+ const key = args.trim();
1458
+ const { list } = loadFeatureList(dir);
1459
+ const tasks = flattenTasks(list);
1460
+
1461
+ if (key === "clear") {
1462
+ await clearRework(dir);
1463
+ notify(ctx, "Rework record cleared.", "info");
1464
+ refreshWidget(ctx);
1465
+ return;
1466
+ }
1467
+
1468
+ let target = tasks.find((t) => t.compositeKey === key || t.key === key || t.id === key);
1469
+ if (!target && ctx.hasUI) {
1470
+ const rows = tasks.map((t) => `${t.compositeKey} [${t.status}] ${t.description}`);
1471
+ const picked = await ctx.ui.select("Send which task back to rework?", rows);
1472
+ if (picked === undefined) return;
1473
+ target = tasks[rows.indexOf(picked)];
1474
+ }
1475
+ if (!target) {
1476
+ notify(ctx, key ? `No task matches "${key}".` : "Name a task: /infinity:rework <task-key>", "warning");
1477
+ return;
1478
+ }
1479
+
1480
+ try {
1481
+ const result = await startRework({
1482
+ projectDir: dir,
1483
+ featureId: target.featureId,
1484
+ taskId: target.id,
1485
+ key: target.key,
1486
+ reason: "rework from /infinity:rework",
1487
+ runId,
1488
+ });
1489
+ refreshWidget(ctx);
1490
+ notify(
1491
+ ctx,
1492
+ `${target.compositeKey} → rework (revision ${result.baseRevision}). ` +
1493
+ (result.impacted.length
1494
+ ? `${result.impacted.length} dependent task(s) went with it: ${result.impacted.join(", ")}`
1495
+ : "Nothing depends on it."),
1496
+ "info",
1497
+ );
1498
+ } catch (e) {
1499
+ notify(ctx, e instanceof Error ? e.message : String(e), "error");
1500
+ }
1501
+ },
1502
+ });
1503
+
932
1504
  pi.registerCommand("infinity:halt", {
933
1505
  description: "Stop the continuous loop after the current turn",
934
1506
  handler: async (_args: string, ctx: ExtensionContext) => {
@@ -153,7 +153,7 @@ Every stop carries a reason. A human coming back finds an explanation, not a mys
153
153
  ## Verification
154
154
 
155
155
  - `npm test` — 20 unit files, plain `node:assert`, no framework.
156
- - `npm run e2e` — 13 scenarios over real temp projects, real git repos, real child processes: the
156
+ - `npm run e2e` — 15 scenarios over real temp projects, real git repos, real child processes: the
157
157
  full pipeline walkthrough, loop convergence, every stop condition, SIGKILL-and-restart, a 6-way
158
158
  concurrent write fan-out with an unlocked control, data round-trip, the dashboard, widget
159
159
  rendering across shapes, adversarial input, and the extension adapter itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
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/core/brief.ts CHANGED
@@ -87,6 +87,20 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
87
87
  notes.push(`${blockedTasks.length} task(s) are blocked: ${blockedTasks.map((t) => t.compositeKey).join(", ")}`);
88
88
  }
89
89
 
90
+ // A second pass at a goal is not the same as a first pass at one, and the
91
+ // agent has no way to tell unless the brief says so. Without this the model
92
+ // re-plans from scratch and rebuilds what the last review already accepted.
93
+ const remaining = Array.isArray(config.remainingWork)
94
+ ? (config.remainingWork as unknown[]).filter((w): w is string => typeof w === "string" && w.trim() !== "")
95
+ : [];
96
+ if (remaining.length > 0) {
97
+ const pass = typeof config.goalPass === "number" ? `pass ${config.goalPass}` : "this pass";
98
+ notes.push(
99
+ `The goal was reviewed and judged not yet met. ${remaining.length} item(s) remain for ${pass} — ` +
100
+ `plan for these, not for the whole goal again: ${remaining.join("; ")}`,
101
+ );
102
+ }
103
+
90
104
  const complete = isFinalPhase(phase, config.phases?.enabled) && progress.tasksDone === progress.tasksTotal;
91
105
 
92
106
  return {