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/taskList.ts
CHANGED
|
@@ -49,9 +49,44 @@ export type TaskInput = {
|
|
|
49
49
|
criteria?: string[];
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Feature metadata, supplied alongside the tasks.
|
|
54
|
+
*
|
|
55
|
+
* Features themselves are derived from task keys — `feature-002/task-004`
|
|
56
|
+
* creates `feature-002` — which left no way at all to give a feature a name or
|
|
57
|
+
* its acceptance criteria. The DEFINE gate requires criteria on every feature,
|
|
58
|
+
* so the first gate in the pipeline could not be passed through the tools: the
|
|
59
|
+
* only route was hand-editing the plan file, which the brief tells you not to
|
|
60
|
+
* do.
|
|
61
|
+
*
|
|
62
|
+
* Unlike `tasks`, this is a merge and never a deletion. Omission means
|
|
63
|
+
* deletion for tasks because the model has to submit the authoritative list;
|
|
64
|
+
* features are not submitted at all, they are inferred, so omitting one here
|
|
65
|
+
* means "nothing to say about it", not "remove it".
|
|
66
|
+
*/
|
|
67
|
+
export type FeatureInput = {
|
|
68
|
+
id: string;
|
|
69
|
+
name?: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
criteria?: string[];
|
|
72
|
+
};
|
|
73
|
+
|
|
52
74
|
export type ApplyInput = {
|
|
53
75
|
baseRevision?: number;
|
|
54
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The complete, authoritative task list. Omission means deletion — one
|
|
78
|
+
* unambiguous rule beats incremental edits a model loses track of.
|
|
79
|
+
*
|
|
80
|
+
* Leaving the whole field out is different from sending `[]`: absent means
|
|
81
|
+
* "I am not touching the tasks", empty means "delete them all". DEFINE needs
|
|
82
|
+
* that distinction, because criteria are written there and tasks do not
|
|
83
|
+
* exist until PLAN.
|
|
84
|
+
*/
|
|
85
|
+
tasks?: TaskInput[];
|
|
86
|
+
/** Names and acceptance criteria, merged onto features by id. */
|
|
87
|
+
features?: FeatureInput[];
|
|
88
|
+
/** The one-line statement of what this whole run is for. */
|
|
89
|
+
goal?: string;
|
|
55
90
|
};
|
|
56
91
|
|
|
57
92
|
export type Change = {
|
|
@@ -96,6 +131,34 @@ function validateSubtasks(raw: TaskInput["subtasks"], path: string): Subtask[] {
|
|
|
96
131
|
});
|
|
97
132
|
}
|
|
98
133
|
|
|
134
|
+
/** At most this many features may be described in one submission. */
|
|
135
|
+
const MAX_FEATURES = 100;
|
|
136
|
+
/** And this many acceptance criteria on any one of them. */
|
|
137
|
+
const MAX_CRITERIA = 40;
|
|
138
|
+
|
|
139
|
+
function bounded(value: string, max: number, path: string): string {
|
|
140
|
+
const trimmed = String(value ?? "").trim();
|
|
141
|
+
if (trimmed.length > max) {
|
|
142
|
+
throw new ValidationError(`${path} exceeds ${max} characters (${trimmed.length})`);
|
|
143
|
+
}
|
|
144
|
+
return trimmed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateCriteria(raw: unknown, path: string): string[] {
|
|
148
|
+
if (!Array.isArray(raw)) throw new ValidationError(`${path} must be an array`);
|
|
149
|
+
if (raw.length > MAX_CRITERIA) {
|
|
150
|
+
throw new ValidationError(`${path} supports at most ${MAX_CRITERIA} entries, got ${raw.length}`);
|
|
151
|
+
}
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
for (const [i, entry] of raw.entries()) {
|
|
154
|
+
const text = bounded(String(entry ?? ""), MAX_SUBJECT_LEN, `${path}[${i}]`);
|
|
155
|
+
// An empty criterion is worse than none: it looks like the work was done.
|
|
156
|
+
if (!text) throw new ValidationError(`${path}[${i}] must be non-empty`);
|
|
157
|
+
if (!out.includes(text)) out.push(text);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
function validateDependsOn(raw: string[] | undefined, path: string): string[] {
|
|
100
163
|
if (!Array.isArray(raw)) return [];
|
|
101
164
|
if (raw.length > MAX_DEPENDS_ON) {
|
|
@@ -126,11 +189,24 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
|
|
|
126
189
|
`Re-read the plan and resubmit.`,
|
|
127
190
|
);
|
|
128
191
|
}
|
|
129
|
-
if (!Array.isArray(input.tasks)) {
|
|
192
|
+
if (input.tasks !== undefined && !Array.isArray(input.tasks)) {
|
|
130
193
|
throw new ValidationError("tasks must be an array");
|
|
131
194
|
}
|
|
132
|
-
if (input.tasks.
|
|
133
|
-
throw new ValidationError(
|
|
195
|
+
if (input.tasks === undefined && input.features === undefined && input.goal === undefined) {
|
|
196
|
+
throw new ValidationError("nothing submitted: send tasks, features, or a goal");
|
|
197
|
+
}
|
|
198
|
+
const inputFeatures = input.features;
|
|
199
|
+
const goal = input.goal;
|
|
200
|
+
if (inputFeatures !== undefined && !Array.isArray(inputFeatures)) {
|
|
201
|
+
throw new ValidationError("features must be an array");
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(inputFeatures) && inputFeatures.length > MAX_FEATURES) {
|
|
204
|
+
throw new ValidationError(
|
|
205
|
+
`features supports at most ${MAX_FEATURES} items, got ${inputFeatures.length}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if ((input.tasks?.length ?? 0) > MAX_TASKS) {
|
|
209
|
+
throw new ValidationError(`tasks supports at most ${MAX_TASKS} items, got ${input.tasks!.length}`);
|
|
134
210
|
}
|
|
135
211
|
|
|
136
212
|
const before = flattenTasks(current);
|
|
@@ -146,8 +222,24 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
|
|
|
146
222
|
const staged: Staged[] = [];
|
|
147
223
|
const seen = new Set<string>();
|
|
148
224
|
|
|
149
|
-
|
|
150
|
-
|
|
225
|
+
// No `tasks` field means the submission is about features or the goal, and
|
|
226
|
+
// the task list carries over untouched. Re-staging what is already stored
|
|
227
|
+
// keeps every downstream step — dependency validation, the rebuild, the
|
|
228
|
+
// diff — on exactly one code path.
|
|
229
|
+
if (input.tasks === undefined) {
|
|
230
|
+
for (const f of current.features) {
|
|
231
|
+
for (const t of f.tasks ?? []) {
|
|
232
|
+
staged.push({
|
|
233
|
+
featureId: f.id,
|
|
234
|
+
task: structuredClone(t),
|
|
235
|
+
compositeKey: t.key ?? `${f.id}/${t.id}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < (input.tasks?.length ?? 0); i++) {
|
|
242
|
+
const raw = input.tasks![i]!;
|
|
151
243
|
const path = `tasks[${i}]`;
|
|
152
244
|
const key = validateKey(String(raw?.key ?? ""), `${path}.key`);
|
|
153
245
|
if (seen.has(key)) throw new ValidationError(`${path}.key is duplicated: ${key}`);
|
|
@@ -272,6 +364,60 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
|
|
|
272
364
|
feature.tasks.push(s.task);
|
|
273
365
|
}
|
|
274
366
|
|
|
367
|
+
// -- feature metadata -----------------------------------------------------
|
|
368
|
+
let metaChanged = false;
|
|
369
|
+
for (const [i, input] of (Array.isArray(inputFeatures) ? inputFeatures : []).entries()) {
|
|
370
|
+
const id = validateKey(input?.id ?? "", `features[${i}].id`);
|
|
371
|
+
// A model that has seen the plan file will reasonably try to nest tasks
|
|
372
|
+
// inside a feature. Silently dropping them would look like the write
|
|
373
|
+
// succeeded and lose the work; say where they go instead.
|
|
374
|
+
if ("tasks" in (input as object)) {
|
|
375
|
+
throw new ValidationError(
|
|
376
|
+
`features[${i}].tasks is not accepted — submit tasks in the top-level "tasks" array, ` +
|
|
377
|
+
`keyed "${id}/task-001". features carries names and criteria only.`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
let feature = featureById.get(id);
|
|
381
|
+
if (!feature) {
|
|
382
|
+
// Declaring a feature before its tasks exist is legitimate: DEFINE is
|
|
383
|
+
// where criteria are written, and PLAN is where tasks arrive.
|
|
384
|
+
feature = { id, name: id, passes: false, tasks: [] };
|
|
385
|
+
next.features.push(feature);
|
|
386
|
+
featureById.set(id, feature);
|
|
387
|
+
metaChanged = true;
|
|
388
|
+
}
|
|
389
|
+
if (typeof input.name === "string" && input.name.trim()) {
|
|
390
|
+
const name = bounded(input.name, MAX_SUBJECT_LEN, `features[${i}].name`);
|
|
391
|
+
if (feature.name !== name) {
|
|
392
|
+
feature.name = name;
|
|
393
|
+
metaChanged = true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (typeof input.description === "string") {
|
|
397
|
+
const description = bounded(input.description, MAX_SUBJECT_LEN, `features[${i}].description`);
|
|
398
|
+
if (feature.description !== description) {
|
|
399
|
+
feature.description = description;
|
|
400
|
+
metaChanged = true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (Array.isArray(input.criteria)) {
|
|
404
|
+
const criteria = validateCriteria(input.criteria, `features[${i}].criteria`);
|
|
405
|
+
if (JSON.stringify(feature.criteria ?? []) !== JSON.stringify(criteria)) {
|
|
406
|
+
feature.criteria = criteria;
|
|
407
|
+
metaChanged = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (typeof goal === "string" && goal.trim()) {
|
|
413
|
+
const title = bounded(goal, MAX_SUBJECT_LEN, "goal");
|
|
414
|
+
const goals = Array.isArray(next.goals) ? next.goals : [];
|
|
415
|
+
if (goals[0]?.title !== title) {
|
|
416
|
+
next.goals = [{ ...(goals[0] ?? { id: "goal-001" }), title }, ...goals.slice(1)];
|
|
417
|
+
metaChanged = true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
275
421
|
// A feature passes when it has tasks and all of them are complete.
|
|
276
422
|
for (const f of next.features) {
|
|
277
423
|
f.passes = f.tasks.length > 0 && f.tasks.every((t) => t.status === "complete");
|
|
@@ -295,7 +441,8 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
|
|
|
295
441
|
const reordered =
|
|
296
442
|
oldOrder.length !== newOrder.length || oldOrder.some((k, i) => k !== newOrder[i]);
|
|
297
443
|
|
|
298
|
-
const changed =
|
|
444
|
+
const changed =
|
|
445
|
+
added.length > 0 || updated.length > 0 || removed.length > 0 || reordered || metaChanged;
|
|
299
446
|
next.baseRevision = changed ? current.baseRevision + 1 : current.baseRevision;
|
|
300
447
|
|
|
301
448
|
return {
|
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
|
}
|
package/src/unstuck.ts
CHANGED
|
@@ -34,6 +34,23 @@ export interface ChooseUnstuckOpts {
|
|
|
34
34
|
masterUsed?: boolean;
|
|
35
35
|
// allow explicit strategies override for testing (otherwise reads harness/config.json)
|
|
36
36
|
strategies?: UnstuckStrategy[];
|
|
37
|
+
/**
|
|
38
|
+
* Rungs already taken during this stall. Without it the ladder cannot climb:
|
|
39
|
+
* `reframe` has no budget of its own, so it is eligible forever and shadows
|
|
40
|
+
* every rung below it. Reframing twice in a row is also just reframing.
|
|
41
|
+
*/
|
|
42
|
+
tried?: UnstuckStrategy[];
|
|
43
|
+
/**
|
|
44
|
+
* Whether `rework` and `replan` require the tree to have moved.
|
|
45
|
+
*
|
|
46
|
+
* Defaults to the review policy `review.bounceRequiresDelta`, which is what
|
|
47
|
+
* it was written for: do not bounce REVIEW backwards again if nothing has
|
|
48
|
+
* changed since the last bounce. A stuck run is the opposite case — it is
|
|
49
|
+
* *defined* by nothing having changed — so the escalation path passes false
|
|
50
|
+
* explicitly. A ladder that refuses to climb in the only situation it exists
|
|
51
|
+
* for is not a ladder.
|
|
52
|
+
*/
|
|
53
|
+
requireDeltaForRework?: boolean;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
export interface ChooseUnstuckResult {
|
|
@@ -138,6 +155,9 @@ export function chooseUnstuckStrategy(opts: ChooseUnstuckOpts = {}): ChooseUnstu
|
|
|
138
155
|
}
|
|
139
156
|
const consultedCount = typeof opts.consultedCount === "number" ? opts.consultedCount : 0;
|
|
140
157
|
const masterUsed = !!opts.masterUsed;
|
|
158
|
+
const tried = new Set<UnstuckStrategy>(opts.tried ?? []);
|
|
159
|
+
const requireDelta =
|
|
160
|
+
typeof opts.requireDeltaForRework === "boolean" ? opts.requireDeltaForRework : bounceRequiresDelta;
|
|
141
161
|
|
|
142
162
|
// hysteresis guard
|
|
143
163
|
if (hysteresisMs > 0) {
|
|
@@ -154,15 +174,21 @@ export function chooseUnstuckStrategy(opts: ChooseUnstuckOpts = {}): ChooseUnstu
|
|
|
154
174
|
const fileDelta = opts.fileDelta !== undefined ? !!opts.fileDelta : true;
|
|
155
175
|
|
|
156
176
|
for (const strategy of strategies) {
|
|
177
|
+
// One turn per rung per stall.
|
|
178
|
+
//
|
|
179
|
+
// The budgeted rungs count their *effects* — rework.json entries, replan
|
|
180
|
+
// history — which only exist if the agent acted on the advice. A stuck
|
|
181
|
+
// agent by definition does not, so the budget never moved and the ladder
|
|
182
|
+
// jammed: offering `replan` to an agent that ignores it, forever, is not
|
|
183
|
+
// an escalation ladder, it is the same stall with a different sentence.
|
|
184
|
+
// The budgets still bound the run across stalls; this bounds one stall.
|
|
185
|
+
if (tried.has(strategy)) continue;
|
|
186
|
+
|
|
157
187
|
if (strategy === "retry") {
|
|
158
188
|
if (dedup) continue; // same fingerprint loop, skip retry
|
|
159
|
-
// retry has no budget beyond hysteresis
|
|
160
189
|
return { strategy: "retry", reason: "retry eligible", fingerprintDedup: dedup };
|
|
161
190
|
}
|
|
162
191
|
if (strategy === "reframe") {
|
|
163
|
-
if (dedup) {
|
|
164
|
-
// allow reframe even with dedup, but if dedup and no fileDelta, still allow? For now allow reframe
|
|
165
|
-
}
|
|
166
192
|
return { strategy: "reframe", reason: "reframe eligible", fingerprintDedup: dedup };
|
|
167
193
|
}
|
|
168
194
|
if (strategy === "consult") {
|
|
@@ -181,29 +207,30 @@ export function chooseUnstuckStrategy(opts: ChooseUnstuckOpts = {}): ChooseUnstu
|
|
|
181
207
|
if (strategy === "rework") {
|
|
182
208
|
if ((reworkCount ?? 0) >= maxReworks) continue;
|
|
183
209
|
if ((bounceCount ?? 0) >= maxBounces) continue;
|
|
184
|
-
if (
|
|
185
|
-
// also fileDelta guard if configured as bounceRequiresDelta
|
|
210
|
+
if (requireDelta && !fileDelta) continue;
|
|
186
211
|
return { strategy: "rework", reason: "rework eligible", fingerprintDedup: dedup };
|
|
187
212
|
}
|
|
188
213
|
if (strategy === "replan") {
|
|
189
214
|
if ((replanCount ?? 0) >= maxReplans) continue;
|
|
190
|
-
if (
|
|
215
|
+
if (requireDelta && !fileDelta) continue;
|
|
191
216
|
return { strategy: "replan", reason: "replan eligible", fingerprintDedup: dedup };
|
|
192
217
|
}
|
|
193
218
|
if (strategy === "master") {
|
|
194
219
|
if (masterUsed) continue;
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
220
|
+
// MASTER is the last rung and fires once per run. It is deliberately
|
|
221
|
+
// NOT defaulted to a specific model: routing ships vendor-neutral, and
|
|
222
|
+
// an empty slot means "whatever pi is already configured with". A
|
|
223
|
+
// hardcoded default here would silently redirect the hardest work in
|
|
224
|
+
// the run to one vendor's model.
|
|
225
|
+
const masterModel = typeof routerCfg.master === "string" && routerCfg.master.trim()
|
|
226
|
+
? routerCfg.master.trim()
|
|
227
|
+
: null;
|
|
228
|
+
return {
|
|
229
|
+
strategy: "master",
|
|
230
|
+
reason: masterModel ? `master ${masterModel}` : "master (pi's current model)",
|
|
231
|
+
nextModel: masterModel,
|
|
232
|
+
fingerprintDedup: dedup,
|
|
233
|
+
};
|
|
207
234
|
}
|
|
208
235
|
}
|
|
209
236
|
|