infinity-harness 2.0.4 → 2.2.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 +111 -0
- package/README.md +98 -7
- package/extensions/infinity-harness/index.ts +771 -10
- package/harness/docs/ARCHITECTURE.md +1 -1
- package/harness/docs/phases/define.md +27 -9
- package/harness/docs/phases/ship.md +1 -1
- package/harness/skills/code-review.md +2 -2
- package/harness/skills/context-hygiene.md +1 -1
- package/harness/skills/diagnosing-bugs.md +1 -1
- package/harness/skills/domain-modeling.md +2 -1
- package/harness/skills/prototype.md +2 -2
- package/package.json +1 -1
- package/src/core/brief.ts +14 -0
- package/src/core/gates.ts +45 -4
- package/src/core/init.ts +379 -0
- package/src/escalate.ts +370 -0
- package/src/goal.ts +411 -0
- package/src/loop.ts +158 -12
- package/src/taskList.ts +154 -7
- package/src/ui/widget.ts +15 -0
- package/src/unstuck.ts +46 -19
package/src/goal.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the goal loop, connected to the pipeline.
|
|
3
|
+
*
|
|
4
|
+
* `goalSpec`, `goalLoop` and `goalState` are a complete outer loop: state a
|
|
5
|
+
* goal, do a pass of work, judge whether the goal is actually met, and go
|
|
6
|
+
* round again if it is not, under iteration and wall-clock limits. Ported from
|
|
7
|
+
* pi-long-task, 1,600 lines, fully typed, well tested.
|
|
8
|
+
*
|
|
9
|
+
* And nothing ever turned the crank. `createGoalLoopState` was called by its
|
|
10
|
+
* own tests and by nothing else. The three modules were a state machine with
|
|
11
|
+
* no driver — which is why the harness could finish a pipeline and declare
|
|
12
|
+
* "complete" without anyone ever asking the only question that matters: is the
|
|
13
|
+
* thing the human asked for actually done?
|
|
14
|
+
*
|
|
15
|
+
* This module is the driver, and the mapping it chooses is the whole design:
|
|
16
|
+
*
|
|
17
|
+
* one goal iteration = one full pass of the phase pipeline
|
|
18
|
+
*
|
|
19
|
+
* DEFINE→SHIP produces work; the goal loop then asks whether that work met the
|
|
20
|
+
* goal. If it did, the run is over. If it did not, the pipeline is rewound to
|
|
21
|
+
* DEFINE with the remaining work named, and the next iteration begins. The
|
|
22
|
+
* gate decides whether the *work* is done; the goal loop decides whether the
|
|
23
|
+
* *goal* is done. They are different questions and the harness needed both.
|
|
24
|
+
*
|
|
25
|
+
* The reviewer's verdict comes from the agent, not from here. Whether a body
|
|
26
|
+
* of work satisfies a stated goal is a judgement, and the harness's rule is
|
|
27
|
+
* that judgements belong to the model while enforcement belongs to the gate.
|
|
28
|
+
* What this module enforces is that the judgement is recorded, bounded, and
|
|
29
|
+
* has consequences.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { resolve } from "node:path";
|
|
33
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
34
|
+
import {
|
|
35
|
+
createGoalLoopState,
|
|
36
|
+
startGoalIteration,
|
|
37
|
+
recordGeneratedTodo,
|
|
38
|
+
recordWorkerResult,
|
|
39
|
+
recordReviewerResult,
|
|
40
|
+
cancelGoalLoop,
|
|
41
|
+
goalLoopStopReason,
|
|
42
|
+
type GoalLoopState,
|
|
43
|
+
type GoalReviewerDecision,
|
|
44
|
+
} from "./goalLoop.ts";
|
|
45
|
+
import { GoalStateStore, canonicalGoalSpecPath } from "./goalState.ts";
|
|
46
|
+
import { createGoalSpecification, type GoalSpecification } from "./goalSpec.ts";
|
|
47
|
+
import { loadConfig, saveConfig } from "./core/config.ts";
|
|
48
|
+
import { loadFeatureList, computeProgress } from "./core/featureList.ts";
|
|
49
|
+
import { writeTaskList } from "./taskList.ts";
|
|
50
|
+
import { harnessDir } from "./core/paths.ts";
|
|
51
|
+
import { readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
|
|
52
|
+
import { getPhaseOrder } from "./core/phases.ts";
|
|
53
|
+
import { PHASE_ROLE, type Phase } from "./core/types.ts";
|
|
54
|
+
|
|
55
|
+
/** Which goal run this project is on, so a new session can find it. */
|
|
56
|
+
export const GOAL_POINTER_FILE = "goal.json";
|
|
57
|
+
|
|
58
|
+
type GoalPointer = { goalRunId: string; goalRunDir: string; startedAt: string };
|
|
59
|
+
|
|
60
|
+
export function goalPointerPath(targetDir: string): string {
|
|
61
|
+
return resolve(harnessDir(targetDir), GOAL_POINTER_FILE);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readPointer(targetDir: string): GoalPointer | null {
|
|
65
|
+
return readJsonSafe<GoalPointer | null>(goalPointerPath(targetDir), null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function storeFor(targetDir: string, pointer: GoalPointer): GoalStateStore {
|
|
69
|
+
return new GoalStateStore({
|
|
70
|
+
cwd: targetDir,
|
|
71
|
+
goalRunId: pointer.goalRunId,
|
|
72
|
+
goalRunDir: pointer.goalRunDir,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type GoalView = {
|
|
77
|
+
goal: string;
|
|
78
|
+
goalRunId: string;
|
|
79
|
+
status: GoalLoopState["status"];
|
|
80
|
+
phase: GoalLoopState["phase"];
|
|
81
|
+
iteration: number;
|
|
82
|
+
maxIterations: number;
|
|
83
|
+
/** What the last review said is still missing. */
|
|
84
|
+
remainingWork: string[];
|
|
85
|
+
/** Set once the loop reaches a terminal state. */
|
|
86
|
+
completion: GoalLoopState["completion"];
|
|
87
|
+
startedAt: string;
|
|
88
|
+
deadlineAt?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function viewOf(state: GoalLoopState): GoalView {
|
|
92
|
+
const last = state.iterations[state.iterations.length - 1];
|
|
93
|
+
return {
|
|
94
|
+
goal: state.goal,
|
|
95
|
+
goalRunId: state.goalRunId,
|
|
96
|
+
status: state.status,
|
|
97
|
+
phase: state.phase,
|
|
98
|
+
iteration: state.currentIteration,
|
|
99
|
+
maxIterations: state.limits.maxIterations,
|
|
100
|
+
remainingWork: last?.reviewerResult?.remainingWork ?? [],
|
|
101
|
+
completion: state.completion,
|
|
102
|
+
startedAt: state.startedAt,
|
|
103
|
+
deadlineAt: state.deadlineAt,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The goal run this project is on, or null. Never throws. */
|
|
108
|
+
export async function loadGoal(targetDir: string): Promise<GoalLoopState | null> {
|
|
109
|
+
const pointer = readPointer(targetDir);
|
|
110
|
+
if (!pointer) return null;
|
|
111
|
+
try {
|
|
112
|
+
return await storeFor(targetDir, pointer).loadState();
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type StartGoalOptions = {
|
|
119
|
+
targetDir: string;
|
|
120
|
+
goal: string;
|
|
121
|
+
runId: string;
|
|
122
|
+
maxIterations?: number;
|
|
123
|
+
timeoutMs?: number;
|
|
124
|
+
now?: Date;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* State a goal and open iteration 1.
|
|
129
|
+
*
|
|
130
|
+
* The goal specification is written to `harness/goals/GOAL_SPEC.json` — a
|
|
131
|
+
* committed, human-readable statement of what this run is for — while the
|
|
132
|
+
* loop's own state lives under `tmp/`, because it is run bookkeeping and
|
|
133
|
+
* nobody wants it in a diff.
|
|
134
|
+
*/
|
|
135
|
+
export async function startGoal(options: StartGoalOptions): Promise<{ state: GoalLoopState; spec: GoalSpecification }> {
|
|
136
|
+
const { targetDir, runId } = options;
|
|
137
|
+
const goal = options.goal.trim();
|
|
138
|
+
if (!goal) throw new Error("a goal needs to say something");
|
|
139
|
+
|
|
140
|
+
const existing = await loadGoal(targetDir);
|
|
141
|
+
if (existing && existing.status === "running") {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`This project is already pursuing a goal: "${existing.goal}". ` +
|
|
144
|
+
`Finish it or cancel it (/infinity:goal cancel) before starting another.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const spec = createGoalSpecification({ goalRunId: runId, originalGoal: goal, now: () => options.now ?? new Date() });
|
|
149
|
+
let state = createGoalLoopState({
|
|
150
|
+
goal,
|
|
151
|
+
goalRunId: runId,
|
|
152
|
+
cwd: targetDir,
|
|
153
|
+
maxIterations: options.maxIterations,
|
|
154
|
+
timeoutMs: options.timeoutMs,
|
|
155
|
+
now: () => options.now ?? new Date(),
|
|
156
|
+
});
|
|
157
|
+
state = startGoalIteration(state, { now: options.now });
|
|
158
|
+
|
|
159
|
+
const store = new GoalStateStore({ cwd: targetDir, goalRunId: runId, goalRunDir: state.goalRunDir });
|
|
160
|
+
await store.ensureRunDir();
|
|
161
|
+
await store.saveGoalSpecificationWithCanonical(spec, targetDir);
|
|
162
|
+
await store.saveState(state);
|
|
163
|
+
writeJsonAtomic(goalPointerPath(targetDir), {
|
|
164
|
+
goalRunId: runId,
|
|
165
|
+
goalRunDir: state.goalRunDir,
|
|
166
|
+
startedAt: state.startedAt,
|
|
167
|
+
} satisfies GoalPointer);
|
|
168
|
+
|
|
169
|
+
// The goal also becomes the pipeline's goal, so every brief carries it.
|
|
170
|
+
rewindPipeline(targetDir, goal, [], 1, state.limits.maxIterations);
|
|
171
|
+
return { state, spec };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Record that the pipeline has produced a pass of work.
|
|
176
|
+
*
|
|
177
|
+
* Called when the phase pipeline completes. Moves the iteration through
|
|
178
|
+
* `todo_generated` → `todo_executed`, which is what makes it reviewable.
|
|
179
|
+
*/
|
|
180
|
+
export async function recordPipelinePass(
|
|
181
|
+
targetDir: string,
|
|
182
|
+
summary: string,
|
|
183
|
+
now?: Date,
|
|
184
|
+
): Promise<GoalLoopState | null> {
|
|
185
|
+
const pointer = readPointer(targetDir);
|
|
186
|
+
if (!pointer) return null;
|
|
187
|
+
const store = storeFor(targetDir, pointer);
|
|
188
|
+
let state: GoalLoopState;
|
|
189
|
+
try {
|
|
190
|
+
state = await store.loadState();
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (state.status !== "running") return state;
|
|
195
|
+
|
|
196
|
+
const iteration = state.currentIteration;
|
|
197
|
+
const { list } = loadFeatureList(targetDir);
|
|
198
|
+
const progress = computeProgress(list);
|
|
199
|
+
const at = (now ?? new Date()).toISOString();
|
|
200
|
+
|
|
201
|
+
if (state.phase === "goal_received") {
|
|
202
|
+
state = recordGeneratedTodo(
|
|
203
|
+
state,
|
|
204
|
+
iteration,
|
|
205
|
+
{ todoPath: "harness/features/feature-list.json", summary: `${progress.tasksTotal} task(s) planned`, generatedAt: at },
|
|
206
|
+
{ now },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (state.phase === "todo_generated") {
|
|
210
|
+
state = recordWorkerResult(
|
|
211
|
+
state,
|
|
212
|
+
iteration,
|
|
213
|
+
{
|
|
214
|
+
status: progress.tasksDone === progress.tasksTotal ? "done" : "partial",
|
|
215
|
+
summary,
|
|
216
|
+
totalTasks: progress.tasksTotal,
|
|
217
|
+
completedTasks: progress.tasksDone,
|
|
218
|
+
endedAt: at,
|
|
219
|
+
},
|
|
220
|
+
{ now },
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
await store.saveState(state);
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type ReviewInput = {
|
|
228
|
+
decision: GoalReviewerDecision;
|
|
229
|
+
rationale: string;
|
|
230
|
+
remainingWork?: string[];
|
|
231
|
+
summary?: string;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export type ReviewOutcome = {
|
|
235
|
+
state: GoalLoopState;
|
|
236
|
+
/** True when the goal loop is finished, either way. */
|
|
237
|
+
terminal: boolean;
|
|
238
|
+
/** Set when another pass begins: the pipeline was rewound to here. */
|
|
239
|
+
rewoundTo: Phase | null;
|
|
240
|
+
message: string;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Judge whether the work meets the goal, and act on the answer.
|
|
245
|
+
*
|
|
246
|
+
* A `complete` verdict ends the run. Anything else opens the next iteration
|
|
247
|
+
* and rewinds the pipeline, because the harness has no other way to do more
|
|
248
|
+
* work: the phase machine is forward-only, so a second pass means starting a
|
|
249
|
+
* second pass, with the remaining work stated up front.
|
|
250
|
+
*/
|
|
251
|
+
export async function reviewGoal(
|
|
252
|
+
targetDir: string,
|
|
253
|
+
input: ReviewInput,
|
|
254
|
+
now?: Date,
|
|
255
|
+
): Promise<ReviewOutcome> {
|
|
256
|
+
const pointer = readPointer(targetDir);
|
|
257
|
+
if (!pointer) throw new Error("No goal is being pursued in this project. Start one with /infinity:goal.");
|
|
258
|
+
const store = storeFor(targetDir, pointer);
|
|
259
|
+
let state = await store.loadState();
|
|
260
|
+
|
|
261
|
+
if (state.status !== "running") {
|
|
262
|
+
return { state, terminal: true, message: `The goal loop already finished: ${state.status}.`, rewoundTo: null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const at = (now ?? new Date()).toISOString();
|
|
266
|
+
const remainingWork = (input.remainingWork ?? []).map((s) => s.trim()).filter(Boolean);
|
|
267
|
+
if (input.decision !== "complete" && remainingWork.length === 0) {
|
|
268
|
+
// "Not done" with nothing named is not a review, it is a shrug — and the
|
|
269
|
+
// next iteration would start with no more information than this one had.
|
|
270
|
+
throw new Error(
|
|
271
|
+
`A "${input.decision}" verdict must name what is still missing (remainingWork). ` +
|
|
272
|
+
`The next pass is planned from that list.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
state = recordReviewerResult(
|
|
277
|
+
state,
|
|
278
|
+
state.currentIteration,
|
|
279
|
+
{
|
|
280
|
+
decision: input.decision,
|
|
281
|
+
complete: input.decision === "complete",
|
|
282
|
+
summary: input.summary?.trim() || input.rationale.trim(),
|
|
283
|
+
rationale: input.rationale.trim(),
|
|
284
|
+
remainingWork,
|
|
285
|
+
reviewedAt: at,
|
|
286
|
+
},
|
|
287
|
+
{ now },
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (state.status !== "running") {
|
|
291
|
+
await store.saveState(state);
|
|
292
|
+
await store.initializeResultIfMissing(state);
|
|
293
|
+
return {
|
|
294
|
+
state,
|
|
295
|
+
terminal: true,
|
|
296
|
+
rewoundTo: null,
|
|
297
|
+
message:
|
|
298
|
+
state.status === "done"
|
|
299
|
+
? `Goal met after ${state.iterations.length} pass(es): ${state.goal}`
|
|
300
|
+
: `Goal loop ended ${state.status}: ${state.completion?.reason ?? input.rationale}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Not done, and not fatal: go round again — unless a limit says otherwise.
|
|
305
|
+
const stop = goalLoopStopReason(state, { now });
|
|
306
|
+
if (stop) {
|
|
307
|
+
state = cancelGoalLoop(state, stop.message, { now });
|
|
308
|
+
await store.saveState(state);
|
|
309
|
+
await store.initializeResultIfMissing(state);
|
|
310
|
+
return { state, terminal: true, rewoundTo: null, message: `Goal loop stopped: ${stop.message}` };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
state = startGoalIteration(state, { now });
|
|
314
|
+
await store.saveState(state);
|
|
315
|
+
const phase = rewindPipeline(
|
|
316
|
+
targetDir,
|
|
317
|
+
state.goal,
|
|
318
|
+
remainingWork,
|
|
319
|
+
state.currentIteration,
|
|
320
|
+
state.limits.maxIterations,
|
|
321
|
+
);
|
|
322
|
+
return {
|
|
323
|
+
state,
|
|
324
|
+
terminal: false,
|
|
325
|
+
rewoundTo: phase,
|
|
326
|
+
message:
|
|
327
|
+
`Pass ${state.currentIteration - 1} did not meet the goal. Starting pass ${state.currentIteration} at ` +
|
|
328
|
+
`${phase.toUpperCase()} with ${remainingWork.length} item(s) still to do:\n` +
|
|
329
|
+
remainingWork.map((w) => ` - ${w}`).join("\n"),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Stop pursuing the goal, on purpose. */
|
|
334
|
+
export async function cancelGoal(targetDir: string, reason: string, now?: Date): Promise<GoalLoopState | null> {
|
|
335
|
+
const pointer = readPointer(targetDir);
|
|
336
|
+
if (!pointer) return null;
|
|
337
|
+
const store = storeFor(targetDir, pointer);
|
|
338
|
+
let state: GoalLoopState;
|
|
339
|
+
try {
|
|
340
|
+
state = await store.loadState();
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (state.status !== "running") return state;
|
|
345
|
+
state = cancelGoalLoop(state, reason, { now });
|
|
346
|
+
await store.saveState(state);
|
|
347
|
+
await store.initializeResultIfMissing(state);
|
|
348
|
+
return state;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Point the pipeline at the goal and send it back to the first phase.
|
|
353
|
+
*
|
|
354
|
+
* The phase machine is forward-only by design — the agent must not be able to
|
|
355
|
+
* decide it is bored of BUILD. Rewinding is therefore not something the agent
|
|
356
|
+
* can do; it happens here, once, when a review says the goal is not met, and
|
|
357
|
+
* it is recorded in the goal trace.
|
|
358
|
+
*/
|
|
359
|
+
function rewindPipeline(
|
|
360
|
+
targetDir: string,
|
|
361
|
+
goal: string,
|
|
362
|
+
remainingWork: string[] = [],
|
|
363
|
+
pass = 1,
|
|
364
|
+
maxPasses = 1,
|
|
365
|
+
): Phase {
|
|
366
|
+
// The goal belongs in the plan, not the config: `harness/features/feature-list.json`
|
|
367
|
+
// is the single source of truth the brief, the widget and the dashboard all
|
|
368
|
+
// read. Writing it anywhere else means a goal nothing displays.
|
|
369
|
+
try {
|
|
370
|
+
writeTaskList(targetDir, { goal });
|
|
371
|
+
} catch {
|
|
372
|
+
// A plan too broken to accept a goal is a problem the gate will report;
|
|
373
|
+
// it must not stop the goal loop from being recorded.
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const { config, ok } = loadConfig(targetDir);
|
|
377
|
+
if (!ok) return "define";
|
|
378
|
+
const order = getPhaseOrder(config.phases?.enabled);
|
|
379
|
+
const first = order[0] ?? "define";
|
|
380
|
+
config.currentPhase = first;
|
|
381
|
+
config.currentRole = PHASE_ROLE[first];
|
|
382
|
+
config.remainingWork = remainingWork;
|
|
383
|
+
config.goalPass = pass;
|
|
384
|
+
config.goalMaxPasses = maxPasses;
|
|
385
|
+
// A new pass starts with fresh retry budgets. The old ones were spent on
|
|
386
|
+
// work that turned out to be insufficient, not on work that was wrong.
|
|
387
|
+
config.taskRetryCount = 0;
|
|
388
|
+
config.featureRetryCount = 0;
|
|
389
|
+
config.phaseRetryCount = 0;
|
|
390
|
+
saveConfig(targetDir, config);
|
|
391
|
+
return first;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The canonical goal specification, if one has been written. */
|
|
395
|
+
export function readGoalSpec(targetDir: string): GoalSpecification | null {
|
|
396
|
+
const path = canonicalGoalSpecPath(targetDir);
|
|
397
|
+
if (!existsSync(path)) return null;
|
|
398
|
+
try {
|
|
399
|
+
return JSON.parse(readFileSync(path, "utf-8")) as GoalSpecification;
|
|
400
|
+
} catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** One line for the widget and the status command. */
|
|
406
|
+
export function describeGoal(view: GoalView): string {
|
|
407
|
+
if (view.status !== "running") {
|
|
408
|
+
return `goal ${view.status}: ${view.goal}`;
|
|
409
|
+
}
|
|
410
|
+
return `goal pass ${view.iteration}/${view.maxIterations} · ${view.goal}`;
|
|
411
|
+
}
|
package/src/loop.ts
CHANGED
|
@@ -32,6 +32,13 @@ import { buildBrief, renderBrief } from "./core/brief.ts";
|
|
|
32
32
|
import { harnessDir } from "./core/paths.ts";
|
|
33
33
|
import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.ts";
|
|
34
34
|
import { run } from "./core/exec.ts";
|
|
35
|
+
import {
|
|
36
|
+
emptyEscalationState,
|
|
37
|
+
escalate,
|
|
38
|
+
describeEscalation,
|
|
39
|
+
type EscalationState,
|
|
40
|
+
} from "./escalate.ts";
|
|
41
|
+
import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
|
|
35
42
|
|
|
36
43
|
export const LOOP_STATE_FILE = "loop-state.json";
|
|
37
44
|
export const STOP_FILE = "STOP";
|
|
@@ -52,8 +59,26 @@ export type LoopState = {
|
|
|
52
59
|
lastDecision: string | null;
|
|
53
60
|
stoppedAt: string | null;
|
|
54
61
|
stopReason: string | null;
|
|
62
|
+
/** Where this run sits on the escalation ladder. */
|
|
63
|
+
escalation: EscalationState;
|
|
64
|
+
/** Every rung taken, so the human coming back can see the shape of it. */
|
|
65
|
+
escalations: { at: string; strategy: string; reason: string; applied: string | null }[];
|
|
55
66
|
};
|
|
56
67
|
|
|
68
|
+
/** Escalation history kept in the loop state. Older entries tell no story. */
|
|
69
|
+
export const ESCALATION_HISTORY_LIMIT = 50;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Consecutive stalled gate failures before the ladder is consulted.
|
|
73
|
+
*
|
|
74
|
+
* One is enough. A stalled failure means the gate failed AND nothing in the
|
|
75
|
+
* tree moved — the agent produced no work at all — and there is no reason to
|
|
76
|
+
* let that repeat before doing something about it. The no-progress limit still
|
|
77
|
+
* governs when the run gives up entirely; this only governs when it starts
|
|
78
|
+
* trying something different.
|
|
79
|
+
*/
|
|
80
|
+
export const ESCALATE_AFTER_STALLS = 1;
|
|
81
|
+
|
|
57
82
|
export type LoopBudget = {
|
|
58
83
|
maxIterations: number;
|
|
59
84
|
maxWallClockMs: number;
|
|
@@ -85,12 +110,21 @@ export function newLoopState(runId: string, now = new Date()): LoopState {
|
|
|
85
110
|
lastDecision: null,
|
|
86
111
|
stoppedAt: null,
|
|
87
112
|
stopReason: null,
|
|
113
|
+
escalation: emptyEscalationState(),
|
|
114
|
+
escalations: [],
|
|
88
115
|
};
|
|
89
116
|
}
|
|
90
117
|
|
|
91
118
|
export function loadLoopState(targetDir: string, runId: string, now = new Date()): LoopState {
|
|
92
119
|
const stored = readJsonSafe<LoopState | null>(loopStatePath(targetDir), null);
|
|
93
|
-
if (stored && stored.runId === runId)
|
|
120
|
+
if (stored && stored.runId === runId) {
|
|
121
|
+
// A state file written before the ladder existed has neither field.
|
|
122
|
+
return {
|
|
123
|
+
...stored,
|
|
124
|
+
escalation: { ...emptyEscalationState(), ...(stored.escalation ?? {}) },
|
|
125
|
+
escalations: Array.isArray(stored.escalations) ? stored.escalations : [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
94
128
|
return newLoopState(runId, now);
|
|
95
129
|
}
|
|
96
130
|
|
|
@@ -143,6 +177,8 @@ export async function fingerprint(targetDir: string): Promise<string> {
|
|
|
143
177
|
}
|
|
144
178
|
|
|
145
179
|
export type DecideOptions = {
|
|
180
|
+
/** Skip the escalation ladder. Tests use it to isolate the base loop. */
|
|
181
|
+
skipEscalation?: boolean;
|
|
146
182
|
targetDir: string;
|
|
147
183
|
runId: string;
|
|
148
184
|
now?: Date;
|
|
@@ -205,11 +241,17 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
205
241
|
const allTasksDone = progress.tasksTotal > 0 && progress.tasksDone === progress.tasksTotal;
|
|
206
242
|
|
|
207
243
|
if (isFinalPhase(config.currentPhase, config.phases?.enabled) && allTasksDone) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
244
|
+
const detail = `Pipeline complete: ${progress.tasksDone}/${progress.tasksTotal} tasks across ${progress.featuresTotal} feature(s).`;
|
|
245
|
+
|
|
246
|
+
// A finished pipeline is not necessarily a met goal. When a goal is being
|
|
247
|
+
// pursued, the run does not stop here — it hands the work to the outer
|
|
248
|
+
// loop, which asks the only question the gate cannot: is the thing that
|
|
249
|
+
// was actually asked for done? Without this the harness declares victory
|
|
250
|
+
// on whatever happened to be planned.
|
|
251
|
+
const goalReview = await requestGoalReview(targetDir, detail);
|
|
252
|
+
if (goalReview) return finish(goalReview);
|
|
253
|
+
|
|
254
|
+
return finish({ action: "stop", reason: "complete", detail });
|
|
213
255
|
}
|
|
214
256
|
|
|
215
257
|
const exhausted = isRetryExhausted(config);
|
|
@@ -295,10 +337,65 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
295
337
|
|
|
296
338
|
if (previous === null || previous !== fp) {
|
|
297
339
|
state.noProgressStreak = 0;
|
|
340
|
+
// The tree moved, so whatever the run was stuck on, it is not stuck on it
|
|
341
|
+
// any more. The next stall starts from the bottom of the ladder — the
|
|
342
|
+
// budgets in rework.json and replan.json still bound the run across
|
|
343
|
+
// stalls, but a rung spent on a problem that resolved should not be
|
|
344
|
+
// missing when a different problem appears.
|
|
345
|
+
state.escalation = { ...state.escalation, tried: [] };
|
|
298
346
|
} else {
|
|
299
347
|
state.noProgressStreak += 1;
|
|
300
348
|
}
|
|
301
349
|
|
|
350
|
+
// -- the escalation ladder ------------------------------------------------
|
|
351
|
+
//
|
|
352
|
+
// A stalled failure — the gate failed and the tree did not move — means the
|
|
353
|
+
// agent produced nothing, and repeating the same brief will produce nothing
|
|
354
|
+
// again. Before spending another strike, ask the ladder what to do
|
|
355
|
+
// differently: retry, reframe, consult a stronger model, rework the task
|
|
356
|
+
// that poisoned everything downstream, amend the plan, or go to master.
|
|
357
|
+
//
|
|
358
|
+
// Escalating never *prevents* the run from stopping. The strike is still
|
|
359
|
+
// counted; the ladder just gets a turn first, so a run stops because nothing
|
|
360
|
+
// worked rather than because nothing was tried.
|
|
361
|
+
let escalation = null as Awaited<ReturnType<typeof escalate>> | null;
|
|
362
|
+
if (!options.skipEscalation && state.noProgressStreak >= ESCALATE_AFTER_STALLS) {
|
|
363
|
+
escalation = await escalate({
|
|
364
|
+
targetDir,
|
|
365
|
+
runId,
|
|
366
|
+
phase,
|
|
367
|
+
failures: gate ? gate.failures : [],
|
|
368
|
+
fileDelta: previous !== null && previous !== fp,
|
|
369
|
+
fingerprint: fp,
|
|
370
|
+
state: state.escalation,
|
|
371
|
+
now,
|
|
372
|
+
});
|
|
373
|
+
state.escalation = escalation.next;
|
|
374
|
+
if (escalation.strategy) {
|
|
375
|
+
state.escalations = [
|
|
376
|
+
...state.escalations,
|
|
377
|
+
{
|
|
378
|
+
at: now.toISOString(),
|
|
379
|
+
strategy: escalation.strategy,
|
|
380
|
+
reason: escalation.reason,
|
|
381
|
+
applied: escalation.applied,
|
|
382
|
+
},
|
|
383
|
+
].slice(-ESCALATION_HISTORY_LIMIT);
|
|
384
|
+
|
|
385
|
+
// A new rung is a genuinely different attempt, so it does not count as
|
|
386
|
+
// another repetition of the same one — the streak resets and the ladder
|
|
387
|
+
// gets room to climb. This cannot run forever: every rung is bounded
|
|
388
|
+
// (retry and reframe once per stall, consult and rework and replan by
|
|
389
|
+
// their budgets, master once), so the ladder runs out, returns null, and
|
|
390
|
+
// the streak resumes counting to the stop.
|
|
391
|
+
state.noProgressStreak = 0;
|
|
392
|
+
|
|
393
|
+
// Rework rewrites task statuses, so the plan the next brief reads is not
|
|
394
|
+
// the plan this fingerprint was taken from.
|
|
395
|
+
if (escalation.strategy === "rework") state.lastFingerprint = await fingerprint(targetDir);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
302
399
|
if (state.noProgressStreak >= budget.noProgressLimit) {
|
|
303
400
|
return finish({
|
|
304
401
|
action: "stop",
|
|
@@ -307,7 +404,13 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
307
404
|
`The gate has failed ${state.noProgressStreak} times in a row with no change to the working tree ` +
|
|
308
405
|
`or the plan. The agent is looping without making progress` +
|
|
309
406
|
(gate ? `: ${gate.failures.join(", ")}` : "") +
|
|
310
|
-
|
|
407
|
+
`.` +
|
|
408
|
+
(state.escalations.length
|
|
409
|
+
? ` The escalation ladder was spent first: ${state.escalations
|
|
410
|
+
.map((e) => e.strategy)
|
|
411
|
+
.join(" → ")}.`
|
|
412
|
+
: "") +
|
|
413
|
+
` Stopping so a human can intervene.`,
|
|
311
414
|
});
|
|
312
415
|
}
|
|
313
416
|
|
|
@@ -330,16 +433,59 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
330
433
|
const task = nextActionableTask(list);
|
|
331
434
|
const focus = task ? `\nCurrent task: ${task.compositeKey} — ${task.description}` : "";
|
|
332
435
|
|
|
436
|
+
// An escalation replaces the standard "fix these" nudge, because repeating
|
|
437
|
+
// that nudge is exactly what the ladder exists to interrupt.
|
|
438
|
+
const head = escalation?.instruction
|
|
439
|
+
? `${escalation.instruction}\n`
|
|
440
|
+
: `The ${phase.toUpperCase()} gate did not pass. Fix exactly these, then stop talking — ` +
|
|
441
|
+
`the harness will re-validate automatically.\n\n${failures}${focus}\n`;
|
|
442
|
+
|
|
333
443
|
return finish({
|
|
334
444
|
action: "continue",
|
|
335
|
-
reason: "gate failed",
|
|
336
|
-
message:
|
|
337
|
-
`The ${phase.toUpperCase()} gate did not pass. Fix exactly these, then stop talking — ` +
|
|
338
|
-
`the harness will re-validate automatically.\n\n${failures}${focus}\n\n` +
|
|
339
|
-
renderBrief(brief, fresh.ok ? fresh.config : undefined),
|
|
445
|
+
reason: escalation?.strategy ? `escalated: ${describeEscalation(escalation)}` : "gate failed",
|
|
446
|
+
message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
|
|
340
447
|
});
|
|
341
448
|
}
|
|
342
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Hand a finished pipeline to the goal loop, if one is running.
|
|
452
|
+
*
|
|
453
|
+
* Returns a `continue` decision carrying the review request, or null when
|
|
454
|
+
* there is no goal and the pipeline finishing really is the end of the run.
|
|
455
|
+
* Never throws: a goal loop that cannot be read must not turn a completed
|
|
456
|
+
* pipeline into a crash.
|
|
457
|
+
*/
|
|
458
|
+
async function requestGoalReview(
|
|
459
|
+
targetDir: string,
|
|
460
|
+
summary: string,
|
|
461
|
+
): Promise<LoopDecision | null> {
|
|
462
|
+
try {
|
|
463
|
+
const existing = await loadGoal(targetDir);
|
|
464
|
+
if (!existing || existing.status !== "running") return null;
|
|
465
|
+
|
|
466
|
+
const state = await recordPipelinePass(targetDir, summary);
|
|
467
|
+
if (!state) return null;
|
|
468
|
+
const view = viewOf(state);
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
action: "continue",
|
|
472
|
+
reason: "goal review",
|
|
473
|
+
message:
|
|
474
|
+
`${summary}\n\nTHE PIPELINE IS DONE. THE GOAL MAY NOT BE.\n\n` +
|
|
475
|
+
`Goal: ${view.goal}\nPass ${view.iteration} of at most ${view.maxIterations}.\n\n` +
|
|
476
|
+
`Judge the work against the GOAL, not against the plan — the plan is only what you ` +
|
|
477
|
+
`thought the goal needed when you wrote it. Then call \`infinity_goal\` with ` +
|
|
478
|
+
`action "review":\n` +
|
|
479
|
+
` - decision "complete" ends the run.\n` +
|
|
480
|
+
` - anything else must name what is still missing in remainingWork; the next pass is ` +
|
|
481
|
+
`planned from that list.\n\n` +
|
|
482
|
+
`Do not mark it complete to end the run. The run ending is not the point.`,
|
|
483
|
+
};
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
343
489
|
/** Human-readable one-liner for the status bar / notify. */
|
|
344
490
|
export function describeDecision(d: LoopDecision): string {
|
|
345
491
|
switch (d.action) {
|