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/src/goal.ts ADDED
@@ -0,0 +1,422 @@
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
+ // A review can legitimately arrive before the pipeline finishes: someone can
266
+ // already see that this pass will not meet the goal, and making them wait
267
+ // for a doomed pipeline to complete first is theatre. The state machine only
268
+ // accepts a verdict on an iteration that has recorded its work, so record it
269
+ // — otherwise the caller gets `Cannot update goal iteration 2 from status
270
+ // pending`, which names an internal phase and helps nobody.
271
+ if (state.phase === "goal_received" || state.phase === "todo_generated") {
272
+ const caught = await recordPipelinePass(targetDir, "reviewed before the pipeline finished", now);
273
+ if (caught) state = caught;
274
+ }
275
+
276
+ const at = (now ?? new Date()).toISOString();
277
+ const remainingWork = (input.remainingWork ?? []).map((s) => s.trim()).filter(Boolean);
278
+ if (input.decision !== "complete" && remainingWork.length === 0) {
279
+ // "Not done" with nothing named is not a review, it is a shrug — and the
280
+ // next iteration would start with no more information than this one had.
281
+ throw new Error(
282
+ `A "${input.decision}" verdict must name what is still missing (remainingWork). ` +
283
+ `The next pass is planned from that list.`,
284
+ );
285
+ }
286
+
287
+ state = recordReviewerResult(
288
+ state,
289
+ state.currentIteration,
290
+ {
291
+ decision: input.decision,
292
+ complete: input.decision === "complete",
293
+ summary: input.summary?.trim() || input.rationale.trim(),
294
+ rationale: input.rationale.trim(),
295
+ remainingWork,
296
+ reviewedAt: at,
297
+ },
298
+ { now },
299
+ );
300
+
301
+ if (state.status !== "running") {
302
+ await store.saveState(state);
303
+ await store.initializeResultIfMissing(state);
304
+ return {
305
+ state,
306
+ terminal: true,
307
+ rewoundTo: null,
308
+ message:
309
+ state.status === "done"
310
+ ? `Goal met after ${state.iterations.length} pass(es): ${state.goal}`
311
+ : `Goal loop ended ${state.status}: ${state.completion?.reason ?? input.rationale}`,
312
+ };
313
+ }
314
+
315
+ // Not done, and not fatal: go round again — unless a limit says otherwise.
316
+ const stop = goalLoopStopReason(state, { now });
317
+ if (stop) {
318
+ state = cancelGoalLoop(state, stop.message, { now });
319
+ await store.saveState(state);
320
+ await store.initializeResultIfMissing(state);
321
+ return { state, terminal: true, rewoundTo: null, message: `Goal loop stopped: ${stop.message}` };
322
+ }
323
+
324
+ state = startGoalIteration(state, { now });
325
+ await store.saveState(state);
326
+ const phase = rewindPipeline(
327
+ targetDir,
328
+ state.goal,
329
+ remainingWork,
330
+ state.currentIteration,
331
+ state.limits.maxIterations,
332
+ );
333
+ return {
334
+ state,
335
+ terminal: false,
336
+ rewoundTo: phase,
337
+ message:
338
+ `Pass ${state.currentIteration - 1} did not meet the goal. Starting pass ${state.currentIteration} at ` +
339
+ `${phase.toUpperCase()} with ${remainingWork.length} item(s) still to do:\n` +
340
+ remainingWork.map((w) => ` - ${w}`).join("\n"),
341
+ };
342
+ }
343
+
344
+ /** Stop pursuing the goal, on purpose. */
345
+ export async function cancelGoal(targetDir: string, reason: string, now?: Date): Promise<GoalLoopState | null> {
346
+ const pointer = readPointer(targetDir);
347
+ if (!pointer) return null;
348
+ const store = storeFor(targetDir, pointer);
349
+ let state: GoalLoopState;
350
+ try {
351
+ state = await store.loadState();
352
+ } catch {
353
+ return null;
354
+ }
355
+ if (state.status !== "running") return state;
356
+ state = cancelGoalLoop(state, reason, { now });
357
+ await store.saveState(state);
358
+ await store.initializeResultIfMissing(state);
359
+ return state;
360
+ }
361
+
362
+ /**
363
+ * Point the pipeline at the goal and send it back to the first phase.
364
+ *
365
+ * The phase machine is forward-only by design — the agent must not be able to
366
+ * decide it is bored of BUILD. Rewinding is therefore not something the agent
367
+ * can do; it happens here, once, when a review says the goal is not met, and
368
+ * it is recorded in the goal trace.
369
+ */
370
+ function rewindPipeline(
371
+ targetDir: string,
372
+ goal: string,
373
+ remainingWork: string[] = [],
374
+ pass = 1,
375
+ maxPasses = 1,
376
+ ): Phase {
377
+ // The goal belongs in the plan, not the config: `harness/features/feature-list.json`
378
+ // is the single source of truth the brief, the widget and the dashboard all
379
+ // read. Writing it anywhere else means a goal nothing displays.
380
+ try {
381
+ writeTaskList(targetDir, { goal });
382
+ } catch {
383
+ // A plan too broken to accept a goal is a problem the gate will report;
384
+ // it must not stop the goal loop from being recorded.
385
+ }
386
+
387
+ const { config, ok } = loadConfig(targetDir);
388
+ if (!ok) return "define";
389
+ const order = getPhaseOrder(config.phases?.enabled);
390
+ const first = order[0] ?? "define";
391
+ config.currentPhase = first;
392
+ config.currentRole = PHASE_ROLE[first];
393
+ config.remainingWork = remainingWork;
394
+ config.goalPass = pass;
395
+ config.goalMaxPasses = maxPasses;
396
+ // A new pass starts with fresh retry budgets. The old ones were spent on
397
+ // work that turned out to be insufficient, not on work that was wrong.
398
+ config.taskRetryCount = 0;
399
+ config.featureRetryCount = 0;
400
+ config.phaseRetryCount = 0;
401
+ saveConfig(targetDir, config);
402
+ return first;
403
+ }
404
+
405
+ /** The canonical goal specification, if one has been written. */
406
+ export function readGoalSpec(targetDir: string): GoalSpecification | null {
407
+ const path = canonicalGoalSpecPath(targetDir);
408
+ if (!existsSync(path)) return null;
409
+ try {
410
+ return JSON.parse(readFileSync(path, "utf-8")) as GoalSpecification;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ /** One line for the widget and the status command. */
417
+ export function describeGoal(view: GoalView): string {
418
+ if (view.status !== "running") {
419
+ return `goal ${view.status}: ${view.goal}`;
420
+ }
421
+ return `goal pass ${view.iteration}/${view.maxIterations} · ${view.goal}`;
422
+ }
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) return stored;
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
- return finish({
209
- action: "stop",
210
- reason: "complete",
211
- detail: `Pipeline complete: ${progress.tasksDone}/${progress.tasksTotal} tasks across ${progress.featuresTotal} feature(s).`,
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
- `. Stopping so a human can intervene.`,
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) {
package/src/ui/widget.ts CHANGED
@@ -42,6 +42,14 @@ export type WidgetState = {
42
42
  /** Shown in the header rule, e.g. "rev 42". */
43
43
  revision?: number;
44
44
  retries?: { task: number; max: number };
45
+ /**
46
+ * Which pass at the goal this is. A second pass looks identical to a first
47
+ * one in every other part of the display, which is exactly when someone
48
+ * walks away thinking the run is nearly done.
49
+ */
50
+ goalPass?: { current: number; max: number } | null;
51
+ /** The last rung the escalation ladder took, and what it has spent. */
52
+ escalation?: { strategy: string | null; reworks: number; replans: number } | null;
45
53
  };
46
54
 
47
55
  export type WidgetOptions = {
@@ -255,6 +263,13 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
255
263
  const role: Role = state.retries.task >= state.retries.max ? "blocked" : "active";
256
264
  alerts.push(s.fg(role, "retry " + state.retries.task + "/" + state.retries.max));
257
265
  }
266
+ if (state.goalPass && state.goalPass.max > 1) {
267
+ const role: Role = state.goalPass.current >= state.goalPass.max ? "blocked" : "active";
268
+ alerts.push(s.fg(role, "pass " + state.goalPass.current + "/" + state.goalPass.max));
269
+ }
270
+ if (state.escalation?.strategy) {
271
+ alerts.push(s.fg("rework", g.rework + " " + state.escalation.strategy));
272
+ }
258
273
  if (state.gate && !state.gate.overall) {
259
274
  alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
260
275
  }