infinity-harness 2.0.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 +114 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/extensions/infinity-harness/index.ts +870 -0
- package/harness/docs/ARCHITECTURE.md +159 -0
- package/harness/docs/CONSTRAINTS.md +19 -0
- package/harness/docs/DECISIONS.md +107 -0
- package/harness/docs/DOMAIN.md +13 -0
- package/harness/docs/agents/evaluator.md +14 -0
- package/harness/docs/agents/generator.md +13 -0
- package/harness/docs/agents/planner.md +13 -0
- package/harness/docs/agents/simplifier.md +13 -0
- package/harness/docs/api-patterns.md +23 -0
- package/harness/docs/phases/build.md +47 -0
- package/harness/docs/phases/define.md +58 -0
- package/harness/docs/phases/plan.md +50 -0
- package/harness/docs/phases/review.md +47 -0
- package/harness/docs/phases/ship.md +43 -0
- package/harness/docs/phases/simplify.md +45 -0
- package/harness/docs/phases/verify.md +46 -0
- package/harness/model-router.json +28 -0
- package/harness/skills/README.md +60 -0
- package/harness/skills/auth-security.md +56 -0
- package/harness/skills/building-mcp-servers.md +70 -0
- package/harness/skills/building-tools.md +60 -0
- package/harness/skills/capability-acquisition.md +72 -0
- package/harness/skills/cli-design.md +55 -0
- package/harness/skills/code-review.md +57 -0
- package/harness/skills/codebase-design.md +70 -0
- package/harness/skills/concurrency-async.md +61 -0
- package/harness/skills/config-and-secrets.md +52 -0
- package/harness/skills/context-hygiene.md +51 -0
- package/harness/skills/databases.md +63 -0
- package/harness/skills/diagnosing-bugs.md +84 -0
- package/harness/skills/domain-modeling.md +65 -0
- package/harness/skills/error-handling-logging.md +56 -0
- package/harness/skills/frontend-ui.md +56 -0
- package/harness/skills/grilling.md +48 -0
- package/harness/skills/http-apis.md +60 -0
- package/harness/skills/performance.md +53 -0
- package/harness/skills/pi-todo-adapted.md +41 -0
- package/harness/skills/planning-tasks.md +86 -0
- package/harness/skills/prototype.md +39 -0
- package/harness/skills/research.md +32 -0
- package/harness/skills/resolving-merge-conflicts.md +30 -0
- package/harness/skills/scope-discipline.md +49 -0
- package/harness/skills/self-review.md +45 -0
- package/harness/skills/stuck-protocol.md +51 -0
- package/harness/skills/tdd.md +80 -0
- package/harness/skills/testing-infra.md +57 -0
- package/harness/skills/writing-skills.md +60 -0
- package/package.json +61 -0
- package/src/core/brief.ts +242 -0
- package/src/core/config.ts +265 -0
- package/src/core/exec.ts +130 -0
- package/src/core/featureList.ts +286 -0
- package/src/core/fsx.ts +119 -0
- package/src/core/gates.ts +444 -0
- package/src/core/lock.ts +192 -0
- package/src/core/paths.ts +95 -0
- package/src/core/phases.ts +143 -0
- package/src/core/settings.ts +445 -0
- package/src/core/types.ts +245 -0
- package/src/goalLoop.ts +628 -0
- package/src/goalSpec.ts +679 -0
- package/src/goalState.ts +338 -0
- package/src/loop.ts +355 -0
- package/src/modelRouter.ts +184 -0
- package/src/remote.ts +244 -0
- package/src/replan.ts +300 -0
- package/src/review.ts +53 -0
- package/src/rework.ts +274 -0
- package/src/taskList.ts +355 -0
- package/src/ui/config.ts +286 -0
- package/src/ui/dashboard.ts +1066 -0
- package/src/ui/theme.ts +317 -0
- package/src/ui/widget.ts +370 -0
- package/src/unstuck.ts +214 -0
- package/src/worker.ts +351 -0
- package/types/proper-lockfile.d.ts +19 -0
package/src/goalLoop.ts
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveModel } from "./modelRouter.ts";
|
|
3
|
+
|
|
4
|
+
export type CoordinatorStatus = "done" | "partial" | "blocked" | "failed";
|
|
5
|
+
|
|
6
|
+
export const GOAL_LOOP_STATE_SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_GOAL_LOOP_LIMITS = {
|
|
9
|
+
minIterations: 1,
|
|
10
|
+
maxIterations: 50,
|
|
11
|
+
timeoutMs: 172_800_000,
|
|
12
|
+
iterationTimeoutMs: 10_800_000,
|
|
13
|
+
reviewerTimeoutMs: 1_800_000,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type GoalLoopStatus = "running" | "done" | "partial" | "blocked" | "failed" | "cancelled";
|
|
17
|
+
|
|
18
|
+
export type GoalLoopPhase =
|
|
19
|
+
| "goal_received"
|
|
20
|
+
| "todo_generated"
|
|
21
|
+
| "todo_executed"
|
|
22
|
+
| "reviewed"
|
|
23
|
+
| "complete"
|
|
24
|
+
| "cancelled"
|
|
25
|
+
| "failed";
|
|
26
|
+
|
|
27
|
+
export type GoalIterationStatus =
|
|
28
|
+
| "pending"
|
|
29
|
+
| "todo_generated"
|
|
30
|
+
| "todo_executed"
|
|
31
|
+
| "reviewed_incomplete"
|
|
32
|
+
| "reviewed_complete"
|
|
33
|
+
| "blocked"
|
|
34
|
+
| "failed"
|
|
35
|
+
| "cancelled";
|
|
36
|
+
|
|
37
|
+
export type GoalLoopStopKind = "cancelled" | "timeout" | "max_iterations" | "complete";
|
|
38
|
+
|
|
39
|
+
export interface GoalLoopLimits {
|
|
40
|
+
minIterations: number;
|
|
41
|
+
maxIterations: number;
|
|
42
|
+
timeoutMs: number;
|
|
43
|
+
iterationTimeoutMs: number;
|
|
44
|
+
reviewerTimeoutMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GoalLoopLimitInput {
|
|
48
|
+
minIterations?: number;
|
|
49
|
+
maxIterations?: number;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
iterationTimeoutMs?: number;
|
|
52
|
+
reviewerTimeoutMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GoalLoopCancellation {
|
|
56
|
+
requested: boolean;
|
|
57
|
+
reason?: string;
|
|
58
|
+
requestedAt?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GeneratedTodoState {
|
|
62
|
+
todoPath: string;
|
|
63
|
+
summary?: string;
|
|
64
|
+
contentHash?: string;
|
|
65
|
+
generatedAt: string;
|
|
66
|
+
payloadPath?: string;
|
|
67
|
+
rawTodoPath?: string;
|
|
68
|
+
generatorRunId?: string;
|
|
69
|
+
generatorRunDir?: string;
|
|
70
|
+
generatorResultPath?: string;
|
|
71
|
+
generatorTaskResultPath?: string;
|
|
72
|
+
generatorWorkerCostTotal?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface GoalWorkerResultState {
|
|
76
|
+
status: CoordinatorStatus;
|
|
77
|
+
summary: string;
|
|
78
|
+
runId?: string;
|
|
79
|
+
runDir?: string;
|
|
80
|
+
todoPath?: string;
|
|
81
|
+
resultPath?: string;
|
|
82
|
+
taskResultPath?: string;
|
|
83
|
+
workerProgressPath?: string;
|
|
84
|
+
totalTasks?: number;
|
|
85
|
+
completedTasks?: number;
|
|
86
|
+
failedTasks?: number;
|
|
87
|
+
blockedTasks?: number;
|
|
88
|
+
workerCostTotal?: number;
|
|
89
|
+
error?: string;
|
|
90
|
+
startedAt?: string;
|
|
91
|
+
endedAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type GoalReviewerDecision = "complete" | "incomplete" | "blocked" | "failed";
|
|
95
|
+
|
|
96
|
+
export interface GoalReviewerResultState {
|
|
97
|
+
decision: GoalReviewerDecision;
|
|
98
|
+
complete: boolean;
|
|
99
|
+
summary: string;
|
|
100
|
+
rationale: string;
|
|
101
|
+
remainingWork: string[];
|
|
102
|
+
reviewerSessionId?: string;
|
|
103
|
+
reviewerSessionFile?: string;
|
|
104
|
+
payloadPath?: string;
|
|
105
|
+
rawReviewPath?: string;
|
|
106
|
+
reviewerCostTotal?: number;
|
|
107
|
+
error?: string;
|
|
108
|
+
reviewedAt: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface GoalCompletionState {
|
|
112
|
+
status: GoalLoopStatus;
|
|
113
|
+
reason: string;
|
|
114
|
+
completedAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface GoalLoopTraceEvent {
|
|
118
|
+
timestamp: string;
|
|
119
|
+
phase: GoalLoopPhase;
|
|
120
|
+
event: string;
|
|
121
|
+
message: string;
|
|
122
|
+
iteration?: number;
|
|
123
|
+
details?: Record<string, unknown>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface GoalIterationState {
|
|
127
|
+
iteration: number;
|
|
128
|
+
status: GoalIterationStatus;
|
|
129
|
+
startedAt: string;
|
|
130
|
+
updatedAt: string;
|
|
131
|
+
deadlineAt?: string;
|
|
132
|
+
generatedTodo?: GeneratedTodoState;
|
|
133
|
+
workerResult?: GoalWorkerResultState;
|
|
134
|
+
reviewerResult?: GoalReviewerResultState;
|
|
135
|
+
completion?: GoalCompletionState;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface GoalLoopState {
|
|
139
|
+
schemaVersion: typeof GOAL_LOOP_STATE_SCHEMA_VERSION;
|
|
140
|
+
goalRunId: string;
|
|
141
|
+
goalRunDir: string;
|
|
142
|
+
goal: string;
|
|
143
|
+
status: GoalLoopStatus;
|
|
144
|
+
phase: GoalLoopPhase;
|
|
145
|
+
limits: GoalLoopLimits;
|
|
146
|
+
cancellation: GoalLoopCancellation;
|
|
147
|
+
startedAt: string;
|
|
148
|
+
updatedAt: string;
|
|
149
|
+
deadlineAt?: string;
|
|
150
|
+
currentIteration: number;
|
|
151
|
+
iterations: GoalIterationState[];
|
|
152
|
+
completion?: GoalCompletionState;
|
|
153
|
+
trace: GoalLoopTraceEvent[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface CreateGoalLoopStateOptions extends GoalLoopLimitInput {
|
|
157
|
+
goal: string;
|
|
158
|
+
cwd?: string;
|
|
159
|
+
goalRunId: string;
|
|
160
|
+
goalRunDir?: string;
|
|
161
|
+
now?: () => Date;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface GoalLoopStopReason {
|
|
165
|
+
kind: GoalLoopStopKind;
|
|
166
|
+
message: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class GoalLoopStateError extends Error {
|
|
170
|
+
constructor(message: string) {
|
|
171
|
+
super(message);
|
|
172
|
+
this.name = "GoalLoopStateError";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function normalizeGoalLoopLimits(input: GoalLoopLimitInput = {}): GoalLoopLimits {
|
|
177
|
+
const explicitMaxIterations = optionalPositiveInteger(input.maxIterations);
|
|
178
|
+
const minIterations = positiveInteger(
|
|
179
|
+
input.minIterations,
|
|
180
|
+
explicitMaxIterations ?? DEFAULT_GOAL_LOOP_LIMITS.minIterations,
|
|
181
|
+
);
|
|
182
|
+
const maxIterations = Math.max(minIterations, explicitMaxIterations ?? DEFAULT_GOAL_LOOP_LIMITS.maxIterations);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
minIterations,
|
|
186
|
+
maxIterations,
|
|
187
|
+
timeoutMs: positiveMilliseconds(input.timeoutMs, DEFAULT_GOAL_LOOP_LIMITS.timeoutMs),
|
|
188
|
+
iterationTimeoutMs: positiveMilliseconds(input.iterationTimeoutMs, DEFAULT_GOAL_LOOP_LIMITS.iterationTimeoutMs),
|
|
189
|
+
reviewerTimeoutMs: positiveMilliseconds(input.reviewerTimeoutMs, DEFAULT_GOAL_LOOP_LIMITS.reviewerTimeoutMs),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function createGoalLoopState(options: CreateGoalLoopStateOptions): GoalLoopState {
|
|
194
|
+
const now = options.now?.() ?? new Date();
|
|
195
|
+
const startedAt = now.toISOString();
|
|
196
|
+
const goal = options.goal.trim();
|
|
197
|
+
if (!goal) {
|
|
198
|
+
throw new GoalLoopStateError("Goal loop requires a non-empty goal.");
|
|
199
|
+
}
|
|
200
|
+
const limits = normalizeGoalLoopLimits(options);
|
|
201
|
+
const goalRunDir =
|
|
202
|
+
options.goalRunDir ??
|
|
203
|
+
path.join(path.resolve(options.cwd ?? process.cwd()), "tmp", "infinity-harness", "goals", options.goalRunId);
|
|
204
|
+
const deadlineAt = new Date(now.getTime() + limits.timeoutMs).toISOString();
|
|
205
|
+
const state: GoalLoopState = {
|
|
206
|
+
schemaVersion: GOAL_LOOP_STATE_SCHEMA_VERSION,
|
|
207
|
+
goalRunId: options.goalRunId,
|
|
208
|
+
goalRunDir,
|
|
209
|
+
goal,
|
|
210
|
+
status: "running",
|
|
211
|
+
phase: "goal_received",
|
|
212
|
+
limits,
|
|
213
|
+
cancellation: { requested: false },
|
|
214
|
+
startedAt,
|
|
215
|
+
updatedAt: startedAt,
|
|
216
|
+
deadlineAt,
|
|
217
|
+
currentIteration: 0,
|
|
218
|
+
iterations: [],
|
|
219
|
+
trace: [],
|
|
220
|
+
};
|
|
221
|
+
return withTrace(state, {
|
|
222
|
+
timestamp: startedAt,
|
|
223
|
+
phase: "goal_received",
|
|
224
|
+
event: "goal_received",
|
|
225
|
+
message: "Goal loop received goal.",
|
|
226
|
+
details: { goal, limits },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function goalLoopStopReason(
|
|
231
|
+
state: GoalLoopState,
|
|
232
|
+
options: { now?: Date; abortSignal?: AbortSignal } = {},
|
|
233
|
+
): GoalLoopStopReason | undefined {
|
|
234
|
+
if (isTerminalGoalLoopStatus(state.status)) {
|
|
235
|
+
return { kind: "complete", message: `Goal loop is already ${state.status}.` };
|
|
236
|
+
}
|
|
237
|
+
if (state.cancellation.requested) {
|
|
238
|
+
return { kind: "cancelled", message: state.cancellation.reason ?? "Goal loop cancellation requested." };
|
|
239
|
+
}
|
|
240
|
+
if (options.abortSignal?.aborted) {
|
|
241
|
+
return { kind: "cancelled", message: "Goal loop abort signal was triggered." };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nowMs = options.now?.getTime() ?? Date.now();
|
|
245
|
+
if (state.deadlineAt && nowMs >= Date.parse(state.deadlineAt)) {
|
|
246
|
+
return { kind: "timeout", message: `Goal loop timed out after ${state.limits.timeoutMs}ms.` };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (state.phase === "reviewed" && state.iterations.length >= state.limits.maxIterations) {
|
|
250
|
+
return {
|
|
251
|
+
kind: "max_iterations",
|
|
252
|
+
message: `Goal loop reached maxIterations=${state.limits.maxIterations}.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function startGoalIteration(
|
|
259
|
+
state: GoalLoopState,
|
|
260
|
+
options: { now?: Date; abortSignal?: AbortSignal } = {},
|
|
261
|
+
): GoalLoopState {
|
|
262
|
+
const now = options.now ?? new Date();
|
|
263
|
+
const stopReason = goalLoopStopReason(state, { now, abortSignal: options.abortSignal });
|
|
264
|
+
if (stopReason) {
|
|
265
|
+
return completeStateForStopReason(state, stopReason, now);
|
|
266
|
+
}
|
|
267
|
+
if (!canStartAnotherIterationFromPhase(state.phase)) {
|
|
268
|
+
throw new GoalLoopStateError(`Cannot start goal iteration from phase ${state.phase}.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const iteration = state.iterations.length + 1;
|
|
272
|
+
const timestamp = now.toISOString();
|
|
273
|
+
const deadlineAt = new Date(now.getTime() + state.limits.iterationTimeoutMs).toISOString();
|
|
274
|
+
return withTrace(
|
|
275
|
+
{
|
|
276
|
+
...state,
|
|
277
|
+
phase: "goal_received",
|
|
278
|
+
currentIteration: iteration,
|
|
279
|
+
updatedAt: timestamp,
|
|
280
|
+
iterations: [
|
|
281
|
+
...state.iterations,
|
|
282
|
+
{
|
|
283
|
+
iteration,
|
|
284
|
+
status: "pending",
|
|
285
|
+
startedAt: timestamp,
|
|
286
|
+
updatedAt: timestamp,
|
|
287
|
+
deadlineAt,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
timestamp,
|
|
293
|
+
phase: "goal_received",
|
|
294
|
+
event: "iteration_started",
|
|
295
|
+
message: `Goal loop iteration ${iteration} started.`,
|
|
296
|
+
iteration,
|
|
297
|
+
details: { deadlineAt },
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function recordGeneratedTodo(
|
|
303
|
+
state: GoalLoopState,
|
|
304
|
+
iteration: number,
|
|
305
|
+
todo: Omit<GeneratedTodoState, "generatedAt"> & { generatedAt?: string },
|
|
306
|
+
options: { now?: Date } = {},
|
|
307
|
+
): GoalLoopState {
|
|
308
|
+
const timestamp = (options.now ?? new Date()).toISOString();
|
|
309
|
+
return updateIteration(state, iteration, ["pending"], timestamp, (item) => ({
|
|
310
|
+
item: {
|
|
311
|
+
...item,
|
|
312
|
+
status: "todo_generated",
|
|
313
|
+
updatedAt: timestamp,
|
|
314
|
+
generatedTodo: {
|
|
315
|
+
...todo,
|
|
316
|
+
generatedAt: todo.generatedAt ?? timestamp,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
phase: "todo_generated",
|
|
320
|
+
trace: {
|
|
321
|
+
timestamp,
|
|
322
|
+
phase: "todo_generated",
|
|
323
|
+
event: "todo_generated",
|
|
324
|
+
message: `Goal loop iteration ${iteration} generated TODO markdown.`,
|
|
325
|
+
iteration,
|
|
326
|
+
details: { todoPath: todo.todoPath, summary: todo.summary, contentHash: todo.contentHash },
|
|
327
|
+
},
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function recordWorkerResult(
|
|
332
|
+
state: GoalLoopState,
|
|
333
|
+
iteration: number,
|
|
334
|
+
workerResult: GoalWorkerResultState,
|
|
335
|
+
options: { now?: Date } = {},
|
|
336
|
+
): GoalLoopState {
|
|
337
|
+
const timestamp = (options.now ?? new Date()).toISOString();
|
|
338
|
+
return updateIteration(state, iteration, ["todo_generated"], timestamp, (item) => ({
|
|
339
|
+
item: {
|
|
340
|
+
...item,
|
|
341
|
+
status: workerResult.status === "failed" ? "failed" : "todo_executed",
|
|
342
|
+
updatedAt: timestamp,
|
|
343
|
+
workerResult,
|
|
344
|
+
},
|
|
345
|
+
phase: "todo_executed",
|
|
346
|
+
trace: {
|
|
347
|
+
timestamp,
|
|
348
|
+
phase: "todo_executed",
|
|
349
|
+
event: "todo_executed",
|
|
350
|
+
message: `Goal loop iteration ${iteration} worker finished with status ${workerResult.status}.`,
|
|
351
|
+
iteration,
|
|
352
|
+
details: { status: workerResult.status, runId: workerResult.runId, summary: workerResult.summary },
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function recordReviewerResult(
|
|
358
|
+
state: GoalLoopState,
|
|
359
|
+
iteration: number,
|
|
360
|
+
reviewerResult: GoalReviewerResultState,
|
|
361
|
+
options: { now?: Date } = {},
|
|
362
|
+
): GoalLoopState {
|
|
363
|
+
const timestamp = (options.now ?? new Date()).toISOString();
|
|
364
|
+
return updateIteration(state, iteration, ["todo_executed", "failed"], timestamp, (item) => {
|
|
365
|
+
const terminalStatus = terminalStatusFromReviewer(reviewerResult);
|
|
366
|
+
const completion = terminalStatus
|
|
367
|
+
? { status: terminalStatus, reason: reviewerResult.rationale, completedAt: timestamp }
|
|
368
|
+
: undefined;
|
|
369
|
+
return {
|
|
370
|
+
item: {
|
|
371
|
+
...item,
|
|
372
|
+
status:
|
|
373
|
+
reviewerResult.decision === "complete"
|
|
374
|
+
? "reviewed_complete"
|
|
375
|
+
: reviewerResult.decision === "blocked"
|
|
376
|
+
? "blocked"
|
|
377
|
+
: reviewerResult.decision === "failed"
|
|
378
|
+
? "failed"
|
|
379
|
+
: "reviewed_incomplete",
|
|
380
|
+
updatedAt: timestamp,
|
|
381
|
+
reviewerResult,
|
|
382
|
+
completion,
|
|
383
|
+
},
|
|
384
|
+
phase: terminalStatus ? "complete" : "reviewed",
|
|
385
|
+
status: terminalStatus ?? "running",
|
|
386
|
+
completion,
|
|
387
|
+
trace: {
|
|
388
|
+
timestamp,
|
|
389
|
+
phase: terminalStatus ? "complete" : "reviewed",
|
|
390
|
+
event: "reviewed",
|
|
391
|
+
message: terminalStatus
|
|
392
|
+
? `Goal loop iteration ${iteration} reviewer completed with ${terminalStatus}.`
|
|
393
|
+
: `Goal loop iteration ${iteration} reviewer requested another iteration.`,
|
|
394
|
+
iteration,
|
|
395
|
+
details: {
|
|
396
|
+
decision: reviewerResult.decision,
|
|
397
|
+
complete: reviewerResult.complete,
|
|
398
|
+
summary: reviewerResult.summary,
|
|
399
|
+
remainingWork: reviewerResult.remainingWork,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function failGoalLoop(
|
|
407
|
+
state: GoalLoopState,
|
|
408
|
+
reason: string,
|
|
409
|
+
options: { now?: Date; status?: "failed" | "partial" } = {},
|
|
410
|
+
): GoalLoopState {
|
|
411
|
+
if (isTerminalGoalLoopStatus(state.status)) {
|
|
412
|
+
return state;
|
|
413
|
+
}
|
|
414
|
+
const timestamp = (options.now ?? new Date()).toISOString();
|
|
415
|
+
const status = options.status ?? "failed";
|
|
416
|
+
const phase: GoalLoopPhase = status === "failed" ? "failed" : "complete";
|
|
417
|
+
const completion: GoalCompletionState = { status, reason, completedAt: timestamp };
|
|
418
|
+
const iterations = state.iterations.map((iteration) =>
|
|
419
|
+
iteration.iteration === state.currentIteration && !isTerminalIterationStatus(iteration.status)
|
|
420
|
+
? { ...iteration, status: "failed" as const, updatedAt: timestamp, completion }
|
|
421
|
+
: iteration,
|
|
422
|
+
);
|
|
423
|
+
return withTrace(
|
|
424
|
+
{ ...state, status, phase, completion, iterations, updatedAt: timestamp },
|
|
425
|
+
{
|
|
426
|
+
timestamp,
|
|
427
|
+
phase,
|
|
428
|
+
event: status === "partial" ? "timeout" : "failed",
|
|
429
|
+
message: reason,
|
|
430
|
+
iteration: state.currentIteration || undefined,
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function cancelGoalLoop(
|
|
436
|
+
state: GoalLoopState,
|
|
437
|
+
reason = "Goal loop cancellation requested.",
|
|
438
|
+
options: { now?: Date } = {},
|
|
439
|
+
): GoalLoopState {
|
|
440
|
+
const timestamp = (options.now ?? new Date()).toISOString();
|
|
441
|
+
const completion: GoalCompletionState = { status: "cancelled", reason, completedAt: timestamp };
|
|
442
|
+
const iterations = state.iterations.map((iteration) =>
|
|
443
|
+
iteration.iteration === state.currentIteration && !isTerminalIterationStatus(iteration.status)
|
|
444
|
+
? { ...iteration, status: "cancelled" as const, updatedAt: timestamp, completion }
|
|
445
|
+
: iteration,
|
|
446
|
+
);
|
|
447
|
+
return withTrace(
|
|
448
|
+
{
|
|
449
|
+
...state,
|
|
450
|
+
status: "cancelled",
|
|
451
|
+
phase: "cancelled",
|
|
452
|
+
cancellation: { requested: true, reason, requestedAt: timestamp },
|
|
453
|
+
completion,
|
|
454
|
+
iterations,
|
|
455
|
+
updatedAt: timestamp,
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
timestamp,
|
|
459
|
+
phase: "cancelled",
|
|
460
|
+
event: "cancelled",
|
|
461
|
+
message: reason,
|
|
462
|
+
iteration: state.currentIteration || undefined,
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
/** Resolve reviewer worker model via harness/model-router.json (fresh-read each call); falls back to default */
|
|
469
|
+
export function resolveReviewerModel(opts: { projectDir?: string; difficulty?: string; modelHint?: string } = {}): string {
|
|
470
|
+
try {
|
|
471
|
+
return resolveModel({ projectDir: opts.projectDir, task: opts.difficulty || opts.modelHint ? { difficulty: opts.difficulty, modelHint: opts.modelHint } : undefined });
|
|
472
|
+
} catch {
|
|
473
|
+
// No router, or an unreadable one: fall through to pi's configured model.
|
|
474
|
+
return "";
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function validateGoalLoopState(value: unknown): GoalLoopState {
|
|
479
|
+
if (!value || typeof value !== "object") {
|
|
480
|
+
throw new GoalLoopStateError("Goal loop state must be an object.");
|
|
481
|
+
}
|
|
482
|
+
const state = value as Partial<GoalLoopState>;
|
|
483
|
+
if (state.schemaVersion !== GOAL_LOOP_STATE_SCHEMA_VERSION) {
|
|
484
|
+
throw new GoalLoopStateError(`Unsupported goal loop state schemaVersion: ${String(state.schemaVersion)}.`);
|
|
485
|
+
}
|
|
486
|
+
if (!state.goalRunId || typeof state.goalRunId !== "string") {
|
|
487
|
+
throw new GoalLoopStateError("Goal loop state is missing goalRunId.");
|
|
488
|
+
}
|
|
489
|
+
if (!state.goalRunDir || typeof state.goalRunDir !== "string") {
|
|
490
|
+
throw new GoalLoopStateError("Goal loop state is missing goalRunDir.");
|
|
491
|
+
}
|
|
492
|
+
if (!state.goal || typeof state.goal !== "string") {
|
|
493
|
+
throw new GoalLoopStateError("Goal loop state is missing goal.");
|
|
494
|
+
}
|
|
495
|
+
if (!state.limits || typeof state.limits !== "object") {
|
|
496
|
+
throw new GoalLoopStateError("Goal loop state is missing limits.");
|
|
497
|
+
}
|
|
498
|
+
state.limits = normalizeGoalLoopLimits({
|
|
499
|
+
...state.limits,
|
|
500
|
+
minIterations: state.limits.minIterations ?? DEFAULT_GOAL_LOOP_LIMITS.minIterations,
|
|
501
|
+
});
|
|
502
|
+
if (!Array.isArray(state.iterations) || !Array.isArray(state.trace)) {
|
|
503
|
+
throw new GoalLoopStateError("Goal loop state is missing iterations or trace arrays.");
|
|
504
|
+
}
|
|
505
|
+
return state as GoalLoopState;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function updateIteration(
|
|
509
|
+
state: GoalLoopState,
|
|
510
|
+
iterationNumber: number,
|
|
511
|
+
allowedStatuses: readonly GoalIterationStatus[],
|
|
512
|
+
timestamp: string,
|
|
513
|
+
update: (item: GoalIterationState) => {
|
|
514
|
+
item: GoalIterationState;
|
|
515
|
+
phase: GoalLoopPhase;
|
|
516
|
+
trace: GoalLoopTraceEvent;
|
|
517
|
+
status?: GoalLoopStatus;
|
|
518
|
+
completion?: GoalCompletionState;
|
|
519
|
+
},
|
|
520
|
+
): GoalLoopState {
|
|
521
|
+
if (isTerminalGoalLoopStatus(state.status)) {
|
|
522
|
+
throw new GoalLoopStateError(`Cannot update a terminal goal loop with status ${state.status}.`);
|
|
523
|
+
}
|
|
524
|
+
const index = state.iterations.findIndex((item) => item.iteration === iterationNumber);
|
|
525
|
+
if (index < 0) {
|
|
526
|
+
throw new GoalLoopStateError(`Goal iteration ${iterationNumber} does not exist.`);
|
|
527
|
+
}
|
|
528
|
+
const item = state.iterations[index];
|
|
529
|
+
if (!item || !allowedStatuses.includes(item.status)) {
|
|
530
|
+
throw new GoalLoopStateError(
|
|
531
|
+
`Cannot update goal iteration ${iterationNumber} from status ${item?.status ?? "unknown"}.`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
const result = update(item);
|
|
535
|
+
const iterations = state.iterations.map((candidate, candidateIndex) =>
|
|
536
|
+
candidateIndex === index ? result.item : candidate,
|
|
537
|
+
);
|
|
538
|
+
return withTrace(
|
|
539
|
+
{
|
|
540
|
+
...state,
|
|
541
|
+
status: result.status ?? state.status,
|
|
542
|
+
phase: result.phase,
|
|
543
|
+
completion: result.completion ?? state.completion,
|
|
544
|
+
iterations,
|
|
545
|
+
updatedAt: timestamp,
|
|
546
|
+
},
|
|
547
|
+
result.trace,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function completeStateForStopReason(state: GoalLoopState, stopReason: GoalLoopStopReason, now: Date): GoalLoopState {
|
|
552
|
+
if (stopReason.kind === "complete") {
|
|
553
|
+
return state;
|
|
554
|
+
}
|
|
555
|
+
if (stopReason.kind === "cancelled") {
|
|
556
|
+
return cancelGoalLoop(state, stopReason.message, { now });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const status: GoalLoopStatus = stopReason.kind === "timeout" ? "partial" : "failed";
|
|
560
|
+
const phase: GoalLoopPhase = status === "failed" ? "failed" : "complete";
|
|
561
|
+
const timestamp = now.toISOString();
|
|
562
|
+
const completion: GoalCompletionState = { status, reason: stopReason.message, completedAt: timestamp };
|
|
563
|
+
return withTrace(
|
|
564
|
+
{
|
|
565
|
+
...state,
|
|
566
|
+
status,
|
|
567
|
+
phase,
|
|
568
|
+
completion,
|
|
569
|
+
updatedAt: timestamp,
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
timestamp,
|
|
573
|
+
phase,
|
|
574
|
+
event: stopReason.kind,
|
|
575
|
+
message: stopReason.message,
|
|
576
|
+
iteration: state.currentIteration || undefined,
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function terminalStatusFromReviewer(result: GoalReviewerResultState): GoalLoopStatus | undefined {
|
|
582
|
+
if (result.decision === "complete" || result.complete) {
|
|
583
|
+
return "done";
|
|
584
|
+
}
|
|
585
|
+
if (result.decision === "blocked") {
|
|
586
|
+
return "blocked";
|
|
587
|
+
}
|
|
588
|
+
if (result.decision === "failed") {
|
|
589
|
+
return "failed";
|
|
590
|
+
}
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function canStartAnotherIterationFromPhase(phase: GoalLoopPhase): boolean {
|
|
595
|
+
return phase === "goal_received" || phase === "reviewed";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function isTerminalGoalLoopStatus(status: GoalLoopStatus): boolean {
|
|
599
|
+
return (
|
|
600
|
+
status === "done" || status === "partial" || status === "blocked" || status === "failed" || status === "cancelled"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function isTerminalIterationStatus(status: GoalIterationStatus): boolean {
|
|
605
|
+
return status === "reviewed_complete" || status === "blocked" || status === "failed" || status === "cancelled";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function withTrace(state: GoalLoopState, event: GoalLoopTraceEvent): GoalLoopState {
|
|
609
|
+
return { ...state, trace: [...state.trace, event] };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
613
|
+
return optionalPositiveInteger(value) ?? fallback;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function optionalPositiveInteger(value: number | undefined): number | undefined {
|
|
617
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
618
|
+
return Math.floor(value);
|
|
619
|
+
}
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function positiveMilliseconds(value: number | undefined, fallback: number): number {
|
|
624
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
625
|
+
return Math.floor(value);
|
|
626
|
+
}
|
|
627
|
+
return fallback;
|
|
628
|
+
}
|