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.
- package/CHANGELOG.md +82 -0
- package/README.md +56 -3
- package/extensions/infinity-harness/index.ts +573 -1
- package/harness/docs/ARCHITECTURE.md +1 -1
- package/package.json +1 -1
- package/src/core/brief.ts +14 -0
- package/src/core/gates.ts +37 -3
- package/src/escalate.ts +370 -0
- package/src/goal.ts +422 -0
- package/src/loop.ts +158 -12
- package/src/ui/widget.ts +15 -0
- package/src/unstuck.ts +46 -19
|
@@ -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` —
|
|
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
|
|
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 {
|